From 4fdce3ca2c9d84bb30aee3add5fca1a17df285ab Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:19:41 +0400 Subject: [PATCH] phraser: put the phrasing fallbacks in a versioned json (V-501) Four lines he hears out loud lived as string literals in three Go files, so rewording one meant a rebuild. They move to fallbacks_ru_v1.json on the shape nudges_ru_v1.json already uses: embedded, schema-versioned, several variants, never the same one twice running. The gap phrase is marked fixed, because it names one specific missing model and must not drift into a general "I do not know". Every accessor falls back to the literal it replaced, including on a nil receiver: these strings exist because something already failed, so a broken template file must not take her last words away. --- internal/phraser/fallbacks.go | 238 ++++++++++++++++++++++++++ internal/phraser/fallbacks_ru_v1.json | 42 +++++ 2 files changed, 280 insertions(+) create mode 100644 internal/phraser/fallbacks.go create mode 100644 internal/phraser/fallbacks_ru_v1.json diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go new file mode 100644 index 0000000..b047d60 --- /dev/null +++ b/internal/phraser/fallbacks.go @@ -0,0 +1,238 @@ +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) +} diff --git a/internal/phraser/fallbacks_ru_v1.json b/internal/phraser/fallbacks_ru_v1.json new file mode 100644 index 0000000..e225889 --- /dev/null +++ b/internal/phraser/fallbacks_ru_v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "name": "russian phrasing fallbacks v1", + "notes": [ + "What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.", + "These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.", + "Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.", + "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns." + ], + "entries": { + "chat": { + "variants": [ + "даже не знаю, что сказать.", + "не могу найти слов.", + "мысль ускользнула, повтори?", + "у меня сейчас пусто в голове." + ] + }, + "query_unknown": { + "variants": [ + "не знаю.", + "не знаю, честно.", + "тут я пас.", + "не скажу, не знаю." + ] + }, + "query_sources": { + "variants": [ + "вот что я нашла: {sources}", + "нашла вот это: {sources}", + "есть только это: {sources}" + ] + }, + "world_gap": { + "fixed": true, + "variants": [ + "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." + ] + } + } +}