From 1c9ddbbea29cdec1360903f7edbc21c1b9d41c25 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH] phraser: move the fallbacks onto the deck (V-502) --- internal/phraser/failure_test.go | 11 +-- internal/phraser/fallbacks.go | 146 +++++-------------------------- 2 files changed, 24 insertions(+), 133 deletions(-) diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 5afbd5c..714acc0 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -13,16 +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() - e, ok := DefaultFallbacks().file.Entries[key] - if !ok { - t.Fatalf("no fallback entry %q", key) - } - for _, v := range e.Variants { - if strings.ReplaceAll(v, "{sources}", sources) == got { - return true - } - } - return false + 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 b047d60..a9c7058 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -5,8 +5,6 @@ package phraser // They were four string literals spread across phraser.go, llmphraser.go and // cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so // rewording one was a Go edit, a rebuild and a redeploy for what is product copy. -// This is the same shape nudges_ru_v1.json already uses for nudges: embedded, -// schema-versioned, several variants, and never the same variant twice running. // // The floor under the floor is deliberate. These strings exist because something // already failed, so a broken template file must not be able to take the last @@ -14,13 +12,9 @@ package phraser import ( _ "embed" - "encoding/json" - "fmt" "log" "math/rand" - "strings" "sync" - "time" ) //go:embed fallbacks_ru_v1.json @@ -45,132 +39,56 @@ 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 = map[string]string{ +var hardFloor = registerFloor(map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.", -} +}) -type fallbackEntry struct { - // Fixed — one variant, never picked between. For wording that must not drift - // from turn to turn, like the gap phrase that names an unavailable model. - Fixed bool `json:"fixed"` - Variants []string `json:"variants"` -} - -type fallbackFile struct { - SchemaVersion int `json:"schema_version"` - Name string `json:"name"` - Notes []string `json:"notes"` - Entries map[string]fallbackEntry `json:"entries"` -} - -// Fallbacks picks a hand-written Russian fallback line. -// -// Safe for concurrent use. Never the same variant twice in a row for the same -// entry: hearing the identical words every time a request fails is how a failure -// stops registering as one. -type Fallbacks struct { - mu sync.Mutex - rnd *rand.Rand - last map[string]string - file fallbackFile -} +// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. +type Fallbacks struct{ d *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) { - var f fallbackFile - if err := json.Unmarshal(fallbackJSON, &f); err != nil { - return nil, fmt.Errorf("fallbacks: parse: %w", err) + d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) + if err != nil { + return nil, err } - if f.SchemaVersion != FallbackSchemaVersion { - return nil, fmt.Errorf("fallbacks: schema_version %d, want %d", - f.SchemaVersion, FallbackSchemaVersion) + // query_sources is the one entry whose whole job is to read something back. + if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil { + return nil, err } - for _, k := range fbKeys { - e, ok := f.Entries[k] - if !ok || len(e.Variants) == 0 { - return nil, fmt.Errorf("fallbacks: entry %q is missing or empty", k) - } - if e.Fixed && len(e.Variants) != 1 { - return nil, fmt.Errorf("fallbacks: entry %q is fixed but has %d variants", k, len(e.Variants)) - } - } - // query_sources is the one entry whose whole job is to read something back, - // so a variant without the placeholder would silently drop the sources. - for _, v := range f.Entries[fbQuerySources].Variants { - if !strings.Contains(v, "{sources}") { - return nil, fmt.Errorf("fallbacks: %q variant %q does not use {sources}", fbQuerySources, v) - } - } - if src == nil { - src = rand.NewSource(time.Now().UnixNano()) - } - return &Fallbacks{rnd: rand.New(src), last: map[string]string{}, file: f}, nil + return &Fallbacks{d: d}, nil } -// text returns one variant for key, with {sources} filled in. A nil receiver is -// the unloadable-file case and answers from hardFloor, so the caller never has -// to check whether the templates loaded. -func (f *Fallbacks) text(key, sources string) string { - tmpl := hardFloor[key] - if f != nil { - if e, ok := f.file.Entries[key]; ok && len(e.Variants) > 0 { - tmpl = f.pick(key, e) - } +// deck reads through a nil *Fallbacks, which is the unloadable-file case. +func (f *Fallbacks) deck() *deck { + if f == nil { + return nil } - return strings.ReplaceAll(tmpl, "{sources}", sources) -} - -// pick chooses at random, skipping whatever this entry said last time. -func (f *Fallbacks) pick(key string, e fallbackEntry) string { - f.mu.Lock() - defer f.mu.Unlock() - - choices := e.Variants - if len(choices) > 1 { - fresh := make([]string, 0, len(choices)) - for _, v := range choices { - if v != f.last[key] { - fresh = append(fresh, v) - } - } - if len(fresh) > 0 { - choices = fresh - } - } - got := choices[f.rnd.Intn(len(choices))] - f.last[key] = got - return got + return f.d } // Chat — nothing usable came back on the chat path. -func (f *Fallbacks) Chat() string { return f.text(fbChat, "") } +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.text(fbQueryUnknown, "") } +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.text(fbQuerySources, 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.text(fbWorldGap, "") } +func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) } // Variants returns every line the file can produce, for the persona scorer. -// Order is stable so a failure names the same variant twice running. -func (f *Fallbacks) Variants() []string { - var out []string - for _, k := range fbKeys { - out = append(out, f.file.Entries[k].Variants...) - } - return out -} +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 @@ -208,31 +126,13 @@ func SourcesFallback(sources string) string { return DefaultFallbacks().FromSour // WorldGap — what he hears when the world model is configured and unreachable. func WorldGap() string { return DefaultFallbacks().WorldGap() } -// matches reports whether text is a line the given entry could have produced. -// A caller that has to recognise a fallback cannot compare against one literal -// any more, because the entry picks between variants. -func (f *Fallbacks) matches(key, sources, text string) bool { - if strings.ReplaceAll(hardFloor[key], "{sources}", sources) == text { - return true - } - if f == nil { - return false - } - for _, v := range f.file.Entries[key].Variants { - if strings.ReplaceAll(v, "{sources}", sources) == text { - return true - } - } - return false -} - // 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().matches(fbQueryUnknown, "", 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().matches(fbQuerySources, sources, text) + return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text) }