Merge the confirmation strings family (#174)
This commit is contained in:
+14
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"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
|
// Acceptance itself is recorded by /routines, and the tick
|
||||||
// loop nudges on the interval from there (Vikunja #366).
|
// loop nudges on the interval from there (Vikunja #366).
|
||||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||||
},
|
},
|
||||||
no: func() string {
|
no: func() string {
|
||||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
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
|
// 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 {
|
yes: func() string {
|
||||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
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.
|
// Tool confirm.
|
||||||
{
|
{
|
||||||
@@ -150,16 +151,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||||
if out != "" {
|
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 != "" {
|
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 {
|
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||||
name := firstWord(stripWake(dec.Utterance))
|
name := firstWord(stripWake(dec.Utterance))
|
||||||
if name == "" {
|
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())
|
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: propose tool %q: %v", name, err)
|
log.Printf("voice: propose tool %q: %v", name, err)
|
||||||
return "команды «" + name + "» нет в списке разрешённых."
|
return phraser.C(phraser.ProposeFailed, vars)
|
||||||
}
|
}
|
||||||
if newly {
|
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.
|
// confirmVerdict — the parse of a y/n confirm answer.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}» пока нет в списке — она уже предложена, включи через клиент."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,11 @@ func TestFallbackPersona(t *testing.T) {
|
|||||||
t.Fatalf("LoadActs: %v", err)
|
t.Fatalf("LoadActs: %v", err)
|
||||||
}
|
}
|
||||||
variants = append(variants, act.Variants()...)
|
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))
|
sum, err := say.LoadSummaries(rand.NewSource(20260804))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadSummaries: %v", err)
|
t.Fatalf("LoadSummaries: %v", err)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"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
|
// one variant means fixed. Reported per family, because a failure that names
|
||||||
// "some file" is a failure nobody acts on.
|
// "some file" is a failure nobody acts on.
|
||||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||||
@@ -25,11 +25,16 @@ func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadActs: %v", err)
|
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{
|
for name, keys := range map[string][]string{
|
||||||
"fallbacks": f.d.UnfixedSingles(),
|
"fallbacks": f.d.UnfixedSingles(),
|
||||||
"acks": a.d.UnfixedSingles(),
|
"acks": a.d.UnfixedSingles(),
|
||||||
"queries": q.d.UnfixedSingles(),
|
"queries": q.d.UnfixedSingles(),
|
||||||
"acts": acts.d.UnfixedSingles(),
|
"acts": acts.d.UnfixedSingles(),
|
||||||
|
"confirms": confirms.d.UnfixedSingles(),
|
||||||
} {
|
} {
|
||||||
if len(keys) > 0 {
|
if len(keys) > 0 {
|
||||||
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
|
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
|
||||||
|
|||||||
Reference in New Issue
Block a user