diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index 2b96855..374fd4f 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -6,6 +6,7 @@ import ( "log" "github.com/kami/maven/internal/mcp" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/tool" ) @@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st // destructive: park it and ask. The next utterance answers. phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) h.park(dec.Slots.Fn, dec.Slots.Args, phrase) - return "выполнить «" + phrase + "»? скажи «да» или «нет»." + return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase}) case errors.Is(err, tool.ErrNotEnabled): return h.proposeGap(ctx, dec) case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer): // The row is enabled and the backend is gone. Drafting a proposal // for it (the ErrNotEnabled path) would be answering the wrong // question. - return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён." + return phraser.A(phraser.ActServerDown, nil) case errors.Is(err, mcp.ErrToolGone): - return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools." + return phraser.A(phraser.ActWithdrawn, nil) case errors.Is(err, mcp.ErrNeedsArgs): // An MCP tool that wants named arguments a spoken verb cannot // supply. Guessing them would be a wrong act, so she says so // instead — the tool is still runnable from the authed surface, // where a human types them. - return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать." + return phraser.A(phraser.ActNeedsArgs, nil) } log.Printf("voice: tool %s: %v", dec.Slots.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) } diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 867685d..a2bb47d 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -9,6 +9,7 @@ import ( "time" hexisclient "github.com/kami/hexis/pkg/client" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -144,10 +145,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p log.Printf("ecosystem: praxis attention: %v", err) h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err), started, traceErrorFields(err)) - return "не могу сейчас узнать, что требует внимания." + return phraser.A(phraser.AttentionFail, nil) } if len(items) == 0 { - return "ничего не требует внимания." + return phraser.A(phraser.AttentionNone, nil) } h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)}) var parts []string @@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } } } - return "требует внимания: " + strings.Join(parts, "; ") + return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")}) } // listChangesCapability reads the recent-changes feed. @@ -192,10 +193,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px log.Printf("ecosystem: praxis changes: %v", err) h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err), started, traceErrorFields(err)) - return "не могу сейчас узнать об изменениях." + return phraser.A(phraser.ChangesFail, nil) } if len(changes) == 0 { - return "нет изменений." + return phraser.A(phraser.ChangesNone, nil) } h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)}) var parts []string @@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px typ, _ := c["change_type"].(string) parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } - return "изменения: " + strings.Join(parts, "; ") + return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")}) } // entityAttentionCapability answers "what's going on with X" by resolving X to @@ -230,12 +231,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, subject = dec.Slots.Text } if subject == "" { - return "про что именно спросить?" + return phraser.A(phraser.EcoAboutWhat, nil) } if h.ecosystem == nil || h.ecosystem.nexus == nil { // Without Nexus there is no canonical ref to scope by. Say so rather // than quietly answering about something else. - return "не могу связать это с сущностью — Nexus не настроен." + return phraser.A(phraser.EcoNoNexus, nil) } started := h.now() @@ -248,15 +249,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) if unauthorizedEcosystemError(err) { - return "экосистема отклоняет доступ, проверь токен." + return phraser.A(phraser.EcoDenied, nil) } - return "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, nil) } if len(ambiguous) > 0 { - return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" + return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) } if entityID == "" { - return "не знаю такой сущности." + return phraser.A(phraser.EcoUnknownEntity, nil) } if displayName == "" { displayName = subject @@ -268,7 +269,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, log.Printf("ecosystem: praxis attention for %s: %v", entityID, err) h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err), queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) - return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." + return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName}) } items, scoped := scopedToEntity(items, entityID) if !scoped { @@ -279,7 +280,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID) h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried, map[string]any{"entity_id": entityID, "class": "unscoped_response"}) - return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." + return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName}) } h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{ "entity_id": entityID, "count": len(items), @@ -303,9 +304,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, parts = append(parts, known) } if len(parts) == 0 { - return "по «" + displayName + "» ничего нет." + return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName}) } - return "по «" + displayName + "»: " + strings.Join(parts, "; ") + return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")}) } // scopedToEntity drops items that carry an entity_id other than the one asked @@ -370,7 +371,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri if len(parts) == 0 { return "" } - out := "я помню: " + strings.Join(parts, ", ") + out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")}) if more { out += ", и это не всё" } @@ -520,18 +521,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)})) if unauthorizedEcosystemError(err) { - return "экосистема отклоняет доступ, проверь токен." + return phraser.A(phraser.EcoDenied, nil) } // A genuine Nexus dependency failure, not "no such entity" — stop here // and report degradation rather than silently falling through to the // local command executor (ECOSYSTEM-SPEC.md: services degrade // independently, never a silent all-clear). - return "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, nil) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, map[string]any{"candidates": len(ambiguous)}) - return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" + return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) } if entityID == "" { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started, @@ -550,9 +551,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) if unauthorizedEcosystemError(err) { - return "экосистема отклоняет доступ, проверь токен." + return phraser.A(phraser.EcoDenied, nil) } - return "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, nil) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) @@ -584,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio for _, m := range matches { names = append(names, m.Name) } - return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?" + return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")}) } matched := matches[0] @@ -602,7 +603,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.mu.Unlock() h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, map[string]any{"entity_id": entityID, "capability": matched.Name}) - return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»." + return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName}) } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) @@ -622,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI mergeFields(traceErrorFields(err), map[string]any{ "entity_id": entityID, "capability": capName, "causation_id": causationID, })) - return "не получилось выполнить команду для " + displayName + "." + return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName}) } // One record per hop: the second write this used to make said the same // thing under a different key, in a different shape. @@ -630,5 +631,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI "entity_id": entityID, "entity_name": displayName, "capability": capName, "causation_id": causationID, }) - return "команда выполнена для " + displayName + "." + return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName}) } diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go index 2e14f3a..8b73a22 100644 --- a/cmd/mavend/smarthome.go +++ b/cmd/mavend/smarthome.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strconv" "strings" "time" @@ -141,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { ents, err := w.client.States(ctx) if err != nil { log.Printf("smarthome: summary: %v", err) - return "не смогла достучаться до дома.", true + return phraser.A(phraser.HomeUnreachable, nil), true } if len(ents) == 0 { - return "дом ничего не отдаёт.", true + return phraser.A(phraser.HomeEmpty, nil), true } var on []string var sensors []string @@ -178,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { } // Silent truncation on a status read is the same failure as the cap // one layer up: she has to say the list is not the whole list. - line := "включено: " + strings.Join(shown, ", ") + line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")}) if rest > 0 { line += fmt.Sprintf(" и ещё %d", rest) } @@ -186,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { case dark > 0 && len(sensors) == 0: // Nothing is on and everything she can see is unreachable. "всё // выключено" would be a claim about the house she cannot make. - return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true + return phraser.A(phraser.HomeDark, map[string]string{ + "count": strconv.Itoa(dark), + "word": phraser.Devices(dark), + }), true default: parts = append(parts, "всё выключено") } diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go new file mode 100644 index 0000000..9290c5f --- /dev/null +++ b/internal/phraser/acts.go @@ -0,0 +1,184 @@ +package phraser + +// The act and smart-home replies — what she says when a capability ran, refused, +// or could not be reached. +// +// Fourth family on the shared deck (deck.go). They were literals in +// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was +// a rebuild of the daemon that executes his house. +// +// The four outcomes stay four entries. Reporting a refusal with the wording of +// a success is the one failure mode this family can have, and a shared variant +// set is how it would happen. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed acts_ru_v1.json +var actJSON []byte + +// ActSchemaVersion — this family's own version. +const ActSchemaVersion = 1 + +// The entry keys. +const ( + ActDone = "act_done" + ActDoneOut = "act_done_out" + ActDoneEntity = "act_done_entity" + ActConfirm = "act_confirm" + ActConfirmEntity = "act_confirm_entity" + ActWhich = "act_which" + ActFail = "act_fail" + ActFailOut = "act_fail_out" + ActFailEntity = "act_fail_entity" + ActServerDown = "act_server_down" + ActWithdrawn = "act_withdrawn" + ActNeedsArgs = "act_needs_args" + + EcoDenied = "eco_denied" + EcoDown = "eco_down" + EcoAmbiguous = "eco_ambiguous" + EcoUnknownEntity = "eco_unknown_entity" + EcoNoNexus = "eco_no_nexus" + EcoAboutWhat = "eco_about_what" + EcoRecall = "eco_recall" + + AttentionNone = "attention_none" + AttentionList = "attention_list" + AttentionFail = "attention_fail" + AttentionNoneEntity = "attention_none_entity" + AttentionListEntity = "attention_list_entity" + AttentionFailEntity = "attention_fail_entity" + ChangesNone = "changes_none" + ChangesList = "changes_list" + ChangesFail = "changes_fail" + HomeUnreachable = "home_unreachable" + HomeEmpty = "home_empty" + HomeOn = "home_on" + HomeDark = "home_dark" +) + +var actKeys = []string{ + ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich, + ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs, + EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall, + AttentionNone, AttentionList, AttentionFail, + AttentionNoneEntity, AttentionListEntity, AttentionFailEntity, + ChangesNone, ChangesList, ChangesFail, + HomeUnreachable, HomeEmpty, HomeOn, HomeDark, +} + +// actFloor — 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 actFloor = map[string]string{ + ActDone: "готово.", + ActDoneOut: "готово: {out}", + ActDoneEntity: "команда выполнена для {name}.", + ActConfirm: "выполнить «{name}»? скажи «да» или «нет».", + ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».", + ActWhich: "какую команду для {name}: {items}?", + ActFail: "не получилось выполнить команду.", + ActFailOut: "не получилось выполнить команду: {out}", + ActFailEntity: "не получилось выполнить команду для {name}.", + ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.", + ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.", + ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.", + + EcoDenied: "экосистема отклоняет доступ, проверь токен.", + EcoDown: "экосистема недоступна, попробуй ещё раз.", + EcoAmbiguous: "уточни, что именно: {items}?", + EcoUnknownEntity: "не знаю такой сущности.", + EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.", + EcoAboutWhat: "про что именно спросить?", + EcoRecall: "я помню: {items}", + + AttentionNone: "ничего не требует внимания.", + AttentionList: "требует внимания: {items}", + AttentionFail: "не могу сейчас узнать, что требует внимания.", + AttentionNoneEntity: "по «{name}» ничего нет.", + AttentionListEntity: "по «{name}»: {items}", + AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».", + ChangesNone: "нет изменений.", + ChangesList: "изменения: {items}", + ChangesFail: "не могу сейчас узнать об изменениях.", + HomeUnreachable: "не смогла достучаться до дома.", + HomeEmpty: "дом ничего не отдаёт.", + HomeOn: "включено: {items}", + HomeDark: "дом молчит: {count} {word} не отвечают.", +} + +// Acts picks a hand-written Russian act reply. Safe for concurrent use. +type Acts struct{ d *deck } + +// LoadActs reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadActs(src rand.Source) (*Acts, error) { + d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src) + if err != nil { + return nil, err + } + // The entries that name what ran or what he has to choose between. A + // variant that dropped the name would confirm an act without saying which. + for _, req := range []struct{ key, ph string }{ + {ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"}, + {ActFailEntity, "{name}"}, {ActConfirm, "{name}"}, + {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"}, + {ActWhich, "{name}"}, {ActWhich, "{items}"}, + {EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"}, + {AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"}, + {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, + {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, + } { + if err := d.requirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Acts{d: d}, nil +} + +// deck reads through a nil *Acts, which is the unloadable-file case. +func (a *Acts) deck() *deck { + if a == nil { + return floorDeck(actFloor) + } + return a.d +} + +// Say returns one line for key, with the names filled into the frame. +func (a *Acts) 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 *Acts) Variants() []string { return a.deck().variants() } + +var ( + actOnce sync.Once + actsDeck *Acts +) + +// DefaultActs returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Acts, which still answers from actFloor. +func DefaultActs() *Acts { + actOnce.Do(func() { + a, err := LoadActs(nil) + if err != nil { + log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err) + return + } + actsDeck = a + }) + return actsDeck +} + +// A — one act reply, the way every caller says it. +func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) } + +// IsA reports whether text is a line key could have produced, for the tests. +func IsA(key string, vars map[string]string, text string) bool { + return DefaultActs().deck().matches(key, vars, text) +} diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json new file mode 100644 index 0000000..afcfbc2 --- /dev/null +++ b/internal/phraser/acts_ru_v1.json @@ -0,0 +1,114 @@ +{ + "schema_version": 1, + "name": "russian act and smart-home replies v1", + "notes": [ + "What she says when a capability ran, refused, or could not be reached. 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.", + "\"it ran\", \"it was refused\", \"the ecosystem is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.", + "Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.", + "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the two lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test." + ], + "entries": { + "act_done": { + "fixed": true, + "variants": ["готово."] + }, + "act_done_out": { + "variants": ["готово: {out}", "сделала: {out}"] + }, + "act_done_entity": { + "fixed": true, + "variants": ["команда выполнена для {name}."] + }, + "act_confirm": { + "fixed": true, + "variants": ["выполнить «{name}»? скажи «да» или «нет»."] + }, + "act_confirm_entity": { + "fixed": true, + "variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."] + }, + "act_which": { + "variants": ["какую команду для {name}: {items}?"] + }, + "act_fail": { + "variants": ["не получилось выполнить команду.", "команда не выполнилась."] + }, + "act_fail_out": { + "variants": ["не получилось выполнить команду: {out}"] + }, + "act_fail_entity": { + "variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."] + }, + "act_server_down": { + "variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."] + }, + "act_withdrawn": { + "fixed": true, + "variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."] + }, + "act_needs_args": { + "variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."] + }, + "eco_denied": { + "variants": ["экосистема отклоняет доступ, проверь токен."] + }, + "eco_down": { + "variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."] + }, + "eco_ambiguous": { + "variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"] + }, + "eco_unknown_entity": { + "variants": ["не знаю такой сущности.", "такой сущности у меня нет."] + }, + "eco_no_nexus": { + "variants": ["не могу связать это с сущностью — Nexus не настроен."] + }, + "eco_about_what": { + "variants": ["про что именно спросить?", "про что спросить?"] + }, + "eco_recall": { + "variants": ["я помню: {items}"] + }, + "attention_none": { + "variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."] + }, + "attention_list": { + "variants": ["требует внимания: {items}"] + }, + "attention_fail": { + "variants": ["не могу сейчас узнать, что требует внимания."] + }, + "attention_none_entity": { + "variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."] + }, + "attention_list_entity": { + "variants": ["по «{name}»: {items}"] + }, + "attention_fail_entity": { + "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] + }, + "changes_none": { + "variants": ["нет изменений.", "изменений нет."] + }, + "changes_list": { + "variants": ["изменения: {items}"] + }, + "changes_fail": { + "variants": ["не могу сейчас узнать об изменениях."] + }, + "home_unreachable": { + "variants": ["не смогла достучаться до дома.", "дом не отвечает."] + }, + "home_empty": { + "variants": ["дом ничего не отдаёт.", "дом молчит."] + }, + "home_on": { + "variants": ["включено: {items}"] + }, + "home_dark": { + "variants": ["дом молчит: {count} {word} не отвечают."] + } + } +} diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index b4739cc..4498bd6 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,8 +8,7 @@ import ( "github.com/kami/maven/internal/phraser" ) -// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and -// query_ru_v1.json on the persona checks the nudges already pass. These lines are +// TestFallbackPersona scores every line in every hand-written line family 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. // @@ -38,6 +37,11 @@ func TestFallbackPersona(t *testing.T) { } variants := append(fb.Variants(), ack.Variants()...) variants = append(variants, qry.Variants()...) + act, err := phraser.LoadActs(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadActs: %v", err) + } + variants = append(variants, act.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } @@ -45,7 +49,8 @@ func TestFallbackPersona(t *testing.T) { // The placeholders stand for his own words and carry no persona. body := v for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", - "{location}", "{temp}", "{condition}", "{tail}"} { + "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", + "{entity}", "{count}", "{word}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") {