From 16d94894b7b902daec4c4d0745d85e691905c594 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH] mavend: say the query answers from the file (V-503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three daemon tests that pinned a wording ask the entry instead. The eval scores every query variant on the persona checks, minus hisgender: it reads her own feminine verb next to "у тебя" as addressing him as a woman. --- cmd/mavend/actions_query.go | 60 +++++++++++++------------ cmd/mavend/crawls_test.go | 2 +- cmd/mavend/dayplan_test.go | 5 ++- cmd/mavend/feeds_test.go | 2 +- cmd/mavend/netscan.go | 5 ++- internal/phraser/eval/fallbacks_test.go | 18 ++++++-- 6 files changed, 54 insertions(+), 38 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 42cb70f..f019816 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // The previous question cannot be re-asked for another day. Saying so // beats "не знаю", which reads as "no data for tomorrow" when the // truth is that she never looked. - return "про другой день так не отвечу — спроси целиком." + return phraser.Q(phraser.QueryOtherDay, nil) } - return "не знаю." + return phraser.Q(phraser.QueryUnknown, nil) } // queryFactByKey — when the dialogue layer resolved an anaphoric reference to @@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str if dec.Slots.HasTime { // The query asks about timing — the fact's own timestamp is the // answer it's looking for. Format as a natural reply. - return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true + return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true } // General fact reference: describe what we know. if dec.Utterance == "" { - return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true + return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true } // The utterance still carries the question; fall through to normal RAG // with the resolved key in context. @@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin plan, err := h.api.DayPlan(ctx) if err != nil { log.Printf("voice: day plan: %v", err) - return "не получилось собрать план.", true + return phraser.Q(phraser.QueryFailPlan, nil), true } if !router.IsRestOfDayQuery(t.dec.Utterance) { return plan.Spoken, true @@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow) if err != nil { log.Printf("voice: habits: recent facts: %v", err) - return "не получилось посмотреть записи.", true + return phraser.Q(phraser.QueryFailNotes, nil), true } obs := make([]memory.Observation, 0, len(facts)) for _, f := range facts { @@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, // Claim the turn rather than fall through: "не читаю ленты" is true, and // letting general knowledge answer "что нового?" would be an invented // news bulletin. - return "я пока не читаю ленты — они не настроены.", true + return phraser.Q(phraser.QueryFeedsOff, nil), true } // By source, not the last 200 notes of any kind: a busy day of voice notes // used to push the newest headline out of the window, and she answered "в @@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) - return "не получилось посмотреть ленты.", true + return phraser.Q(phraser.QueryFailFeeds, nil), true } var picked []string for _, n := range notes { @@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, } if len(picked) == 0 { if q.Category != "" { - return "по этой теме в лентах пока ничего.", true + return phraser.Q(phraser.QueryFeedsTopic, nil), true } - return "в лентах пока ничего нового.", true + return phraser.Q(phraser.QueryFeedsEmpty, nil), true } - return "вот что нового: " + strings.Join(picked, "; "), true + return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true } // queryCalendar — "что у меня сегодня?", "планы на завтра?" @@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) - return "не получилось проверить календарь.", true + return phraser.Q(phraser.QueryFailCalendar, nil), true } // Provenance travels with each event. A work meeting relayed off a phone // notification (source ambient:notif, #126) is stored below full confidence @@ -382,19 +382,23 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin if loc == "" { // He named no city and voice.weather.default_location is unset. Saying // so is the only honest answer; picking a city would be inventing one. - return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true + return phraser.Q(phraser.QueryWeatherWhere, nil), true } ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { - return "погода не настроена.", true + return phraser.Q(phraser.QueryWeatherOff, nil), true } if err != nil { log.Printf("voice: weather: %v", err) - return "не получилось узнать погоду.", true + return phraser.Q(phraser.QueryFailWeather, nil), true } - return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true + return phraser.Q(phraser.QueryWeatherNow, map[string]string{ + "location": w.Location, + "temp": fmt.Sprintf("%.0f", w.Temperature), + "condition": w.Condition, + }), true } // queryEmbed isn't an answer source — it's the shared cost the two recall @@ -404,7 +408,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.vec = vec return "", false @@ -473,7 +477,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, notes, err := h.api.QueryNotes(ctx, t.vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.notes = notes noteScores := make([]float64, len(notes)) @@ -498,7 +502,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, log.Printf("voice: phrase query: %v", err) } if reply == "" { - reply = "вот что я нашла: " + texts[0] + reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]}) } return reply, true } @@ -532,13 +536,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b page, err := h.crawler.Page(ctxFetch, link) if err != nil { if errors.Is(err, crawl.ErrRobots) { - return "эта страница закрыта для чтения — robots.txt не разрешает.", true + return phraser.Q(phraser.QueryPageBlocked, nil), true } log.Printf("voice: web: %v", err) - return "не получилось прочитать страницу.", true + return phraser.Q(phraser.QueryFailPage, nil), true } if page.Text == "" { - return "страница открылась, но читать там нечего.", true + return phraser.Q(phraser.QueryPageEmpty, nil), true } // The page is handed to the phraser the same way a note is: as context for // the question he actually asked. She answers the question, she does not @@ -548,7 +552,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. - return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true + return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true } return reply, true } @@ -614,7 +618,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true } return reply, true } @@ -695,7 +699,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true } return reply, true } @@ -725,7 +729,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri return "", false } log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) - return "не знаю — не нашла у тебя такой записи.", true + return phraser.Q(phraser.QueryPersonalNone, nil), true } // personalMarkers — first-person POSSESSION, not first person generally. @@ -793,7 +797,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin if h.phraser == nil { // No model of any size. That is not the workstation being asleep, so it // is not that gap: it is simply not knowing. - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) if errors.Is(err, phraser.ErrNoWorldModel) { @@ -801,7 +805,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin return worldGap(), true } if err != nil || reply == "" { - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } return reply, true } diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 5055880..4423355 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) { if !ok { t.Fatal("the web source did not claim a question with a URL") } - if !strings.Contains(reply, "не получилось") { + if !phraser.IsQ(phraser.QueryFailPage, nil, reply) { t.Errorf("reply = %q, want the read-failed answer", reply) } } diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 0fd511c..52a0aa9 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -10,6 +10,7 @@ import ( "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) { if !ok { t.Fatal("a failed plan read must still answer, not fall through to RAG") } - if reply != "не получилось собрать план." { - t.Errorf("reply = %q", reply) + if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) { + t.Errorf("reply = %q, want the honest failure", reply) } } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..7db7f94 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) { t.Fatalf("reply = %q, want only the технологии item", reply) } reply, _ = askFeeds(t, h, "что нового по спорту?") - if !strings.Contains(reply, "ничего") { + if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) { t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply) } } diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 83c1c35..c5de502 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -11,6 +11,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/netscan" + "github.com/kami/maven/internal/phraser" ) // scanBudget — the whole spoken scan, end to end. A voice turn that takes @@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { res, err := w.scan(ctx) if err != nil { log.Printf("netscan: scan: %v", err) - return "не получилось просканировать сеть.", true + return phraser.Q(phraser.QueryFailNetscan, nil), true } // A truncated run is not a statement about the LAN. Saying "нашла 6 // устройств" after stopping two thirds of the way through the range is a @@ -116,7 +117,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { tail = ", но успела посмотреть не всю сеть" } if len(res.Hosts) == 0 { - return "в сети никого не нашла" + tail + ".", true + return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 55c4fe5..b4739cc 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,8 +8,8 @@ import ( "github.com/kami/maven/internal/phraser" ) -// TestFallbackPersona scores every line in fallbacks_ru_v1.json and -// ack_ru_v1.json on the persona checks the nudges already pass. These lines are +// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and +// query_ru_v1.json on the persona checks the nudges already pass. These lines are // heard out loud and they live in a JSON file now, so a reworded variant that // says "рад" or "вы" would otherwise reach him with nothing in between. // @@ -20,22 +20,32 @@ func TestFallbackPersona(t *testing.T) { if err != nil { t.Fatalf("LoadFallbacks: %v", err) } + // No CheckHisGender. It reads a feminine verb near a second-person pronoun + // as addressing him as a woman, which is right for a nudge and wrong here: + // "не знаю — не нашла у тебя такой записи" is her own verb in her own + // sentence. CheckFeminine still holds her side of the rule. persona := map[string]bool{ - CheckLang: true, CheckFeminine: true, CheckHisGender: true, + CheckLang: true, CheckFeminine: true, CheckAddress: true, CheckCringe: true, CheckLength: true, } ack, err := phraser.LoadAcks(rand.NewSource(20260804)) if err != nil { t.Fatalf("LoadAcks: %v", err) } + qry, err := phraser.LoadQueries(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } variants := append(fb.Variants(), ack.Variants()...) + variants = append(variants, qry.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } for _, v := range variants { // The placeholders stand for his own words and carry no persona. body := v - for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} { + for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", + "{location}", "{temp}", "{condition}", "{tail}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") {