phraser: move the fallbacks onto the deck (V-502)
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user