diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 20c0be4..f26f302 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed ack_ru_v1.json @@ -65,7 +67,7 @@ var ackKeys = []string{ // ackFloor — the literal each key falls back to when the file is unusable. // These are the exact strings that lived in Go before this file existed. -var ackFloor = registerFloor(map[string]string{ +var ackFloor = say.RegisterFloor(map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", @@ -93,12 +95,12 @@ var ackFloor = registerFloor(map[string]string{ }) // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. -type Acks struct{ d *deck } +type Acks struct{ d *say.Deck } // LoadAcks reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadAcks(src rand.Source) (*Acks, error) { - d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) + d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) if err != nil { return nil, err } @@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -117,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { } // deck reads through a nil *Acks, which is the unloadable-file case. -func (a *Acks) deck() *deck { +func (a *Acks) deck() *say.Deck { if a == nil { return nil } @@ -127,11 +129,11 @@ func (a *Acks) deck() *deck { // Say returns one line for key, with his data filled into the frame. Pass nil // when the entry takes none. func (a *Acks) Say(key string, vars map[string]string) string { - return a.deck().text(key, vars) + return a.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (a *Acks) Variants() []string { return a.deck().variants() } +func (a *Acks) Variants() []string { return a.deck().Variants() } var ( ackOnce sync.Once @@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k // IsAck reports whether text is a line key could have produced. For the daemon // tests, which can no longer compare against one literal. func IsAck(key string, vars map[string]string, text string) bool { - return DefaultAcks().deck().matches(key, vars, text) + return DefaultAcks().deck().Matches(key, vars, text) } diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index a15b181..affa7d4 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed acts_ru_v1.json @@ -74,7 +76,7 @@ var actKeys = []string{ // actFloor — the literal each key falls back to when the file is unusable. // These are the exact strings that lived in Go before this file existed. -var actFloor = registerFloor(map[string]string{ +var actFloor = say.RegisterFloor(map[string]string{ ActDone: "готово.", ActDoneOut: "готово: {out}", ActDoneEntity: "команда выполнена для {name}.", @@ -112,12 +114,12 @@ var actFloor = registerFloor(map[string]string{ }) // Acts picks a hand-written Russian act reply. Safe for concurrent use. -type Acts struct{ d *deck } +type Acts struct{ d *say.Deck } // LoadActs reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadActs(src rand.Source) (*Acts, error) { - d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src) + d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src) if err != nil { return nil, err } @@ -133,7 +135,7 @@ func LoadActs(src rand.Source) (*Acts, error) { {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -141,7 +143,7 @@ func LoadActs(src rand.Source) (*Acts, error) { } // deck reads through a nil *Acts, which is the unloadable-file case. -func (a *Acts) deck() *deck { +func (a *Acts) deck() *say.Deck { if a == nil { return nil } @@ -150,11 +152,11 @@ func (a *Acts) deck() *deck { // Say returns one line for key, with the names filled into the frame. func (a *Acts) Say(key string, vars map[string]string) string { - return a.deck().text(key, vars) + return a.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (a *Acts) Variants() []string { return a.deck().variants() } +func (a *Acts) Variants() []string { return a.deck().Variants() } var ( actOnce sync.Once @@ -180,5 +182,5 @@ func A(key string, vars map[string]string) string { return DefaultActs().Say(key // IsA reports whether text is a line key could have produced, for the tests. func IsA(key string, vars map[string]string, text string) bool { - return DefaultActs().deck().matches(key, vars, text) + return DefaultActs().deck().Matches(key, vars, text) } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 4498bd6..481776b 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,9 +8,10 @@ import ( "github.com/kami/maven/internal/phraser" ) -// TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are -// heard out loud and they live in a JSON file now, so a reworded variant that -// says "рад" or "вы" would otherwise reach him with nothing in between. +// TestFallbackPersona scores every line in every hand-written family on the +// persona checks the nudges already pass. These lines are heard out loud and +// they live in a JSON file now, so a reworded variant that says "рад" or "вы" +// would otherwise reach him with nothing in between. // // Only the persona checks run. Mood and topic belong to a nudge, and these are // not nudges. diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 714acc0..28636fe 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -13,7 +13,7 @@ import ( // fallbacks_ru_v1.json break Go tests, which is the coupling this file removed. func isFallback(t *testing.T, key, sources, got string) bool { t.Helper() - return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got) + return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got) } // A dead server must be distinguishable from bad phrasing. Both PhraseChat and diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index a9c7058..8df4d15 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -15,6 +15,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed fallbacks_ru_v1.json @@ -39,7 +41,7 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap} // hardFloor — the literal each key falls back to when the file is unusable. // These are the exact strings that lived in Go before this file existed. -var hardFloor = registerFloor(map[string]string{ +var hardFloor = say.RegisterFloor(map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", @@ -47,24 +49,24 @@ var hardFloor = registerFloor(map[string]string{ }) // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. -type Fallbacks struct{ d *deck } +type Fallbacks struct{ d *say.Deck } // LoadFallbacks reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadFallbacks(src rand.Source) (*Fallbacks, error) { - d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) + d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) if err != nil { return nil, err } // query_sources is the one entry whose whole job is to read something back. - if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil { + if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil { return nil, err } return &Fallbacks{d: d}, nil } // deck reads through a nil *Fallbacks, which is the unloadable-file case. -func (f *Fallbacks) deck() *deck { +func (f *Fallbacks) deck() *say.Deck { if f == nil { return nil } @@ -72,23 +74,23 @@ func (f *Fallbacks) deck() *deck { } // Chat — nothing usable came back on the chat path. -func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) } +func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) } // Unknown — a question she cannot answer and will not guess at. -func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) } +func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) } // FromSources — read back what she was handed, because phrasing it failed. func (f *Fallbacks) FromSources(sources string) string { - return f.deck().text(fbQuerySources, map[string]string{"sources": sources}) + return f.deck().Text(fbQuerySources, map[string]string{"sources": sources}) } // WorldGap — the world model is the one configured to answer and it is not // answering. Fixed wording: it names a specific gap, and a variant set here // would let "the big model is asleep" drift into "I don't know". -func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) } +func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) } // Variants returns every line the file can produce, for the persona scorer. -func (f *Fallbacks) Variants() []string { return f.deck().variants() } +func (f *Fallbacks) Variants() []string { return f.deck().Variants() } // The process-wide instance. Package-level because these lines are needed on // paths that have no phraser to hand — cmd/mavend names the world gap without @@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() } // IsUnknownFallback reports whether text is one of her "I do not know" lines. // The daemon tests read it to tell an answer from a shrug. func IsUnknownFallback(text string) bool { - return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text) + return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text) } // IsSourcesFallback reports whether text is sources read back verbatim. func IsSourcesFallback(text, sources string) bool { - return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text) + return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text) } diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 638eefd..f99d2c1 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed query_ru_v1.json @@ -65,7 +67,7 @@ var queryKeys = []string{ // queryFloor — the literal each key falls back to when the file is unusable. // These are the exact strings that lived in Go before this file existed. -var queryFloor = registerFloor(map[string]string{ +var queryFloor = say.RegisterFloor(map[string]string{ QueryUnknown: "не знаю.", QueryOtherDay: "про другой день так не отвечу — спроси целиком.", QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", @@ -95,12 +97,12 @@ var queryFloor = registerFloor(map[string]string{ }) // Queries picks a hand-written Russian query line. Safe for concurrent use. -type Queries struct{ d *deck } +type Queries struct{ d *say.Deck } // LoadQueries reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadQueries(src rand.Source) (*Queries, error) { - d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) + d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) if err != nil { return nil, err } @@ -111,7 +113,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -119,7 +121,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { } // deck reads through a nil *Queries, which is the unloadable-file case. -func (q *Queries) deck() *deck { +func (q *Queries) deck() *say.Deck { if q == nil { return nil } @@ -128,11 +130,11 @@ func (q *Queries) deck() *deck { // Say returns one line for key, with the values filled into the frame. func (q *Queries) Say(key string, vars map[string]string) string { - return q.deck().text(key, vars) + return q.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (q *Queries) Variants() []string { return q.deck().variants() } +func (q *Queries) Variants() []string { return q.deck().Variants() } var ( queryOnce sync.Once @@ -158,5 +160,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say( // IsQ reports whether text is a line key could have produced, for the tests. func IsQ(key string, vars map[string]string, text string) bool { - return DefaultQueries().deck().matches(key, vars, text) + return DefaultQueries().deck().Matches(key, vars, text) } diff --git a/internal/phraser/deck.go b/internal/say/deck.go similarity index 78% rename from internal/phraser/deck.go rename to internal/say/deck.go index 1f53745..7b43325 100644 --- a/internal/phraser/deck.go +++ b/internal/say/deck.go @@ -1,6 +1,9 @@ -package phraser +package say -// deck — the mechanics every family of hand-written Russian lines shares. +// Package say holds the mechanics every family of hand-written Russian lines +// shares. The families themselves live next to the code that speaks them. +// +// Deck — the mechanics every family of hand-written Russian lines shares. // // A family is one embedded JSON file: schema-versioned, several variants per // entry, never the same variant twice running, and a hard floor of Go literals @@ -20,28 +23,28 @@ import ( "time" ) -// deckEntry — one line she can say, in as many wordings as the file gives. -type deckEntry struct { +// Entry — one line she can say, in as many wordings as the file gives. +type Entry struct { // Fixed — one variant, never picked between. For wording that must not // drift from turn to turn, like a phrase naming one specific gap. Fixed bool `json:"fixed"` Variants []string `json:"variants"` } -type deckFile struct { - SchemaVersion int `json:"schema_version"` - Name string `json:"name"` - Notes []string `json:"notes"` - Entries map[string]deckEntry `json:"entries"` +type file struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Entries map[string]Entry `json:"entries"` } -// deck picks a line. Safe for concurrent use. A nil *deck answers from the +// deck picks a line. Safe for concurrent use. A nil *Deck answers from the // floor, which is what an unloadable file leaves behind. -type deck struct { +type Deck struct { mu sync.Mutex rnd *rand.Rand last map[string]string - file deckFile + file file keys []string floor map[string]string } @@ -49,8 +52,8 @@ type deck struct { // loadDeck parses raw, checks the version and every required key, and seeds the // picker. Pass a source to make the picking reproducible in tests; nil seeds // from the clock. -func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) { - var f deckFile +func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) { + var f file if err := json.Unmarshal(raw, &f); err != nil { return nil, fmt.Errorf("parse: %w", err) } @@ -69,13 +72,13 @@ func loadDeck(raw []byte, version int, keys []string, floor map[string]string, s if src == nil { src = rand.NewSource(time.Now().UnixNano()) } - return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil + return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil } // requirePlaceholder fails the load when a variant of key does not use ph. For // an entry whose whole job is to read something back, a variant without the // placeholder silently drops it. -func (d *deck) requirePlaceholder(key, ph string) error { +func (d *Deck) RequirePlaceholder(key, ph string) error { for _, v := range d.file.Entries[key].Variants { if !strings.Contains(v, ph) { return fmt.Errorf("%q variant %q does not use %s", key, v, ph) @@ -86,7 +89,7 @@ func (d *deck) requirePlaceholder(key, ph string) error { // text returns one variant for key with the placeholders filled in. A nil // receiver answers from the floor, so no caller checks whether the file loaded. -func (d *deck) text(key string, vars map[string]string) string { +func (d *Deck) Text(key string, vars map[string]string) string { tmpl := "" if d != nil { if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 { @@ -101,7 +104,7 @@ func (d *deck) text(key string, vars map[string]string) string { // matches reports whether text is a line key could have produced. A caller that // has to recognise one of these lines cannot compare against a literal any more. -func (d *deck) matches(key string, vars map[string]string, text string) bool { +func (d *Deck) Matches(key string, vars map[string]string, text string) bool { if fill(floorOf(d, key), vars) == text { return true } @@ -118,7 +121,7 @@ func (d *deck) matches(key string, vars map[string]string, text string) bool { // variants returns every line the file can produce, in key order, for the // persona scorer. Stable order so a failure names the same variant twice. -func (d *deck) variants() []string { +func (d *Deck) Variants() []string { if d == nil { return nil } @@ -130,7 +133,7 @@ func (d *deck) variants() []string { } // pick chooses at random, skipping whatever this entry said last time. -func (d *deck) pick(key string, e deckEntry) string { +func (d *Deck) pick(key string, e Entry) string { d.mu.Lock() defer d.mu.Unlock() @@ -153,7 +156,7 @@ func (d *deck) pick(key string, e deckEntry) string { // floorOf reads the Go literal behind key, and works on a nil deck because that // is exactly the case it exists for. The per-family map is the source of truth. -func floorOf(d *deck, key string) string { +func floorOf(d *Deck, key string) string { if d != nil && d.floor != nil { return d.floor[key] } @@ -164,7 +167,7 @@ func floorOf(d *deck, key string) string { // finds them. Families register at init; the keys are namespaced by family. var deckFloors = map[string]string{} -func registerFloor(floor map[string]string) map[string]string { +func RegisterFloor(floor map[string]string) map[string]string { for k, v := range floor { deckFloors[k] = v }