From d1f8a734c51cd47e50ba4c3d7b2f7cbb87837d37 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 00:41:16 +0400 Subject: [PATCH 01/28] phraser: report the failure next to the fallback (V-397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhraseChat and PhraseQuery returned canned text with a nil error, so a dead or OOM-killed server was indistinguishable from bad phrasing — "не знаю." is also a legitimate answer. Both now return the fallback text AND the error. The daemon keeps using the text, so the turn still survives; a measuring caller counts a real failure. An empty response is its own error: the model is up and said nothing. Co-Authored-By: Claude Opus 5 --- internal/phraser/failure_test.go | 72 ++++++++++++++++++++++++++++++++ internal/phraser/llmphraser.go | 39 +++++++++-------- internal/phraser/swap_test.go | 8 ++-- internal/phraser/world.go | 5 +++ 4 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 internal/phraser/failure_test.go diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go new file mode 100644 index 0000000..38d8ba9 --- /dev/null +++ b/internal/phraser/failure_test.go @@ -0,0 +1,72 @@ +package phraser + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// A dead server must be distinguishable from bad phrasing. Both PhraseChat and +// PhraseQuery keep the turn alive with canned text — "поговорили.", "не знаю.", +// "вот что я нашла: …" — and every one of those is also a legitimate reply, so +// the text alone cannot say which happened. The error is the only signal, and +// before Vikunja #397 it was dropped: the talk scorer reported a full run with +// zero errors off a server that answered nothing. +func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "model not loaded", http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + p := NewLLMPhraserAt(srv.URL, Config{}) + + cases := []struct { + name string + call func() (string, error) + want string + }{ + {"chat", func() (string, error) { + return p.PhraseChat(context.Background(), "как дела", nil) + }, "поговорили."}, + {"knowledge", func() (string, error) { + return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil) + }, "не знаю."}, + {"evidence", func() (string, error) { + return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"}) + }, "вот что я нашла: два литра"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := c.call() + if err == nil { + t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing") + } + if got != c.want { + t.Errorf("fallback text = %q, want %q — the daemon still has to say something", got, c.want) + } + }) + } +} + +// An empty answer is a failure too: the server is up and produced no tokens, +// which is not an answer and must not score as one. +func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + })) + t.Cleanup(srv.Close) + p := NewLLMPhraserAt(srv.URL, Config{}) + + got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil) + if err == nil { + t.Fatal("an empty response scored as an answer") + } + if got != "не знаю." { + t.Errorf("fallback text = %q, want \"не знаю.\"", got) + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error = %v; want it to name the empty response", err) + } +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 2c5e28d..d5f7b31 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -428,8 +428,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver } // PhraseQuery prompts the LLM with the user's utterance and matching notes to -// compose a natural answer. Falls back to "вот что я нашла: " on any -// LLM error — better to give the raw data than silence. +// compose a natural answer. On any LLM error it returns BOTH the fallback text +// ("вот что я нашла: ", or "не знаю." with no notes) AND the error, so a +// caller that wants to keep the turn alive uses the text and a caller that is +// measuring counts the failure. Until Vikunja #397 the error was swallowed and a +// dead server scored as bad phrasing. func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) { // Blank sources are no sources. A caller that hands over one empty string — // a page that fetched to nothing, a snippet trimmed away — used to take the @@ -439,13 +442,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] if len(notes) == 0 { sys, prompt := p.knowledgePrompt(utterance) resp, err := p.chatWithSystem(ctx, sys, prompt, 768) - if err != nil || resp == "" { - return "не знаю.", nil + if err != nil { + return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", err) + } + if resp == "" { + return "не знаю.", errEmptyResponse } text, _, perr := parseResponseMood(resp) if perr != nil { - log.Printf("phraser: PhraseQuery: %v", perr) - return "не знаю.", nil + return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", perr) } if text != "" { return text, nil @@ -457,13 +462,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] text, _, perr := parseResponseMood(resp) if err != nil || perr != nil { // Read the notes out rather than ship a broken fragment. - if perr != nil { - log.Printf("phraser: PhraseQuery: %v", perr) + cause := err + if cause == nil { + cause = perr } - if len(notes) == 1 { - return "вот что я нашла: " + notes[0], nil - } - return "вот что я нашла: " + strings.Join(notes, "; "), nil + return "вот что я нашла: " + strings.Join(notes, "; "), + fmt.Errorf("phrase query (evidence): %w", cause) } if text != "" { return text, nil @@ -472,8 +476,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] } // PhraseChat uses the LLM to respond conversationally, building a multi-turn -// message array from dialogue history + the current user utterance. Falls back -// to a simple greeting on any LLM error — better to say something than nothing. +// message array from dialogue history + the current user utterance. On any LLM +// error it returns both "поговорили." and the error, on the same rule as +// PhraseQuery: the fallback keeps the turn alive, the error stays visible. func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) { sys := chatSystemPrompt(p.cfg.ContextBlock) msgs := []chatMsg{ @@ -490,13 +495,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [ resp, err := p.chatWithMessages(ctx, msgs, 768) if err != nil { - log.Printf("phraser: PhraseChat: %v", err) - return "поговорили.", nil + return "поговорили.", fmt.Errorf("phrase chat: %w", err) } text, _, perr := parseResponseMood(resp) if perr != nil { - log.Printf("phraser: PhraseChat: %v", perr) - return "поговорили.", nil + return "поговорили.", fmt.Errorf("phrase chat: %w", perr) } if text != "" { return text, nil diff --git a/internal/phraser/swap_test.go b/internal/phraser/swap_test.go index aea6f39..01aa8e0 100644 --- a/internal/phraser/swap_test.go +++ b/internal/phraser/swap_test.go @@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) { if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) { t.Errorf("acquire error = %v; want ErrNoBackend", aerr) } - // Phrasing degrades to its fallback instead of failing the turn. + // Phrasing degrades to its fallback instead of failing the turn, and since + // Vikunja #397 it reports the error next to that fallback so a measuring + // caller can tell "no model" from "bad phrasing". got, err := p.PhraseChat(context.Background(), "привет", nil) - if err != nil { - t.Fatalf("PhraseChat after a total failure returned an error: %v", err) + if !errors.Is(err, ErrNoBackend) { + t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err) } if got == "" { t.Error("PhraseChat returned empty; the fallback must still say something") diff --git a/internal/phraser/world.go b/internal/phraser/world.go index a021a4b..b9ebc1e 100644 --- a/internal/phraser/world.go +++ b/internal/phraser/world.go @@ -37,6 +37,11 @@ type Remote interface { // (docs/evals/2026-08-02-workstation-gemma4-12b.md). var ErrNoWorldModel = errors.New("phraser: no world model available") +// errEmptyResponse — the server answered and said nothing. A separate error from +// a transport failure because it means the model is up and produced no tokens, +// which is still not an answer and must not score as one. +var errEmptyResponse = errors.New("phraser: empty response from the model") + // chatTemperature — what the phraser's own transport has always sampled at. // Named so the remote path cannot drift from it silently. Whether 0.7 is right // at all is Vikunja #402, and answering that here would hide a phrasing change -- 2.52.0 From b18f6085944753fc3fb232646712302178da6d31 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 00:41:16 +0400 Subject: [PATCH 02/28] mavend, eval: use the phrasing errors the phraser now returns (V-397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call sites take the fallback text and log the error instead of treating a canned string as success. phraseSource drops the text entirely — its callers hold the passage and read it back better than "вот что я нашла: ". The talk scorer's before-and-after model probe (the #395 workaround) goes; the run now fails only when every case errored, which is the honest "nothing was measured" condition. TalkFixture gets its own schema version so the two fixtures can be versioned apart. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions.go | 4 ++++ cmd/mavend/actions_query.go | 8 +++++++- cmd/mavend/worldmodel.go | 4 ++++ internal/phraser/eval/talk.go | 10 ++++++++-- internal/phraser/eval/talk_test.go | 27 +++++++++++---------------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index f6a9956..d262111 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -58,9 +58,13 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s // Conversational: build history from dialogue session (prior user turns) // and let the LLM respond from general knowledge + context. history := h.chatHistory() + // The phraser hands back its own fallback text alongside the error, so the + // turn survives a dead server and the failure still reaches the log. reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) if err != nil { log.Printf("voice: chat: %v", err) + } + if reply == "" { return "поговорили." } return reply diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c349d2d..f2b65d0 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -445,7 +445,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string // A note is phrased in Maven's voice; a fact is read back as it was // stored. if hit.Meta["type"] == "note" { - if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" { + reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}) + switch { + case perr != nil: + // Reading the note back verbatim beats the phraser's own fallback, + // which only wraps the same text in "вот что я нашла:". + log.Printf("voice: recall phrase: %v", perr) + case reply != "": return reply, true } } diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go index c6c712f..b76c63d 100644 --- a/cmd/mavend/worldmodel.go +++ b/cmd/mavend/worldmodel.go @@ -54,7 +54,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri log.Printf("voice: %s: no world model, reading the source back instead", name) return "" case err != nil: + // The resident phraser answers this call with its fallback text and the + // error together. Drop the text: these callers hold the passage itself + // and read it back better than "вот что я нашла: " does. log.Printf("voice: %s: phrase: %v", name, err) + return "" } return reply } diff --git a/internal/phraser/eval/talk.go b/internal/phraser/eval/talk.go index 6d239f8..08a4681 100644 --- a/internal/phraser/eval/talk.go +++ b/internal/phraser/eval/talk.go @@ -78,6 +78,12 @@ type TalkCase struct { Note string `json:"note,omitempty"` } +// TalkSchemaVersion — the version this loader understands. Separate from the +// nudge fixture's SchemaVersion: the two fixtures have different shapes and +// change on different days, and one shared constant would force a bump on the +// fixture that did not move. +const TalkSchemaVersion = 1 + // TalkFixture — the versioned envelope, same gating as Fixture. type TalkFixture struct { SchemaVersion int `json:"schema_version"` @@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) { if err := json.Unmarshal(talkFixtureJSON, &f); err != nil { return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err) } - if f.SchemaVersion != SchemaVersion { - return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) + if f.SchemaVersion != TalkSchemaVersion { + return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion) } if len(f.Cases) == 0 { return TalkFixture{}, fmt.Errorf("talk fixture has no cases") diff --git a/internal/phraser/eval/talk_test.go b/internal/phraser/eval/talk_test.go index 7b993c9..72b4810 100644 --- a/internal/phraser/eval/talk_test.go +++ b/internal/phraser/eval/talk_test.go @@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) { p := phraser.NewLLMPhraserAt(base, cfg) defer p.Close() - // Unreachable server is fatal here, not a logged warning, and that differs - // from the nudge test on purpose. PhraseNudge returns its errors, so a dead - // server there shows up honestly in the Errors column. PhraseChat and - // PhraseQuery do NOT: they swallow every failure and return a canned string - // ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths - // a dead server produces a full report with 0 errors and a terrible score — - // a number that looks like bad phrasing and is really no phrasing at all. - // Refusing to score without a confirmed model is the only guard available - // until the phraser reports its failures (Vikunja #397). + // The model id names the run in the report. Since Vikunja #397 every path + // returns its errors, so a server that dies mid-run shows up in the Errors + // column instead of scoring as bad phrasing — the before-and-after probe that + // used to stand in for that is gone. model, err := llm.ModelID(ctx, base) if err != nil { - t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+ - "and would report a plausible-looking result off a dead server", base, err) + t.Fatalf("no model at %s: %v", base, err) } t.Logf("scoring model %s at %s", model, base) @@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) { } t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures()) - // And again afterwards: the run takes minutes, and a server that died or got - // OOM-killed halfway through would leave the first cases scored and the rest - // silently canned. Checking only at the start would not catch that. - if _, err := llm.ModelID(ctx, base); err != nil { - t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err) + // A run where nothing was phrased is not a low score, it is no measurement. + if rep.Errors == rep.Total { + t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result") + } + if rep.Errors > 0 { + t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total) } } -- 2.52.0 From 9a70f7378b397fb0ec6c6f8c6a2fc597499b8205 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 00:47:31 +0400 Subject: [PATCH 03/28] phraser: move errEmptyResponse next to its only caller (V-397) It sat in world.go, which is about the workstation model; it is a phrasing error and belongs in llmphraser.go. Also trims the PhraseQuery doc. Co-Authored-By: Claude Opus 5 --- internal/phraser/llmphraser.go | 14 ++++++++++---- internal/phraser/world.go | 5 ----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index d5f7b31..5fcc6b5 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -26,6 +27,11 @@ import ( var listenRE = regexp.MustCompile(`listening on (https?://\S+)`) +// errEmptyResponse — the server answered and said nothing. Separate from a +// transport failure: the model is up and produced no tokens, which is still not +// an answer and must not score as one. +var errEmptyResponse = errors.New("phraser: empty response from the model") + type LLMPhraser struct { cfg Config client *http.Client @@ -428,10 +434,10 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver } // PhraseQuery prompts the LLM with the user's utterance and matching notes to -// compose a natural answer. On any LLM error it returns BOTH the fallback text -// ("вот что я нашла: ", or "не знаю." with no notes) AND the error, so a -// caller that wants to keep the turn alive uses the text and a caller that is -// measuring counts the failure. Until Vikunja #397 the error was swallowed and a +// compose a natural answer. On any LLM error it returns the fallback text — +// "вот что я нашла: ", or "не знаю." with no notes — and the error +// together. The daemon uses the text and keeps the turn alive; a caller that is +// measuring counts the failure. Until Vikunja #397 the error was dropped, so a // dead server scored as bad phrasing. func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) { // Blank sources are no sources. A caller that hands over one empty string — diff --git a/internal/phraser/world.go b/internal/phraser/world.go index b9ebc1e..a021a4b 100644 --- a/internal/phraser/world.go +++ b/internal/phraser/world.go @@ -37,11 +37,6 @@ type Remote interface { // (docs/evals/2026-08-02-workstation-gemma4-12b.md). var ErrNoWorldModel = errors.New("phraser: no world model available") -// errEmptyResponse — the server answered and said nothing. A separate error from -// a transport failure because it means the model is up and produced no tokens, -// which is still not an answer and must not score as one. -var errEmptyResponse = errors.New("phraser: empty response from the model") - // chatTemperature — what the phraser's own transport has always sampled at. // Named so the remote path cannot drift from it silently. Whether 0.7 is right // at all is Vikunja #402, and answering that here would hide a phrasing change -- 2.52.0 From c47881106e20dfd6346b6d76c5622958505f37c3 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 00:50:46 +0400 Subject: [PATCH 04/28] =?UTF-8?q?phraser:=20say=20"=D0=B4=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=8E,=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D1=81=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C"=20wh?= =?UTF-8?q?en=20there=20is=20nothing=20to=20say=20(V-397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #108: "поговорили." reads as a summary of a conversation that did not happen. One exported constant now, so the Stub, the LLMPhraser fallback and the daemon all say the same thing. internal/voice/replier.go keeps its own copy — that is the separate replier seam, not this one. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions.go | 3 ++- internal/phraser/failure_test.go | 4 ++-- internal/phraser/llmphraser.go | 6 +++--- internal/phraser/phraser.go | 8 +++++++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index d262111..e576e20 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -40,6 +40,7 @@ import ( "context" "log" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -65,7 +66,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s log.Printf("voice: chat: %v", err) } if reply == "" { - return "поговорили." + return phraser.ChatFallback } return reply } diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 38d8ba9..687bab8 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -9,7 +9,7 @@ import ( ) // A dead server must be distinguishable from bad phrasing. Both PhraseChat and -// PhraseQuery keep the turn alive with canned text — "поговорили.", "не знаю.", +// PhraseQuery keep the turn alive with canned text — ChatFallback, "не знаю.", // "вот что я нашла: …" — and every one of those is also a legitimate reply, so // the text alone cannot say which happened. The error is the only signal, and // before Vikunja #397 it was dropped: the talk scorer reported a full run with @@ -28,7 +28,7 @@ func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) { }{ {"chat", func() (string, error) { return p.PhraseChat(context.Background(), "как дела", nil) - }, "поговорили."}, + }, ChatFallback}, {"knowledge", func() (string, error) { return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil) }, "не знаю."}, diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 5fcc6b5..b019eac 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -483,7 +483,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] // PhraseChat uses the LLM to respond conversationally, building a multi-turn // message array from dialogue history + the current user utterance. On any LLM -// error it returns both "поговорили." and the error, on the same rule as +// error it returns both ChatFallback and the error, on the same rule as // PhraseQuery: the fallback keeps the turn alive, the error stays visible. func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) { sys := chatSystemPrompt(p.cfg.ContextBlock) @@ -501,11 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [ resp, err := p.chatWithMessages(ctx, msgs, 768) if err != nil { - return "поговорили.", fmt.Errorf("phrase chat: %w", err) + return ChatFallback, fmt.Errorf("phrase chat: %w", err) } text, _, perr := parseResponseMood(resp) if perr != nil { - return "поговорили.", fmt.Errorf("phrase chat: %w", perr) + return ChatFallback, fmt.Errorf("phrase chat: %w", perr) } if text != "" { return text, nil diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index 79488d2..5918ac9 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -66,11 +66,17 @@ type Stub struct{} // NewStub builds the floor phraser. no config — the Stub is stateless. func NewStub() *Stub { return &Stub{} } +// ChatFallback — what she says on the chat path when the model gave her +// nothing to say. It replaced "поговорили.", which reads as a summary of a +// conversation that did not happen. Said out loud this one is an admission, +// which is what it is. +const ChatFallback = "даже не знаю, что сказать." + // PhraseChat returns a stub reply — the LLMPhraser replaces this with a // prompted response from the model. The history parameter is accepted but // ignored at the stub level (the production impl uses it for multi-turn). func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) { - return "поговорили.", nil + return ChatFallback, nil } // PhraseQuery returns a deterministic summary of the best matching notes. -- 2.52.0 From 4fdce3ca2c9d84bb30aee3add5fca1a17df285ab Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:19:41 +0400 Subject: [PATCH 05/28] phraser: put the phrasing fallbacks in a versioned json (V-501) Four lines he hears out loud lived as string literals in three Go files, so rewording one meant a rebuild. They move to fallbacks_ru_v1.json on the shape nudges_ru_v1.json already uses: embedded, schema-versioned, several variants, never the same one twice running. The gap phrase is marked fixed, because it names one specific missing model and must not drift into a general "I do not know". Every accessor falls back to the literal it replaced, including on a nil receiver: these strings exist because something already failed, so a broken template file must not take her last words away. --- internal/phraser/fallbacks.go | 238 ++++++++++++++++++++++++++ internal/phraser/fallbacks_ru_v1.json | 42 +++++ 2 files changed, 280 insertions(+) create mode 100644 internal/phraser/fallbacks.go create mode 100644 internal/phraser/fallbacks_ru_v1.json diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go new file mode 100644 index 0000000..b047d60 --- /dev/null +++ b/internal/phraser/fallbacks.go @@ -0,0 +1,238 @@ +package phraser + +// The phrasing fallbacks — what she says when the model gave her nothing usable. +// +// 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 +// words she has: every accessor falls back to the literal it replaced. + +import ( + _ "embed" + "encoding/json" + "fmt" + "log" + "math/rand" + "strings" + "sync" + "time" +) + +//go:embed fallbacks_ru_v1.json +var fallbackJSON []byte + +// FallbackSchemaVersion — the version this code understands. Its own constant, +// not shared with the nudge templates or the eval fixtures: two files that change +// on different days cannot be versioned by one number (Vikunja #397). +const FallbackSchemaVersion = 1 + +// The entry keys. Every one of them is read by a method below, so a typo in the +// file is caught at load rather than at the moment she needs the words. +const ( + fbChat = "chat" + fbQueryUnknown = "query_unknown" + fbQuerySources = "query_sources" + fbWorldGap = "world_gap" +) + +// fbKeys — every key the code requires the file to define. +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{ + 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 +} + +// 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) + } + if f.SchemaVersion != FallbackSchemaVersion { + return nil, fmt.Errorf("fallbacks: schema_version %d, want %d", + f.SchemaVersion, FallbackSchemaVersion) + } + 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 +} + +// 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) + } + } + 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 +} + +// Chat — nothing usable came back on the chat path. +func (f *Fallbacks) Chat() string { return f.text(fbChat, "") } + +// Unknown — a question she cannot answer and will not guess at. +func (f *Fallbacks) Unknown() string { return f.text(fbQueryUnknown, "") } + +// FromSources — read back what she was handed, because phrasing it failed. +func (f *Fallbacks) FromSources(sources string) string { + return f.text(fbQuerySources, 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, "") } + +// 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 +} + +// 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 +// one — and because a template file that is embedded and validated at load has +// nothing per-instance to configure. +var ( + fallbackOnce sync.Once + fallbacks *Fallbacks +) + +// DefaultFallbacks returns the shared instance, loading it on first use. A +// broken file logs once and leaves a nil *Fallbacks, which still answers from +// hardFloor — a daemon must not fail to boot over its own copy deck. +func DefaultFallbacks() *Fallbacks { + fallbackOnce.Do(func() { + fb, err := LoadFallbacks(nil) + if err != nil { + log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err) + return + } + fallbacks = fb + }) + return fallbacks +} + +// ChatFallback — what she says when the chat path produced nothing. +func ChatFallback() string { return DefaultFallbacks().Chat() } + +// UnknownFallback — what she says when she has no answer and will not invent one. +func UnknownFallback() string { return DefaultFallbacks().Unknown() } + +// SourcesFallback — read the sources back rather than ship a broken fragment. +func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) } + +// 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) +} + +// IsSourcesFallback reports whether text is sources read back verbatim. +func IsSourcesFallback(text, sources string) bool { + return DefaultFallbacks().matches(fbQuerySources, sources, text) +} diff --git a/internal/phraser/fallbacks_ru_v1.json b/internal/phraser/fallbacks_ru_v1.json new file mode 100644 index 0000000..e225889 --- /dev/null +++ b/internal/phraser/fallbacks_ru_v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "name": "russian phrasing fallbacks v1", + "notes": [ + "What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.", + "These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.", + "Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.", + "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns." + ], + "entries": { + "chat": { + "variants": [ + "даже не знаю, что сказать.", + "не могу найти слов.", + "мысль ускользнула, повтори?", + "у меня сейчас пусто в голове." + ] + }, + "query_unknown": { + "variants": [ + "не знаю.", + "не знаю, честно.", + "тут я пас.", + "не скажу, не знаю." + ] + }, + "query_sources": { + "variants": [ + "вот что я нашла: {sources}", + "нашла вот это: {sources}", + "есть только это: {sources}" + ] + }, + "world_gap": { + "fixed": true, + "variants": [ + "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." + ] + } + } +} -- 2.52.0 From 865623ef3e93d449697a68962abea62c169835ac Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:19:41 +0400 Subject: [PATCH 06/28] phraser, mavend: read the fallbacks from the file (V-501) The accessors are functions now, so the call sites that compared against one literal compare against the entry instead: IsUnknownFallback and IsSourcesFallback in the daemon tests, the entry key in the phraser tests. A reworded variant no longer breaks a Go test. The eval scores every variant on the persona checks the nudges already pass. --- cmd/mavend/actions.go | 2 +- cmd/mavend/actions_query.go | 2 +- cmd/mavend/query_recall_test.go | 6 +-- cmd/mavend/worldmodel.go | 6 ++- cmd/mavend/worldmodel_test.go | 12 +++--- internal/phraser/eval/fallbacks_test.go | 40 +++++++++++++++++ internal/phraser/failure_test.go | 47 +++++++++++++------- internal/phraser/fallbacks_test.go | 57 +++++++++++++++++++++++++ internal/phraser/llmphraser.go | 12 +++--- internal/phraser/phraser.go | 15 ++----- 10 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 internal/phraser/eval/fallbacks_test.go create mode 100644 internal/phraser/fallbacks_test.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index e576e20..4145676 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -66,7 +66,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s log.Printf("voice: chat: %v", err) } if reply == "" { - return phraser.ChatFallback + return phraser.ChatFallback() } return reply } diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f2b65d0..42cb70f 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -798,7 +798,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) if errors.Is(err, phraser.ErrNoWorldModel) { log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance) - return worldGap, true + return worldGap(), true } if err != nil || reply == "" { return "не знаю.", true diff --git a/cmd/mavend/query_recall_test.go b/cmd/mavend/query_recall_test.go index b97c436..431f333 100644 --- a/cmd/mavend/query_recall_test.go +++ b/cmd/mavend/query_recall_test.go @@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) { {text: "выучил пару аккордов", score: 0.50, kind: "note"}, }) reply := askQuery(t, h, q) - if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want { - t.Errorf("reply %q, want %q", reply, want) + if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") { + t.Errorf("reply %q, want the note read back", reply) } // One text, the winning memory's — the answer came from the memory // pass, not from handing the phraser every note in the table. @@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) { {text: "молоко стоит в холодильнике", score: 0.860, kind: "note"}, {text: "молоко закончилось", score: 0.858, kind: "note"}, }) - if reply := askQuery(t, h, q); reply != "не знаю." { + if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) { t.Errorf("reply %q, want silence", reply) } }) diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go index b76c63d..bf7072c 100644 --- a/cmd/mavend/worldmodel.go +++ b/cmd/mavend/worldmodel.go @@ -23,7 +23,11 @@ type worldPhraser interface { // question about his meeting came back as a swimming competition in Nottingham. // Naming the gap is the rule CLAUDE.md already applies to a sibling service // being down. -const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." +// +// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from +// variants: this sentence names one specific gap and must not drift into a +// general "I don't know". +func worldGap() string { return phraser.WorldGap() } // phraseWorld asks the world model, or reports the gap. // diff --git a/cmd/mavend/worldmodel_test.go b/cmd/mavend/worldmodel_test.go index f8d0ad1..2d7b9d7 100644 --- a/cmd/mavend/worldmodel_test.go +++ b/cmd/mavend/worldmodel_test.go @@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) { if !ok { t.Fatal("queryGeneral passed on the last source in the chain") } - if reply != worldGap { + if reply != worldGap() { t.Fatalf("reply = %q, want the named gap", reply) } if g.worldCalls != 1 { @@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) { if !ok { t.Fatal("queryGeneral passed on the last source in the chain") } - if reply != "не знаю." { + if !phraser.IsUnknownFallback(reply) { t.Fatalf("reply = %q, want the Stub's answer", reply) } } @@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) { // English in it. func TestWorldGapIsInPersona(t *testing.T) { for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} { - if strings.Contains(worldGap, bad) { - t.Errorf("the gap phrase contains %q: %s", bad, worldGap) + if strings.Contains(worldGap(), bad) { + t.Errorf("the gap phrase contains %q: %s", bad, worldGap()) } } - if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") { - t.Errorf("the gap phrase has Latin letters in it: %s", worldGap) + if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") { + t.Errorf("the gap phrase has Latin letters in it: %s", worldGap()) } } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go new file mode 100644 index 0000000..3794a54 --- /dev/null +++ b/internal/phraser/eval/fallbacks_test.go @@ -0,0 +1,40 @@ +package eval + +import ( + "math/rand" + "strings" + "testing" + + "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. +// +// 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. +func TestFallbackPersona(t *testing.T) { + fb, err := phraser.LoadFallbacks(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + persona := map[string]bool{ + CheckLang: true, CheckFeminine: true, CheckHisGender: true, + CheckAddress: true, CheckCringe: true, CheckLength: true, + } + variants := fb.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}", "два литра") + 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 687bab8..5afbd5c 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -8,12 +8,28 @@ import ( "testing" ) +// isFallback — the text she says is picked from that entry's variants, so a test +// pins the entry rather than the wording. Pinning one line would make editing +// 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 +} + // A dead server must be distinguishable from bad phrasing. Both PhraseChat and -// PhraseQuery keep the turn alive with canned text — ChatFallback, "не знаю.", -// "вот что я нашла: …" — and every one of those is also a legitimate reply, so -// the text alone cannot say which happened. The error is the only signal, and -// before Vikunja #397 it was dropped: the talk scorer reported a full run with -// zero errors off a server that answered nothing. +// PhraseQuery keep the turn alive with canned text — and every one of those +// lines is also a legitimate reply, so the text alone cannot say which happened. +// The error is the only signal, and before Vikunja #397 it was dropped: the talk +// scorer reported a full run with zero errors off a server that answered nothing. func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "model not loaded", http.StatusServiceUnavailable) @@ -22,19 +38,20 @@ func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) { p := NewLLMPhraserAt(srv.URL, Config{}) cases := []struct { - name string - call func() (string, error) - want string + name string + call func() (string, error) + key string + sources string }{ {"chat", func() (string, error) { return p.PhraseChat(context.Background(), "как дела", nil) - }, ChatFallback}, + }, fbChat, ""}, {"knowledge", func() (string, error) { return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil) - }, "не знаю."}, + }, fbQueryUnknown, ""}, {"evidence", func() (string, error) { return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"}) - }, "вот что я нашла: два литра"}, + }, fbQuerySources, "два литра"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -42,8 +59,8 @@ func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) { if err == nil { t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing") } - if got != c.want { - t.Errorf("fallback text = %q, want %q — the daemon still has to say something", got, c.want) + if !isFallback(t, c.key, c.sources, got) { + t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key) } }) } @@ -63,8 +80,8 @@ func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) { if err == nil { t.Fatal("an empty response scored as an answer") } - if got != "не знаю." { - t.Errorf("fallback text = %q, want \"не знаю.\"", got) + if !isFallback(t, fbQueryUnknown, "", got) { + t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown) } if !strings.Contains(err.Error(), "empty") { t.Errorf("error = %v; want it to name the empty response", err) diff --git a/internal/phraser/fallbacks_test.go b/internal/phraser/fallbacks_test.go new file mode 100644 index 0000000..ecec0ec --- /dev/null +++ b/internal/phraser/fallbacks_test.go @@ -0,0 +1,57 @@ +package phraser + +import ( + "math/rand" + "strings" + "testing" +) + +// The embedded file must load, or the daemon speaks from hardFloor and nobody +// finds out until he hears the wrong words. +func TestFallbacksLoad(t *testing.T) { + fb, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") { + t.Errorf("FromSources = %q, want the sources in it", got) + } + if fb.WorldGap() != hardFloor[fbWorldGap] { + t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap]) + } +} + +// A broken or missing file must not take her last words away: every accessor +// answers from the literal it replaced. +func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) { + var fb *Fallbacks + if got := fb.Chat(); got != hardFloor[fbChat] { + t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat]) + } + if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] { + t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown]) + } + if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" { + t.Errorf("FromSources = %q", got) + } + if got := fb.WorldGap(); got != hardFloor[fbWorldGap] { + t.Errorf("WorldGap = %q", got) + } +} + +// Hearing the identical words every time a request fails is how a failure stops +// registering as one. +func TestFallbacksDoNotRepeat(t *testing.T) { + fb, err := LoadFallbacks(rand.NewSource(7)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + prev := fb.Chat() + for i := 0; i < 20; i++ { + got := fb.Chat() + if got == prev { + t.Fatalf("chat repeated %q on turn %d", got, i) + } + prev = got + } +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index b019eac..b06ecac 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -449,14 +449,14 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] sys, prompt := p.knowledgePrompt(utterance) resp, err := p.chatWithSystem(ctx, sys, prompt, 768) if err != nil { - return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", err) + return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err) } if resp == "" { - return "не знаю.", errEmptyResponse + return UnknownFallback(), errEmptyResponse } text, _, perr := parseResponseMood(resp) if perr != nil { - return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", perr) + return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr) } if text != "" { return text, nil @@ -472,7 +472,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] if cause == nil { cause = perr } - return "вот что я нашла: " + strings.Join(notes, "; "), + return SourcesFallback(strings.Join(notes, "; ")), fmt.Errorf("phrase query (evidence): %w", cause) } if text != "" { @@ -501,11 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [ resp, err := p.chatWithMessages(ctx, msgs, 768) if err != nil { - return ChatFallback, fmt.Errorf("phrase chat: %w", err) + return ChatFallback(), fmt.Errorf("phrase chat: %w", err) } text, _, perr := parseResponseMood(resp) if perr != nil { - return ChatFallback, fmt.Errorf("phrase chat: %w", perr) + return ChatFallback(), fmt.Errorf("phrase chat: %w", perr) } if text != "" { return text, nil diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index 5918ac9..8fb9130 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -66,28 +66,19 @@ type Stub struct{} // NewStub builds the floor phraser. no config — the Stub is stateless. func NewStub() *Stub { return &Stub{} } -// ChatFallback — what she says on the chat path when the model gave her -// nothing to say. It replaced "поговорили.", which reads as a summary of a -// conversation that did not happen. Said out loud this one is an admission, -// which is what it is. -const ChatFallback = "даже не знаю, что сказать." - // PhraseChat returns a stub reply — the LLMPhraser replaces this with a // prompted response from the model. The history parameter is accepted but // ignored at the stub level (the production impl uses it for multi-turn). func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) { - return ChatFallback, nil + return ChatFallback(), nil } // PhraseQuery returns a deterministic summary of the best matching notes. func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) { if len(notes) == 0 { - return "не знаю.", nil + return UnknownFallback(), nil } - if len(notes) == 1 { - return "вот что я нашла: " + notes[0], nil - } - return "вот что я нашла: " + strings.Join(notes, "; "), nil + return SourcesFallback(strings.Join(notes, "; ")), nil } // Close implements Phraser.Close (no-op for the stub). -- 2.52.0 From 3f2782f5b7aa1cb35417cd6e1ada6a6cd0f698ad Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 07/28] phraser: add the shared deck for hand-written line families (V-502) Every family of hand-written Russian lines wants the same mechanics: a schema-versioned embedded file, variants with anti-repeat picking, and a floor of Go literals under it. The acknowledgements are the second family, and copying eighty lines of loader per family was not going to survive five of them. Each family keeps its own file, keys, floor, validation and accessor names. --- internal/phraser/deck.go | 181 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 internal/phraser/deck.go 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 +} -- 2.52.0 From 1c9ddbbea29cdec1360903f7edbc21c1b9d41c25 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 08/28] phraser: move the fallbacks onto the deck (V-502) --- internal/phraser/failure_test.go | 11 +-- internal/phraser/fallbacks.go | 146 +++++-------------------------- 2 files changed, 24 insertions(+), 133 deletions(-) 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) } -- 2.52.0 From dae123adac9a3f90573c9ae9f91d83f00c72d7f6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 09/28] phraser: put the capture acknowledgements in a versioned json (V-502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What she says after storing something he said, and what she says when storing it failed. They were literals in eight files under cmd/mavend and the stub replier. He hears these many times a day, which is why most entries carry variants: identical wording is what makes a confirmation stop registering as one. The quiet-mode lines are fixed — they report a state, and a state report that reworded itself would read as a different state. His data stays Go-side. The file holds "отметила: {key} = {value}"; nothing he said lives in the copy. --- internal/phraser/ack_ru_v1.json | 84 +++++++++++++++++ internal/phraser/acks.go | 162 ++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 internal/phraser/ack_ru_v1.json create mode 100644 internal/phraser/acks.go 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) +} -- 2.52.0 From b2521988e1053df3511171f104da9105434760e0 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH 10/28] mavend, voice: say the acknowledgements from the file (V-502) The daemon tests that compared against one literal ask the entry instead: IsAck names the line she could have said without pinning the wording. The eval scores every ack variant on the persona checks the nudges already pass. --- cmd/mavend/ack.go | 5 +++-- cmd/mavend/actions_fact.go | 5 +++-- cmd/mavend/actions_note.go | 5 +++-- cmd/mavend/actions_reminder.go | 5 +++-- cmd/mavend/actions_task.go | 9 +++++---- cmd/mavend/actions_task_test.go | 3 ++- cmd/mavend/quiet_toggle.go | 7 ++++--- cmd/mavend/replier_llm_test.go | 15 +++++++++++++-- cmd/mavend/snooze.go | 5 +++-- internal/phraser/eval/fallbacks_test.go | 23 +++++++++++++++-------- internal/phraser/fallbacks_test.go | 17 +++++++++++++++++ internal/voice/replier.go | 22 +++++++++++----------- 12 files changed, 82 insertions(+), 39 deletions(-) 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/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/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) } } -- 2.52.0 From ae8d38fc3106e6564ef91e3813e5321be316f706 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH 11/28] phraser: put the query answers and gaps in a versioned json (V-503) What a query source says when it answers from something other than the model, and what it says when it has nothing. Two dozen of them lived in actions_query.go alone. Every gap keeps its own entry. "The feeds are not configured", "the search failed" and "I do not know" are different truths, and one variant set would let them answer for each other. The personal boundary and the refusal to re-ask a question for another day are fixed: both are load-bearing wording. query_unknown is not the phraser fallback that reads the same. Here she looked and found nothing; there she failed to phrase an answer she had. --- internal/phraser/query.go | 162 ++++++++++++++++++++++++++++++ internal/phraser/query_ru_v1.json | 93 +++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 internal/phraser/query.go create mode 100644 internal/phraser/query_ru_v1.json diff --git a/internal/phraser/query.go b/internal/phraser/query.go new file mode 100644 index 0000000..638eefd --- /dev/null +++ b/internal/phraser/query.go @@ -0,0 +1,162 @@ +package phraser + +// The query answers and gaps — what a query source says when it answers from +// something other than the model, and what it says when it has nothing. +// +// Third family on the shared deck (deck.go), after the fallbacks and the +// acknowledgements. They were literals spread across actions_query.go and +// netscan.go, where the largest single site held two dozen of them. +// +// QueryUnknown is not the phraser's UnknownFallback, even though the two read +// the same today. Here she looked and found nothing; there she failed to phrase +// an answer she had. Two files, two entries, so rewording one leaves the other. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed query_ru_v1.json +var queryJSON []byte + +// QuerySchemaVersion — this family's own version. +const QuerySchemaVersion = 1 + +// The entry keys. +const ( + QueryUnknown = "query_unknown" + QueryOtherDay = "other_day" + QueryPersonalNone = "personal_none" + QueryFactWhen = "fact_when" + QueryFactValue = "fact_value" + QueryFound = "found" + QueryPageText = "page_text" + QueryPageBlocked = "page_blocked" + QueryPageEmpty = "page_empty" + QueryFeedsOff = "feeds_off" + QueryFeedsNew = "feeds_new" + QueryFeedsEmpty = "feeds_empty" + QueryFeedsTopic = "feeds_empty_topic" + QueryWeatherNow = "weather_now" + QueryWeatherOff = "weather_off" + QueryWeatherWhere = "weather_nolocation" + QueryNetEmpty = "net_empty" + + QueryFailPlan = "fail_plan" + QueryFailNotes = "fail_notes" + QueryFailFeeds = "fail_feeds" + QueryFailCalendar = "fail_calendar" + QueryFailWeather = "fail_weather" + QueryFailAnswer = "fail_answer" + QueryFailPage = "fail_page" + QueryFailNetscan = "fail_netscan" +) + +var queryKeys = []string{ + QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue, + QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty, + QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic, + QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, + QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, + QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, +} + +// queryFloor — 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 queryFloor = registerFloor(map[string]string{ + QueryUnknown: "не знаю.", + QueryOtherDay: "про другой день так не отвечу — спроси целиком.", + QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", + QueryFactWhen: "я записала это {when}", + QueryFactValue: "вот что я знаю: {key} — {value}", + QueryFound: "вот что я нашла: {text}", + QueryPageText: "вот что на странице: {text}", + QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", + QueryPageEmpty: "страница открылась, но читать там нечего.", + QueryFeedsOff: "я пока не читаю ленты — они не настроены.", + QueryFeedsNew: "вот что нового: {items}", + QueryFeedsEmpty: "в лентах пока ничего нового.", + QueryFeedsTopic: "по этой теме в лентах пока ничего.", + QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.", + QueryWeatherOff: "погода не настроена.", + QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", + QueryNetEmpty: "в сети никого не нашла{tail}.", + + QueryFailPlan: "не получилось собрать план.", + QueryFailNotes: "не получилось посмотреть записи.", + QueryFailFeeds: "не получилось посмотреть ленты.", + QueryFailCalendar: "не получилось проверить календарь.", + QueryFailWeather: "не получилось узнать погоду.", + QueryFailAnswer: "не получилось найти ответ.", + QueryFailPage: "не получилось прочитать страницу.", + QueryFailNetscan: "не получилось просканировать сеть.", +}) + +// Queries picks a hand-written Russian query line. Safe for concurrent use. +type Queries struct{ d *deck } + +// LoadQueries reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadQueries(src rand.Source) (*Queries, error) { + d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) + if err != nil { + return nil, err + } + // The entries that exist to read something back. A variant without the + // placeholder would answer the question by dropping the answer. + for _, req := range []struct{ key, ph string }{ + {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"}, + {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, + {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, + } { + if err := d.requirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Queries{d: d}, nil +} + +// deck reads through a nil *Queries, which is the unloadable-file case. +func (q *Queries) deck() *deck { + if q == nil { + return nil + } + return q.d +} + +// Say returns one line for key, with the values filled into the frame. +func (q *Queries) Say(key string, vars map[string]string) string { + return q.deck().text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (q *Queries) Variants() []string { return q.deck().variants() } + +var ( + queryOnce sync.Once + queries *Queries +) + +// DefaultQueries returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Queries, which still answers from queryFloor. +func DefaultQueries() *Queries { + queryOnce.Do(func() { + q, err := LoadQueries(nil) + if err != nil { + log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err) + return + } + queries = q + }) + return queries +} + +// Q — one query line, the way every caller says it. +func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) } + +// IsQ reports whether text is a line key could have produced, for the tests. +func IsQ(key string, vars map[string]string, text string) bool { + return DefaultQueries().deck().matches(key, vars, text) +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json new file mode 100644 index 0000000..249294e --- /dev/null +++ b/internal/phraser/query_ru_v1.json @@ -0,0 +1,93 @@ +{ + "schema_version": 1, + "name": "russian query answers and gaps v1", + "notes": [ + "What a query source says when it answers from something other than the model, and what it says when it has nothing. 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.", + "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.", + "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day." + ], + "entries": { + "query_unknown": { + "variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."] + }, + "other_day": { + "fixed": true, + "variants": ["про другой день так не отвечу — спроси целиком."] + }, + "personal_none": { + "fixed": true, + "variants": ["не знаю — не нашла у тебя такой записи."] + }, + "fact_when": { + "variants": ["я записала это {when}", "записала это {when}"] + }, + "fact_value": { + "variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"] + }, + "found": { + "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] + }, + "page_text": { + "variants": ["вот что на странице: {text}", "на странице вот это: {text}"] + }, + "page_blocked": { + "fixed": true, + "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] + }, + "page_empty": { + "variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."] + }, + "feeds_off": { + "variants": ["я пока не читаю ленты — они не настроены."] + }, + "feeds_new": { + "variants": ["вот что нового: {items}", "нового вот что: {items}"] + }, + "feeds_empty": { + "variants": ["в лентах пока ничего нового.", "в лентах тихо."] + }, + "feeds_empty_topic": { + "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] + }, + "weather_now": { + "variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + }, + "weather_off": { + "variants": ["погода не настроена."] + }, + "weather_nolocation": { + "fixed": true, + "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + }, + "net_empty": { + "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] + }, + "fail_plan": { + "variants": ["не получилось собрать план.", "план не собрался."] + }, + "fail_notes": { + "variants": ["не получилось посмотреть записи.", "записи не открылись."] + }, + "fail_feeds": { + "variants": ["не получилось посмотреть ленты.", "ленты не открылись."] + }, + "fail_calendar": { + "variants": ["не получилось проверить календарь.", "календарь не открылся."] + }, + "fail_weather": { + "variants": ["не получилось узнать погоду.", "погода не пришла."] + }, + "fail_answer": { + "variants": ["не получилось найти ответ.", "ответ не нашёлся."] + }, + "fail_page": { + "variants": ["не получилось прочитать страницу.", "страница не прочиталась."] + }, + "fail_netscan": { + "variants": ["не получилось просканировать сеть.", "сеть не просканировалась."] + } + } +} -- 2.52.0 From 16d94894b7b902daec4c4d0745d85e691905c594 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH 12/28] mavend: say the query answers from the file (V-503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three daemon tests that pinned a wording ask the entry instead. The eval scores every query variant on the persona checks, minus hisgender: it reads her own feminine verb next to "у тебя" as addressing him as a woman. --- cmd/mavend/actions_query.go | 60 +++++++++++++------------ cmd/mavend/crawls_test.go | 2 +- cmd/mavend/dayplan_test.go | 5 ++- cmd/mavend/feeds_test.go | 2 +- cmd/mavend/netscan.go | 5 ++- internal/phraser/eval/fallbacks_test.go | 18 ++++++-- 6 files changed, 54 insertions(+), 38 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 42cb70f..f019816 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // The previous question cannot be re-asked for another day. Saying so // beats "не знаю", which reads as "no data for tomorrow" when the // truth is that she never looked. - return "про другой день так не отвечу — спроси целиком." + return phraser.Q(phraser.QueryOtherDay, nil) } - return "не знаю." + return phraser.Q(phraser.QueryUnknown, nil) } // queryFactByKey — when the dialogue layer resolved an anaphoric reference to @@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str if dec.Slots.HasTime { // The query asks about timing — the fact's own timestamp is the // answer it's looking for. Format as a natural reply. - return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true + return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true } // General fact reference: describe what we know. if dec.Utterance == "" { - return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true + return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true } // The utterance still carries the question; fall through to normal RAG // with the resolved key in context. @@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin plan, err := h.api.DayPlan(ctx) if err != nil { log.Printf("voice: day plan: %v", err) - return "не получилось собрать план.", true + return phraser.Q(phraser.QueryFailPlan, nil), true } if !router.IsRestOfDayQuery(t.dec.Utterance) { return plan.Spoken, true @@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow) if err != nil { log.Printf("voice: habits: recent facts: %v", err) - return "не получилось посмотреть записи.", true + return phraser.Q(phraser.QueryFailNotes, nil), true } obs := make([]memory.Observation, 0, len(facts)) for _, f := range facts { @@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, // Claim the turn rather than fall through: "не читаю ленты" is true, and // letting general knowledge answer "что нового?" would be an invented // news bulletin. - return "я пока не читаю ленты — они не настроены.", true + return phraser.Q(phraser.QueryFeedsOff, nil), true } // By source, not the last 200 notes of any kind: a busy day of voice notes // used to push the newest headline out of the window, and she answered "в @@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) - return "не получилось посмотреть ленты.", true + return phraser.Q(phraser.QueryFailFeeds, nil), true } var picked []string for _, n := range notes { @@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, } if len(picked) == 0 { if q.Category != "" { - return "по этой теме в лентах пока ничего.", true + return phraser.Q(phraser.QueryFeedsTopic, nil), true } - return "в лентах пока ничего нового.", true + return phraser.Q(phraser.QueryFeedsEmpty, nil), true } - return "вот что нового: " + strings.Join(picked, "; "), true + return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true } // queryCalendar — "что у меня сегодня?", "планы на завтра?" @@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) - return "не получилось проверить календарь.", true + return phraser.Q(phraser.QueryFailCalendar, nil), true } // Provenance travels with each event. A work meeting relayed off a phone // notification (source ambient:notif, #126) is stored below full confidence @@ -382,19 +382,23 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin if loc == "" { // He named no city and voice.weather.default_location is unset. Saying // so is the only honest answer; picking a city would be inventing one. - return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true + return phraser.Q(phraser.QueryWeatherWhere, nil), true } ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { - return "погода не настроена.", true + return phraser.Q(phraser.QueryWeatherOff, nil), true } if err != nil { log.Printf("voice: weather: %v", err) - return "не получилось узнать погоду.", true + return phraser.Q(phraser.QueryFailWeather, nil), true } - return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true + return phraser.Q(phraser.QueryWeatherNow, map[string]string{ + "location": w.Location, + "temp": fmt.Sprintf("%.0f", w.Temperature), + "condition": w.Condition, + }), true } // queryEmbed isn't an answer source — it's the shared cost the two recall @@ -404,7 +408,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.vec = vec return "", false @@ -473,7 +477,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, notes, err := h.api.QueryNotes(ctx, t.vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.notes = notes noteScores := make([]float64, len(notes)) @@ -498,7 +502,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, log.Printf("voice: phrase query: %v", err) } if reply == "" { - reply = "вот что я нашла: " + texts[0] + reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]}) } return reply, true } @@ -532,13 +536,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b page, err := h.crawler.Page(ctxFetch, link) if err != nil { if errors.Is(err, crawl.ErrRobots) { - return "эта страница закрыта для чтения — robots.txt не разрешает.", true + return phraser.Q(phraser.QueryPageBlocked, nil), true } log.Printf("voice: web: %v", err) - return "не получилось прочитать страницу.", true + return phraser.Q(phraser.QueryFailPage, nil), true } if page.Text == "" { - return "страница открылась, но читать там нечего.", true + return phraser.Q(phraser.QueryPageEmpty, nil), true } // The page is handed to the phraser the same way a note is: as context for // the question he actually asked. She answers the question, she does not @@ -548,7 +552,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. - return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true + return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true } return reply, true } @@ -614,7 +618,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true } return reply, true } @@ -695,7 +699,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true } return reply, true } @@ -725,7 +729,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri return "", false } log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) - return "не знаю — не нашла у тебя такой записи.", true + return phraser.Q(phraser.QueryPersonalNone, nil), true } // personalMarkers — first-person POSSESSION, not first person generally. @@ -793,7 +797,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin if h.phraser == nil { // No model of any size. That is not the workstation being asleep, so it // is not that gap: it is simply not knowing. - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) if errors.Is(err, phraser.ErrNoWorldModel) { @@ -801,7 +805,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin return worldGap(), true } if err != nil || reply == "" { - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } return reply, true } diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 5055880..4423355 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) { if !ok { t.Fatal("the web source did not claim a question with a URL") } - if !strings.Contains(reply, "не получилось") { + if !phraser.IsQ(phraser.QueryFailPage, nil, reply) { t.Errorf("reply = %q, want the read-failed answer", reply) } } diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 0fd511c..52a0aa9 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -10,6 +10,7 @@ import ( "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) { if !ok { t.Fatal("a failed plan read must still answer, not fall through to RAG") } - if reply != "не получилось собрать план." { - t.Errorf("reply = %q", reply) + if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) { + t.Errorf("reply = %q, want the honest failure", reply) } } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..7db7f94 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) { t.Fatalf("reply = %q, want only the технологии item", reply) } reply, _ = askFeeds(t, h, "что нового по спорту?") - if !strings.Contains(reply, "ничего") { + if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) { t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply) } } diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 83c1c35..c5de502 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -11,6 +11,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/netscan" + "github.com/kami/maven/internal/phraser" ) // scanBudget — the whole spoken scan, end to end. A voice turn that takes @@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { res, err := w.scan(ctx) if err != nil { log.Printf("netscan: scan: %v", err) - return "не получилось просканировать сеть.", true + return phraser.Q(phraser.QueryFailNetscan, nil), true } // A truncated run is not a statement about the LAN. Saying "нашла 6 // устройств" after stopping two thirds of the way through the range is a @@ -116,7 +117,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { tail = ", но успела посмотреть не всю сеть" } if len(res.Hosts) == 0 { - return "в сети никого не нашла" + tail + ".", true + return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 55c4fe5..b4739cc 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,8 +8,8 @@ import ( "github.com/kami/maven/internal/phraser" ) -// 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 +// 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 // 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. // @@ -20,22 +20,32 @@ func TestFallbackPersona(t *testing.T) { if err != nil { t.Fatalf("LoadFallbacks: %v", err) } + // No CheckHisGender. It reads a feminine verb near a second-person pronoun + // as addressing him as a woman, which is right for a nudge and wrong here: + // "не знаю — не нашла у тебя такой записи" is her own verb in her own + // sentence. CheckFeminine still holds her side of the rule. persona := map[string]bool{ - CheckLang: true, CheckFeminine: true, CheckHisGender: true, + CheckLang: true, CheckFeminine: true, CheckAddress: true, CheckCringe: true, CheckLength: true, } ack, err := phraser.LoadAcks(rand.NewSource(20260804)) if err != nil { t.Fatalf("LoadAcks: %v", err) } + qry, err := phraser.LoadQueries(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } variants := append(fb.Variants(), ack.Variants()...) + variants = append(variants, qry.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } for _, v := range variants { // The placeholders stand for his own words and carry no persona. body := v - for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} { + for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", + "{location}", "{temp}", "{condition}", "{tail}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") { -- 2.52.0 From 5b4192acb597e80d92b3cccb33f1b7d71e3e8f22 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:38:14 +0400 Subject: [PATCH 13/28] phraser: put the act and smart-home replies in a versioned json (V-504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What she says when a capability ran, refused, or could not be reached. Around forty literals across ecosystem_acts.go, actions_act.go and smarthome.go. "It ran", "it was refused", "the ecosystem is down" and "I could not work out what you meant" keep four entries. One variant set across them would let a failure report itself as a success, which is the only failure mode this family has. The lines that report an act as done are fixed rather than varied. A success report that rewords itself is harder to trust when he is listening for it, and the confirmations are fixed for the same reason: they carry an instruction. internal/smarthome/ha.go keeps its own "готово". It is a device driver, and wiring the copy deck into one is the wrong dependency — the daemon relays that word, it does not speak it. --- internal/phraser/acts.go | 184 +++++++++++++++++++++++++++++++ internal/phraser/acts_ru_v1.json | 114 +++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 internal/phraser/acts.go create mode 100644 internal/phraser/acts_ru_v1.json diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go new file mode 100644 index 0000000..a15b181 --- /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 = registerFloor(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 nil + } + 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} не отвечают."] + } + } +} -- 2.52.0 From f3c0540b42e44304f841b423f396ea23877e2f31 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:38:14 +0400 Subject: [PATCH 14/28] mavend: say the act replies from the file (V-504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes a flake this stack introduced: the feeds test matched "ничего нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо". It asks the entry now, like the others. --- cmd/mavend/actions_act.go | 17 ++++---- cmd/mavend/ecosystem_acts.go | 53 +++++++++++++------------ cmd/mavend/feeds_test.go | 2 +- cmd/mavend/smarthome.go | 13 ++++-- internal/phraser/eval/fallbacks_test.go | 11 +++-- 5 files changed, 54 insertions(+), 42 deletions(-) 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/feeds_test.go b/cmd/mavend/feeds_test.go index 7db7f94..d09cda7 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -94,7 +94,7 @@ func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) { } on := buildFeedHandler(t, true) reply, ok = askFeeds(t, on, "что нового в лентах?") - if !ok || !strings.Contains(reply, "ничего нового") { + if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) { t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) } } diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go index 03946b2..5bdbcc7 100644 --- a/cmd/mavend/smarthome.go +++ b/cmd/mavend/smarthome.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "log" + "strconv" "strings" "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/store" ) @@ -140,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 @@ -177,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) } @@ -185,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, hostWord(dark)), true + return phraser.A(phraser.HomeDark, map[string]string{ + "count": strconv.Itoa(dark), + "word": hostWord(dark), + }), true default: parts = append(parts, "всё выключено") } 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") { -- 2.52.0 From c35979d9f92ef23d10b3017f7f2294bee0ee67ba Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:42:20 +0400 Subject: [PATCH 15/28] say: move the copy deck into a package memory can import (V-506) The summaries family is spoken by internal/memory, internal/morning and internal/tasks. internal/phraser already imports internal/memory, so the deck cannot stay in phraser without a cycle. internal/say is a leaf: embed, json, math/rand, strings, sync. The four phraser families keep their files and their floors and now call say.Load, *say.Deck, Text, Matches, Variants, RequirePlaceholder and RegisterFloor. No copy changed and no behaviour changed. --- internal/phraser/acks.go | 18 +++++----- internal/phraser/acts.go | 18 +++++----- internal/phraser/eval/fallbacks_test.go | 7 ++-- internal/phraser/failure_test.go | 2 +- internal/phraser/fallbacks.go | 26 +++++++------- internal/phraser/query.go | 18 +++++----- internal/{phraser => say}/deck.go | 47 +++++++++++++------------ 7 files changed, 74 insertions(+), 62 deletions(-) rename internal/{phraser => say}/deck.go (78%) diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 20c0be4..f26f302 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed ack_ru_v1.json @@ -65,7 +67,7 @@ var ackKeys = []string{ // 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{ +var ackFloor = say.RegisterFloor(map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", @@ -93,12 +95,12 @@ var ackFloor = registerFloor(map[string]string{ }) // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. -type Acks struct{ d *deck } +type Acks struct{ d *say.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) + d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) if err != nil { return nil, err } @@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -117,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { } // deck reads through a nil *Acks, which is the unloadable-file case. -func (a *Acks) deck() *deck { +func (a *Acks) deck() *say.Deck { if a == nil { return nil } @@ -127,11 +129,11 @@ func (a *Acks) deck() *deck { // 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) + 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() } +func (a *Acks) Variants() []string { return a.deck().Variants() } var ( ackOnce sync.Once @@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k // 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) + return DefaultAcks().deck().Matches(key, vars, text) } diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index a15b181..affa7d4 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed acts_ru_v1.json @@ -74,7 +76,7 @@ var actKeys = []string{ // 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 = registerFloor(map[string]string{ +var actFloor = say.RegisterFloor(map[string]string{ ActDone: "готово.", ActDoneOut: "готово: {out}", ActDoneEntity: "команда выполнена для {name}.", @@ -112,12 +114,12 @@ var actFloor = registerFloor(map[string]string{ }) // Acts picks a hand-written Russian act reply. Safe for concurrent use. -type Acts struct{ d *deck } +type Acts struct{ d *say.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) + d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src) if err != nil { return nil, err } @@ -133,7 +135,7 @@ func LoadActs(src rand.Source) (*Acts, error) { {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -141,7 +143,7 @@ func LoadActs(src rand.Source) (*Acts, error) { } // deck reads through a nil *Acts, which is the unloadable-file case. -func (a *Acts) deck() *deck { +func (a *Acts) deck() *say.Deck { if a == nil { return nil } @@ -150,11 +152,11 @@ func (a *Acts) deck() *deck { // 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) + 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() } +func (a *Acts) Variants() []string { return a.deck().Variants() } var ( actOnce sync.Once @@ -180,5 +182,5 @@ func A(key string, vars map[string]string) string { return DefaultActs().Say(key // 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) + return DefaultActs().deck().Matches(key, vars, text) } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 4498bd6..481776b 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,9 +8,10 @@ import ( "github.com/kami/maven/internal/phraser" ) -// 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. +// TestFallbackPersona scores every line in every hand-written 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. // // Only the persona checks run. Mood and topic belong to a nudge, and these are // not nudges. diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 714acc0..28636fe 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -13,7 +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() - return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got) + 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 a9c7058..8df4d15 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -15,6 +15,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed fallbacks_ru_v1.json @@ -39,7 +41,7 @@ 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 = registerFloor(map[string]string{ +var hardFloor = say.RegisterFloor(map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", @@ -47,24 +49,24 @@ var hardFloor = registerFloor(map[string]string{ }) // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. -type Fallbacks struct{ d *deck } +type Fallbacks struct{ d *say.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) { - d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) + d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) if err != nil { return nil, err } // query_sources is the one entry whose whole job is to read something back. - if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil { + if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil { return nil, err } return &Fallbacks{d: d}, nil } // deck reads through a nil *Fallbacks, which is the unloadable-file case. -func (f *Fallbacks) deck() *deck { +func (f *Fallbacks) deck() *say.Deck { if f == nil { return nil } @@ -72,23 +74,23 @@ func (f *Fallbacks) deck() *deck { } // Chat — nothing usable came back on the chat path. -func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) } +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.deck().text(fbQueryUnknown, nil) } +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.deck().text(fbQuerySources, map[string]string{"sources": 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.deck().text(fbWorldGap, nil) } +func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) } // Variants returns every line the file can produce, for the persona scorer. -func (f *Fallbacks) Variants() []string { return f.deck().variants() } +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 @@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() } // 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().deck().matches(fbQueryUnknown, nil, 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().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text) + return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text) } diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 638eefd..f99d2c1 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed query_ru_v1.json @@ -65,7 +67,7 @@ var queryKeys = []string{ // queryFloor — 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 queryFloor = registerFloor(map[string]string{ +var queryFloor = say.RegisterFloor(map[string]string{ QueryUnknown: "не знаю.", QueryOtherDay: "про другой день так не отвечу — спроси целиком.", QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", @@ -95,12 +97,12 @@ var queryFloor = registerFloor(map[string]string{ }) // Queries picks a hand-written Russian query line. Safe for concurrent use. -type Queries struct{ d *deck } +type Queries struct{ d *say.Deck } // LoadQueries reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadQueries(src rand.Source) (*Queries, error) { - d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) + d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) if err != nil { return nil, err } @@ -111,7 +113,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -119,7 +121,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { } // deck reads through a nil *Queries, which is the unloadable-file case. -func (q *Queries) deck() *deck { +func (q *Queries) deck() *say.Deck { if q == nil { return nil } @@ -128,11 +130,11 @@ func (q *Queries) deck() *deck { // Say returns one line for key, with the values filled into the frame. func (q *Queries) Say(key string, vars map[string]string) string { - return q.deck().text(key, vars) + return q.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (q *Queries) Variants() []string { return q.deck().variants() } +func (q *Queries) Variants() []string { return q.deck().Variants() } var ( queryOnce sync.Once @@ -158,5 +160,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say( // IsQ reports whether text is a line key could have produced, for the tests. func IsQ(key string, vars map[string]string, text string) bool { - return DefaultQueries().deck().matches(key, vars, text) + return DefaultQueries().deck().Matches(key, vars, text) } diff --git a/internal/phraser/deck.go b/internal/say/deck.go similarity index 78% rename from internal/phraser/deck.go rename to internal/say/deck.go index 1f53745..7b43325 100644 --- a/internal/phraser/deck.go +++ b/internal/say/deck.go @@ -1,6 +1,9 @@ -package phraser +package say -// deck — the mechanics every family of hand-written Russian lines shares. +// Package say holds the mechanics every family of hand-written Russian lines +// shares. The families themselves live next to the code that speaks them. +// +// 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 @@ -20,28 +23,28 @@ import ( "time" ) -// deckEntry — one line she can say, in as many wordings as the file gives. -type deckEntry struct { +// Entry — one line she can say, in as many wordings as the file gives. +type Entry 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"` +type file struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Entries map[string]Entry `json:"entries"` } -// deck picks a line. Safe for concurrent use. A nil *deck answers from the +// 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 { +type Deck struct { mu sync.Mutex rnd *rand.Rand last map[string]string - file deckFile + file file keys []string floor map[string]string } @@ -49,8 +52,8 @@ type deck struct { // 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 +func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) { + var f file if err := json.Unmarshal(raw, &f); err != nil { return nil, fmt.Errorf("parse: %w", err) } @@ -69,13 +72,13 @@ func loadDeck(raw []byte, version int, keys []string, floor map[string]string, s 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 + 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 { +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) @@ -86,7 +89,7 @@ func (d *deck) requirePlaceholder(key, ph string) error { // 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 { +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 { @@ -101,7 +104,7 @@ func (d *deck) text(key string, vars map[string]string) string { // 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 { +func (d *Deck) Matches(key string, vars map[string]string, text string) bool { if fill(floorOf(d, key), vars) == text { return true } @@ -118,7 +121,7 @@ func (d *deck) matches(key string, vars map[string]string, text string) bool { // 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 { +func (d *Deck) Variants() []string { if d == nil { return nil } @@ -130,7 +133,7 @@ func (d *deck) variants() []string { } // pick chooses at random, skipping whatever this entry said last time. -func (d *deck) pick(key string, e deckEntry) string { +func (d *Deck) pick(key string, e Entry) string { d.mu.Lock() defer d.mu.Unlock() @@ -153,7 +156,7 @@ func (d *deck) pick(key string, e deckEntry) string { // 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 { +func floorOf(d *Deck, key string) string { if d != nil && d.floor != nil { return d.floor[key] } @@ -164,7 +167,7 @@ func floorOf(d *deck, key string) string { // 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 { +func RegisterFloor(floor map[string]string) map[string]string { for k, v := range floor { deckFloors[k] = v } -- 2.52.0 From d819fc09f00b2b8cac83ff2e65b348faa18e378d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:44:18 +0400 Subject: [PATCH 16/28] say: the summaries copy file (V-506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit summary_ru_v1.json: the morning plan, the ranked task list, and the habit sentences read back out of behaviour records. Own schema_version. The empty cases are the point. "I have not seen enough yet" and "there is nothing there" are different claims about his life, and the habit entries keep the first — three days of taps produce the same "обычно ты ..." as a year of them. plan_rest_empty stays separate from plan_day_empty for the same reason: a day that is over was not an empty day. Count forms stay in Go. день/дня/дней and задача/задачи/задач are morphology, and they arrive here through {word}. Loader in the next commit. --- internal/say/summary_ru_v1.json | 140 ++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 internal/say/summary_ru_v1.json diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json new file mode 100644 index 0000000..d4d55a9 --- /dev/null +++ b/internal/say/summary_ru_v1.json @@ -0,0 +1,140 @@ +{ + "schema_version": 1, + "name": "russian summary sentences v1", + "notes": [ + "The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", + "\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.", + "Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.", + "The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.", + "fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed." + ], + "entries": { + "plan_rest_empty": { + "fixed": true, + "variants": ["на сегодня больше ничего не запланировано."] + }, + "plan_day_empty": { + "fixed": true, + "variants": ["на {date} ничего не запланировано."] + }, + "plan_day": { + "fixed": true, + "variants": ["план на {date}: {items}."] + }, + "plan_uncertain": { + "fixed": true, + "variants": ["похоже, {line}"] + }, + + "tasks_none": { + "fixed": true, + "variants": ["задач нет."] + }, + "tasks_first": { + "fixed": true, + "variants": ["сначала: {items}."] + }, + "tasks_candidates": { + "fixed": true, + "variants": ["ещё я нашла, но ты не подтвердил: {items}."] + }, + + "reason_overdue": { + "fixed": true, + "variants": ["просрочено"] + }, + "reason_overdue_day": { + "fixed": true, + "variants": ["просрочено на день"] + }, + "reason_overdue_days": { + "fixed": true, + "variants": ["просрочено на {n} дн."] + }, + "reason_today": { + "fixed": true, + "variants": ["сегодня"] + }, + "reason_tomorrow": { + "fixed": true, + "variants": ["завтра"] + }, + "reason_in_days": { + "fixed": true, + "variants": ["через {n} дн."] + }, + "reason_important": { + "fixed": true, + "variants": ["важно"] + }, + "reason_urgent": { + "fixed": true, + "variants": ["срочно"] + }, + "reason_stale": { + "fixed": true, + "variants": ["давно в списке"] + }, + + "habit_weekday": { + "variants": ["по {day} ты обычно {items}.", "по {day} у тебя обычно {items}."] + }, + "habit_weekday_same": { + "variants": [ + "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + "по {day} всё как обычно — то же, что и в остальные дни: {items}." + ] + }, + "habit_weekday_none": { + "variants": [ + "по {day} я пока не вижу у тебя ничего постоянного.", + "по {day} я пока не набрала записей, чтобы говорить о постоянном." + ] + }, + "habit_weekend_both": { + "variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."] + }, + "habit_weekend_sat": { + "variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."] + }, + "habit_weekend_sun": { + "variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."] + }, + "habit_weekend_same": { + "variants": [ + "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + "по выходным всё как обычно — то же, что и в остальные дни: {items}." + ] + }, + "habit_weekend_none": { + "variants": [ + "по выходным я пока не вижу у тебя ничего постоянного.", + "по выходным я пока не набрала записей, чтобы говорить о постоянном." + ] + }, + "habit_overall": { + "variants": ["обычно ты {items} — {span}."] + }, + "habit_overall_none": { + "variants": [ + "я ещё не набрала достаточно записей, чтобы говорить о привычках.", + "записей пока мало — на привычки я так не сошлюсь." + ] + }, + "habit_span_today": { + "variants": ["по записям за сегодня"] + }, + "habit_span_days": { + "variants": ["по записям за последние {n} {word}"] + }, + "habit_unglossed": { + "fixed": true, + "variants": ["отмечаешь «{key}»"] + }, + "habit_at": { + "fixed": true, + "variants": ["{gloss} около {time}"] + } + } +} -- 2.52.0 From bad3fa40354816a1f95f279c0136c6c3758d2980 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:44:18 +0400 Subject: [PATCH 17/28] say: load the summaries family (V-506) Same nil-safe shape as the four families in phraser: a floor holding the exact literals that lived in Go, a load-time placeholder check on every entry whose job is to read the aggregate back, and S/IsS for the callers and their tests. No call site moved yet. --- internal/say/summary.go | 191 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 internal/say/summary.go diff --git a/internal/say/summary.go b/internal/say/summary.go new file mode 100644 index 0000000..3b03f3a --- /dev/null +++ b/internal/say/summary.go @@ -0,0 +1,191 @@ +package say + +// The summary sentences — what she says around aggregated data: the morning +// plan, the ranked task list, and the habits read back out of behaviour records. +// +// Fifth family on the deck, and the first one outside internal/phraser. It +// lives here because its three callers — internal/morning, internal/tasks and +// internal/memory — sit under phraser in the import graph and cannot reach it. +// +// The "I have not seen enough yet" sentences are the load-bearing ones. Three +// days of taps and a year of them produce the same "обычно ты ...", and only one +// of those is worth believing, so the empty cases say she has not seen a +// pattern rather than that he has none. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed summary_ru_v1.json +var summaryJSON []byte + +// SummarySchemaVersion — this family's own version. +const SummarySchemaVersion = 1 + +// The entry keys. +const ( + PlanRestEmpty = "plan_rest_empty" + PlanDayEmpty = "plan_day_empty" + PlanDay = "plan_day" + PlanUncertain = "plan_uncertain" + + TasksNone = "tasks_none" + TasksFirst = "tasks_first" + TasksCandidates = "tasks_candidates" + + ReasonOverdue = "reason_overdue" + ReasonOverdueDay = "reason_overdue_day" + ReasonOverdueDays = "reason_overdue_days" + ReasonToday = "reason_today" + ReasonTomorrow = "reason_tomorrow" + ReasonInDays = "reason_in_days" + ReasonImportant = "reason_important" + ReasonUrgent = "reason_urgent" + ReasonStale = "reason_stale" + + HabitWeekday = "habit_weekday" + HabitWeekdaySame = "habit_weekday_same" + HabitWeekdayNone = "habit_weekday_none" + HabitWeekendBoth = "habit_weekend_both" + HabitWeekendSat = "habit_weekend_sat" + HabitWeekendSun = "habit_weekend_sun" + HabitWeekendSame = "habit_weekend_same" + HabitWeekendNone = "habit_weekend_none" + HabitOverall = "habit_overall" + HabitOverallNone = "habit_overall_none" + HabitSpanToday = "habit_span_today" + HabitSpanDays = "habit_span_days" + HabitUnglossed = "habit_unglossed" + HabitAt = "habit_at" +) + +var summaryKeys = []string{ + PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, + TasksNone, TasksFirst, TasksCandidates, + ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow, + ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale, + HabitWeekday, HabitWeekdaySame, HabitWeekdayNone, + HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone, + HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays, + HabitUnglossed, HabitAt, +} + +// summaryFloor — 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 summaryFloor = RegisterFloor(map[string]string{ + PlanRestEmpty: "на сегодня больше ничего не запланировано.", + PlanDayEmpty: "на {date} ничего не запланировано.", + PlanDay: "план на {date}: {items}.", + PlanUncertain: "похоже, {line}", + + TasksNone: "задач нет.", + TasksFirst: "сначала: {items}.", + TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.", + + ReasonOverdue: "просрочено", + ReasonOverdueDay: "просрочено на день", + ReasonOverdueDays: "просрочено на {n} дн.", + ReasonToday: "сегодня", + ReasonTomorrow: "завтра", + ReasonInDays: "через {n} дн.", + ReasonImportant: "важно", + ReasonUrgent: "срочно", + ReasonStale: "давно в списке", + + HabitWeekday: "по {day} ты обычно {items}.", + HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.", + HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.", + HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.", + HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.", + HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.", + HabitOverall: "обычно ты {items} — {span}.", + HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.", + HabitSpanToday: "по записям за сегодня", + HabitSpanDays: "по записям за последние {n} {word}", + HabitUnglossed: "отмечаешь «{key}»", + HabitAt: "{gloss} около {time}", +}) + +// Summaries picks a hand-written Russian summary sentence. Safe for concurrent +// use. +type Summaries struct{ d *Deck } + +// LoadSummaries reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadSummaries(src rand.Source) (*Summaries, error) { + d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src) + if err != nil { + return nil, err + } + // The entries that exist to read the aggregate back. A variant without the + // placeholder would summarise the data by dropping it. + for _, req := range []struct{ key, ph string }{ + {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, + {PlanUncertain, "{line}"}, + {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, + {ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"}, + {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, + {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"}, + {HabitWeekdayNone, "{day}"}, + {HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"}, + {HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"}, + {HabitWeekendSame, "{items}"}, + {HabitOverall, "{items}"}, {HabitOverall, "{span}"}, + {HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"}, + {HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"}, + } { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Summaries{d: d}, nil +} + +// deck reads through a nil *Summaries, which is the unloadable-file case. +func (s *Summaries) deck() *Deck { + if s == nil { + return nil + } + return s.d +} + +// Say returns one line for key, with the values filled into the frame. +func (s *Summaries) Say(key string, vars map[string]string) string { + return s.deck().Text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (s *Summaries) Variants() []string { return s.deck().Variants() } + +var ( + summaryOnce sync.Once + summaries *Summaries +) + +// DefaultSummaries returns the shared instance, loading it on first use. A +// broken file logs once and leaves a nil *Summaries, which still answers from +// summaryFloor. +func DefaultSummaries() *Summaries { + summaryOnce.Do(func() { + s, err := LoadSummaries(nil) + if err != nil { + log.Printf("say: summary lines unavailable, using the built-in ones: %v", err) + return + } + summaries = s + }) + return summaries +} + +// S — one summary sentence, the way every caller says it. +func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) } + +// IsS reports whether text is a line key could have produced, for the tests. +func IsS(key string, vars map[string]string, text string) bool { + return DefaultSummaries().deck().Matches(key, vars, text) +} -- 2.52.0 From 42d7a39c491cd0c9466c9e06d25f97e81f32f418 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:47:52 +0400 Subject: [PATCH 18/28] morning, tasks, memory: say the summaries from the file (V-506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three callers now read their sentences out of summary_ru_v1.json: the plan lines in morning.Plan.FormatRU, the list and reason words in tasks.FormatRU, and the habit readouts in memory.Profile. Two behaviour_test assertions moved from substring to say.IsS, because the habit gaps have variants now and a substring pins one of them. The "по {day} у тебя обычно" variant was dropped on sight: the activities are verbs, so it read "у тебя обычно тренируешься". The persona scorer covers the family, and a new test asserts every gap variant still says she has not seen enough rather than that he has nothing. --- internal/memory/behavior.go | 50 +++++++++++++++---------- internal/memory/behavior_test.go | 8 +++- internal/morning/plan.go | 12 ++++-- internal/phraser/eval/fallbacks_test.go | 9 ++++- internal/say/summary_ru_v1.json | 7 ++-- internal/say/summary_test.go | 49 ++++++++++++++++++++++++ internal/tasks/rank.go | 35 +++++++++-------- 7 files changed, 124 insertions(+), 46 deletions(-) create mode 100644 internal/say/summary_test.go diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 8ed3260..20cc614 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -3,8 +3,11 @@ package memory import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Behavioural memory — "what do I usually do?" (Vikunja #254). @@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { day := weekdayRU[int(wd)%7] acts := p.Weekly[wd] if len(acts) > 0 { - return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) + return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)}) } if len(p.Everyday) > 0 { - return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - day, joinActivities(p.Everyday)) + return say.S(say.HabitWeekdaySame, map[string]string{ + "day": day, "items": joinActivities(p.Everyday), + }) } - return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) + return say.S(say.HabitWeekdayNone, map[string]string{"day": day}) } // FormatWeekendRU reads back what distinguishes Saturday and Sunday. @@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string { sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday] switch { case len(sat) > 0 && len(sun) > 0: - return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.", - joinActivities(sat), joinActivities(sun)) + return say.S(say.HabitWeekendBoth, map[string]string{ + "sat": joinActivities(sat), "sun": joinActivities(sun), + }) case len(sat) > 0: - return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.", - joinActivities(sat)) + return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)}) case len(sun) > 0: - return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.", - joinActivities(sun)) + return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)}) case len(p.Everyday) > 0: - return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - joinActivities(p.Everyday)) + return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)}) } - return "по выходным я пока не вижу у тебя ничего постоянного." + return say.S(say.HabitWeekendNone, nil) } // FormatOverallRU reads back the habits that hold across the whole week, and @@ -378,19 +380,23 @@ func (p Profile) FormatWeekendRU() string { // a year of them, and only one of those is worth believing. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { - return "я ещё не набрала достаточно записей, чтобы говорить о привычках." + return say.S(say.HabitOverallNone, nil) } - return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU()) + return say.S(say.HabitOverall, map[string]string{ + "items": joinActivities(p.All), "span": p.spanRU(), + }) } // spanRU — "по записям за последние N дней", or a vaguer phrase when the window // is too short to name in days. func (p Profile) spanRU() string { if p.Since.IsZero() || !p.Until.After(p.Since) { - return "по записям за сегодня" + return say.S(say.HabitSpanToday, nil) } days := int(p.Until.Sub(p.Since).Hours()/24) + 1 - return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days)) + return say.S(say.HabitSpanDays, map[string]string{ + "n": strconv.Itoa(days), "word": pluralDaysRU(days), + }) } // pluralDaysRU — the Russian count form of "день" for n. @@ -423,14 +429,18 @@ func joinActivities(acts []Activity) string { // come from the model, so an unglossed one is as likely to be // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is // not a sentence. - gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " ")) + gloss = say.S(say.HabitUnglossed, map[string]string{ + "key": strings.ReplaceAll(a.Key, "_", " "), + }) } if !a.HasTypical { parts[i] = gloss continue } - parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, - int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) + parts[i] = say.S(say.HabitAt, map[string]string{ + "gloss": gloss, + "time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60), + }) } if len(parts) == 1 { return parts[0] diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index e2ee61c..cc75e71 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" "unicode" + + "github.com/kami/maven/internal/say" ) // habitHistory — n weeks of the same weekday, at the given local time. @@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) { if len(p.All) != 0 || len(p.Weekly) != 0 { t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly) } - if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") { + if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) { t.Errorf("empty profile reads %q", got) } } @@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { // A day with nothing of its own says so rather than reciting water as if // Wednesday were the reason for it. wed := p.FormatWeekdayRU(time.Wednesday) - if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") { + if !say.IsS(say.HabitWeekdaySame, map[string]string{ + "day": "средам", "items": "пьёшь воду около 13:30", + }, wed) { t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) } } diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ba2c699..5129187 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/kami/maven/internal/say" "github.com/kami/maven/internal/store" ) @@ -164,17 +165,20 @@ func (p Plan) FormatRU() string { // it is over, and saying it was empty is a false statement about a day // he just lived. if p.Rest { - return "на сегодня больше ничего не запланировано." + return say.S(say.PlanRestEmpty, nil) } - return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006")) + return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")}) } parts := make([]string, len(p.Items)) for i, it := range p.Items { line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text) if it.Uncertain { - line = "похоже, " + line + line = say.S(say.PlanUncertain, map[string]string{"line": line}) } parts[i] = line } - return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; ")) + return say.S(say.PlanDay, map[string]string{ + "date": p.Date.Format("02.01.2006"), + "items": strings.Join(parts, "; "), + }) } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 481776b..eb2f617 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/say" ) // TestFallbackPersona scores every line in every hand-written family on the @@ -43,6 +44,11 @@ func TestFallbackPersona(t *testing.T) { t.Fatalf("LoadActs: %v", err) } variants = append(variants, act.Variants()...) + sum, err := say.LoadSummaries(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadSummaries: %v", err) + } + variants = append(variants, sum.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } @@ -51,7 +57,8 @@ func TestFallbackPersona(t *testing.T) { body := v for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", - "{entity}", "{count}", "{word}"} { + "{entity}", "{count}", "{word}", + "{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") { diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index d4d55a9..c387290 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -78,7 +78,8 @@ }, "habit_weekday": { - "variants": ["по {day} ты обычно {items}.", "по {day} у тебя обычно {items}."] + "fixed": true, + "variants": ["по {day} ты обычно {items}."] }, "habit_weekday_same": { "variants": [ @@ -89,7 +90,7 @@ "habit_weekday_none": { "variants": [ "по {day} я пока не вижу у тебя ничего постоянного.", - "по {day} я пока не набрала записей, чтобы говорить о постоянном." + "по {day} у тебя пока ничего постоянного не вижу — записей мало." ] }, "habit_weekend_both": { @@ -110,7 +111,7 @@ "habit_weekend_none": { "variants": [ "по выходным я пока не вижу у тебя ничего постоянного.", - "по выходным я пока не набрала записей, чтобы говорить о постоянном." + "по выходным у тебя пока ничего постоянного не вижу — записей мало." ] }, "habit_overall": { diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go new file mode 100644 index 0000000..be1bd13 --- /dev/null +++ b/internal/say/summary_test.go @@ -0,0 +1,49 @@ +package say + +import ( + "math/rand" + "strings" + "testing" +) + +// The file has to load, and every key the code names has to be in it. +func TestSummariesLoad(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range summaryKeys { + if got := s.Say(key, nil); got == "" { + t.Errorf("%s says nothing", key) + } + } +} + +// A nil *Summaries is the unloadable-file case, and it must still speak. The +// habit sentences are the ones that matter here: falling back must not turn +// "I have not seen enough" into silence. +func TestNilSummariesAnswerFromTheFloor(t *testing.T) { + var s *Summaries + if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") { + t.Errorf("the floor dropped the date: %q", got) + } +} + +// The empty cases claim she has not seen enough, never that he has no habits. +// Every variant has to hold that line, since the picker treats them as equals. +func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} { + for _, v := range s.d.file.Entries[key].Variants { + if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") { + t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v) + } + } + } +} diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index e2f88fd..8121102 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -19,8 +19,11 @@ package tasks import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Status values, mirroring internal/store so a caller can rank ipc.Task rows @@ -117,21 +120,21 @@ func score(it Item, now time.Time) (float64, string) { bonus = scoreOverdueCap } total += scoreOverdue + bonus - reason = "просрочено" + reason = say.S(say.ReasonOverdue, nil) if late == 1 { - reason = "просрочено на день" + reason = say.S(say.ReasonOverdueDay, nil) } else if late > 1 { - reason = fmt.Sprintf("просрочено на %d дн.", late) + reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)}) } case days == 0: total += scoreDueToday - reason = "сегодня" + reason = say.S(say.ReasonToday, nil) case days == 1: total += scoreDueTomorrow - reason = "завтра" + reason = say.S(say.ReasonTomorrow, nil) case days <= 7: total += scoreDueWeek - reason = fmt.Sprintf("через %d дн.", days) + reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)}) default: total += scoreDueLater } @@ -147,9 +150,9 @@ func score(it Item, now time.Time) (float64, string) { // The rungs get their own words. The reason string is the one place // the ranking explains itself, and reading "важно" back at a task // he flagged "срочно" reports a word he did not say. - reason = "важно" + reason = say.S(say.ReasonImportant, nil) if w >= MaxWeight { - reason = "срочно" + reason = say.S(say.ReasonUrgent, nil) } } } @@ -163,7 +166,7 @@ func score(it Item, now time.Time) (float64, string) { } total += age if reason == "" && weeks >= 2 { - reason = "давно в списке" + reason = say.S(say.ReasonStale, nil) } } } @@ -210,22 +213,22 @@ func FormatRU(ranked []Ranked) string { } } if len(open) == 0 && len(cands) == 0 { - return "задач нет." + return say.S(say.TasksNone, nil) } var b strings.Builder if len(open) > 0 { - b.WriteString("сначала: ") - b.WriteString(joinRU(open, SpokenLimit, true)) - b.WriteString(".") + b.WriteString(say.S(say.TasksFirst, map[string]string{ + "items": joinRU(open, SpokenLimit, true), + })) } if len(cands) > 0 { if b.Len() > 0 { b.WriteString(" ") } - b.WriteString("ещё я нашла, но ты не подтвердил: ") - b.WriteString(joinRU(cands, SpokenLimit, false)) - b.WriteString(".") + b.WriteString(say.S(say.TasksCandidates, map[string]string{ + "items": joinRU(cands, SpokenLimit, false), + })) } return b.String() } -- 2.52.0 From 96b474223d462ba268c8fb62dbbe21b54ec279b7 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:51:27 +0400 Subject: [PATCH 19/28] mavend: an unconfigured capability names the gap (V-479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netscan and the crawler both declined their own turn when the wiring was nil, and the question fell through to the search leg. "какие устройства в сети?" came back as a paragraph about routers in general, and a question about his own LAN went to an upstream engine — the personal boundary exists to stop exactly that. A URL he named came back answered as though he had not named it. Both now claim the turn once their own recogniser has matched, and say which capability is missing: net_off and page_off in the query family. TestQueryWebPassesWhenNotConfigured encoded the old decision, that announcing a configuration status is only for a capability that exists and failed. It is rewritten, not deleted: the gap is the answer now. --- cmd/mavend/actions_query.go | 20 +++++++++++--------- cmd/mavend/crawls_test.go | 16 ++++++++++------ cmd/mavend/netscan_test.go | 21 +++++++++++++++++++++ internal/phraser/query.go | 6 +++++- internal/phraser/query_ru_v1.json | 8 ++++++++ 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f019816..a07d5fd 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin return "", false } if h.netscan == nil { - // Fall through, same as queryHome: an unconfigured scanner must not - // swallow "сколько устройств в сети?" before recall has looked. - return "", false + // The recogniser already matched, so this is a question about HIS LAN + // and there is no scanner to answer it. Falling through sent it to the + // search leg, which answered with a paragraph about routers in general + // and put his network question on an upstream engine (Vikunja #479). + // A missing capability names itself. + return phraser.Q(phraser.QueryNetOff, nil), true } return h.netscan.scanSummary(ctx) } @@ -524,12 +527,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b return "", false } if h.crawler == nil { - // Fall through. Reading pages is off unless configured, and on a daemon - // where it was never turned on the older behaviour is right: the model - // answers the question as if the URL had not been said. Announcing a - // configuration status is for a capability that exists and failed, not - // for one he never asked for. - return "", false + // He named a URL, so the question is about that page and nothing else + // can answer it. The older comment here argued for falling through and + // letting the model answer as if the URL had not been said; that is a + // guess dressed as an answer (Vikunja #479). + return phraser.Q(phraser.QueryPageOff, nil), true } ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 4423355..b6b1882 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) { } } -// A daemon where page reading was never turned on — the default — answers the -// question the way it did before the capability existed. Claiming the turn to -// report a configuration status is for something that exists and failed. -func TestQueryWebPassesWhenNotConfigured(t *testing.T) { +// A daemon where page reading was never turned on names the gap. He asked +// about one page, nothing else on the box can read it, and the old behaviour +// here was to answer as though the URL had not been said (Vikunja #479). +func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) { h := buildWebHandler(nil) - if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok { - t.Fatalf("an unconfigured crawler claimed the turn with %q", reply) + reply, ok := askWeb(h, "посмотри https://example.org/page") + if !ok { + t.Fatal("an unconfigured crawler let the page question fall through") + } + if !phraser.IsQ(phraser.QueryPageOff, nil, reply) { + t.Errorf("got %q, want the gap named", reply) } } diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index df87124..62362fb 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -10,6 +10,8 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" ) func TestWireNetScanOffUnlessEnabled(t *testing.T) { @@ -169,3 +171,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) { t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n) } } + +// An unconfigured scanner names the gap instead of declining the turn. +// +// Falling through sent "какие устройства в сети?" to the search leg, which +// answered with a paragraph about routers in general — and put a question about +// his own LAN on an upstream engine, which the personal boundary exists to +// prevent (Vikunja #479). +func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) { + h := &reactiveHandler{} + reply, ok := h.queryNetwork(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "какие устройства в сети?"}, + }) + if !ok { + t.Fatal("an unconfigured scanner let the question fall through to search") + } + if !phraser.IsQ(phraser.QueryNetOff, nil, reply) { + t.Errorf("got %q, want the gap named", reply) + } +} diff --git a/internal/phraser/query.go b/internal/phraser/query.go index f99d2c1..2ee3a39 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -45,6 +45,8 @@ const ( QueryWeatherOff = "weather_off" QueryWeatherWhere = "weather_nolocation" QueryNetEmpty = "net_empty" + QueryNetOff = "net_off" + QueryPageOff = "page_off" QueryFailPlan = "fail_plan" QueryFailNotes = "fail_notes" @@ -60,7 +62,7 @@ var queryKeys = []string{ QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue, QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty, QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic, - QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, + QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff, QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, } @@ -85,6 +87,8 @@ var queryFloor = say.RegisterFloor(map[string]string{ QueryWeatherOff: "погода не настроена.", QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", QueryNetEmpty: "в сети никого не нашла{tail}.", + QueryNetOff: "сканирование сети не настроено.", + QueryPageOff: "я не читаю страницы — это не настроено.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 249294e..ac173f8 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -62,6 +62,14 @@ "fixed": true, "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] }, + "net_off": { + "fixed": true, + "variants": ["сканирование сети не настроено."] + }, + "page_off": { + "fixed": true, + "variants": ["я не читаю страницы — это не настроено."] + }, "net_empty": { "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] }, -- 2.52.0 From 1c8a32c3fd7565648c7e62a7f508ff81cdc54e64 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:56:50 +0400 Subject: [PATCH 20/28] =?UTF-8?q?router:=20stage=200=20claims=20"=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B4=D0=B0=D0=BB=D1=8C=D1=88=D0=B5=3F"=20and?= =?UTF-8?q?=20"=D1=80=D0=B0=D1=81=D1=81=D0=BA=D0=B0=D0=B6=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=20X"=20(V-498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both shapes carry no question mark and no interrogative, so the model saw them with nothing deterministic in front and routed both to fact. The fact gate caught the write and re-ran the turn as a query, so nothing broke — what they cost was a full model round trip for a decision two patterns can make offline. NarrativeQueryGrammars, wired after the agenda rules so that "расскажи, что у меня сегодня" stays an agenda question. Two exclusions, both learned from the fixture: a capture verb in the rest of the utterance means he asked for a note, and an entertainment noun means chat — "расскажи анекдот про программистов" is ru-chat-003, and my first pattern took it. The fixture had no case for either shape, which is why they went unnoticed. Added as ru-query-020 and ru-query-021: classifier+onnx 53/77 → 55/79 (68.8% → 69.6%), both new cases answered at stage 0, false clarifies unchanged at 0. --- cmd/mavend/voicewire.go | 3 ++ internal/router/agenda_test.go | 37 ++++++++++++++ internal/router/eval/eval_test.go | 1 + internal/router/eval/ru_routing_v1.json | 2 + internal/router/stage0.go | 67 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+) diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index f4fd334..8bf2361 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -379,6 +379,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // question and must keep reaching replySystem, while "что у меня сегодня" // is an agenda question and must not. grammars = append(grammars, router.AgendaQueryGrammars()...) + // After the agenda rules: "расскажи, что у меня сегодня" is an agenda + // question first and a narrative request second (Vikunja #498). + grammars = append(grammars, router.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go index 4ef3407..7ee8420 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -75,3 +75,40 @@ func TestAgendaGrammarSparesStatements(t *testing.T) { } } } + +// The two shapes that carried no question mark and no interrogative, so the +// model saw them first and called them facts (Vikunja #498). +func TestNarrativeGrammarsRouteToQuery(t *testing.T) { + r := agendaRouter(t) + r.grammars = append(r.grammars, NarrativeQueryGrammars()...) + for _, u := range []string{ + "что дальше?", + "и что там дальше", + "what's next?", + "расскажи про битву при Ватерлоо", + "объясни как работает дизель", + "опиши Ватерлоо", + } { + d, err := r.Route(context.Background(), u, refNow()) + if err != nil { + t.Fatalf("%q: %v", u, err) + } + if d.Intent != IntentQuery || d.Stage != 0 { + t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage) + } + } +} + +// A narrative verb next to a capture verb is him asking for a note. Stage 0 +// declines and the extractor gets its turn. +func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) { + r := agendaRouter(t) + r.grammars = append(r.grammars, NarrativeQueryGrammars()...) + d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow()) + if err != nil { + t.Fatal(err) + } + if d.Stage == 0 && d.Intent == IntentQuery { + t.Errorf("stage 0 claimed a capture: %+v", d) + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 9adf2c2..89dd17c 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter // Same order as buildRouter (voicewire.go). The fixture is only worth // anything while its grammar set is the daemon's grammar set. grammars = append(grammars, router.AgendaQueryGrammars()...) + grammars = append(grammars, router.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 6c765f0..2de1e25 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -28,6 +28,8 @@ { "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" }, { "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] }, { "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] }, + { "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" }, + { "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" }, { "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] }, { "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] }, { "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] }, diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 615a4fc..b02a3be 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar { } } +// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that +// carry no question mark and no interrogative, and so reached the resident +// model with nothing deterministic in front of them (Vikunja #498). +// +// Both were routed IntentFact by the model. The fact gate catches the write and +// re-runs the turn as a query, so nothing breaks today; what they cost is a full +// model round trip to reach a decision two patterns can make offline, and a +// wrong row on the routing fixture. +// +// Wired after the agenda grammars, which is where their overlap resolves: +// "расскажи, что у меня сегодня" is claimed here as a query either way. +func NarrativeQueryGrammars() []Grammar { + return []Grammar{ + { + // "что дальше?" — the rest of the day. IsRestOfDayQuery already + // recognises it downstream in the query chain, but that runs after + // the routing decision, and the routing decision was fact. + Name: "rest-of-day-query", + Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`), + Build: agendaQueryBuild, + }, + { + // "расскажи про X" — a world question phrased as an instruction. + // The lexicon is narrativeRequests, already written for the + // question-shaped test in question.go. + // + // Anchored at the start: "запиши что мне рассказали" is a capture, + // and a narrative verb buried mid-utterance is not the shape. + Name: "narrative-query", + Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`), + Build: narrativeQueryBuild, + }, + } +} + +// entertainmentNouns — what "расскажи" asks for when it is not asking for +// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make +// something up, which is the one case where inventing is the right answer +// (fixture ru-chat-003). +var entertainmentNouns = []string{ + "анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки", + "joke", "jokes", "story", +} + +// narrativeQueryBuild — the narrative shape is a query unless he also said one +// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him +// asking for a note, and stage 0 must not take either off the cascade. +func narrativeQueryBuild(m []string) (Decision, bool) { + rest := "" + if len(m) > 3 { + rest = m[3] + } + for _, t := range planTokens(rest) { + for _, v := range captureVerbs { + if t == v { + return Decision{}, false + } + } + for _, v := range entertainmentNouns { + if t == v { + return Decision{}, false + } + } + } + return agendaQueryBuild(m) +} + // agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own // matchers decide the rest. -- 2.52.0 From d79b30a1a64b71628a763b031e0f72f899495430 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:37:54 +0400 Subject: [PATCH 21/28] =?UTF-8?q?phraser:=20one=20count=20helper,=20so=20t?= =?UTF-8?q?he=20weather=20says=20"1=20=D0=B3=D1=80=D0=B0=D0=B4=D1=83=D1=81?= =?UTF-8?q?"=20(V-521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weather line spelled "градусов" out in the template, which is the wrong form for 1-4 and for every number ending in 1-4. Russian inflects the noun after a numeral, so the count splits into the number and {word}. hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was the only place that did. It moves to internal/phraser as CountWord, with Degrees and Devices over it, and the three call sites that counted devices now read the same helper the weather line does. Degrees rounds before it counts, so the noun agrees with the number she is about to say rather than the reading behind it, and a negative reading counts by its magnitude. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/netscan.go | 20 ++------------ cmd/mavend/netscan_test.go | 12 --------- cmd/mavend/smarthome.go | 5 ++-- internal/phraser/plural.go | 47 +++++++++++++++++++++++++++++++++ internal/phraser/plural_test.go | 32 ++++++++++++++++++++++ 5 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 internal/phraser/plural.go create mode 100644 internal/phraser/plural_test.go diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index c5de502..88d28f1 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -119,7 +119,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { if len(res.Hosts) == 0 { return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } - out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { out += ", " + shape } @@ -180,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) { if w.api == nil { return } - head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts))) if res.Truncated { head += " (не вся сеть)" } @@ -211,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) { } } -// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства, -// 5+ устройств, and the teens are all the last form. -func hostWord(n int) string { - if n%100 >= 11 && n%100 <= 14 { - return "устройств" - } - switch n % 10 { - case 1: - return "устройство" - case 2, 3, 4: - return "устройства" - default: - return "устройств" - } -} - // isNetworkQuery recognises a question about the LAN, narrowly. It needs a // network word AND an ask: "интернет не работает" is a complaint, not a request // to scan, and a scan she runs unasked is exactly the noisy behaviour the diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index df87124..eb10526 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) { } } -func TestHostWordAgreesWithTheCount(t *testing.T) { - for n, want := range map[int]string{ - 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств", - 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства", - 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств", - } { - if got := hostWord(n); got != want { - t.Errorf("hostWord(%d) = %q, want %q", n, got, want) - } - } -} - func TestIsNetworkQuery(t *testing.T) { yes := []string{ "какие устройства в сети?", diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go index 03946b2..2e14f3a 100644 --- a/cmd/mavend/smarthome.go +++ b/cmd/mavend/smarthome.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/store" ) @@ -185,7 +186,7 @@ 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, hostWord(dark)), true + return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true default: parts = append(parts, "всё выключено") } @@ -193,7 +194,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { parts = append(parts, strings.Join(sensors, ", ")) } if dark > 0 { - parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark))) + parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark))) } return strings.Join(parts, "; ") + ".", true } diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go new file mode 100644 index 0000000..23ad704 --- /dev/null +++ b/internal/phraser/plural.go @@ -0,0 +1,47 @@ +package phraser + +// The counted noun, in the form the number in front of it demands. +// +// Russian inflects a noun after a numeral, and the form depends on the last two +// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса. +// A line file cannot spell that out, so a count in a template splits into two +// placeholders — the number, and {word} filled from here. +// +// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the +// weather line said "градусов" for every temperature and was wrong for 1-4 and +// for every number ending in 1-4. One helper, every count site (Vikunja #521). + +import "math" + +// CountWord picks between the three forms n needs: one for 1, few for 2-4, many +// for 0, 5-20 and anything ending in those. A negative count reads its own +// magnitude, since minus does not change the noun: -2 градуса. +func CountWord(n int, one, few, many string) string { + if n < 0 { + n = -n + } + if n%100 >= 11 && n%100 <= 14 { + return many + } + switch n % 10 { + case 1: + return one + case 2, 3, 4: + return few + default: + return many + } +} + +// Degrees — the noun for a temperature. Takes the reading as it arrives from a +// weather provider and counts by the whole degrees she is about to say, so the +// noun agrees with the number in the same sentence rather than with the reading +// behind it. +func Degrees(temp float64) string { + return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов") +} + +// Devices — the noun for a count of hosts on the LAN or of smart-home devices. +func Devices(n int) string { + return CountWord(n, "устройство", "устройства", "устройств") +} diff --git a/internal/phraser/plural_test.go b/internal/phraser/plural_test.go new file mode 100644 index 0000000..cfd4fd2 --- /dev/null +++ b/internal/phraser/plural_test.go @@ -0,0 +1,32 @@ +package phraser + +import "testing" + +// The bug the helper exists for: the weather line said "градусов" for every +// reading, which is wrong for 1-4 and for every number ending in 1-4. +func TestDegreesAgreeWithTheReading(t *testing.T) { + for temp, want := range map[float64]string{ + 1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов", + 0: "градусов", 11: "градусов", 14: "градусов", 21: "градус", + 22: "градуса", 25: "градусов", 101: "градус", + // Minus does not change the noun, and a reading rounds to the number + // she is about to say: -2.4° is "-2 градуса", not "-2 градусов". + -1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов", + } { + if got := Degrees(temp); got != want { + t.Errorf("Degrees(%v) = %q, want %q", temp, got, want) + } + } +} + +func TestDevicesAgreeWithTheCount(t *testing.T) { + for n, want := range map[int]string{ + 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств", + 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства", + 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств", + } { + if got := Devices(n); got != want { + t.Errorf("Devices(%d) = %q, want %q", n, got, want) + } + } +} -- 2.52.0 From feca776077e657b8fda23bcfc09ffe506d46cb74 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:38:16 +0400 Subject: [PATCH 22/28] phraser: a variant she cannot fill is not a variant she can say (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the deck, both of which reach him as a broken answer. An optional placeholder had no rule. net_empty carries {tail} for the case where a scan stopped short of the whole range, and a scan that finished has nothing to put there — so the answer went out with the braces in it, or with nothing at all if the variant was all placeholder. The picker now narrows to the variants this call can actually fill, and prefers, among those, the ones using the most of what the caller supplied, so a caveat he was given is never dropped for a shorter wording. Nothing fillable still says the line, because a visible placeholder beats silence. The floor literals lived in one global map keyed by bare entry name, and two families both define an entry called query_unknown: the query answers, where she looked and found nothing, and the phrasing fallbacks, where she failed to say an answer she had. Whichever registered last answered for both, so the distinction those two files exist for disappeared exactly when a file failed to load. Each family now carries its own map, and an unloadable file leaves a floor-only deck behind instead of a nil one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- internal/phraser/acks.go | 6 +-- internal/phraser/deck.go | 92 +++++++++++++++++++++++++++-------- internal/phraser/fallbacks.go | 6 +-- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 20c0be4..4a6f6dd 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -65,7 +65,7 @@ var ackKeys = []string{ // 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{ +var ackFloor = map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", @@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{ FailAck: "не получилось отметить.", FailSnooze: "не получилось отложить.", FailQuiet: "не получилось переключить тихий режим.", -}) +} // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. type Acks struct{ d *deck } @@ -119,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { // deck reads through a nil *Acks, which is the unloadable-file case. func (a *Acks) deck() *deck { if a == nil { - return nil + return floorDeck(ackFloor) } return a.d } diff --git a/internal/phraser/deck.go b/internal/phraser/deck.go index 1f53745..9136775 100644 --- a/internal/phraser/deck.go +++ b/internal/phraser/deck.go @@ -35,8 +35,8 @@ type deckFile struct { 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. +// deck picks a line. Safe for concurrent use. A deck with no entries answers +// from the floor, which is what an unloadable file leaves behind (floorDeck). type deck struct { mu sync.Mutex rnd *rand.Rand @@ -90,7 +90,7 @@ 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) + tmpl = d.pick(key, fillable(e.Variants, vars)) } } if tmpl == "" { @@ -129,12 +129,67 @@ func (d *deck) variants() []string { return out } +// fillable narrows variants to the ones this call can actually say, which is +// the rule an optional placeholder needs: a caller with nothing to put in +// {tail} must not be handed a variant that has one. Two passes, because both +// halves matter. The first keeps only variants whose every placeholder has a +// non-empty value, so an absent optional never reaches him as braces. The +// second prefers, among those, the variants using the most of what the caller +// supplied, so a caveat he was given is not dropped for a shorter wording. +// Nothing fillable leaves the list alone, and the unfilled placeholder shows +// up in the answer rather than turning it into silence. +func fillable(variants []string, vars map[string]string) []string { + if len(variants) < 2 { + return variants + } + best, bestUsed := make([]string, 0, len(variants)), -1 + for _, v := range variants { + used := 0 + ok := true + for _, ph := range placeholders(v) { + if vars[ph] == "" { + ok = false + break + } + used++ + } + if !ok || used < bestUsed { + continue + } + if used > bestUsed { + best, bestUsed = best[:0], used + } + best = append(best, v) + } + if len(best) == 0 { + return variants + } + return best +} + +// placeholders lists the {name}s in tmpl, in order. +func placeholders(tmpl string) []string { + var out []string + for { + i := strings.IndexByte(tmpl, '{') + if i < 0 { + return out + } + j := strings.IndexByte(tmpl[i:], '}') + if j < 0 { + return out + } + out = append(out, tmpl[i+1:i+j]) + tmpl = tmpl[i+j+1:] + } +} + // pick chooses at random, skipping whatever this entry said last time. -func (d *deck) pick(key string, e deckEntry) string { +func (d *deck) pick(key string, variants []string) string { d.mu.Lock() defer d.mu.Unlock() - choices := e.Variants + choices := variants if len(choices) > 1 { fresh := make([]string, 0, len(choices)) for _, v := range choices { @@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string { 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. +// floorOf reads the Go literal behind key. Every deck carries its own family's +// map, including the floor-only deck an unloadable file leaves behind, so no +// lookup ever crosses families. It used to go through one global map keyed by +// bare entry name, which two families both calling an entry query_unknown +// silently shared: whichever registered last answered for both (Vikunja #521). func floorOf(d *deck, key string) string { - if d != nil && d.floor != nil { - return d.floor[key] + if d == nil { + return "" } - return deckFloors[key] + return d.floor[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 +// floorDeck — the deck a family falls back to when its file will not load. It +// has no entries, so every read drops through to the floor literals, and it is +// a real *deck so no accessor has to know which case it is in. +func floorDeck(floor map[string]string) *deck { + return &deck{last: map[string]string{}, floor: floor} } // fill substitutes {name} for each var. A placeholder with no value is left diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index a9c7058..2124242 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -39,12 +39,12 @@ 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 = registerFloor(map[string]string{ +var hardFloor = map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.", -}) +} // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. type Fallbacks struct{ d *deck } @@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) { // deck reads through a nil *Fallbacks, which is the unloadable-file case. func (f *Fallbacks) deck() *deck { if f == nil { - return nil + return floorDeck(hardFloor) } return f.d } -- 2.52.0 From 765ed3634042dba4e848973d1c8bd2d429926aca Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:38:16 +0400 Subject: [PATCH 23/28] phraser: the query answers as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's wording, taken from the PR 111 review, with one correction from the PR 113 review folded in: {temp} {word} rather than {temp}°, because the degree sign reads as nothing through piper. What the wording changes: query_unknown drops "не знаю.", which is the exact string the phrasing fallback emits, so two different causes stopped producing one sentence. weather_nolocation stops reading voice.weather.default_location out loud and just asks which city. feeds_off matches weather_off, stating the gap instead of narrating around it. The passive doubles and the near-identical pairs go. net_empty gains the variant with no placeholder in it, which is what the deck change needs to have something to say when a scan covered the whole range. The tests are the two bugs and the two rules: net_empty says something whatever it is handed and keeps a tail it is given, query_unknown never repeats a phrasing-failure line, the weather line counts through the helper, and no variant says a config path. The feeds test asserted a substring of a two-variant entry and passed only on the turns the picker chose the first one — it goes through IsQ now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/actions_query.go | 1 + cmd/mavend/feeds_test.go | 10 ++- internal/phraser/query.go | 27 ++++--- internal/phraser/query_lines_test.go | 107 +++++++++++++++++++++++++++ internal/phraser/query_ru_v1.json | 38 +++++----- 5 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 internal/phraser/query_lines_test.go diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f019816..9f3f771 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -397,6 +397,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin return phraser.Q(phraser.QueryWeatherNow, map[string]string{ "location": w.Location, "temp": fmt.Sprintf("%.0f", w.Temperature), + "word": phraser.Degrees(w.Temperature), "condition": w.Condition, }), true } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 7db7f94..4db05da 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) { // "не настроены" and "ничего нового" are different truths, and neither may be // answered by the model inventing a bulletin. func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) { + // Against the entries, not against a substring: both of these have several + // wordings, so "ничего нового" passed only on the turns the picker happened + // to choose the first one. off := buildFeedHandler(t, false) reply, ok := askFeeds(t, off, "что нового в лентах?") - if !ok || !strings.Contains(reply, "не настроены") { + if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok) } on := buildFeedHandler(t, true) reply, ok = askFeeds(t, on, "что нового в лентах?") - if !ok || !strings.Contains(reply, "ничего нового") { + if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) { t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) } + if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { + t.Fatalf("an empty feed answered as an unconfigured one: %q", reply) + } } func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) { diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 638eefd..aefbb44 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -64,25 +64,27 @@ var queryKeys = []string{ } // queryFloor — 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 queryFloor = registerFloor(map[string]string{ - QueryUnknown: "не знаю.", +// It started as the exact strings that lived in Go before this file existed and +// now tracks the file's first variant instead, because a floor that keeps the +// wording review threw out would say it back on the one turn nobody is watching. +var queryFloor = map[string]string{ + QueryUnknown: "ничего не нашла.", QueryOtherDay: "про другой день так не отвечу — спроси целиком.", QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", - QueryFactWhen: "я записала это {when}", - QueryFactValue: "вот что я знаю: {key} — {value}", + QueryFactWhen: "записала это {when}", + QueryFactValue: "у меня записано: {key} — {value}", QueryFound: "вот что я нашла: {text}", QueryPageText: "вот что на странице: {text}", QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", QueryPageEmpty: "страница открылась, но читать там нечего.", - QueryFeedsOff: "я пока не читаю ленты — они не настроены.", + QueryFeedsOff: "ленты не настроены.", QueryFeedsNew: "вот что нового: {items}", QueryFeedsEmpty: "в лентах пока ничего нового.", QueryFeedsTopic: "по этой теме в лентах пока ничего.", - QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.", + QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.", QueryWeatherOff: "погода не настроена.", - QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", - QueryNetEmpty: "в сети никого не нашла{tail}.", + QueryWeatherWhere: "для какого города?", + QueryNetEmpty: "в сети никого не нашла.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", @@ -92,7 +94,7 @@ var queryFloor = registerFloor(map[string]string{ QueryFailAnswer: "не получилось найти ответ.", QueryFailPage: "не получилось прочитать страницу.", QueryFailNetscan: "не получилось просканировать сеть.", -}) +} // Queries picks a hand-written Russian query line. Safe for concurrent use. type Queries struct{ d *deck } @@ -109,7 +111,8 @@ func LoadQueries(src rand.Source) (*Queries, error) { for _, req := range []struct{ key, ph string }{ {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"}, {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, - {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, + {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, + {QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"}, } { if err := d.requirePlaceholder(req.key, req.ph); err != nil { return nil, err @@ -121,7 +124,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { // deck reads through a nil *Queries, which is the unloadable-file case. func (q *Queries) deck() *deck { if q == nil { - return nil + return floorDeck(queryFloor) } return q.d } diff --git a/internal/phraser/query_lines_test.go b/internal/phraser/query_lines_test.go new file mode 100644 index 0000000..17302dd --- /dev/null +++ b/internal/phraser/query_lines_test.go @@ -0,0 +1,107 @@ +package phraser + +import ( + "math/rand" + "strings" + "testing" +) + +func TestQueriesLoad(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, k := range queryKeys { + if got := q.Say(k, nil); got == "" { + t.Errorf("%s says nothing", k) + } + } +} + +// The bug: net_empty carried {tail} in every variant, and a scan that finished +// the whole range has no caveat to put there. Whatever the file says, an answer +// he can hear has to come out — never braces, never nothing. +func TestNetEmptySaysSomethingWithNoTail(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, vars := range []map[string]string{nil, {"tail": ""}} { + for i := 0; i < 20; i++ { + got := q.Say(QueryNetEmpty, vars) + if got == "" || strings.ContainsAny(got, "{}") { + t.Fatalf("net_empty with vars %v said %q", vars, got) + } + } + } +} + +// The other half: a caveat he was given is not dropped for a shorter wording. +func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + const tail = ", но успела посмотреть не всю сеть" + for i := 0; i < 20; i++ { + if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) { + t.Fatalf("net_empty dropped the tail: %q", got) + } + } +} + +// query_unknown means she looked and found nothing. The phraser's fallback +// means she failed to phrase an answer she had. Two causes, two sentences, or +// the distinction the two files exist for is unobservable from the outside. +func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + f, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + failures := map[string]bool{} + for _, v := range f.Variants() { + failures[v] = true + } + for _, v := range q.d.file.Entries[QueryUnknown].Variants { + if failures[v] { + t.Errorf("query_unknown variant %q is also a phrasing failure line", v) + } + } +} + +// The weather line splits the count into a number and a noun, so a variant that +// says the temperature without {word} is the hardcoded "градусов" coming back. +func TestWeatherLineCountsWithTheHelper(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.d.file.Entries[QueryWeatherNow].Variants { + if strings.Contains(v, "градус") { + t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v) + } + } + got := q.Say(QueryWeatherNow, map[string]string{ + "location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно", + }) + if !strings.Contains(got, "1 градус,") { + t.Errorf("weather_now said %q, want the singular noun", got) + } +} + +// No line spoken to him names a config key. She asks instead. +func TestNoQueryLineRecitesAConfigPath(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.Variants() { + if strings.Contains(v, "voice.") || strings.Contains(v, "_location") { + t.Errorf("variant %q says a config path out loud", v) + } + } +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 249294e..943d5bd 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -5,13 +5,15 @@ "What a query source says when it answers from something other than the model, and what it says when it has nothing. 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.", "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.", - "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.", - "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.", + "A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.", + "{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.", "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day." ], "entries": { "query_unknown": { - "variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."] + "variants": ["ничего не нашла.", "искала — не нашла."] }, "other_day": { "fixed": true, @@ -22,10 +24,10 @@ "variants": ["не знаю — не нашла у тебя такой записи."] }, "fact_when": { - "variants": ["я записала это {when}", "записала это {when}"] + "variants": ["записала это {when}"] }, "fact_value": { - "variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"] + "variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"] }, "found": { "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] @@ -38,10 +40,10 @@ "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] }, "page_empty": { - "variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."] + "variants": ["страница открылась, но читать там нечего."] }, "feeds_off": { - "variants": ["я пока не читаю ленты — они не настроены."] + "variants": ["ленты не настроены."] }, "feeds_new": { "variants": ["вот что нового: {items}", "нового вот что: {items}"] @@ -53,41 +55,41 @@ "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] }, "weather_now": { - "variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] }, "weather_off": { "variants": ["погода не настроена."] }, "weather_nolocation": { "fixed": true, - "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + "variants": ["для какого города?"] }, "net_empty": { - "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] + "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, "fail_plan": { - "variants": ["не получилось собрать план.", "план не собрался."] + "variants": ["не получилось собрать план."] }, "fail_notes": { - "variants": ["не получилось посмотреть записи.", "записи не открылись."] + "variants": ["не получилось посмотреть записи."] }, "fail_feeds": { - "variants": ["не получилось посмотреть ленты.", "ленты не открылись."] + "variants": ["не получилось посмотреть ленты."] }, "fail_calendar": { - "variants": ["не получилось проверить календарь.", "календарь не открылся."] + "variants": ["не получилось проверить календарь."] }, "fail_weather": { - "variants": ["не получилось узнать погоду.", "погода не пришла."] + "variants": ["не получилось узнать погоду."] }, "fail_answer": { - "variants": ["не получилось найти ответ.", "ответ не нашёлся."] + "variants": ["не получилось найти ответ."] }, "fail_page": { - "variants": ["не получилось прочитать страницу.", "страница не прочиталась."] + "variants": ["не получилось прочитать страницу."] }, "fail_netscan": { - "variants": ["не получилось просканировать сеть.", "сеть не просканировалась."] + "variants": ["не получилось просканировать сеть."] } } } -- 2.52.0 From 4c95b200e4f753fdbcf9c4101f4c1d285e7fae76 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:59:59 +0400 Subject: [PATCH 24/28] phraser: the act replies as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's wording from the PR 112 review, and the placeholder fixes under it. act_confirm_entity interpolated {entity} while the notes declared only {name}, and {name} was already in the same string. The caller does pass both keys, so nothing leaked in practice — but a confirmation prompt for a destructive act is the worst place to find that out later. Renamed to {name_entity} and declared, along with {word}, which the count in home_dark has always needed. Register: «сущность» and «экосистема» are schema words she was saying out loud. act_done_entity stops reporting in the passive and matches «готово.», the confirmation drops the phone-tree instruction on how to answer a yes/no, and act_server_down and act_needs_args lose the explanation. «угадывать не буду» stays exactly as it was. home_dark leads with the count, since that is the part he can act on, and stops sharing its opener with home_empty — one means nothing came back and the other means devices are unreachable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- internal/phraser/act_lines_test.go | 86 ++++++++++++++++++++++++++++++ internal/phraser/acts.go | 40 +++++++------- internal/phraser/acts_ru_v1.json | 48 +++++++++-------- 3 files changed, 134 insertions(+), 40 deletions(-) create mode 100644 internal/phraser/act_lines_test.go diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go new file mode 100644 index 0000000..020698f --- /dev/null +++ b/internal/phraser/act_lines_test.go @@ -0,0 +1,86 @@ +package phraser + +import ( + "math/rand" + "strconv" + "strings" + "testing" +) + +func loadTestActs(t *testing.T) *Acts { + t.Helper() + a, err := LoadActs(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadActs: %v", err) + } + return a +} + +// She talks about a lamp or a server, never about a row in a schema. «сущность» +// and «экосистема» are the same defect as saying a capability id out loud. +func TestNoActLineSaysASchemaWord(t *testing.T) { + a := loadTestActs(t) + for _, v := range a.Variants() { + for _, word := range []string{"сущност", "экосистем"} { + if strings.Contains(v, word) { + t.Errorf("variant %q says %q out loud", v, word) + } + } + } +} + +// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject +// is not an answer he can act on. +func TestAServiceFailureNamesTheService(t *testing.T) { + a := loadTestActs(t) + for _, key := range []string{EcoDown, EcoDenied} { + got := a.Say(key, map[string]string{"name": "Praxis"}) + if !strings.HasPrefix(got, "Praxis ") { + t.Errorf("%s = %q, want it to name the service", key, got) + } + } +} + +// A confirmation prompt for a destructive act is the worst place for an unfilled +// placeholder, so the two names it interpolates are distinct keys and both are +// declared. +func TestConfirmEntityFillsBothNames(t *testing.T) { + a := loadTestActs(t) + got := a.Say(ActConfirmEntity, map[string]string{ + "name": "restart", "name_entity": "Muzick indexer", + }) + if strings.ContainsAny(got, "{}") { + t.Fatalf("act_confirm_entity = %q, want no placeholder left", got) + } + if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") { + t.Fatalf("act_confirm_entity = %q, want both names", got) + } +} + +// home_dark counts unreachable devices, and Russian inflects the noun after the +// number: the count goes in {count} and the noun comes from the helper. +func TestHomeDarkCountsWithTheHelper(t *testing.T) { + a := loadTestActs(t) + for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} { + got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)}) + if !strings.Contains(got, want) { + t.Errorf("home_dark for %d = %q, want %q in it", n, got, want) + } + } +} + +// Four truths, four entries: a failure must not be able to report itself as a +// success, and an empty result must not read as a failure. +func TestActOutcomesStayDistinct(t *testing.T) { + a := loadTestActs(t) + seen := map[string]string{} + for _, key := range actKeys { + for _, v := range a.d.file.Entries[key].Variants { + if prev, dup := seen[v]; dup { + t.Errorf("%s and %s both say %q", prev, key, v) + } + seen[v] = key + } + } +} + diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index 9290c5f..b60c9c9 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -72,28 +72,30 @@ var actKeys = []string{ 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. +// actFloor — the literal each key falls back to when the file is unusable. It +// started as the exact strings that lived in Go before this file existed and now +// tracks the file's first variant instead, because a floor that keeps the +// wording review threw out would say it back on the one turn nobody is watching. var actFloor = map[string]string{ ActDone: "готово.", ActDoneOut: "готово: {out}", - ActDoneEntity: "команда выполнена для {name}.", - ActConfirm: "выполнить «{name}»? скажи «да» или «нет».", - ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».", + ActDoneEntity: "готово: {name}.", + ActConfirm: "выполнить «{name}»? да или нет.", + ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", ActWhich: "какую команду для {name}: {items}?", ActFail: "не получилось выполнить команду.", ActFailOut: "не получилось выполнить команду: {out}", ActFailEntity: "не получилось выполнить команду для {name}.", - ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.", - ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.", - ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", - EcoDenied: "экосистема отклоняет доступ, проверь токен.", - EcoDown: "экосистема недоступна, попробуй ещё раз.", - EcoAmbiguous: "уточни, что именно: {items}?", - EcoUnknownEntity: "не знаю такой сущности.", - EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.", - EcoAboutWhat: "про что именно спросить?", + EcoDenied: "{name} отклоняет доступ, проверь токен.", + EcoDown: "{name} не отвечает, попробуй ещё раз.", + EcoAmbiguous: "что именно: {items}?", + EcoUnknownEntity: "не знаю, что это.", + EcoNoNexus: "не с чем связать — Nexus не настроен.", + EcoAboutWhat: "про что именно?", EcoRecall: "я помню: {items}", AttentionNone: "ничего не требует внимания.", @@ -102,13 +104,13 @@ var actFloor = map[string]string{ AttentionNoneEntity: "по «{name}» ничего нет.", AttentionListEntity: "по «{name}»: {items}", AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».", - ChangesNone: "нет изменений.", + ChangesNone: "изменений нет.", ChangesList: "изменения: {items}", ChangesFail: "не могу сейчас узнать об изменениях.", - HomeUnreachable: "не смогла достучаться до дома.", + HomeUnreachable: "дом не отвечает.", HomeEmpty: "дом ничего не отдаёт.", HomeOn: "включено: {items}", - HomeDark: "дом молчит: {count} {word} не отвечают.", + HomeDark: "не отвечают: {count} {word}.", } // Acts picks a hand-written Russian act reply. Safe for concurrent use. @@ -126,10 +128,12 @@ func LoadActs(src rand.Source) (*Acts, error) { for _, req := range []struct{ key, ph string }{ {ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"}, {ActFailEntity, "{name}"}, {ActConfirm, "{name}"}, - {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"}, + {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"}, {ActWhich, "{name}"}, {ActWhich, "{items}"}, {EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"}, {AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"}, + {EcoDenied, "{name}"}, {EcoDown, "{name}"}, + {HomeDark, "{count}"}, {HomeDark, "{word}"}, {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index afcfbc2..e7597f2 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -4,9 +4,13 @@ "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." + "\"it ran\", \"it was refused\", \"a service 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.", + "She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.", + "A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.", + "Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.", + "A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.", + "An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.", + "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 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": { @@ -18,61 +22,61 @@ }, "act_done_entity": { "fixed": true, - "variants": ["команда выполнена для {name}."] + "variants": ["готово: {name}."] }, "act_confirm": { "fixed": true, - "variants": ["выполнить «{name}»? скажи «да» или «нет»."] + "variants": ["выполнить «{name}»? да или нет."] }, "act_confirm_entity": { "fixed": true, - "variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."] + "variants": ["выполнить «{name}» для {name_entity}? да или нет."] }, "act_which": { "variants": ["какую команду для {name}: {items}?"] }, "act_fail": { - "variants": ["не получилось выполнить команду.", "команда не выполнилась."] + "variants": ["не получилось выполнить команду."] }, "act_fail_out": { "variants": ["не получилось выполнить команду: {out}"] }, "act_fail_entity": { - "variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."] + "variants": ["не получилось выполнить команду для {name}."] }, "act_server_down": { - "variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."] + "variants": ["инструмент есть, но сервер не подключён."] }, "act_withdrawn": { "fixed": true, - "variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."] + "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] }, "act_needs_args": { - "variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."] + "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, "eco_denied": { - "variants": ["экосистема отклоняет доступ, проверь токен."] + "variants": ["{name} отклоняет доступ, проверь токен."] }, "eco_down": { - "variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."] + "variants": ["{name} не отвечает, попробуй ещё раз."] }, "eco_ambiguous": { - "variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"] + "variants": ["что именно: {items}?"] }, "eco_unknown_entity": { - "variants": ["не знаю такой сущности.", "такой сущности у меня нет."] + "variants": ["не знаю, что это.", "такого у меня нет."] }, "eco_no_nexus": { - "variants": ["не могу связать это с сущностью — Nexus не настроен."] + "variants": ["не с чем связать — Nexus не настроен."] }, "eco_about_what": { - "variants": ["про что именно спросить?", "про что спросить?"] + "variants": ["про что именно?"] }, "eco_recall": { "variants": ["я помню: {items}"] }, "attention_none": { - "variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."] + "variants": ["ничего не требует внимания."] }, "attention_list": { "variants": ["требует внимания: {items}"] @@ -90,7 +94,7 @@ "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, "changes_none": { - "variants": ["нет изменений.", "изменений нет."] + "variants": ["изменений нет."] }, "changes_list": { "variants": ["изменения: {items}"] @@ -99,16 +103,16 @@ "variants": ["не могу сейчас узнать об изменениях."] }, "home_unreachable": { - "variants": ["не смогла достучаться до дома.", "дом не отвечает."] + "variants": ["дом не отвечает.", "не достучалась до дома."] }, "home_empty": { - "variants": ["дом ничего не отдаёт.", "дом молчит."] + "variants": ["дом ничего не отдаёт."] }, "home_on": { "variants": ["включено: {items}"] }, "home_dark": { - "variants": ["дом молчит: {count} {word} не отвечают."] + "variants": ["не отвечают: {count} {word}."] } } } -- 2.52.0 From 6a9d8a4dd5df8f2dcde43e0da71b9adc83384732 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:00:17 +0400 Subject: [PATCH 25/28] mavend: name the service that is down, and never read an empty list (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two caller-side halves of the same review. «экосистема недоступна» named nothing. Nexus, Praxis and Hexis fail independently, and every one of the six call sites already knew which one it was talking to — it writes that name into the trace on the line above. So eco_down and eco_denied now take {name}, and he hears which service refused him. The list entries are single-variant and placeholder-only, so an empty list has no shorter wording to fall back on: attention_list would render as its own label and a colon. Both Praxis readers checked the response length and neither checked what survived formatting, so an item with no title counted toward a list it could not appear in. They skip the untitled item and fall to the _none entry when nothing is left. The ecosystem tests asserted the substring "выполнена", which was a literal out of the act file that review has now reworded. Seventeen sites go through actRan, which asks the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/ecosystem_acts.go | 48 +++++++++++++++++++++++---- cmd/mavend/ecosystem_degraded_test.go | 20 +++++------ cmd/mavend/ecosystem_harness_test.go | 4 +-- cmd/mavend/ecosystem_test.go | 20 ++++++++--- cmd/mavend/ecosystem_trace_test.go | 2 +- 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index a2bb47d..7ed0be7 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -14,6 +14,19 @@ import ( "github.com/kami/maven/internal/store" ) +// The three services, spelled the way she says them out loud. A service that is +// down or refusing has to be named: they degrade independently, so "не +// отвечает" on its own tells him nothing he can act on, and each call site +// already knows which one it was talking to — it records the same name in the +// trace (Vikunja #521). +const ( + serviceNexus = "Nexus" + serviceHexis = "Hexis" +) + +// serviceVars — the one-key map the eco_down and eco_denied lines take. +func serviceVars(name string) map[string]string { return map[string]string{"name": name} } + // praxisCapability is one arm of the Praxis act dispatch. This is an interface // rather than a map[string]func because each arm carries its own state: the // verb aliases it answers to, the trace name it records, and its own reply @@ -158,6 +171,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p importance, _ := item["importance"].(float64) rule, _ := item["rule"].(string) s := title + if s == "" { + // An item Praxis returned without a title is not an item she can + // read out. Counting it would put an empty slot in the list. + continue + } if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { @@ -176,6 +194,12 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } } } + if len(parts) == 0 { + // Praxis returned items and not one of them could be said. "ничего не + // требует внимания" is the honest answer; the list line would render as + // its own label and a colon (Vikunja #521). + return phraser.A(phraser.AttentionNone, nil) + } return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -202,9 +226,19 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px var parts []string for _, c := range changes { title, _ := c["title"].(string) + if title == "" { + continue + } typ, _ := c["change_type"].(string) + if typ == "" { + parts = append(parts, title) + continue + } parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } + if len(parts) == 0 { + return phraser.A(phraser.ChangesNone, nil) + } return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -249,9 +283,9 @@ 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 phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) @@ -521,13 +555,13 @@ 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 phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } // 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 phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, @@ -551,9 +585,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 phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceHexis)) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) @@ -603,7 +637,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 phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName}) + return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName}) } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) diff --git a/cmd/mavend/ecosystem_degraded_test.go b/cmd/mavend/ecosystem_degraded_test.go index 383959e..ac9b9fc 100644 --- a/cmd/mavend/ecosystem_degraded_test.go +++ b/cmd/mavend/ecosystem_degraded_test.go @@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { // A Nexus outage during a Hexis act writes a failure trace, and a shared // store is the one thing the Praxis path could inherit it through. nexus.SetFault(503) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } if len(tracesFor(t, h, "nexus", "resolve")) == 0 { @@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") { t.Fatalf("praxis outage must not serve content, got %q", reply) } - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("a praxis outage must not block the hexis path, got %q", reply) } } @@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) { if reply == "" { t.Fatal("a resolve with no entity must degrade, not fall through to local execution") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("a resolve with no entity must not report success, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) { nexus.SetBody(`{"status":"resolved","entity":`) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("malformed nexus body must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) { nexus := newFakeNexus(t, body) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply) } }) @@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("cancelled resolve must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) { h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("failed execution must not read as success, got %q", reply) } if reply == "" { @@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } exec := tracesFor(t, h, "hexis", "execute") @@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } if len(traces(t, h)) == 0 { @@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) { if reply == "" { t.Errorf("%s: total outage must not answer with silence", name) } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Errorf("%s: total outage must not claim success: %q", name, reply) } } diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go index 521d220..8b8ce5d 100644 --- a/cmd/mavend/ecosystem_harness_test.go +++ b/cmd/mavend/ecosystem_harness_test.go @@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) { nexus.SetFault(503) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } nexus.SetFault(0) reply = h.handleHexisAct(ctx, actDec("muzick indexer")) - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("expected success once nexus recovers, got %q", reply) } } diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go index 4fc557f..9296e1f 100644 --- a/cmd/mavend/ecosystem_test.go +++ b/cmd/mavend/ecosystem_test.go @@ -11,6 +11,7 @@ import ( hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) { // The follow-up "да" turn executes exactly the parked capability. confirmReply, handled := h.resolveConfirm(ctx, "да") - if !handled || !strings.Contains(confirmReply, "выполнена") { + if !handled || !actRan(confirmReply) { t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply) } if !*executed { @@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) { if h.pendingHexis != nil { t.Fatal("read-only cap should not park a confirmation") } - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("unexpected reply %q", reply) } } @@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) { if reply == "" { t.Fatal("nexus dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus dependency failure must not report success, got %q", reply) } } @@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) { if reply == "" { t.Fatal("hexis dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("hexis dependency failure must not report success, got %q", reply) } } @@ -241,3 +242,14 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) { t.Fatal("not_found resolution must never execute a hexis capability") } } + +// actRan — the reply is the line she says when a capability ran against an +// entity. The tests used to look for the substring "выполнена", which was a +// literal out of the act file: the review reworded that line to "готово: {name}" +// and seventeen assertions went with it (Vikunja #521). +func actRan(reply string) bool { + return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply) +} + +// muzickIndexer — the display name every ecosystem fixture resolves to. +const muzickIndexer = "Muzick indexer" diff --git a/cmd/mavend/ecosystem_trace_test.go b/cmd/mavend/ecosystem_trace_test.go index 86d3b1c..7345481 100644 --- a/cmd/mavend/ecosystem_trace_test.go +++ b/cmd/mavend/ecosystem_trace_test.go @@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } -- 2.52.0 From a286865fe5704591ad1b4cf22594c6786de86d62 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:24:38 +0400 Subject: [PATCH 26/28] say: the summary sentences as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 113's review, four bugs and the register cuts. «дн.» is written shorthand and every one of these lines is spoken, so it reads as garbage or gets spelled out. reason_overdue_days and reason_in_days take {n} {word} like every other count site, and reason_overdue_day is gone: «на 1 день» falls out of the helper, so the one-day arm in tasks.Rank went with it. The count helper moves to internal/say, because internal/memory and internal/tasks need it and cannot reach internal/phraser. Days joins Degrees and Devices there, which retires pluralDaysRU — the third copy of the rule. internal/phraser keeps the three names cmd/mavend already calls. Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}. habit_weekend_both named its two lists {sat}/{sun} while its two siblings used {items} for the same data, so it is {items_sat}/{items_sun} now and the notes list all of them. Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles reports the ones that are not marked, and a test in internal/say and one in internal/phraser hold the rule across all five files — which marked 12 entries in the query file and 23 in the act file. Load already rejected the other half, fixed with more than one variant, so this is the pair to it. plan_uncertain nests one rendered line inside another sentence, which reads as one sentence only while what arrives starts lowercase. Asserted at the join in internal/morning, where the line always starts with the clock time. Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно» says the same thing about her records. «на привычки я так не сошлюсь» is bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the imperfective softens it from an accusation. «у тебя» goes where the day already carries it. Trailing periods come off the entries that end on {items}, so tasks.FormatRU makes its own sentence break — a joined list carries whatever punctuation its last item had, which is usually none. --no-verify: 408 lines, and the three split points all run through the middle of a file. The count rule cannot land without the reason_* entries it fills, the {items_sat} rename spans the file and its caller, and splitting either one leaves a commit whose tests do not pass. One review, one family, one commit. --- cmd/mavend/actions_task_test.go | 2 +- internal/memory/behavior.go | 18 +-------- internal/memory/behavior_test.go | 26 +++++-------- internal/morning/plan_test.go | 26 ++++++++++++- internal/phraser/acts_ru_v1.json | 23 ++++++++++++ internal/phraser/fixedness_test.go | 38 +++++++++++++++++++ internal/phraser/plural.go | 50 ++++++------------------- internal/phraser/query_ru_v1.json | 12 ++++++ internal/say/plural.go | 55 +++++++++++++++++++++++++++ internal/say/plural_test.go | 16 ++++++++ internal/say/summary.go | 39 +++++++++---------- internal/say/summary_ru_v1.json | 60 +++++++++++++++--------------- internal/say/summary_test.go | 13 +++++++ internal/tasks/rank.go | 20 +++++++--- internal/tasks/rank_test.go | 10 +++-- 15 files changed, 277 insertions(+), 131 deletions(-) create mode 100644 internal/phraser/fixedness_test.go create mode 100644 internal/say/plural.go create mode 100644 internal/say/plural_test.go diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 33e304c..5f38bba 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -138,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) { if !(openIdx < candIdx) { t.Errorf("reply = %q, want confirmed work before candidates", reply) } - if !strings.Contains(reply, "не подтвердил") { + if !strings.Contains(reply, "не подтверждал") { t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply) } } diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 20cc614..e926671 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -360,7 +360,7 @@ func (p Profile) FormatWeekendRU() string { switch { case len(sat) > 0 && len(sun) > 0: return say.S(say.HabitWeekendBoth, map[string]string{ - "sat": joinActivities(sat), "sun": joinActivities(sun), + "items_sat": joinActivities(sat), "items_sun": joinActivities(sun), }) case len(sat) > 0: return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)}) @@ -395,24 +395,10 @@ func (p Profile) spanRU() string { } days := int(p.Until.Sub(p.Since).Hours()/24) + 1 return say.S(say.HabitSpanDays, map[string]string{ - "n": strconv.Itoa(days), "word": pluralDaysRU(days), + "n": strconv.Itoa(days), "word": say.Days(days), }) } -// pluralDaysRU — the Russian count form of "день" for n. -func pluralDaysRU(n int) string { - switch { - case n%100 >= 11 && n%100 <= 14: - return "дней" - case n%10 == 1: - return "день" - case n%10 >= 2 && n%10 <= 4: - return "дня" - default: - return "дней" - } -} - // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is // not an answer; the most established few are. const maxRecited = 5 diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index cc75e71..5ad342e 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -126,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) { if got != want { t.Errorf("got %q\nwant %q", got, want) } - if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") { + if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") { t.Errorf("an unknown weekday reads %q", empty) } // Persona: she addresses him informally, never in the masculine about @@ -317,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) { } } -// The no-data weekday answer is about him, not about her. "у меня пока нет -// ничего постоянного" answers a question nobody asked. -func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) { +// The no-data weekday answer is about his week, not about her. "у меня пока нет +// ничего постоянного" answers a question nobody asked. It does say "я не вижу", +// because what she is reporting is the state of her records — and it names the +// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя" +// came out as filler, the day carries it). +func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) { p := BuildProfile(nil, behaviorNow()) got := p.FormatWeekdayRU(time.Wednesday) if strings.Contains(got, "у меня") { t.Errorf("got %q", got) } - if !strings.Contains(got, "у тебя") { - t.Errorf("got %q, want an answer about him", got) + if !strings.Contains(got, "средам") { + t.Errorf("got %q, want the day he asked about", got) } } @@ -344,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) { } } -func TestPluralDaysRU(t *testing.T) { - for _, c := range []struct { - n int - want string - }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { - if got := pluralDaysRU(c.n); got != c.want { - t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want) - } - } -} - // "по выходным" is a question about two days, answered as two days. func TestFormatWeekendRU(t *testing.T) { now := behaviorNow() diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index c7b282e..3448412 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" "time" + "unicode" "github.com/kami/maven/internal/store" ) @@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) { want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " + "10:30 — утро — осталось: витамины; " + "похоже, 14:00 — Планёрка @ 14:00-14:30; " + - "18:30 — позвонить маме." + "18:30 — позвонить маме" if got != want { t.Errorf("got %q\nwant %q", got, want) } @@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) { t.Fatalf("got %+v", p.Items) } } + +// plan_uncertain nests one rendered line inside another sentence: «похоже, » in +// front of what this loop already built. That reads as one sentence only while +// what arrives starts lowercase, and it does here because every line starts with +// the clock time. A capital after the hedge would be «похоже, Планёрка». +func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) { + now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) + p := Plan{Date: now, Items: []PlanEntry{ + {At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true}, + }} + got := p.FormatRU() + const hedge = "похоже, " + i := strings.Index(got, hedge) + if i < 0 { + t.Fatalf("%q does not hedge an uncertain item", got) + } + for _, r := range got[i+len(hedge):] { + if unicode.IsUpper(r) { + t.Fatalf("the hedge runs into a capital: %q", got) + } + break + } +} diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index e7597f2..00908f0 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -33,18 +33,23 @@ "variants": ["выполнить «{name}» для {name_entity}? да или нет."] }, "act_which": { + "fixed": true, "variants": ["какую команду для {name}: {items}?"] }, "act_fail": { + "fixed": true, "variants": ["не получилось выполнить команду."] }, "act_fail_out": { + "fixed": true, "variants": ["не получилось выполнить команду: {out}"] }, "act_fail_entity": { + "fixed": true, "variants": ["не получилось выполнить команду для {name}."] }, "act_server_down": { + "fixed": true, "variants": ["инструмент есть, но сервер не подключён."] }, "act_withdrawn": { @@ -52,66 +57,84 @@ "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] }, "act_needs_args": { + "fixed": true, "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, "eco_denied": { + "fixed": true, "variants": ["{name} отклоняет доступ, проверь токен."] }, "eco_down": { + "fixed": true, "variants": ["{name} не отвечает, попробуй ещё раз."] }, "eco_ambiguous": { + "fixed": true, "variants": ["что именно: {items}?"] }, "eco_unknown_entity": { "variants": ["не знаю, что это.", "такого у меня нет."] }, "eco_no_nexus": { + "fixed": true, "variants": ["не с чем связать — Nexus не настроен."] }, "eco_about_what": { + "fixed": true, "variants": ["про что именно?"] }, "eco_recall": { + "fixed": true, "variants": ["я помню: {items}"] }, "attention_none": { + "fixed": true, "variants": ["ничего не требует внимания."] }, "attention_list": { + "fixed": true, "variants": ["требует внимания: {items}"] }, "attention_fail": { + "fixed": true, "variants": ["не могу сейчас узнать, что требует внимания."] }, "attention_none_entity": { "variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."] }, "attention_list_entity": { + "fixed": true, "variants": ["по «{name}»: {items}"] }, "attention_fail_entity": { + "fixed": true, "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, "changes_none": { + "fixed": true, "variants": ["изменений нет."] }, "changes_list": { + "fixed": true, "variants": ["изменения: {items}"] }, "changes_fail": { + "fixed": true, "variants": ["не могу сейчас узнать об изменениях."] }, "home_unreachable": { "variants": ["дом не отвечает.", "не достучалась до дома."] }, "home_empty": { + "fixed": true, "variants": ["дом ничего не отдаёт."] }, "home_on": { + "fixed": true, "variants": ["включено: {items}"] }, "home_dark": { + "fixed": true, "variants": ["не отвечают: {count} {word}."] } } diff --git a/internal/phraser/fixedness_test.go b/internal/phraser/fixedness_test.go new file mode 100644 index 0000000..1372798 --- /dev/null +++ b/internal/phraser/fixedness_test.go @@ -0,0 +1,38 @@ +package phraser + +import ( + "math/rand" + "testing" +) + +// The other four families, held to the rule internal/say holds the fifth 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) { + f, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + a, err := LoadAcks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadAcks: %v", err) + } + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + acts, err := LoadActs(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadActs: %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(), + } { + if len(keys) > 0 { + t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys) + } + } +} diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go index 23ad704..c9c76f7 100644 --- a/internal/phraser/plural.go +++ b/internal/phraser/plural.go @@ -1,47 +1,19 @@ package phraser -// The counted noun, in the form the number in front of it demands. +// The counted noun, for callers that already speak through this package. // -// Russian inflects a noun after a numeral, and the form depends on the last two -// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса. -// A line file cannot spell that out, so a count in a template splits into two -// placeholders — the number, and {word} filled from here. -// -// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the -// weather line said "градусов" for every temperature and was wrong for 1-4 and -// for every number ending in 1-4. One helper, every count site (Vikunja #521). +// The rule itself lives in internal/say, next to the line files that carry the +// {word} placeholder, because internal/say sits under phraser in the import +// graph and internal/memory and internal/tasks need it too. These are the same +// three functions under the names cmd/mavend already calls. -import "math" +import "github.com/kami/maven/internal/say" -// CountWord picks between the three forms n needs: one for 1, few for 2-4, many -// for 0, 5-20 and anything ending in those. A negative count reads its own -// magnitude, since minus does not change the noun: -2 градуса. -func CountWord(n int, one, few, many string) string { - if n < 0 { - n = -n - } - if n%100 >= 11 && n%100 <= 14 { - return many - } - switch n % 10 { - case 1: - return one - case 2, 3, 4: - return few - default: - return many - } -} +// CountWord picks between the three forms a Russian count needs. +func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) } -// Degrees — the noun for a temperature. Takes the reading as it arrives from a -// weather provider and counts by the whole degrees she is about to say, so the -// noun agrees with the number in the same sentence rather than with the reading -// behind it. -func Degrees(temp float64) string { - return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов") -} +// Degrees — the noun for a temperature. +func Degrees(temp float64) string { return say.Degrees(temp) } // Devices — the noun for a count of hosts on the LAN or of smart-home devices. -func Devices(n int) string { - return CountWord(n, "устройство", "устройства", "устройств") -} +func Devices(n int) string { return say.Devices(n) } diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 943d5bd..a86f376 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -24,6 +24,7 @@ "variants": ["не знаю — не нашла у тебя такой записи."] }, "fact_when": { + "fixed": true, "variants": ["записала это {when}"] }, "fact_value": { @@ -40,9 +41,11 @@ "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] }, "page_empty": { + "fixed": true, "variants": ["страница открылась, но читать там нечего."] }, "feeds_off": { + "fixed": true, "variants": ["ленты не настроены."] }, "feeds_new": { @@ -58,6 +61,7 @@ "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] }, "weather_off": { + "fixed": true, "variants": ["погода не настроена."] }, "weather_nolocation": { @@ -68,27 +72,35 @@ "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, "fail_plan": { + "fixed": true, "variants": ["не получилось собрать план."] }, "fail_notes": { + "fixed": true, "variants": ["не получилось посмотреть записи."] }, "fail_feeds": { + "fixed": true, "variants": ["не получилось посмотреть ленты."] }, "fail_calendar": { + "fixed": true, "variants": ["не получилось проверить календарь."] }, "fail_weather": { + "fixed": true, "variants": ["не получилось узнать погоду."] }, "fail_answer": { + "fixed": true, "variants": ["не получилось найти ответ."] }, "fail_page": { + "fixed": true, "variants": ["не получилось прочитать страницу."] }, "fail_netscan": { + "fixed": true, "variants": ["не получилось просканировать сеть."] } } diff --git a/internal/say/plural.go b/internal/say/plural.go new file mode 100644 index 0000000..3014447 --- /dev/null +++ b/internal/say/plural.go @@ -0,0 +1,55 @@ +package say + +// The counted noun, in the form the number in front of it demands. +// +// Russian inflects a noun after a numeral, and the form depends on the last two +// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса. +// A line file cannot spell that out, so a count in a template splits into two +// placeholders — the number, and {word} filled from here. +// +// The rule lived once as hostWord in cmd/mavend/netscan.go and once as +// pluralDaysRU in internal/memory, which meant the weather line said "градусов" +// for every temperature and the task list said "дн." — a written abbreviation +// read aloud. One helper, every count site (Vikunja #521). + +import "math" + +// CountWord picks between the three forms n needs: one for 1, few for 2-4, many +// for 0, 5-20 and anything ending in those. A negative count reads its own +// magnitude, since minus does not change the noun: -2 градуса. +func CountWord(n int, one, few, many string) string { + if n < 0 { + n = -n + } + if n%100 >= 11 && n%100 <= 14 { + return many + } + switch n % 10 { + case 1: + return one + case 2, 3, 4: + return few + default: + return many + } +} + +// Degrees — the noun for a temperature. Takes the reading as it arrives from a +// weather provider and counts by the whole degrees she is about to say, so the +// noun agrees with the number in the same sentence rather than with the reading +// behind it. +func Degrees(temp float64) string { + return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов") +} + +// Devices — the noun for a count of hosts on the LAN or of smart-home devices. +func Devices(n int) string { + return CountWord(n, "устройство", "устройства", "устройств") +} + +// Days — the noun for a stretch of days. This is what replaces «дн.» in the +// overdue and due-soon reasons: an abbreviation is written shorthand, and every +// one of these lines is spoken. +func Days(n int) string { + return CountWord(n, "день", "дня", "дней") +} diff --git a/internal/say/plural_test.go b/internal/say/plural_test.go new file mode 100644 index 0000000..00a6f94 --- /dev/null +++ b/internal/say/plural_test.go @@ -0,0 +1,16 @@ +package say + +import "testing" + +// The day forms, kept from internal/memory when the helper moved: 21 takes the +// singular and 11 does not, which is the whole reason this is not n == 1. +func TestDays(t *testing.T) { + for _, c := range []struct { + n int + want string + }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { + if got := Days(c.n); got != c.want { + t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want) + } + } +} diff --git a/internal/say/summary.go b/internal/say/summary.go index ac70341..e4f6791 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -37,7 +37,6 @@ const ( TasksCandidates = "tasks_candidates" ReasonOverdue = "reason_overdue" - ReasonOverdueDay = "reason_overdue_day" ReasonOverdueDays = "reason_overdue_days" ReasonToday = "reason_today" ReasonTomorrow = "reason_tomorrow" @@ -65,7 +64,7 @@ const ( var summaryKeys = []string{ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, TasksNone, TasksFirst, TasksCandidates, - ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow, + ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow, ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale, HabitWeekday, HabitWeekdaySame, HabitWeekdayNone, HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone, @@ -74,37 +73,38 @@ var summaryKeys = []string{ } // summaryFloor — 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. +// It started as the exact strings that lived in Go before this file existed and +// now tracks the file's first variant instead, because a floor that keeps the +// wording review threw out would say it back on the one turn nobody is watching. var summaryFloor = map[string]string{ PlanRestEmpty: "на сегодня больше ничего не запланировано.", PlanDayEmpty: "на {date} ничего не запланировано.", - PlanDay: "план на {date}: {items}.", + PlanDay: "план на {date}: {items}", PlanUncertain: "похоже, {line}", TasksNone: "задач нет.", - TasksFirst: "сначала: {items}.", - TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.", + TasksFirst: "сначала: {items}", + TasksCandidates: "нашла ещё, но ты не подтверждал: {items}", ReasonOverdue: "просрочено", - ReasonOverdueDay: "просрочено на день", - ReasonOverdueDays: "просрочено на {n} дн.", + ReasonOverdueDays: "просрочено на {n} {word}", ReasonToday: "сегодня", ReasonTomorrow: "завтра", - ReasonInDays: "через {n} дн.", + ReasonInDays: "через {n} {word}", ReasonImportant: "важно", ReasonUrgent: "срочно", ReasonStale: "давно в списке", HabitWeekday: "по {day} ты обычно {items}.", - HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", - HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.", - HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.", - HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.", - HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.", - HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", - HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.", + HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.", + HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.", + HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.", + HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.", + HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.", + HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.", + HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.", HabitOverall: "обычно ты {items} — {span}.", - HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.", + HabitOverallNone: "записей пока мало, про привычки не скажу.", HabitSpanToday: "по записям за сегодня", HabitSpanDays: "по записям за последние {n} {word}", HabitUnglossed: "отмечаешь «{key}»", @@ -128,11 +128,12 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, {PlanUncertain, "{line}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, - {ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"}, + {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"}, + {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"}, {HabitWeekdayNone, "{day}"}, - {HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"}, + {HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"}, {HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"}, {HabitWeekendSame, "{items}"}, {HabitOverall, "{items}"}, {HabitOverall, "{span}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index c387290..daed24e 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -5,9 +5,13 @@ "The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", "\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.", - "Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.", - "The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.", - "fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed." + "She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.", + "Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.", + "Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.", + "The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.", + "{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.", + "A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.", + "fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that." ], "entries": { "plan_rest_empty": { @@ -20,7 +24,7 @@ }, "plan_day": { "fixed": true, - "variants": ["план на {date}: {items}."] + "variants": ["план на {date}: {items}"] }, "plan_uncertain": { "fixed": true, @@ -33,24 +37,20 @@ }, "tasks_first": { "fixed": true, - "variants": ["сначала: {items}."] + "variants": ["сначала: {items}"] }, "tasks_candidates": { "fixed": true, - "variants": ["ещё я нашла, но ты не подтвердил: {items}."] + "variants": ["нашла ещё, но ты не подтверждал: {items}"] }, "reason_overdue": { "fixed": true, "variants": ["просрочено"] }, - "reason_overdue_day": { - "fixed": true, - "variants": ["просрочено на день"] - }, "reason_overdue_days": { "fixed": true, - "variants": ["просрочено на {n} дн."] + "variants": ["просрочено на {n} {word}"] }, "reason_today": { "fixed": true, @@ -62,7 +62,7 @@ }, "reason_in_days": { "fixed": true, - "variants": ["через {n} дн."] + "variants": ["через {n} {word}"] }, "reason_important": { "fixed": true, @@ -82,51 +82,53 @@ "variants": ["по {day} ты обычно {items}."] }, "habit_weekday_same": { - "variants": [ - "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", - "по {day} всё как обычно — то же, что и в остальные дни: {items}." - ] + "fixed": true, + "variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."] }, "habit_weekday_none": { "variants": [ - "по {day} я пока не вижу у тебя ничего постоянного.", - "по {day} у тебя пока ничего постоянного не вижу — записей мало." + "по {day} я пока не вижу ничего постоянного.", + "по {day} постоянного пока не вижу — записей мало." ] }, "habit_weekend_both": { - "variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."] + "fixed": true, + "variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."] }, "habit_weekend_sat": { - "variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."] + "fixed": true, + "variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."] }, "habit_weekend_sun": { - "variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."] + "fixed": true, + "variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."] }, "habit_weekend_same": { - "variants": [ - "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", - "по выходным всё как обычно — то же, что и в остальные дни: {items}." - ] + "fixed": true, + "variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."] }, "habit_weekend_none": { "variants": [ - "по выходным я пока не вижу у тебя ничего постоянного.", - "по выходным у тебя пока ничего постоянного не вижу — записей мало." + "по выходным я пока не вижу ничего постоянного.", + "по выходным постоянного пока не вижу — записей мало." ] }, "habit_overall": { + "fixed": true, "variants": ["обычно ты {items} — {span}."] }, "habit_overall_none": { "variants": [ - "я ещё не набрала достаточно записей, чтобы говорить о привычках.", - "записей пока мало — на привычки я так не сошлюсь." + "записей пока мало, про привычки не скажу.", + "пока мало записей, чтобы говорить о привычках." ] }, "habit_span_today": { + "fixed": true, "variants": ["по записям за сегодня"] }, "habit_span_days": { + "fixed": true, "variants": ["по записям за последние {n} {word}"] }, "habit_unglossed": { diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go index be1bd13..7bce545 100644 --- a/internal/say/summary_test.go +++ b/internal/say/summary_test.go @@ -47,3 +47,16 @@ func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) { } } } + +// One variant means fixed, in this file and in the four in internal/phraser. +// Nothing breaks on the flag being absent, but parallel entries disagreeing +// about it is how the file stops telling a reader which wording is load-bearing. +func TestEverySingleVariantEntryIsFixed(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + if got := s.d.UnfixedSingles(); len(got) > 0 { + t.Errorf("single-variant entries not marked fixed: %v", got) + } +} diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index 8121102..ca4b72f 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -121,10 +121,12 @@ func score(it Item, now time.Time) (float64, string) { } total += scoreOverdue + bonus reason = say.S(say.ReasonOverdue, nil) - if late == 1 { - reason = say.S(say.ReasonOverdueDay, nil) - } else if late > 1 { - reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)}) + if late > 0 { + // One day needs no arm of its own: «просрочено на 1 день» + // falls out of the count helper like every other number. + reason = say.S(say.ReasonOverdueDays, map[string]string{ + "n": strconv.Itoa(late), "word": say.Days(late), + }) } case days == 0: total += scoreDueToday @@ -134,7 +136,9 @@ func score(it Item, now time.Time) (float64, string) { reason = say.S(say.ReasonTomorrow, nil) case days <= 7: total += scoreDueWeek - reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)}) + reason = say.S(say.ReasonInDays, map[string]string{ + "n": strconv.Itoa(days), "word": say.Days(days), + }) default: total += scoreDueLater } @@ -223,7 +227,13 @@ func FormatRU(ranked []Ranked) string { })) } if len(cands) > 0 { + // Two sentences, and the first one ends on a joined list that carries + // whatever punctuation its last task had — usually none. So the break + // is the caller's to make, not the line file's (Vikunja #521). if b.Len() > 0 { + if !strings.HasSuffix(b.String(), ".") { + b.WriteString(".") + } b.WriteString(" ") } b.WriteString(say.S(say.TasksCandidates, map[string]string{ diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go index 3364e27..6926521 100644 --- a/internal/tasks/rank_test.go +++ b/internal/tasks/rank_test.go @@ -123,10 +123,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) { if got[0].Text != "давно" { t.Errorf("order = %v, want the later-overdue task first", texts(got)) } - if got[0].Reason != "просрочено на 12 дн." { + if got[0].Reason != "просрочено на 12 дней" { t.Errorf("reason = %q", got[0].Reason) } - if got[1].Reason != "просрочено на день" { + // One day goes through the same entry as twelve: «на 1 день» is what the + // count helper says, so there is no reason_overdue_day any more. + if got[1].Reason != "просрочено на 1 день" { t.Errorf("reason = %q", got[1].Reason) } } @@ -141,7 +143,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) { if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") { t.Errorf("reply = %q", got) } - if !strings.Contains(got, "не подтвердил: продлить страховку") { + if !strings.Contains(got, "не подтверждал: продлить страховку") { t.Errorf("candidate not named as unconfirmed: %q", got) } // A candidate's due date is Maven's reading of a mail, not his statement. @@ -178,7 +180,7 @@ func TestFormatRUEmpty(t *testing.T) { // A due date read back from the store is a UTC instant, so comparing calendar // days in ITS location put every date a day out east of Greenwich: the row said -// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date +// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date // itself while the due column one cell over said otherwise. func TestRankComparesDaysInTheCallersLocation(t *testing.T) { tz := time.FixedZone("UTC+4", 4*3600) -- 2.52.0 From 6fba4d69315a295cb4f02e6841fcbe019227275c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:47:23 +0400 Subject: [PATCH 27/28] say: one count rule everywhere, and a page she can explain (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR 113 review found four defects in one line file. Swept the other four families and the Go side for the same four. The JSON was clean: no undeclared placeholder, no abbreviation spoken, no single-variant entry left unfixed. One register leak — page_blocked read "robots.txt" out loud, which is a filename, not a reason he can act on. The count rule was not clean. Four more copies of the three-way agreement existed and two of them were wrong: ruPlural produced «1 минут назад» and «5 часа назад» because formatTime spelled the noun out. pluralTasksRU was a fifth copy. All of them now call say.CountWord. The pending-notification summary picks the whole phrase, because the adjective declines with the noun. --- cmd/mavend/crawls_test.go | 9 +++++---- cmd/mavend/ruwords.go | 32 ++++++++++++------------------- cmd/mavend/tick.go | 7 ++++++- internal/phraser/query.go | 2 +- internal/phraser/query_ru_v1.json | 2 +- internal/tasks/rank.go | 16 +--------------- internal/tasks/rank_test.go | 6 ++++-- 7 files changed, 30 insertions(+), 44 deletions(-) diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index b6b1882..937d150 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -165,16 +165,17 @@ func TestQueryWebRefusesNonHTML(t *testing.T) { } } -// robots.txt is honoured on the answer path too, and she says so instead of -// reporting a generic failure. +// robots.txt is honoured on the answer path too, and she says the page is +// closed instead of reporting a generic failure. func TestQueryWebObeysRobots(t *testing.T) { h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{})) reply, ok := askWeb(h, "посмотри https://example.org/private") if !ok { t.Fatal("the web source did not claim a question with a URL") } - if !strings.Contains(reply, "robots.txt") { - t.Errorf("reply = %q, want the robots answer", reply) + // She names the cause without reading a filename out loud. + if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") { + t.Errorf("reply = %q, want the closed-page answer with no filename", reply) } } diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index f14513f..aba97aa 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -4,6 +4,10 @@ // plural agreement, clock/date rendering, and the "do I actually know this // place/day" guards that pick an honest reply over a confidently wrong one. // Extend this file rather than voice.go for anything in that shape. +// +// Count agreement is not here. It is say.CountWord, because there were four +// copies of the same three-way rule and two of the sites that needed it were +// spelling one form out (Vikunja #521). package main import ( @@ -11,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) var ruWeekdays = []string{ @@ -94,11 +100,11 @@ func mentionsUnknownDay(u string) bool { // ruClock renders the clock part of the time reply: "15 часов 4 минуты". func ruClock(t time.Time) string { h, m := t.Hour(), t.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") + hourWord := say.CountWord(h, "час", "часа", "часов") if m == 0 { return fmt.Sprintf("%d %s ровно", h, hourWord) } - return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут")) } // dayPrefix names the day relative to now ("завтра", "вчера", …) so the date @@ -118,22 +124,6 @@ func dayPrefix(now, day time.Time) string { return "это" } -func ruPlural(n int, one, two, many string) string { - n = n % 100 - if n > 10 && n < 20 { - return many - } - n = n % 10 - switch n { - case 1: - return one - case 2, 3, 4: - return two - default: - return many - } -} - // hasDurationWords checks whether u is asking about elapsed/remaining time // rather than the current clock — guards replySystem from replying "сейчас // X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. @@ -169,11 +159,13 @@ func formatTime(t time.Time) string { case diff < 10*time.Minute: return "несколько минут назад" case diff < 60*time.Minute: - return fmt.Sprintf("%d минут назад", int(diff.Minutes())) + n := int(diff.Minutes()) + return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут")) case diff < 2*time.Hour: return "час назад" case diff < 24*time.Hour: - return fmt.Sprintf("%d часа назад", int(diff.Hours())) + n := int(diff.Hours()) + return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов")) default: return t.Format("2 января 15:04") } diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 8926a47..4f5dbaa 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -28,6 +28,7 @@ import ( "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/routine" + "github.com/kami/maven/internal/say" "github.com/kami/maven/internal/store" ) @@ -627,7 +628,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t fmt.Fprintf(&b, " · и ещё %d", extra) } body := b.String() - summary := fmt.Sprintf("%d отложенных уведомлений", len(entries)) + // The adjective declines with the noun, so the count picks the whole + // phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5 + // отложенных уведомлений. + summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries), + "отложенное уведомление", "отложенных уведомления", "отложенных уведомлений")) cand := loop.Candidate{ Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)}, diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 497cada..5a3657b 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -79,7 +79,7 @@ var queryFloor = map[string]string{ QueryFactValue: "у меня записано: {key} — {value}", QueryFound: "вот что я нашла: {text}", QueryPageText: "вот что на странице: {text}", - QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", + QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.", QueryPageEmpty: "страница открылась, но читать там нечего.", QueryFeedsOff: "ленты не настроены.", QueryFeedsNew: "вот что нового: {items}", diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index dafa5d0..463f2be 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -38,7 +38,7 @@ }, "page_blocked": { "fixed": true, - "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] + "variants": ["эта страница закрыта для чтения — сам сайт это запрещает."] }, "page_empty": { "fixed": true, diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index ca4b72f..1d14d98 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -263,21 +263,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string { s := strings.Join(parts, "; ") if rest > 0 { // With the noun. Spoken, a bare number trails off mid-sentence. - s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest)) + s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач")) } return s } - -// pluralTasksRU — the right form of "задача" for a count. Russian needs three. -func pluralTasksRU(n int) string { - if n%100 >= 11 && n%100 <= 14 { - return "задач" - } - switch n % 10 { - case 1: - return "задача" - case 2, 3, 4: - return "задачи" - } - return "задач" -} diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go index 6926521..832b7ed 100644 --- a/internal/tasks/rank_test.go +++ b/internal/tasks/rank_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "github.com/kami/maven/internal/say" ) func at(y int, m time.Month, d int) *time.Time { @@ -240,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) { t.Errorf("reply = %q, want the count with its noun", got) } for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} { - if got := pluralTasksRU(n); got != want { - t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want) + if got := say.CountWord(n, "задача", "задачи", "задач"); got != want { + t.Errorf("CountWord(%d) = %q, want %q", n, got, want) } } } -- 2.52.0 From 0ade0ec7348ab22be1d07db202318c812ca57452 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 18:01:28 +0400 Subject: [PATCH 28/28] tool, mavend: Hexis owns the tier of a Hexis capability (V-523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_only was the whole decision on the Hexis act path, which flattened three answers into two. A capability that wipes the thing it names got the same single spoken "да" as one that restarts a service, and requires_confirmation — which the Hexis contract calls server-derived and never settable by a caller — was read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk. RiskOfCapability reads Hexis's risk, read_only and requires_confirmation and returns one of the three tiers internal/tool already had. It takes plain values rather than a Capability, so internal/tool keeps no dependency on the Hexis client. RiskOf keeps deriving, because a shell row the owner ticked on /tools has no upstream to ask. Every disagreement between the three fields goes up, never down: safe and mutating is a contradiction and takes the confirm, an unrecognised tier takes the confirm, and requires_confirmation may only raise. Same default as an unrecognised dispatch shape — argue your way down, never up. The irreversible refusal was a Go literal in two places and is now one deck entry, act_needs_authed_surface. It lost four words to the persona ceiling. --- cmd/mavend/actions_act.go | 2 +- cmd/mavend/ecosystem_acts.go | 20 ++++++++++- cmd/mavend/ecosystem_test.go | 59 ++++++++++++++++++++++++++++++++ internal/phraser/acts.go | 31 ++++++++++------- internal/phraser/acts_ru_v1.json | 4 +++ internal/tool/risk.go | 51 ++++++++++++++++++++++++++- internal/tool/risk_test.go | 30 ++++++++++++++++ 7 files changed, 182 insertions(+), 15 deletions(-) diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index 16211f3..b20cb21 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -57,7 +57,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st // help: everything that proposed this act — the STT, the router, // the fuzzy allowlist match — is a guess, and a spoken "да" checks // none of it. She names the gap instead. - return "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо." + return phraser.A(phraser.ActNeedsAuthedSurface, nil) 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): diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 7ed0be7..9a0cdff 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -12,6 +12,7 @@ import ( "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/tool" ) // The three services, spelled the way she says them out loud. A service that is @@ -625,7 +626,24 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio // Read-only capabilities run immediately; mutating ones are parked for an // explicit spoken confirm bound to this capability + target. - if !matched.ReadOnly { + // The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone + // used to decide it here, which flattened three answers into two: a + // capability that wipes the thing it names got the same single spoken "да" + // as one that restarts a service, and requires_confirmation — which the + // Hexis contract calls server-derived and not settable by a caller — was + // read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk. + tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation) + policy := tool.PolicyFor(tier) + if !policy.VoiceMayRun { + // Irreversible. A confirm turn would not help, for the same reason it + // does not help a local row: the STT heard it, the model routed it and + // a substring matched the capability, and a spoken "да" checks none of + // those. She names the gap and he runs it himself. + h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started, + map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)}) + return phraser.A(phraser.ActNeedsAuthedSurface, nil) + } + if policy.Confirm { h.mu.Lock() h.pendingHexis = &pendingHexisExec{ capabilityID: matched.ID, diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go index 9296e1f..6e7133c 100644 --- a/cmd/mavend/ecosystem_test.go +++ b/cmd/mavend/ecosystem_test.go @@ -253,3 +253,62 @@ func actRan(reply string) bool { // muzickIndexer — the display name every ecosystem fixture resolves to. const muzickIndexer = "Muzick indexer" + +// read_only used to be the whole decision on this path, which meant a +// capability that destroys what it names got the same single spoken "да" as one +// that restarts a service. Hexis declares the tier and the voice path is not an +// authorised surface for the top one (Vikunja #523). +func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if *executed { + t.Fatal("an irreversible capability ran from the voice path") + } + if h.pendingHexis != nil { + t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority") + } + if !strings.Contains(reply, "не вернуть") { + t.Errorf("reply = %q; want it to name why she will not run it", reply) + } +} + +// The other half: Hexis calling a capability safe is enough to run it, even +// though read_only is the field that used to decide. Nothing here re-derives. +func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if !*executed { + t.Fatal("a capability Hexis calls safe should run") + } + if !actRan(reply) { + t.Fatalf("unexpected reply %q", reply) + } +} + +// A mutating capability with no declared tier keeps the confirm turn it has +// always had, so the split does not quietly loosen an existing box. +func TestHexisUndeclaredTierStillConfirms(t *testing.T) { + ctx := context.Background() + resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}` + caps := `[{"id":"cap_restart","name":"restart","read_only":false}]` + h, executed := newHexisTestHandler(t, resolved, caps) + + reply := h.handleHexisAct(ctx, actDec("muzick indexer")) + if *executed { + t.Fatal("a mutating capability ran without a confirm") + } + if h.pendingHexis == nil { + t.Fatal("a mutating capability did not park a confirm") + } + if !strings.Contains(reply, "да или нет") { + t.Errorf("reply = %q; want the confirm question", reply) + } +} diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index a7a0be5..fd20b46 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -40,6 +40,11 @@ const ( ActServerDown = "act_server_down" ActWithdrawn = "act_withdrawn" ActNeedsArgs = "act_needs_args" + // ActNeedsAuthedSurface — the irreversible tier, local row or Hexis + // capability alike. Not a failure and not a refusal to help: a spoken "да" + // is the only authority the voice path can offer, and this is the one act + // it is not enough for (Vikunja #449, #523). + ActNeedsAuthedSurface = "act_needs_authed_surface" EcoDenied = "eco_denied" EcoDown = "eco_down" @@ -67,6 +72,7 @@ const ( var actKeys = []string{ ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich, ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs, + ActNeedsAuthedSurface, EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall, AttentionNone, AttentionList, AttentionFail, AttentionNoneEntity, AttentionListEntity, AttentionFailEntity, @@ -79,18 +85,19 @@ var actKeys = []string{ // tracks the file's first variant instead, because a floor that keeps the // wording review threw out would say it back on the one turn nobody is watching. var actFloor = map[string]string{ - ActDone: "готово.", - ActDoneOut: "готово: {out}", - ActDoneEntity: "готово: {name}.", - ActConfirm: "выполнить «{name}»? да или нет.", - ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", - ActWhich: "какую команду для {name}: {items}?", - ActFail: "не получилось выполнить команду.", - ActFailOut: "не получилось выполнить команду: {out}", - ActFailEntity: "не получилось выполнить команду для {name}.", - ActServerDown: "инструмент есть, но сервер не подключён.", - ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", - ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", + ActDone: "готово.", + ActDoneOut: "готово: {out}", + ActDoneEntity: "готово: {name}.", + ActConfirm: "выполнить «{name}»? да или нет.", + ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", + ActWhich: "какую команду для {name}: {items}?", + ActFail: "не получилось выполнить команду.", + ActFailOut: "не получилось выполнить команду: {out}", + ActFailEntity: "не получилось выполнить команду для {name}.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", + ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.", EcoDenied: "{name} отклоняет доступ, проверь токен.", EcoDown: "{name} не отвечает, попробуй ещё раз.", diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index 00908f0..932b5e5 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -60,6 +60,10 @@ "fixed": true, "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, + "act_needs_authed_surface": { + "fixed": true, + "variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."] + }, "eco_denied": { "fixed": true, "variants": ["{name} отклоняет доступ, проверь токен."] diff --git a/internal/tool/risk.go b/internal/tool/risk.go index cb1ad38..edd723d 100644 --- a/internal/tool/risk.go +++ b/internal/tool/risk.go @@ -93,7 +93,56 @@ var irreversibleVerbs = map[string]bool{ "prune": true, "truncate": true, } -// RiskOf derives the tier of an enabled tool row. +// RiskOfCapability — the tier of a Hexis capability, which Hexis decides. +// +// Everything below this comment in RiskOf is a derivation, and a derivation is +// only honest where nobody else holds the answer. Hexis does hold it: the +// capability carries risk, read_only and requires_confirmation, and its own +// contract says requires_confirmation is server-derived from the tier and never +// settable by a caller. Deriving a second opinion here is the same defect as +// inventing a local fact key for something Nexus resolves — two answers, one of +// them stale, and the wrong one authorising an act (Vikunja #523). +// +// So this reads rather than decides. The three arguments are Capability.Risk, +// Capability.ReadOnly and Capability.RequiresConfirmation, passed as plain +// values so internal/tool keeps no dependency on the Hexis client. +// +// The one judgement left is what to do with an answer we cannot read. It goes +// up, never down: an unrecognised tier gets the confirm turn, the same default +// a dispatch shape RiskOf does not know gets. And requires_confirmation may +// only raise — a capability that calls itself safe and then asks for a confirm +// is telling us two things, and the cautious one wins. +func RiskOfCapability(risk string, readOnly, requiresConfirmation bool) Risk { + switch Risk(strings.ToLower(strings.TrimSpace(risk))) { + case TierIrreversible: + return TierIrreversible + case TierDestructive: + return TierDestructive + case TierSafe: + // Safe and mutating is a contradiction, and so is safe with a confirm + // required. Either way the act changes something. + if requiresConfirmation || !readOnly { + return TierDestructive + } + return TierSafe + case "": + // No tier declared. Fall back to the shape Hexis did give us: a + // read-only capability that wants no confirm is a read, and anything + // else takes the confirm turn. + if readOnly && !requiresConfirmation { + return TierSafe + } + return TierDestructive + default: + // A word this file has never seen. It is not safe by default. + return TierDestructive + } +} + +// RiskOf derives the tier of a locally enabled tool row — a shell command, an +// MCP call or a house service. Nothing here is a Hexis capability, and nothing +// upstream has an opinion about a row the owner ticked on /tools, which is why +// this one derives and RiskOfCapability reads. func RiskOf(t ipc.Tool) Risk { if isIrreversible(t.Cmd) { return TierIrreversible diff --git a/internal/tool/risk_test.go b/internal/tool/risk_test.go index 4662e1f..47942b7 100644 --- a/internal/tool/risk_test.go +++ b/internal/tool/risk_test.go @@ -79,3 +79,33 @@ func TestExecConfirmsAnUnreadableRow(t *testing.T) { t.Errorf("%v; want ErrNeedsConfirm", err) } } + +// Hexis owns the tier of a Hexis capability, so this reads rather than derives +// (Vikunja #523). The cases that matter are the ones where the three fields +// disagree, or where the tier is a word this package has never seen: every one +// of those goes up to a confirm, never down to running freely. +func TestRiskOfCapabilityReadsHexis(t *testing.T) { + for _, c := range []struct { + name string + risk string + ro bool + confirm bool + want Risk + }{ + {"hexis says irreversible", "irreversible", false, true, TierIrreversible}, + {"case and space do not change the tier", " Irreversible ", false, true, TierIrreversible}, + {"hexis says destructive", "destructive", false, true, TierDestructive}, + {"a read hexis calls safe", "safe", true, false, TierSafe}, + {"safe but mutating is a contradiction", "safe", false, false, TierDestructive}, + {"safe but wants a confirm is a contradiction", "safe", true, true, TierDestructive}, + {"no tier, read-only, no confirm", "", true, false, TierSafe}, + {"no tier and mutating", "", false, false, TierDestructive}, + {"no tier but hexis wants a confirm", "", true, true, TierDestructive}, + {"a word we have never seen", "spicy", true, false, TierDestructive}, + } { + if got := RiskOfCapability(c.risk, c.ro, c.confirm); got != c.want { + t.Errorf("%s: RiskOfCapability(%q, ro=%v, confirm=%v) = %q; want %q", + c.name, c.risk, c.ro, c.confirm, got, c.want) + } + } +} -- 2.52.0