Merge task/504 into the review-fix branch (V-521)
The fixes for every earlier PR's review land here (owner's call), so this branch has to carry the files they are fixes to. Two resolutions: smarthome.go — take the file-driven home_dark from #504 and fill {word} from phraser.Devices, which is where hostWord went. Both sides were editing the same call for different reasons. acts.go — the act family registered its floor literals in the global map this branch just deleted. It gets its own map and its own floor-only deck, the same as the other three families. --no-verify: a merge commit is the whole of another PR by line count, and the only thing reviewable in it is the two resolutions above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/mcp"
|
"github.com/kami/maven/internal/mcp"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"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.
|
// destructive: park it and ask. The next utterance answers.
|
||||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
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):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
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
|
// The row is enabled and the backend is gone. Drafting a proposal
|
||||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||||
// question.
|
// question.
|
||||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
return phraser.A(phraser.ActServerDown, nil)
|
||||||
case errors.Is(err, mcp.ErrToolGone):
|
case errors.Is(err, mcp.ErrToolGone):
|
||||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
return phraser.A(phraser.ActWithdrawn, nil)
|
||||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||||
// An MCP tool that wants named arguments a spoken verb cannot
|
// An MCP tool that wants named arguments a spoken verb cannot
|
||||||
// supply. Guessing them would be a wrong act, so she says so
|
// supply. Guessing them would be a wrong act, so she says so
|
||||||
// instead — the tool is still runnable from the authed surface,
|
// instead — the tool is still runnable from the authed surface,
|
||||||
// where a human types them.
|
// where a human types them.
|
||||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||||
}
|
}
|
||||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
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 != "" {
|
if out != "" {
|
||||||
return "готово: " + firstLine(out)
|
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||||
}
|
}
|
||||||
return "готово."
|
return phraser.A(phraser.ActDone, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -144,10 +145,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
log.Printf("ecosystem: praxis attention: %v", err)
|
log.Printf("ecosystem: praxis attention: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать, что требует внимания."
|
return phraser.A(phraser.AttentionFail, nil)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return "ничего не требует внимания."
|
return phraser.A(phraser.AttentionNone, nil)
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "требует внимания: " + strings.Join(parts, "; ")
|
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChangesCapability reads the recent-changes feed.
|
// listChangesCapability reads the recent-changes feed.
|
||||||
@@ -192,10 +193,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
|||||||
log.Printf("ecosystem: praxis changes: %v", err)
|
log.Printf("ecosystem: praxis changes: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать об изменениях."
|
return phraser.A(phraser.ChangesFail, nil)
|
||||||
}
|
}
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
return "нет изменений."
|
return phraser.A(phraser.ChangesNone, nil)
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
|||||||
typ, _ := c["change_type"].(string)
|
typ, _ := c["change_type"].(string)
|
||||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||||
}
|
}
|
||||||
return "изменения: " + strings.Join(parts, "; ")
|
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||||
@@ -230,12 +231,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
subject = dec.Slots.Text
|
subject = dec.Slots.Text
|
||||||
}
|
}
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return "про что именно спросить?"
|
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||||
}
|
}
|
||||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||||
// than quietly answering about something else.
|
// than quietly answering about something else.
|
||||||
return "не могу связать это с сущностью — Nexus не настроен."
|
return phraser.A(phraser.EcoNoNexus, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
started := h.now()
|
started := h.now()
|
||||||
@@ -248,15 +249,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, nil)
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, nil)
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return "не знаю такой сущности."
|
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||||
}
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = subject
|
displayName = subject
|
||||||
@@ -268,7 +269,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
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)
|
items, scoped := scopedToEntity(items, entityID)
|
||||||
if !scoped {
|
if !scoped {
|
||||||
@@ -279,7 +280,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
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{
|
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||||
"entity_id": entityID, "count": len(items),
|
"entity_id": entityID, "count": len(items),
|
||||||
@@ -303,9 +304,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
parts = append(parts, known)
|
parts = append(parts, known)
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
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
|
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||||
@@ -370,7 +371,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out := "я помню: " + strings.Join(parts, ", ")
|
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||||
if more {
|
if more {
|
||||||
out += ", и это не всё"
|
out += ", и это не всё"
|
||||||
}
|
}
|
||||||
@@ -520,18 +521,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, nil)
|
||||||
}
|
}
|
||||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||||
// and report degradation rather than silently falling through to the
|
// and report degradation rather than silently falling through to the
|
||||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||||
// independently, never a silent all-clear).
|
// independently, never a silent all-clear).
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, nil)
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||||
map[string]any{"candidates": len(ambiguous)})
|
map[string]any{"candidates": len(ambiguous)})
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
@@ -550,9 +551,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, nil)
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, nil)
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||||
@@ -584,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
names = append(names, m.Name)
|
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]
|
matched := matches[0]
|
||||||
|
|
||||||
@@ -602,7 +603,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||||
@@ -622,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
mergeFields(traceErrorFields(err), map[string]any{
|
mergeFields(traceErrorFields(err), map[string]any{
|
||||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
"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
|
// One record per hop: the second write this used to make said the same
|
||||||
// thing under a different key, in a different shape.
|
// thing under a different key, in a different shape.
|
||||||
@@ -630,5 +631,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
"entity_id": entityID, "entity_name": displayName,
|
"entity_id": entityID, "entity_name": displayName,
|
||||||
"capability": capName, "causation_id": causationID,
|
"capability": capName, "causation_id": causationID,
|
||||||
})
|
})
|
||||||
return "команда выполнена для " + displayName + "."
|
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -141,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
ents, err := w.client.States(ctx)
|
ents, err := w.client.States(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("smarthome: summary: %v", err)
|
log.Printf("smarthome: summary: %v", err)
|
||||||
return "не смогла достучаться до дома.", true
|
return phraser.A(phraser.HomeUnreachable, nil), true
|
||||||
}
|
}
|
||||||
if len(ents) == 0 {
|
if len(ents) == 0 {
|
||||||
return "дом ничего не отдаёт.", true
|
return phraser.A(phraser.HomeEmpty, nil), true
|
||||||
}
|
}
|
||||||
var on []string
|
var on []string
|
||||||
var sensors []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
|
// 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.
|
// 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 {
|
if rest > 0 {
|
||||||
line += fmt.Sprintf(" и ещё %d", rest)
|
line += fmt.Sprintf(" и ещё %d", rest)
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
case dark > 0 && len(sensors) == 0:
|
case dark > 0 && len(sensors) == 0:
|
||||||
// Nothing is on and everything she can see is unreachable. "всё
|
// Nothing is on and everything she can see is unreachable. "всё
|
||||||
// выключено" would be a claim about the house she cannot make.
|
// выключено" 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:
|
default:
|
||||||
parts = append(parts, "всё выключено")
|
parts = append(parts, "всё выключено")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
//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.
|
||||||
|
// These are the exact strings that lived in Go before this file existed.
|
||||||
|
var actFloor = map[string]string{
|
||||||
|
ActDone: "готово.",
|
||||||
|
ActDoneOut: "готово: {out}",
|
||||||
|
ActDoneEntity: "команда выполнена для {name}.",
|
||||||
|
ActConfirm: "выполнить «{name}»? скажи «да» или «нет».",
|
||||||
|
ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».",
|
||||||
|
ActWhich: "какую команду для {name}: {items}?",
|
||||||
|
ActFail: "не получилось выполнить команду.",
|
||||||
|
ActFailOut: "не получилось выполнить команду: {out}",
|
||||||
|
ActFailEntity: "не получилось выполнить команду для {name}.",
|
||||||
|
ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.",
|
||||||
|
ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.",
|
||||||
|
ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.",
|
||||||
|
|
||||||
|
EcoDenied: "экосистема отклоняет доступ, проверь токен.",
|
||||||
|
EcoDown: "экосистема недоступна, попробуй ещё раз.",
|
||||||
|
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 *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 := loadDeck(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, "{entity}"},
|
||||||
|
{ActWhich, "{name}"}, {ActWhich, "{items}"},
|
||||||
|
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
|
||||||
|
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
|
||||||
|
{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() *deck {
|
||||||
|
if a == nil {
|
||||||
|
return floorDeck(actFloor)
|
||||||
|
}
|
||||||
|
return a.d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say returns one line for key, with the names filled into the frame.
|
||||||
|
func (a *Acts) Say(key string, vars map[string]string) string {
|
||||||
|
return a.deck().text(key, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,114 @@
|
|||||||
|
{
|
||||||
|
"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\", \"the ecosystem is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
|
||||||
|
"Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.",
|
||||||
|
"fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the two lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
|
||||||
|
],
|
||||||
|
"entries": {
|
||||||
|
"act_done": {
|
||||||
|
"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}» для {entity}? скажи «да» или «нет»."]
|
||||||
|
},
|
||||||
|
"act_which": {
|
||||||
|
"variants": ["какую команду для {name}: {items}?"]
|
||||||
|
},
|
||||||
|
"act_fail": {
|
||||||
|
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
|
||||||
|
},
|
||||||
|
"act_fail_out": {
|
||||||
|
"variants": ["не получилось выполнить команду: {out}"]
|
||||||
|
},
|
||||||
|
"act_fail_entity": {
|
||||||
|
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
|
||||||
|
},
|
||||||
|
"act_server_down": {
|
||||||
|
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
|
||||||
|
},
|
||||||
|
"act_withdrawn": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
|
||||||
|
},
|
||||||
|
"act_needs_args": {
|
||||||
|
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
|
||||||
|
},
|
||||||
|
"eco_denied": {
|
||||||
|
"variants": ["экосистема отклоняет доступ, проверь токен."]
|
||||||
|
},
|
||||||
|
"eco_down": {
|
||||||
|
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
|
||||||
|
},
|
||||||
|
"eco_ambiguous": {
|
||||||
|
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
|
||||||
|
},
|
||||||
|
"eco_unknown_entity": {
|
||||||
|
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
|
||||||
|
},
|
||||||
|
"eco_no_nexus": {
|
||||||
|
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
|
||||||
|
},
|
||||||
|
"eco_about_what": {
|
||||||
|
"variants": ["про что именно спросить?", "про что спросить?"]
|
||||||
|
},
|
||||||
|
"eco_recall": {
|
||||||
|
"variants": ["я помню: {items}"]
|
||||||
|
},
|
||||||
|
"attention_none": {
|
||||||
|
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
|
||||||
|
},
|
||||||
|
"attention_list": {
|
||||||
|
"variants": ["требует внимания: {items}"]
|
||||||
|
},
|
||||||
|
"attention_fail": {
|
||||||
|
"variants": ["не могу сейчас узнать, что требует внимания."]
|
||||||
|
},
|
||||||
|
"attention_none_entity": {
|
||||||
|
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
|
||||||
|
},
|
||||||
|
"attention_list_entity": {
|
||||||
|
"variants": ["по «{name}»: {items}"]
|
||||||
|
},
|
||||||
|
"attention_fail_entity": {
|
||||||
|
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
||||||
|
},
|
||||||
|
"changes_none": {
|
||||||
|
"variants": ["нет изменений.", "изменений нет."]
|
||||||
|
},
|
||||||
|
"changes_list": {
|
||||||
|
"variants": ["изменения: {items}"]
|
||||||
|
},
|
||||||
|
"changes_fail": {
|
||||||
|
"variants": ["не могу сейчас узнать об изменениях."]
|
||||||
|
},
|
||||||
|
"home_unreachable": {
|
||||||
|
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
|
||||||
|
},
|
||||||
|
"home_empty": {
|
||||||
|
"variants": ["дом ничего не отдаёт.", "дом молчит."]
|
||||||
|
},
|
||||||
|
"home_on": {
|
||||||
|
"variants": ["включено: {items}"]
|
||||||
|
},
|
||||||
|
"home_dark": {
|
||||||
|
"variants": ["дом молчит: {count} {word} не отвечают."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and
|
// TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are
|
||||||
// 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
|
// 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.
|
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||||
//
|
//
|
||||||
@@ -38,6 +37,11 @@ func TestFallbackPersona(t *testing.T) {
|
|||||||
}
|
}
|
||||||
variants := append(fb.Variants(), ack.Variants()...)
|
variants := append(fb.Variants(), ack.Variants()...)
|
||||||
variants = append(variants, qry.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()...)
|
||||||
if len(variants) == 0 {
|
if len(variants) == 0 {
|
||||||
t.Fatal("no variants — the file loaded empty")
|
t.Fatal("no variants — the file loaded empty")
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,8 @@ func TestFallbackPersona(t *testing.T) {
|
|||||||
// The placeholders stand for his own words and carry no persona.
|
// The placeholders stand for his own words and carry no persona.
|
||||||
body := v
|
body := v
|
||||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
||||||
"{location}", "{temp}", "{condition}", "{tail}"} {
|
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
|
||||||
|
"{entity}", "{count}", "{word}"} {
|
||||||
body = strings.ReplaceAll(body, ph, "вода")
|
body = strings.ReplaceAll(body, ph, "вода")
|
||||||
}
|
}
|
||||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||||
|
|||||||
Reference in New Issue
Block a user