Compare commits

..

9 Commits

Author SHA1 Message Date
claude 761cf9f3e0 Merge PR #115 into task/479 (V-498) 2026-08-04 14:08:56 +02:00
claude 1c8a32c3fd router: stage 0 claims "что дальше?" and "расскажи про X" (V-498)
Both shapes carry no question mark and no interrogative, so the model saw
them with nothing deterministic in front and routed both to fact. The fact
gate caught the write and re-ran the turn as a query, so nothing broke —
what they cost was a full model round trip for a decision two patterns can
make offline.

NarrativeQueryGrammars, wired after the agenda rules so that "расскажи,
что у меня сегодня" stays an agenda question. Two exclusions, both learned
from the fixture: a capture verb in the rest of the utterance means he
asked for a note, and an entertainment noun means chat — "расскажи анекдот
про программистов" is ru-chat-003, and my first pattern took it.

The fixture had no case for either shape, which is why they went unnoticed.
Added as ru-query-020 and ru-query-021: classifier+onnx 53/77 → 55/79
(68.8% → 69.6%), both new cases answered at stage 0, false clarifies
unchanged at 0.
2026-08-04 01:56:50 +04:00
claude 96b474223d mavend: an unconfigured capability names the gap (V-479)
Netscan and the crawler both declined their own turn when the wiring was
nil, and the question fell through to the search leg. "какие устройства в
сети?" came back as a paragraph about routers in general, and a question
about his own LAN went to an upstream engine — the personal boundary
exists to stop exactly that. A URL he named came back answered as though
he had not named it.

Both now claim the turn once their own recogniser has matched, and say
which capability is missing: net_off and page_off in the query family.

TestQueryWebPassesWhenNotConfigured encoded the old decision, that
announcing a configuration status is only for a capability that exists and
failed. It is rewritten, not deleted: the gap is the answer now.
2026-08-04 01:51:27 +04:00
claude 42d7a39c49 morning, tasks, memory: say the summaries from the file (V-506)
The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.

Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".

The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
2026-08-04 01:47:52 +04:00
claude bad3fa4035 say: load the summaries family (V-506)
Same nil-safe shape as the four families in phraser: a floor holding the
exact literals that lived in Go, a load-time placeholder check on every
entry whose job is to read the aggregate back, and S/IsS for the callers
and their tests. No call site moved yet.
2026-08-04 01:44:18 +04:00
claude d819fc09f0 say: the summaries copy file (V-506)
summary_ru_v1.json: the morning plan, the ranked task list, and the habit
sentences read back out of behaviour records. Own schema_version.

The empty cases are the point. "I have not seen enough yet" and "there is
nothing there" are different claims about his life, and the habit entries
keep the first — three days of taps produce the same "обычно ты ..." as a
year of them. plan_rest_empty stays separate from plan_day_empty for the
same reason: a day that is over was not an empty day.

Count forms stay in Go. день/дня/дней and задача/задачи/задач are
morphology, and they arrive here through {word}. Loader in the next commit.
2026-08-04 01:44:18 +04:00
claude c35979d9f9 say: move the copy deck into a package memory can import (V-506)
The summaries family is spoken by internal/memory, internal/morning and
internal/tasks. internal/phraser already imports internal/memory, so the
deck cannot stay in phraser without a cycle.

internal/say is a leaf: embed, json, math/rand, strings, sync. The four
phraser families keep their files and their floors and now call say.Load,
*say.Deck, Text, Matches, Variants, RequirePlaceholder and RegisterFloor.
No copy changed and no behaviour changed.
2026-08-04 01:42:20 +04:00
claude f3c0540b42 mavend: say the act replies from the file (V-504)
Also fixes a flake this stack introduced: the feeds test matched "ничего
нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо".
It asks the entry now, like the others.
2026-08-04 01:38:14 +04:00
claude 5b4192acb5 phraser: put the act and smart-home replies in a versioned json (V-504)
What she says when a capability ran, refused, or could not be reached. Around
forty literals across ecosystem_acts.go, actions_act.go and smarthome.go.

"It ran", "it was refused", "the ecosystem is down" and "I could not work out
what you meant" keep four entries. One variant set across them would let a
failure report itself as a success, which is the only failure mode this family
has.

The lines that report an act as done are fixed rather than varied. A success
report that rewords itself is harder to trust when he is listening for it, and
the confirmations are fixed for the same reason: they carry an instruction.

internal/smarthome/ha.go keeps its own "готово". It is a device driver, and
wiring the copy deck into one is the wrong dependency — the daemon relays that
word, it does not speak it.
2026-08-04 01:38:14 +04:00
32 changed files with 1112 additions and 457 deletions
+9 -8
View File
@@ -6,6 +6,7 @@ import (
"log"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
)
@@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
// destructive: park it and ask. The next utterance answers.
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
// The row is enabled and the backend is gone. Drafting a proposal
// for it (the ErrNotEnabled path) would be answering the wrong
// question.
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
return phraser.A(phraser.ActServerDown, nil)
case errors.Is(err, mcp.ErrToolGone):
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
return phraser.A(phraser.ActWithdrawn, nil)
case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so
// instead — the tool is still runnable from the authed surface,
// where a human types them.
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
return phraser.A(phraser.ActNeedsArgs, nil)
}
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
if out != "" {
return "не получилось выполнить команду: " + firstLine(out)
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
}
return "не получилось выполнить команду."
return phraser.A(phraser.ActFail, nil)
}
if out != "" {
return "готово: " + firstLine(out)
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
}
return "готово."
return phraser.A(phraser.ActDone, nil)
}
+11 -10
View File
@@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
return "", false
}
if h.netscan == nil {
// Fall through, same as queryHome: an unconfigured scanner must not
// swallow "сколько устройств в сети?" before recall has looked.
return "", false
// The recogniser already matched, so this is a question about HIS LAN
// and there is no scanner to answer it. Falling through sent it to the
// search leg, which answered with a paragraph about routers in general
// and put his network question on an upstream engine (Vikunja #479).
// A missing capability names itself.
return phraser.Q(phraser.QueryNetOff, nil), true
}
return h.netscan.scanSummary(ctx)
}
@@ -397,7 +400,6 @@ 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
}
@@ -525,12 +527,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false
}
if h.crawler == nil {
// Fall through. Reading pages is off unless configured, and on a daemon
// where it was never turned on the older behaviour is right: the model
// answers the question as if the URL had not been said. Announcing a
// configuration status is for a capability that exists and failed, not
// for one he never asked for.
return "", false
// He named a URL, so the question is about that page and nothing else
// can answer it. The older comment here argued for falling through and
// letting the model answer as if the URL had not been said; that is a
// guess dressed as an answer (Vikunja #479).
return phraser.Q(phraser.QueryPageOff, nil), true
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
+10 -6
View File
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
}
}
// A daemon where page reading was never turned on — the default — answers the
// question the way it did before the capability existed. Claiming the turn to
// report a configuration status is for something that exists and failed.
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
// A daemon where page reading was never turned on names the gap. He asked
// about one page, nothing else on the box can read it, and the old behaviour
// here was to answer as though the URL had not been said (Vikunja #479).
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
reply, ok := askWeb(h, "посмотри https://example.org/page")
if !ok {
t.Fatal("an unconfigured crawler let the page question fall through")
}
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
t.Errorf("got %q, want the gap named", reply)
}
}
+27 -26
View File
@@ -9,6 +9,7 @@ import (
"time"
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -144,10 +145,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать, что требует внимания."
return phraser.A(phraser.AttentionFail, nil)
}
if len(items) == 0 {
return "ничего не требует внимания."
return phraser.A(phraser.AttentionNone, nil)
}
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
@@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
}
}
}
return "требует внимания: " + strings.Join(parts, "; ")
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
}
// listChangesCapability reads the recent-changes feed.
@@ -192,10 +193,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err))
return "не могу сейчас узнать об изменениях."
return phraser.A(phraser.ChangesFail, nil)
}
if len(changes) == 0 {
return "нет изменений."
return phraser.A(phraser.ChangesNone, nil)
}
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string
@@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
typ, _ := c["change_type"].(string)
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
}
return "изменения: " + strings.Join(parts, "; ")
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
}
// entityAttentionCapability answers "what's going on with X" by resolving X to
@@ -230,12 +231,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
subject = dec.Slots.Text
}
if subject == "" {
return "про что именно спросить?"
return phraser.A(phraser.EcoAboutWhat, nil)
}
if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else.
return "не могу связать это с сущностью — Nexus не настроен."
return phraser.A(phraser.EcoNoNexus, nil)
}
started := h.now()
@@ -248,15 +249,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
return phraser.A(phraser.EcoDenied, nil)
}
return "экосистема недоступна, попробуй ещё раз."
return phraser.A(phraser.EcoDown, nil)
}
if len(ambiguous) > 0 {
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
}
if entityID == "" {
return "не знаю такой сущности."
return phraser.A(phraser.EcoUnknownEntity, nil)
}
if displayName == "" {
displayName = subject
@@ -268,7 +269,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
}
items, scoped := scopedToEntity(items, entityID)
if !scoped {
@@ -279,7 +280,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
}
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
"entity_id": entityID, "count": len(items),
@@ -303,9 +304,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known)
}
if len(parts) == 0 {
return "по «" + displayName + "» ничего нет."
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
}
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
}
// scopedToEntity drops items that carry an entity_id other than the one asked
@@ -370,7 +371,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if len(parts) == 0 {
return ""
}
out := "я помню: " + strings.Join(parts, ", ")
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
if more {
out += ", и это не всё"
}
@@ -520,18 +521,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
return phraser.A(phraser.EcoDenied, nil)
}
// A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade
// independently, never a silent all-clear).
return "экосистема недоступна, попробуй ещё раз."
return phraser.A(phraser.EcoDown, nil)
}
if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)})
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
@@ -550,9 +551,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
return phraser.A(phraser.EcoDenied, nil)
}
return "экосистема недоступна, попробуй ещё раз."
return phraser.A(phraser.EcoDown, nil)
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -584,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
for _, m := range matches {
names = append(names, m.Name)
}
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
}
matched := matches[0]
@@ -602,7 +603,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
}
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
@@ -622,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID,
}))
return "не получилось выполнить команду для " + displayName + "."
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
}
// One record per hop: the second write this used to make said the same
// thing under a different key, in a different shape.
@@ -630,5 +631,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
"entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID,
})
return "команда выполнена для " + displayName + "."
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
}
+1 -7
View File
@@ -87,12 +87,9 @@ 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 || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
if !ok || !strings.Contains(reply, "не настроены") {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
}
on := buildFeedHandler(t, true)
@@ -100,9 +97,6 @@ func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
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) {
+18 -2
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), phraser.Devices(len(res.Hosts)))
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(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), phraser.Devices(len(res.Hosts)))
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
@@ -211,6 +211,22 @@ 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
+33
View File
@@ -10,6 +10,8 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
@@ -74,6 +76,18 @@ 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{
"какие устройства в сети?",
@@ -157,3 +171,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
}
}
// An unconfigured scanner names the gap instead of declining the turn.
//
// Falling through sent "какие устройства в сети?" to the search leg, which
// answered with a paragraph about routers in general — and put a question about
// his own LAN on an upstream engine, which the personal boundary exists to
// prevent (Vikunja #479).
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
h := &reactiveHandler{}
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "какие устройства в сети?"},
})
if !ok {
t.Fatal("an unconfigured scanner let the question fall through to search")
}
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
t.Errorf("got %q, want the gap named", reply)
}
}
+9 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
@@ -141,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
ents, err := w.client.States(ctx)
if err != nil {
log.Printf("smarthome: summary: %v", err)
return "не смогла достучаться до дома.", true
return phraser.A(phraser.HomeUnreachable, nil), true
}
if len(ents) == 0 {
return "дом ничего не отдаёт.", true
return phraser.A(phraser.HomeEmpty, nil), true
}
var on []string
var sensors []string
@@ -178,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
}
// Silent truncation on a status read is the same failure as the cap
// one layer up: she has to say the list is not the whole list.
line := "включено: " + strings.Join(shown, ", ")
line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
if rest > 0 {
line += fmt.Sprintf(" и ещё %d", rest)
}
@@ -186,7 +187,10 @@ 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, phraser.Devices(dark)), true
return phraser.A(phraser.HomeDark, map[string]string{
"count": strconv.Itoa(dark),
"word": hostWord(dark),
}), true
default:
parts = append(parts, "всё выключено")
}
@@ -194,7 +198,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, phraser.Devices(dark)))
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
}
return strings.Join(parts, "; ") + ".", true
}
+3
View File
@@ -379,6 +379,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
// question first and a narrative request second (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+30 -20
View File
@@ -3,8 +3,11 @@ package memory
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
@@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd]
if len(acts) > 0 {
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
}
if len(p.Everyday) > 0 {
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
day, joinActivities(p.Everyday))
return say.S(say.HabitWeekdaySame, map[string]string{
"day": day, "items": joinActivities(p.Everyday),
})
}
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
}
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
@@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string {
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch {
case len(sat) > 0 && len(sun) > 0:
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
joinActivities(sat), joinActivities(sun))
return say.S(say.HabitWeekendBoth, map[string]string{
"sat": joinActivities(sat), "sun": joinActivities(sun),
})
case len(sat) > 0:
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
joinActivities(sat))
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
case len(sun) > 0:
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
joinActivities(sun))
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
case len(p.Everyday) > 0:
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
joinActivities(p.Everyday))
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
}
return "по выходным я пока не вижу у тебя ничего постоянного."
return say.S(say.HabitWeekendNone, nil)
}
// FormatOverallRU reads back the habits that hold across the whole week, and
@@ -378,19 +380,23 @@ func (p Profile) FormatWeekendRU() string {
// a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 {
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
return say.S(say.HabitOverallNone, nil)
}
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
return say.S(say.HabitOverall, map[string]string{
"items": joinActivities(p.All), "span": p.spanRU(),
})
}
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days.
func (p Profile) spanRU() string {
if p.Since.IsZero() || !p.Until.After(p.Since) {
return "по записям за сегодня"
return say.S(say.HabitSpanToday, nil)
}
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
return say.S(say.HabitSpanDays, map[string]string{
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
})
}
// pluralDaysRU — the Russian count form of "день" for n.
@@ -423,14 +429,18 @@ func joinActivities(acts []Activity) string {
// come from the model, so an unglossed one is as likely to be
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
// not a sentence.
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
gloss = say.S(say.HabitUnglossed, map[string]string{
"key": strings.ReplaceAll(a.Key, "_", " "),
})
}
if !a.HasTypical {
parts[i] = gloss
continue
}
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
parts[i] = say.S(say.HabitAt, map[string]string{
"gloss": gloss,
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
})
}
if len(parts) == 1 {
return parts[0]
+6 -2
View File
@@ -5,6 +5,8 @@ import (
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/say"
)
// habitHistory — n weeks of the same weekday, at the given local time.
@@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
t.Errorf("empty profile reads %q", got)
}
}
@@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
// A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday)
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
if !say.IsS(say.HabitWeekdaySame, map[string]string{
"day": "средам", "items": "пьёшь воду около 13:30",
}, wed) {
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
}
}
+8 -4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -164,17 +165,20 @@ func (p Plan) FormatRU() string {
// it is over, and saying it was empty is a false statement about a day
// he just lived.
if p.Rest {
return "на сегодня больше ничего не запланировано."
return say.S(say.PlanRestEmpty, nil)
}
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain {
line = "похоже, " + line
line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
parts[i] = line
}
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
return say.S(say.PlanDay, map[string]string{
"date": p.Date.Format("02.01.2006"),
"items": strings.Join(parts, "; "),
})
}
+12 -10
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed ack_ru_v1.json
@@ -65,7 +67,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 = map[string]string{
var ackFloor = say.RegisterFloor(map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
@@ -90,15 +92,15 @@ var ackFloor = map[string]string{
FailAck: "не получилось отметить.",
FailSnooze: "не получилось отложить.",
FailQuiet: "не получилось переключить тихий режим.",
}
})
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *deck }
type Acks struct{ d *say.Deck }
// LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) {
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
if err != nil {
return nil, err
}
@@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -117,9 +119,9 @@ func LoadAcks(src rand.Source) (*Acks, error) {
}
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *deck {
func (a *Acks) deck() *say.Deck {
if a == nil {
return floorDeck(ackFloor)
return nil
}
return a.d
}
@@ -127,11 +129,11 @@ func (a *Acks) deck() *deck {
// Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string {
return a.deck().text(key, vars)
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acks) Variants() []string { return a.deck().variants() }
func (a *Acks) Variants() []string { return a.deck().Variants() }
var (
ackOnce sync.Once
@@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
return DefaultAcks().deck().matches(key, vars, text)
return DefaultAcks().deck().Matches(key, vars, text)
}
+186
View File
@@ -0,0 +1,186 @@
package phraser
// The act and smart-home replies — what she says when a capability ran, refused,
// or could not be reached.
//
// Fourth family on the shared deck (deck.go). They were literals in
// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
// a rebuild of the daemon that executes his house.
//
// The four outcomes stay four entries. Reporting a refusal with the wording of
// a success is the one failure mode this family can have, and a shared variant
// set is how it would happen.
import (
_ "embed"
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed acts_ru_v1.json
var actJSON []byte
// ActSchemaVersion — this family's own version.
const ActSchemaVersion = 1
// The entry keys.
const (
ActDone = "act_done"
ActDoneOut = "act_done_out"
ActDoneEntity = "act_done_entity"
ActConfirm = "act_confirm"
ActConfirmEntity = "act_confirm_entity"
ActWhich = "act_which"
ActFail = "act_fail"
ActFailOut = "act_fail_out"
ActFailEntity = "act_fail_entity"
ActServerDown = "act_server_down"
ActWithdrawn = "act_withdrawn"
ActNeedsArgs = "act_needs_args"
EcoDenied = "eco_denied"
EcoDown = "eco_down"
EcoAmbiguous = "eco_ambiguous"
EcoUnknownEntity = "eco_unknown_entity"
EcoNoNexus = "eco_no_nexus"
EcoAboutWhat = "eco_about_what"
EcoRecall = "eco_recall"
AttentionNone = "attention_none"
AttentionList = "attention_list"
AttentionFail = "attention_fail"
AttentionNoneEntity = "attention_none_entity"
AttentionListEntity = "attention_list_entity"
AttentionFailEntity = "attention_fail_entity"
ChangesNone = "changes_none"
ChangesList = "changes_list"
ChangesFail = "changes_fail"
HomeUnreachable = "home_unreachable"
HomeEmpty = "home_empty"
HomeOn = "home_on"
HomeDark = "home_dark"
)
var actKeys = []string{
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
AttentionNone, AttentionList, AttentionFail,
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
ChangesNone, ChangesList, ChangesFail,
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
}
// actFloor — 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 actFloor = say.RegisterFloor(map[string]string{
ActDone: "готово.",
ActDoneOut: "готово: {out}",
ActDoneEntity: "команда выполнена для {name}.",
ActConfirm: "выполнить «{name}»? скажи «да» или «нет».",
ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».",
ActWhich: "какую команду для {name}: {items}?",
ActFail: "не получилось выполнить команду.",
ActFailOut: "не получилось выполнить команду: {out}",
ActFailEntity: "не получилось выполнить команду для {name}.",
ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.",
ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.",
ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.",
EcoDenied: "экосистема отклоняет доступ, проверь токен.",
EcoDown: "экосистема недоступна, попробуй ещё раз.",
EcoAmbiguous: "уточни, что именно: {items}?",
EcoUnknownEntity: "не знаю такой сущности.",
EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.",
EcoAboutWhat: "про что именно спросить?",
EcoRecall: "я помню: {items}",
AttentionNone: "ничего не требует внимания.",
AttentionList: "требует внимания: {items}",
AttentionFail: "не могу сейчас узнать, что требует внимания.",
AttentionNoneEntity: "по «{name}» ничего нет.",
AttentionListEntity: "по «{name}»: {items}",
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
ChangesNone: "нет изменений.",
ChangesList: "изменения: {items}",
ChangesFail: "не могу сейчас узнать об изменениях.",
HomeUnreachable: "не смогла достучаться до дома.",
HomeEmpty: "дом ничего не отдаёт.",
HomeOn: "включено: {items}",
HomeDark: "дом молчит: {count} {word} не отвечают.",
})
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
type Acts struct{ d *say.Deck }
// LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) {
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
if err != nil {
return nil, err
}
// The entries that name what ran or what he has to choose between. A
// variant that dropped the name would confirm an act without saying which.
for _, req := range []struct{ key, ph string }{
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"},
{ActWhich, "{name}"}, {ActWhich, "{items}"},
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Acts{d: d}, nil
}
// deck reads through a nil *Acts, which is the unloadable-file case.
func (a *Acts) deck() *say.Deck {
if a == nil {
return nil
}
return a.d
}
// Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string {
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acts) Variants() []string { return a.deck().Variants() }
var (
actOnce sync.Once
actsDeck *Acts
)
// DefaultActs returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Acts, which still answers from actFloor.
func DefaultActs() *Acts {
actOnce.Do(func() {
a, err := LoadActs(nil)
if err != nil {
log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
return
}
actsDeck = a
})
return actsDeck
}
// A — one act reply, the way every caller says it.
func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
// IsA reports whether text is a line key could have produced, for the tests.
func IsA(key string, vars map[string]string, text string) bool {
return DefaultActs().deck().Matches(key, vars, text)
}
+114
View File
@@ -0,0 +1,114 @@
{
"schema_version": 1,
"name": "russian act and smart-home replies v1",
"notes": [
"What she says when a capability ran, refused, or could not be reached. 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.",
"\"it ran\", \"it was refused\", \"the ecosystem is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
"Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.",
"fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the two lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
],
"entries": {
"act_done": {
"fixed": true,
"variants": ["готово."]
},
"act_done_out": {
"variants": ["готово: {out}", "сделала: {out}"]
},
"act_done_entity": {
"fixed": true,
"variants": ["команда выполнена для {name}."]
},
"act_confirm": {
"fixed": true,
"variants": ["выполнить «{name}»? скажи «да» или «нет»."]
},
"act_confirm_entity": {
"fixed": true,
"variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."]
},
"act_which": {
"variants": ["какую команду для {name}: {items}?"]
},
"act_fail": {
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
},
"act_fail_out": {
"variants": ["не получилось выполнить команду: {out}"]
},
"act_fail_entity": {
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
},
"act_server_down": {
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
},
"act_withdrawn": {
"fixed": true,
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
},
"act_needs_args": {
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
},
"eco_denied": {
"variants": ["экосистема отклоняет доступ, проверь токен."]
},
"eco_down": {
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
},
"eco_ambiguous": {
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
},
"eco_unknown_entity": {
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
},
"eco_no_nexus": {
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
},
"eco_about_what": {
"variants": ["про что именно спросить?", "про что спросить?"]
},
"eco_recall": {
"variants": ["я помню: {items}"]
},
"attention_none": {
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
},
"attention_list": {
"variants": ["требует внимания: {items}"]
},
"attention_fail": {
"variants": ["не могу сейчас узнать, что требует внимания."]
},
"attention_none_entity": {
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
},
"attention_list_entity": {
"variants": ["по «{name}»: {items}"]
},
"attention_fail_entity": {
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
},
"changes_none": {
"variants": ["нет изменений.", "изменений нет."]
},
"changes_list": {
"variants": ["изменения: {items}"]
},
"changes_fail": {
"variants": ["не могу сейчас узнать об изменениях."]
},
"home_unreachable": {
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
},
"home_empty": {
"variants": ["дом ничего не отдаёт.", "дом молчит."]
},
"home_on": {
"variants": ["включено: {items}"]
},
"home_dark": {
"variants": ["дом молчит: {count} {word} не отвечают."]
}
}
}
+18 -5
View File
@@ -6,12 +6,13 @@ import (
"testing"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/say"
)
// 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.
// TestFallbackPersona scores every line in every hand-written family 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.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges.
@@ -38,6 +39,16 @@ func TestFallbackPersona(t *testing.T) {
}
variants := append(fb.Variants(), ack.Variants()...)
variants = append(variants, qry.Variants()...)
act, err := phraser.LoadActs(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
sum, err := say.LoadSummaries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadSummaries: %v", err)
}
variants = append(variants, sum.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
@@ -45,7 +56,9 @@ func TestFallbackPersona(t *testing.T) {
// The placeholders stand for his own words and carry no persona.
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}"} {
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}",
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
+16 -14
View File
@@ -15,6 +15,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed fallbacks_ru_v1.json
@@ -39,56 +41,56 @@ 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 = map[string]string{
var hardFloor = say.RegisterFloor(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 }
type Fallbacks struct{ d *say.Deck }
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
if err != nil {
return nil, err
}
// query_sources is the one entry whose whole job is to read something back.
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
return nil, err
}
return &Fallbacks{d: d}, nil
}
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *deck {
func (f *Fallbacks) deck() *say.Deck {
if f == nil {
return floorDeck(hardFloor)
return nil
}
return f.d
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
// Variants returns every line the file can produce, for the persona scorer.
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
@@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() }
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
}
-47
View File
@@ -1,47 +0,0 @@
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
@@ -1,32 +0,0 @@
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)
}
}
}
+26 -23
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed query_ru_v1.json
@@ -43,6 +45,8 @@ const (
QueryWeatherOff = "weather_off"
QueryWeatherWhere = "weather_nolocation"
QueryNetEmpty = "net_empty"
QueryNetOff = "net_off"
QueryPageOff = "page_off"
QueryFailPlan = "fail_plan"
QueryFailNotes = "fail_notes"
@@ -58,33 +62,33 @@ var queryKeys = []string{
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
}
// queryFloor — the literal each key falls back to when the file is unusable.
// 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: "ничего не нашла.",
// These are the exact strings that lived in Go before this file existed.
var queryFloor = say.RegisterFloor(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} {word}, {condition}.",
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "для какого города?",
QueryNetEmpty: "в сети никого не нашла.",
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
QueryNetEmpty: "в сети никого не нашла{tail}.",
QueryNetOff: "сканирование сети не настроено.",
QueryPageOff: "я не читаю страницы — это не настроено.",
QueryFailPlan: "не получилось собрать план.",
QueryFailNotes: "не получилось посмотреть записи.",
@@ -94,15 +98,15 @@ var queryFloor = map[string]string{
QueryFailAnswer: "не получилось найти ответ.",
QueryFailPage: "не получилось прочитать страницу.",
QueryFailNetscan: "не получилось просканировать сеть.",
}
})
// Queries picks a hand-written Russian query line. Safe for concurrent use.
type Queries struct{ d *deck }
type Queries struct{ d *say.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)
d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
if err != nil {
return nil, err
}
@@ -111,10 +115,9 @@ 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, "{word}"}, {QueryWeatherNow, "{condition}"},
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -122,20 +125,20 @@ func LoadQueries(src rand.Source) (*Queries, error) {
}
// deck reads through a nil *Queries, which is the unloadable-file case.
func (q *Queries) deck() *deck {
func (q *Queries) deck() *say.Deck {
if q == nil {
return floorDeck(queryFloor)
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)
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() }
func (q *Queries) Variants() []string { return q.deck().Variants() }
var (
queryOnce sync.Once
@@ -161,5 +164,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say(
// 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)
return DefaultQueries().deck().Matches(key, vars, text)
}
-107
View File
@@ -1,107 +0,0 @@
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)
}
}
}
+26 -20
View File
@@ -5,15 +5,13 @@
"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, 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.",
"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": [ичего не нашла.", "искала — не нашла."]
"variants": [е знаю.", "не нашла ничего.", "ничего не нашла."]
},
"other_day": {
"fixed": true,
@@ -24,10 +22,10 @@
"variants": ["не знаю — не нашла у тебя такой записи."]
},
"fact_when": {
"variants": ["записала это {when}"]
"variants": ["я записала это {when}", "записала это {when}"]
},
"fact_value": {
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
@@ -40,10 +38,10 @@
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
},
"page_empty": {
"variants": ["страница открылась, но читать там нечего."]
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
},
"feeds_off": {
"variants": ["ленты не настроены."]
"variants": ["я пока не читаю ленты — они не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
@@ -55,41 +53,49 @@
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
},
"weather_now": {
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
},
"weather_off": {
"variants": ["погода не настроена."]
},
"weather_nolocation": {
"fixed": true,
"variants": ["для какого города?"]
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
},
"net_off": {
"fixed": true,
"variants": ["сканирование сети не настроено."]
},
"page_off": {
"fixed": true,
"variants": ["я не читаю страницы — это не настроено."]
},
"net_empty": {
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{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": ["не получилось просканировать сеть.", "сеть не просканировалась."]
}
}
}
+37
View File
@@ -75,3 +75,40 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
}
}
}
// The two shapes that carried no question mark and no interrogative, so the
// model saw them first and called them facts (Vikunja #498).
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
for _, u := range []string{
"что дальше?",
"и что там дальше",
"what's next?",
"расскажи про битву при Ватерлоо",
"объясни как работает дизель",
"опиши Ватерлоо",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("%q: %v", u, err)
}
if d.Intent != IntentQuery || d.Stage != 0 {
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
}
}
}
// A narrative verb next to a capture verb is him asking for a note. Stage 0
// declines and the extractor gets its turn.
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
if err != nil {
t.Fatal(err)
}
if d.Stage == 0 && d.Intent == IntentQuery {
t.Errorf("stage 0 claimed a capture: %+v", d)
}
}
+1
View File
@@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+2
View File
@@ -28,6 +28,8 @@
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
+67
View File
@@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar {
}
}
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
// carry no question mark and no interrogative, and so reached the resident
// model with nothing deterministic in front of them (Vikunja #498).
//
// Both were routed IntentFact by the model. The fact gate catches the write and
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
// model round trip to reach a decision two patterns can make offline, and a
// wrong row on the routing fixture.
//
// Wired after the agenda grammars, which is where their overlap resolves:
// "расскажи, что у меня сегодня" is claimed here as a query either way.
func NarrativeQueryGrammars() []Grammar {
return []Grammar{
{
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
// recognises it downstream in the query chain, but that runs after
// the routing decision, and the routing decision was fact.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "расскажи про X" — a world question phrased as an instruction.
// The lexicon is narrativeRequests, already written for the
// question-shaped test in question.go.
//
// Anchored at the start: "запиши что мне рассказали" is a capture,
// and a narrative verb buried mid-utterance is not the shape.
Name: "narrative-query",
Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`),
Build: narrativeQueryBuild,
},
}
}
// entertainmentNouns — what "расскажи" asks for when it is not asking for
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
// something up, which is the one case where inventing is the right answer
// (fixture ru-chat-003).
var entertainmentNouns = []string{
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
"joke", "jokes", "story",
}
// narrativeQueryBuild — the narrative shape is a query unless he also said one
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
// asking for a note, and stage 0 must not take either off the cascade.
func narrativeQueryBuild(m []string) (Decision, bool) {
rest := ""
if len(m) > 3 {
rest = m[3]
}
for _, t := range planTokens(rest) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
}
}
for _, v := range entertainmentNouns {
if t == v {
return Decision{}, false
}
}
}
return agendaQueryBuild(m)
}
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
// matchers decide the rest.
@@ -1,6 +1,9 @@
package phraser
package say
// deck — the mechanics every family of hand-written Russian lines shares.
// Package say holds the mechanics every family of hand-written Russian lines
// shares. The families themselves live next to the code that speaks them.
//
// Deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
@@ -20,28 +23,28 @@ import (
"time"
)
// deckEntry — one line she can say, in as many wordings as the file gives.
type deckEntry struct {
// Entry — one line she can say, in as many wordings as the file gives.
type Entry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type deckFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]deckEntry `json:"entries"`
type file struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]Entry `json:"entries"`
}
// 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 {
// deck picks a line. Safe for concurrent use. A nil *Deck answers from the
// floor, which is what an unloadable file leaves behind.
type Deck struct {
mu sync.Mutex
rnd *rand.Rand
last map[string]string
file deckFile
file file
keys []string
floor map[string]string
}
@@ -49,8 +52,8 @@ type deck struct {
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
var f deckFile
func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
var f file
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
@@ -69,13 +72,13 @@ func loadDeck(raw []byte, version int, keys []string, floor map[string]string, s
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *deck) requirePlaceholder(key, ph string) error {
func (d *Deck) RequirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
@@ -86,11 +89,11 @@ func (d *deck) requirePlaceholder(key, ph string) error {
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
func (d *deck) text(key string, vars map[string]string) string {
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, fillable(e.Variants, vars))
tmpl = d.pick(key, e)
}
}
if tmpl == "" {
@@ -101,7 +104,7 @@ func (d *deck) text(key string, vars map[string]string) string {
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *deck) matches(key string, vars map[string]string, text string) bool {
func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
@@ -118,7 +121,7 @@ func (d *deck) matches(key string, vars map[string]string, text string) bool {
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *deck) variants() []string {
func (d *Deck) Variants() []string {
if d == nil {
return nil
}
@@ -129,67 +132,12 @@ 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, variants []string) string {
func (d *Deck) pick(key string, e Entry) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := variants
choices := e.Variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
@@ -206,23 +154,24 @@ func (d *deck) pick(key string, variants []string) string {
return got
}
// 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 {
return ""
// 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.
func floorOf(d *Deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
}
return d.floor[key]
return deckFloors[key]
}
// 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}
// 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
}
// fill substitutes {name} for each var. A placeholder with no value is left
+191
View File
@@ -0,0 +1,191 @@
package say
// The summary sentences — what she says around aggregated data: the morning
// plan, the ranked task list, and the habits read back out of behaviour records.
//
// Fifth family on the deck, and the first one outside internal/phraser. It
// lives here because its three callers — internal/morning, internal/tasks and
// internal/memory — sit under phraser in the import graph and cannot reach it.
//
// The "I have not seen enough yet" sentences are the load-bearing ones. Three
// days of taps and a year of them produce the same "обычно ты ...", and only one
// of those is worth believing, so the empty cases say she has not seen a
// pattern rather than that he has none.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed summary_ru_v1.json
var summaryJSON []byte
// SummarySchemaVersion — this family's own version.
const SummarySchemaVersion = 1
// The entry keys.
const (
PlanRestEmpty = "plan_rest_empty"
PlanDayEmpty = "plan_day_empty"
PlanDay = "plan_day"
PlanUncertain = "plan_uncertain"
TasksNone = "tasks_none"
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
ReasonOverdue = "reason_overdue"
ReasonOverdueDay = "reason_overdue_day"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
ReasonTomorrow = "reason_tomorrow"
ReasonInDays = "reason_in_days"
ReasonImportant = "reason_important"
ReasonUrgent = "reason_urgent"
ReasonStale = "reason_stale"
HabitWeekday = "habit_weekday"
HabitWeekdaySame = "habit_weekday_same"
HabitWeekdayNone = "habit_weekday_none"
HabitWeekendBoth = "habit_weekend_both"
HabitWeekendSat = "habit_weekend_sat"
HabitWeekendSun = "habit_weekend_sun"
HabitWeekendSame = "habit_weekend_same"
HabitWeekendNone = "habit_weekend_none"
HabitOverall = "habit_overall"
HabitOverallNone = "habit_overall_none"
HabitSpanToday = "habit_span_today"
HabitSpanDays = "habit_span_days"
HabitUnglossed = "habit_unglossed"
HabitAt = "habit_at"
)
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays,
HabitUnglossed, HabitAt,
}
// summaryFloor — 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 summaryFloor = RegisterFloor(map[string]string{
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}.",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}.",
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
ReasonOverdue: "просрочено",
ReasonOverdueDay: "просрочено на день",
ReasonOverdueDays: "просрочено на {n} дн.",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} дн.",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
HabitOverall: "обычно ты {items} — {span}.",
HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.",
HabitSpanToday: "по записям за сегодня",
HabitSpanDays: "по записям за последние {n} {word}",
HabitUnglossed: "отмечаешь «{key}»",
HabitAt: "{gloss} около {time}",
})
// Summaries picks a hand-written Russian summary sentence. Safe for concurrent
// use.
type Summaries struct{ d *Deck }
// LoadSummaries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadSummaries(src rand.Source) (*Summaries, error) {
d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src)
if err != nil {
return nil, err
}
// The entries that exist to read the aggregate back. A variant without the
// placeholder would summarise the data by dropping it.
for _, req := range []struct{ key, ph string }{
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
{HabitWeekendSame, "{items}"},
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
{HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"},
{HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Summaries{d: d}, nil
}
// deck reads through a nil *Summaries, which is the unloadable-file case.
func (s *Summaries) deck() *Deck {
if s == nil {
return nil
}
return s.d
}
// Say returns one line for key, with the values filled into the frame.
func (s *Summaries) Say(key string, vars map[string]string) string {
return s.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (s *Summaries) Variants() []string { return s.deck().Variants() }
var (
summaryOnce sync.Once
summaries *Summaries
)
// DefaultSummaries returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Summaries, which still answers from
// summaryFloor.
func DefaultSummaries() *Summaries {
summaryOnce.Do(func() {
s, err := LoadSummaries(nil)
if err != nil {
log.Printf("say: summary lines unavailable, using the built-in ones: %v", err)
return
}
summaries = s
})
return summaries
}
// S — one summary sentence, the way every caller says it.
func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) }
// IsS reports whether text is a line key could have produced, for the tests.
func IsS(key string, vars map[string]string, text string) bool {
return DefaultSummaries().deck().Matches(key, vars, text)
}
+141
View File
@@ -0,0 +1,141 @@
{
"schema_version": 1,
"name": "russian summary sentences v1",
"notes": [
"The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.",
"Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.",
"The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.",
"fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed."
],
"entries": {
"plan_rest_empty": {
"fixed": true,
"variants": ["на сегодня больше ничего не запланировано."]
},
"plan_day_empty": {
"fixed": true,
"variants": ["на {date} ничего не запланировано."]
},
"plan_day": {
"fixed": true,
"variants": ["план на {date}: {items}."]
},
"plan_uncertain": {
"fixed": true,
"variants": ["похоже, {line}"]
},
"tasks_none": {
"fixed": true,
"variants": ["задач нет."]
},
"tasks_first": {
"fixed": true,
"variants": ["сначала: {items}."]
},
"tasks_candidates": {
"fixed": true,
"variants": ["ещё я нашла, но ты не подтвердил: {items}."]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
},
"reason_overdue_day": {
"fixed": true,
"variants": ["просрочено на день"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} дн."]
},
"reason_today": {
"fixed": true,
"variants": ["сегодня"]
},
"reason_tomorrow": {
"fixed": true,
"variants": ["завтра"]
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} дн."]
},
"reason_important": {
"fixed": true,
"variants": ["важно"]
},
"reason_urgent": {
"fixed": true,
"variants": ["срочно"]
},
"reason_stale": {
"fixed": true,
"variants": ["давно в списке"]
},
"habit_weekday": {
"fixed": true,
"variants": ["по {day} ты обычно {items}."]
},
"habit_weekday_same": {
"variants": [
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_weekend_both": {
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
},
"habit_weekend_sat": {
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
},
"habit_weekend_sun": {
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
},
"habit_weekend_same": {
"variants": [
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekend_none": {
"variants": [
"по выходным я пока не вижу у тебя ничего постоянного.",
"по выходным у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_overall": {
"variants": ["обычно ты {items} — {span}."]
},
"habit_overall_none": {
"variants": [
"я ещё не набрала достаточно записей, чтобы говорить о привычках.",
"записей пока мало — на привычки я так не сошлюсь."
]
},
"habit_span_today": {
"variants": ["по записям за сегодня"]
},
"habit_span_days": {
"variants": ["по записям за последние {n} {word}"]
},
"habit_unglossed": {
"fixed": true,
"variants": ["отмечаешь «{key}»"]
},
"habit_at": {
"fixed": true,
"variants": ["{gloss} около {time}"]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package say
import (
"math/rand"
"strings"
"testing"
)
// The file has to load, and every key the code names has to be in it.
func TestSummariesLoad(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range summaryKeys {
if got := s.Say(key, nil); got == "" {
t.Errorf("%s says nothing", key)
}
}
}
// A nil *Summaries is the unloadable-file case, and it must still speak. The
// habit sentences are the ones that matter here: falling back must not turn
// "I have not seen enough" into silence.
func TestNilSummariesAnswerFromTheFloor(t *testing.T) {
var s *Summaries
if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") {
t.Errorf("the floor dropped the date: %q", got)
}
}
// The empty cases claim she has not seen enough, never that he has no habits.
// Every variant has to hold that line, since the picker treats them as equals.
func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} {
for _, v := range s.d.file.Entries[key].Variants {
if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") {
t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v)
}
}
}
}
+19 -16
View File
@@ -19,8 +19,11 @@ package tasks
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
@@ -117,21 +120,21 @@ func score(it Item, now time.Time) (float64, string) {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = "просрочено"
reason = say.S(say.ReasonOverdue, nil)
if late == 1 {
reason = "просрочено на день"
reason = say.S(say.ReasonOverdueDay, nil)
} else if late > 1 {
reason = fmt.Sprintf("просрочено на %d дн.", late)
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
}
case days == 0:
total += scoreDueToday
reason = "сегодня"
reason = say.S(say.ReasonToday, nil)
case days == 1:
total += scoreDueTomorrow
reason = "завтра"
reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
reason = fmt.Sprintf("через %d дн.", days)
reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
default:
total += scoreDueLater
}
@@ -147,9 +150,9 @@ func score(it Item, now time.Time) (float64, string) {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
reason = "важно"
reason = say.S(say.ReasonImportant, nil)
if w >= MaxWeight {
reason = "срочно"
reason = say.S(say.ReasonUrgent, nil)
}
}
}
@@ -163,7 +166,7 @@ func score(it Item, now time.Time) (float64, string) {
}
total += age
if reason == "" && weeks >= 2 {
reason = "давно в списке"
reason = say.S(say.ReasonStale, nil)
}
}
}
@@ -210,22 +213,22 @@ func FormatRU(ranked []Ranked) string {
}
}
if len(open) == 0 && len(cands) == 0 {
return "задач нет."
return say.S(say.TasksNone, nil)
}
var b strings.Builder
if len(open) > 0 {
b.WriteString("сначала: ")
b.WriteString(joinRU(open, SpokenLimit, true))
b.WriteString(".")
b.WriteString(say.S(say.TasksFirst, map[string]string{
"items": joinRU(open, SpokenLimit, true),
}))
}
if len(cands) > 0 {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(joinRU(cands, SpokenLimit, false))
b.WriteString(".")
b.WriteString(say.S(say.TasksCandidates, map[string]string{
"items": joinRU(cands, SpokenLimit, false),
}))
}
return b.String()
}