Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fba4d6931 | |||
| 12c18dcf65 | |||
| a286865fe5 | |||
| 28c0ff73bd | |||
| 761cf9f3e0 | |||
| 6a9d8a4dd5 | |||
| 4c95b200e4 | |||
| 6ae1312ff1 | |||
| 1c8a32c3fd | |||
| 96b474223d | |||
| 42d7a39c49 | |||
| bad3fa4035 | |||
| d819fc09f0 | |||
| c35979d9f9 | |||
| f3c0540b42 | |||
| 5b4192acb5 |
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
@@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||
// The row is enabled and the backend is gone. Drafting a proposal
|
||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||
// question.
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
return phraser.A(phraser.ActServerDown, nil)
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
return phraser.A(phraser.ActWithdrawn, nil)
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "готово."
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
}
|
||||
|
||||
@@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
// The recogniser already matched, so this is a question about HIS LAN
|
||||
// and there is no scanner to answer it. Falling through sent it to the
|
||||
// search leg, which answered with a paragraph about routers in general
|
||||
// and put his network question on an upstream engine (Vikunja #479).
|
||||
// A missing capability names itself.
|
||||
return phraser.Q(phraser.QueryNetOff, nil), true
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
@@ -525,12 +528,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||
// where it was never turned on the older behaviour is right: the model
|
||||
// answers the question as if the URL had not been said. Announcing a
|
||||
// configuration status is for a capability that exists and failed, not
|
||||
// for one he never asked for.
|
||||
return "", false
|
||||
// He named a URL, so the question is about that page and nothing else
|
||||
// can answer it. The older comment here argued for falling through and
|
||||
// letting the model answer as if the URL had not been said; that is a
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
if !strings.Contains(reply, "не подтверждал") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon where page reading was never turned on — the default — answers the
|
||||
// question the way it did before the capability existed. Claiming the turn to
|
||||
// report a configuration status is for something that exists and failed.
|
||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||
// A daemon where page reading was never turned on names the gap. He asked
|
||||
// about one page, nothing else on the box can read it, and the old behaviour
|
||||
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||
if !ok {
|
||||
t.Fatal("an unconfigured crawler let the page question fall through")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
||||
t.Errorf("got %q, want the gap named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,16 +165,17 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||
// reporting a generic failure.
|
||||
// robots.txt is honoured on the answer path too, and she says the page is
|
||||
// closed instead of reporting a generic failure.
|
||||
func TestQueryWebObeysRobots(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
// She names the cause without reading a filename out loud.
|
||||
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,24 @@ import (
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -144,10 +158,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "ничего не требует внимания."
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
@@ -157,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 != "" {
|
||||
@@ -175,7 +194,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
}
|
||||
}
|
||||
return "требует внимания: " + strings.Join(parts, "; ")
|
||||
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, "; ")})
|
||||
}
|
||||
|
||||
// listChangesCapability reads the recent-changes feed.
|
||||
@@ -192,19 +217,29 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
return phraser.A(phraser.ChangesFail, nil)
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return "нет изменений."
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
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))
|
||||
}
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
if len(parts) == 0 {
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
}
|
||||
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
@@ -230,12 +265,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return "про что именно спросить?"
|
||||
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||
// than quietly answering about something else.
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
return phraser.A(phraser.EcoNoNexus, nil)
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
@@ -248,15 +283,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
}
|
||||
if entityID == "" {
|
||||
return "не знаю такой сущности."
|
||||
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
@@ -268,7 +303,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
@@ -279,7 +314,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
@@ -303,9 +338,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
@@ -370,7 +405,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
@@ -520,18 +555,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, 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 "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
@@ -550,9 +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 "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -584,7 +619,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
for _, m := range matches {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||
}
|
||||
matched := matches[0]
|
||||
|
||||
@@ -602,7 +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 "выполнить «" + matched.Name + "» для " + 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)
|
||||
@@ -622,7 +657,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
@@ -630,5 +665,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return "команда выполнена для " + displayName + "."
|
||||
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||
// store is the one thing the Praxis path could inherit it through.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
|
||||
nexus.SetFault(503)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
if !actRan(reply) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
// The follow-up "да" turn executes exactly the parked capability.
|
||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
||||
if !handled || !actRan(confirmReply) {
|
||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||
}
|
||||
if !*executed {
|
||||
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
||||
if h.pendingHexis != nil {
|
||||
t.Fatal("read-only cap should not park a confirmation")
|
||||
}
|
||||
if !strings.Contains(reply, "выполнена") {
|
||||
if !actRan(reply) {
|
||||
t.Fatalf("unexpected reply %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
||||
if reply == "" {
|
||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
if actRan(reply) {
|
||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -241,3 +242,14 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
||||
t.Fatal("not_found resolution must never execute a hexis capability")
|
||||
}
|
||||
}
|
||||
|
||||
// actRan — the reply is the line she says when a capability ran against an
|
||||
// entity. The tests used to look for the substring "выполнена", which was a
|
||||
// literal out of the act file: the review reworded that line to "готово: {name}"
|
||||
// and seventeen assertions went with it (Vikunja #521).
|
||||
func actRan(reply string) bool {
|
||||
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
|
||||
}
|
||||
|
||||
// muzickIndexer — the display name every ecosystem fixture resolves to.
|
||||
const muzickIndexer = "Muzick indexer"
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -157,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
|
||||
// An unconfigured scanner names the gap instead of declining the turn.
|
||||
//
|
||||
// Falling through sent "какие устройства в сети?" to the search leg, which
|
||||
// answered with a paragraph about routers in general — and put a question about
|
||||
// his own LAN on an upstream engine, which the personal boundary exists to
|
||||
// prevent (Vikunja #479).
|
||||
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||
h := &reactiveHandler{}
|
||||
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an unconfigured scanner let the question fall through to search")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
||||
t.Errorf("got %q, want the gap named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-20
@@ -4,6 +4,10 @@
|
||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||
// Extend this file rather than voice.go for anything in that shape.
|
||||
//
|
||||
// Count agreement is not here. It is say.CountWord, because there were four
|
||||
// copies of the same three-way rule and two of the sites that needed it were
|
||||
// spelling one form out (Vikunja #521).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +15,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
var ruWeekdays = []string{
|
||||
@@ -94,11 +100,11 @@ func mentionsUnknownDay(u string) bool {
|
||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||
func ruClock(t time.Time) string {
|
||||
h, m := t.Hour(), t.Minute()
|
||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
||||
hourWord := say.CountWord(h, "час", "часа", "часов")
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||
}
|
||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
|
||||
}
|
||||
|
||||
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||
@@ -118,22 +124,6 @@ func dayPrefix(now, day time.Time) string {
|
||||
return "это"
|
||||
}
|
||||
|
||||
func ruPlural(n int, one, two, many string) string {
|
||||
n = n % 100
|
||||
if n > 10 && n < 20 {
|
||||
return many
|
||||
}
|
||||
n = n % 10
|
||||
switch n {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return two
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||
// rather than the current clock — guards replySystem from replying "сейчас
|
||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||
@@ -169,11 +159,13 @@ func formatTime(t time.Time) string {
|
||||
case diff < 10*time.Minute:
|
||||
return "несколько минут назад"
|
||||
case diff < 60*time.Minute:
|
||||
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
||||
n := int(diff.Minutes())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
|
||||
case diff < 2*time.Hour:
|
||||
return "час назад"
|
||||
case diff < 24*time.Hour:
|
||||
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -141,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: summary: %v", err)
|
||||
return "не смогла достучаться до дома.", true
|
||||
return phraser.A(phraser.HomeUnreachable, nil), true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return "дом ничего не отдаёт.", true
|
||||
return phraser.A(phraser.HomeEmpty, nil), true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
@@ -178,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
}
|
||||
// Silent truncation on a status read is the same failure as the cap
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
@@ -186,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true
|
||||
return phraser.A(phraser.HomeDark, map[string]string{
|
||||
"count": strconv.Itoa(dark),
|
||||
"word": phraser.Devices(dark),
|
||||
}), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
|
||||
+6
-1
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -627,7 +628,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||
// The adjective declines with the noun, so the count picks the whole
|
||||
// phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
|
||||
// отложенных уведомлений.
|
||||
summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
|
||||
"отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
|
||||
@@ -379,6 +379,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
||||
// question first and a narrative request second (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
|
||||
+30
-34
@@ -3,8 +3,11 @@ package memory
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||
@@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
day := weekdayRU[int(wd)%7]
|
||||
acts := p.Weekly[wd]
|
||||
if len(acts) > 0 {
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
|
||||
}
|
||||
if len(p.Everyday) > 0 {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
return say.S(say.HabitWeekdaySame, map[string]string{
|
||||
"day": day, "items": joinActivities(p.Everyday),
|
||||
})
|
||||
}
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
|
||||
}
|
||||
|
||||
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
||||
@@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string {
|
||||
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||
switch {
|
||||
case len(sat) > 0 && len(sun) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
||||
joinActivities(sat), joinActivities(sun))
|
||||
return say.S(say.HabitWeekendBoth, map[string]string{
|
||||
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
|
||||
})
|
||||
case len(sat) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
||||
joinActivities(sat))
|
||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||
case len(sun) > 0:
|
||||
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
||||
joinActivities(sun))
|
||||
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
||||
case len(p.Everyday) > 0:
|
||||
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
joinActivities(p.Everyday))
|
||||
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
||||
}
|
||||
return "по выходным я пока не вижу у тебя ничего постоянного."
|
||||
return say.S(say.HabitWeekendNone, nil)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
||||
@@ -378,33 +380,23 @@ func (p Profile) FormatWeekendRU() string {
|
||||
// a year of them, and only one of those is worth believing.
|
||||
func (p Profile) FormatOverallRU() string {
|
||||
if len(p.All) == 0 {
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
return say.S(say.HabitOverallNone, nil)
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
|
||||
return say.S(say.HabitOverall, map[string]string{
|
||||
"items": joinActivities(p.All), "span": p.spanRU(),
|
||||
})
|
||||
}
|
||||
|
||||
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
||||
// is too short to name in days.
|
||||
func (p Profile) spanRU() string {
|
||||
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
||||
return "по записям за сегодня"
|
||||
return say.S(say.HabitSpanToday, nil)
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
||||
}
|
||||
|
||||
// pluralDaysRU — the Russian count form of "день" for n.
|
||||
func pluralDaysRU(n int) string {
|
||||
switch {
|
||||
case n%100 >= 11 && n%100 <= 14:
|
||||
return "дней"
|
||||
case n%10 == 1:
|
||||
return "день"
|
||||
case n%10 >= 2 && n%10 <= 4:
|
||||
return "дня"
|
||||
default:
|
||||
return "дней"
|
||||
}
|
||||
return say.S(say.HabitSpanDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||
})
|
||||
}
|
||||
|
||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||
@@ -423,14 +415,18 @@ func joinActivities(acts []Activity) string {
|
||||
// come from the model, so an unglossed one is as likely to be
|
||||
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
||||
// not a sentence.
|
||||
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||
gloss = say.S(say.HabitUnglossed, map[string]string{
|
||||
"key": strings.ReplaceAll(a.Key, "_", " "),
|
||||
})
|
||||
}
|
||||
if !a.HasTypical {
|
||||
parts[i] = gloss
|
||||
continue
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
parts[i] = say.S(say.HabitAt, map[string]string{
|
||||
"gloss": gloss,
|
||||
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
|
||||
})
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||
@@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||
}
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
|
||||
t.Errorf("empty profile reads %q", got)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
|
||||
t.Errorf("an unknown weekday reads %q", empty)
|
||||
}
|
||||
// Persona: she addresses him informally, never in the masculine about
|
||||
@@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
// A day with nothing of its own says so rather than reciting water as if
|
||||
// Wednesday were the reason for it.
|
||||
wed := p.FormatWeekdayRU(time.Wednesday)
|
||||
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
|
||||
if !say.IsS(say.HabitWeekdaySame, map[string]string{
|
||||
"day": "средам", "items": "пьёшь воду около 13:30",
|
||||
}, wed) {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
@@ -313,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||
// The no-data weekday answer is about his week, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
|
||||
// because what she is reporting is the state of her records — and it names the
|
||||
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
|
||||
// came out as filler, the day carries it).
|
||||
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
|
||||
p := BuildProfile(nil, behaviorNow())
|
||||
got := p.FormatWeekdayRU(time.Wednesday)
|
||||
if strings.Contains(got, "у меня") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "у тебя") {
|
||||
t.Errorf("got %q, want an answer about him", got)
|
||||
if !strings.Contains(got, "средам") {
|
||||
t.Errorf("got %q, want the day he asked about", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralDaysRU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := pluralDaysRU(c.n); got != c.want {
|
||||
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "по выходным" is a question about two days, answered as two days.
|
||||
func TestFormatWeekendRU(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -164,17 +165,20 @@ func (p Plan) FormatRU() string {
|
||||
// it is over, and saying it was empty is a false statement about a day
|
||||
// he just lived.
|
||||
if p.Rest {
|
||||
return "на сегодня больше ничего не запланировано."
|
||||
return say.S(say.PlanRestEmpty, nil)
|
||||
}
|
||||
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
for i, it := range p.Items {
|
||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||
if it.Uncertain {
|
||||
line = "похоже, " + line
|
||||
line = say.S(say.PlanUncertain, map[string]string{"line": line})
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||
return say.S(say.PlanDay, map[string]string{
|
||||
"date": p.Date.Format("02.01.2006"),
|
||||
"items": strings.Join(parts, "; "),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) {
|
||||
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"10:30 — утро — осталось: витамины; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||
"18:30 — позвонить маме."
|
||||
"18:30 — позвонить маме"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
@@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
|
||||
t.Fatalf("got %+v", p.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
|
||||
// front of what this loop already built. That reads as one sentence only while
|
||||
// what arrives starts lowercase, and it does here because every line starts with
|
||||
// the clock time. A capital after the hedge would be «похоже, Планёрка».
|
||||
func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
p := Plan{Date: now, Items: []PlanEntry{
|
||||
{At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
|
||||
}}
|
||||
got := p.FormatRU()
|
||||
const hedge = "похоже, "
|
||||
i := strings.Index(got, hedge)
|
||||
if i < 0 {
|
||||
t.Fatalf("%q does not hedge an uncertain item", got)
|
||||
}
|
||||
for _, r := range got[i+len(hedge):] {
|
||||
if unicode.IsUpper(r) {
|
||||
t.Fatalf("the hedge runs into a capital: %q", got)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
//go:embed ack_ru_v1.json
|
||||
@@ -93,12 +95,12 @@ var ackFloor = map[string]string{
|
||||
}
|
||||
|
||||
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
||||
type Acks struct{ d *deck }
|
||||
type Acks struct{ d *say.Deck }
|
||||
|
||||
// LoadAcks reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
||||
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
|
||||
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
|
||||
} {
|
||||
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
|
||||
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -117,9 +119,9 @@ func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
}
|
||||
|
||||
// deck reads through a nil *Acks, which is the unloadable-file case.
|
||||
func (a *Acks) deck() *deck {
|
||||
func (a *Acks) deck() *say.Deck {
|
||||
if a == nil {
|
||||
return floorDeck(ackFloor)
|
||||
return say.FloorDeck(ackFloor)
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
@@ -127,11 +129,11 @@ func (a *Acks) deck() *deck {
|
||||
// Say returns one line for key, with his data filled into the frame. Pass nil
|
||||
// when the entry takes none.
|
||||
func (a *Acks) Say(key string, vars map[string]string) string {
|
||||
return a.deck().text(key, vars)
|
||||
return a.deck().Text(key, vars)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (a *Acks) Variants() []string { return a.deck().variants() }
|
||||
func (a *Acks) Variants() []string { return a.deck().Variants() }
|
||||
|
||||
var (
|
||||
ackOnce sync.Once
|
||||
@@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k
|
||||
// IsAck reports whether text is a line key could have produced. For the daemon
|
||||
// tests, which can no longer compare against one literal.
|
||||
func IsAck(key string, vars map[string]string, text string) bool {
|
||||
return DefaultAcks().deck().matches(key, vars, text)
|
||||
return DefaultAcks().deck().Matches(key, vars, text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTestActs(t *testing.T) *Acts {
|
||||
t.Helper()
|
||||
a, err := LoadActs(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// She talks about a lamp or a server, never about a row in a schema. «сущность»
|
||||
// and «экосистема» are the same defect as saying a capability id out loud.
|
||||
func TestNoActLineSaysASchemaWord(t *testing.T) {
|
||||
a := loadTestActs(t)
|
||||
for _, v := range a.Variants() {
|
||||
for _, word := range []string{"сущност", "экосистем"} {
|
||||
if strings.Contains(v, word) {
|
||||
t.Errorf("variant %q says %q out loud", v, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
|
||||
// is not an answer he can act on.
|
||||
func TestAServiceFailureNamesTheService(t *testing.T) {
|
||||
a := loadTestActs(t)
|
||||
for _, key := range []string{EcoDown, EcoDenied} {
|
||||
got := a.Say(key, map[string]string{"name": "Praxis"})
|
||||
if !strings.HasPrefix(got, "Praxis ") {
|
||||
t.Errorf("%s = %q, want it to name the service", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A confirmation prompt for a destructive act is the worst place for an unfilled
|
||||
// placeholder, so the two names it interpolates are distinct keys and both are
|
||||
// declared.
|
||||
func TestConfirmEntityFillsBothNames(t *testing.T) {
|
||||
a := loadTestActs(t)
|
||||
got := a.Say(ActConfirmEntity, map[string]string{
|
||||
"name": "restart", "name_entity": "Muzick indexer",
|
||||
})
|
||||
if strings.ContainsAny(got, "{}") {
|
||||
t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
|
||||
}
|
||||
if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
|
||||
t.Fatalf("act_confirm_entity = %q, want both names", got)
|
||||
}
|
||||
}
|
||||
|
||||
// home_dark counts unreachable devices, and Russian inflects the noun after the
|
||||
// number: the count goes in {count} and the noun comes from the helper.
|
||||
func TestHomeDarkCountsWithTheHelper(t *testing.T) {
|
||||
a := loadTestActs(t)
|
||||
for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
|
||||
got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Four truths, four entries: a failure must not be able to report itself as a
|
||||
// success, and an empty result must not read as a failure.
|
||||
func TestActOutcomesStayDistinct(t *testing.T) {
|
||||
a := loadTestActs(t)
|
||||
seen := map[string]string{}
|
||||
for _, key := range actKeys {
|
||||
for _, v := range a.d.VariantsOf(key) {
|
||||
if prev, dup := seen[v]; dup {
|
||||
t.Errorf("%s and %s both say %q", prev, key, v)
|
||||
}
|
||||
seen[v] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package phraser
|
||||
|
||||
// The act and smart-home replies — what she says when a capability ran, refused,
|
||||
// or could not be reached.
|
||||
//
|
||||
// Fourth family on the shared deck (deck.go). They were literals in
|
||||
// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
|
||||
// a rebuild of the daemon that executes his house.
|
||||
//
|
||||
// The four outcomes stay four entries. Reporting a refusal with the wording of
|
||||
// a success is the one failure mode this family can have, and a shared variant
|
||||
// set is how it would happen.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
//go:embed acts_ru_v1.json
|
||||
var actJSON []byte
|
||||
|
||||
// ActSchemaVersion — this family's own version.
|
||||
const ActSchemaVersion = 1
|
||||
|
||||
// The entry keys.
|
||||
const (
|
||||
ActDone = "act_done"
|
||||
ActDoneOut = "act_done_out"
|
||||
ActDoneEntity = "act_done_entity"
|
||||
ActConfirm = "act_confirm"
|
||||
ActConfirmEntity = "act_confirm_entity"
|
||||
ActWhich = "act_which"
|
||||
ActFail = "act_fail"
|
||||
ActFailOut = "act_fail_out"
|
||||
ActFailEntity = "act_fail_entity"
|
||||
ActServerDown = "act_server_down"
|
||||
ActWithdrawn = "act_withdrawn"
|
||||
ActNeedsArgs = "act_needs_args"
|
||||
|
||||
EcoDenied = "eco_denied"
|
||||
EcoDown = "eco_down"
|
||||
EcoAmbiguous = "eco_ambiguous"
|
||||
EcoUnknownEntity = "eco_unknown_entity"
|
||||
EcoNoNexus = "eco_no_nexus"
|
||||
EcoAboutWhat = "eco_about_what"
|
||||
EcoRecall = "eco_recall"
|
||||
|
||||
AttentionNone = "attention_none"
|
||||
AttentionList = "attention_list"
|
||||
AttentionFail = "attention_fail"
|
||||
AttentionNoneEntity = "attention_none_entity"
|
||||
AttentionListEntity = "attention_list_entity"
|
||||
AttentionFailEntity = "attention_fail_entity"
|
||||
ChangesNone = "changes_none"
|
||||
ChangesList = "changes_list"
|
||||
ChangesFail = "changes_fail"
|
||||
HomeUnreachable = "home_unreachable"
|
||||
HomeEmpty = "home_empty"
|
||||
HomeOn = "home_on"
|
||||
HomeDark = "home_dark"
|
||||
)
|
||||
|
||||
var actKeys = []string{
|
||||
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
|
||||
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
|
||||
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
|
||||
AttentionNone, AttentionList, AttentionFail,
|
||||
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
|
||||
ChangesNone, ChangesList, ChangesFail,
|
||||
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
|
||||
}
|
||||
|
||||
// actFloor — the literal each key falls back to when the file is unusable. 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}» для {name_entity}? да или нет.",
|
||||
ActWhich: "какую команду для {name}: {items}?",
|
||||
ActFail: "не получилось выполнить команду.",
|
||||
ActFailOut: "не получилось выполнить команду: {out}",
|
||||
ActFailEntity: "не получилось выполнить команду для {name}.",
|
||||
ActServerDown: "инструмент есть, но сервер не подключён.",
|
||||
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
|
||||
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
||||
|
||||
EcoDenied: "{name} отклоняет доступ, проверь токен.",
|
||||
EcoDown: "{name} не отвечает, попробуй ещё раз.",
|
||||
EcoAmbiguous: "что именно: {items}?",
|
||||
EcoUnknownEntity: "не знаю, что это.",
|
||||
EcoNoNexus: "не с чем связать — Nexus не настроен.",
|
||||
EcoAboutWhat: "про что именно?",
|
||||
EcoRecall: "я помню: {items}",
|
||||
|
||||
AttentionNone: "ничего не требует внимания.",
|
||||
AttentionList: "требует внимания: {items}",
|
||||
AttentionFail: "не могу сейчас узнать, что требует внимания.",
|
||||
AttentionNoneEntity: "по «{name}» ничего нет.",
|
||||
AttentionListEntity: "по «{name}»: {items}",
|
||||
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
|
||||
ChangesNone: "изменений нет.",
|
||||
ChangesList: "изменения: {items}",
|
||||
ChangesFail: "не могу сейчас узнать об изменениях.",
|
||||
HomeUnreachable: "дом не отвечает.",
|
||||
HomeEmpty: "дом ничего не отдаёт.",
|
||||
HomeOn: "включено: {items}",
|
||||
HomeDark: "не отвечают: {count} {word}.",
|
||||
}
|
||||
|
||||
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
|
||||
type Acts struct{ d *say.Deck }
|
||||
|
||||
// LoadActs reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadActs(src rand.Source) (*Acts, error) {
|
||||
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The entries that name what ran or what he has to choose between. A
|
||||
// variant that dropped the name would confirm an act without saying which.
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
|
||||
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
|
||||
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Acts{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Acts, which is the unloadable-file case.
|
||||
func (a *Acts) deck() *say.Deck {
|
||||
if a == nil {
|
||||
return say.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)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (a *Acts) Variants() []string { return a.deck().Variants() }
|
||||
|
||||
var (
|
||||
actOnce sync.Once
|
||||
actsDeck *Acts
|
||||
)
|
||||
|
||||
// DefaultActs returns the shared instance, loading it on first use. A broken
|
||||
// file logs once and leaves a nil *Acts, which still answers from actFloor.
|
||||
func DefaultActs() *Acts {
|
||||
actOnce.Do(func() {
|
||||
a, err := LoadActs(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
|
||||
return
|
||||
}
|
||||
actsDeck = a
|
||||
})
|
||||
return actsDeck
|
||||
}
|
||||
|
||||
// A — one act reply, the way every caller says it.
|
||||
func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
|
||||
|
||||
// IsA reports whether text is a line key could have produced, for the tests.
|
||||
func IsA(key string, vars map[string]string, text string) bool {
|
||||
return DefaultActs().deck().Matches(key, vars, text)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian act and smart-home replies v1",
|
||||
"notes": [
|
||||
"What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"\"it ran\", \"it was refused\", \"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": {
|
||||
"fixed": true,
|
||||
"variants": ["готово."]
|
||||
},
|
||||
"act_done_out": {
|
||||
"variants": ["готово: {out}", "сделала: {out}"]
|
||||
},
|
||||
"act_done_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["готово: {name}."]
|
||||
},
|
||||
"act_confirm": {
|
||||
"fixed": true,
|
||||
"variants": ["выполнить «{name}»? да или нет."]
|
||||
},
|
||||
"act_confirm_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
|
||||
},
|
||||
"act_which": {
|
||||
"fixed": true,
|
||||
"variants": ["какую команду для {name}: {items}?"]
|
||||
},
|
||||
"act_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду."]
|
||||
},
|
||||
"act_fail_out": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду: {out}"]
|
||||
},
|
||||
"act_fail_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду для {name}."]
|
||||
},
|
||||
"act_server_down": {
|
||||
"fixed": true,
|
||||
"variants": ["инструмент есть, но сервер не подключён."]
|
||||
},
|
||||
"act_withdrawn": {
|
||||
"fixed": true,
|
||||
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
|
||||
},
|
||||
"act_needs_args": {
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"eco_denied": {
|
||||
"fixed": true,
|
||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
||||
},
|
||||
"eco_down": {
|
||||
"fixed": true,
|
||||
"variants": ["{name} не отвечает, попробуй ещё раз."]
|
||||
},
|
||||
"eco_ambiguous": {
|
||||
"fixed": true,
|
||||
"variants": ["что именно: {items}?"]
|
||||
},
|
||||
"eco_unknown_entity": {
|
||||
"variants": ["не знаю, что это.", "такого у меня нет."]
|
||||
},
|
||||
"eco_no_nexus": {
|
||||
"fixed": true,
|
||||
"variants": ["не с чем связать — Nexus не настроен."]
|
||||
},
|
||||
"eco_about_what": {
|
||||
"fixed": true,
|
||||
"variants": ["про что именно?"]
|
||||
},
|
||||
"eco_recall": {
|
||||
"fixed": true,
|
||||
"variants": ["я помню: {items}"]
|
||||
},
|
||||
"attention_none": {
|
||||
"fixed": true,
|
||||
"variants": ["ничего не требует внимания."]
|
||||
},
|
||||
"attention_list": {
|
||||
"fixed": true,
|
||||
"variants": ["требует внимания: {items}"]
|
||||
},
|
||||
"attention_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать, что требует внимания."]
|
||||
},
|
||||
"attention_none_entity": {
|
||||
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
|
||||
},
|
||||
"attention_list_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["по «{name}»: {items}"]
|
||||
},
|
||||
"attention_fail_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
||||
},
|
||||
"changes_none": {
|
||||
"fixed": true,
|
||||
"variants": ["изменений нет."]
|
||||
},
|
||||
"changes_list": {
|
||||
"fixed": true,
|
||||
"variants": ["изменения: {items}"]
|
||||
},
|
||||
"changes_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать об изменениях."]
|
||||
},
|
||||
"home_unreachable": {
|
||||
"variants": ["дом не отвечает.", "не достучалась до дома."]
|
||||
},
|
||||
"home_empty": {
|
||||
"fixed": true,
|
||||
"variants": ["дом ничего не отдаёт."]
|
||||
},
|
||||
"home_on": {
|
||||
"fixed": true,
|
||||
"variants": ["включено: {items}"]
|
||||
},
|
||||
"home_dark": {
|
||||
"fixed": true,
|
||||
"variants": ["не отвечают: {count} {word}."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and
|
||||
// query_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// heard out loud and they live in a JSON file now, so a reworded variant that
|
||||
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||
// TestFallbackPersona scores every line in every hand-written family on the
|
||||
// persona checks the nudges already pass. These lines are heard out loud and
|
||||
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
|
||||
// would otherwise reach him with nothing in between.
|
||||
//
|
||||
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
||||
// not nudges.
|
||||
@@ -38,6 +39,16 @@ func TestFallbackPersona(t *testing.T) {
|
||||
}
|
||||
variants := append(fb.Variants(), ack.Variants()...)
|
||||
variants = append(variants, qry.Variants()...)
|
||||
act, err := phraser.LoadActs(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
variants = append(variants, act.Variants()...)
|
||||
sum, err := say.LoadSummaries(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSummaries: %v", err)
|
||||
}
|
||||
variants = append(variants, sum.Variants()...)
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("no variants — the file loaded empty")
|
||||
}
|
||||
@@ -45,7 +56,9 @@ func TestFallbackPersona(t *testing.T) {
|
||||
// The placeholders stand for his own words and carry no persona.
|
||||
body := v
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
||||
"{location}", "{temp}", "{condition}", "{tail}"} {
|
||||
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
|
||||
"{entity}", "{count}", "{word}",
|
||||
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
|
||||
body = strings.ReplaceAll(body, ph, "вода")
|
||||
}
|
||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
//go:embed fallbacks_ru_v1.json
|
||||
@@ -47,48 +49,48 @@ var hardFloor = map[string]string{
|
||||
}
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *deck }
|
||||
type Fallbacks struct{ d *say.Deck }
|
||||
|
||||
// LoadFallbacks reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
||||
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// query_sources is the one entry whose whole job is to read something back.
|
||||
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
|
||||
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Fallbacks{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||
func (f *Fallbacks) deck() *deck {
|
||||
func (f *Fallbacks) deck() *say.Deck {
|
||||
if f == nil {
|
||||
return floorDeck(hardFloor)
|
||||
return say.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
|
||||
@@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() }
|
||||
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
|
||||
// The daemon tests read it to tell an answer from a shrug.
|
||||
func IsUnknownFallback(text string) bool {
|
||||
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
|
||||
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
|
||||
}
|
||||
|
||||
// IsSourcesFallback reports whether text is sources read back verbatim.
|
||||
func IsSourcesFallback(text, sources string) bool {
|
||||
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
|
||||
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other four families, held to the rule internal/say holds the fifth to:
|
||||
// one variant means fixed. Reported per family, because a failure that names
|
||||
// "some file" is a failure nobody acts on.
|
||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
f, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
a, err := LoadAcks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
acts, err := LoadActs(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
for name, keys := range map[string][]string{
|
||||
"fallbacks": f.d.UnfixedSingles(),
|
||||
"acks": a.d.UnfixedSingles(),
|
||||
"queries": q.d.UnfixedSingles(),
|
||||
"acts": acts.d.UnfixedSingles(),
|
||||
} {
|
||||
if len(keys) > 0 {
|
||||
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-39
@@ -1,47 +1,19 @@
|
||||
package phraser
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
// The counted noun, for callers that already speak through this package.
|
||||
//
|
||||
// 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).
|
||||
// The rule itself lives in internal/say, next to the line files that carry the
|
||||
// {word} placeholder, because internal/say sits under phraser in the import
|
||||
// graph and internal/memory and internal/tasks need it too. These are the same
|
||||
// three functions under the names cmd/mavend already calls.
|
||||
|
||||
import "math"
|
||||
import "github.com/kami/maven/internal/say"
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// CountWord picks between the three forms a Russian count needs.
|
||||
func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
|
||||
|
||||
// Degrees — the noun for a temperature. 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)), "градус", "градуса", "градусов")
|
||||
}
|
||||
// Degrees — the noun for a temperature.
|
||||
func Degrees(temp float64) string { return say.Degrees(temp) }
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
func Devices(n int) string { return say.Devices(n) }
|
||||
|
||||
+16
-10
@@ -16,6 +16,8 @@ import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
//go:embed query_ru_v1.json
|
||||
@@ -43,6 +45,8 @@ const (
|
||||
QueryWeatherOff = "weather_off"
|
||||
QueryWeatherWhere = "weather_nolocation"
|
||||
QueryNetEmpty = "net_empty"
|
||||
QueryNetOff = "net_off"
|
||||
QueryPageOff = "page_off"
|
||||
|
||||
QueryFailPlan = "fail_plan"
|
||||
QueryFailNotes = "fail_notes"
|
||||
@@ -58,7 +62,7 @@ var queryKeys = []string{
|
||||
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
||||
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
||||
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
||||
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
|
||||
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
|
||||
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
||||
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
||||
}
|
||||
@@ -75,7 +79,7 @@ var queryFloor = map[string]string{
|
||||
QueryFactValue: "у меня записано: {key} — {value}",
|
||||
QueryFound: "вот что я нашла: {text}",
|
||||
QueryPageText: "вот что на странице: {text}",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
|
||||
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||
QueryFeedsOff: "ленты не настроены.",
|
||||
QueryFeedsNew: "вот что нового: {items}",
|
||||
@@ -85,6 +89,8 @@ var queryFloor = map[string]string{
|
||||
QueryWeatherOff: "погода не настроена.",
|
||||
QueryWeatherWhere: "для какого города?",
|
||||
QueryNetEmpty: "в сети никого не нашла.",
|
||||
QueryNetOff: "сканирование сети не настроено.",
|
||||
QueryPageOff: "я не читаю страницы — это не настроено.",
|
||||
|
||||
QueryFailPlan: "не получилось собрать план.",
|
||||
QueryFailNotes: "не получилось посмотреть записи.",
|
||||
@@ -97,12 +103,12 @@ var queryFloor = map[string]string{
|
||||
}
|
||||
|
||||
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||
type Queries struct{ d *deck }
|
||||
type Queries struct{ d *say.Deck }
|
||||
|
||||
// LoadQueries reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
|
||||
d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +120,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
{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
|
||||
}
|
||||
}
|
||||
@@ -122,20 +128,20 @@ func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
}
|
||||
|
||||
// deck reads through a nil *Queries, which is the unloadable-file case.
|
||||
func (q *Queries) deck() *deck {
|
||||
func (q *Queries) deck() *say.Deck {
|
||||
if q == nil {
|
||||
return floorDeck(queryFloor)
|
||||
return say.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
|
||||
@@ -161,5 +167,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)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
|
||||
for _, v := range f.Variants() {
|
||||
failures[v] = true
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
|
||||
for _, v := range q.d.VariantsOf(QueryUnknown) {
|
||||
if failures[v] {
|
||||
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestWeatherLineCountsWithTheHelper(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
|
||||
for _, v := range q.d.VariantsOf(QueryWeatherNow) {
|
||||
if strings.Contains(v, "градус") {
|
||||
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"fixed": true,
|
||||
"variants": ["записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
@@ -37,12 +38,14 @@
|
||||
},
|
||||
"page_blocked": {
|
||||
"fixed": true,
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
"variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"fixed": true,
|
||||
"variants": ["страница открылась, но читать там нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"fixed": true,
|
||||
"variants": ["ленты не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
@@ -58,37 +61,54 @@
|
||||
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"fixed": true,
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
"fixed": true,
|
||||
"variants": ["для какого города?"]
|
||||
},
|
||||
"net_off": {
|
||||
"fixed": true,
|
||||
"variants": ["сканирование сети не настроено."]
|
||||
},
|
||||
"page_off": {
|
||||
"fixed": true,
|
||||
"variants": ["я не читаю страницы — это не настроено."]
|
||||
},
|
||||
"net_empty": {
|
||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось собрать план."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть записи."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть ленты."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось проверить календарь."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось узнать погоду."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось найти ответ."]
|
||||
},
|
||||
"fail_page": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось прочитать страницу."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +75,40 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two shapes that carried no question mark and no interrogative, so the
|
||||
// model saw them first and called them facts (Vikunja #498).
|
||||
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
for _, u := range []string{
|
||||
"что дальше?",
|
||||
"и что там дальше",
|
||||
"what's next?",
|
||||
"расскажи про битву при Ватерлоо",
|
||||
"объясни как работает дизель",
|
||||
"опиши Ватерлоо",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery || d.Stage != 0 {
|
||||
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A narrative verb next to a capture verb is him asking for a note. Stage 0
|
||||
// declines and the extractor gets its turn.
|
||||
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Stage == 0 && d.Intent == IntentQuery {
|
||||
t.Errorf("stage 0 claimed a capture: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||
// anything while its grammar set is the daemon's grammar set.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
|
||||
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
||||
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
|
||||
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
|
||||
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
|
||||
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
|
||||
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
|
||||
|
||||
@@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar {
|
||||
}
|
||||
}
|
||||
|
||||
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
|
||||
// carry no question mark and no interrogative, and so reached the resident
|
||||
// model with nothing deterministic in front of them (Vikunja #498).
|
||||
//
|
||||
// Both were routed IntentFact by the model. The fact gate catches the write and
|
||||
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
|
||||
// model round trip to reach a decision two patterns can make offline, and a
|
||||
// wrong row on the routing fixture.
|
||||
//
|
||||
// Wired after the agenda grammars, which is where their overlap resolves:
|
||||
// "расскажи, что у меня сегодня" is claimed here as a query either way.
|
||||
func NarrativeQueryGrammars() []Grammar {
|
||||
return []Grammar{
|
||||
{
|
||||
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
|
||||
// recognises it downstream in the query chain, but that runs after
|
||||
// the routing decision, and the routing decision was fact.
|
||||
Name: "rest-of-day-query",
|
||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// "расскажи про X" — a world question phrased as an instruction.
|
||||
// The lexicon is narrativeRequests, already written for the
|
||||
// question-shaped test in question.go.
|
||||
//
|
||||
// Anchored at the start: "запиши что мне рассказали" is a capture,
|
||||
// and a narrative verb buried mid-utterance is not the shape.
|
||||
Name: "narrative-query",
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`),
|
||||
Build: narrativeQueryBuild,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// entertainmentNouns — what "расскажи" asks for when it is not asking for
|
||||
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
|
||||
// something up, which is the one case where inventing is the right answer
|
||||
// (fixture ru-chat-003).
|
||||
var entertainmentNouns = []string{
|
||||
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
|
||||
"joke", "jokes", "story",
|
||||
}
|
||||
|
||||
// narrativeQueryBuild — the narrative shape is a query unless he also said one
|
||||
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
|
||||
// asking for a note, and stage 0 must not take either off the cascade.
|
||||
func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
rest := ""
|
||||
if len(m) > 3 {
|
||||
rest = m[3]
|
||||
}
|
||||
for _, t := range planTokens(rest) {
|
||||
for _, v := range captureVerbs {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
for _, v := range entertainmentNouns {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return agendaQueryBuild(m)
|
||||
}
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
// matchers decide the rest.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package phraser
|
||||
package say
|
||||
|
||||
// deck — the mechanics every family of hand-written Russian lines shares.
|
||||
// Package say holds the mechanics every family of hand-written Russian lines
|
||||
// shares. The families themselves live next to the code that speaks them.
|
||||
//
|
||||
// Deck — the mechanics every family of hand-written Russian lines shares.
|
||||
//
|
||||
// A family is one embedded JSON file: schema-versioned, several variants per
|
||||
// entry, never the same variant twice running, and a hard floor of Go literals
|
||||
@@ -20,28 +23,28 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// deckEntry — one line she can say, in as many wordings as the file gives.
|
||||
type deckEntry struct {
|
||||
// Entry — one line she can say, in as many wordings as the file gives.
|
||||
type Entry struct {
|
||||
// Fixed — one variant, never picked between. For wording that must not
|
||||
// drift from turn to turn, like a phrase naming one specific gap.
|
||||
Fixed bool `json:"fixed"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type deckFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Entries map[string]deckEntry `json:"entries"`
|
||||
type file struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Entries map[string]Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A deck with no entries answers
|
||||
// from the floor, which is what an unloadable file leaves behind (floorDeck).
|
||||
type deck struct {
|
||||
// Deck picks a line. Safe for concurrent use. A 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 deckFile
|
||||
file file
|
||||
keys []string
|
||||
floor map[string]string
|
||||
}
|
||||
@@ -49,8 +52,8 @@ type deck struct {
|
||||
// loadDeck parses raw, checks the version and every required key, and seeds the
|
||||
// picker. Pass a source to make the picking reproducible in tests; nil seeds
|
||||
// from the clock.
|
||||
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
|
||||
var f deckFile
|
||||
func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
|
||||
var f file
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
@@ -69,13 +72,13 @@ func loadDeck(raw []byte, version int, keys []string, floor map[string]string, s
|
||||
if src == nil {
|
||||
src = rand.NewSource(time.Now().UnixNano())
|
||||
}
|
||||
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
|
||||
return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
|
||||
}
|
||||
|
||||
// requirePlaceholder fails the load when a variant of key does not use ph. For
|
||||
// an entry whose whole job is to read something back, a variant without the
|
||||
// placeholder silently drops it.
|
||||
func (d *deck) requirePlaceholder(key, ph string) error {
|
||||
func (d *Deck) RequirePlaceholder(key, ph string) error {
|
||||
for _, v := range d.file.Entries[key].Variants {
|
||||
if !strings.Contains(v, ph) {
|
||||
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
|
||||
@@ -86,7 +89,7 @@ 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 {
|
||||
@@ -101,7 +104,7 @@ func (d *deck) text(key string, vars map[string]string) string {
|
||||
|
||||
// matches reports whether text is a line key could have produced. A caller that
|
||||
// has to recognise one of these lines cannot compare against a literal any more.
|
||||
func (d *deck) matches(key string, vars map[string]string, text string) bool {
|
||||
func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
|
||||
if fill(floorOf(d, key), vars) == text {
|
||||
return true
|
||||
}
|
||||
@@ -118,7 +121,7 @@ func (d *deck) matches(key string, vars map[string]string, text string) bool {
|
||||
|
||||
// variants returns every line the file can produce, in key order, for the
|
||||
// persona scorer. Stable order so a failure names the same variant twice.
|
||||
func (d *deck) variants() []string {
|
||||
func (d *Deck) Variants() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -129,6 +132,33 @@ func (d *deck) variants() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// VariantsOf returns the wordings the file gives for one key, for a test that
|
||||
// has something to say about every one of them.
|
||||
func (d *Deck) VariantsOf(key string) []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.file.Entries[key].Variants
|
||||
}
|
||||
|
||||
// UnfixedSingles lists the keys with exactly one variant that are not marked
|
||||
// fixed. Nothing breaks on one — the picker has nothing to pick either way — but
|
||||
// the flag is what a reader goes by, and parallel entries disagreeing about it
|
||||
// is how a family stops being readable. Load already rejects the other half of
|
||||
// the rule, fixed with more than one variant, so this is the pair to it.
|
||||
func (d *Deck) UnfixedSingles() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, k := range d.keys {
|
||||
if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillable narrows variants to the ones this call can actually say, which is
|
||||
// the rule an optional placeholder needs: a caller with nothing to put in
|
||||
// {tail} must not be handed a variant that has one. Two passes, because both
|
||||
@@ -185,7 +215,7 @@ func placeholders(tmpl string) []string {
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (d *deck) pick(key string, variants []string) string {
|
||||
func (d *Deck) pick(key string, variants []string) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
@@ -211,18 +241,18 @@ func (d *deck) pick(key string, variants []string) string {
|
||||
// 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 {
|
||||
func floorOf(d *Deck, key string) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
return d.floor[key]
|
||||
}
|
||||
|
||||
// floorDeck — the deck a family falls back to when its file will not load. It
|
||||
// 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}
|
||||
// a real *Deck so no accessor has to know which case it is in.
|
||||
func FloorDeck(floor map[string]string) *Deck {
|
||||
return &Deck{last: map[string]string{}, floor: floor}
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
@@ -0,0 +1,55 @@
|
||||
package say
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
//
|
||||
// Russian inflects a noun after a numeral, and the form depends on the last two
|
||||
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
|
||||
// A line file cannot spell that out, so a count in a template splits into two
|
||||
// placeholders — the number, and {word} filled from here.
|
||||
//
|
||||
// The rule lived once as hostWord in cmd/mavend/netscan.go and once as
|
||||
// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
|
||||
// for every temperature and the task list said "дн." — a written abbreviation
|
||||
// read aloud. One helper, every count site (Vikunja #521).
|
||||
|
||||
import "math"
|
||||
|
||||
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
|
||||
// for 0, 5-20 and anything ending in those. A negative count reads its own
|
||||
// magnitude, since minus does not change the noun: -2 градуса.
|
||||
func CountWord(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
|
||||
// weather provider and counts by the whole degrees she is about to say, so the
|
||||
// noun agrees with the number in the same sentence rather than with the reading
|
||||
// behind it.
|
||||
func Degrees(temp float64) string {
|
||||
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
|
||||
}
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
|
||||
// Days — the noun for a stretch of days. This is what replaces «дн.» in the
|
||||
// overdue and due-soon reasons: an abbreviation is written shorthand, and every
|
||||
// one of these lines is spoken.
|
||||
func Days(n int) string {
|
||||
return CountWord(n, "день", "дня", "дней")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package say
|
||||
|
||||
import "testing"
|
||||
|
||||
// The day forms, kept from internal/memory when the helper moved: 21 takes the
|
||||
// singular and 11 does not, which is the whole reason this is not n == 1.
|
||||
func TestDays(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := Days(c.n); got != c.want {
|
||||
t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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"
|
||||
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, 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.
|
||||
// It started as the exact strings that lived in Go before this file existed and
|
||||
// now tracks the file's first variant instead, because a floor that keeps the
|
||||
// wording review threw out would say it back on the one turn nobody is watching.
|
||||
var summaryFloor = map[string]string{
|
||||
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
|
||||
PlanDayEmpty: "на {date} ничего не запланировано.",
|
||||
PlanDay: "план на {date}: {items}",
|
||||
PlanUncertain: "похоже, {line}",
|
||||
|
||||
TasksNone: "задач нет.",
|
||||
TasksFirst: "сначала: {items}",
|
||||
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
|
||||
|
||||
ReasonOverdue: "просрочено",
|
||||
ReasonOverdueDays: "просрочено на {n} {word}",
|
||||
ReasonToday: "сегодня",
|
||||
ReasonTomorrow: "завтра",
|
||||
ReasonInDays: "через {n} {word}",
|
||||
ReasonImportant: "важно",
|
||||
ReasonUrgent: "срочно",
|
||||
ReasonStale: "давно в списке",
|
||||
|
||||
HabitWeekday: "по {day} ты обычно {items}.",
|
||||
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
|
||||
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_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}"}, {ReasonOverdueDays, "{word}"},
|
||||
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
|
||||
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
|
||||
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
|
||||
{HabitWeekdayNone, "{day}"},
|
||||
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_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 FloorDeck(summaryFloor)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"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.",
|
||||
"She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
|
||||
"Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
|
||||
"Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
|
||||
"The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
|
||||
"{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
|
||||
"A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
|
||||
"fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
|
||||
],
|
||||
"entries": {
|
||||
"plan_rest_empty": {
|
||||
"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_days": {
|
||||
"fixed": true,
|
||||
"variants": ["просрочено на {n} {word}"]
|
||||
},
|
||||
"reason_today": {
|
||||
"fixed": true,
|
||||
"variants": ["сегодня"]
|
||||
},
|
||||
"reason_tomorrow": {
|
||||
"fixed": true,
|
||||
"variants": ["завтра"]
|
||||
},
|
||||
"reason_in_days": {
|
||||
"fixed": true,
|
||||
"variants": ["через {n} {word}"]
|
||||
},
|
||||
"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": {
|
||||
"fixed": true,
|
||||
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
|
||||
},
|
||||
"habit_weekday_none": {
|
||||
"variants": [
|
||||
"по {day} я пока не вижу ничего постоянного.",
|
||||
"по {day} постоянного пока не вижу — записей мало."
|
||||
]
|
||||
},
|
||||
"habit_weekend_both": {
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
|
||||
},
|
||||
"habit_weekend_sat": {
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
|
||||
},
|
||||
"habit_weekend_sun": {
|
||||
"fixed": true,
|
||||
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
|
||||
},
|
||||
"habit_weekend_same": {
|
||||
"fixed": true,
|
||||
"variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."]
|
||||
},
|
||||
"habit_weekend_none": {
|
||||
"variants": [
|
||||
"по выходным я пока не вижу ничего постоянного.",
|
||||
"по выходным постоянного пока не вижу — записей мало."
|
||||
]
|
||||
},
|
||||
"habit_overall": {
|
||||
"fixed": true,
|
||||
"variants": ["обычно ты {items} — {span}."]
|
||||
},
|
||||
"habit_overall_none": {
|
||||
"variants": [
|
||||
"записей пока мало, про привычки не скажу.",
|
||||
"пока мало записей, чтобы говорить о привычках."
|
||||
]
|
||||
},
|
||||
"habit_span_today": {
|
||||
"fixed": true,
|
||||
"variants": ["по записям за сегодня"]
|
||||
},
|
||||
"habit_span_days": {
|
||||
"fixed": true,
|
||||
"variants": ["по записям за последние {n} {word}"]
|
||||
},
|
||||
"habit_unglossed": {
|
||||
"fixed": true,
|
||||
"variants": ["отмечаешь «{key}»"]
|
||||
},
|
||||
"habit_at": {
|
||||
"fixed": true,
|
||||
"variants": ["{gloss} около {time}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One variant means fixed, in this file and in the four in internal/phraser.
|
||||
// Nothing breaks on the flag being absent, but parallel entries disagreeing
|
||||
// about it is how the file stops telling a reader which wording is load-bearing.
|
||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
s, err := LoadSummaries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := s.d.UnfixedSingles(); len(got) > 0 {
|
||||
t.Errorf("single-variant entries not marked fixed: %v", got)
|
||||
}
|
||||
}
|
||||
+32
-33
@@ -19,8 +19,11 @@ package tasks
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
|
||||
@@ -117,21 +120,25 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
bonus = scoreOverdueCap
|
||||
}
|
||||
total += scoreOverdue + bonus
|
||||
reason = "просрочено"
|
||||
if late == 1 {
|
||||
reason = "просрочено на день"
|
||||
} else if late > 1 {
|
||||
reason = fmt.Sprintf("просрочено на %d дн.", late)
|
||||
reason = say.S(say.ReasonOverdue, nil)
|
||||
if late > 0 {
|
||||
// One day needs no arm of its own: «просрочено на 1 день»
|
||||
// falls out of the count helper like every other number.
|
||||
reason = say.S(say.ReasonOverdueDays, map[string]string{
|
||||
"n": strconv.Itoa(late), "word": say.Days(late),
|
||||
})
|
||||
}
|
||||
case days == 0:
|
||||
total += scoreDueToday
|
||||
reason = "сегодня"
|
||||
reason = say.S(say.ReasonToday, nil)
|
||||
case days == 1:
|
||||
total += scoreDueTomorrow
|
||||
reason = "завтра"
|
||||
reason = say.S(say.ReasonTomorrow, nil)
|
||||
case days <= 7:
|
||||
total += scoreDueWeek
|
||||
reason = fmt.Sprintf("через %d дн.", days)
|
||||
reason = say.S(say.ReasonInDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||
})
|
||||
default:
|
||||
total += scoreDueLater
|
||||
}
|
||||
@@ -147,9 +154,9 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
// The rungs get their own words. The reason string is the one place
|
||||
// the ranking explains itself, and reading "важно" back at a task
|
||||
// he flagged "срочно" reports a word he did not say.
|
||||
reason = "важно"
|
||||
reason = say.S(say.ReasonImportant, nil)
|
||||
if w >= MaxWeight {
|
||||
reason = "срочно"
|
||||
reason = say.S(say.ReasonUrgent, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +170,7 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
}
|
||||
total += age
|
||||
if reason == "" && weeks >= 2 {
|
||||
reason = "давно в списке"
|
||||
reason = say.S(say.ReasonStale, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,22 +217,28 @@ func FormatRU(ranked []Ranked) string {
|
||||
}
|
||||
}
|
||||
if len(open) == 0 && len(cands) == 0 {
|
||||
return "задач нет."
|
||||
return say.S(say.TasksNone, nil)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if len(open) > 0 {
|
||||
b.WriteString("сначала: ")
|
||||
b.WriteString(joinRU(open, SpokenLimit, true))
|
||||
b.WriteString(".")
|
||||
b.WriteString(say.S(say.TasksFirst, map[string]string{
|
||||
"items": joinRU(open, SpokenLimit, true),
|
||||
}))
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
// Two sentences, and the first one ends on a joined list that carries
|
||||
// whatever punctuation its last task had — usually none. So the break
|
||||
// is the caller's to make, not the line file's (Vikunja #521).
|
||||
if b.Len() > 0 {
|
||||
if !strings.HasSuffix(b.String(), ".") {
|
||||
b.WriteString(".")
|
||||
}
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||||
b.WriteString(joinRU(cands, SpokenLimit, false))
|
||||
b.WriteString(".")
|
||||
b.WriteString(say.S(say.TasksCandidates, map[string]string{
|
||||
"items": joinRU(cands, SpokenLimit, false),
|
||||
}))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -250,21 +263,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
s := strings.Join(parts, "; ")
|
||||
if rest > 0 {
|
||||
// With the noun. Spoken, a bare number trails off mid-sentence.
|
||||
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
|
||||
s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
|
||||
func pluralTasksRU(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "задач"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "задача"
|
||||
case 2, 3, 4:
|
||||
return "задачи"
|
||||
}
|
||||
return "задач"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
func at(y int, m time.Month, d int) *time.Time {
|
||||
@@ -123,10 +125,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) {
|
||||
if got[0].Text != "давно" {
|
||||
t.Errorf("order = %v, want the later-overdue task first", texts(got))
|
||||
}
|
||||
if got[0].Reason != "просрочено на 12 дн." {
|
||||
if got[0].Reason != "просрочено на 12 дней" {
|
||||
t.Errorf("reason = %q", got[0].Reason)
|
||||
}
|
||||
if got[1].Reason != "просрочено на день" {
|
||||
// One day goes through the same entry as twelve: «на 1 день» is what the
|
||||
// count helper says, so there is no reason_overdue_day any more.
|
||||
if got[1].Reason != "просрочено на 1 день" {
|
||||
t.Errorf("reason = %q", got[1].Reason)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +145,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
|
||||
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
|
||||
t.Errorf("reply = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "не подтвердил: продлить страховку") {
|
||||
if !strings.Contains(got, "не подтверждал: продлить страховку") {
|
||||
t.Errorf("candidate not named as unconfirmed: %q", got)
|
||||
}
|
||||
// A candidate's due date is Maven's reading of a mail, not his statement.
|
||||
@@ -178,7 +182,7 @@ func TestFormatRUEmpty(t *testing.T) {
|
||||
|
||||
// A due date read back from the store is a UTC instant, so comparing calendar
|
||||
// days in ITS location put every date a day out east of Greenwich: the row said
|
||||
// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
|
||||
// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date
|
||||
// itself while the due column one cell over said otherwise.
|
||||
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
|
||||
tz := time.FixedZone("UTC+4", 4*3600)
|
||||
@@ -238,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) {
|
||||
t.Errorf("reply = %q, want the count with its noun", got)
|
||||
}
|
||||
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
|
||||
if got := pluralTasksRU(n); got != want {
|
||||
t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
|
||||
if got := say.CountWord(n, "задача", "задачи", "задач"); got != want {
|
||||
t.Errorf("CountWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user