diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index 0fa21e1..67070c6 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -118,13 +119,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve // // Acceptance itself is recorded by /routines, and the tick // loop nudges on the interval from there (Vikunja #366). - return "поняла — подтверди на странице рутин, и начну напоминать." + return phraser.C(phraser.ConfirmRoutineAuthed, nil) }, no: func() string { if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { log.Printf("voice: dismiss proposed routine: %v", err) } - return "хорошо, не буду." + return phraser.C(phraser.ConfirmRoutineNo, nil) }, }, // Hexis execution confirm. Bound to the exact capability + target that @@ -137,7 +138,7 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve yes: func() string { return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName) }, - no: func() string { return "отменила." }, + no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) }, }, // Tool confirm. { @@ -150,16 +151,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve if err != nil { log.Printf("voice: tool %s (confirmed): %v", p.fn, err) if out != "" { - return "не получилось выполнить команду: " + firstLine(out) + return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)}) } - return "не получилось выполнить команду." + return phraser.A(phraser.ActFail, nil) } if out != "" { - return "готово: " + firstLine(out) + return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)}) } - return "готово." + return phraser.A(phraser.ActDone, nil) }, - no: func() string { return "отменила." }, + no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) }, }, } } @@ -170,17 +171,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string { name := firstWord(stripWake(dec.Utterance)) if name == "" { - return "не разобрала команду — попробуй иначе." + return phraser.C(phraser.ProposeNoVerb, nil) } + vars := map[string]string{"name": name} newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now()) if err != nil { log.Printf("voice: propose tool %q: %v", name, err) - return "команды «" + name + "» нет в списке разрешённых." + return phraser.C(phraser.ProposeFailed, vars) } if newly { - return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент." + return phraser.C(phraser.ProposeNew, vars) } - return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент." + return phraser.C(phraser.ProposeAlready, vars) } // confirmVerdict — the parse of a y/n confirm answer. diff --git a/internal/phraser/confirm.go b/internal/phraser/confirm.go new file mode 100644 index 0000000..41c9079 --- /dev/null +++ b/internal/phraser/confirm.go @@ -0,0 +1,139 @@ +package phraser + +// The confirmation answers — what she says once he has answered a confirm, and +// what she says when an act names a verb she may not run. +// +// Fifth family on the shared deck (deck.go). They were literals in +// cmd/mavend/confirm.go. +// +// The prompt is deliberately not here. act_confirm and act_confirm_entity are in +// acts_ru_v1.json, where they belong: the sentence he has to hear before he says +// yes is an act line, and it already loads with its {name} placeholder required. +// Vikunja #505 asked whether this family should move at all. It moves, but only +// the answers, and every entry is fixed. He answered a question about one +// specific thing, so variety here buys nothing and costs the one property that +// matters: the same act reports the same outcome every time. +// +// The tool-confirm success and failure lines are not here either. They said +// "готово." and "не получилось выполнить команду." in two places, which is the +// acts family word for word, so the confirmed run now reports through ActDone +// and ActFail rather than keeping a second copy that can drift. + +import ( + _ "embed" + "log" + "math/rand" + "sync" + + "github.com/kami/maven/internal/say" +) + +//go:embed confirm_ru_v1.json +var confirmJSON []byte + +// ConfirmSchemaVersion — this family's own version. +const ConfirmSchemaVersion = 1 + +// The entry keys. +const ( + // ConfirmCancelled — he said no. It names no capability, because the + // prompt he answered named one and nothing ran. + ConfirmCancelled = "confirm_cancelled" + // ConfirmRoutineAuthed — he said yes to a proposed routine out loud, which + // is not an acceptance. A room mic cannot hand the tick loop a standing new + // reason to speak, so the row stays proposed and this line points at the + // page where the accept button is gated (Vikunja #367). + ConfirmRoutineAuthed = "confirm_routine_authed" + ConfirmRoutineNo = "confirm_routine_no" + + // The propose-gap lines: an act whose verb is not on the allowlist. She + // drafts the registration and says so. She never enables it. + ProposeNoVerb = "propose_no_verb" + ProposeFailed = "propose_failed" + ProposeNew = "propose_new" + ProposeAlready = "propose_already" +) + +var confirmKeys = []string{ + ConfirmCancelled, ConfirmRoutineAuthed, ConfirmRoutineNo, + ProposeNoVerb, ProposeFailed, ProposeNew, ProposeAlready, +} + +// confirmFloor — the literal each key falls back to when the file is unusable. +// These are the exact strings that lived in cmd/mavend/confirm.go. +var confirmFloor = map[string]string{ + ConfirmCancelled: "отменила.", + ConfirmRoutineAuthed: "поняла — подтверди на странице рутин, и начну напоминать.", + ConfirmRoutineNo: "хорошо, не буду.", + + ProposeNoVerb: "не разобрала команду — попробуй иначе.", + ProposeFailed: "команды «{name}» нет в списке разрешённых.", + ProposeNew: "команды «{name}» нет в списке. Предложила её добавить — включи через клиент.", + ProposeAlready: "команды «{name}» пока нет в списке — она уже предложена, включи через клиент.", +} + +// Confirms picks a hand-written Russian confirmation answer. Safe for +// concurrent use. +type Confirms struct{ d *say.Deck } + +// LoadConfirms reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadConfirms(src rand.Source) (*Confirms, error) { + d, err := say.Load(confirmJSON, ConfirmSchemaVersion, confirmKeys, confirmFloor, src) + if err != nil { + return nil, err + } + // Every propose line that names the verb must keep naming it. A variant that + // dropped {name} would tell him a command is not allowed without saying + // which one, and the whole point of the line is that he goes and enables it. + for _, key := range []string{ProposeFailed, ProposeNew, ProposeAlready} { + if err := d.RequirePlaceholder(key, "{name}"); err != nil { + return nil, err + } + } + return &Confirms{d: d}, nil +} + +// deck reads through a nil *Confirms, which is the unloadable-file case. +func (c *Confirms) deck() *say.Deck { + if c == nil { + return say.FloorDeck(confirmFloor) + } + return c.d +} + +// Say returns the line for key, with the verb filled into the frame. +func (c *Confirms) Say(key string, vars map[string]string) string { + return c.deck().Text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (c *Confirms) Variants() []string { return c.deck().Variants() } + +var ( + confirmOnce sync.Once + confirmsDeck *Confirms +) + +// DefaultConfirms returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Confirms, which still answers from +// confirmFloor: a daemon must not fail to boot over its own copy deck. +func DefaultConfirms() *Confirms { + confirmOnce.Do(func() { + c, err := LoadConfirms(nil) + if err != nil { + log.Printf("phraser: confirmation answers unavailable, using the built-in lines: %v", err) + return + } + confirmsDeck = c + }) + return confirmsDeck +} + +// C — one confirmation answer, the way every caller says it. +func C(key string, vars map[string]string) string { return DefaultConfirms().Say(key, vars) } + +// IsC reports whether text is a line key could have produced, for the tests. +func IsC(key string, vars map[string]string, text string) bool { + return DefaultConfirms().deck().Matches(key, vars, text) +} diff --git a/internal/phraser/confirm_lines_test.go b/internal/phraser/confirm_lines_test.go new file mode 100644 index 0000000..38626a3 --- /dev/null +++ b/internal/phraser/confirm_lines_test.go @@ -0,0 +1,51 @@ +package phraser + +import ( + "math/rand" + "strings" + "testing" +) + +// The confirmation family has the strictest constraints of the six, so they are +// tested rather than left to the doc comment (Vikunja #505). +func TestConfirmFamilyLoads(t *testing.T) { + c, err := LoadConfirms(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadConfirms: %v", err) + } + if got := len(c.Variants()); got != len(confirmKeys) { + t.Errorf("variants %d, want %d: every entry is fixed at one wording", got, len(confirmKeys)) + } +} + +// A propose line that lost {name} would tell him a command is not allowed +// without saying which one, which is the whole content of the line. +func TestEveryProposeLineNamesTheVerb(t *testing.T) { + for _, key := range []string{ProposeFailed, ProposeNew, ProposeAlready} { + got := C(key, map[string]string{"name": "перезагрузи"}) + if !strings.Contains(got, "перезагрузи") { + t.Errorf("%s: %q does not name the verb", key, got) + } + } +} + +// A spoken yes does not accept a routine. The line has to keep saying where the +// acceptance happens, or he hears agreement and gets no reminders. +func TestRoutineYesStillPointsAtThePage(t *testing.T) { + got := C(ConfirmRoutineAuthed, nil) + if !strings.Contains(got, "рутин") { + t.Errorf("%q does not name the routines page", got) + } +} + +// The floor answers when the file will not load, so the deck can never leave a +// confirmed act with nothing to say. +func TestConfirmFloorAnswersWithoutTheFile(t *testing.T) { + var c *Confirms + if got := c.Say(ConfirmCancelled, nil); got != confirmFloor[ConfirmCancelled] { + t.Errorf("nil deck: %q, want the floor line", got) + } + if got := c.Say(ProposeFailed, map[string]string{"name": "стоп"}); !strings.Contains(got, "стоп") { + t.Errorf("nil deck: %q does not name the verb", got) + } +} diff --git a/internal/phraser/confirm_ru_v1.json b/internal/phraser/confirm_ru_v1.json new file mode 100644 index 0000000..ad15a66 --- /dev/null +++ b/internal/phraser/confirm_ru_v1.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "name": "confirm_ru_v1", + "notes": [ + "What she says once a confirmation has been answered, and what she says when an act names a verb she is not allowed to run.", + "The prompt itself is not here. act_confirm and act_confirm_entity live in acts_ru_v1.json, because the sentence he has to hear before he says yes is an act line.", + "Every entry is fixed. He answered a question about one specific thing, so the answer names what happened to that thing and does not get reworded for variety.", + "confirm_routine_authed is the strictest of them. A spoken yes does not accept a routine, so this line has to keep pointing at the page that does." + ], + "entries": { + "confirm_cancelled": { + "fixed": true, + "variants": ["отменила."] + }, + "confirm_routine_authed": { + "fixed": true, + "variants": ["поняла — подтверди на странице рутин, и начну напоминать."] + }, + "confirm_routine_no": { + "fixed": true, + "variants": ["хорошо, не буду."] + }, + "propose_no_verb": { + "fixed": true, + "variants": ["не разобрала команду — попробуй иначе."] + }, + "propose_failed": { + "fixed": true, + "variants": ["команды «{name}» нет в списке разрешённых."] + }, + "propose_new": { + "fixed": true, + "variants": ["команды «{name}» нет в списке. Предложила её добавить — включи через клиент."] + }, + "propose_already": { + "fixed": true, + "variants": ["команды «{name}» пока нет в списке — она уже предложена, включи через клиент."] + } + } +} diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index eb2f617..f4dc98a 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -44,6 +44,11 @@ func TestFallbackPersona(t *testing.T) { t.Fatalf("LoadActs: %v", err) } variants = append(variants, act.Variants()...) + con, err := phraser.LoadConfirms(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadConfirms: %v", err) + } + variants = append(variants, con.Variants()...) sum, err := say.LoadSummaries(rand.NewSource(20260804)) if err != nil { t.Fatalf("LoadSummaries: %v", err) diff --git a/internal/phraser/fixedness_test.go b/internal/phraser/fixedness_test.go index 1372798..1cb280c 100644 --- a/internal/phraser/fixedness_test.go +++ b/internal/phraser/fixedness_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -// The other four families, held to the rule internal/say holds the fifth to: +// The five embedded families, held to the rule internal/say holds the summary to: // one variant means fixed. Reported per family, because a failure that names // "some file" is a failure nobody acts on. func TestEverySingleVariantEntryIsFixed(t *testing.T) { @@ -25,11 +25,16 @@ func TestEverySingleVariantEntryIsFixed(t *testing.T) { if err != nil { t.Fatalf("LoadActs: %v", err) } + confirms, err := LoadConfirms(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadConfirms: %v", err) + } for name, keys := range map[string][]string{ "fallbacks": f.d.UnfixedSingles(), "acks": a.d.UnfixedSingles(), "queries": q.d.UnfixedSingles(), "acts": acts.d.UnfixedSingles(), + "confirms": confirms.d.UnfixedSingles(), } { if len(keys) > 0 { t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)