phraser: the query answers as review rewrote them (V-521)
The owner's wording, taken from the PR 111 review, with one correction from the
PR 113 review folded in: {temp} {word} rather than {temp}°, because the degree
sign reads as nothing through piper.
What the wording changes: query_unknown drops "не знаю.", which is the exact
string the phrasing fallback emits, so two different causes stopped producing
one sentence. weather_nolocation stops reading voice.weather.default_location
out loud and just asks which city. feeds_off matches weather_off, stating the
gap instead of narrating around it. The passive doubles and the near-identical
pairs go.
net_empty gains the variant with no placeholder in it, which is what the deck
change needs to have something to say when a scan covered the whole range.
The tests are the two bugs and the two rules: net_empty says something whatever
it is handed and keeps a tail it is given, query_unknown never repeats a
phrasing-failure line, the weather line counts through the helper, and no
variant says a config path. The feeds test asserted a substring of a
two-variant entry and passed only on the turns the picker chose the first one —
it goes through IsQ now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+15
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user