diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 20c0be4..4a6f6dd 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -65,7 +65,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 = map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", @@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{ FailAck: "не получилось отметить.", FailSnooze: "не получилось отложить.", FailQuiet: "не получилось переключить тихий режим.", -}) +} // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. type Acks struct{ d *deck } @@ -119,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 { if a == nil { - return nil + return floorDeck(ackFloor) } return a.d } diff --git a/internal/phraser/deck.go b/internal/phraser/deck.go index 1f53745..9136775 100644 --- a/internal/phraser/deck.go +++ b/internal/phraser/deck.go @@ -35,8 +35,8 @@ type deckFile struct { Entries map[string]deckEntry `json:"entries"` } -// deck picks a line. Safe for concurrent use. A nil *deck answers from the -// floor, which is what an unloadable file leaves behind. +// deck picks a line. Safe for concurrent use. A deck with no entries answers +// from the floor, which is what an unloadable file leaves behind (floorDeck). type deck struct { mu sync.Mutex rnd *rand.Rand @@ -90,7 +90,7 @@ 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 { - tmpl = d.pick(key, e) + tmpl = d.pick(key, fillable(e.Variants, vars)) } } if tmpl == "" { @@ -129,12 +129,67 @@ func (d *deck) variants() []string { return out } +// fillable narrows variants to the ones this call can actually say, which is +// the rule an optional placeholder needs: a caller with nothing to put in +// {tail} must not be handed a variant that has one. Two passes, because both +// halves matter. The first keeps only variants whose every placeholder has a +// non-empty value, so an absent optional never reaches him as braces. The +// second prefers, among those, the variants using the most of what the caller +// supplied, so a caveat he was given is not dropped for a shorter wording. +// Nothing fillable leaves the list alone, and the unfilled placeholder shows +// up in the answer rather than turning it into silence. +func fillable(variants []string, vars map[string]string) []string { + if len(variants) < 2 { + return variants + } + best, bestUsed := make([]string, 0, len(variants)), -1 + for _, v := range variants { + used := 0 + ok := true + for _, ph := range placeholders(v) { + if vars[ph] == "" { + ok = false + break + } + used++ + } + if !ok || used < bestUsed { + continue + } + if used > bestUsed { + best, bestUsed = best[:0], used + } + best = append(best, v) + } + if len(best) == 0 { + return variants + } + return best +} + +// placeholders lists the {name}s in tmpl, in order. +func placeholders(tmpl string) []string { + var out []string + for { + i := strings.IndexByte(tmpl, '{') + if i < 0 { + return out + } + j := strings.IndexByte(tmpl[i:], '}') + if j < 0 { + return out + } + out = append(out, tmpl[i+1:i+j]) + tmpl = tmpl[i+j+1:] + } +} + // 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, variants []string) string { d.mu.Lock() defer d.mu.Unlock() - choices := e.Variants + choices := variants if len(choices) > 1 { fresh := make([]string, 0, len(choices)) for _, v := range choices { @@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string { return got } -// 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. +// floorOf reads the Go literal behind key. Every deck carries its own family's +// map, including the floor-only deck an unloadable file leaves behind, so no +// lookup ever crosses families. It used to go through one global map keyed by +// bare entry name, which two families both calling an entry query_unknown +// silently shared: whichever registered last answered for both (Vikunja #521). func floorOf(d *deck, key string) string { - if d != nil && d.floor != nil { - return d.floor[key] + if d == nil { + return "" } - return deckFloors[key] + return d.floor[key] } -// deckFloors — every family's floor literals in one map, so a nil deck still -// 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 { - for k, v := range floor { - deckFloors[k] = v - } - return floor +// floorDeck — the deck a family falls back to when its file will not load. It +// has no entries, so every read drops through to the floor literals, and it is +// a real *deck so no accessor has to know which case it is in. +func floorDeck(floor map[string]string) *deck { + return &deck{last: map[string]string{}, floor: floor} } // fill substitutes {name} for each var. A placeholder with no value is left diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index a9c7058..2124242 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -39,12 +39,12 @@ 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 = map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.", -}) +} // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. type Fallbacks struct{ d *deck } @@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) { // deck reads through a nil *Fallbacks, which is the unloadable-file case. func (f *Fallbacks) deck() *deck { if f == nil { - return nil + return floorDeck(hardFloor) } return f.d }