phraser: move the fallbacks onto the deck (V-502)

This commit is contained in:
2026-08-04 01:26:52 +04:00
parent 3f2782f5b7
commit 1c9ddbbea2
2 changed files with 24 additions and 133 deletions
+1 -10
View File
@@ -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
+23 -123
View File
@@ -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)
}