package phraser // The phrasing fallbacks — what she says when the model gave her nothing usable. // // 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 // words she has: every accessor falls back to the literal it replaced. import ( _ "embed" "encoding/json" "fmt" "log" "math/rand" "strings" "sync" "time" ) //go:embed fallbacks_ru_v1.json var fallbackJSON []byte // FallbackSchemaVersion — the version this code understands. Its own constant, // not shared with the nudge templates or the eval fixtures: two files that change // on different days cannot be versioned by one number (Vikunja #397). const FallbackSchemaVersion = 1 // The entry keys. Every one of them is read by a method below, so a typo in the // file is caught at load rather than at the moment she needs the words. const ( fbChat = "chat" fbQueryUnknown = "query_unknown" fbQuerySources = "query_sources" fbWorldGap = "world_gap" ) // fbKeys — every key the code requires the file to define. 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{ 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 } // 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) } if f.SchemaVersion != FallbackSchemaVersion { return nil, fmt.Errorf("fallbacks: schema_version %d, want %d", f.SchemaVersion, FallbackSchemaVersion) } 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 } // 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) } } 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 } // Chat — nothing usable came back on the chat path. func (f *Fallbacks) Chat() string { return f.text(fbChat, "") } // Unknown — a question she cannot answer and will not guess at. func (f *Fallbacks) Unknown() string { return f.text(fbQueryUnknown, "") } // FromSources — read back what she was handed, because phrasing it failed. func (f *Fallbacks) FromSources(sources string) string { return f.text(fbQuerySources, 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, "") } // 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 } // 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 // one — and because a template file that is embedded and validated at load has // nothing per-instance to configure. var ( fallbackOnce sync.Once fallbacks *Fallbacks ) // DefaultFallbacks returns the shared instance, loading it on first use. A // broken file logs once and leaves a nil *Fallbacks, which still answers from // hardFloor — a daemon must not fail to boot over its own copy deck. func DefaultFallbacks() *Fallbacks { fallbackOnce.Do(func() { fb, err := LoadFallbacks(nil) if err != nil { log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err) return } fallbacks = fb }) return fallbacks } // ChatFallback — what she says when the chat path produced nothing. func ChatFallback() string { return DefaultFallbacks().Chat() } // UnknownFallback — what she says when she has no answer and will not invent one. func UnknownFallback() string { return DefaultFallbacks().Unknown() } // SourcesFallback — read the sources back rather than ship a broken fragment. func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) } // 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) } // IsSourcesFallback reports whether text is sources read back verbatim. func IsSourcesFallback(text, sources string) bool { return DefaultFallbacks().matches(fbQuerySources, sources, text) }