From feca776077e657b8fda23bcfc09ffe506d46cb74 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:38:16 +0400 Subject: [PATCH] phraser: a variant she cannot fill is not a variant she can say (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the deck, both of which reach him as a broken answer. An optional placeholder had no rule. net_empty carries {tail} for the case where a scan stopped short of the whole range, and a scan that finished has nothing to put there — so the answer went out with the braces in it, or with nothing at all if the variant was all placeholder. The picker now narrows to the variants this call can actually fill, and prefers, among those, the ones using the most of what the caller supplied, so a caveat he was given is never dropped for a shorter wording. Nothing fillable still says the line, because a visible placeholder beats silence. The floor literals lived in one global map keyed by bare entry name, and two families both define an entry called query_unknown: the query answers, where she looked and found nothing, and the phrasing fallbacks, where she failed to say an answer she had. Whichever registered last answered for both, so the distinction those two files exist for disappeared exactly when a file failed to load. Each family now carries its own map, and an unloadable file leaves a floor-only deck behind instead of a nil one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- internal/phraser/acks.go | 6 +-- internal/phraser/deck.go | 92 +++++++++++++++++++++++++++-------- internal/phraser/fallbacks.go | 6 +-- 3 files changed, 79 insertions(+), 25 deletions(-) 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 }