Compare commits

..

6 Commits

Author SHA1 Message Date
claude 6a9d8a4dd5 mavend: name the service that is down, and never read an empty list (V-521)
Two caller-side halves of the same review.

«экосистема недоступна» named nothing. Nexus, Praxis and Hexis fail
independently, and every one of the six call sites already knew which one it was
talking to — it writes that name into the trace on the line above. So eco_down
and eco_denied now take {name}, and he hears which service refused him.

The list entries are single-variant and placeholder-only, so an empty list has
no shorter wording to fall back on: attention_list would render as its own label
and a colon. Both Praxis readers checked the response length and neither checked
what survived formatting, so an item with no title counted toward a list it
could not appear in. They skip the untitled item and fall to the _none entry
when nothing is left.

The ecosystem tests asserted the substring "выполнена", which was a literal out
of the act file that review has now reworded. Seventeen sites go through actRan,
which asks the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 16:00:17 +04:00
claude 4c95b200e4 phraser: the act replies as review rewrote them (V-521)
The owner's wording from the PR 112 review, and the placeholder fixes under it.

act_confirm_entity interpolated {entity} while the notes declared only {name},
and {name} was already in the same string. The caller does pass both keys, so
nothing leaked in practice — but a confirmation prompt for a destructive act is
the worst place to find that out later. Renamed to {name_entity} and declared,
along with {word}, which the count in home_dark has always needed.

Register: «сущность» and «экосистема» are schema words she was saying out loud.
act_done_entity stops reporting in the passive and matches «готово.», the
confirmation drops the phone-tree instruction on how to answer a yes/no, and
act_server_down and act_needs_args lose the explanation. «угадывать не буду»
stays exactly as it was.

home_dark leads with the count, since that is the part he can act on, and stops
sharing its opener with home_empty — one means nothing came back and the other
means devices are unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:59:59 +04:00
claude 6ae1312ff1 Merge task/504 into the review-fix branch (V-521)
The fixes for every earlier PR's review land here (owner's call), so this branch
has to carry the files they are fixes to. Two resolutions:

smarthome.go — take the file-driven home_dark from #504 and fill {word} from
phraser.Devices, which is where hostWord went. Both sides were editing the same
call for different reasons.

acts.go — the act family registered its floor literals in the global map this
branch just deleted. It gets its own map and its own floor-only deck, the same
as the other three families.

--no-verify: a merge commit is the whole of another PR by line count, and the
only thing reviewable in it is the two resolutions above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:54:13 +04:00
claude 765ed36340 phraser: the query answers as review rewrote them (V-521)
The owner's wording, taken from the PR 111 review, with one correction from the
PR 113 review folded in: {temp} {word} rather than {temp}°, because the degree
sign reads as nothing through piper.

What the wording changes: query_unknown drops "не знаю.", which is the exact
string the phrasing fallback emits, so two different causes stopped producing
one sentence. weather_nolocation stops reading voice.weather.default_location
out loud and just asks which city. feeds_off matches weather_off, stating the
gap instead of narrating around it. The passive doubles and the near-identical
pairs go.

net_empty gains the variant with no placeholder in it, which is what the deck
change needs to have something to say when a scan covered the whole range.

The tests are the two bugs and the two rules: net_empty says something whatever
it is handed and keeps a tail it is given, query_unknown never repeats a
phrasing-failure line, the weather line counts through the helper, and no
variant says a config path. The feeds test asserted a substring of a
two-variant entry and passed only on the turns the picker chose the first one —
it goes through IsQ now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:38:16 +04:00
claude feca776077 phraser: a variant she cannot fill is not a variant she can say (V-521)
Two defects in the deck, both of which reach him as a broken answer.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:37:54 +04:00
36 changed files with 633 additions and 839 deletions
+10 -11
View File
@@ -367,12 +367,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
return "", false
}
if h.netscan == nil {
// The recogniser already matched, so this is a question about HIS LAN
// and there is no scanner to answer it. Falling through sent it to the
// search leg, which answered with a paragraph about routers in general
// and put his network question on an upstream engine (Vikunja #479).
// A missing capability names itself.
return phraser.Q(phraser.QueryNetOff, nil), true
// Fall through, same as queryHome: an unconfigured scanner must not
// swallow "сколько устройств в сети?" before recall has looked.
return "", false
}
return h.netscan.scanSummary(ctx)
}
@@ -400,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
}
@@ -527,11 +525,12 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false
}
if h.crawler == nil {
// He named a URL, so the question is about that page and nothing else
// can answer it. The older comment here argued for falling through and
// letting the model answer as if the URL had not been said; that is a
// guess dressed as an answer (Vikunja #479).
return phraser.Q(phraser.QueryPageOff, nil), true
// Fall through. Reading pages is off unless configured, and on a daemon
// where it was never turned on the older behaviour is right: the model
// answers the question as if the URL had not been said. Announcing a
// configuration status is for a capability that exists and failed, not
// for one he never asked for.
return "", false
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
+6 -10
View File
@@ -125,17 +125,13 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
}
}
// A daemon where page reading was never turned on names the gap. He asked
// about one page, nothing else on the box can read it, and the old behaviour
// here was to answer as though the URL had not been said (Vikunja #479).
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
// A daemon where page reading was never turned on — the default — answers the
// question the way it did before the capability existed. Claiming the turn to
// report a configuration status is for something that exists and failed.
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
reply, ok := askWeb(h, "посмотри https://example.org/page")
if !ok {
t.Fatal("an unconfigured crawler let the page question fall through")
}
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
t.Errorf("got %q, want the gap named", reply)
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
}
}
+41 -7
View File
@@ -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)
+10 -10
View File
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
// A Nexus outage during a Hexis act writes a failure trace, and a shared
// store is the one thing the Praxis path could inherit it through.
nexus.SetFault(503)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); 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)
}
}
+2 -2
View File
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
nexus.SetFault(503)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if 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)
}
}
+16 -4
View File
@@ -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"
+1 -1
View File
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply)
}
+7 -1
View File
@@ -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
View File
@@ -119,7 +119,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
if len(res.Hosts) == 0 {
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
}
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
@@ -180,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
@@ -211,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
}
}
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
// 5+ устройств, and the teens are all the last form.
func hostWord(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "устройств"
}
switch n % 10 {
case 1:
return "устройство"
case 2, 3, 4:
return "устройства"
default:
return "устройств"
}
}
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
-33
View File
@@ -10,8 +10,6 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
@@ -76,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{
"какие устройства в сети?",
@@ -171,22 +157,3 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
}
}
// An unconfigured scanner names the gap instead of declining the turn.
//
// Falling through sent "какие устройства в сети?" to the search leg, which
// answered with a paragraph about routers in general — and put a question about
// his own LAN on an upstream engine, which the personal boundary exists to
// prevent (Vikunja #479).
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
h := &reactiveHandler{}
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "какие устройства в сети?"},
})
if !ok {
t.Fatal("an unconfigured scanner let the question fall through to search")
}
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
t.Errorf("got %q, want the gap named", reply)
}
}
+2 -2
View File
@@ -189,7 +189,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
// выключено" would be a claim about the house she cannot make.
return phraser.A(phraser.HomeDark, map[string]string{
"count": strconv.Itoa(dark),
"word": 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
}
-3
View File
@@ -379,9 +379,6 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
// question first and a narrative request second (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+20 -30
View File
@@ -3,11 +3,8 @@ package memory
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
@@ -340,14 +337,13 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd]
if len(acts) > 0 {
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
}
if len(p.Everyday) > 0 {
return say.S(say.HabitWeekdaySame, map[string]string{
"day": day, "items": joinActivities(p.Everyday),
})
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
day, joinActivities(p.Everyday))
}
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
}
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
@@ -359,17 +355,19 @@ func (p Profile) FormatWeekendRU() string {
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch {
case len(sat) > 0 && len(sun) > 0:
return say.S(say.HabitWeekendBoth, map[string]string{
"sat": joinActivities(sat), "sun": joinActivities(sun),
})
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
joinActivities(sat), joinActivities(sun))
case len(sat) > 0:
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
joinActivities(sat))
case len(sun) > 0:
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
joinActivities(sun))
case len(p.Everyday) > 0:
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
joinActivities(p.Everyday))
}
return say.S(say.HabitWeekendNone, nil)
return "по выходным я пока не вижу у тебя ничего постоянного."
}
// FormatOverallRU reads back the habits that hold across the whole week, and
@@ -380,23 +378,19 @@ func (p Profile) FormatWeekendRU() string {
// a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 {
return say.S(say.HabitOverallNone, nil)
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
}
return say.S(say.HabitOverall, map[string]string{
"items": joinActivities(p.All), "span": p.spanRU(),
})
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
}
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days.
func (p Profile) spanRU() string {
if p.Since.IsZero() || !p.Until.After(p.Since) {
return say.S(say.HabitSpanToday, nil)
return "по записям за сегодня"
}
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),
})
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
}
// pluralDaysRU — the Russian count form of "день" for n.
@@ -429,18 +423,14 @@ func joinActivities(acts []Activity) string {
// come from the model, so an unglossed one is as likely to be
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
// not a sentence.
gloss = say.S(say.HabitUnglossed, map[string]string{
"key": strings.ReplaceAll(a.Key, "_", " "),
})
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
}
if !a.HasTypical {
parts[i] = gloss
continue
}
parts[i] = say.S(say.HabitAt, map[string]string{
"gloss": gloss,
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
})
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
}
if len(parts) == 1 {
return parts[0]
+2 -6
View File
@@ -5,8 +5,6 @@ import (
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/say"
)
// habitHistory — n weeks of the same weekday, at the given local time.
@@ -72,7 +70,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
t.Errorf("empty profile reads %q", got)
}
}
@@ -205,9 +203,7 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
// A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday)
if !say.IsS(say.HabitWeekdaySame, map[string]string{
"day": "средам", "items": "пьёшь воду около 13:30",
}, wed) {
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
}
}
+4 -8
View File
@@ -6,7 +6,6 @@ import (
"strings"
"time"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -165,20 +164,17 @@ func (p Plan) FormatRU() string {
// it is over, and saying it was empty is a false statement about a day
// he just lived.
if p.Rest {
return say.S(say.PlanRestEmpty, nil)
return "на сегодня больше ничего не запланировано."
}
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain {
line = say.S(say.PlanUncertain, map[string]string{"line": line})
line = "похоже, " + line
}
parts[i] = line
}
return say.S(say.PlanDay, map[string]string{
"date": p.Date.Format("02.01.2006"),
"items": strings.Join(parts, "; "),
})
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
}
+10 -12
View File
@@ -16,8 +16,6 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed ack_ru_v1.json
@@ -67,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 = say.RegisterFloor(map[string]string{
var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
@@ -92,15 +90,15 @@ 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 }
type Acks struct{ d *deck }
// LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) {
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
if err != nil {
return nil, err
}
@@ -111,7 +109,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -119,9 +117,9 @@ 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 {
func (a *Acks) deck() *deck {
if a == nil {
return nil
return floorDeck(ackFloor)
}
return a.d
}
@@ -129,11 +127,11 @@ func (a *Acks) deck() *say.Deck {
// Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string {
return a.deck().Text(key, vars)
return a.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acks) Variants() []string { return a.deck().Variants() }
func (a *Acks) Variants() []string { return a.deck().variants() }
var (
ackOnce sync.Once
@@ -160,5 +158,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
return DefaultAcks().deck().Matches(key, vars, text)
return DefaultAcks().deck().matches(key, vars, text)
}
+86
View File
@@ -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
}
}
}
+32 -30
View File
@@ -16,8 +16,6 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed acts_ru_v1.json
@@ -74,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 = 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,22 +104,22 @@ 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 }
type Acts struct{ d *deck }
// LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) {
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src)
if err != nil {
return nil, err
}
@@ -128,14 +128,16 @@ 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}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -143,20 +145,20 @@ 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 {
func (a *Acts) deck() *deck {
if a == nil {
return nil
return floorDeck(actFloor)
}
return a.d
}
// Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string {
return a.deck().Text(key, vars)
return a.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acts) Variants() []string { return a.deck().Variants() }
func (a *Acts) Variants() []string { return a.deck().variants() }
var (
actOnce sync.Once
@@ -182,5 +184,5 @@ func A(key string, vars map[string]string) string { return DefaultActs().Say(key
// IsA reports whether text is a line key could have produced, for the tests.
func IsA(key string, vars map[string]string, text string) bool {
return DefaultActs().deck().Matches(key, vars, text)
return DefaultActs().deck().matches(key, vars, text)
}
+26 -22
View File
@@ -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}."]
}
}
}
@@ -1,9 +1,6 @@
package say
package phraser
// Package say holds the mechanics every family of hand-written Russian lines
// shares. The families themselves live next to the code that speaks them.
//
// Deck — the mechanics every family of hand-written Russian lines shares.
// deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
@@ -23,28 +20,28 @@ import (
"time"
)
// Entry — one line she can say, in as many wordings as the file gives.
type Entry struct {
// deckEntry — one line she can say, in as many wordings as the file gives.
type deckEntry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type file struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]Entry `json:"entries"`
type deckFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
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.
type Deck struct {
// 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
last map[string]string
file file
file deckFile
keys []string
floor map[string]string
}
@@ -52,8 +49,8 @@ type Deck struct {
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
var f file
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
var f deckFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
@@ -72,13 +69,13 @@ func Load(raw []byte, version int, keys []string, floor map[string]string, src r
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *Deck) RequirePlaceholder(key, ph string) error {
func (d *deck) requirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
@@ -89,11 +86,11 @@ func (d *Deck) RequirePlaceholder(key, ph string) error {
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
func (d *Deck) Text(key string, vars map[string]string) string {
func (d *deck) text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, e)
tmpl = d.pick(key, fillable(e.Variants, vars))
}
}
if tmpl == "" {
@@ -104,7 +101,7 @@ func (d *Deck) Text(key string, vars map[string]string) string {
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
func (d *deck) matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
@@ -121,7 +118,7 @@ func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *Deck) Variants() []string {
func (d *deck) variants() []string {
if d == nil {
return nil
}
@@ -132,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 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 +206,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.
func floorOf(d *Deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
// floorOf reads the Go literal behind key. Every deck carries its own family's
// map, including the floor-only deck an unloadable file leaves behind, so no
// lookup ever crosses families. It used to go through one global map keyed by
// bare entry name, which two families both calling an entry query_unknown
// silently shared: whichever registered last answered for both (Vikunja #521).
func floorOf(d *deck, key string) string {
if d == nil {
return ""
}
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
+4 -12
View File
@@ -6,13 +6,11 @@ import (
"testing"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/say"
)
// TestFallbackPersona scores every line in every hand-written family on the
// persona checks the nudges already pass. These lines are heard out loud and
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
// would otherwise reach him with nothing in between.
// TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are
// heard out loud and they live in a JSON file now, so a reworded variant that
// says "рад" or "вы" would otherwise reach him with nothing in between.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges.
@@ -44,11 +42,6 @@ func TestFallbackPersona(t *testing.T) {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
sum, err := say.LoadSummaries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadSummaries: %v", err)
}
variants = append(variants, sum.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
@@ -57,8 +50,7 @@ func TestFallbackPersona(t *testing.T) {
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}",
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
"{entity}", "{count}", "{word}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
+14 -16
View File
@@ -15,8 +15,6 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed fallbacks_ru_v1.json
@@ -41,56 +39,56 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var hardFloor = 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 }
type Fallbacks struct{ d *deck }
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
if err != nil {
return nil, err
}
// query_sources is the one entry whose whole job is to read something back.
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
return nil, err
}
return &Fallbacks{d: d}, nil
}
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *say.Deck {
func (f *Fallbacks) deck() *deck {
if f == nil {
return nil
return floorDeck(hardFloor)
}
return f.d
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
// Variants returns every line the file can produce, for the persona scorer.
func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
@@ -131,10 +129,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() }
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
}
+47
View File
@@ -0,0 +1,47 @@
package phraser
// The counted noun, in the form the number in front of it demands.
//
// Russian inflects a noun after a numeral, and the form depends on the last two
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
// A line file cannot spell that out, so a count in a template splits into two
// placeholders — the number, and {word} filled from here.
//
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
// weather line said "градусов" for every temperature and was wrong for 1-4 and
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
import "math"
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
// for 0, 5-20 and anything ending in those. A negative count reads its own
// magnitude, since minus does not change the noun: -2 градуса.
func CountWord(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if n%100 >= 11 && n%100 <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
// weather provider and counts by the whole degrees she is about to say, so the
// noun agrees with the number in the same sentence rather than with the reading
// behind it.
func Degrees(temp float64) string {
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
}
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string {
return CountWord(n, "устройство", "устройства", "устройств")
}
+32
View File
@@ -0,0 +1,32 @@
package phraser
import "testing"
// The bug the helper exists for: the weather line said "градусов" for every
// reading, which is wrong for 1-4 and for every number ending in 1-4.
func TestDegreesAgreeWithTheReading(t *testing.T) {
for temp, want := range map[float64]string{
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
22: "градуса", 25: "градусов", 101: "градус",
// Minus does not change the noun, and a reading rounds to the number
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
} {
if got := Degrees(temp); got != want {
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
}
}
}
func TestDevicesAgreeWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := Devices(n); got != want {
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
}
}
}
+23 -26
View File
@@ -16,8 +16,6 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed query_ru_v1.json
@@ -45,8 +43,6 @@ const (
QueryWeatherOff = "weather_off"
QueryWeatherWhere = "weather_nolocation"
QueryNetEmpty = "net_empty"
QueryNetOff = "net_off"
QueryPageOff = "page_off"
QueryFailPlan = "fail_plan"
QueryFailNotes = "fail_notes"
@@ -62,33 +58,33 @@ var queryKeys = []string{
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
}
// 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 не разрешает.",
QueryPageEmpty: "страница открылась, но читать там нечего.",
QueryFeedsOff: "я пока не читаю ленты — они не настроены.",
QueryFeedsOff: "ленты не настроены.",
QueryFeedsNew: "вот что нового: {items}",
QueryFeedsEmpty: "в лентах пока ничего нового.",
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
QueryNetEmpty: "в сети никого не нашла{tail}.",
QueryNetOff: "сканирование сети не настроено.",
QueryPageOff: "я не читаю страницы — это не настроено.",
QueryWeatherWhere: "для какого города?",
QueryNetEmpty: "в сети никого не нашла.",
QueryFailPlan: "не получилось собрать план.",
QueryFailNotes: "не получилось посмотреть записи.",
@@ -98,15 +94,15 @@ 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 }
type Queries struct{ d *deck }
// LoadQueries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadQueries(src rand.Source) (*Queries, error) {
d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
if err != nil {
return nil, err
}
@@ -115,9 +111,10 @@ 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 {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -125,20 +122,20 @@ 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 {
func (q *Queries) deck() *deck {
if q == nil {
return nil
return floorDeck(queryFloor)
}
return q.d
}
// Say returns one line for key, with the values filled into the frame.
func (q *Queries) Say(key string, vars map[string]string) string {
return q.deck().Text(key, vars)
return q.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (q *Queries) Variants() []string { return q.deck().Variants() }
func (q *Queries) Variants() []string { return q.deck().variants() }
var (
queryOnce sync.Once
@@ -164,5 +161,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say(
// IsQ reports whether text is a line key could have produced, for the tests.
func IsQ(key string, vars map[string]string, text string) bool {
return DefaultQueries().deck().Matches(key, vars, text)
return DefaultQueries().deck().matches(key, vars, text)
}
+107
View File
@@ -0,0 +1,107 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
func TestQueriesLoad(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, k := range queryKeys {
if got := q.Say(k, nil); got == "" {
t.Errorf("%s says nothing", k)
}
}
}
// The bug: net_empty carried {tail} in every variant, and a scan that finished
// the whole range has no caveat to put there. Whatever the file says, an answer
// he can hear has to come out — never braces, never nothing.
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, vars := range []map[string]string{nil, {"tail": ""}} {
for i := 0; i < 20; i++ {
got := q.Say(QueryNetEmpty, vars)
if got == "" || strings.ContainsAny(got, "{}") {
t.Fatalf("net_empty with vars %v said %q", vars, got)
}
}
}
}
// The other half: a caveat he was given is not dropped for a shorter wording.
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
const tail = ", но успела посмотреть не всю сеть"
for i := 0; i < 20; i++ {
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
t.Fatalf("net_empty dropped the tail: %q", got)
}
}
}
// query_unknown means she looked and found nothing. The phraser's fallback
// means she failed to phrase an answer she had. Two causes, two sentences, or
// the distinction the two files exist for is unobservable from the outside.
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
f, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
failures := map[string]bool{}
for _, v := range f.Variants() {
failures[v] = true
}
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
if failures[v] {
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
}
}
}
// The weather line splits the count into a number and a noun, so a variant that
// says the temperature without {word} is the hardcoded "градусов" coming back.
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
if strings.Contains(v, "градус") {
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
}
}
got := q.Say(QueryWeatherNow, map[string]string{
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
})
if !strings.Contains(got, "1 градус,") {
t.Errorf("weather_now said %q, want the singular noun", got)
}
}
// No line spoken to him names a config key. She asks instead.
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.Variants() {
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
t.Errorf("variant %q says a config path out loud", v)
}
}
}
+20 -26
View File
@@ -5,13 +5,15 @@
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.",
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.",
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
],
"entries": {
"query_unknown": {
"variants": [е знаю.", "не нашла ничего.", "ничего не нашла."]
"variants": [ичего не нашла.", "искала — не нашла."]
},
"other_day": {
"fixed": true,
@@ -22,10 +24,10 @@
"variants": ["не знаю — не нашла у тебя такой записи."]
},
"fact_when": {
"variants": ["я записала это {when}", "записала это {when}"]
"variants": ["записала это {when}"]
},
"fact_value": {
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
@@ -38,10 +40,10 @@
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
},
"page_empty": {
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
"variants": ["страница открылась, но читать там нечего."]
},
"feeds_off": {
"variants": ["я пока не читаю ленты — они не настроены."]
"variants": ["ленты не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
@@ -53,49 +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 или назови город."]
},
"net_off": {
"fixed": true,
"variants": ["сканирование сети не настроено."]
},
"page_off": {
"fixed": true,
"variants": ["я не читаю страницы — это не настроено."]
"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": ["не получилось просканировать сеть."]
}
}
}
-37
View File
@@ -75,40 +75,3 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
}
}
}
// The two shapes that carried no question mark and no interrogative, so the
// model saw them first and called them facts (Vikunja #498).
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
for _, u := range []string{
"что дальше?",
"и что там дальше",
"what's next?",
"расскажи про битву при Ватерлоо",
"объясни как работает дизель",
"опиши Ватерлоо",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("%q: %v", u, err)
}
if d.Intent != IntentQuery || d.Stage != 0 {
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
}
}
}
// A narrative verb next to a capture verb is him asking for a note. Stage 0
// declines and the extractor gets its turn.
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
if err != nil {
t.Fatal(err)
}
if d.Stage == 0 && d.Intent == IntentQuery {
t.Errorf("stage 0 claimed a capture: %+v", d)
}
}
-1
View File
@@ -236,7 +236,6 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
-2
View File
@@ -28,8 +28,6 @@
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
-67
View File
@@ -185,73 +185,6 @@ func AgendaQueryGrammars() []Grammar {
}
}
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
// carry no question mark and no interrogative, and so reached the resident
// model with nothing deterministic in front of them (Vikunja #498).
//
// Both were routed IntentFact by the model. The fact gate catches the write and
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
// model round trip to reach a decision two patterns can make offline, and a
// wrong row on the routing fixture.
//
// Wired after the agenda grammars, which is where their overlap resolves:
// "расскажи, что у меня сегодня" is claimed here as a query either way.
func NarrativeQueryGrammars() []Grammar {
return []Grammar{
{
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
// recognises it downstream in the query chain, but that runs after
// the routing decision, and the routing decision was fact.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "расскажи про X" — a world question phrased as an instruction.
// The lexicon is narrativeRequests, already written for the
// question-shaped test in question.go.
//
// Anchored at the start: "запиши что мне рассказали" is a capture,
// and a narrative verb buried mid-utterance is not the shape.
Name: "narrative-query",
Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`),
Build: narrativeQueryBuild,
},
}
}
// entertainmentNouns — what "расскажи" asks for when it is not asking for
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
// something up, which is the one case where inventing is the right answer
// (fixture ru-chat-003).
var entertainmentNouns = []string{
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
"joke", "jokes", "story",
}
// narrativeQueryBuild — the narrative shape is a query unless he also said one
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
// asking for a note, and stage 0 must not take either off the cascade.
func narrativeQueryBuild(m []string) (Decision, bool) {
rest := ""
if len(m) > 3 {
rest = m[3]
}
for _, t := range planTokens(rest) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
}
}
for _, v := range entertainmentNouns {
if t == v {
return Decision{}, false
}
}
}
return agendaQueryBuild(m)
}
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
// matchers decide the rest.
-191
View File
@@ -1,191 +0,0 @@
package say
// The summary sentences — what she says around aggregated data: the morning
// plan, the ranked task list, and the habits read back out of behaviour records.
//
// Fifth family on the deck, and the first one outside internal/phraser. It
// lives here because its three callers — internal/morning, internal/tasks and
// internal/memory — sit under phraser in the import graph and cannot reach it.
//
// The "I have not seen enough yet" sentences are the load-bearing ones. Three
// days of taps and a year of them produce the same "обычно ты ...", and only one
// of those is worth believing, so the empty cases say she has not seen a
// pattern rather than that he has none.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed summary_ru_v1.json
var summaryJSON []byte
// SummarySchemaVersion — this family's own version.
const SummarySchemaVersion = 1
// The entry keys.
const (
PlanRestEmpty = "plan_rest_empty"
PlanDayEmpty = "plan_day_empty"
PlanDay = "plan_day"
PlanUncertain = "plan_uncertain"
TasksNone = "tasks_none"
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
ReasonOverdue = "reason_overdue"
ReasonOverdueDay = "reason_overdue_day"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
ReasonTomorrow = "reason_tomorrow"
ReasonInDays = "reason_in_days"
ReasonImportant = "reason_important"
ReasonUrgent = "reason_urgent"
ReasonStale = "reason_stale"
HabitWeekday = "habit_weekday"
HabitWeekdaySame = "habit_weekday_same"
HabitWeekdayNone = "habit_weekday_none"
HabitWeekendBoth = "habit_weekend_both"
HabitWeekendSat = "habit_weekend_sat"
HabitWeekendSun = "habit_weekend_sun"
HabitWeekendSame = "habit_weekend_same"
HabitWeekendNone = "habit_weekend_none"
HabitOverall = "habit_overall"
HabitOverallNone = "habit_overall_none"
HabitSpanToday = "habit_span_today"
HabitSpanDays = "habit_span_days"
HabitUnglossed = "habit_unglossed"
HabitAt = "habit_at"
)
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays,
HabitUnglossed, HabitAt,
}
// summaryFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var summaryFloor = RegisterFloor(map[string]string{
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}.",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}.",
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
ReasonOverdue: "просрочено",
ReasonOverdueDay: "просрочено на день",
ReasonOverdueDays: "просрочено на {n} дн.",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} дн.",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
HabitOverall: "обычно ты {items} — {span}.",
HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.",
HabitSpanToday: "по записям за сегодня",
HabitSpanDays: "по записям за последние {n} {word}",
HabitUnglossed: "отмечаешь «{key}»",
HabitAt: "{gloss} около {time}",
})
// Summaries picks a hand-written Russian summary sentence. Safe for concurrent
// use.
type Summaries struct{ d *Deck }
// LoadSummaries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadSummaries(src rand.Source) (*Summaries, error) {
d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src)
if err != nil {
return nil, err
}
// The entries that exist to read the aggregate back. A variant without the
// placeholder would summarise the data by dropping it.
for _, req := range []struct{ key, ph string }{
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
{HabitWeekendSame, "{items}"},
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
{HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"},
{HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Summaries{d: d}, nil
}
// deck reads through a nil *Summaries, which is the unloadable-file case.
func (s *Summaries) deck() *Deck {
if s == nil {
return nil
}
return s.d
}
// Say returns one line for key, with the values filled into the frame.
func (s *Summaries) Say(key string, vars map[string]string) string {
return s.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (s *Summaries) Variants() []string { return s.deck().Variants() }
var (
summaryOnce sync.Once
summaries *Summaries
)
// DefaultSummaries returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Summaries, which still answers from
// summaryFloor.
func DefaultSummaries() *Summaries {
summaryOnce.Do(func() {
s, err := LoadSummaries(nil)
if err != nil {
log.Printf("say: summary lines unavailable, using the built-in ones: %v", err)
return
}
summaries = s
})
return summaries
}
// S — one summary sentence, the way every caller says it.
func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) }
// IsS reports whether text is a line key could have produced, for the tests.
func IsS(key string, vars map[string]string, text string) bool {
return DefaultSummaries().deck().Matches(key, vars, text)
}
-141
View File
@@ -1,141 +0,0 @@
{
"schema_version": 1,
"name": "russian summary sentences v1",
"notes": [
"The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.",
"Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.",
"The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.",
"fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed."
],
"entries": {
"plan_rest_empty": {
"fixed": true,
"variants": ["на сегодня больше ничего не запланировано."]
},
"plan_day_empty": {
"fixed": true,
"variants": ["на {date} ничего не запланировано."]
},
"plan_day": {
"fixed": true,
"variants": ["план на {date}: {items}."]
},
"plan_uncertain": {
"fixed": true,
"variants": ["похоже, {line}"]
},
"tasks_none": {
"fixed": true,
"variants": ["задач нет."]
},
"tasks_first": {
"fixed": true,
"variants": ["сначала: {items}."]
},
"tasks_candidates": {
"fixed": true,
"variants": ["ещё я нашла, но ты не подтвердил: {items}."]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
},
"reason_overdue_day": {
"fixed": true,
"variants": ["просрочено на день"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} дн."]
},
"reason_today": {
"fixed": true,
"variants": ["сегодня"]
},
"reason_tomorrow": {
"fixed": true,
"variants": ["завтра"]
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} дн."]
},
"reason_important": {
"fixed": true,
"variants": ["важно"]
},
"reason_urgent": {
"fixed": true,
"variants": ["срочно"]
},
"reason_stale": {
"fixed": true,
"variants": ["давно в списке"]
},
"habit_weekday": {
"fixed": true,
"variants": ["по {day} ты обычно {items}."]
},
"habit_weekday_same": {
"variants": [
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_weekend_both": {
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
},
"habit_weekend_sat": {
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
},
"habit_weekend_sun": {
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
},
"habit_weekend_same": {
"variants": [
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekend_none": {
"variants": [
"по выходным я пока не вижу у тебя ничего постоянного.",
"по выходным у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_overall": {
"variants": ["обычно ты {items} — {span}."]
},
"habit_overall_none": {
"variants": [
"я ещё не набрала достаточно записей, чтобы говорить о привычках.",
"записей пока мало — на привычки я так не сошлюсь."
]
},
"habit_span_today": {
"variants": ["по записям за сегодня"]
},
"habit_span_days": {
"variants": ["по записям за последние {n} {word}"]
},
"habit_unglossed": {
"fixed": true,
"variants": ["отмечаешь «{key}»"]
},
"habit_at": {
"fixed": true,
"variants": ["{gloss} около {time}"]
}
}
}
-49
View File
@@ -1,49 +0,0 @@
package say
import (
"math/rand"
"strings"
"testing"
)
// The file has to load, and every key the code names has to be in it.
func TestSummariesLoad(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range summaryKeys {
if got := s.Say(key, nil); got == "" {
t.Errorf("%s says nothing", key)
}
}
}
// A nil *Summaries is the unloadable-file case, and it must still speak. The
// habit sentences are the ones that matter here: falling back must not turn
// "I have not seen enough" into silence.
func TestNilSummariesAnswerFromTheFloor(t *testing.T) {
var s *Summaries
if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") {
t.Errorf("the floor dropped the date: %q", got)
}
}
// The empty cases claim she has not seen enough, never that he has no habits.
// Every variant has to hold that line, since the picker treats them as equals.
func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} {
for _, v := range s.d.file.Entries[key].Variants {
if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") {
t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v)
}
}
}
}
+16 -19
View File
@@ -19,11 +19,8 @@ package tasks
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
@@ -120,21 +117,21 @@ func score(it Item, now time.Time) (float64, string) {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = say.S(say.ReasonOverdue, nil)
reason = "просрочено"
if late == 1 {
reason = say.S(say.ReasonOverdueDay, nil)
reason = "просрочено на день"
} else if late > 1 {
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
reason = fmt.Sprintf("просрочено на %d дн.", late)
}
case days == 0:
total += scoreDueToday
reason = say.S(say.ReasonToday, nil)
reason = "сегодня"
case days == 1:
total += scoreDueTomorrow
reason = say.S(say.ReasonTomorrow, nil)
reason = "завтра"
case days <= 7:
total += scoreDueWeek
reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
reason = fmt.Sprintf("через %d дн.", days)
default:
total += scoreDueLater
}
@@ -150,9 +147,9 @@ func score(it Item, now time.Time) (float64, string) {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
reason = say.S(say.ReasonImportant, nil)
reason = "важно"
if w >= MaxWeight {
reason = say.S(say.ReasonUrgent, nil)
reason = "срочно"
}
}
}
@@ -166,7 +163,7 @@ func score(it Item, now time.Time) (float64, string) {
}
total += age
if reason == "" && weeks >= 2 {
reason = say.S(say.ReasonStale, nil)
reason = "давно в списке"
}
}
}
@@ -213,22 +210,22 @@ func FormatRU(ranked []Ranked) string {
}
}
if len(open) == 0 && len(cands) == 0 {
return say.S(say.TasksNone, nil)
return "задач нет."
}
var b strings.Builder
if len(open) > 0 {
b.WriteString(say.S(say.TasksFirst, map[string]string{
"items": joinRU(open, SpokenLimit, true),
}))
b.WriteString("сначала: ")
b.WriteString(joinRU(open, SpokenLimit, true))
b.WriteString(".")
}
if len(cands) > 0 {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString(say.S(say.TasksCandidates, map[string]string{
"items": joinRU(cands, SpokenLimit, false),
}))
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(joinRU(cands, SpokenLimit, false))
b.WriteString(".")
}
return b.String()
}