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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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": [
|
||||
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user