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/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) +} 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 +} 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/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) } 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) } }