Review PR 111: query strings — declension helper, net_empty silence, register cuts #161
@@ -400,6 +400,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||
"location": w.Location,
|
||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||
"word": phraser.Degrees(w.Temperature),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,16 +165,17 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||
// reporting a generic failure.
|
||||
// robots.txt is honoured on the answer path too, and she says the page is
|
||||
// closed instead of reporting a generic failure.
|
||||
func TestQueryWebObeysRobots(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
// She names the cause without reading a filename out loud.
|
||||
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ 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
|
||||
@@ -158,6 +171,11 @@ 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 != "" {
|
||||
@@ -176,6 +194,12 @@ 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, "; ")})
|
||||
}
|
||||
|
||||
@@ -202,9 +226,19 @@ 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, "; ")})
|
||||
}
|
||||
|
||||
@@ -249,9 +283,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, nil)
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
@@ -521,13 +555,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, nil)
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
// 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, nil)
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
@@ -551,9 +585,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, nil)
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -603,7 +637,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, "entity": displayName})
|
||||
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
|
||||
}
|
||||
|
||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||
|
||||
@@ -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")); strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(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")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(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 strings.Contains(reply, "выполнена") {
|
||||
if actRan(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 == "" || strings.Contains(reply, "выполнена") {
|
||||
if reply == "" || actRan(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")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(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 == "" || strings.Contains(reply, "выполнена") {
|
||||
if reply == "" || actRan(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 strings.Contains(reply, "выполнена") {
|
||||
if actRan(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")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(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")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(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 strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
|
||||
nexus.SetFault(503)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
if !actRan(reply) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
// The follow-up "да" turn executes exactly the parked capability.
|
||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
||||
if !handled || !actRan(confirmReply) {
|
||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||
}
|
||||
if !*executed {
|
||||
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
||||
if h.pendingHexis != nil {
|
||||
t.Fatal("read-only cap should not park a confirmation")
|
||||
}
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
if !actRan(reply) {
|
||||
t.Fatalf("unexpected reply %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -241,3 +242,14 @@ 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"
|
||||
|
||||
@@ -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")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +87,12 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
// Against the entries, not against a substring: both of these have several
|
||||
// wordings, so "ничего нового" passed only on the turns the picker happened
|
||||
// to choose the first one.
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
@@ -97,6 +100,9 @@ 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) {
|
||||
|
||||
+2
-18
@@ -119,7 +119,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if len(res.Hosts) == 0 {
|
||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
@@ -211,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
}
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
// 5+ устройств, and the teens are all the last form.
|
||||
func hostWord(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "устройств"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "устройство"
|
||||
case 2, 3, 4:
|
||||
return "устройства"
|
||||
default:
|
||||
return "устройств"
|
||||
}
|
||||
}
|
||||
|
||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||
|
||||
@@ -76,18 +76,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
|
||||
+12
-20
@@ -4,6 +4,10 @@
|
||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||
// Extend this file rather than voice.go for anything in that shape.
|
||||
//
|
||||
// Count agreement is not here. It is say.CountWord, because there were four
|
||||
// copies of the same three-way rule and two of the sites that needed it were
|
||||
// spelling one form out (Vikunja #521).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +15,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
var ruWeekdays = []string{
|
||||
@@ -94,11 +100,11 @@ func mentionsUnknownDay(u string) bool {
|
||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||
func ruClock(t time.Time) string {
|
||||
h, m := t.Hour(), t.Minute()
|
||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
||||
hourWord := say.CountWord(h, "час", "часа", "часов")
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||
}
|
||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
|
||||
}
|
||||
|
||||
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||
@@ -118,22 +124,6 @@ func dayPrefix(now, day time.Time) string {
|
||||
return "это"
|
||||
}
|
||||
|
||||
func ruPlural(n int, one, two, many string) string {
|
||||
n = n % 100
|
||||
if n > 10 && n < 20 {
|
||||
return many
|
||||
}
|
||||
n = n % 10
|
||||
switch n {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return two
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||
// rather than the current clock — guards replySystem from replying "сейчас
|
||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||
@@ -169,11 +159,13 @@ func formatTime(t time.Time) string {
|
||||
case diff < 10*time.Minute:
|
||||
return "несколько минут назад"
|
||||
case diff < 60*time.Minute:
|
||||
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
||||
n := int(diff.Minutes())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
|
||||
case diff < 2*time.Hour:
|
||||
return "час назад"
|
||||
case diff < 24*time.Hour:
|
||||
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
}
|
||||
|
||||
@@ -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": hostWord(dark),
|
||||
"word": phraser.Devices(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, hostWord(dark)))
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
+6
-1
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -627,7 +628,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||
// The adjective declines with the noun, so the count picks the whole
|
||||
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
|
||||
// отложенных уведомлений.
|
||||
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
|
||||
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
|
||||
@@ -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{
|
||||
"sat": joinActivities(sat), "sun": joinActivities(sun),
|
||||
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
|
||||
})
|
||||
case len(sat) > 0:
|
||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||
@@ -395,24 +395,10 @@ 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": pluralDaysRU(days),
|
||||
"n": strconv.Itoa(days), "word": say.Days(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
|
||||
|
||||
@@ -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,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(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) {
|
||||
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 an answer about him", got)
|
||||
if !strings.Contains(got, "средам") {
|
||||
t.Errorf("got %q, want the day he asked about", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,17 +347,6 @@ 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()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -137,7 +138,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)
|
||||
}
|
||||
@@ -215,3 +216,26 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = say.RegisterFloor(map[string]string{
|
||||
var ackFloor = map[string]string{
|
||||
AckFact: "записала факт.",
|
||||
AckFactKey: "отметила: {key}",
|
||||
AckFactValue: "отметила: {key} = {value}",
|
||||
@@ -92,7 +92,7 @@ var ackFloor = say.RegisterFloor(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 nil
|
||||
return say.FloorDeck(ackFloor)
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -74,28 +74,30 @@ var actKeys = []string{
|
||||
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{
|
||||
// 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{
|
||||
ActDone: "готово.",
|
||||
ActDoneOut: "готово: {out}",
|
||||
ActDoneEntity: "команда выполнена для {name}.",
|
||||
ActConfirm: "выполнить «{name}»? скажи «да» или «нет».",
|
||||
ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».",
|
||||
ActDoneEntity: "готово: {name}.",
|
||||
ActConfirm: "выполнить «{name}»? да или нет.",
|
||||
ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.",
|
||||
ActWhich: "какую команду для {name}: {items}?",
|
||||
ActFail: "не получилось выполнить команду.",
|
||||
ActFailOut: "не получилось выполнить команду: {out}",
|
||||
ActFailEntity: "не получилось выполнить команду для {name}.",
|
||||
ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.",
|
||||
ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.",
|
||||
ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.",
|
||||
ActServerDown: "инструмент есть, но сервер не подключён.",
|
||||
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
|
||||
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
||||
|
||||
EcoDenied: "экосистема отклоняет доступ, проверь токен.",
|
||||
EcoDown: "экосистема недоступна, попробуй ещё раз.",
|
||||
EcoAmbiguous: "уточни, что именно: {items}?",
|
||||
EcoUnknownEntity: "не знаю такой сущности.",
|
||||
EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.",
|
||||
EcoAboutWhat: "про что именно спросить?",
|
||||
EcoDenied: "{name} отклоняет доступ, проверь токен.",
|
||||
EcoDown: "{name} не отвечает, попробуй ещё раз.",
|
||||
EcoAmbiguous: "что именно: {items}?",
|
||||
EcoUnknownEntity: "не знаю, что это.",
|
||||
EcoNoNexus: "не с чем связать — Nexus не настроен.",
|
||||
EcoAboutWhat: "про что именно?",
|
||||
EcoRecall: "я помню: {items}",
|
||||
|
||||
AttentionNone: "ничего не требует внимания.",
|
||||
@@ -104,14 +106,14 @@ var actFloor = say.RegisterFloor(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 }
|
||||
@@ -128,10 +130,12 @@ 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, "{entity}"},
|
||||
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_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}"},
|
||||
} {
|
||||
@@ -145,7 +149,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 nil
|
||||
return say.FloorDeck(actFloor)
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
"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."
|
||||
"\"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."
|
||||
],
|
||||
"entries": {
|
||||
"act_done": {
|
||||
@@ -18,97 +22,120 @@
|
||||
},
|
||||
"act_done_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["команда выполнена для {name}."]
|
||||
"variants": ["готово: {name}."]
|
||||
},
|
||||
"act_confirm": {
|
||||
"fixed": true,
|
||||
"variants": ["выполнить «{name}»? скажи «да» или «нет»."]
|
||||
"variants": ["выполнить «{name}»? да или нет."]
|
||||
},
|
||||
"act_confirm_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."]
|
||||
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
|
||||
},
|
||||
"act_which": {
|
||||
"fixed": true,
|
||||
"variants": ["какую команду для {name}: {items}?"]
|
||||
},
|
||||
"act_fail": {
|
||||
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду."]
|
||||
},
|
||||
"act_fail_out": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду: {out}"]
|
||||
},
|
||||
"act_fail_entity": {
|
||||
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду для {name}."]
|
||||
},
|
||||
"act_server_down": {
|
||||
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
|
||||
"fixed": true,
|
||||
"variants": ["инструмент есть, но сервер не подключён."]
|
||||
},
|
||||
"act_withdrawn": {
|
||||
"fixed": true,
|
||||
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
|
||||
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
|
||||
},
|
||||
"act_needs_args": {
|
||||
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"eco_denied": {
|
||||
"variants": ["экосистема отклоняет доступ, проверь токен."]
|
||||
"fixed": true,
|
||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
||||
},
|
||||
"eco_down": {
|
||||
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
|
||||
"fixed": true,
|
||||
"variants": ["{name} не отвечает, попробуй ещё раз."]
|
||||
},
|
||||
"eco_ambiguous": {
|
||||
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
|
||||
"fixed": true,
|
||||
"variants": ["что именно: {items}?"]
|
||||
},
|
||||
"eco_unknown_entity": {
|
||||
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
|
||||
"variants": ["не знаю, что это.", "такого у меня нет."]
|
||||
},
|
||||
"eco_no_nexus": {
|
||||
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
|
||||
"fixed": true,
|
||||
"variants": ["не с чем связать — Nexus не настроен."]
|
||||
},
|
||||
"eco_about_what": {
|
||||
"variants": ["про что именно спросить?", "про что спросить?"]
|
||||
"fixed": true,
|
||||
"variants": ["про что именно?"]
|
||||
},
|
||||
"eco_recall": {
|
||||
"fixed": true,
|
||||
"variants": ["я помню: {items}"]
|
||||
},
|
||||
"attention_none": {
|
||||
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
|
||||
"fixed": true,
|
||||
"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": {
|
||||
"variants": ["нет изменений.", "изменений нет."]
|
||||
"fixed": true,
|
||||
"variants": ["изменений нет."]
|
||||
},
|
||||
"changes_list": {
|
||||
"fixed": true,
|
||||
"variants": ["изменения: {items}"]
|
||||
},
|
||||
"changes_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать об изменениях."]
|
||||
},
|
||||
"home_unreachable": {
|
||||
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
|
||||
"variants": ["дом не отвечает.", "не достучалась до дома."]
|
||||
},
|
||||
"home_empty": {
|
||||
"variants": ["дом ничего не отдаёт.", "дом молчит."]
|
||||
"fixed": true,
|
||||
"variants": ["дом ничего не отдаёт."]
|
||||
},
|
||||
"home_on": {
|
||||
"fixed": true,
|
||||
"variants": ["включено: {items}"]
|
||||
},
|
||||
"home_dark": {
|
||||
"variants": ["дом молчит: {count} {word} не отвечают."]
|
||||
"fixed": true,
|
||||
"variants": ["не отвечают: {count} {word}."]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = say.RegisterFloor(map[string]string{
|
||||
var hardFloor = map[string]string{
|
||||
fbChat: "даже не знаю, что сказать.",
|
||||
fbQueryUnknown: "не знаю.",
|
||||
fbQuerySources: "вот что я нашла: {sources}",
|
||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||
})
|
||||
}
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *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 nil
|
||||
return say.FloorDeck(hardFloor)
|
||||
}
|
||||
return f.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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) }
|
||||
@@ -0,0 +1,32 @@
|
||||
package phraser
|
||||
|
||||
import "testing"
|
||||
|
||||
// The bug the helper exists for: the weather line said "градусов" for every
|
||||
// reading, which is wrong for 1-4 and for every number ending in 1-4.
|
||||
func TestDegreesAgreeWithTheReading(t *testing.T) {
|
||||
for temp, want := range map[float64]string{
|
||||
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
|
||||
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
|
||||
22: "градуса", 25: "градусов", 101: "градус",
|
||||
// Minus does not change the noun, and a reading rounds to the number
|
||||
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
|
||||
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
|
||||
} {
|
||||
if got := Degrees(temp); got != want {
|
||||
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesAgreeWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := Devices(n); got != want {
|
||||
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-13
@@ -68,25 +68,27 @@ var queryKeys = []string{
|
||||
}
|
||||
|
||||
// queryFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var queryFloor = say.RegisterFloor(map[string]string{
|
||||
QueryUnknown: "не знаю.",
|
||||
// It started as the exact strings that lived in Go before this file existed and
|
||||
// now tracks the file's first variant instead, because a floor that keeps the
|
||||
// wording review threw out would say it back on the one turn nobody is watching.
|
||||
var queryFloor = map[string]string{
|
||||
QueryUnknown: "ничего не нашла.",
|
||||
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
|
||||
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
|
||||
QueryFactWhen: "я записала это {when}",
|
||||
QueryFactValue: "вот что я знаю: {key} — {value}",
|
||||
QueryFactWhen: "записала это {when}",
|
||||
QueryFactValue: "у меня записано: {key} — {value}",
|
||||
QueryFound: "вот что я нашла: {text}",
|
||||
QueryPageText: "вот что на странице: {text}",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
|
||||
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||
QueryFeedsOff: "я пока не читаю ленты — они не настроены.",
|
||||
QueryFeedsOff: "ленты не настроены.",
|
||||
QueryFeedsNew: "вот что нового: {items}",
|
||||
QueryFeedsEmpty: "в лентах пока ничего нового.",
|
||||
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
|
||||
QueryWeatherOff: "погода не настроена.",
|
||||
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
|
||||
QueryNetEmpty: "в сети никого не нашла{tail}.",
|
||||
QueryWeatherWhere: "для какого города?",
|
||||
QueryNetEmpty: "в сети никого не нашла.",
|
||||
QueryNetOff: "сканирование сети не настроено.",
|
||||
QueryPageOff: "я не читаю страницы — это не настроено.",
|
||||
|
||||
@@ -98,7 +100,7 @@ var queryFloor = say.RegisterFloor(map[string]string{
|
||||
QueryFailAnswer: "не получилось найти ответ.",
|
||||
QueryFailPage: "не получилось прочитать страницу.",
|
||||
QueryFailNetscan: "не получилось просканировать сеть.",
|
||||
})
|
||||
}
|
||||
|
||||
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||
type Queries struct{ d *say.Deck }
|
||||
@@ -115,7 +117,8 @@ func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
|
||||
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
|
||||
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
|
||||
} {
|
||||
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
@@ -127,7 +130,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 nil
|
||||
return say.FloorDeck(queryFloor)
|
||||
}
|
||||
return q.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueriesLoad(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, k := range queryKeys {
|
||||
if got := q.Say(k, nil); got == "" {
|
||||
t.Errorf("%s says nothing", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bug: net_empty carried {tail} in every variant, and a scan that finished
|
||||
// the whole range has no caveat to put there. Whatever the file says, an answer
|
||||
// he can hear has to come out — never braces, never nothing.
|
||||
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, vars := range []map[string]string{nil, {"tail": ""}} {
|
||||
for i := 0; i < 20; i++ {
|
||||
got := q.Say(QueryNetEmpty, vars)
|
||||
if got == "" || strings.ContainsAny(got, "{}") {
|
||||
t.Fatalf("net_empty with vars %v said %q", vars, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: a caveat he was given is not dropped for a shorter wording.
|
||||
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
const tail = ", но успела посмотреть не всю сеть"
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
|
||||
t.Fatalf("net_empty dropped the tail: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// query_unknown means she looked and found nothing. The phraser's fallback
|
||||
// means she failed to phrase an answer she had. Two causes, two sentences, or
|
||||
// the distinction the two files exist for is unobservable from the outside.
|
||||
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
f, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
failures := map[string]bool{}
|
||||
for _, v := range f.Variants() {
|
||||
failures[v] = true
|
||||
}
|
||||
for _, v := range q.d.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@
|
||||
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
|
||||
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
|
||||
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
|
||||
],
|
||||
"entries": {
|
||||
"query_unknown": {
|
||||
"variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."]
|
||||
"variants": ["ничего не нашла.", "искала — не нашла."]
|
||||
},
|
||||
"other_day": {
|
||||
"fixed": true,
|
||||
@@ -22,10 +24,11 @@
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"variants": ["я записала это {when}", "записала это {when}"]
|
||||
"fixed": true,
|
||||
"variants": ["записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
|
||||
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
|
||||
},
|
||||
"found": {
|
||||
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
|
||||
@@ -35,13 +38,15 @@
|
||||
},
|
||||
"page_blocked": {
|
||||
"fixed": true,
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
"variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
|
||||
"fixed": true,
|
||||
"variants": ["страница открылась, но читать там нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"variants": ["я пока не читаю ленты — они не настроены."]
|
||||
"fixed": true,
|
||||
"variants": ["ленты не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
|
||||
@@ -53,14 +58,15 @@
|
||||
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
|
||||
},
|
||||
"weather_now": {
|
||||
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
|
||||
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"fixed": true,
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
"fixed": true,
|
||||
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
|
||||
"variants": ["для какого города?"]
|
||||
},
|
||||
"net_off": {
|
||||
"fixed": true,
|
||||
@@ -71,31 +77,39 @@
|
||||
"variants": ["я не читаю страницы — это не настроено."]
|
||||
},
|
||||
"net_empty": {
|
||||
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."]
|
||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"variants": ["не получилось собрать план.", "план не собрался."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось собрать план."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"variants": ["не получилось посмотреть записи.", "записи не открылись."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть записи."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"variants": ["не получилось посмотреть ленты.", "ленты не открылись."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть ленты."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"variants": ["не получилось проверить календарь.", "календарь не открылся."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось проверить календарь."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"variants": ["не получилось узнать погоду.", "погода не пришла."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось узнать погоду."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"variants": ["не получилось найти ответ.", "ответ не нашёлся."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось найти ответ."]
|
||||
},
|
||||
"fail_page": {
|
||||
"variants": ["не получилось прочитать страницу.", "страница не прочиталась."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось прочитать страницу."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"variants": ["не получилось просканировать сеть.", "сеть не просканировалась."]
|
||||
"fixed": true,
|
||||
"variants": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-19
@@ -38,8 +38,8 @@ type file struct {
|
||||
Entries map[string]Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A nil *Deck answers from the
|
||||
// floor, which is what an unloadable file leaves behind.
|
||||
// Deck picks a line. Safe for concurrent use. A Deck with no entries answers
|
||||
// from the floor, which is what an unloadable file leaves behind (FloorDeck).
|
||||
type Deck struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
@@ -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, e)
|
||||
tmpl = d.pick(key, fillable(e.Variants, vars))
|
||||
}
|
||||
}
|
||||
if tmpl == "" {
|
||||
@@ -132,12 +132,94 @@ 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, e Entry) string {
|
||||
func (d *Deck) pick(key string, variants []string) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
choices := variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
@@ -154,24 +236,23 @@ func (d *Deck) pick(key string, e Entry) string {
|
||||
return got
|
||||
}
|
||||
|
||||
// floorOf reads the Go literal behind key, and works on a nil deck because that
|
||||
// is exactly the case it exists for. The per-family map is the source of truth.
|
||||
// floorOf reads the Go literal behind key. Every deck carries its own family's
|
||||
// map, including the floor-only deck an unloadable file leaves behind, so no
|
||||
// lookup ever crosses families. It used to go through one global map keyed by
|
||||
// bare entry name, which two families both calling an entry query_unknown
|
||||
// silently shared: whichever registered last answered for both (Vikunja #521).
|
||||
func floorOf(d *Deck, key string) string {
|
||||
if d != nil && d.floor != nil {
|
||||
return d.floor[key]
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
return deckFloors[key]
|
||||
return d.floor[key]
|
||||
}
|
||||
|
||||
// deckFloors — every family's floor literals in one map, so a nil deck still
|
||||
// finds them. Families register at init; the keys are namespaced by family.
|
||||
var deckFloors = map[string]string{}
|
||||
|
||||
func RegisterFloor(floor map[string]string) map[string]string {
|
||||
for k, v := range floor {
|
||||
deckFloors[k] = v
|
||||
}
|
||||
return floor
|
||||
// FloorDeck — the deck a family falls back to when its file will not load. It
|
||||
// has no entries, so every read drops through to the floor literals, and it is
|
||||
// a real *Deck so no accessor has to know which case it is in.
|
||||
func FloorDeck(floor map[string]string) *Deck {
|
||||
return &Deck{last: map[string]string{}, floor: floor}
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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, "день", "дня", "дней")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-22
@@ -37,7 +37,6 @@ const (
|
||||
TasksCandidates = "tasks_candidates"
|
||||
|
||||
ReasonOverdue = "reason_overdue"
|
||||
ReasonOverdueDay = "reason_overdue_day"
|
||||
ReasonOverdueDays = "reason_overdue_days"
|
||||
ReasonToday = "reason_today"
|
||||
ReasonTomorrow = "reason_tomorrow"
|
||||
@@ -65,7 +64,7 @@ const (
|
||||
var summaryKeys = []string{
|
||||
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
|
||||
TasksNone, TasksFirst, TasksCandidates,
|
||||
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
|
||||
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
|
||||
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
|
||||
@@ -74,42 +73,43 @@ var summaryKeys = []string{
|
||||
}
|
||||
|
||||
// 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{
|
||||
// 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{
|
||||
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
|
||||
PlanDayEmpty: "на {date} ничего не запланировано.",
|
||||
PlanDay: "план на {date}: {items}.",
|
||||
PlanDay: "план на {date}: {items}",
|
||||
PlanUncertain: "похоже, {line}",
|
||||
|
||||
TasksNone: "задач нет.",
|
||||
TasksFirst: "сначала: {items}.",
|
||||
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
|
||||
TasksFirst: "сначала: {items}",
|
||||
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
|
||||
|
||||
ReasonOverdue: "просрочено",
|
||||
ReasonOverdueDay: "просрочено на день",
|
||||
ReasonOverdueDays: "просрочено на {n} дн.",
|
||||
ReasonOverdueDays: "просрочено на {n} {word}",
|
||||
ReasonToday: "сегодня",
|
||||
ReasonTomorrow: "завтра",
|
||||
ReasonInDays: "через {n} дн.",
|
||||
ReasonInDays: "через {n} {word}",
|
||||
ReasonImportant: "важно",
|
||||
ReasonUrgent: "срочно",
|
||||
ReasonStale: "давно в списке",
|
||||
|
||||
HabitWeekday: "по {day} ты обычно {items}.",
|
||||
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
|
||||
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
|
||||
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
|
||||
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
|
||||
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
|
||||
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
|
||||
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_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,11 +128,12 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
|
||||
{PlanUncertain, "{line}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
|
||||
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
|
||||
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
|
||||
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
|
||||
{HabitWeekdayNone, "{day}"},
|
||||
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
|
||||
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
|
||||
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
|
||||
{HabitWeekendSame, "{items}"},
|
||||
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
|
||||
@@ -149,7 +150,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 nil
|
||||
return FloorDeck(summaryFloor)
|
||||
}
|
||||
return s.d
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"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."
|
||||
"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."
|
||||
],
|
||||
"entries": {
|
||||
"plan_rest_empty": {
|
||||
@@ -20,7 +24,7 @@
|
||||
},
|
||||
"plan_day": {
|
||||
"fixed": true,
|
||||
"variants": ["план на {date}: {items}."]
|
||||
"variants": ["план на {date}: {items}"]
|
||||
},
|
||||
"plan_uncertain": {
|
||||
"fixed": true,
|
||||
@@ -33,24 +37,20 @@
|
||||
},
|
||||
"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} дн."]
|
||||
"variants": ["просрочено на {n} {word}"]
|
||||
},
|
||||
"reason_today": {
|
||||
"fixed": true,
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"reason_in_days": {
|
||||
"fixed": true,
|
||||
"variants": ["через {n} дн."]
|
||||
"variants": ["через {n} {word}"]
|
||||
},
|
||||
"reason_important": {
|
||||
"fixed": true,
|
||||
@@ -82,51 +82,53 @@
|
||||
"variants": ["по {day} ты обычно {items}."]
|
||||
},
|
||||
"habit_weekday_same": {
|
||||
"variants": [
|
||||
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
|
||||
]
|
||||
"fixed": true,
|
||||
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
|
||||
},
|
||||
"habit_weekday_none": {
|
||||
"variants": [
|
||||
"по {day} я пока не вижу у тебя ничего постоянного.",
|
||||
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
|
||||
"по {day} я пока не вижу ничего постоянного.",
|
||||
"по {day} постоянного пока не вижу — записей мало."
|
||||
]
|
||||
},
|
||||
"habit_weekend_both": {
|
||||
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
|
||||
},
|
||||
"habit_weekend_sat": {
|
||||
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
|
||||
},
|
||||
"habit_weekend_sun": {
|
||||
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
|
||||
"fixed": true,
|
||||
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
|
||||
},
|
||||
"habit_weekend_same": {
|
||||
"variants": [
|
||||
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
|
||||
]
|
||||
"fixed": true,
|
||||
"variants": ["по выходным всё как обычно — то же, что и в остальные дни: {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": {
|
||||
|
||||
@@ -47,3 +47,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-20
@@ -121,10 +121,12 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
}
|
||||
total += scoreOverdue + bonus
|
||||
reason = say.S(say.ReasonOverdue, nil)
|
||||
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)})
|
||||
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),
|
||||
})
|
||||
}
|
||||
case days == 0:
|
||||
total += scoreDueToday
|
||||
@@ -134,7 +136,9 @@ 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)})
|
||||
reason = say.S(say.ReasonInDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||
})
|
||||
default:
|
||||
total += scoreDueLater
|
||||
}
|
||||
@@ -223,7 +227,13 @@ 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{
|
||||
@@ -253,21 +263,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
s := strings.Join(parts, "; ")
|
||||
if rest > 0 {
|
||||
// With the noun. Spoken, a bare number trails off mid-sentence.
|
||||
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
|
||||
s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
|
||||
func pluralTasksRU(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "задач"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "задача"
|
||||
case 2, 3, 4:
|
||||
return "задачи"
|
||||
}
|
||||
return "задач"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
func at(y int, m time.Month, d int) *time.Time {
|
||||
@@ -123,10 +125,12 @@ 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)
|
||||
}
|
||||
if got[1].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 день" {
|
||||
t.Errorf("reason = %q", got[1].Reason)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +145,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.
|
||||
@@ -178,7 +182,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 "просрочено на день" on the due date
|
||||
// "сегодня" for a task due tomorrow, and "просрочено на 1 день" 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)
|
||||
@@ -238,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) {
|
||||
t.Errorf("reply = %q, want the count with its noun", got)
|
||||
}
|
||||
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
|
||||
if got := pluralTasksRU(n); got != want {
|
||||
t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
|
||||
if got := say.CountWord(n, "задача", "задачи", "задач"); got != want {
|
||||
t.Errorf("CountWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user