say: move the copy deck into a package memory can import (V-506)

The summaries family is spoken by internal/memory, internal/morning and
internal/tasks. internal/phraser already imports internal/memory, so the
deck cannot stay in phraser without a cycle.

internal/say is a leaf: embed, json, math/rand, strings, sync. The four
phraser families keep their files and their floors and now call say.Load,
*say.Deck, Text, Matches, Variants, RequirePlaceholder and RegisterFloor.
No copy changed and no behaviour changed.
This commit is contained in:
2026-08-04 01:42:20 +04:00
parent f3c0540b42
commit c35979d9f9
7 changed files with 74 additions and 62 deletions
+10 -8
View File
@@ -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)
}
+10 -8
View File
@@ -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)
}
+4 -3
View File
@@ -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.
+1 -1
View File
@@ -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
+14 -12
View File
@@ -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)
}
+10 -8
View File
@@ -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)
}
@@ -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
}