From ae8d38fc3106e6564ef91e3813e5321be316f706 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH 1/2] phraser: put the query answers and gaps in a versioned json (V-503) What a query source says when it answers from something other than the model, and what it says when it has nothing. Two dozen of them lived in actions_query.go alone. Every gap keeps its own entry. "The feeds are not configured", "the search failed" and "I do not know" are different truths, and one variant set would let them answer for each other. The personal boundary and the refusal to re-ask a question for another day are fixed: both are load-bearing wording. query_unknown is not the phraser fallback that reads the same. Here she looked and found nothing; there she failed to phrase an answer she had. --- internal/phraser/query.go | 162 ++++++++++++++++++++++++++++++ internal/phraser/query_ru_v1.json | 93 +++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 internal/phraser/query.go create mode 100644 internal/phraser/query_ru_v1.json diff --git a/internal/phraser/query.go b/internal/phraser/query.go new file mode 100644 index 0000000..638eefd --- /dev/null +++ b/internal/phraser/query.go @@ -0,0 +1,162 @@ +package phraser + +// The query answers and gaps — what a query source says when it answers from +// something other than the model, and what it says when it has nothing. +// +// Third family on the shared deck (deck.go), after the fallbacks and the +// acknowledgements. They were literals spread across actions_query.go and +// netscan.go, where the largest single site held two dozen of them. +// +// QueryUnknown is not the phraser's UnknownFallback, even though the two read +// the same today. Here she looked and found nothing; there she failed to phrase +// an answer she had. Two files, two entries, so rewording one leaves the other. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed query_ru_v1.json +var queryJSON []byte + +// QuerySchemaVersion — this family's own version. +const QuerySchemaVersion = 1 + +// The entry keys. +const ( + QueryUnknown = "query_unknown" + QueryOtherDay = "other_day" + QueryPersonalNone = "personal_none" + QueryFactWhen = "fact_when" + QueryFactValue = "fact_value" + QueryFound = "found" + QueryPageText = "page_text" + QueryPageBlocked = "page_blocked" + QueryPageEmpty = "page_empty" + QueryFeedsOff = "feeds_off" + QueryFeedsNew = "feeds_new" + QueryFeedsEmpty = "feeds_empty" + QueryFeedsTopic = "feeds_empty_topic" + QueryWeatherNow = "weather_now" + QueryWeatherOff = "weather_off" + QueryWeatherWhere = "weather_nolocation" + QueryNetEmpty = "net_empty" + + QueryFailPlan = "fail_plan" + QueryFailNotes = "fail_notes" + QueryFailFeeds = "fail_feeds" + QueryFailCalendar = "fail_calendar" + QueryFailWeather = "fail_weather" + QueryFailAnswer = "fail_answer" + QueryFailPage = "fail_page" + QueryFailNetscan = "fail_netscan" +) + +var queryKeys = []string{ + QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue, + QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty, + QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic, + QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, + QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar, + QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan, +} + +// queryFloor — the literal each key falls back to when the file is unusable. +// These are the exact strings that lived in Go before this file existed. +var queryFloor = registerFloor(map[string]string{ + QueryUnknown: "не знаю.", + QueryOtherDay: "про другой день так не отвечу — спроси целиком.", + QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", + QueryFactWhen: "я записала это {when}", + QueryFactValue: "вот что я знаю: {key} — {value}", + QueryFound: "вот что я нашла: {text}", + QueryPageText: "вот что на странице: {text}", + QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", + QueryPageEmpty: "страница открылась, но читать там нечего.", + QueryFeedsOff: "я пока не читаю ленты — они не настроены.", + QueryFeedsNew: "вот что нового: {items}", + QueryFeedsEmpty: "в лентах пока ничего нового.", + QueryFeedsTopic: "по этой теме в лентах пока ничего.", + QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.", + QueryWeatherOff: "погода не настроена.", + QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", + QueryNetEmpty: "в сети никого не нашла{tail}.", + + QueryFailPlan: "не получилось собрать план.", + QueryFailNotes: "не получилось посмотреть записи.", + QueryFailFeeds: "не получилось посмотреть ленты.", + QueryFailCalendar: "не получилось проверить календарь.", + QueryFailWeather: "не получилось узнать погоду.", + QueryFailAnswer: "не получилось найти ответ.", + QueryFailPage: "не получилось прочитать страницу.", + QueryFailNetscan: "не получилось просканировать сеть.", +}) + +// Queries picks a hand-written Russian query line. Safe for concurrent use. +type Queries struct{ d *deck } + +// LoadQueries reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadQueries(src rand.Source) (*Queries, error) { + d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) + if err != nil { + return nil, err + } + // The entries that exist to read something back. A variant without the + // placeholder would answer the question by dropping the answer. + for _, req := range []struct{ key, ph string }{ + {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"}, + {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, + {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, + } { + if err := d.requirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Queries{d: d}, nil +} + +// deck reads through a nil *Queries, which is the unloadable-file case. +func (q *Queries) deck() *deck { + if q == nil { + return nil + } + return q.d +} + +// Say returns one line for key, with the values filled into the frame. +func (q *Queries) Say(key string, vars map[string]string) string { + return q.deck().text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (q *Queries) Variants() []string { return q.deck().variants() } + +var ( + queryOnce sync.Once + queries *Queries +) + +// DefaultQueries returns the shared instance, loading it on first use. A broken +// file logs once and leaves a nil *Queries, which still answers from queryFloor. +func DefaultQueries() *Queries { + queryOnce.Do(func() { + q, err := LoadQueries(nil) + if err != nil { + log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err) + return + } + queries = q + }) + return queries +} + +// Q — one query line, the way every caller says it. +func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) } + +// IsQ reports whether text is a line key could have produced, for the tests. +func IsQ(key string, vars map[string]string, text string) bool { + return DefaultQueries().deck().matches(key, vars, text) +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json new file mode 100644 index 0000000..249294e --- /dev/null +++ b/internal/phraser/query_ru_v1.json @@ -0,0 +1,93 @@ +{ + "schema_version": 1, + "name": "russian query answers and gaps v1", + "notes": [ + "What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", + "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.", + "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day." + ], + "entries": { + "query_unknown": { + "variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."] + }, + "other_day": { + "fixed": true, + "variants": ["про другой день так не отвечу — спроси целиком."] + }, + "personal_none": { + "fixed": true, + "variants": ["не знаю — не нашла у тебя такой записи."] + }, + "fact_when": { + "variants": ["я записала это {when}", "записала это {when}"] + }, + "fact_value": { + "variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"] + }, + "found": { + "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] + }, + "page_text": { + "variants": ["вот что на странице: {text}", "на странице вот это: {text}"] + }, + "page_blocked": { + "fixed": true, + "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] + }, + "page_empty": { + "variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."] + }, + "feeds_off": { + "variants": ["я пока не читаю ленты — они не настроены."] + }, + "feeds_new": { + "variants": ["вот что нового: {items}", "нового вот что: {items}"] + }, + "feeds_empty": { + "variants": ["в лентах пока ничего нового.", "в лентах тихо."] + }, + "feeds_empty_topic": { + "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] + }, + "weather_now": { + "variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + }, + "weather_off": { + "variants": ["погода не настроена."] + }, + "weather_nolocation": { + "fixed": true, + "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + }, + "net_empty": { + "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] + }, + "fail_plan": { + "variants": ["не получилось собрать план.", "план не собрался."] + }, + "fail_notes": { + "variants": ["не получилось посмотреть записи.", "записи не открылись."] + }, + "fail_feeds": { + "variants": ["не получилось посмотреть ленты.", "ленты не открылись."] + }, + "fail_calendar": { + "variants": ["не получилось проверить календарь.", "календарь не открылся."] + }, + "fail_weather": { + "variants": ["не получилось узнать погоду.", "погода не пришла."] + }, + "fail_answer": { + "variants": ["не получилось найти ответ.", "ответ не нашёлся."] + }, + "fail_page": { + "variants": ["не получилось прочитать страницу.", "страница не прочиталась."] + }, + "fail_netscan": { + "variants": ["не получилось просканировать сеть.", "сеть не просканировалась."] + } + } +} -- 2.52.0 From 16d94894b7b902daec4c4d0745d85e691905c594 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH 2/2] mavend: say the query answers from the file (V-503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three daemon tests that pinned a wording ask the entry instead. The eval scores every query variant on the persona checks, minus hisgender: it reads her own feminine verb next to "у тебя" as addressing him as a woman. --- cmd/mavend/actions_query.go | 60 +++++++++++++------------ cmd/mavend/crawls_test.go | 2 +- cmd/mavend/dayplan_test.go | 5 ++- cmd/mavend/feeds_test.go | 2 +- cmd/mavend/netscan.go | 5 ++- internal/phraser/eval/fallbacks_test.go | 18 ++++++-- 6 files changed, 54 insertions(+), 38 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 42cb70f..f019816 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // The previous question cannot be re-asked for another day. Saying so // beats "не знаю", which reads as "no data for tomorrow" when the // truth is that she never looked. - return "про другой день так не отвечу — спроси целиком." + return phraser.Q(phraser.QueryOtherDay, nil) } - return "не знаю." + return phraser.Q(phraser.QueryUnknown, nil) } // queryFactByKey — when the dialogue layer resolved an anaphoric reference to @@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str if dec.Slots.HasTime { // The query asks about timing — the fact's own timestamp is the // answer it's looking for. Format as a natural reply. - return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true + return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true } // General fact reference: describe what we know. if dec.Utterance == "" { - return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true + return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true } // The utterance still carries the question; fall through to normal RAG // with the resolved key in context. @@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin plan, err := h.api.DayPlan(ctx) if err != nil { log.Printf("voice: day plan: %v", err) - return "не получилось собрать план.", true + return phraser.Q(phraser.QueryFailPlan, nil), true } if !router.IsRestOfDayQuery(t.dec.Utterance) { return plan.Spoken, true @@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow) if err != nil { log.Printf("voice: habits: recent facts: %v", err) - return "не получилось посмотреть записи.", true + return phraser.Q(phraser.QueryFailNotes, nil), true } obs := make([]memory.Observation, 0, len(facts)) for _, f := range facts { @@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, // Claim the turn rather than fall through: "не читаю ленты" is true, and // letting general knowledge answer "что нового?" would be an invented // news bulletin. - return "я пока не читаю ленты — они не настроены.", true + return phraser.Q(phraser.QueryFeedsOff, nil), true } // By source, not the last 200 notes of any kind: a busy day of voice notes // used to push the newest headline out of the window, and she answered "в @@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) - return "не получилось посмотреть ленты.", true + return phraser.Q(phraser.QueryFailFeeds, nil), true } var picked []string for _, n := range notes { @@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, } if len(picked) == 0 { if q.Category != "" { - return "по этой теме в лентах пока ничего.", true + return phraser.Q(phraser.QueryFeedsTopic, nil), true } - return "в лентах пока ничего нового.", true + return phraser.Q(phraser.QueryFeedsEmpty, nil), true } - return "вот что нового: " + strings.Join(picked, "; "), true + return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true } // queryCalendar — "что у меня сегодня?", "планы на завтра?" @@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) - return "не получилось проверить календарь.", true + return phraser.Q(phraser.QueryFailCalendar, nil), true } // Provenance travels with each event. A work meeting relayed off a phone // notification (source ambient:notif, #126) is stored below full confidence @@ -382,19 +382,23 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin if loc == "" { // He named no city and voice.weather.default_location is unset. Saying // so is the only honest answer; picking a city would be inventing one. - return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true + return phraser.Q(phraser.QueryWeatherWhere, nil), true } ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { - return "погода не настроена.", true + return phraser.Q(phraser.QueryWeatherOff, nil), true } if err != nil { log.Printf("voice: weather: %v", err) - return "не получилось узнать погоду.", true + return phraser.Q(phraser.QueryFailWeather, nil), true } - return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true + return phraser.Q(phraser.QueryWeatherNow, map[string]string{ + "location": w.Location, + "temp": fmt.Sprintf("%.0f", w.Temperature), + "condition": w.Condition, + }), true } // queryEmbed isn't an answer source — it's the shared cost the two recall @@ -404,7 +408,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.vec = vec return "", false @@ -473,7 +477,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, notes, err := h.api.QueryNotes(ctx, t.vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) - return "не получилось найти ответ.", true + return phraser.Q(phraser.QueryFailAnswer, nil), true } t.notes = notes noteScores := make([]float64, len(notes)) @@ -498,7 +502,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, log.Printf("voice: phrase query: %v", err) } if reply == "" { - reply = "вот что я нашла: " + texts[0] + reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]}) } return reply, true } @@ -532,13 +536,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b page, err := h.crawler.Page(ctxFetch, link) if err != nil { if errors.Is(err, crawl.ErrRobots) { - return "эта страница закрыта для чтения — robots.txt не разрешает.", true + return phraser.Q(phraser.QueryPageBlocked, nil), true } log.Printf("voice: web: %v", err) - return "не получилось прочитать страницу.", true + return phraser.Q(phraser.QueryFailPage, nil), true } if page.Text == "" { - return "страница открылась, но читать там нечего.", true + return phraser.Q(phraser.QueryPageEmpty, nil), true } // The page is handed to the phraser the same way a note is: as context for // the question he actually asked. She answers the question, she does not @@ -548,7 +552,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. - return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true + return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true } return reply, true } @@ -614,7 +618,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true } return reply, true } @@ -695,7 +699,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. - return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true } return reply, true } @@ -725,7 +729,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri return "", false } log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) - return "не знаю — не нашла у тебя такой записи.", true + return phraser.Q(phraser.QueryPersonalNone, nil), true } // personalMarkers — first-person POSSESSION, not first person generally. @@ -793,7 +797,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin if h.phraser == nil { // No model of any size. That is not the workstation being asleep, so it // is not that gap: it is simply not knowing. - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) if errors.Is(err, phraser.ErrNoWorldModel) { @@ -801,7 +805,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin return worldGap(), true } if err != nil || reply == "" { - return "не знаю.", true + return phraser.Q(phraser.QueryUnknown, nil), true } return reply, true } diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index 5055880..4423355 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) { if !ok { t.Fatal("the web source did not claim a question with a URL") } - if !strings.Contains(reply, "не получилось") { + if !phraser.IsQ(phraser.QueryFailPage, nil, reply) { t.Errorf("reply = %q, want the read-failed answer", reply) } } diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go index 0fd511c..52a0aa9 100644 --- a/cmd/mavend/dayplan_test.go +++ b/cmd/mavend/dayplan_test.go @@ -10,6 +10,7 @@ import ( "github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) { if !ok { t.Fatal("a failed plan read must still answer, not fall through to RAG") } - if reply != "не получилось собрать план." { - t.Errorf("reply = %q", reply) + if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) { + t.Errorf("reply = %q, want the honest failure", reply) } } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 0759c50..7db7f94 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) { t.Fatalf("reply = %q, want only the технологии item", reply) } reply, _ = askFeeds(t, h, "что нового по спорту?") - if !strings.Contains(reply, "ничего") { + if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) { t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply) } } diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index 83c1c35..c5de502 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -11,6 +11,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/netscan" + "github.com/kami/maven/internal/phraser" ) // scanBudget — the whole spoken scan, end to end. A voice turn that takes @@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { res, err := w.scan(ctx) if err != nil { log.Printf("netscan: scan: %v", err) - return "не получилось просканировать сеть.", true + return phraser.Q(phraser.QueryFailNetscan, nil), true } // A truncated run is not a statement about the LAN. Saying "нашла 6 // устройств" after stopping two thirds of the way through the range is a @@ -116,7 +117,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { tail = ", но успела посмотреть не всю сеть" } if len(res.Hosts) == 0 { - return "в сети никого не нашла" + tail + ".", true + return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 55c4fe5..b4739cc 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -8,8 +8,8 @@ import ( "github.com/kami/maven/internal/phraser" ) -// TestFallbackPersona scores every line in fallbacks_ru_v1.json and -// ack_ru_v1.json on the persona checks the nudges already pass. These lines are +// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and +// query_ru_v1.json on the persona checks the nudges already pass. These lines are // heard out loud and they live in a JSON file now, so a reworded variant that // says "рад" or "вы" would otherwise reach him with nothing in between. // @@ -20,22 +20,32 @@ func TestFallbackPersona(t *testing.T) { if err != nil { t.Fatalf("LoadFallbacks: %v", err) } + // No CheckHisGender. It reads a feminine verb near a second-person pronoun + // as addressing him as a woman, which is right for a nudge and wrong here: + // "не знаю — не нашла у тебя такой записи" is her own verb in her own + // sentence. CheckFeminine still holds her side of the rule. persona := map[string]bool{ - CheckLang: true, CheckFeminine: true, CheckHisGender: true, + CheckLang: true, CheckFeminine: true, CheckAddress: true, CheckCringe: true, CheckLength: true, } ack, err := phraser.LoadAcks(rand.NewSource(20260804)) if err != nil { t.Fatalf("LoadAcks: %v", err) } + qry, err := phraser.LoadQueries(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } variants := append(fb.Variants(), ack.Variants()...) + variants = append(variants, qry.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } for _, v := range variants { // The placeholders stand for his own words and carry no persona. body := v - for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} { + for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", + "{location}", "{temp}", "{condition}", "{tail}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") { -- 2.52.0