Compare commits

...

3 Commits

Author SHA1 Message Date
claude 765ed36340 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
2026-08-04 15:38:16 +04:00
claude feca776077 phraser: a variant she cannot fill is not a variant she can say (V-521)
Two defects in the deck, both of which reach him as a broken answer.

An optional placeholder had no rule. net_empty carries {tail} for the case
where a scan stopped short of the whole range, and a scan that finished has
nothing to put there — so the answer went out with the braces in it, or with
nothing at all if the variant was all placeholder. The picker now narrows to
the variants this call can actually fill, and prefers, among those, the ones
using the most of what the caller supplied, so a caveat he was given is never
dropped for a shorter wording. Nothing fillable still says the line, because a
visible placeholder beats silence.

The floor literals lived in one global map keyed by bare entry name, and two
families both define an entry called query_unknown: the query answers, where
she looked and found nothing, and the phrasing fallbacks, where she failed to
say an answer she had. Whichever registered last answered for both, so the
distinction those two files exist for disappeared exactly when a file failed to
load. Each family now carries its own map, and an unloadable file leaves a
floor-only deck behind instead of a nil one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:38:16 +04:00
claude d79b30a1a6 phraser: one count helper, so the weather says "1 градус" (V-521)
The weather line spelled "градусов" out in the template, which is the wrong
form for 1-4 and for every number ending in 1-4. Russian inflects the noun
after a numeral, so the count splits into the number and {word}.

hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was
the only place that did. It moves to internal/phraser as CountWord, with
Degrees and Devices over it, and the three call sites that counted devices now
read the same helper the weather line does. Degrees rounds before it counts, so
the noun agrees with the number she is about to say rather than the reading
behind it, and a negative reading counts by its magnitude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:37:54 +04:00
13 changed files with 314 additions and 89 deletions
+1
View File
@@ -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
}
+8 -2
View File
@@ -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) {
+2 -18
View File
@@ -119,7 +119,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
if len(res.Hosts) == 0 {
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
}
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
@@ -180,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
@@ -211,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
}
}
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
// 5+ устройств, and the teens are all the last form.
func hostWord(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "устройств"
}
switch n % 10 {
case 1:
return "устройство"
case 2, 3, 4:
return "устройства"
default:
return "устройств"
}
}
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
-12
View File
@@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
}
}
func TestHostWordAgreesWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := hostWord(n); got != want {
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
}
}
}
func TestIsNetworkQuery(t *testing.T) {
yes := []string{
"какие устройства в сети?",
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/smarthome"
"github.com/kami/maven/internal/store"
)
@@ -185,7 +186,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
case dark > 0 && len(sensors) == 0:
// Nothing is on and everything she can see is unreachable. "всё
// выключено" would be a claim about the house she cannot make.
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true
default:
parts = append(parts, "всё выключено")
}
@@ -193,7 +194,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
parts = append(parts, strings.Join(sensors, ", "))
}
if dark > 0 {
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
}
return strings.Join(parts, "; ") + ".", true
}
+3 -3
View File
@@ -65,7 +65,7 @@ var ackKeys = []string{
// ackFloor — 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 ackFloor = registerFloor(map[string]string{
var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
@@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{
FailAck: "не получилось отметить.",
FailSnooze: "не получилось отложить.",
FailQuiet: "не получилось переключить тихий режим.",
})
}
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *deck }
@@ -119,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *deck {
if a == nil {
return nil
return floorDeck(ackFloor)
}
return a.d
}
+73 -19
View File
@@ -35,8 +35,8 @@ type deckFile struct {
Entries map[string]deckEntry `json:"entries"`
}
// deck picks a line. Safe for concurrent use. A nil *deck answers from the
// floor, which is what an unloadable file leaves behind.
// deck picks a line. Safe for concurrent use. A deck with no entries answers
// from the floor, which is what an unloadable file leaves behind (floorDeck).
type deck struct {
mu sync.Mutex
rnd *rand.Rand
@@ -90,7 +90,7 @@ func (d *deck) text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, e)
tmpl = d.pick(key, fillable(e.Variants, vars))
}
}
if tmpl == "" {
@@ -129,12 +129,67 @@ func (d *deck) variants() []string {
return out
}
// fillable narrows variants to the ones this call can actually say, which is
// the rule an optional placeholder needs: a caller with nothing to put in
// {tail} must not be handed a variant that has one. Two passes, because both
// halves matter. The first keeps only variants whose every placeholder has a
// non-empty value, so an absent optional never reaches him as braces. The
// second prefers, among those, the variants using the most of what the caller
// supplied, so a caveat he was given is not dropped for a shorter wording.
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
// up in the answer rather than turning it into silence.
func fillable(variants []string, vars map[string]string) []string {
if len(variants) < 2 {
return variants
}
best, bestUsed := make([]string, 0, len(variants)), -1
for _, v := range variants {
used := 0
ok := true
for _, ph := range placeholders(v) {
if vars[ph] == "" {
ok = false
break
}
used++
}
if !ok || used < bestUsed {
continue
}
if used > bestUsed {
best, bestUsed = best[:0], used
}
best = append(best, v)
}
if len(best) == 0 {
return variants
}
return best
}
// placeholders lists the {name}s in tmpl, in order.
func placeholders(tmpl string) []string {
var out []string
for {
i := strings.IndexByte(tmpl, '{')
if i < 0 {
return out
}
j := strings.IndexByte(tmpl[i:], '}')
if j < 0 {
return out
}
out = append(out, tmpl[i+1:i+j])
tmpl = tmpl[i+j+1:]
}
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *deck) pick(key string, e deckEntry) string {
func (d *deck) pick(key string, variants []string) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := e.Variants
choices := variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
@@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string {
return got
}
// floorOf reads the Go literal behind key, and works on a nil deck because that
// is exactly the case it exists for. The per-family map is the source of truth.
// floorOf reads the Go literal behind key. Every deck carries its own family's
// map, including the floor-only deck an unloadable file leaves behind, so no
// lookup ever crosses families. It used to go through one global map keyed by
// bare entry name, which two families both calling an entry query_unknown
// silently shared: whichever registered last answered for both (Vikunja #521).
func floorOf(d *deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
if d == nil {
return ""
}
return deckFloors[key]
return d.floor[key]
}
// deckFloors — every family's floor literals in one map, so a nil deck still
// finds them. Families register at init; the keys are namespaced by family.
var deckFloors = map[string]string{}
func registerFloor(floor map[string]string) map[string]string {
for k, v := range floor {
deckFloors[k] = v
}
return floor
// floorDeck — the deck a family falls back to when its file will not load. It
// has no entries, so every read drops through to the floor literals, and it is
// a real *deck so no accessor has to know which case it is in.
func floorDeck(floor map[string]string) *deck {
return &deck{last: map[string]string{}, floor: floor}
}
// fill substitutes {name} for each var. A placeholder with no value is left
+3 -3
View File
@@ -39,12 +39,12 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — 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 hardFloor = registerFloor(map[string]string{
var hardFloor = map[string]string{
fbChat: "даже не знаю, что сказать.",
fbQueryUnknown: "не знаю.",
fbQuerySources: "вот что я нашла: {sources}",
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
})
}
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
type Fallbacks struct{ d *deck }
@@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *deck {
if f == nil {
return nil
return floorDeck(hardFloor)
}
return f.d
}
+47
View File
@@ -0,0 +1,47 @@
package phraser
// The counted noun, in the form the number in front of it demands.
//
// Russian inflects a noun after a numeral, and the form depends on the last two
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
// A line file cannot spell that out, so a count in a template splits into two
// placeholders — the number, and {word} filled from here.
//
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
// weather line said "градусов" for every temperature and was wrong for 1-4 and
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
import "math"
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
// for 0, 5-20 and anything ending in those. A negative count reads its own
// magnitude, since minus does not change the noun: -2 градуса.
func CountWord(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if n%100 >= 11 && n%100 <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
// weather provider and counts by the whole degrees she is about to say, so the
// noun agrees with the number in the same sentence rather than with the reading
// behind it.
func Degrees(temp float64) string {
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
}
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string {
return CountWord(n, "устройство", "устройства", "устройств")
}
+32
View File
@@ -0,0 +1,32 @@
package phraser
import "testing"
// The bug the helper exists for: the weather line said "градусов" for every
// reading, which is wrong for 1-4 and for every number ending in 1-4.
func TestDegreesAgreeWithTheReading(t *testing.T) {
for temp, want := range map[float64]string{
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
22: "градуса", 25: "градусов", 101: "градус",
// Minus does not change the noun, and a reading rounds to the number
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
} {
if got := Degrees(temp); got != want {
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
}
}
}
func TestDevicesAgreeWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := Devices(n); got != want {
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
}
}
}
+15 -12
View File
@@ -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
}
+107
View File
@@ -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)
}
}
}
+20 -18
View File
@@ -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": ["не получилось просканировать сеть."]
}
}
}