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") { 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": ["не получилось просканировать сеть.", "сеть не просканировалась."] + } + } +}