Merge the confirmation strings family (#174)

This commit is contained in:
2026-08-05 16:07:57 +04:00
6 changed files with 255 additions and 13 deletions
+14 -12
View File
@@ -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.
+139
View File
@@ -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)
}
+51
View File
@@ -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)
}
}
+40
View File
@@ -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}» пока нет в списке — она уже предложена, включи через клиент."]
}
}
}
+5
View File
@@ -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)
+6 -1
View File
@@ -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)