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.go b/cmd/mavend/actions.go index f6a9956..4145676 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" ) @@ -58,10 +59,14 @@ 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) - return "поговорили." + } + if reply == "" { + return phraser.ChatFallback() } return reply } diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index f3395dc..b20cb21 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,37 +51,37 @@ 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.ErrNeedsAuthedSurface): // Irreversible (internal/tool/risk.go). A confirm turn would not // 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): // 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/actions_act_risk_test.go b/cmd/mavend/actions_act_risk_test.go index 24fd271..31447f1 100644 --- a/cmd/mavend/actions_act_risk_test.go +++ b/cmd/mavend/actions_act_risk_test.go @@ -42,7 +42,9 @@ func TestActPathSpeaksTheTiers(t *testing.T) { if reply := act("status"); !strings.HasPrefix(reply, "готово") { t.Errorf("safe act replied %q; want it to have run", reply) } - if reply := act("restart"); !strings.Contains(reply, "скажи «да»") { + // PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a + // yes/no question — so the confirm turn is recognised by the question. + if reply := act("restart"); !strings.Contains(reply, "да или нет") { t.Errorf("destructive act replied %q; want a confirm turn", reply) } // Clear the confirm the destructive act parked, so what is pending after @@ -52,7 +54,7 @@ func TestActPathSpeaksTheTiers(t *testing.T) { h.mu.Unlock() reply := act("wipe") - if strings.Contains(reply, "скажи «да»") { + if strings.Contains(reply, "да или нет") { t.Fatalf("irreversible act asked for a confirm: %q", reply) } if !strings.Contains(reply, "не вернуть") { 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_list_test.go b/cmd/mavend/actions_list_test.go index 31a94cd..6e8c288 100644 --- a/cmd/mavend/actions_list_test.go +++ b/cmd/mavend/actions_list_test.go @@ -17,7 +17,7 @@ func listHandler(t *testing.T) *reactiveHandler { return &reactiveHandler{dataStore: newTestStore(t), now: listNow} } -func say(t *testing.T, h *reactiveHandler, utterance string) (string, bool) { +func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) { t.Helper() return h.captureListFromNote(context.Background(), router.Decision{ Intent: router.IntentNote, Utterance: utterance, @@ -27,11 +27,11 @@ func say(t *testing.T, h *reactiveHandler, utterance string) (string, bool) { func TestListCaptureAddsAndReadsBack(t *testing.T) { h := listHandler(t) for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} { - if reply, ok := say(t, h, u); !ok { + if reply, ok := askList(t, h, u); !ok { t.Fatalf("%q was not claimed (reply %q)", u, reply) } } - if reply, ok := say(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") { + if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") { t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok) } answer, ok := h.queryList(context.Background(), &queryTurn{ @@ -58,7 +58,7 @@ func TestListCapturePassesOrdinaryNotes(t *testing.T) { "купил новый ноутбук", "добавь в список покупок", } { - if reply, ok := say(t, h, u); ok { + if reply, ok := askList(t, h, u); ok { t.Errorf("%q was claimed as a list turn: %q", u, reply) } } @@ -71,11 +71,11 @@ func TestListCrossOffOneItemAndThenAll(t *testing.T) { "добавь в список покупок хлеб", "добавь в список аптеки бинт", } { - if _, ok := say(t, h, u); !ok { + if _, ok := askList(t, h, u); !ok { t.Fatalf("%q was not claimed", u) } } - reply, ok := say(t, h, "вычеркни молоко") + reply, ok := askList(t, h, "вычеркни молоко") if !ok || !strings.Contains(reply, "молоко") { t.Fatalf("cross off replied %q, %v", reply, ok) } @@ -86,7 +86,7 @@ func TestListCrossOffOneItemAndThenAll(t *testing.T) { if len(open) != 1 || open[0].Item != "хлеб" { t.Fatalf("open list %+v; want only хлеб", open) } - if reply, ok := say(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") { + if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") { t.Errorf("clear replied %q, %v", reply, ok) } open, err = h.dataStore.ListItems(context.Background(), "покупки", "") @@ -168,7 +168,7 @@ func TestListGrammarsRouteWithoutTheModel(t *testing.T) { func TestListStoreSourceIsVoice(t *testing.T) { h := listHandler(t) - if _, ok := say(t, h, "добавь в список покупок молоко"); !ok { + if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok { t.Fatal("not claimed") } items, err := h.dataStore.ListItems(context.Background(), "покупки", "") diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index b552ddd..5d90b12 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" ) @@ -29,13 +30,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_query.go b/cmd/mavend/actions_query.go index b0efef5..eb54c00 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -152,9 +152,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 @@ -172,11 +172,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. @@ -202,7 +202,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 @@ -247,7 +247,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 { @@ -286,7 +286,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 "в @@ -294,7 +294,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 { @@ -311,11 +311,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 — "что у меня сегодня?", "планы на завтра?" @@ -329,7 +329,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 @@ -372,9 +372,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) } @@ -387,19 +390,24 @@ 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), + "word": phraser.Degrees(w.Temperature), + "condition": w.Condition, + }), true } // queryEmbed isn't an answer source — it's the shared cost the two recall @@ -409,7 +417,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 @@ -450,7 +458,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 } } @@ -472,7 +486,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)) @@ -497,7 +511,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 } @@ -519,25 +533,24 @@ 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() 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 @@ -547,7 +560,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 } @@ -613,7 +626,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 } @@ -694,7 +707,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 } @@ -724,7 +737,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. @@ -792,15 +805,15 @@ 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) { 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 + return phraser.Q(phraser.QueryUnknown, nil), true } return reply, true } 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..5f38bba 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) } } @@ -137,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/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 5055880..937d150 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) } } @@ -156,21 +160,22 @@ 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) } } -// 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/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/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 867685d..9a0cdff 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -9,10 +9,25 @@ 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" + "github.com/kami/maven/internal/tool" ) +// 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 @@ -144,10 +159,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 @@ -157,6 +172,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 != "" { @@ -175,7 +195,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } } } - return "требует внимания: " + strings.Join(parts, "; ") + 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, "; ")}) } // listChangesCapability reads the recent-changes feed. @@ -192,19 +218,29 @@ 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 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)) } - return "изменения: " + strings.Join(parts, "; ") + if len(parts) == 0 { + return phraser.A(phraser.ChangesNone, nil) + } + 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 +266,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 +284,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, serviceVars(serviceNexus)) } - return "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } 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 +304,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 +315,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 +339,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 +406,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 +556,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, 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 "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } 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 +586,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, serviceVars(serviceHexis)) } - return "экосистема недоступна, попробуй ещё раз." + return phraser.A(phraser.EcoDown, serviceVars(serviceHexis)) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) @@ -584,13 +620,30 @@ 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] // 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, @@ -602,7 +655,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, "name_entity": displayName}) } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) @@ -622,7 +675,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 +683,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/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..6e7133c 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,73 @@ 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" + +// 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/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) } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..4db05da 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) } } @@ -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/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 83c1c35..88d28f1 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,9 +117,9 @@ 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))) + out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { out += ", " + shape } @@ -179,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 += " (не вся сеть)" } @@ -210,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..0adc2ce 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) { @@ -74,18 +76,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{ "какие устройства в сети?", @@ -169,3 +159,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/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/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/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/smarthome.go b/cmd/mavend/smarthome.go index 03946b2..8b73a22 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": phraser.Devices(dark), + }), true default: parts = append(parts, "всё выключено") } @@ -193,7 +198,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/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/cmd/mavend/tick.go b/cmd/mavend/tick.go index 04ff8af..ab5987b 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" ) @@ -630,7 +631,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/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 4b07dd4..363a17e 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.ListGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go index c6c712f..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. // @@ -54,7 +58,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/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/memory/behavior.go b/internal/memory/behavior.go index 8ed3260..e926671 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{ + "items_sat": joinActivities(sat), "items_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,33 +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)) -} - -// 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 "дней" - } + return say.S(say.HabitSpanDays, map[string]string{ + "n": strconv.Itoa(days), "word": say.Days(days), + }) } // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is @@ -423,14 +415,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..5ad342e 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) } } @@ -124,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 @@ -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) } } @@ -313,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) } } @@ -340,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.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/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/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..c860663 --- /dev/null +++ b/internal/phraser/acks.go @@ -0,0 +1,164 @@ +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" + + "github.com/kami/maven/internal/say" +) + +//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 = 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 *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 := say.Load(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() *say.Deck { + if a == nil { + return say.FloorDeck(ackFloor) + } + 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) +} diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go new file mode 100644 index 0000000..9d48054 --- /dev/null +++ b/internal/phraser/act_lines_test.go @@ -0,0 +1,85 @@ +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.VariantsOf(key) { + 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 new file mode 100644 index 0000000..fd20b46 --- /dev/null +++ b/internal/phraser/acts.go @@ -0,0 +1,197 @@ +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" + + "github.com/kami/maven/internal/say" +) + +//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" + // 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" + 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, + ActNeedsAuthedSurface, + 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. 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}» для {name_entity}? да или нет.", + ActWhich: "какую команду для {name}: {items}?", + ActFail: "не получилось выполнить команду.", + ActFailOut: "не получилось выполнить команду: {out}", + ActFailEntity: "не получилось выполнить команду для {name}.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", + ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.", + + EcoDenied: "{name} отклоняет доступ, проверь токен.", + EcoDown: "{name} не отвечает, попробуй ещё раз.", + 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 *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 := say.Load(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, "{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}"}, + } { + 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() *say.Deck { + if a == nil { + return say.FloorDeck(actFloor) + } + return a.d +} + +// Say returns one line for key, with the names filled into the frame. +func (a *Acts) Say(key string, vars map[string]string) string { + return a.deck().Text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (a *Acts) Variants() []string { return a.deck().Variants() } + +var ( + actOnce sync.Once + actsDeck *Acts +) + +// DefaultActs returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Acts, which still answers from actFloor. +func DefaultActs() *Acts { + actOnce.Do(func() { + a, err := LoadActs(nil) + if err != nil { + log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err) + return + } + actsDeck = a + }) + return actsDeck +} + +// A — one act reply, the way every caller says it. +func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) } + +// IsA reports whether text is a line key could have produced, for the tests. +func IsA(key string, vars map[string]string, text string) bool { + return DefaultActs().deck().Matches(key, vars, text) +} diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json new file mode 100644 index 0000000..932b5e5 --- /dev/null +++ b/internal/phraser/acts_ru_v1.json @@ -0,0 +1,145 @@ +{ + "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\", \"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": { + "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}» для {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": { + "fixed": true, + "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] + }, + "act_needs_args": { + "fixed": true, + "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] + }, + "act_needs_authed_surface": { + "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/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go new file mode 100644 index 0000000..eb2f617 --- /dev/null +++ b/internal/phraser/eval/fallbacks_test.go @@ -0,0 +1,70 @@ +package eval + +import ( + "math/rand" + "strings" + "testing" + + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/say" +) + +// 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. +func TestFallbackPersona(t *testing.T) { + fb, err := phraser.LoadFallbacks(rand.NewSource(20260804)) + 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, + 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()...) + act, err := phraser.LoadActs(rand.NewSource(20260804)) + if err != nil { + 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") + } + 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}", "{when}", "{items}", + "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", + "{entity}", "{count}", "{word}", + "{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} { + 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/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) } } diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go new file mode 100644 index 0000000..28636fe --- /dev/null +++ b/internal/phraser/failure_test.go @@ -0,0 +1,80 @@ +package phraser + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "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() + return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got) +} + +// 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 +// 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) + })) + t.Cleanup(srv.Close) + p := NewLLMPhraserAt(srv.URL, Config{}) + + cases := []struct { + name string + call func() (string, error) + key string + sources string + }{ + {"chat", func() (string, error) { + return p.PhraseChat(context.Background(), "как дела", nil) + }, 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) { + got, err := c.call() + if err == nil { + t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing") + } + 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) + } + }) + } +} + +// 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 !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.go b/internal/phraser/fallbacks.go new file mode 100644 index 0000000..6876fcf --- /dev/null +++ b/internal/phraser/fallbacks.go @@ -0,0 +1,140 @@ +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. +// +// 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" + "log" + "math/rand" + "sync" + + "github.com/kami/maven/internal/say" +) + +//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: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.", +} + +// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. +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 := 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 { + return nil, err + } + return &Fallbacks{d: d}, nil +} + +// deck reads through a nil *Fallbacks, which is the unloadable-file case. +func (f *Fallbacks) deck() *say.Deck { + if f == nil { + return say.FloorDeck(hardFloor) + } + return f.d +} + +// Chat — nothing usable came back on the chat path. +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) } + +// 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}) +} + +// 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) } + +// Variants returns every line the file can produce, for the persona scorer. +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 +// 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() } + +// 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) +} + +// 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) +} 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": [ + "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." + ] + } + } +} diff --git a/internal/phraser/fallbacks_test.go b/internal/phraser/fallbacks_test.go new file mode 100644 index 0000000..789badc --- /dev/null +++ b/internal/phraser/fallbacks_test.go @@ -0,0 +1,74 @@ +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 + } +} + +// 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/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/llmphraser.go b/internal/phraser/llmphraser.go index 2c5e28d..b06ecac 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,8 +434,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 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 — // a page that fetched to nothing, a snippet trimmed away — used to take the @@ -439,13 +448,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 UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err) + } + if resp == "" { + return UnknownFallback(), errEmptyResponse } text, _, perr := parseResponseMood(resp) if perr != nil { - log.Printf("phraser: PhraseQuery: %v", perr) - return "не знаю.", nil + return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr) } if text != "" { return text, nil @@ -457,13 +468,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 SourcesFallback(strings.Join(notes, "; ")), + fmt.Errorf("phrase query (evidence): %w", cause) } if text != "" { return text, nil @@ -472,8 +482,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 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) msgs := []chatMsg{ @@ -490,13 +501,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 ChatFallback(), fmt.Errorf("phrase chat: %w", err) } text, _, perr := parseResponseMood(resp) if perr != nil { - log.Printf("phraser: PhraseChat: %v", perr) - return "поговорили.", nil + 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..8fb9130 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} } // 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. 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). diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go new file mode 100644 index 0000000..c9c76f7 --- /dev/null +++ b/internal/phraser/plural.go @@ -0,0 +1,19 @@ +package phraser + +// The counted noun, for callers that already speak through this package. +// +// 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 "github.com/kami/maven/internal/say" + +// 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. +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 say.Devices(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) + } + } +} diff --git a/internal/phraser/query.go b/internal/phraser/query.go new file mode 100644 index 0000000..5a3657b --- /dev/null +++ b/internal/phraser/query.go @@ -0,0 +1,171 @@ +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" + + "github.com/kami/maven/internal/say" +) + +//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" + QueryNetOff = "net_off" + QueryPageOff = "page_off" + + 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, QueryNetOff, QueryPageOff, + QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, + QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, +} + +// queryFloor — 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 queryFloor = map[string]string{ + QueryUnknown: "ничего не нашла.", + QueryOtherDay: "про другой день так не отвечу — спроси целиком.", + QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", + QueryFactWhen: "записала это {when}", + QueryFactValue: "у меня записано: {key} — {value}", + QueryFound: "вот что я нашла: {text}", + QueryPageText: "вот что на странице: {text}", + QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.", + QueryPageEmpty: "страница открылась, но читать там нечего.", + QueryFeedsOff: "ленты не настроены.", + QueryFeedsNew: "вот что нового: {items}", + QueryFeedsEmpty: "в лентах пока ничего нового.", + QueryFeedsTopic: "по этой теме в лентах пока ничего.", + QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.", + QueryWeatherOff: "погода не настроена.", + QueryWeatherWhere: "для какого города?", + QueryNetEmpty: "в сети никого не нашла.", + QueryNetOff: "сканирование сети не настроено.", + QueryPageOff: "я не читаю страницы — это не настроено.", + + QueryFailPlan: "не получилось собрать план.", + QueryFailNotes: "не получилось посмотреть записи.", + QueryFailFeeds: "не получилось посмотреть ленты.", + QueryFailCalendar: "не получилось проверить календарь.", + QueryFailWeather: "не получилось узнать погоду.", + QueryFailAnswer: "не получилось найти ответ.", + QueryFailPage: "не получилось прочитать страницу.", + QueryFailNetscan: "не получилось просканировать сеть.", +} + +// Queries picks a hand-written Russian query line. Safe for concurrent use. +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 := say.Load(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, "{word}"}, {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() *say.Deck { + if q == nil { + return say.FloorDeck(queryFloor) + } + 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_lines_test.go b/internal/phraser/query_lines_test.go new file mode 100644 index 0000000..4ccde68 --- /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.VariantsOf(QueryUnknown) { + 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.VariantsOf(QueryWeatherNow) { + 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 new file mode 100644 index 0000000..463f2be --- /dev/null +++ b/internal/phraser/query_ru_v1.json @@ -0,0 +1,115 @@ +{ + "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, 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": ["ничего не нашла.", "искала — не нашла."] + }, + "other_day": { + "fixed": true, + "variants": ["про другой день так не отвечу — спроси целиком."] + }, + "personal_none": { + "fixed": true, + "variants": ["не знаю — не нашла у тебя такой записи."] + }, + "fact_when": { + "fixed": true, + "variants": ["записала это {when}"] + }, + "fact_value": { + "variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"] + }, + "found": { + "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] + }, + "page_text": { + "variants": ["вот что на странице: {text}", "на странице вот это: {text}"] + }, + "page_blocked": { + "fixed": true, + "variants": ["эта страница закрыта для чтения — сам сайт это запрещает."] + }, + "page_empty": { + "fixed": true, + "variants": ["страница открылась, но читать там нечего."] + }, + "feeds_off": { + "fixed": true, + "variants": ["ленты не настроены."] + }, + "feeds_new": { + "variants": ["вот что нового: {items}", "нового вот что: {items}"] + }, + "feeds_empty": { + "variants": ["в лентах пока ничего нового.", "в лентах тихо."] + }, + "feeds_empty_topic": { + "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] + }, + "weather_now": { + "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] + }, + "weather_off": { + "fixed": true, + "variants": ["погода не настроена."] + }, + "weather_nolocation": { + "fixed": true, + "variants": ["для какого города?"] + }, + "net_off": { + "fixed": true, + "variants": ["сканирование сети не настроено."] + }, + "page_off": { + "fixed": true, + "variants": ["я не читаю страницы — это не настроено."] + }, + "net_empty": { + "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/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/router/agenda_test.go b/internal/router/agenda_test.go index 9789c90..19ad16f 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -76,6 +76,29 @@ 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) + } + } +} + // The tomorrow form and the bare event noun. Both were measured answering // "пока не умею" on the deployed daemon, 02-08-2026, while the same question // about today worked — the first rule set needed "у меня" or a calendar noun @@ -101,6 +124,20 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) { } } +// 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) + } +} + // The two new rules are narrow on purpose. A world question that opens with // "когда" is not an agenda question, and telling her about a plan is not // asking about one. 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 2f6004e..68d5b1d 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -30,6 +30,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 eafc89a..fac9fb6 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -208,6 +208,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) +} + // dayWordPattern — the day words an agenda question can name. Weekdays appear // in the accusative and prepositional forms the questions actually use ("в // среду", "на среде"), which is why the stems carry an inflection tail rather diff --git a/internal/say/deck.go b/internal/say/deck.go new file mode 100644 index 0000000..7d88a67 --- /dev/null +++ b/internal/say/deck.go @@ -0,0 +1,265 @@ +package say + +// 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 +// 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" +) + +// 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 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 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 + last map[string]string + file file + 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 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) + } + 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, fillable(e.Variants, vars)) + } + } + 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 +} + +// VariantsOf returns the wordings the file gives for one key, for a test that +// has something to say about every one of them. +func (d *Deck) VariantsOf(key string) []string { + if d == nil { + return nil + } + return d.file.Entries[key].Variants +} + +// UnfixedSingles lists the keys with exactly one variant that are not marked +// fixed. Nothing breaks on one — the picker has nothing to pick either way — but +// the flag is what a reader goes by, and parallel entries disagreeing about it +// is how a family stops being readable. Load already rejects the other half of +// the rule, fixed with more than one variant, so this is the pair to it. +func (d *Deck) UnfixedSingles() []string { + if d == nil { + return nil + } + var out []string + for _, k := range d.keys { + if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed { + out = append(out, k) + } + } + 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, variants []string) string { + d.mu.Lock() + defer d.mu.Unlock() + + choices := 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. 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 { + return "" + } + return d.floor[key] +} + +// 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 +// 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 +} 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 new file mode 100644 index 0000000..e4f6791 --- /dev/null +++ b/internal/say/summary.go @@ -0,0 +1,192 @@ +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" + 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, 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. +// 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}", + PlanUncertain: "похоже, {line}", + + TasksNone: "задач нет.", + TasksFirst: "сначала: {items}", + TasksCandidates: "нашла ещё, но ты не подтверждал: {items}", + + ReasonOverdue: "просрочено", + ReasonOverdueDays: "просрочено на {n} {word}", + ReasonToday: "сегодня", + ReasonTomorrow: "завтра", + ReasonInDays: "через {n} {word}", + ReasonImportant: "важно", + ReasonUrgent: "срочно", + ReasonStale: "давно в списке", + + HabitWeekday: "по {day} ты обычно {items}.", + HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.", + HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.", + HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_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}"}, {ReasonOverdueDays, "{word}"}, + {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, + {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, + {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"}, + {HabitWeekdayNone, "{day}"}, + {HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_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 FloorDeck(summaryFloor) + } + 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) +} diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json new file mode 100644 index 0000000..daed24e --- /dev/null +++ b/internal/say/summary_ru_v1.json @@ -0,0 +1,143 @@ +{ + "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.", + "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": { + "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_days": { + "fixed": true, + "variants": ["просрочено на {n} {word}"] + }, + "reason_today": { + "fixed": true, + "variants": ["сегодня"] + }, + "reason_tomorrow": { + "fixed": true, + "variants": ["завтра"] + }, + "reason_in_days": { + "fixed": true, + "variants": ["через {n} {word}"] + }, + "reason_important": { + "fixed": true, + "variants": ["важно"] + }, + "reason_urgent": { + "fixed": true, + "variants": ["срочно"] + }, + "reason_stale": { + "fixed": true, + "variants": ["давно в списке"] + }, + + "habit_weekday": { + "fixed": true, + "variants": ["по {day} ты обычно {items}."] + }, + "habit_weekday_same": { + "fixed": true, + "variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."] + }, + "habit_weekday_none": { + "variants": [ + "по {day} я пока не вижу ничего постоянного.", + "по {day} постоянного пока не вижу — записей мало." + ] + }, + "habit_weekend_both": { + "fixed": true, + "variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."] + }, + "habit_weekend_sat": { + "fixed": true, + "variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."] + }, + "habit_weekend_sun": { + "fixed": true, + "variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."] + }, + "habit_weekend_same": { + "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": { + "fixed": true, + "variants": ["отмечаешь «{key}»"] + }, + "habit_at": { + "fixed": true, + "variants": ["{gloss} около {time}"] + } + } +} diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go new file mode 100644 index 0000000..7bce545 --- /dev/null +++ b/internal/say/summary_test.go @@ -0,0 +1,62 @@ +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) + } + } + } +} + +// 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 e2f88fd..1d14d98 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,25 @@ func score(it Item, now time.Time) (float64, string) { bonus = scoreOverdueCap } total += scoreOverdue + bonus - reason = "просрочено" - if late == 1 { - reason = "просрочено на день" - } else if late > 1 { - reason = fmt.Sprintf("просрочено на %d дн.", late) + reason = say.S(say.ReasonOverdue, nil) + 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 - 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), "word": say.Days(days), + }) default: total += scoreDueLater } @@ -147,9 +154,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 +170,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 +217,28 @@ 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 { + // 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("ещё я нашла, но ты не подтвердил: ") - 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() } @@ -250,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 3364e27..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 { @@ -123,10 +125,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 +145,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 +182,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) @@ -238,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) } } } 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) + } + } +} 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) } }