Compare commits

..

3 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
39 changed files with 415 additions and 802 deletions
+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()
+1 -1
View File
@@ -138,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
if !(openIdx < candIdx) {
t.Errorf("reply = %q, want confirmed work before candidates", reply)
}
if !strings.Contains(reply, "не подтверждал") {
if !strings.Contains(reply, "не подтвердил") {
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
}
}
+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)
}
}
+7 -41
View File
@@ -14,19 +14,6 @@ import (
"github.com/kami/maven/internal/store"
)
// The three services, spelled the way she says them out loud. A service that is
// down or refusing has to be named: they degrade independently, so "не
// отвечает" on its own tells him nothing he can act on, and each call site
// already knows which one it was talking to — it records the same name in the
// trace (Vikunja #521).
const (
serviceNexus = "Nexus"
serviceHexis = "Hexis"
)
// serviceVars — the one-key map the eco_down and eco_denied lines take.
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
// rather than a map[string]func because each arm carries its own state: the
// verb aliases it answers to, the trace name it records, and its own reply
@@ -171,11 +158,6 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
importance, _ := item["importance"].(float64)
rule, _ := item["rule"].(string)
s := title
if s == "" {
// An item Praxis returned without a title is not an item she can
// read out. Counting it would put an empty slot in the list.
continue
}
if importance > 0 {
s += fmt.Sprintf(" (важность %d", int(importance))
if rule != "" {
@@ -194,12 +176,6 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
}
}
}
if len(parts) == 0 {
// Praxis returned items and not one of them could be said. "ничего не
// требует внимания" is the honest answer; the list line would render as
// its own label and a colon (Vikunja #521).
return phraser.A(phraser.AttentionNone, nil)
}
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
}
@@ -226,19 +202,9 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
var parts []string
for _, c := range changes {
title, _ := c["title"].(string)
if title == "" {
continue
}
typ, _ := c["change_type"].(string)
if typ == "" {
parts = append(parts, title)
continue
}
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
}
if len(parts) == 0 {
return phraser.A(phraser.ChangesNone, nil)
}
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
}
@@ -283,9 +249,9 @@ 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 phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
return phraser.A(phraser.EcoDenied, nil)
}
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
return phraser.A(phraser.EcoDown, nil)
}
if len(ambiguous) > 0 {
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
@@ -555,13 +521,13 @@ 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 phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
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 phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
return phraser.A(phraser.EcoDown, nil)
}
if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
@@ -585,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 phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
return phraser.A(phraser.EcoDenied, nil)
}
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
return phraser.A(phraser.EcoDown, nil)
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -637,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 phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
}
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
+10 -10
View File
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
// A Nexus outage during a Hexis act writes a failure trace, and a shared
// store is the one thing the Praxis path could inherit it through.
nexus.SetFault(503)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
t.Fatalf("nexus outage must not report success, got %q", reply)
}
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
t.Fatalf("praxis outage must not serve content, got %q", reply)
}
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
}
}
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
}
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
nexus.SetBody(`{"status":"resolved","entity":`)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || actRan(reply) {
if reply == "" || strings.Contains(reply, "выполнена") {
t.Fatalf("malformed nexus body must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
nexus := newFakeNexus(t, body)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
}
})
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || actRan(reply) {
if reply == "" || strings.Contains(reply, "выполнена") {
t.Fatalf("cancelled resolve must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Fatalf("failed execution must not read as success, got %q", reply)
}
if reply == "" {
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
exec := tracesFor(t, h, "hexis", "execute")
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
if len(traces(t, h)) == 0 {
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
if reply == "" {
t.Errorf("%s: total outage must not answer with silence", name)
}
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Errorf("%s: total outage must not claim success: %q", name, reply)
}
}
+2 -2
View File
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
nexus.SetFault(503)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Fatalf("nexus outage must not report success, got %q", reply)
}
nexus.SetFault(0)
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
if !actRan(reply) {
if !strings.Contains(reply, "выполнена") {
t.Fatalf("expected success once nexus recovers, got %q", reply)
}
}
+4 -16
View File
@@ -11,7 +11,6 @@ import (
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -86,7 +85,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
// The follow-up "да" turn executes exactly the parked capability.
confirmReply, handled := h.resolveConfirm(ctx, "да")
if !handled || !actRan(confirmReply) {
if !handled || !strings.Contains(confirmReply, "выполнена") {
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
}
if !*executed {
@@ -126,7 +125,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
if h.pendingHexis != nil {
t.Fatal("read-only cap should not park a confirmation")
}
if !actRan(reply) {
if !strings.Contains(reply, "выполнена") {
t.Fatalf("unexpected reply %q", reply)
}
}
@@ -187,7 +186,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("nexus dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
}
}
@@ -220,7 +219,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("hexis dependency failure must not fall through with an empty reply")
}
if actRan(reply) {
if strings.Contains(reply, "выполнена") {
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
}
}
@@ -242,14 +241,3 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
t.Fatal("not_found resolution must never execute a hexis capability")
}
}
// actRan — the reply is the line she says when a capability ran against an
// entity. The tests used to look for the substring "выполнена", which was a
// literal out of the act file: the review reworded that line to "готово: {name}"
// and seventeen assertions went with it (Vikunja #521).
func actRan(reply string) bool {
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
}
// muzickIndexer — the display name every ecosystem fixture resolves to.
const muzickIndexer = "Muzick indexer"
+1 -1
View File
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
t.Fatalf("setup: expected success, got %q", reply)
}
+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)
}
}
+2 -2
View File
@@ -189,7 +189,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
// выключено" would be a claim about the house she cannot make.
return phraser.A(phraser.HomeDark, map[string]string{
"count": strconv.Itoa(dark),
"word": phraser.Devices(dark),
"word": hostWord(dark),
}), true
default:
parts = append(parts, "всё выключено")
@@ -198,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,
+16 -2
View File
@@ -360,7 +360,7 @@ func (p Profile) FormatWeekendRU() string {
switch {
case len(sat) > 0 && len(sun) > 0:
return say.S(say.HabitWeekendBoth, map[string]string{
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
"sat": joinActivities(sat), "sun": joinActivities(sun),
})
case len(sat) > 0:
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
@@ -395,10 +395,24 @@ func (p Profile) spanRU() string {
}
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
return say.S(say.HabitSpanDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
})
}
// pluralDaysRU — the Russian count form of "день" for n.
func pluralDaysRU(n int) string {
switch {
case n%100 >= 11 && n%100 <= 14:
return "дней"
case n%10 == 1:
return "день"
case n%10 >= 2 && n%10 <= 4:
return "дня"
default:
return "дней"
}
}
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
// not an answer; the most established few are.
const maxRecited = 5
+17 -9
View File
@@ -126,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
t.Errorf("an unknown weekday reads %q", empty)
}
// Persona: she addresses him informally, never in the masculine about
@@ -317,19 +317,16 @@ func TestOverallNamesThePeriod(t *testing.T) {
}
}
// The no-data weekday answer is about his week, not about her. "у меня пока нет
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
// because what she is reporting is the state of her records — and it names the
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
// came out as filler, the day carries it).
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
// The no-data weekday answer is about him, not about her. "у меня пока нет
// ничего постоянного" answers a question nobody asked.
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
p := BuildProfile(nil, behaviorNow())
got := p.FormatWeekdayRU(time.Wednesday)
if strings.Contains(got, "у меня") {
t.Errorf("got %q", got)
}
if !strings.Contains(got, "средам") {
t.Errorf("got %q, want the day he asked about", got)
if !strings.Contains(got, "у тебя") {
t.Errorf("got %q, want an answer about him", got)
}
}
@@ -347,6 +344,17 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
}
}
func TestPluralDaysRU(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
if got := pluralDaysRU(c.n); got != c.want {
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
}
}
}
// "по выходным" is a question about two days, answered as two days.
func TestFormatWeekendRU(t *testing.T) {
now := behaviorNow()
+1 -25
View File
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/store"
)
@@ -138,7 +137,7 @@ func TestPlanFormatRU(t *testing.T) {
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
"10:30 — утро — осталось: витамины; " +
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
"18:30 — позвонить маме"
"18:30 — позвонить маме."
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
@@ -216,26 +215,3 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
t.Fatalf("got %+v", p.Items)
}
}
// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
// front of what this loop already built. That reads as one sentence only while
// what arrives starts lowercase, and it does here because every line starts with
// the clock time. A capital after the hedge would be «похоже, Планёрка».
func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
p := Plan{Date: now, Items: []PlanEntry{
{At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
}}
got := p.FormatRU()
const hedge = "похоже, "
i := strings.Index(got, hedge)
if i < 0 {
t.Fatalf("%q does not hedge an uncertain item", got)
}
for _, r := range got[i+len(hedge):] {
if unicode.IsUpper(r) {
t.Fatalf("the hedge runs into a capital: %q", got)
}
break
}
}
+3 -3
View File
@@ -67,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}",
@@ -92,7 +92,7 @@ var ackFloor = map[string]string{
FailAck: "не получилось отметить.",
FailSnooze: "не получилось отложить.",
FailQuiet: "не получилось переключить тихий режим.",
}
})
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *say.Deck }
@@ -121,7 +121,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *say.Deck {
if a == nil {
return say.FloorDeck(ackFloor)
return nil
}
return a.d
}
-85
View File
@@ -1,85 +0,0 @@
package phraser
import (
"math/rand"
"strconv"
"strings"
"testing"
)
func loadTestActs(t *testing.T) *Acts {
t.Helper()
a, err := LoadActs(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
return a
}
// She talks about a lamp or a server, never about a row in a schema. «сущность»
// and «экосистема» are the same defect as saying a capability id out loud.
func TestNoActLineSaysASchemaWord(t *testing.T) {
a := loadTestActs(t)
for _, v := range a.Variants() {
for _, word := range []string{"сущност", "экосистем"} {
if strings.Contains(v, word) {
t.Errorf("variant %q says %q out loud", v, word)
}
}
}
}
// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
// is not an answer he can act on.
func TestAServiceFailureNamesTheService(t *testing.T) {
a := loadTestActs(t)
for _, key := range []string{EcoDown, EcoDenied} {
got := a.Say(key, map[string]string{"name": "Praxis"})
if !strings.HasPrefix(got, "Praxis ") {
t.Errorf("%s = %q, want it to name the service", key, got)
}
}
}
// A confirmation prompt for a destructive act is the worst place for an unfilled
// placeholder, so the two names it interpolates are distinct keys and both are
// declared.
func TestConfirmEntityFillsBothNames(t *testing.T) {
a := loadTestActs(t)
got := a.Say(ActConfirmEntity, map[string]string{
"name": "restart", "name_entity": "Muzick indexer",
})
if strings.ContainsAny(got, "{}") {
t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
}
if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
t.Fatalf("act_confirm_entity = %q, want both names", got)
}
}
// home_dark counts unreachable devices, and Russian inflects the noun after the
// number: the count goes in {count} and the noun comes from the helper.
func TestHomeDarkCountsWithTheHelper(t *testing.T) {
a := loadTestActs(t)
for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
if !strings.Contains(got, want) {
t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
}
}
}
// Four truths, four entries: a failure must not be able to report itself as a
// success, and an empty result must not read as a failure.
func TestActOutcomesStayDistinct(t *testing.T) {
a := loadTestActs(t)
seen := map[string]string{}
for _, key := range actKeys {
for _, v := range a.d.VariantsOf(key) {
if prev, dup := seen[v]; dup {
t.Errorf("%s and %s both say %q", prev, key, v)
}
seen[v] = key
}
}
}
+21 -25
View File
@@ -74,30 +74,28 @@ var actKeys = []string{
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
}
// actFloor — 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 actFloor = map[string]string{
// 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}» для {name_entity}? да или нет.",
ActDoneEntity: "команда выполнена для {name}.",
ActConfirm: "выполнить «{name}»? скажи «да» или «нет».",
ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».",
ActWhich: "какую команду для {name}: {items}?",
ActFail: "не получилось выполнить команду.",
ActFailOut: "не получилось выполнить команду: {out}",
ActFailEntity: "не получилось выполнить команду для {name}.",
ActServerDown: "инструмент есть, но сервер не подключён.",
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.",
ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.",
ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.",
EcoDenied: "{name} отклоняет доступ, проверь токен.",
EcoDown: "{name} не отвечает, попробуй ещё раз.",
EcoAmbiguous: "что именно: {items}?",
EcoUnknownEntity: "не знаю, что это.",
EcoNoNexus: "не с чем связать — Nexus не настроен.",
EcoAboutWhat: "про что именно?",
EcoDenied: "экосистема отклоняет доступ, проверь токен.",
EcoDown: "экосистема недоступна, попробуй ещё раз.",
EcoAmbiguous: "уточни, что именно: {items}?",
EcoUnknownEntity: "не знаю такой сущности.",
EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.",
EcoAboutWhat: "про что именно спросить?",
EcoRecall: "я помню: {items}",
AttentionNone: "ничего не требует внимания.",
@@ -106,14 +104,14 @@ var actFloor = map[string]string{
AttentionNoneEntity: "по «{name}» ничего нет.",
AttentionListEntity: "по «{name}»: {items}",
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
ChangesNone: "изменений нет.",
ChangesNone: "нет изменений.",
ChangesList: "изменения: {items}",
ChangesFail: "не могу сейчас узнать об изменениях.",
HomeUnreachable: "дом не отвечает.",
HomeUnreachable: "не смогла достучаться до дома.",
HomeEmpty: "дом ничего не отдаёт.",
HomeOn: "включено: {items}",
HomeDark: "не отвечают: {count} {word}.",
}
HomeDark: "дом молчит: {count} {word} не отвечают.",
})
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
type Acts struct{ d *say.Deck }
@@ -130,12 +128,10 @@ func LoadActs(src rand.Source) (*Acts, error) {
for _, req := range []struct{ key, ph string }{
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"},
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"},
{ActWhich, "{name}"}, {ActWhich, "{items}"},
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
{EcoDenied, "{name}"}, {EcoDown, "{name}"},
{HomeDark, "{count}"}, {HomeDark, "{word}"},
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
} {
@@ -149,7 +145,7 @@ func LoadActs(src rand.Source) (*Acts, error) {
// deck reads through a nil *Acts, which is the unloadable-file case.
func (a *Acts) deck() *say.Deck {
if a == nil {
return say.FloorDeck(actFloor)
return nil
}
return a.d
}
+22 -49
View File
@@ -4,13 +4,9 @@
"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\", \"a service 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.",
"She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.",
"A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.",
"Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.",
"A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.",
"An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.",
"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 lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
"\"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": {
@@ -22,120 +18,97 @@
},
"act_done_entity": {
"fixed": true,
"variants": ["готово: {name}."]
"variants": ["команда выполнена для {name}."]
},
"act_confirm": {
"fixed": true,
"variants": ["выполнить «{name}»? да или нет."]
"variants": ["выполнить «{name}»? скажи «да» или «нет»."]
},
"act_confirm_entity": {
"fixed": true,
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
"variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."]
},
"act_which": {
"fixed": true,
"variants": ["какую команду для {name}: {items}?"]
},
"act_fail": {
"fixed": true,
"variants": ["не получилось выполнить команду."]
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
},
"act_fail_out": {
"fixed": true,
"variants": ["не получилось выполнить команду: {out}"]
},
"act_fail_entity": {
"fixed": true,
"variants": ["не получилось выполнить команду для {name}."]
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
},
"act_server_down": {
"fixed": true,
"variants": ["инструмент есть, но сервер не подключён."]
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
},
"act_withdrawn": {
"fixed": true,
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
},
"act_needs_args": {
"fixed": true,
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
},
"eco_denied": {
"fixed": true,
"variants": ["{name} отклоняет доступ, проверь токен."]
"variants": ["экосистема отклоняет доступ, проверь токен."]
},
"eco_down": {
"fixed": true,
"variants": ["{name} не отвечает, попробуй ещё раз."]
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
},
"eco_ambiguous": {
"fixed": true,
"variants": ["что именно: {items}?"]
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
},
"eco_unknown_entity": {
"variants": ["не знаю, что это.", "такого у меня нет."]
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
},
"eco_no_nexus": {
"fixed": true,
"variants": ["не с чем связать — Nexus не настроен."]
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
},
"eco_about_what": {
"fixed": true,
"variants": ["про что именно?"]
"variants": ["про что именно спросить?", "про что спросить?"]
},
"eco_recall": {
"fixed": true,
"variants": ["я помню: {items}"]
},
"attention_none": {
"fixed": true,
"variants": ["ничего не требует внимания."]
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
},
"attention_list": {
"fixed": true,
"variants": ["требует внимания: {items}"]
},
"attention_fail": {
"fixed": true,
"variants": ["не могу сейчас узнать, что требует внимания."]
},
"attention_none_entity": {
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
},
"attention_list_entity": {
"fixed": true,
"variants": ["по «{name}»: {items}"]
},
"attention_fail_entity": {
"fixed": true,
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
},
"changes_none": {
"fixed": true,
"variants": ["изменений нет."]
"variants": ["нет изменений.", "изменений нет."]
},
"changes_list": {
"fixed": true,
"variants": ["изменения: {items}"]
},
"changes_fail": {
"fixed": true,
"variants": ["не могу сейчас узнать об изменениях."]
},
"home_unreachable": {
"variants": ["дом не отвечает.", "не достучалась до дома."]
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
},
"home_empty": {
"fixed": true,
"variants": ["дом ничего не отдаёт."]
"variants": ["дом ничего не отдаёт.", "дом молчит."]
},
"home_on": {
"fixed": true,
"variants": ["включено: {items}"]
},
"home_dark": {
"fixed": true,
"variants": ["не отвечают: {count} {word}."]
"variants": ["дом молчит: {count} {word} не отвечают."]
}
}
}
+3 -3
View File
@@ -41,12 +41,12 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var hardFloor = 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 *say.Deck }
@@ -68,7 +68,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *say.Deck {
if f == nil {
return say.FloorDeck(hardFloor)
return nil
}
return f.d
}
-38
View File
@@ -1,38 +0,0 @@
package phraser
import (
"math/rand"
"testing"
)
// The other four families, held to the rule internal/say holds the fifth to:
// one variant means fixed. Reported per family, because a failure that names
// "some file" is a failure nobody acts on.
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
f, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
a, err := LoadAcks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
acts, err := LoadActs(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
for name, keys := range map[string][]string{
"fallbacks": f.d.UnfixedSingles(),
"acks": a.d.UnfixedSingles(),
"queries": q.d.UnfixedSingles(),
"acts": acts.d.UnfixedSingles(),
} {
if len(keys) > 0 {
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
}
}
}
-19
View File
@@ -1,19 +0,0 @@
package phraser
// The counted noun, for callers that already speak through this package.
//
// The rule itself lives in internal/say, next to the line files that carry the
// {word} placeholder, because internal/say sits under phraser in the import
// graph and internal/memory and internal/tasks need it too. These are the same
// three functions under the names cmd/mavend already calls.
import "github.com/kami/maven/internal/say"
// CountWord picks between the three forms a Russian count needs.
func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
// Degrees — the noun for a temperature.
func Degrees(temp float64) string { return say.Degrees(temp) }
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string { return say.Devices(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)
}
}
}
+17 -16
View File
@@ -45,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"
@@ -60,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: "не получилось посмотреть записи.",
@@ -96,7 +98,7 @@ var queryFloor = map[string]string{
QueryFailAnswer: "не получилось найти ответ.",
QueryFailPage: "не получилось прочитать страницу.",
QueryFailNetscan: "не получилось просканировать сеть.",
}
})
// Queries picks a hand-written Russian query line. Safe for concurrent use.
type Queries struct{ d *say.Deck }
@@ -113,8 +115,7 @@ 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 {
return nil, err
@@ -126,7 +127,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
// deck reads through a nil *Queries, which is the unloadable-file case.
func (q *Queries) deck() *say.Deck {
if q == nil {
return say.FloorDeck(queryFloor)
return nil
}
return q.d
}
-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.VariantsOf(QueryUnknown) {
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.VariantsOf(QueryWeatherNow) {
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 -32
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,11 +22,10 @@
"variants": ["не знаю — не нашла у тебя такой записи."]
},
"fact_when": {
"fixed": true,
"variants": ["записала это {when}"]
"variants": ["я записала это {when}", "записала это {when}"]
},
"fact_value": {
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
@@ -41,12 +38,10 @@
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
},
"page_empty": {
"fixed": true,
"variants": ["страница открылась, но читать там нечего."]
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
},
"feeds_off": {
"fixed": true,
"variants": ["ленты не настроены."]
"variants": ["я пока не читаю ленты — они не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
@@ -58,50 +53,49 @@
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
},
"weather_now": {
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
},
"weather_off": {
"fixed": true,
"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": {
"fixed": true,
"variants": ["не получилось собрать план."]
"variants": ["не получилось собрать план.", "план не собрался."]
},
"fail_notes": {
"fixed": true,
"variants": ["не получилось посмотреть записи."]
"variants": ["не получилось посмотреть записи.", "записи не открылись."]
},
"fail_feeds": {
"fixed": true,
"variants": ["не получилось посмотреть ленты."]
"variants": ["не получилось посмотреть ленты.", "ленты не открылись."]
},
"fail_calendar": {
"fixed": true,
"variants": ["не получилось проверить календарь."]
"variants": ["не получилось проверить календарь.", "календарь не открылся."]
},
"fail_weather": {
"fixed": true,
"variants": ["не получилось узнать погоду."]
"variants": ["не получилось узнать погоду.", "погода не пришла."]
},
"fail_answer": {
"fixed": true,
"variants": ["не получилось найти ответ."]
"variants": ["не получилось найти ответ.", "ответ не нашёлся."]
},
"fail_page": {
"fixed": true,
"variants": ["не получилось прочитать страницу."]
"variants": ["не получилось прочитать страницу.", "страница не прочиталась."]
},
"fail_netscan": {
"fixed": true,
"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.
+19 -100
View File
@@ -38,8 +38,8 @@ type file struct {
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).
// 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
@@ -93,7 +93,7 @@ func (d *Deck) Text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, fillable(e.Variants, vars))
tmpl = d.pick(key, e)
}
}
if tmpl == "" {
@@ -132,94 +132,12 @@ func (d *Deck) Variants() []string {
return out
}
// VariantsOf returns the wordings the file gives for one key, for a test that
// has something to say about every one of them.
func (d *Deck) VariantsOf(key string) []string {
if d == nil {
return nil
}
return d.file.Entries[key].Variants
}
// UnfixedSingles lists the keys with exactly one variant that are not marked
// fixed. Nothing breaks on one — the picker has nothing to pick either way — but
// the flag is what a reader goes by, and parallel entries disagreeing about it
// is how a family stops being readable. Load already rejects the other half of
// the rule, fixed with more than one variant, so this is the pair to it.
func (d *Deck) UnfixedSingles() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed {
out = append(out, k)
}
}
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 {
@@ -236,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).
// 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 {
return ""
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
-55
View File
@@ -1,55 +0,0 @@
package say
// 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 and once as
// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
// for every temperature and the task list said "дн." — a written abbreviation
// read aloud. 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, "устройство", "устройства", "устройств")
}
// Days — the noun for a stretch of days. This is what replaces «дн.» in the
// overdue and due-soon reasons: an abbreviation is written shorthand, and every
// one of these lines is spoken.
func Days(n int) string {
return CountWord(n, "день", "дня", "дней")
}
-16
View File
@@ -1,16 +0,0 @@
package say
import "testing"
// The day forms, kept from internal/memory when the helper moved: 21 takes the
// singular and 11 does not, which is the whole reason this is not n == 1.
func TestDays(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
if got := Days(c.n); got != c.want {
t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
}
}
}
+22 -23
View File
@@ -37,6 +37,7 @@ const (
TasksCandidates = "tasks_candidates"
ReasonOverdue = "reason_overdue"
ReasonOverdueDay = "reason_overdue_day"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
ReasonTomorrow = "reason_tomorrow"
@@ -64,7 +65,7 @@ const (
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
@@ -73,43 +74,42 @@ var summaryKeys = []string{
}
// summaryFloor — 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 summaryFloor = map[string]string{
// 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}",
PlanDay: "план на {date}: {items}.",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}",
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
TasksFirst: "сначала: {items}.",
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
ReasonOverdue: "просрочено",
ReasonOverdueDays: "просрочено на {n} {word}",
ReasonOverdueDay: "просрочено на день",
ReasonOverdueDays: "просрочено на {n} дн.",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} {word}",
ReasonInDays: "через {n} дн.",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.",
HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.",
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
HabitOverall: "обычно ты {items} — {span}.",
HabitOverallNone: "записей пока мало, про привычки не скажу.",
HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.",
HabitSpanToday: "по записям за сегодня",
HabitSpanDays: "по записям за последние {n} {word}",
HabitUnglossed: "отмечаешь «{key}»",
HabitAt: "{gloss} около {time}",
}
})
// Summaries picks a hand-written Russian summary sentence. Safe for concurrent
// use.
@@ -128,12 +128,11 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
{HabitWeekendSame, "{items}"},
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
@@ -150,7 +149,7 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
// deck reads through a nil *Summaries, which is the unloadable-file case.
func (s *Summaries) deck() *Deck {
if s == nil {
return FloorDeck(summaryFloor)
return nil
}
return s.d
}
+29 -31
View File
@@ -5,13 +5,9 @@
"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.",
"She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
"Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
"Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
"The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
"{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
"A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
"fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
"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": {
@@ -24,7 +20,7 @@
},
"plan_day": {
"fixed": true,
"variants": ["план на {date}: {items}"]
"variants": ["план на {date}: {items}."]
},
"plan_uncertain": {
"fixed": true,
@@ -37,20 +33,24 @@
},
"tasks_first": {
"fixed": true,
"variants": ["сначала: {items}"]
"variants": ["сначала: {items}."]
},
"tasks_candidates": {
"fixed": true,
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
"variants": ["ещё я нашла, но ты не подтвердил: {items}."]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
},
"reason_overdue_day": {
"fixed": true,
"variants": ["просрочено на день"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} {word}"]
"variants": ["просрочено на {n} дн."]
},
"reason_today": {
"fixed": true,
@@ -62,7 +62,7 @@
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} {word}"]
"variants": ["через {n} дн."]
},
"reason_important": {
"fixed": true,
@@ -82,53 +82,51 @@
"variants": ["по {day} ты обычно {items}."]
},
"habit_weekday_same": {
"fixed": true,
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
"variants": [
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу ничего постоянного.",
"по {day} постоянного пока не вижу — записей мало."
"по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_weekend_both": {
"fixed": true,
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
},
"habit_weekend_sat": {
"fixed": true,
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
},
"habit_weekend_sun": {
"fixed": true,
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
},
"habit_weekend_same": {
"fixed": true,
"variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."]
"variants": [
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekend_none": {
"variants": [
"по выходным я пока не вижу ничего постоянного.",
"по выходным постоянного пока не вижу — записей мало."
"по выходным я пока не вижу у тебя ничего постоянного.",
"по выходным у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_overall": {
"fixed": true,
"variants": ["обычно ты {items} — {span}."]
},
"habit_overall_none": {
"variants": [
"записей пока мало, про привычки не скажу.",
"пока мало записей, чтобы говорить о привычках."
"я ещё не набрала достаточно записей, чтобы говорить о привычках.",
"записей пока мало — на привычки я так не сошлюсь."
]
},
"habit_span_today": {
"fixed": true,
"variants": ["по записям за сегодня"]
},
"habit_span_days": {
"fixed": true,
"variants": ["по записям за последние {n} {word}"]
},
"habit_unglossed": {
-13
View File
@@ -47,16 +47,3 @@ func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
}
}
}
// One variant means fixed, in this file and in the four in internal/phraser.
// Nothing breaks on the flag being absent, but parallel entries disagreeing
// about it is how the file stops telling a reader which wording is load-bearing.
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
if got := s.d.UnfixedSingles(); len(got) > 0 {
t.Errorf("single-variant entries not marked fixed: %v", got)
}
}
+5 -15
View File
@@ -121,12 +121,10 @@ func score(it Item, now time.Time) (float64, string) {
}
total += scoreOverdue + bonus
reason = say.S(say.ReasonOverdue, nil)
if late > 0 {
// One day needs no arm of its own: «просрочено на 1 день»
// falls out of the count helper like every other number.
reason = say.S(say.ReasonOverdueDays, map[string]string{
"n": strconv.Itoa(late), "word": say.Days(late),
})
if late == 1 {
reason = say.S(say.ReasonOverdueDay, nil)
} else if late > 1 {
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
}
case days == 0:
total += scoreDueToday
@@ -136,9 +134,7 @@ func score(it Item, now time.Time) (float64, string) {
reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
reason = say.S(say.ReasonInDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
})
reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
default:
total += scoreDueLater
}
@@ -227,13 +223,7 @@ func FormatRU(ranked []Ranked) string {
}))
}
if len(cands) > 0 {
// Two sentences, and the first one ends on a joined list that carries
// whatever punctuation its last task had — usually none. So the break
// is the caller's to make, not the line file's (Vikunja #521).
if b.Len() > 0 {
if !strings.HasSuffix(b.String(), ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
b.WriteString(say.S(say.TasksCandidates, map[string]string{
+4 -6
View File
@@ -123,12 +123,10 @@ func TestRankOverdueDaysAreCounted(t *testing.T) {
if got[0].Text != "давно" {
t.Errorf("order = %v, want the later-overdue task first", texts(got))
}
if got[0].Reason != "просрочено на 12 дней" {
if got[0].Reason != "просрочено на 12 дн." {
t.Errorf("reason = %q", got[0].Reason)
}
// One day goes through the same entry as twelve: «на 1 день» is what the
// count helper says, so there is no reason_overdue_day any more.
if got[1].Reason != "просрочено на 1 день" {
if got[1].Reason != "просрочено на день" {
t.Errorf("reason = %q", got[1].Reason)
}
}
@@ -143,7 +141,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
t.Errorf("reply = %q", got)
}
if !strings.Contains(got, "не подтверждал: продлить страховку") {
if !strings.Contains(got, "не подтвердил: продлить страховку") {
t.Errorf("candidate not named as unconfirmed: %q", got)
}
// A candidate's due date is Maven's reading of a mail, not his statement.
@@ -180,7 +178,7 @@ func TestFormatRUEmpty(t *testing.T) {
// A due date read back from the store is a UTC instant, so comparing calendar
// days in ITS location put every date a day out east of Greenwich: the row said
// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date
// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
// itself while the due column one cell over said otherwise.
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
tz := time.FixedZone("UTC+4", 4*3600)