Compare commits

...

2 Commits

Author SHA1 Message Date
claude 16d94894b7 mavend: say the query answers from the file (V-503)
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.
2026-08-04 01:32:15 +04:00
claude ae8d38fc31 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.
2026-08-04 01:32:15 +04:00
8 changed files with 309 additions and 38 deletions
+32 -28
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+3 -2
View File
@@ -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 != "" {
+14 -4
View File
@@ -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") {
+162
View File
@@ -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)
}
+93
View File
@@ -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": ["не получилось просканировать сеть.", "сеть не просканировалась."]
}
}
}