diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f019816..9f3f771 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -397,6 +397,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin return phraser.Q(phraser.QueryWeatherNow, map[string]string{ "location": w.Location, "temp": fmt.Sprintf("%.0f", w.Temperature), + "word": phraser.Degrees(w.Temperature), "condition": w.Condition, }), true } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 7db7f94..4db05da 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) { // "не настроены" and "ничего нового" are different truths, and neither may be // answered by the model inventing a bulletin. func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) { + // Against the entries, not against a substring: both of these have several + // wordings, so "ничего нового" passed only on the turns the picker happened + // to choose the first one. off := buildFeedHandler(t, false) reply, ok := askFeeds(t, off, "что нового в лентах?") - if !ok || !strings.Contains(reply, "не настроены") { + if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok) } on := buildFeedHandler(t, true) reply, ok = askFeeds(t, on, "что нового в лентах?") - if !ok || !strings.Contains(reply, "ничего нового") { + if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) { t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) } + if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { + t.Fatalf("an empty feed answered as an unconfigured one: %q", reply) + } } func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) { diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 638eefd..aefbb44 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -64,25 +64,27 @@ var queryKeys = []string{ } // queryFloor — the literal each key falls back to when the file is unusable. -// These are the exact strings that lived in Go before this file existed. -var queryFloor = registerFloor(map[string]string{ - QueryUnknown: "не знаю.", +// It started as the exact strings that lived in Go before this file existed and +// now tracks the file's first variant instead, because a floor that keeps the +// wording review threw out would say it back on the one turn nobody is watching. +var queryFloor = map[string]string{ + QueryUnknown: "ничего не нашла.", QueryOtherDay: "про другой день так не отвечу — спроси целиком.", QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", - QueryFactWhen: "я записала это {when}", - QueryFactValue: "вот что я знаю: {key} — {value}", + QueryFactWhen: "записала это {when}", + QueryFactValue: "у меня записано: {key} — {value}", QueryFound: "вот что я нашла: {text}", QueryPageText: "вот что на странице: {text}", QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", QueryPageEmpty: "страница открылась, но читать там нечего.", - QueryFeedsOff: "я пока не читаю ленты — они не настроены.", + QueryFeedsOff: "ленты не настроены.", QueryFeedsNew: "вот что нового: {items}", QueryFeedsEmpty: "в лентах пока ничего нового.", QueryFeedsTopic: "по этой теме в лентах пока ничего.", - QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.", + QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.", QueryWeatherOff: "погода не настроена.", - QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", - QueryNetEmpty: "в сети никого не нашла{tail}.", + QueryWeatherWhere: "для какого города?", + QueryNetEmpty: "в сети никого не нашла.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", @@ -92,7 +94,7 @@ var queryFloor = registerFloor(map[string]string{ QueryFailAnswer: "не получилось найти ответ.", QueryFailPage: "не получилось прочитать страницу.", QueryFailNetscan: "не получилось просканировать сеть.", -}) +} // Queries picks a hand-written Russian query line. Safe for concurrent use. type Queries struct{ d *deck } @@ -109,7 +111,8 @@ func LoadQueries(src rand.Source) (*Queries, error) { for _, req := range []struct{ key, ph string }{ {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"}, {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, - {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, + {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, + {QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"}, } { if err := d.requirePlaceholder(req.key, req.ph); err != nil { return nil, err @@ -121,7 +124,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { // deck reads through a nil *Queries, which is the unloadable-file case. func (q *Queries) deck() *deck { if q == nil { - return nil + return floorDeck(queryFloor) } return q.d } diff --git a/internal/phraser/query_lines_test.go b/internal/phraser/query_lines_test.go new file mode 100644 index 0000000..17302dd --- /dev/null +++ b/internal/phraser/query_lines_test.go @@ -0,0 +1,107 @@ +package phraser + +import ( + "math/rand" + "strings" + "testing" +) + +func TestQueriesLoad(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, k := range queryKeys { + if got := q.Say(k, nil); got == "" { + t.Errorf("%s says nothing", k) + } + } +} + +// The bug: net_empty carried {tail} in every variant, and a scan that finished +// the whole range has no caveat to put there. Whatever the file says, an answer +// he can hear has to come out — never braces, never nothing. +func TestNetEmptySaysSomethingWithNoTail(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, vars := range []map[string]string{nil, {"tail": ""}} { + for i := 0; i < 20; i++ { + got := q.Say(QueryNetEmpty, vars) + if got == "" || strings.ContainsAny(got, "{}") { + t.Fatalf("net_empty with vars %v said %q", vars, got) + } + } + } +} + +// The other half: a caveat he was given is not dropped for a shorter wording. +func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + const tail = ", но успела посмотреть не всю сеть" + for i := 0; i < 20; i++ { + if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) { + t.Fatalf("net_empty dropped the tail: %q", got) + } + } +} + +// query_unknown means she looked and found nothing. The phraser's fallback +// means she failed to phrase an answer she had. Two causes, two sentences, or +// the distinction the two files exist for is unobservable from the outside. +func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + f, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + failures := map[string]bool{} + for _, v := range f.Variants() { + failures[v] = true + } + for _, v := range q.d.file.Entries[QueryUnknown].Variants { + if failures[v] { + t.Errorf("query_unknown variant %q is also a phrasing failure line", v) + } + } +} + +// The weather line splits the count into a number and a noun, so a variant that +// says the temperature without {word} is the hardcoded "градусов" coming back. +func TestWeatherLineCountsWithTheHelper(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.d.file.Entries[QueryWeatherNow].Variants { + if strings.Contains(v, "градус") { + t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v) + } + } + got := q.Say(QueryWeatherNow, map[string]string{ + "location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно", + }) + if !strings.Contains(got, "1 градус,") { + t.Errorf("weather_now said %q, want the singular noun", got) + } +} + +// No line spoken to him names a config key. She asks instead. +func TestNoQueryLineRecitesAConfigPath(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.Variants() { + if strings.Contains(v, "voice.") || strings.Contains(v, "_location") { + t.Errorf("variant %q says a config path out loud", v) + } + } +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 249294e..943d5bd 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -5,13 +5,15 @@ "What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.", - "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.", - "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.", + "A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.", + "{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.", "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day." ], "entries": { "query_unknown": { - "variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."] + "variants": ["ничего не нашла.", "искала — не нашла."] }, "other_day": { "fixed": true, @@ -22,10 +24,10 @@ "variants": ["не знаю — не нашла у тебя такой записи."] }, "fact_when": { - "variants": ["я записала это {when}", "записала это {when}"] + "variants": ["записала это {when}"] }, "fact_value": { - "variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"] + "variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"] }, "found": { "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] @@ -38,10 +40,10 @@ "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] }, "page_empty": { - "variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."] + "variants": ["страница открылась, но читать там нечего."] }, "feeds_off": { - "variants": ["я пока не читаю ленты — они не настроены."] + "variants": ["ленты не настроены."] }, "feeds_new": { "variants": ["вот что нового: {items}", "нового вот что: {items}"] @@ -53,41 +55,41 @@ "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] }, "weather_now": { - "variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] }, "weather_off": { "variants": ["погода не настроена."] }, "weather_nolocation": { "fixed": true, - "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + "variants": ["для какого города?"] }, "net_empty": { - "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] + "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, "fail_plan": { - "variants": ["не получилось собрать план.", "план не собрался."] + "variants": ["не получилось собрать план."] }, "fail_notes": { - "variants": ["не получилось посмотреть записи.", "записи не открылись."] + "variants": ["не получилось посмотреть записи."] }, "fail_feeds": { - "variants": ["не получилось посмотреть ленты.", "ленты не открылись."] + "variants": ["не получилось посмотреть ленты."] }, "fail_calendar": { - "variants": ["не получилось проверить календарь.", "календарь не открылся."] + "variants": ["не получилось проверить календарь."] }, "fail_weather": { - "variants": ["не получилось узнать погоду.", "погода не пришла."] + "variants": ["не получилось узнать погоду."] }, "fail_answer": { - "variants": ["не получилось найти ответ.", "ответ не нашёлся."] + "variants": ["не получилось найти ответ."] }, "fail_page": { - "variants": ["не получилось прочитать страницу.", "страница не прочиталась."] + "variants": ["не получилось прочитать страницу."] }, "fail_netscan": { - "variants": ["не получилось просканировать сеть.", "сеть не просканировалась."] + "variants": ["не получилось просканировать сеть."] } } }