Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a9d8a4dd5 | |||
| 4c95b200e4 | |||
| 6ae1312ff1 | |||
| 765ed36340 | |||
| feca776077 | |||
| d79b30a1a6 |
@@ -397,6 +397,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||
"location": w.Location,
|
||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||
"word": phraser.Degrees(w.Temperature),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ var ackKeys = []string{
|
||||
|
||||
// ackFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var ackFloor = registerFloor(map[string]string{
|
||||
var ackFloor = map[string]string{
|
||||
AckFact: "записала факт.",
|
||||
AckFactKey: "отметила: {key}",
|
||||
AckFactValue: "отметила: {key} = {value}",
|
||||
@@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{
|
||||
FailAck: "не получилось отметить.",
|
||||
FailSnooze: "не получилось отложить.",
|
||||
FailQuiet: "не получилось переключить тихий режим.",
|
||||
})
|
||||
}
|
||||
|
||||
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
||||
type Acks struct{ d *deck }
|
||||
@@ -119,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
// deck reads through a nil *Acks, which is the unloadable-file case.
|
||||
func (a *Acks) deck() *deck {
|
||||
if a == nil {
|
||||
return nil
|
||||
return floorDeck(ackFloor)
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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.file.Entries[key].Variants {
|
||||
if prev, dup := seen[v]; dup {
|
||||
t.Errorf("%s and %s both say %q", prev, key, v)
|
||||
}
|
||||
seen[v] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-21
@@ -72,28 +72,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 = 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: "ничего не требует внимания.",
|
||||
@@ -102,14 +104,14 @@ var actFloor = 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 *deck }
|
||||
@@ -126,10 +128,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}"},
|
||||
} {
|
||||
@@ -143,7 +147,7 @@ func LoadActs(src rand.Source) (*Acts, error) {
|
||||
// deck reads through a nil *Acts, which is the unloadable-file case.
|
||||
func (a *Acts) deck() *deck {
|
||||
if a == nil {
|
||||
return nil
|
||||
return 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,61 +22,61 @@
|
||||
},
|
||||
"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": {
|
||||
"variants": ["какую команду для {name}: {items}?"]
|
||||
},
|
||||
"act_fail": {
|
||||
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
|
||||
"variants": ["не получилось выполнить команду."]
|
||||
},
|
||||
"act_fail_out": {
|
||||
"variants": ["не получилось выполнить команду: {out}"]
|
||||
},
|
||||
"act_fail_entity": {
|
||||
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
|
||||
"variants": ["не получилось выполнить команду для {name}."]
|
||||
},
|
||||
"act_server_down": {
|
||||
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
|
||||
"variants": ["инструмент есть, но сервер не подключён."]
|
||||
},
|
||||
"act_withdrawn": {
|
||||
"fixed": true,
|
||||
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
|
||||
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
|
||||
},
|
||||
"act_needs_args": {
|
||||
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"eco_denied": {
|
||||
"variants": ["экосистема отклоняет доступ, проверь токен."]
|
||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
||||
},
|
||||
"eco_down": {
|
||||
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
|
||||
"variants": ["{name} не отвечает, попробуй ещё раз."]
|
||||
},
|
||||
"eco_ambiguous": {
|
||||
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
|
||||
"variants": ["что именно: {items}?"]
|
||||
},
|
||||
"eco_unknown_entity": {
|
||||
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
|
||||
"variants": ["не знаю, что это.", "такого у меня нет."]
|
||||
},
|
||||
"eco_no_nexus": {
|
||||
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
|
||||
"variants": ["не с чем связать — Nexus не настроен."]
|
||||
},
|
||||
"eco_about_what": {
|
||||
"variants": ["про что именно спросить?", "про что спросить?"]
|
||||
"variants": ["про что именно?"]
|
||||
},
|
||||
"eco_recall": {
|
||||
"variants": ["я помню: {items}"]
|
||||
},
|
||||
"attention_none": {
|
||||
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
|
||||
"variants": ["ничего не требует внимания."]
|
||||
},
|
||||
"attention_list": {
|
||||
"variants": ["требует внимания: {items}"]
|
||||
@@ -90,7 +94,7 @@
|
||||
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
||||
},
|
||||
"changes_none": {
|
||||
"variants": ["нет изменений.", "изменений нет."]
|
||||
"variants": ["изменений нет."]
|
||||
},
|
||||
"changes_list": {
|
||||
"variants": ["изменения: {items}"]
|
||||
@@ -99,16 +103,16 @@
|
||||
"variants": ["не могу сейчас узнать об изменениях."]
|
||||
},
|
||||
"home_unreachable": {
|
||||
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
|
||||
"variants": ["дом не отвечает.", "не достучалась до дома."]
|
||||
},
|
||||
"home_empty": {
|
||||
"variants": ["дом ничего не отдаёт.", "дом молчит."]
|
||||
"variants": ["дом ничего не отдаёт."]
|
||||
},
|
||||
"home_on": {
|
||||
"variants": ["включено: {items}"]
|
||||
},
|
||||
"home_dark": {
|
||||
"variants": ["дом молчит: {count} {word} не отвечают."]
|
||||
"variants": ["не отвечают: {count} {word}."]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
-19
@@ -35,8 +35,8 @@ type deckFile struct {
|
||||
Entries map[string]deckEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A nil *deck answers from the
|
||||
// floor, which is what an unloadable file leaves behind.
|
||||
// deck picks a line. Safe for concurrent use. A deck with no entries answers
|
||||
// from the floor, which is what an unloadable file leaves behind (floorDeck).
|
||||
type deck struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
@@ -90,7 +90,7 @@ func (d *deck) text(key string, vars map[string]string) string {
|
||||
tmpl := ""
|
||||
if d != nil {
|
||||
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
|
||||
tmpl = d.pick(key, e)
|
||||
tmpl = d.pick(key, fillable(e.Variants, vars))
|
||||
}
|
||||
}
|
||||
if tmpl == "" {
|
||||
@@ -129,12 +129,67 @@ func (d *deck) variants() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// fillable narrows variants to the ones this call can actually say, which is
|
||||
// the rule an optional placeholder needs: a caller with nothing to put in
|
||||
// {tail} must not be handed a variant that has one. Two passes, because both
|
||||
// halves matter. The first keeps only variants whose every placeholder has a
|
||||
// non-empty value, so an absent optional never reaches him as braces. The
|
||||
// second prefers, among those, the variants using the most of what the caller
|
||||
// supplied, so a caveat he was given is not dropped for a shorter wording.
|
||||
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
|
||||
// up in the answer rather than turning it into silence.
|
||||
func fillable(variants []string, vars map[string]string) []string {
|
||||
if len(variants) < 2 {
|
||||
return variants
|
||||
}
|
||||
best, bestUsed := make([]string, 0, len(variants)), -1
|
||||
for _, v := range variants {
|
||||
used := 0
|
||||
ok := true
|
||||
for _, ph := range placeholders(v) {
|
||||
if vars[ph] == "" {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
used++
|
||||
}
|
||||
if !ok || used < bestUsed {
|
||||
continue
|
||||
}
|
||||
if used > bestUsed {
|
||||
best, bestUsed = best[:0], used
|
||||
}
|
||||
best = append(best, v)
|
||||
}
|
||||
if len(best) == 0 {
|
||||
return variants
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// placeholders lists the {name}s in tmpl, in order.
|
||||
func placeholders(tmpl string) []string {
|
||||
var out []string
|
||||
for {
|
||||
i := strings.IndexByte(tmpl, '{')
|
||||
if i < 0 {
|
||||
return out
|
||||
}
|
||||
j := strings.IndexByte(tmpl[i:], '}')
|
||||
if j < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, tmpl[i+1:i+j])
|
||||
tmpl = tmpl[i+j+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (d *deck) pick(key string, e deckEntry) string {
|
||||
func (d *deck) pick(key string, variants []string) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
choices := variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
@@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string {
|
||||
return got
|
||||
}
|
||||
|
||||
// floorOf reads the Go literal behind key, and works on a nil deck because that
|
||||
// is exactly the case it exists for. The per-family map is the source of truth.
|
||||
// floorOf reads the Go literal behind key. Every deck carries its own family's
|
||||
// map, including the floor-only deck an unloadable file leaves behind, so no
|
||||
// lookup ever crosses families. It used to go through one global map keyed by
|
||||
// bare entry name, which two families both calling an entry query_unknown
|
||||
// silently shared: whichever registered last answered for both (Vikunja #521).
|
||||
func floorOf(d *deck, key string) string {
|
||||
if d != nil && d.floor != nil {
|
||||
return d.floor[key]
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
return deckFloors[key]
|
||||
return d.floor[key]
|
||||
}
|
||||
|
||||
// deckFloors — every family's floor literals in one map, so a nil deck still
|
||||
// finds them. Families register at init; the keys are namespaced by family.
|
||||
var deckFloors = map[string]string{}
|
||||
|
||||
func registerFloor(floor map[string]string) map[string]string {
|
||||
for k, v := range floor {
|
||||
deckFloors[k] = v
|
||||
}
|
||||
return floor
|
||||
// floorDeck — the deck a family falls back to when its file will not load. It
|
||||
// has no entries, so every read drops through to the floor literals, and it is
|
||||
// a real *deck so no accessor has to know which case it is in.
|
||||
func floorDeck(floor map[string]string) *deck {
|
||||
return &deck{last: map[string]string{}, floor: floor}
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
|
||||
@@ -39,12 +39,12 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
||||
|
||||
// hardFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var hardFloor = registerFloor(map[string]string{
|
||||
var hardFloor = map[string]string{
|
||||
fbChat: "даже не знаю, что сказать.",
|
||||
fbQueryUnknown: "не знаю.",
|
||||
fbQuerySources: "вот что я нашла: {sources}",
|
||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||
})
|
||||
}
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *deck }
|
||||
@@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||
func (f *Fallbacks) deck() *deck {
|
||||
if f == nil {
|
||||
return nil
|
||||
return floorDeck(hardFloor)
|
||||
}
|
||||
return f.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package phraser
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
//
|
||||
// Russian inflects a noun after a numeral, and the form depends on the last two
|
||||
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
|
||||
// A line file cannot spell that out, so a count in a template splits into two
|
||||
// placeholders — the number, and {word} filled from here.
|
||||
//
|
||||
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
|
||||
// weather line said "градусов" for every temperature and was wrong for 1-4 and
|
||||
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
|
||||
|
||||
import "math"
|
||||
|
||||
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
|
||||
// for 0, 5-20 and anything ending in those. A negative count reads its own
|
||||
// magnitude, since minus does not change the noun: -2 градуса.
|
||||
func CountWord(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
|
||||
// weather provider and counts by the whole degrees she is about to say, so the
|
||||
// noun agrees with the number in the same sentence rather than with the reading
|
||||
// behind it.
|
||||
func Degrees(temp float64) string {
|
||||
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
|
||||
}
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package phraser
|
||||
|
||||
import "testing"
|
||||
|
||||
// The bug the helper exists for: the weather line said "градусов" for every
|
||||
// reading, which is wrong for 1-4 and for every number ending in 1-4.
|
||||
func TestDegreesAgreeWithTheReading(t *testing.T) {
|
||||
for temp, want := range map[float64]string{
|
||||
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
|
||||
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
|
||||
22: "градуса", 25: "градусов", 101: "градус",
|
||||
// Minus does not change the noun, and a reading rounds to the number
|
||||
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
|
||||
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
|
||||
} {
|
||||
if got := Degrees(temp); got != want {
|
||||
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesAgreeWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := Devices(n); got != want {
|
||||
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -64,25 +64,27 @@ var queryKeys = []string{
|
||||
}
|
||||
|
||||
// queryFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var queryFloor = registerFloor(map[string]string{
|
||||
QueryUnknown: "не знаю.",
|
||||
// It started as the exact strings that lived in Go before this file existed and
|
||||
// now tracks the file's first variant instead, because a floor that keeps the
|
||||
// wording review threw out would say it back on the one turn nobody is watching.
|
||||
var queryFloor = map[string]string{
|
||||
QueryUnknown: "ничего не нашла.",
|
||||
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
|
||||
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
|
||||
QueryFactWhen: "я записала это {when}",
|
||||
QueryFactValue: "вот что я знаю: {key} — {value}",
|
||||
QueryFactWhen: "записала это {when}",
|
||||
QueryFactValue: "у меня записано: {key} — {value}",
|
||||
QueryFound: "вот что я нашла: {text}",
|
||||
QueryPageText: "вот что на странице: {text}",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
|
||||
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||
QueryFeedsOff: "я пока не читаю ленты — они не настроены.",
|
||||
QueryFeedsOff: "ленты не настроены.",
|
||||
QueryFeedsNew: "вот что нового: {items}",
|
||||
QueryFeedsEmpty: "в лентах пока ничего нового.",
|
||||
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
|
||||
QueryWeatherOff: "погода не настроена.",
|
||||
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
|
||||
QueryNetEmpty: "в сети никого не нашла{tail}.",
|
||||
QueryWeatherWhere: "для какого города?",
|
||||
QueryNetEmpty: "в сети никого не нашла.",
|
||||
|
||||
QueryFailPlan: "не получилось собрать план.",
|
||||
QueryFailNotes: "не получилось посмотреть записи.",
|
||||
@@ -92,7 +94,7 @@ var queryFloor = registerFloor(map[string]string{
|
||||
QueryFailAnswer: "не получилось найти ответ.",
|
||||
QueryFailPage: "не получилось прочитать страницу.",
|
||||
QueryFailNetscan: "не получилось просканировать сеть.",
|
||||
})
|
||||
}
|
||||
|
||||
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||
type Queries struct{ d *deck }
|
||||
@@ -109,7 +111,8 @@ func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
|
||||
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
|
||||
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
|
||||
} {
|
||||
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
@@ -121,7 +124,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
// deck reads through a nil *Queries, which is the unloadable-file case.
|
||||
func (q *Queries) deck() *deck {
|
||||
if q == nil {
|
||||
return nil
|
||||
return floorDeck(queryFloor)
|
||||
}
|
||||
return q.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueriesLoad(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, k := range queryKeys {
|
||||
if got := q.Say(k, nil); got == "" {
|
||||
t.Errorf("%s says nothing", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bug: net_empty carried {tail} in every variant, and a scan that finished
|
||||
// the whole range has no caveat to put there. Whatever the file says, an answer
|
||||
// he can hear has to come out — never braces, never nothing.
|
||||
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, vars := range []map[string]string{nil, {"tail": ""}} {
|
||||
for i := 0; i < 20; i++ {
|
||||
got := q.Say(QueryNetEmpty, vars)
|
||||
if got == "" || strings.ContainsAny(got, "{}") {
|
||||
t.Fatalf("net_empty with vars %v said %q", vars, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: a caveat he was given is not dropped for a shorter wording.
|
||||
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
const tail = ", но успела посмотреть не всю сеть"
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
|
||||
t.Fatalf("net_empty dropped the tail: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// query_unknown means she looked and found nothing. The phraser's fallback
|
||||
// means she failed to phrase an answer she had. Two causes, two sentences, or
|
||||
// the distinction the two files exist for is unobservable from the outside.
|
||||
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
f, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
failures := map[string]bool{}
|
||||
for _, v := range f.Variants() {
|
||||
failures[v] = true
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
|
||||
if failures[v] {
|
||||
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The weather line splits the count into a number and a noun, so a variant that
|
||||
// says the temperature without {word} is the hardcoded "градусов" coming back.
|
||||
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
|
||||
if strings.Contains(v, "градус") {
|
||||
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
|
||||
}
|
||||
}
|
||||
got := q.Say(QueryWeatherNow, map[string]string{
|
||||
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
|
||||
})
|
||||
if !strings.Contains(got, "1 градус,") {
|
||||
t.Errorf("weather_now said %q, want the singular noun", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No line spoken to him names a config key. She asks instead.
|
||||
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, v := range q.Variants() {
|
||||
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
|
||||
t.Errorf("variant %q says a config path out loud", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,15 @@
|
||||
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
|
||||
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
|
||||
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
|
||||
],
|
||||
"entries": {
|
||||
"query_unknown": {
|
||||
"variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."]
|
||||
"variants": ["ничего не нашла.", "искала — не нашла."]
|
||||
},
|
||||
"other_day": {
|
||||
"fixed": true,
|
||||
@@ -22,10 +24,10 @@
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"variants": ["я записала это {when}", "записала это {when}"]
|
||||
"variants": ["записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
|
||||
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
|
||||
},
|
||||
"found": {
|
||||
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
|
||||
@@ -38,10 +40,10 @@
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
|
||||
"variants": ["страница открылась, но читать там нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"variants": ["я пока не читаю ленты — они не настроены."]
|
||||
"variants": ["ленты не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
|
||||
@@ -53,41 +55,41 @@
|
||||
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
|
||||
},
|
||||
"weather_now": {
|
||||
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
|
||||
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
"fixed": true,
|
||||
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
|
||||
"variants": ["для какого города?"]
|
||||
},
|
||||
"net_empty": {
|
||||
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."]
|
||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"variants": ["не получилось собрать план.", "план не собрался."]
|
||||
"variants": ["не получилось собрать план."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"variants": ["не получилось посмотреть записи.", "записи не открылись."]
|
||||
"variants": ["не получилось посмотреть записи."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"variants": ["не получилось посмотреть ленты.", "ленты не открылись."]
|
||||
"variants": ["не получилось посмотреть ленты."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"variants": ["не получилось проверить календарь.", "календарь не открылся."]
|
||||
"variants": ["не получилось проверить календарь."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"variants": ["не получилось узнать погоду.", "погода не пришла."]
|
||||
"variants": ["не получилось узнать погоду."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"variants": ["не получилось найти ответ.", "ответ не нашёлся."]
|
||||
"variants": ["не получилось найти ответ."]
|
||||
},
|
||||
"fail_page": {
|
||||
"variants": ["не получилось прочитать страницу.", "страница не прочиталась."]
|
||||
"variants": ["не получилось прочитать страницу."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"variants": ["не получилось просканировать сеть.", "сеть не просканировалась."]
|
||||
"variants": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user