From 3f2782f5b7aa1cb35417cd6e1ada6a6cd0f698ad Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 1/4] phraser: add the shared deck for hand-written line families (V-502) Every family of hand-written Russian lines wants the same mechanics: a schema-versioned embedded file, variants with anti-repeat picking, and a floor of Go literals under it. The acknowledgements are the second family, and copying eighty lines of loader per family was not going to survive five of them. Each family keeps its own file, keys, floor, validation and accessor names. --- internal/phraser/deck.go | 181 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 internal/phraser/deck.go diff --git a/internal/phraser/deck.go b/internal/phraser/deck.go new file mode 100644 index 0000000..1f53745 --- /dev/null +++ b/internal/phraser/deck.go @@ -0,0 +1,181 @@ +package phraser + +// deck — the mechanics every family of hand-written Russian lines shares. +// +// A family is one embedded JSON file: schema-versioned, several variants per +// entry, never the same variant twice running, and a hard floor of Go literals +// under it so a broken file cannot take her words away. fallbacks.go was the +// first family (Vikunja #501) and acks.go the second, at which point copying +// eighty lines of loader per family stopped being defensible. +// +// What stays per family: the file, the keys, the floor literals, the accessor +// names, and any validation only that family can state. + +import ( + "encoding/json" + "fmt" + "math/rand" + "strings" + "sync" + "time" +) + +// deckEntry — one line she can say, in as many wordings as the file gives. +type deckEntry struct { + // Fixed — one variant, never picked between. For wording that must not + // drift from turn to turn, like a phrase naming one specific gap. + Fixed bool `json:"fixed"` + Variants []string `json:"variants"` +} + +type deckFile struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Entries map[string]deckEntry `json:"entries"` +} + +// deck picks a line. Safe for concurrent use. A nil *deck answers from the +// floor, which is what an unloadable file leaves behind. +type deck struct { + mu sync.Mutex + rnd *rand.Rand + last map[string]string + file deckFile + keys []string + floor map[string]string +} + +// loadDeck parses raw, checks the version and every required key, and seeds the +// picker. Pass a source to make the picking reproducible in tests; nil seeds +// from the clock. +func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) { + var f deckFile + if err := json.Unmarshal(raw, &f); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + if f.SchemaVersion != version { + return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version) + } + for _, k := range keys { + e, ok := f.Entries[k] + if !ok || len(e.Variants) == 0 { + return nil, fmt.Errorf("entry %q is missing or empty", k) + } + if e.Fixed && len(e.Variants) != 1 { + return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants)) + } + } + if src == nil { + src = rand.NewSource(time.Now().UnixNano()) + } + return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil +} + +// requirePlaceholder fails the load when a variant of key does not use ph. For +// an entry whose whole job is to read something back, a variant without the +// placeholder silently drops it. +func (d *deck) requirePlaceholder(key, ph string) error { + for _, v := range d.file.Entries[key].Variants { + if !strings.Contains(v, ph) { + return fmt.Errorf("%q variant %q does not use %s", key, v, ph) + } + } + return nil +} + +// text returns one variant for key with the placeholders filled in. A nil +// receiver answers from the floor, so no caller checks whether the file loaded. +func (d *deck) text(key string, vars map[string]string) string { + tmpl := "" + if d != nil { + if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 { + tmpl = d.pick(key, e) + } + } + if tmpl == "" { + tmpl = floorOf(d, key) + } + return fill(tmpl, vars) +} + +// matches reports whether text is a line key could have produced. A caller that +// has to recognise one of these lines cannot compare against a literal any more. +func (d *deck) matches(key string, vars map[string]string, text string) bool { + if fill(floorOf(d, key), vars) == text { + return true + } + if d == nil { + return false + } + for _, v := range d.file.Entries[key].Variants { + if fill(v, vars) == text { + return true + } + } + return false +} + +// variants returns every line the file can produce, in key order, for the +// persona scorer. Stable order so a failure names the same variant twice. +func (d *deck) variants() []string { + if d == nil { + return nil + } + var out []string + for _, k := range d.keys { + out = append(out, d.file.Entries[k].Variants...) + } + return out +} + +// pick chooses at random, skipping whatever this entry said last time. +func (d *deck) pick(key string, e deckEntry) string { + d.mu.Lock() + defer d.mu.Unlock() + + choices := e.Variants + if len(choices) > 1 { + fresh := make([]string, 0, len(choices)) + for _, v := range choices { + if v != d.last[key] { + fresh = append(fresh, v) + } + } + if len(fresh) > 0 { + choices = fresh + } + } + got := choices[d.rnd.Intn(len(choices))] + d.last[key] = got + return got +} + +// floorOf reads the Go literal behind key, and works on a nil deck because that +// is exactly the case it exists for. The per-family map is the source of truth. +func floorOf(d *deck, key string) string { + if d != nil && d.floor != nil { + return d.floor[key] + } + return deckFloors[key] +} + +// deckFloors — every family's floor literals in one map, so a nil deck still +// finds them. Families register at init; the keys are namespaced by family. +var deckFloors = map[string]string{} + +func registerFloor(floor map[string]string) map[string]string { + for k, v := range floor { + deckFloors[k] = v + } + return floor +} + +// fill substitutes {name} for each var. A placeholder with no value is left +// alone rather than blanked, so a missing value is visible instead of silent. +func fill(tmpl string, vars map[string]string) string { + for k, v := range vars { + tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v) + } + return tmpl +} -- 2.52.0 From 1c9ddbbea29cdec1360903f7edbc21c1b9d41c25 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 2/4] phraser: move the fallbacks onto the deck (V-502) --- internal/phraser/failure_test.go | 11 +-- internal/phraser/fallbacks.go | 146 +++++-------------------------- 2 files changed, 24 insertions(+), 133 deletions(-) diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 5afbd5c..714acc0 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -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 diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index b047d60..a9c7058 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -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) } -- 2.52.0 From dae123adac9a3f90573c9ae9f91d83f00c72d7f6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 3/4] phraser: put the capture acknowledgements in a versioned json (V-502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What she says after storing something he said, and what she says when storing it failed. They were literals in eight files under cmd/mavend and the stub replier. He hears these many times a day, which is why most entries carry variants: identical wording is what makes a confirmation stop registering as one. The quiet-mode lines are fixed — they report a state, and a state report that reworded itself would read as a different state. His data stays Go-side. The file holds "отметила: {key} = {value}"; nothing he said lives in the copy. --- internal/phraser/ack_ru_v1.json | 84 +++++++++++++++++ internal/phraser/acks.go | 162 ++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 internal/phraser/ack_ru_v1.json create mode 100644 internal/phraser/acks.go diff --git a/internal/phraser/ack_ru_v1.json b/internal/phraser/ack_ru_v1.json new file mode 100644 index 0000000..01f5254 --- /dev/null +++ b/internal/phraser/ack_ru_v1.json @@ -0,0 +1,84 @@ +{ + "schema_version": 1, + "name": "russian capture acknowledgements v1", + "notes": [ + "What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", + "He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.", + "Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.", + "An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored." + ], + "entries": { + "ack_fact": { + "variants": ["записала факт.", "записала.", "запомнила."] + }, + "ack_fact_key": { + "variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"] + }, + "ack_fact_kv": { + "variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"] + }, + "ack_note": { + "variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."] + }, + "ack_reminder": { + "variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."] + }, + "ack_act": { + "variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"] + }, + "ack_task": { + "variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"] + }, + "ack_task_urgent": { + "variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"] + }, + "ack_task_duplicate": { + "variants": ["это уже в списке.", "такое уже есть в задачах."] + }, + "ack_nudge": { + "variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."] + }, + "ack_snooze": { + "variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."] + }, + "ack_generic": { + "variants": ["приняла.", "поняла."] + }, + "quiet_on": { + "fixed": true, + "variants": ["тихий режим включён. буду реже напоминать."] + }, + "quiet_off": { + "fixed": true, + "variants": ["тихий режим выключен."] + }, + "fail_fact": { + "variants": ["не получилось сохранить факт.", "факт не сохранился."] + }, + "fail_note": { + "variants": ["не получилось сохранить заметку.", "заметка не сохранилась."] + }, + "fail_reminder": { + "variants": ["не получилось поставить напоминание.", "напоминание не поставилось."] + }, + "fail_reminder_time": { + "variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."] + }, + "fail_task": { + "variants": ["не получилось записать задачу.", "задача не записалась."] + }, + "fail_ack": { + "variants": ["не получилось отметить.", "не смогла отметить."] + }, + "fail_snooze": { + "variants": ["не получилось отложить.", "не смогла отложить."] + }, + "fail_quiet": { + "variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."] + }, + "fail_fact_unparsed": { + "variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"] + } + } +} diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go new file mode 100644 index 0000000..20c0be4 --- /dev/null +++ b/internal/phraser/acks.go @@ -0,0 +1,162 @@ +package phraser + +// The capture acknowledgements — what she says after storing something he said, +// and what she says when storing it failed. +// +// They were string literals in eight files under cmd/mavend plus the stub +// replier in internal/voice. He hears them many times a day, which is exactly +// why they need variants and exactly why rewording one must not be a rebuild. +// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go). +// +// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key +// and the value are interpolated here, so nothing he said lives in the copy. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed ack_ru_v1.json +var ackJSON []byte + +// AckSchemaVersion — this family's own version. A file that changes on a +// different day than the fallbacks cannot share their number (Vikunja #397). +const AckSchemaVersion = 1 + +// The entry keys. Namespaced by family, because the floor map behind a nil deck +// is process-wide. +const ( + AckFact = "ack_fact" + AckFactKey = "ack_fact_key" + AckFactValue = "ack_fact_kv" + AckNote = "ack_note" + AckReminder = "ack_reminder" + AckAct = "ack_act" + AckTask = "ack_task" + AckTaskUrgent = "ack_task_urgent" + AckTaskDuplicate = "ack_task_duplicate" + AckNudge = "ack_nudge" + AckSnooze = "ack_snooze" + AckGeneric = "ack_generic" + AckQuietOn = "quiet_on" + AckQuietOff = "quiet_off" + + FailFact = "fail_fact" + FailFactUnparsed = "fail_fact_unparsed" + FailNote = "fail_note" + FailReminder = "fail_reminder" + FailReminderTime = "fail_reminder_time" + FailTask = "fail_task" + FailAck = "fail_ack" + FailSnooze = "fail_snooze" + FailQuiet = "fail_quiet" +) + +// ackKeys — every key the code requires the file to define. +var ackKeys = []string{ + AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct, + AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric, + AckQuietOn, AckQuietOff, + FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime, + FailTask, FailAck, FailSnooze, FailQuiet, +} + +// ackFloor — 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 ackFloor = registerFloor(map[string]string{ + AckFact: "записала факт.", + AckFactKey: "отметила: {key}", + AckFactValue: "отметила: {key} = {value}", + AckNote: "сохранила заметку.", + AckReminder: "напомню.", + AckAct: "ок, записала действие: {fn}", + AckTask: "записала: {text}", + AckTaskUrgent: "поняла, беру в работу: {text}", + AckTaskDuplicate: "это уже в списке.", + AckNudge: "отлично, отметила.", + AckSnooze: "хорошо, вернусь к этому позже.", + AckGeneric: "приняла.", + AckQuietOn: "тихий режим включён. буду реже напоминать.", + AckQuietOff: "тихий режим выключен.", + + FailFact: "не получилось сохранить факт.", + FailFactUnparsed: "не разобрала, что записать — попробуй иначе.", + FailNote: "не получилось сохранить заметку.", + FailReminder: "не получилось поставить напоминание.", + FailReminderTime: "не получилось разобрать время напоминания.", + FailTask: "не получилось записать задачу.", + FailAck: "не получилось отметить.", + FailSnooze: "не получилось отложить.", + FailQuiet: "не получилось переключить тихий режим.", +}) + +// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. +type Acks struct{ d *deck } + +// LoadAcks reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadAcks(src rand.Source) (*Acks, error) { + d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) + if err != nil { + return nil, err + } + // The three entries that exist to read his own words back. A variant + // without the placeholder would confirm the capture and drop what was + // captured, which reads as a successful save of nothing. + for _, req := range []struct{ key, ph string }{ + {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, + {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, + } { + if err := d.requirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Acks{d: d}, nil +} + +// deck reads through a nil *Acks, which is the unloadable-file case. +func (a *Acks) deck() *deck { + if a == nil { + return nil + } + return a.d +} + +// Say returns one line for key, with his data filled into the frame. Pass nil +// when the entry takes none. +func (a *Acks) Say(key string, vars map[string]string) string { + return a.deck().text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (a *Acks) Variants() []string { return a.deck().variants() } + +var ( + ackOnce sync.Once + acks *Acks +) + +// DefaultAcks returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Acks, which still answers from ackFloor. +func DefaultAcks() *Acks { + ackOnce.Do(func() { + a, err := LoadAcks(nil) + if err != nil { + log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err) + return + } + acks = a + }) + return acks +} + +// Ack — one acknowledgement line, the way every caller says it. +func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) } + +// IsAck reports whether text is a line key could have produced. For the daemon +// tests, which can no longer compare against one literal. +func IsAck(key string, vars map[string]string, text string) bool { + return DefaultAcks().deck().matches(key, vars, text) +} -- 2.52.0 From b2521988e1053df3511171f104da9105434760e0 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 4/4] mavend, voice: say the acknowledgements from the file (V-502) The daemon tests that compared against one literal ask the entry instead: IsAck names the line she could have said without pinning the wording. The eval scores every ack variant on the persona checks the nudges already pass. --- cmd/mavend/ack.go | 5 +++-- cmd/mavend/actions_fact.go | 5 +++-- cmd/mavend/actions_note.go | 5 +++-- cmd/mavend/actions_reminder.go | 5 +++-- cmd/mavend/actions_task.go | 9 +++++---- cmd/mavend/actions_task_test.go | 3 ++- cmd/mavend/quiet_toggle.go | 7 ++++--- cmd/mavend/replier_llm_test.go | 15 +++++++++++++-- cmd/mavend/snooze.go | 5 +++-- internal/phraser/eval/fallbacks_test.go | 23 +++++++++++++++-------- internal/phraser/fallbacks_test.go | 17 +++++++++++++++++ internal/voice/replier.go | 22 +++++++++++----------- 12 files changed, 82 insertions(+), 39 deletions(-) diff --git a/cmd/mavend/ack.go b/cmd/mavend/ack.go index 9fcd430..7339a40 100644 --- a/cmd/mavend/ack.go +++ b/cmd/mavend/ack.go @@ -16,6 +16,7 @@ import ( "log" "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS } if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil { log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) - return "не получилось отметить.", true + return phraser.Ack(phraser.FailAck, nil), true } log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src) - return "отлично, отметила.", true + return phraser.Ack(phraser.AckNudge, nil), true } // ackFromFact — post-action hook, called once the turn's decision has been diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 5645282..d902642 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -14,7 +15,7 @@ import ( // it for recall, and let pattern detection propose a routine. func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { if !dec.Slots.HasKey { - return "не разобрала, что записать — попробуй иначе." + return phraser.Ack(phraser.FailFactUnparsed, nil) } // A question is never a fact about him (#470). "какая последняя версия // языка Go?" used to land here, and the value stored was whatever the @@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s factID, err := h.api.WriteFact(ctx, req) if err != nil { log.Printf("voice: write fact: %v", err) - return "не получилось сохранить факт." + return phraser.Ack(phraser.FailFact, nil) } // Index the fact in long-term memory (best-effort, must not fail the fact // write). Facts aren't in the notes table, so this is the only recall path diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index c7c20a5..f8b79b5 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -5,6 +5,7 @@ import ( "log" "strconv" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed note: %v", err) - return "не получилось сохранить заметку." + return phraser.Ack(phraser.FailNote, nil) } noteTs := h.now() noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") if err != nil { log.Printf("voice: write note: %v", err) - return "не получилось сохранить заметку." + return phraser.Ack(phraser.FailNote, nil) } // Insert into long-term memory (best-effort, must not fail the note write). // text/ts in the meta make a Search hit self-describing (see bestRecall). diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index ce2a632..ab9a544 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -4,6 +4,7 @@ import ( "context" "log" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio } } if !dec.Slots.HasTime { - return "не получилось разобрать время напоминания." + return phraser.Ack(phraser.FailReminderTime, nil) } } payload := `{"text":` + jsonString(dec.Utterance) + `}` if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { log.Printf("voice: create reminder: %v", err) - return "не получилось поставить напоминание." + return phraser.Ack(phraser.FailReminder, nil) } return "" } diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 99187f0..a2dded3 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -5,6 +5,7 @@ import ( "log" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tasks" @@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De }) if err != nil { log.Printf("voice: capture task: %v", err) - return "не получилось записать задачу.", true + return phraser.Ack(phraser.FailTask, nil), true } if resp.Promoted { // It was a candidate Maven derived from something she read, and he has // now said it himself. Saying "уже в списке" here would be answering a // confirmation with a shrug. - return "поняла, беру в работу: " + cap.Text, true + return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true } if !resp.Created { - return "это уже в списке.", true + return phraser.Ack(phraser.AckTaskDuplicate, nil), true } - return "записала: " + cap.Text, true + return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true } // queryTasks — "какие у меня задачи?", "что мне нужно сделать?". diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 628b30d..33e304c 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) { if !ok { t.Fatal("a failed capture still claims the turn — the note path must not double-write") } - if !strings.Contains(reply, "не получилось") { + if !phraser.IsAck(phraser.FailTask, nil, reply) { t.Errorf("reply = %q, want an honest failure", reply) } } diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go index 1bbd922..02f6e10 100644 --- a/cmd/mavend/quiet_toggle.go +++ b/cmd/mavend/quiet_toggle.go @@ -11,6 +11,7 @@ import ( "unicode" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" ) // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when @@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s return "", false } val := "false" - reply := "тихий режим выключен." + reply := phraser.Ack(phraser.AckQuietOff, nil) if on { val = "true" - reply = "тихий режим включён. буду реже напоминать." + reply = phraser.Ack(phraser.AckQuietOn, nil) } if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ Ts: h.now(), @@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s Confidence: 1.0, }); err != nil { log.Printf("voice: write quiet_hours: %v", err) - return "не получилось переключить тихий режим.", true + return phraser.Ack(phraser.FailQuiet, nil), true } return reply, true } diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index fae6d39..075716a 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) @@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { func TestLLMReplierFallsBackToStubOnError(t *testing.T) { r := newLLMReplier(stubCompleter{err: errReplierTest}, nil) - assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error") + assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error") } func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { r := newLLMReplier(stubCompleter{out: ""}, nil) - assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm") + assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm") } func TestLLMReplierClarifyUsesStub(t *testing.T) { @@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) { assertStub(t, r, router.Decision{Clarify: true}, "clarify") } +// assertAck — the stub picks between variants now, so two calls to it are not +// expected to match. What must hold is that the reply is a line that entry can +// produce, which is the same claim without pinning one wording. +func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) { + t.Helper() + if got := r.Reply(d); !phraser.IsAck(key, nil, got) { + t.Errorf("on %s: got %q, want a %q line", what, got, key) + } +} + func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) { t.Helper() got, want := r.Reply(d), voice.NewStubReplier().Reply(d) diff --git a/cmd/mavend/snooze.go b/cmd/mavend/snooze.go index 88fb235..f92c981 100644 --- a/cmd/mavend/snooze.go +++ b/cmd/mavend/snooze.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" ) @@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu } if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil { log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) - return "не получилось отложить.", true + return phraser.Ack(phraser.FailSnooze, nil), true } log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src) - return "хорошо, вернусь к этому позже.", true + return phraser.Ack(phraser.AckSnooze, nil), true } // pendingNudge — the newest still-pending nudge sent inside snoozeWindow. diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 3794a54..55c4fe5 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,13 +8,13 @@ import ( "github.com/kami/maven/internal/phraser" ) -// TestFallbackPersona scores every line in fallbacks_ru_v1.json on the persona -// checks the nudges are already held to. These lines are heard out loud, and -// they live in a JSON file now, so a reworded variant that says "рад" or "вы" -// would otherwise reach him with nothing between it and the speaker. +// TestFallbackPersona scores every line in fallbacks_ru_v1.json and +// ack_ru_v1.json on the persona checks the nudges already pass. These lines are +// heard out loud and they live in a JSON file now, so a reworded variant that +// says "рад" or "вы" would otherwise reach him with nothing in between. // // Only the persona checks run. Mood and topic belong to a nudge, and these are -// not nudges: they are what she says when there is no answer. +// not nudges. func TestFallbackPersona(t *testing.T) { fb, err := phraser.LoadFallbacks(rand.NewSource(20260804)) if err != nil { @@ -24,13 +24,20 @@ func TestFallbackPersona(t *testing.T) { CheckLang: true, CheckFeminine: true, CheckHisGender: true, CheckAddress: true, CheckCringe: true, CheckLength: true, } - variants := fb.Variants() + ack, err := phraser.LoadAcks(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadAcks: %v", err) + } + variants := append(fb.Variants(), ack.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } for _, v := range variants { - // {sources} stands for his own notes and never carries persona of its own. - body := strings.ReplaceAll(v, "{sources}", "два литра") + // The placeholders stand for his own words and carry no persona. + body := v + for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} { + body = strings.ReplaceAll(body, ph, "вода") + } for _, r := range RunChecks(Case{}, body, "neutral") { if persona[r.Name] && !r.Pass { t.Errorf("%q fails %s: %s", v, r.Name, r.Detail) diff --git a/internal/phraser/fallbacks_test.go b/internal/phraser/fallbacks_test.go index ecec0ec..789badc 100644 --- a/internal/phraser/fallbacks_test.go +++ b/internal/phraser/fallbacks_test.go @@ -55,3 +55,20 @@ func TestFallbacksDoNotRepeat(t *testing.T) { prev = got } } + +// The acknowledgements load, fill his words into the frame, and answer from the +// floor when the file is gone. +func TestAcksLoad(t *testing.T) { + a, err := LoadAcks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadAcks: %v", err) + } + got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"}) + if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") { + t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got) + } + var nilAcks *Acks + if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] { + t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote]) + } +} diff --git a/internal/voice/replier.go b/internal/voice/replier.go index 1f6e71f..b1c5cca 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -25,7 +25,10 @@ // the daemon seam (config wiring, no CoreAPI or voice-package change). package voice -import "github.com/kami/maven/internal/router" +import ( + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" +) // Replier — the reactive reply phrasing seam. The daemon's reactive handler // calls Reply with the router's Decision; the impl produces a terse reply @@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string { if !d.Slots.HasFn { return "не могу это сделать — не разобрала действие." } - return "ок, записала действие: " + d.Slots.Fn + return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn}) case router.IntentReminder: - if d.Slots.HasTime { - return "напомню." - } - return "напомню." + return phraser.Ack(phraser.AckReminder, nil) case router.IntentFact: if d.Slots.HasKey { if d.Slots.Value != "" { - return "отметила: " + d.Slots.Key + " = " + d.Slots.Value + return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value}) } - return "отметила: " + d.Slots.Key + return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key}) } - return "записала факт." + return phraser.Ack(phraser.AckFact, nil) case router.IntentNote: - return "сохранила заметку." + return phraser.Ack(phraser.AckNote, nil) case router.IntentQuery: return "поискала в заметках — ничего не нашла." case router.IntentChat: return "поговорили." // stub — LLMReplier replaces this default: - return "приняла." + return phraser.Ack(phraser.AckGeneric, nil) } } -- 2.52.0