Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8da529be0 | |||
| 0258a40b0d | |||
| f6a8752d00 | |||
| 5b7480ddaf | |||
| 8aa790cc48 | |||
| 0ade0ec734 | |||
| d960e211d3 | |||
| 6fba4d6931 | |||
| 12c18dcf65 | |||
| a286865fe5 | |||
| 28c0ff73bd | |||
| 761cf9f3e0 | |||
| 6a9d8a4dd5 | |||
| 4c95b200e4 | |||
| 6ae1312ff1 | |||
| 765ed36340 | |||
| feca776077 | |||
| d79b30a1a6 | |||
| 7db139b83e | |||
| 0987dabfc4 | |||
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb | |||
| ea9c746852 | |||
| d60a51c9e7 | |||
| 9aabb01e2a | |||
| 2815adee03 | |||
| 9f51596e2f | |||
| 87d176153a | |||
| 7d4b4ad736 | |||
| 1c8a32c3fd | |||
| 96b474223d | |||
| 42d7a39c49 | |||
| bad3fa4035 | |||
| d819fc09f0 | |||
| c35979d9f9 | |||
| f3c0540b42 | |||
| 5b4192acb5 | |||
| 16d94894b7 | |||
| ae8d38fc31 | |||
| b2521988e1 | |||
| dae123adac | |||
| 1c9ddbbea2 | |||
| 3f2782f5b7 | |||
| 865623ef3e | |||
| 4fdce3ca2c | |||
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 |
@@ -198,6 +198,32 @@ All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_ll
|
|||||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||||
workspace enforces that the Go and relabelling prompts remain identical.
|
workspace enforces that the Go and relabelling prompts remain identical.
|
||||||
|
|
||||||
|
## Russian patterns — three mechanisms, no fourth
|
||||||
|
|
||||||
|
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||||||
|
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||||||
|
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||||||
|
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||||
|
|
||||||
|
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||||
|
capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is
|
||||||
|
a data change, and there is exactly one copy: months used to live in three files.
|
||||||
|
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||||
|
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||||
|
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||||
|
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||||||
|
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||||||
|
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||||||
|
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||||||
|
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||||||
|
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||||||
|
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||||||
|
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||||||
|
Nexus's, never a local pattern.
|
||||||
|
|
||||||
|
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||||||
|
`TestONNX*` tests, not eyeballed.
|
||||||
|
|
||||||
## Non-goals (hard constraints)
|
## Non-goals (hard constraints)
|
||||||
|
|
||||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||||
|
|||||||
+3
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return "не получилось отметить.", true
|
return phraser.Ack(phraser.FailAck, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return "отлично, отметила.", true
|
return phraser.Ack(phraser.AckNudge, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,10 +59,14 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
|||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
|
// The phraser hands back its own fallback text alongside the error, so the
|
||||||
|
// turn survives a dead server and the failure still reaches the log.
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: chat: %v", err)
|
log.Printf("voice: chat: %v", err)
|
||||||
return "поговорили."
|
}
|
||||||
|
if reply == "" {
|
||||||
|
return phraser.ChatFallback()
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,37 @@ 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.ErrNeedsAuthedSurface):
|
||||||
|
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||||
|
// help: everything that proposed this act — the STT, the router,
|
||||||
|
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||||
|
// none of it. She names the gap instead.
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
|
||||||
|
// one costs a confirm turn, an irreversible one is refused with the reason.
|
||||||
|
func TestActPathSpeaksTheTiers(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := h.now()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cmd []string
|
||||||
|
destructive bool
|
||||||
|
}{
|
||||||
|
{"status", []string{"true"}, false},
|
||||||
|
{"restart", []string{"true"}, true},
|
||||||
|
{"wipe", []string{"rm", "-rf"}, true},
|
||||||
|
} {
|
||||||
|
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
|
||||||
|
t.Fatalf("propose %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
|
||||||
|
t.Fatalf("enable %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
act := func(fn string) string {
|
||||||
|
return h.actionAct(ctx, router.Decision{
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Utterance: fn,
|
||||||
|
Slots: router.Slots{Fn: fn, HasFn: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
|
||||||
|
t.Errorf("safe act replied %q; want it to have run", reply)
|
||||||
|
}
|
||||||
|
// PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
|
||||||
|
// yes/no question — so the confirm turn is recognised by the question.
|
||||||
|
if reply := act("restart"); !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||||
|
}
|
||||||
|
// Clear the confirm the destructive act parked, so what is pending after
|
||||||
|
// the irreversible one is only what the irreversible one parked.
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
reply := act("wipe")
|
||||||
|
if strings.Contains(reply, "да или нет") {
|
||||||
|
t.Fatalf("irreversible act asked for a confirm: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
|
||||||
|
}
|
||||||
|
// Nothing was parked, so a later "да" cannot pick it up.
|
||||||
|
h.mu.Lock()
|
||||||
|
pending := h.pending
|
||||||
|
h.mu.Unlock()
|
||||||
|
if pending != nil {
|
||||||
|
t.Errorf("an irreversible act parked %+v", pending)
|
||||||
|
}
|
||||||
|
// And it is still an enabled row — refusing to run it from voice is not
|
||||||
|
// the same as taking it off the allowlist.
|
||||||
|
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
|
||||||
|
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
// it for recall, and let pattern detection propose a routine.
|
// it for recall, and let pattern detection propose a routine.
|
||||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||||
if !dec.Slots.HasKey {
|
if !dec.Slots.HasKey {
|
||||||
return "не разобрала, что записать — попробуй иначе."
|
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||||
}
|
}
|
||||||
// A question is never a fact about him (#470). "какая последняя версия
|
// A question is never a fact about him (#470). "какая последняя версия
|
||||||
// языка Go?" used to land here, and the value stored was whatever the
|
// языка Go?" used to land here, and the value stored was whatever the
|
||||||
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
factID, err := h.api.WriteFact(ctx, req)
|
factID, err := h.api.WriteFact(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return phraser.Ack(phraser.FailFact, nil)
|
||||||
}
|
}
|
||||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// write). Facts aren't in the notes table, so this is the only recall path
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standing lists on the voice path (Vikunja #453).
|
||||||
|
//
|
||||||
|
// Three halves, mirroring what task capture already does: an add that runs at
|
||||||
|
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
||||||
|
// on the same note path because "всё купил" is note-shaped.
|
||||||
|
//
|
||||||
|
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
||||||
|
// and nothing outside it writes one: the web UI has no list page, no reach
|
||||||
|
// files groceries, and the digestion worker does not read the table. When
|
||||||
|
// something outside mavend needs to add to a list, the ipc seam is what it
|
||||||
|
// grows through — the intake rules that CaptureTaskReq documents are about
|
||||||
|
// shared intake, and there is none here yet.
|
||||||
|
//
|
||||||
|
// Nothing here speaks unprompted. A list is answered when asked about.
|
||||||
|
|
||||||
|
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
||||||
|
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
||||||
|
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
|
if h.dataStore == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Clearing is read before removing on purpose: "всё купил" and "купил
|
||||||
|
// молоко" start with the same word, and only the second one names an item.
|
||||||
|
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
||||||
|
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: clear list: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "в списке и так ничего не было.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула всё, список пустой.", true
|
||||||
|
}
|
||||||
|
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
||||||
|
if reply, ok := h.removeListItem(ctx, cap); ok {
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
||||||
|
// must stay one, so the turn goes back rather than claiming a removal
|
||||||
|
// that removed nothing.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cap, ok := router.ParseListCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
||||||
|
List: cap.List,
|
||||||
|
Item: cap.Item,
|
||||||
|
Source: "tap:voice",
|
||||||
|
CreatedTs: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: add list item: %v", err)
|
||||||
|
return "не получилось добавить в список.", true
|
||||||
|
}
|
||||||
|
if !res.Created {
|
||||||
|
return cap.Item + " уже в списке.", true
|
||||||
|
}
|
||||||
|
return "добавила в список: " + cap.Item + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeListItem crosses one named item off. It reports false when the list
|
||||||
|
// holds nothing by that name, which is what keeps the marker words from
|
||||||
|
// swallowing ordinary notes.
|
||||||
|
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
||||||
|
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list items: %v", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
want := store.NormalizeTaskText(cap.Item)
|
||||||
|
for _, li := range items {
|
||||||
|
if store.NormalizeTaskText(li.Item) != want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
||||||
|
log.Printf("voice: cross off list item: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула: " + li.Item + ".", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryList — "что в списке покупок?", "что мне купить?".
|
||||||
|
//
|
||||||
|
// A query source, so it sits in querySources and either claims the turn or
|
||||||
|
// passes it on. It is before the recall sources for the reason every specific
|
||||||
|
// source is: the notes pass would otherwise answer a list question with
|
||||||
|
// whatever note is nearest.
|
||||||
|
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||||
|
if !ok || h.dataStore == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list items: %v", err)
|
||||||
|
return "не получилось посмотреть список.", true
|
||||||
|
}
|
||||||
|
return formatListRU(list, items), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
||||||
|
// shopping list is heard in a shop and a numbered recital is unusable there.
|
||||||
|
func formatListRU(list string, items []store.ListItem) string {
|
||||||
|
name := "списке " + listGenitive(list)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "в " + name + " пусто."
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, li := range items {
|
||||||
|
names = append(names, li.Item)
|
||||||
|
}
|
||||||
|
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
||||||
|
// declines the noun and she must not say "в списке покупки".
|
||||||
|
func listGenitive(list string) string {
|
||||||
|
switch list {
|
||||||
|
case "покупки":
|
||||||
|
return "покупок"
|
||||||
|
case "аптека":
|
||||||
|
return "аптеки"
|
||||||
|
case "хозяйство":
|
||||||
|
return "хозяйства"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func listHandler(t *testing.T) *reactiveHandler {
|
||||||
|
t.Helper()
|
||||||
|
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
return h.captureListFromNote(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentNote, Utterance: utterance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCaptureAddsAndReadsBack(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
|
||||||
|
if reply, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed (reply %q)", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
|
||||||
|
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
|
||||||
|
}
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
|
||||||
|
t.Errorf("answer %q; want both items", answer)
|
||||||
|
}
|
||||||
|
if strings.Contains(answer, "списке покупки") {
|
||||||
|
t.Errorf("answer %q declines the list name wrong", answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An utterance with no list marker is a note and must stay one, whichever half
|
||||||
|
// of the parser it brushes against.
|
||||||
|
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"молоко закончилось",
|
||||||
|
"надо бы съездить в магазин",
|
||||||
|
"купил новый ноутбук",
|
||||||
|
"добавь в список покупок",
|
||||||
|
} {
|
||||||
|
if reply, ok := askList(t, h, u); ok {
|
||||||
|
t.Errorf("%q was claimed as a list turn: %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"добавь в список покупок молоко",
|
||||||
|
"добавь в список покупок хлеб",
|
||||||
|
"добавь в список аптеки бинт",
|
||||||
|
} {
|
||||||
|
if _, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply, ok := askList(t, h, "вычеркни молоко")
|
||||||
|
if !ok || !strings.Contains(reply, "молоко") {
|
||||||
|
t.Fatalf("cross off replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 1 || open[0].Item != "хлеб" {
|
||||||
|
t.Fatalf("open list %+v; want only хлеб", open)
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
|
||||||
|
t.Errorf("clear replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Errorf("%d items still open after всё купил", len(open))
|
||||||
|
}
|
||||||
|
// The other list is untouched, and it is read back on its own.
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
|
||||||
|
})
|
||||||
|
if !ok || !strings.Contains(answer, "бинт") {
|
||||||
|
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "пусто") {
|
||||||
|
t.Errorf("empty answer %q; want it to say so", answer)
|
||||||
|
}
|
||||||
|
if _, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||||
|
}); ok {
|
||||||
|
t.Error("the list source claimed a task question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 answers a list turn without the model: the grammars route it, and the
|
||||||
|
// action handlers re-parse what the grammar matched.
|
||||||
|
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
}{
|
||||||
|
{"добавь в список покупок молоко", router.IntentNote},
|
||||||
|
{"что в списке покупок?", router.IntentQuery},
|
||||||
|
{"всё купил", router.IntentNote},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
var got router.Intent
|
||||||
|
claimed := false
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch(c.utterance)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dec, ok := g.Build(m); ok {
|
||||||
|
got, claimed = dec.Intent, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
t.Errorf("no list grammar claimed %q", c.utterance)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := g.Build(m); ok {
|
||||||
|
t.Errorf("grammar %s claimed a reminder", g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStoreSourceIsVoice(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
|
||||||
|
t.Fatal("not claimed")
|
||||||
|
}
|
||||||
|
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Source != "tap:voice" {
|
||||||
|
t.Errorf("stored %+v; want one row from tap:voice", items)
|
||||||
|
}
|
||||||
|
if items[0].Status != store.ListItemOpen {
|
||||||
|
t.Errorf("status %q; want open", items[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,19 +18,25 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
|||||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
||||||
|
// for the same reason and at the same cost: before the embedding is paid
|
||||||
|
// for, and it passes the turn straight back when no marker matches.
|
||||||
|
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
noteTs := h.now()
|
noteTs := h.now()
|
||||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||||
|
|||||||
+65
-42
@@ -85,6 +85,11 @@ var querySources = []querySource{
|
|||||||
// the money facts the poller wrote, and the notes pass would otherwise
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
// answer it from whatever he once said about spending. Its matcher needs a
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
|
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||||
|
// about the shopping list, and the recall pass would otherwise answer it
|
||||||
|
// from an old note about the shop. Its matcher needs an explicit list
|
||||||
|
// marker, so "надо бы съездить в магазин" is untouched.
|
||||||
|
{name: "list", answer: (*reactiveHandler).queryList},
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// a question about the feeds she reads, and general knowledge would answer
|
||||||
@@ -147,9 +152,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
// The previous question cannot be re-asked for another day. Saying so
|
// The previous question cannot be re-asked for another day. Saying so
|
||||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||||
// truth is that she never looked.
|
// truth is that she never looked.
|
||||||
return "про другой день так не отвечу — спроси целиком."
|
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||||
}
|
}
|
||||||
return "не знаю."
|
return phraser.Q(phraser.QueryUnknown, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||||
@@ -167,11 +172,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
if dec.Slots.HasTime {
|
if dec.Slots.HasTime {
|
||||||
// The query asks about timing — the fact's own timestamp is the
|
// The query asks about timing — the fact's own timestamp is the
|
||||||
// answer it's looking for. Format as a natural reply.
|
// answer it's looking for. Format as a natural reply.
|
||||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||||
}
|
}
|
||||||
// General fact reference: describe what we know.
|
// General fact reference: describe what we know.
|
||||||
if dec.Utterance == "" {
|
if dec.Utterance == "" {
|
||||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||||
}
|
}
|
||||||
// The utterance still carries the question; fall through to normal RAG
|
// The utterance still carries the question; fall through to normal RAG
|
||||||
// with the resolved key in context.
|
// with the resolved key in context.
|
||||||
@@ -197,7 +202,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
|||||||
plan, err := h.api.DayPlan(ctx)
|
plan, err := h.api.DayPlan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: day plan: %v", err)
|
log.Printf("voice: day plan: %v", err)
|
||||||
return "не получилось собрать план.", true
|
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||||
}
|
}
|
||||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||||
return plan.Spoken, true
|
return plan.Spoken, true
|
||||||
@@ -242,7 +247,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
|||||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: habits: recent facts: %v", err)
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
return "не получилось посмотреть записи.", true
|
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||||
}
|
}
|
||||||
obs := make([]memory.Observation, 0, len(facts))
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
@@ -281,7 +286,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// letting general knowledge answer "что нового?" would be an invented
|
||||||
// news bulletin.
|
// news bulletin.
|
||||||
return "я пока не читаю ленты — они не настроены.", true
|
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||||
// used to push the newest headline out of the window, and she answered "в
|
// used to push the newest headline out of the window, and she answered "в
|
||||||
@@ -289,7 +294,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: feeds: recent notes: %v", err)
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
return "не получилось посмотреть ленты.", true
|
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||||
}
|
}
|
||||||
var picked []string
|
var picked []string
|
||||||
for _, n := range notes {
|
for _, n := range notes {
|
||||||
@@ -306,11 +311,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
if q.Category != "" {
|
if q.Category != "" {
|
||||||
return "по этой теме в лентах пока ничего.", true
|
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||||
}
|
}
|
||||||
return "в лентах пока ничего нового.", true
|
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||||
}
|
}
|
||||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
@@ -324,7 +329,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return "не получилось проверить календарь.", true
|
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||||
}
|
}
|
||||||
// Provenance travels with each event. A work meeting relayed off a phone
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
// notification (source ambient:notif, #126) is stored below full confidence
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
@@ -341,7 +346,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
// calls States and nothing else, so there is no confirm turn here — the only
|
// calls States and nothing else, so there is no confirm turn here — the only
|
||||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isHomeQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.home == nil {
|
if h.home == nil {
|
||||||
@@ -363,38 +368,51 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
|||||||
// because Scan takes no target — the utterance selects the question, never the
|
// because Scan takes no target — the utterance selects the question, never the
|
||||||
// subnet.
|
// subnet.
|
||||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isNetworkQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.netscan == nil {
|
if h.netscan == nil {
|
||||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
// The recogniser already matched, so this is a question about HIS LAN
|
||||||
// swallow "сколько устройств в сети?" before recall has looked.
|
// and there is no scanner to answer it. Falling through sent it to the
|
||||||
return "", false
|
// 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)
|
return h.netscan.scanSummary(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isWeatherQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||||
if loc == "" {
|
if loc == "" {
|
||||||
// He named no city and voice.weather.default_location is unset. Saying
|
// He named no city and voice.weather.default_location is unset. Saying
|
||||||
// so is the only honest answer; picking a city would be inventing one.
|
// so is the only honest answer; picking a city would be inventing one.
|
||||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||||
}
|
}
|
||||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||||
if errors.Is(err, weather.ErrNotConfigured) {
|
if errors.Is(err, weather.ErrNotConfigured) {
|
||||||
return "погода не настроена.", true
|
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||||
|
}
|
||||||
|
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||||
|
// He named a place and the geocoder does not have it. Saying so beats
|
||||||
|
// reading out the default city's temperature (Vikunja #421).
|
||||||
|
return "не знаю такого города — " + loc + ".", true
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: weather: %v", err)
|
log.Printf("voice: weather: %v", err)
|
||||||
return "не получилось узнать погоду.", true
|
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||||
|
"location": w.Location,
|
||||||
|
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||||
|
"word": phraser.Degrees(w.Temperature),
|
||||||
|
"condition": w.Condition,
|
||||||
|
}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||||
@@ -404,7 +422,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
|||||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed query: %v", err)
|
log.Printf("voice: embed query: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||||
}
|
}
|
||||||
t.vec = vec
|
t.vec = vec
|
||||||
return "", false
|
return "", false
|
||||||
@@ -445,7 +463,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
|||||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||||
// stored.
|
// stored.
|
||||||
if hit.Meta["type"] == "note" {
|
if hit.Meta["type"] == "note" {
|
||||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||||||
|
switch {
|
||||||
|
case perr != nil:
|
||||||
|
// Reading the note back verbatim beats the phraser's own fallback,
|
||||||
|
// which only wraps the same text in "вот что я нашла:".
|
||||||
|
log.Printf("voice: recall phrase: %v", perr)
|
||||||
|
case reply != "":
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +491,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: query notes: %v", err)
|
log.Printf("voice: query notes: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||||
}
|
}
|
||||||
t.notes = notes
|
t.notes = notes
|
||||||
noteScores := make([]float64, len(notes))
|
noteScores := make([]float64, len(notes))
|
||||||
@@ -492,7 +516,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: phrase query: %v", err)
|
log.Printf("voice: phrase query: %v", err)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = "вот что я нашла: " + texts[0]
|
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -514,25 +538,24 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.crawler == nil {
|
if h.crawler == nil {
|
||||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
// He named a URL, so the question is about that page and nothing else
|
||||||
// where it was never turned on the older behaviour is right: the model
|
// can answer it. The older comment here argued for falling through and
|
||||||
// answers the question as if the URL had not been said. Announcing a
|
// letting the model answer as if the URL had not been said; that is a
|
||||||
// configuration status is for a capability that exists and failed, not
|
// guess dressed as an answer (Vikunja #479).
|
||||||
// for one he never asked for.
|
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
page, err := h.crawler.Page(ctxFetch, link)
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, crawl.ErrRobots) {
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: web: %v", err)
|
log.Printf("voice: web: %v", err)
|
||||||
return "не получилось прочитать страницу.", true
|
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||||
}
|
}
|
||||||
if page.Text == "" {
|
if page.Text == "" {
|
||||||
return "страница открылась, но читать там нечего.", true
|
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||||
}
|
}
|
||||||
// The page is handed to the phraser the same way a note is: as context for
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
// the question he actually asked. She answers the question, she does not
|
// the question he actually asked. She answers the question, she does not
|
||||||
@@ -542,7 +565,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser (or it failed): read back the top of the page rather than
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
// pretend the fetch did not happen.
|
// pretend the fetch did not happen.
|
||||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -608,7 +631,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best evidence rather than
|
// No phraser, or it failed. Read back the best evidence rather than
|
||||||
// pretend the search did not happen.
|
// pretend the search did not happen.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -689,7 +712,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||||
// the search did not happen.
|
// the search did not happen.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -719,7 +742,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||||
return "не знаю — не нашла у тебя такой записи.", true
|
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||||
@@ -787,15 +810,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
|||||||
if h.phraser == nil {
|
if h.phraser == nil {
|
||||||
// No model of any size. That is not the workstation being asleep, so it
|
// No model of any size. That is not the workstation being asleep, so it
|
||||||
// is not that gap: it is simply not knowing.
|
// is not that gap: it is simply not knowing.
|
||||||
return "не знаю.", true
|
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||||
}
|
}
|
||||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||||
return worldGap, true
|
return worldGap(), true
|
||||||
}
|
}
|
||||||
if err != nil || reply == "" {
|
if err != nil || reply == "" {
|
||||||
return "не знаю.", true
|
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dec.Slots.HasTime {
|
if !dec.Slots.HasTime {
|
||||||
return "не получилось разобрать время напоминания."
|
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||||
log.Printf("voice: create reminder: %v", err)
|
log.Printf("voice: create reminder: %v", err)
|
||||||
return "не получилось поставить напоминание."
|
return phraser.Ack(phraser.FailReminder, nil)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"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"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: capture task: %v", err)
|
log.Printf("voice: capture task: %v", err)
|
||||||
return "не получилось записать задачу.", true
|
return phraser.Ack(phraser.FailTask, nil), true
|
||||||
}
|
}
|
||||||
if resp.Promoted {
|
if resp.Promoted {
|
||||||
// It was a candidate Maven derived from something she read, and he has
|
// It was a candidate Maven derived from something she read, and he has
|
||||||
// now said it himself. Saying "уже в списке" here would be answering a
|
// now said it himself. Saying "уже в списке" here would be answering a
|
||||||
// confirmation with a shrug.
|
// confirmation with a shrug.
|
||||||
return "поняла, беру в работу: " + cap.Text, true
|
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
if !resp.Created {
|
if !resp.Created {
|
||||||
return "это уже в списке.", true
|
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||||
}
|
}
|
||||||
return "записала: " + cap.Text, true
|
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||||
t.Errorf("reply = %q, want an honest failure", reply)
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|||||||
if !(openIdx < candIdx) {
|
if !(openIdx < candIdx) {
|
||||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
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)
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
|
|||||||
// out, and "" when nothing was parked. Call it right after
|
// out, and "" when nothing was parked. Call it right after
|
||||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||||
// only reported here — the words themselves still go on to be routed fresh.
|
// only reported here — the words themselves still go on to be routed fresh.
|
||||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||||
@@ -157,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|||||||
// askClarify parks the request and returns the question to ask instead of the
|
// askClarify parks the request and returns the question to ask instead of the
|
||||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||||
// the caller falls back to the canned reply.
|
// the caller falls back to the canned reply.
|
||||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
Missing: []dialogue.Slot{slot},
|
Missing: []dialogue.Slot{slot},
|
||||||
@@ -192,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -206,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
// would fire at 11:00 saying "напомни" and nothing else.
|
// would fire at 11:00 saying "напомни" and nothing else.
|
||||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||||
return h.reaskOrGiveUp(q, merged, text), true
|
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||||
}
|
}
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
|
||||||
// One gap filled is not the same as a complete request. askClarify parks
|
// One gap filled is not the same as a complete request. askClarify parks
|
||||||
// only the first gap, because one question per turn is the rule, but a
|
// only the first gap, because one question per turn is the rule, but a
|
||||||
@@ -217,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
// a reminder with no time, which answered "не получилось разобрать время
|
// a reminder with no time, which answered "не получилось разобрать время
|
||||||
// напоминания." — an error for a request she never finished asking about.
|
// напоминания." — an error for a request she never finished asking about.
|
||||||
// Re-enter the loop instead, one question at a time as before.
|
// Re-enter the loop instead, one question at a time as before.
|
||||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
|||||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||||
// costs a question exactly like a second try at the first one does, so the cap
|
// costs a question exactly like a second try at the first one does, so the cap
|
||||||
// still bounds how many times she can speak before acting or letting go.
|
// still bounds how many times she can speak before acting or letting go.
|
||||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -270,7 +270,7 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
|
|||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: q.Intent,
|
Intent: q.Intent,
|
||||||
Slots: merged,
|
Slots: merged,
|
||||||
Missing: []dialogue.Slot{remaining[0]},
|
Missing: []dialogue.Slot{remaining[0]},
|
||||||
@@ -287,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
|
|||||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||||
// again while she has attempts left, otherwise say she did not understand and
|
// again while she has attempts left, otherwise say she did not understand and
|
||||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||||
question := ""
|
question := ""
|
||||||
if len(q.Missing) > 0 {
|
if len(q.Missing) > 0 {
|
||||||
question = clarifyQuestions[q.Missing[0]]
|
question = clarifyQuestions[q.Missing[0]]
|
||||||
}
|
}
|
||||||
if question == "" || !q.CanAsk() {
|
if question == "" || !q.CanAsk() {
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||||
return clarifyGaveUp
|
return clarifyGaveUp
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
|
|||||||
q.Slots = merged
|
q.Slots = merged
|
||||||
q.Attempts++
|
q.Attempts++
|
||||||
q.Asked = h.now()
|
q.Asked = h.now()
|
||||||
h.clarifyStore.Put(voiceDialogueID, q)
|
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||||
return question
|
return question
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-21
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||||
if !asked || question != "Когда?" {
|
if !asked || question != "Когда?" {
|
||||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||||
t.Fatal("a fact with no key should be asked about")
|
t.Fatal("a fact with no key should be asked about")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||||
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, now := newClarifyHandler(t)
|
h, st, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a first question")
|
t.Fatal("expected a first question")
|
||||||
}
|
}
|
||||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||||
@@ -185,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||||
@@ -199,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// First answer parses, but re-park it by hand as if she had asked again:
|
// First answer parses, but re-park it by hand as if she had asked again:
|
||||||
@@ -232,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
|||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||||
t.Fatal("an act with no fn should be asked about")
|
t.Fatal("an act with no fn should be asked about")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||||
@@ -260,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||||
@@ -284,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||||
} {
|
} {
|
||||||
if question, asked := h.askClarify(dec); asked {
|
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||||
// TTL: she must say the old request is gone AND still answer the new words.
|
// TTL: she must say the old request is gone AND still answer the new words.
|
||||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.embedder = emb
|
h.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "как дела")
|
reply := h.handleText(ctx, "", "как дела")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||||
}
|
}
|
||||||
if trimClarifyExpired(reply) == "" {
|
if trimClarifyExpired(reply) == "" {
|
||||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
// The notice is said once, not on every later utterance.
|
// The notice is said once, not on every later utterance.
|
||||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||||
if !asked || question != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||||
t.Fatal("expected the subject question")
|
t.Fatal("expected the subject question")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||||
@@ -440,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
// The confirm turn used to return before the notice was even computed, so he
|
// The confirm turn used to return before the notice was even computed, so he
|
||||||
// answered the confirm and never heard that the older request was let go.
|
// answered the confirm and never heard that the older request was let go.
|
||||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// A confirm parked with a longer life than the question, so only the
|
// A confirm parked with a longer life than the question, so only the
|
||||||
@@ -451,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "нет")
|
reply := h.handleText(ctx, "", "нет")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||||
}
|
}
|
||||||
@@ -461,7 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
if h.pending != nil {
|
if h.pending != nil {
|
||||||
t.Fatal("the confirm must still have been consumed")
|
t.Fatal("the confirm must still have been consumed")
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,7 +476,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
at := h.now().Add(2 * time.Hour)
|
at := h.now().Add(2 * time.Hour)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||||
if !asked || question != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
@@ -501,3 +501,52 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|||||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||||
|
// was asked. Before this the clarify store had one global key, so a question
|
||||||
|
// asked in the web chat and never answered captured the next utterance from
|
||||||
|
// telegram, or from the mic, and answered it against a request the speaker had
|
||||||
|
// never made (Vikunja #466).
|
||||||
|
func TestClarifyIsPerConversation(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||||
|
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||||
|
|
||||||
|
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question on the web conversation")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||||
|
}
|
||||||
|
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||||
|
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||||
|
func voiceCtx() context.Context {
|
||||||
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-11
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daemon where page reading was never turned on — the default — answers the
|
// A daemon where page reading was never turned on names the gap. He asked
|
||||||
// question the way it did before the capability existed. Claiming the turn to
|
// about one page, nothing else on the box can read it, and the old behaviour
|
||||||
// report a configuration status is for something that exists and failed.
|
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
h := buildWebHandler(nil)
|
h := buildWebHandler(nil)
|
||||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,21 +160,22 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
// robots.txt is honoured on the answer path too, and she says the page is
|
||||||
// reporting a generic failure.
|
// closed instead of reporting a generic failure.
|
||||||
func TestQueryWebObeysRobots(t *testing.T) {
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "robots.txt") {
|
// She names the cause without reading a filename out loud.
|
||||||
t.Errorf("reply = %q, want the robots answer", reply)
|
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||||
|
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
}
|
}
|
||||||
if reply != "не получилось собрать план." {
|
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||||
t.Errorf("reply = %q", reply)
|
t.Errorf("reply = %q, want the honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+183
-32
@@ -9,10 +9,25 @@ 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"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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
|
// verb aliases it answers to, the trace name it records, and its own reply
|
||||||
@@ -144,10 +159,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
|
||||||
@@ -157,6 +172,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
importance, _ := item["importance"].(float64)
|
importance, _ := item["importance"].(float64)
|
||||||
rule, _ := item["rule"].(string)
|
rule, _ := item["rule"].(string)
|
||||||
s := title
|
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 {
|
if importance > 0 {
|
||||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||||
if rule != "" {
|
if rule != "" {
|
||||||
@@ -175,7 +195,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.
|
// listChangesCapability reads the recent-changes feed.
|
||||||
@@ -192,19 +218,29 @@ 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
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
title, _ := c["title"].(string)
|
title, _ := c["title"].(string)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
typ, _ := c["change_type"].(string)
|
typ, _ := c["change_type"].(string)
|
||||||
|
if typ == "" {
|
||||||
|
parts = append(parts, title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
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
|
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||||
@@ -230,12 +266,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 +284,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, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
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 +304,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 +315,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 +339,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 +406,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 += ", и это не всё"
|
||||||
}
|
}
|
||||||
@@ -497,6 +533,64 @@ func traceErrorFields(err error) map[string]any {
|
|||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityResolution — what asking Nexus about a turn's candidate names came to.
|
||||||
|
// One shape rather than five return values, because the caller needs the
|
||||||
|
// reference that answered as well as the answer: it goes in the trace.
|
||||||
|
type entityResolution struct {
|
||||||
|
subject string // the reference Nexus answered about
|
||||||
|
entityID string // set when exactly one name resolved
|
||||||
|
displayName string // that entity's name as Nexus spells it
|
||||||
|
ambiguous []string // candidate display names to ask between
|
||||||
|
err error // a dependency failure, not a miss
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEntityCandidates asks Nexus about each name the turn offered and
|
||||||
|
// reports what it knows, stopping early where the answer is already decided.
|
||||||
|
//
|
||||||
|
// The rules, in the order they apply:
|
||||||
|
//
|
||||||
|
// - A dependency failure ends it. Nexus being down is not "no such entity",
|
||||||
|
// and asking about the next name would report the outage as a miss.
|
||||||
|
// - Nexus calling one name ambiguous ends it. It has the candidates and it is
|
||||||
|
// telling us to ask.
|
||||||
|
// - Two names resolving to different entities is a clarify too, this time ours:
|
||||||
|
// "перезапусти nginx на muzick-indexer" names both a service and its host,
|
||||||
|
// and picking either would be inventing an intent he did not state.
|
||||||
|
// - Nothing resolving returns the first name as the subject, so the trace says
|
||||||
|
// what was actually looked for.
|
||||||
|
func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution {
|
||||||
|
var out entityResolution
|
||||||
|
for _, ref := range refs {
|
||||||
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil)
|
||||||
|
if err != nil {
|
||||||
|
return entityResolution{subject: ref, err: err}
|
||||||
|
}
|
||||||
|
if len(ambiguous) > 0 {
|
||||||
|
return entityResolution{subject: ref, ambiguous: ambiguous}
|
||||||
|
}
|
||||||
|
if entityID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.entityID == "" {
|
||||||
|
out = entityResolution{subject: ref, entityID: entityID, displayName: displayName}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entityID == out.entityID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Both are real and they are not the same thing. Hand back the names
|
||||||
|
// Nexus spells, not the words he happened to say.
|
||||||
|
return entityResolution{
|
||||||
|
subject: out.subject,
|
||||||
|
ambiguous: []string{out.displayName, displayName},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.entityID == "" && len(refs) > 0 {
|
||||||
|
out.subject = refs[0]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// handleHexisAct — resolves entity references through Nexus and executes
|
// handleHexisAct — resolves entity references through Nexus and executes
|
||||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||||
// or "" to fall through to the system command executor.
|
// or "" to fall through to the system command executor.
|
||||||
@@ -514,28 +608,31 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
// The names come from entityReferences, not straight from the Text slot: the
|
||||||
|
// model transliterates Latin names as it routes (Vikunja #476, #524).
|
||||||
started := h.now()
|
started := h.now()
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||||
|
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
// 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, serviceVars(serviceNexus))
|
||||||
}
|
}
|
||||||
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,
|
||||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
map[string]any{"subject": redactSubject(subject)})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||||
@@ -550,9 +647,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, serviceVars(serviceHexis))
|
||||||
}
|
}
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||||
}
|
}
|
||||||
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)})
|
||||||
@@ -569,10 +666,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
verbLower := strings.ToLower(verb)
|
verbLower := strings.ToLower(verb)
|
||||||
|
|
||||||
|
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||||
|
// indexer"), which no capability name ever contains. Read it the other way
|
||||||
|
// round then: the phrase is the haystack and the capability name is what we
|
||||||
|
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||||
|
// fn is a single verb and containment already means what it says.
|
||||||
|
loose := !dec.Slots.HasFn
|
||||||
var matches []*hexisclient.Capability
|
var matches []*hexisclient.Capability
|
||||||
for i, c := range caps {
|
for i, c := range caps {
|
||||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
name := strings.ToLower(c.Name)
|
||||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
hit := strings.Contains(name, verbLower) ||
|
||||||
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
|
||||||
|
if loose && name != "" && strings.Contains(verbLower, name) {
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
matches = append(matches, &caps[i])
|
matches = append(matches, &caps[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,13 +692,30 @@ 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]
|
||||||
|
|
||||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||||
// explicit spoken confirm bound to this capability + target.
|
// explicit spoken confirm bound to this capability + target.
|
||||||
if !matched.ReadOnly {
|
// The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone
|
||||||
|
// used to decide it here, which flattened three answers into two: a
|
||||||
|
// capability that wipes the thing it names got the same single spoken "да"
|
||||||
|
// as one that restarts a service, and requires_confirmation — which the
|
||||||
|
// Hexis contract calls server-derived and not settable by a caller — was
|
||||||
|
// read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.
|
||||||
|
tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation)
|
||||||
|
policy := tool.PolicyFor(tier)
|
||||||
|
if !policy.VoiceMayRun {
|
||||||
|
// Irreversible. A confirm turn would not help, for the same reason it
|
||||||
|
// does not help a local row: the STT heard it, the model routed it and
|
||||||
|
// a substring matched the capability, and a spoken "да" checks none of
|
||||||
|
// those. She names the gap and he runs it himself.
|
||||||
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started,
|
||||||
|
map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)})
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
|
}
|
||||||
|
if policy.Confirm {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.pendingHexis = &pendingHexisExec{
|
h.pendingHexis = &pendingHexisExec{
|
||||||
capabilityID: matched.ID,
|
capabilityID: matched.ID,
|
||||||
@@ -602,7 +727,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, "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 +747,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 +755,31 @@ 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
// asks what to do.
|
||||||
|
//
|
||||||
|
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||||
|
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||||
|
// path was never entered — the capability existed and no utterance could reach
|
||||||
|
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||||
|
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||||
|
// not against the allowlist.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||||
|
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||||
|
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||||
|
// matching capability, and then she asks after all. Authority is unchanged:
|
||||||
|
// resolution stops on ambiguity and a mutating capability still goes through
|
||||||
|
// the spoken confirm in handleHexisAct.
|
||||||
|
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||||
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.handleHexisAct(ctx, dec)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
// 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.
|
// store is the one thing the Praxis path could inherit it through.
|
||||||
nexus.SetFault(503)
|
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)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
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") {
|
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
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)
|
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 == "" {
|
if reply == "" {
|
||||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
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)
|
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
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)
|
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
|||||||
nexus := newFakeNexus(t, body)
|
nexus := newFakeNexus(t, body)
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
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)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
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)
|
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
|||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
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)
|
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
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)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
exec := tracesFor(t, h, "hexis", "execute")
|
exec := tracesFor(t, h, "hexis", "execute")
|
||||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
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)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(traces(t, h)) == 0 {
|
if len(traces(t, h)) == 0 {
|
||||||
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Errorf("%s: total outage must not answer with silence", name)
|
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)
|
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)
|
nexus.SetFault(503)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
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)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
nexus.SetFault(0)
|
nexus.SetFault(0)
|
||||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
|||||||
|
|
||||||
// The follow-up "да" turn executes exactly the parked capability.
|
// The follow-up "да" turn executes exactly the parked capability.
|
||||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
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)
|
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||||
}
|
}
|
||||||
if !*executed {
|
if !*executed {
|
||||||
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
|||||||
if h.pendingHexis != nil {
|
if h.pendingHexis != nil {
|
||||||
t.Fatal("read-only cap should not park a confirmation")
|
t.Fatal("read-only cap should not park a confirmation")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("unexpected reply %q", reply)
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("nexus dependency failure must not fall through with an empty 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)
|
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("hexis dependency failure must not fall through with an empty 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)
|
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,3 +242,73 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
|||||||
t.Fatal("not_found resolution must never execute a hexis capability")
|
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"
|
||||||
|
|
||||||
|
// read_only used to be the whole decision on this path, which meant a
|
||||||
|
// capability that destroys what it names got the same single spoken "да" as one
|
||||||
|
// that restarts a service. Hexis declares the tier and the voice path is not an
|
||||||
|
// authorised surface for the top one (Vikunja #523).
|
||||||
|
func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("an irreversible capability ran from the voice path")
|
||||||
|
}
|
||||||
|
if h.pendingHexis != nil {
|
||||||
|
t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("reply = %q; want it to name why she will not run it", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half: Hexis calling a capability safe is enough to run it, even
|
||||||
|
// though read_only is the field that used to decide. Nothing here re-derives.
|
||||||
|
func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !*executed {
|
||||||
|
t.Fatal("a capability Hexis calls safe should run")
|
||||||
|
}
|
||||||
|
if !actRan(reply) {
|
||||||
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mutating capability with no declared tier keeps the confirm turn it has
|
||||||
|
// always had, so the split does not quietly loosen an existing box.
|
||||||
|
func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("a mutating capability ran without a confirm")
|
||||||
|
}
|
||||||
|
if h.pendingHexis == nil {
|
||||||
|
t.Fatal("a mutating capability did not park a confirm")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("reply = %q; want the confirm question", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
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)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// latinRun matches a run of Latin-script words — the shape a service, host or
|
||||||
|
// project name takes in a Russian sentence. Digits, dot, dash and underscore
|
||||||
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
|
// maxEntityReferences caps how many names one utterance may send to Nexus. The
|
||||||
|
// cap is not about correctness, it is about one turn not fanning out into a
|
||||||
|
// dozen HTTP calls when the utterance is a paragraph of English.
|
||||||
|
const maxEntityReferences = 4
|
||||||
|
|
||||||
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
|
func hasLatin(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.In(r, unicode.Latin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityReferences returns the names Nexus is asked to resolve, in the order
|
||||||
|
// they were said.
|
||||||
|
//
|
||||||
|
// Normally there is one, and it is the router's Text slot — the verb phrase the
|
||||||
|
// model wrote. But the resident model rewrites a Russian utterance as it routes,
|
||||||
|
// and on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
|
// service nobody has ever named, so the act cannot resolve its target even with
|
||||||
|
// every gate open.
|
||||||
|
//
|
||||||
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin run
|
||||||
|
// and the model's Text holds none has a name certainly been rewritten. Anything
|
||||||
|
// else keeps the Text slot, so an English utterance and a Russian entity name
|
||||||
|
// are both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
|
// invent a second name to be wrong about.
|
||||||
|
//
|
||||||
|
// What this does NOT do is pick. It used to return the longest run, and length
|
||||||
|
// is a guess: "перезапусти nginx на muzick-indexer" has two names in it and the
|
||||||
|
// longer one is not reliably the target. Nexus owns which names it knows
|
||||||
|
// (docs/ecosystem.md — ambiguous resolution asks the owner, it does not pick),
|
||||||
|
// so every run goes over and Nexus answers. Two runs that both resolve are a
|
||||||
|
// clarify, not a coin toss.
|
||||||
|
func entityReferences(dec router.Decision) []string {
|
||||||
|
text := dec.Slots.Text
|
||||||
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
var refs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
// A single stray letter is not a name.
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(m)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
refs = append(refs, m)
|
||||||
|
if len(refs) == maxEntityReferences {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntityReferences pins when his own words win over the model's, and that
|
||||||
|
// every name he said goes over rather than one of them being picked.
|
||||||
|
func TestEntityReferences(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
utterance string
|
||||||
|
text string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the model transliterated the name",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить музик индексер",
|
||||||
|
want: []string{"muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "it kept the name, so nothing to repair",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить muzick indexer",
|
||||||
|
want: []string{"перезагрузить muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
|
utterance: "перезапусти домашний сервер",
|
||||||
|
text: "перезагрузить домашний сервер",
|
||||||
|
want: []string{"перезагрузить домашний сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an English turn never enters the recovery",
|
||||||
|
utterance: "restart muzick indexer",
|
||||||
|
text: "restart muzick indexer",
|
||||||
|
want: []string{"restart muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both names go over, in the order he said them",
|
||||||
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx", "muzick-indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one stray letter is not a name",
|
||||||
|
utterance: "перезапусти сервер a",
|
||||||
|
text: "перезагрузить сервер",
|
||||||
|
want: []string{"перезагрузить сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the same name twice is one question",
|
||||||
|
utterance: "перезапусти nginx, ну правда, nginx",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
|
got := entityReferences(dec)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||||
|
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||||
|
// the service that exists.
|
||||||
|
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
h.handleHexisAct(ctx, dec)
|
||||||
|
|
||||||
|
reqs := nexus.Requests()
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
t.Fatal("nexus was never asked")
|
||||||
|
}
|
||||||
|
body := string(reqs[0].Body)
|
||||||
|
if !strings.Contains(body, "muzick indexer") {
|
||||||
|
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||||
|
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||||
|
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||||
|
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||||
|
}
|
||||||
|
reply := h.hexisBeforeClarify(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||||
|
}
|
||||||
|
if hexis.Count("", "/api/v1") == 0 {
|
||||||
|
t.Fatal("hexis was never contacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||||
|
// she asks exactly what she asked before.
|
||||||
|
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||||
|
}
|
||||||
|
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||||
|
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||||
|
}
|
||||||
|
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||||
|
t.Fatal("she must still ask what to do")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nexusInOrder serves one resolve answer per call, in order, so a test can say
|
||||||
|
// what Nexus knows about the first name and what it knows about the second. The
|
||||||
|
// last body repeats once the list runs out.
|
||||||
|
func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
n := 0
|
||||||
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||||
|
"POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
body := bodies[min(n, len(bodies)-1)]
|
||||||
|
n++
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
|
||||||
|
// service and the host it runs on. Both are real, and which one he meant is not
|
||||||
|
// in the utterance, so she asks. Picking one by length was the old behaviour and
|
||||||
|
// length is not evidence (Vikunja #524).
|
||||||
|
func TestTwoResolvedNamesAsk(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusResolved("ent_nginx", "nginx", "service"),
|
||||||
|
fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
||||||
|
t.Fatalf("reply = %q, want both names she found", reply)
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||||
|
t.Fatal("she must not execute against a target she is still asking about")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
|
||||||
|
// an entity, so there is nothing to ask about and the act runs.
|
||||||
|
func TestTheNameNexusKnowsWins(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusNotFound(),
|
||||||
|
fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the resolvable name must carry the act")
|
||||||
|
}
|
||||||
|
if len(nexus.Requests()) != 2 {
|
||||||
|
t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") == 0 {
|
||||||
|
t.Fatal("hexis was never asked to run it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||||
}
|
}
|
||||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||||
if !strings.Contains(reply, "ничего") {
|
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||||
// answered by the model inventing a bulletin.
|
// answered by the model inventing a bulletin.
|
||||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||||
|
// Against the entries, not against a substring: both of these have several
|
||||||
|
// wordings, so "ничего нового" passed only on the turns the picker happened
|
||||||
|
// to choose the first one.
|
||||||
off := buildFeedHandler(t, false)
|
off := buildFeedHandler(t, false)
|
||||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "не настроены") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
on := buildFeedHandler(t, true)
|
on := buildFeedHandler(t, true)
|
||||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
|
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
|
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||||
|
|||||||
+50
-3
@@ -1,17 +1,64 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||||
|
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||||
const voiceDialogueID = "voice"
|
const voiceDialogueID = "voice"
|
||||||
|
|
||||||
|
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||||
|
// Separate from the mic: an old client that sends no id still must not answer
|
||||||
|
// a question she asked out loud.
|
||||||
|
const textDialogueID = "text"
|
||||||
|
|
||||||
|
// dialogueKey — the context key carrying the id of the conversation this turn
|
||||||
|
// belongs to. It rides the context rather than a parameter for the same reason
|
||||||
|
// the correlation id does: every step of the turn needs it, most of them only
|
||||||
|
// to hand to the next one, and threading it by hand would put it in six
|
||||||
|
// clarify signatures that have nothing else to say about it.
|
||||||
|
type dialogueKey struct{}
|
||||||
|
|
||||||
|
// dialogueIDFor builds the id a turn is held under: the conversation the reach
|
||||||
|
// named, qualified by the tap it arrived on, or the tap's own fallback when it
|
||||||
|
// named none.
|
||||||
|
//
|
||||||
|
// A parked clarifying question used to be held under voiceDialogueID no matter
|
||||||
|
// where the turn came from, so one unanswerable question captured the next
|
||||||
|
// three utterances from anywhere. Three independent curl sessions fed a
|
||||||
|
// capture attempt that had already failed, and a reminder among them was lost
|
||||||
|
// (Vikunja #466).
|
||||||
|
func dialogueIDFor(src turnSource, conversation string) string {
|
||||||
|
if conversation != "" {
|
||||||
|
return string(src) + ":" + conversation
|
||||||
|
}
|
||||||
|
if src == sourceVoice {
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
return textDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDialogueID tags a turn with that id.
|
||||||
|
func withDialogueID(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, dialogueKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
|
||||||
|
// what an unthreaded caller — a test, an internal replay — gets.
|
||||||
|
func dialogueIDOf(ctx context.Context) string {
|
||||||
|
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
||||||
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
||||||
// cycle), so the two structs are hand-kept copies and every field has to be
|
// cycle), so the two structs are hand-kept copies and every field has to be
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
|
||||||
|
// per day says what was not done, then what he could still do (Vikunja #473).
|
||||||
|
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
|
||||||
|
cand := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{
|
||||||
|
{Key: "meds", Label: "таблетки"},
|
||||||
|
{Key: "stretch", Label: "растяжка", Optional: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
if !strings.Contains(body, "не сделано — таблетки") {
|
||||||
|
t.Fatalf("the required item must be named as not done: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "если будет время — растяжка") {
|
||||||
|
t.Fatalf("the optional item must read softer: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "не сделано — таблетки, растяжка") {
|
||||||
|
t.Fatalf("optional must not be folded into the required list: %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing optional missing: the sentence is what it always was.
|
||||||
|
only := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
|
||||||
|
}
|
||||||
|
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
|
||||||
|
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-20
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/netscan"
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
res, err := w.scan(ctx)
|
res, err := w.scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("netscan: scan: %v", err)
|
log.Printf("netscan: scan: %v", err)
|
||||||
return "не получилось просканировать сеть.", true
|
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||||
}
|
}
|
||||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||||
// устройств" after stopping two thirds of the way through the range is a
|
// устройств" after stopping two thirds of the way through the range is a
|
||||||
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
tail = ", но успела посмотреть не всю сеть"
|
tail = ", но успела посмотреть не всю сеть"
|
||||||
}
|
}
|
||||||
if len(res.Hosts) == 0 {
|
if len(res.Hosts) == 0 {
|
||||||
return "в сети никого не нашла" + tail + ".", true
|
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if shape := scanShape(res.Hosts); shape != "" {
|
if shape := scanShape(res.Hosts); shape != "" {
|
||||||
out += ", " + shape
|
out += ", " + shape
|
||||||
}
|
}
|
||||||
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
if w.api == nil {
|
if w.api == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if res.Truncated {
|
if res.Truncated {
|
||||||
head += " (не вся сеть)"
|
head += " (не вся сеть)"
|
||||||
}
|
}
|
||||||
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
|
||||||
// 5+ устройств, and the teens are all the last form.
|
|
||||||
func hostWord(n int) string {
|
|
||||||
if n%100 >= 11 && n%100 <= 14 {
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
switch n % 10 {
|
|
||||||
case 1:
|
|
||||||
return "устройство"
|
|
||||||
case 2, 3, 4:
|
|
||||||
return "устройства"
|
|
||||||
default:
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
|||||||
+21
-12
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
@@ -74,18 +76,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
|
||||||
for n, want := range map[int]string{
|
|
||||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
|
||||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
|
||||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
|
||||||
} {
|
|
||||||
if got := hostWord(n); got != want {
|
|
||||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsNetworkQuery(t *testing.T) {
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
yes := []string{
|
yes := []string{
|
||||||
"какие устройства в сети?",
|
"какие устройства в сети?",
|
||||||
@@ -169,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|||||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/phraser/eval"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The persona checks, run before she speaks (Vikunja #399).
|
||||||
|
//
|
||||||
|
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
||||||
|
// everything the fixtures measured was offline knowledge: we could say "about
|
||||||
|
// one reply in three is broken" and still ship every one of them. This runs the
|
||||||
|
// cheap half of that on the live path, and replaces a failing message with the
|
||||||
|
// deterministic floor.
|
||||||
|
//
|
||||||
|
// Which checks: the unambiguous string tests only — feminine self-reference,
|
||||||
|
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
||||||
|
// path-specific, and not ontopic, which compares against fragments the fixture
|
||||||
|
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
||||||
|
//
|
||||||
|
// No retry. A retry doubles the latency on the exact turn that is already going
|
||||||
|
// badly, and on the nudge path the moment has passed.
|
||||||
|
//
|
||||||
|
// The known cost, written down because it is real: a wrongly flagged good reply
|
||||||
|
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
||||||
|
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
||||||
|
// no longer be tuned for sensitivity alone.
|
||||||
|
|
||||||
|
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
||||||
|
const checkLeak = "leak"
|
||||||
|
|
||||||
|
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
||||||
|
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
||||||
|
// the failure mode, not a hypothetical (Vikunja #398).
|
||||||
|
var leakPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
||||||
|
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
||||||
|
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
||||||
|
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
||||||
|
// still carries the keys is one it could not read.
|
||||||
|
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
||||||
|
// The persona block quoted back at him.
|
||||||
|
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
||||||
|
func checkPersonaLeak(body string) (string, bool) {
|
||||||
|
for _, re := range leakPatterns {
|
||||||
|
if m := re.FindString(body); m != "" {
|
||||||
|
return "leaked " + strings.TrimSpace(m), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// personaRejects counts what the guard caught, by check name, so the real
|
||||||
|
// production rate is knowable rather than inferred from the fixture.
|
||||||
|
var personaRejects = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
by map[string]int
|
||||||
|
}{by: map[string]int{}}
|
||||||
|
|
||||||
|
func personaRejectCounts() map[string]int {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
defer personaRejects.mu.Unlock()
|
||||||
|
out := make(map[string]int, len(personaRejects.by))
|
||||||
|
for k, v := range personaRejects.by {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSpoken checks a phrased message. It returns the failed check and false
|
||||||
|
// when the message must not be said; path names the caller, for the log.
|
||||||
|
//
|
||||||
|
// An empty message passes: the caller already treats that as a failure and
|
||||||
|
// falls back on its own, and reporting it as a persona breach would put a
|
||||||
|
// misleading line in the count.
|
||||||
|
func guardSpoken(path, body string) (string, bool) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if detail, ok := checkPersonaLeak(body); !ok {
|
||||||
|
return rejectSpoken(path, checkLeak, detail, body), false
|
||||||
|
}
|
||||||
|
// Feminine and address only. HisGender is not run here: it reads a
|
||||||
|
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
||||||
|
// воды" — as a woman being addressed, when it is her own correct
|
||||||
|
// self-reference. Offline that is a point of score; on this path it would
|
||||||
|
// replace a good reply with a stub one on every fact she confirms.
|
||||||
|
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
||||||
|
if !r.Pass {
|
||||||
|
return rejectSpoken(path, r.Name, r.Detail, body), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
||||||
|
// prefix: the point of the log line is that the failure can be read back later
|
||||||
|
// and argued with.
|
||||||
|
func rejectSpoken(path, check, detail, body string) string {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
personaRejects.by[check]++
|
||||||
|
personaRejects.mu.Unlock()
|
||||||
|
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
||||||
|
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
||||||
|
// tick has already decided she speaks, so the choice is the floor's wording or
|
||||||
|
// a broken sentence.
|
||||||
|
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
||||||
|
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
||||||
|
if err != nil {
|
||||||
|
// The Stub is templates over the candidate and does not fail. If it
|
||||||
|
// somehow does, the model's text is still what the rule decided to
|
||||||
|
// say, and saying nothing is the worse outcome.
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
return stub
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
||||||
|
good := []string{
|
||||||
|
"записала: купить хлеб.",
|
||||||
|
"поняла, напомню в 11:00.",
|
||||||
|
"ты не пил воду с утра.",
|
||||||
|
"я рада, что получилось.",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, body := range good {
|
||||||
|
if check, ok := guardSpoken("test", body); !ok {
|
||||||
|
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
||||||
|
bad := []struct {
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"<think>он просил воду</think> попей воды.", checkLeak},
|
||||||
|
{"Thinking Process: он давно не пил.", checkLeak},
|
||||||
|
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
|
||||||
|
{"я напомнил тебе про воду.", "feminine"},
|
||||||
|
{"вы давно не пили воду.", "address"},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
check, ok := guardSpoken("test", c.body)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("guardSpoken(%q) let it through", c.body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if check != c.want {
|
||||||
|
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardCountsWhatItCaught(t *testing.T) {
|
||||||
|
before := personaRejectCounts()[checkLeak]
|
||||||
|
if _, ok := guardSpoken("test", "<think>…"); ok {
|
||||||
|
t.Fatal("a leaked reasoning block was let through")
|
||||||
|
}
|
||||||
|
if after := personaRejectCounts()[checkLeak]; after != before+1 {
|
||||||
|
t.Errorf("leak count %d; want %d", after, before+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
|
||||||
|
// deterministic wording, not dropped and not retried.
|
||||||
|
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
|
||||||
|
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
|
||||||
|
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
|
||||||
|
got := guardNudge(bad, cand)
|
||||||
|
if got.Body == bad.Body {
|
||||||
|
t.Fatal("the broken nudge was delivered unchanged")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(got.Body) == "" {
|
||||||
|
t.Fatal("the nudge was dropped rather than re-worded")
|
||||||
|
}
|
||||||
|
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
|
||||||
|
if guardNudge(good, cand).Body != good.Body {
|
||||||
|
t.Error("a good nudge was replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||||
})
|
})
|
||||||
reply := askQuery(t, h, q)
|
reply := askQuery(t, h, q)
|
||||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||||
t.Errorf("reply %q, want %q", reply, want)
|
t.Errorf("reply %q, want the note read back", reply)
|
||||||
}
|
}
|
||||||
// One text, the winning memory's — the answer came from the memory
|
// One text, the winning memory's — the answer came from the memory
|
||||||
// pass, not from handing the phraser every note in the table.
|
// pass, not from handing the phraser every note in the table.
|
||||||
@@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
||||||
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
||||||
})
|
})
|
||||||
if reply := askQuery(t, h, q); reply != "не знаю." {
|
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
|
||||||
t.Errorf("reply %q, want silence", reply)
|
t.Errorf("reply %q, want silence", reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+60
-39
@@ -11,6 +11,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||||
@@ -32,10 +34,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
val := "false"
|
val := "false"
|
||||||
reply := "тихий режим выключен."
|
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||||
if on {
|
if on {
|
||||||
val = "true"
|
val = "true"
|
||||||
reply = "тихий режим включён. буду реже напоминать."
|
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||||
}
|
}
|
||||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
Ts: h.now(),
|
Ts: h.now(),
|
||||||
@@ -46,34 +48,44 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
Confidence: 1.0,
|
Confidence: 1.0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
return "не получилось переключить тихий режим.", true
|
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietInflections — the inflectional endings a stem may carry and still be
|
// quietStem reports whether tok is one of the words a vocabulary slot accepts.
|
||||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
// A slot is written as alternatives joined by "|", and an alternative comes in
|
||||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
// two flavours:
|
||||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
//
|
||||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
// - a dictionary form, matched through the dictionary, so every case and
|
||||||
var quietInflections = []string{
|
// gender of it counts. This is what the nouns and adjectives want: "тихий",
|
||||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
// "тихом", "тихо" and "тише" are one word.
|
||||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
// - a form prefixed with "=", matched as the exact token. This is what the
|
||||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
// VERBS want, and it is not a shortcut. A command is an imperative, and the
|
||||||
}
|
// dictionary quite correctly files "говори" and "говорил" under one lemma —
|
||||||
|
// so lemma-matching a verb slot read "он говорил тихим голосом весь вечер",
|
||||||
// quietStem reports whether tok is the given stem carrying at most one
|
// a remark about his evening, as an order to go quiet. Aspect pairs are two
|
||||||
// inflectional ending. Word boundaries come from tokenisation (see
|
// separate verbs, which is why several imperatives are listed by hand.
|
||||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
//
|
||||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
// Word boundaries come from tokenisation (see quietTokens), not from a regexp —
|
||||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
// Go's \b is ASCII-oriented and treats every Cyrillic letter as a non-word
|
||||||
func quietStem(tok, stem string) bool {
|
// character, so `\bтих\b` would happily match inside "тихонько". Comparing whole
|
||||||
if !strings.HasPrefix(tok, stem) {
|
// tokens sidesteps that entirely.
|
||||||
return false
|
//
|
||||||
}
|
// The comparison is a dictionary lookup, not a stem plus a list of 36 endings
|
||||||
suffix := tok[len(stem):]
|
// (Vikunja #526). The distinction the old comment described is exactly the one a
|
||||||
for _, e := range quietInflections {
|
// dictionary makes: "тихий", "тихом", "тихо" and "тише" are one word inflected,
|
||||||
if suffix == e {
|
// while "тихонько" and "потихоньку" are different words — and the dictionary
|
||||||
|
// knows that without anybody deciding that "онько" is not an ending.
|
||||||
|
func quietStem(tok, slot string) bool {
|
||||||
|
for _, form := range strings.Split(slot, "|") {
|
||||||
|
if exact, ok := strings.CutPrefix(form, "="); ok {
|
||||||
|
if tok == exact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if morph.SameWord(tok, form) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +128,10 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as sequences of
|
||||||
|
// dictionary forms. They used to be truncated stems ("тих", "выключ"), which is
|
||||||
|
// what the ending list existed to complete; a dictionary form needs no
|
||||||
|
// completing (Vikunja #526).
|
||||||
//
|
//
|
||||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||||
@@ -127,36 +142,42 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
var (
|
var (
|
||||||
quietOffPhrases = [][]string{
|
quietOffPhrases = [][]string{
|
||||||
{"quiet", "off"}, {"quiet", "end"},
|
{"quiet", "off"}, {"quiet", "end"},
|
||||||
{"громк", "режим"}, {"шумн", "режим"},
|
{"громкий", "режим"}, {"шумный", "режим"},
|
||||||
{"отмен", "тих"}, {"выключ", "тих"},
|
{"=отмени|=отменяй|=отменить", "тихий"},
|
||||||
|
{"=выключи|=выключай|=выключить", "тихий"},
|
||||||
}
|
}
|
||||||
quietOnPhrases = [][]string{
|
quietOnPhrases = [][]string{
|
||||||
{"quiet", "on"}, {"quiet", "mode"},
|
{"quiet", "on"}, {"quiet", "mode"},
|
||||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
{"тихий", "режим"}, {"не", "=шуми|=шумите"}, {"не", "=беспокой|=беспокоить"},
|
||||||
// The noun form and the comparative. "режим тишины" is how the
|
// The noun form and the comparative. "режим тишины" is how the
|
||||||
// setting is named half the time, and "сделай потише" is how it is
|
// setting is named half the time, and "сделай потише" is how it is
|
||||||
// actually asked for out loud. Both used to fall through to the
|
// actually asked for out loud. Both used to fall through to the
|
||||||
// router, which has no quiet intent, so the command did nothing.
|
// router, which has no quiet intent, so the command did nothing.
|
||||||
{"режим", "тишин"}, {"сделай", "тише"}, {"сделай", "потише"},
|
{"режим", "тишина"}, {"=сделай", "тихий"}, {"=сделай", "потише"},
|
||||||
{"говори", "тише"}, {"будь", "потише"},
|
{"=говори", "тихий"}, {"=будь", "потише"},
|
||||||
{"тих"}, {"потише"},
|
{"тихий"}, {"потише"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// quietWordStems — every stem that names the setting. Used by the
|
// quietWordStems — every word that names the setting. Used by the
|
||||||
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
||||||
// recognise "хватит тишины" without an ON phrase having matched.
|
// recognise "хватит тишины" without an ON phrase having matched.
|
||||||
var quietWordStems = []string{"тих", "тишин", "потише"}
|
var quietWordStems = []string{"тихий", "тишина", "потише"}
|
||||||
|
|
||||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||||
var quietNegatorWords = map[string]bool{
|
var quietNegatorWords = map[string]bool{
|
||||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
// quietNegatorStems — negators that inflect. Imperatives, matched exactly for
|
||||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
// the reason quietStem gives: "выключи" is a command and "выключил" is a report
|
||||||
// and "выключай" all count and "выключатель" does not.
|
// about earlier, and one lemma covers both. "выключатель" was never a negator
|
||||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
// and is not one now.
|
||||||
|
var quietNegatorStems = []string{
|
||||||
|
"=выключи|=выключай|=выключить", "=отмени|=отменяй|=отменить",
|
||||||
|
"=прекрати|=прекращай|=прекратить", "=убери|=убирай|=убрать",
|
||||||
|
"stop", "cancel", "disable",
|
||||||
|
}
|
||||||
|
|
||||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -85,3 +87,38 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||||
|
// task table. It went dead when the router started claiming the marker as an
|
||||||
|
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||||
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||||
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
now := time.Now()
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(api)
|
||||||
|
h := &reactiveHandler{
|
||||||
|
api: api,
|
||||||
|
embedder: emb,
|
||||||
|
router: buildRouter(emb, matcher, 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
memStore: memory.NewInMemoryStore(),
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||||
|
}
|
||||||
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||||
|
if err != nil || len(open) != 1 {
|
||||||
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||||
|
}
|
||||||
|
// The words he said, not the model's rewrite of them.
|
||||||
|
if open[0].Text != "купить молоко" {
|
||||||
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
|||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(d)
|
||||||
}
|
}
|
||||||
|
// The persona checks, on the live path (personaguard.go). A reply that
|
||||||
|
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||||
|
if _, ok := guardSpoken("reply", out); !ok {
|
||||||
|
return r.stub.Reply(d)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
|||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||||
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
|||||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||||
|
// expected to match. What must hold is that the reply is a line that entry can
|
||||||
|
// produce, which is the same claim without pinning one wording.
|
||||||
|
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||||
|
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||||
|
|||||||
+26
-36
@@ -4,6 +4,10 @@
|
|||||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
// 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.
|
// 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.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,17 +15,15 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ruWeekdays = []string{
|
// Weekday and month names are a closed class — the language has seven and
|
||||||
"воскресенье", "понедельник", "вторник", "среда",
|
// twelve — so they live complete in internal/lexicon, where internal/ttsnorm
|
||||||
"четверг", "пятница", "суббота",
|
// reads the same twelve month names instead of keeping a second copy
|
||||||
}
|
// (Vikunja #525).
|
||||||
|
|
||||||
var ruMonths = []string{
|
|
||||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||||
// other than here. She only keeps one clock, and saying so is better than
|
// other than here. She only keeps one clock, and saying so is better than
|
||||||
@@ -33,13 +35,15 @@ var ruMonths = []string{
|
|||||||
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||||
|
|
||||||
// notPlaceAfterV — words that follow "в" without naming a place, so
|
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||||
// mentionsUnknownPlace does not mistake them for a city.
|
// mentionsUnknownPlace does not mistake them for a city. Closed set, kept
|
||||||
var notPlaceAfterV = map[string]bool{
|
// complete in internal/lexicon.
|
||||||
"данный": true, "данную": true, "этот": true, "эту": true,
|
var notPlaceAfterV = func() map[string]bool {
|
||||||
"котором": true, "какое": true, "какой": true, "который": true,
|
m := map[string]bool{}
|
||||||
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
for _, w := range lexicon.NotPlaceAfterV() {
|
||||||
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
m[w] = true
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||||
// that looks like a place we do not know ("который час в киеве"). Used only to
|
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||||
@@ -94,11 +98,11 @@ func mentionsUnknownDay(u string) bool {
|
|||||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
func ruClock(t time.Time) string {
|
func ruClock(t time.Time) string {
|
||||||
h, m := t.Hour(), t.Minute()
|
h, m := t.Hour(), t.Minute()
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
hourWord := say.CountWord(h, "час", "часа", "часов")
|
||||||
if m == 0 {
|
if m == 0 {
|
||||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
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
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
@@ -118,22 +122,6 @@ func dayPrefix(now, day time.Time) string {
|
|||||||
return "это"
|
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
|
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||||
// rather than the current clock — guards replySystem from replying "сейчас
|
// rather than the current clock — guards replySystem from replying "сейчас
|
||||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||||
@@ -169,11 +157,13 @@ func formatTime(t time.Time) string {
|
|||||||
case diff < 10*time.Minute:
|
case diff < 10*time.Minute:
|
||||||
return "несколько минут назад"
|
return "несколько минут назад"
|
||||||
case diff < 60*time.Minute:
|
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:
|
case diff < 2*time.Hour:
|
||||||
return "час назад"
|
return "час назад"
|
||||||
case diff < 24*time.Hour:
|
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:
|
default:
|
||||||
return t.Format("2 января 15:04")
|
return t.Format("2 января 15:04")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1043,3 +1043,26 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
|||||||
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSimulatorRoutesWithTheDeployedSeeds — the scenarios must replay against
|
||||||
|
// the classifier the deploy runs, not an empty one.
|
||||||
|
//
|
||||||
|
// They did not. The seed path was relative to the working directory, which is
|
||||||
|
// cmd/mavend under `go test`, so every file failed to open and the whole
|
||||||
|
// simulator scored three green scenarios with zero examples loaded (Vikunja
|
||||||
|
// #465). The count is asserted rather than logged, because a silent zero is
|
||||||
|
// exactly the failure that hid here for as long as it did.
|
||||||
|
func TestSimulatorRoutesWithTheDeployedSeeds(t *testing.T) {
|
||||||
|
cls := router.NewClassifier(router.NewHashEmbedder(1024))
|
||||||
|
seedClassifier(cls)
|
||||||
|
total := 0
|
||||||
|
for _, intent := range cls.Intents() {
|
||||||
|
total += len(cls.Examples(intent))
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Fatalf("no seed examples loaded from %s — the simulator would route on nothing", seedPath())
|
||||||
|
}
|
||||||
|
if len(cls.Intents()) != 7 {
|
||||||
|
t.Fatalf("seeded %d intents, want all 7", len(cls.Intents()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-5
@@ -4,10 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/smarthome"
|
"github.com/kami/maven/internal/smarthome"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -140,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
|
||||||
@@ -177,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)
|
||||||
}
|
}
|
||||||
@@ -185,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, hostWord(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, "всё выключено")
|
||||||
}
|
}
|
||||||
@@ -193,7 +198,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
|||||||
parts = append(parts, strings.Join(sensors, ", "))
|
parts = append(parts, strings.Join(sensors, ", "))
|
||||||
}
|
}
|
||||||
if dark > 0 {
|
if dark > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "; ") + ".", true
|
return strings.Join(parts, "; ") + ".", true
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return "не получилось отложить.", true
|
return phraser.Ack(phraser.FailSnooze, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return "хорошо, вернусь к этому позже.", true
|
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||||
@@ -81,15 +82,23 @@ func (h *reactiveHandler) pendingNudge(ctx context.Context, now time.Time) (ipc.
|
|||||||
return ipc.Nudge{}, false
|
return ipc.Nudge{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// snoozePhrases — the deferral vocabulary, as stem sequences. Matched by
|
// snoozePhrases — the deferral vocabulary. Matched by quietPhrase and quietStem
|
||||||
// quietPhrase (quiet_toggle.go), which carries the rule that matters here:
|
// (quiet_toggle.go), so an adverb is matched through the dictionary and a verb
|
||||||
|
// exactly, prefixed with "=": "напомни" is a request and "напомнил" is a report
|
||||||
|
// about earlier, and the dictionary files both under напомнить (Vikunja #526).
|
||||||
|
// The verbs used to be truncated stems — "напомн", "отлож" — which is what the
|
||||||
|
// deleted ending list existed to complete.
|
||||||
|
//
|
||||||
|
// quietPhrase carries the rule that matters here:
|
||||||
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
// a single-word pattern matches only a single-word utterance. Bare "потом" is
|
||||||
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
// an answer; "потом схожу за водой" is a plan, and reporting a plan must not
|
||||||
// silence the rule that prompted it.
|
// silence the rule that prompted it.
|
||||||
var snoozePhrases = [][]string{
|
var snoozePhrases = [][]string{
|
||||||
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
{"не", "сейчас"}, {"не", "могу", "сейчас"}, {"не", "до", "этого"},
|
||||||
{"напомн", "позже"}, {"напомн", "потом"}, {"спрос", "позже"},
|
{"=напомни|=напоминай", "позже"}, {"=напомни|=напоминай", "потом"},
|
||||||
{"отлож"}, {"позже"}, {"потом"}, {"попозже"}, {"погоди"},
|
{"=спроси|=спрашивай", "позже"},
|
||||||
|
{"=отложи|=отложим|=откладывай"}, {"позже"}, {"потом"}, {"попозже"},
|
||||||
|
{"=погоди|=погодите"},
|
||||||
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
{"not", "now"}, {"later"}, {"snooze"}, {"remind", "me", "later"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-9
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,6 +177,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
t.queueNudge(ctx, cand, state, now)
|
t.queueNudge(ctx, cand, state, now)
|
||||||
} else {
|
} else {
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||||
|
if err == nil {
|
||||||
|
pn = guardNudge(pn, *cand)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -627,7 +631,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t
|
|||||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||||
}
|
}
|
||||||
body := b.String()
|
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{
|
cand := loop.Candidate{
|
||||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||||
@@ -761,11 +769,7 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
|
|||||||
facts := t.gatherMorningFacts(ctx)
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
labels := make([]string, len(cand.Missing))
|
body := morningNudgeBody(cand)
|
||||||
for i, it := range cand.Missing {
|
|
||||||
labels[i] = it.Label
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
|
||||||
pn := delivery.PhrasedNudge{
|
pn := delivery.PhrasedNudge{
|
||||||
Candidate: loop.Candidate{
|
Candidate: loop.Candidate{
|
||||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
@@ -781,6 +785,26 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningNudgeBody words the one message a routine gets per day. Required
|
||||||
|
// items are what she says was not done; optional ones follow, worded as
|
||||||
|
// something he could still do rather than something he owes (Vikunja #473).
|
||||||
|
// Operator text, not phrased by the model, for the same reason it always was:
|
||||||
|
// a checklist item must not be invented.
|
||||||
|
func morningNudgeBody(cand morning.Candidate) string {
|
||||||
|
labels := func(items []morning.Item) string {
|
||||||
|
out := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.Label
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||||
|
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||||
|
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
// decision) and morningStatus (read-only query) so the two paths can never
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
@@ -986,7 +1010,7 @@ type daemonAPI struct {
|
|||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
}
|
}
|
||||||
@@ -1002,11 +1026,11 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
|||||||
return d.getEvents(n), nil
|
return d.getEvents(n), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
if d.chatFn == nil {
|
if d.chatFn == nil {
|
||||||
return "", errors.New("mavend: chat not available")
|
return "", errors.New("mavend: chat not available")
|
||||||
}
|
}
|
||||||
return d.chatFn(ctx, text), nil
|
return d.chatFn(ctx, conversation, text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which subject is this question about — the weather, the house, the LAN, or
|
||||||
|
// none of them. Third of the three mechanisms replacing hand-written Russian
|
||||||
|
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||||
|
// sets that can be finished and internal/morph answers the grammar questions;
|
||||||
|
// this is for the sets that can never be finished, because "is this about the
|
||||||
|
// house" is a question about meaning and no word list closes it.
|
||||||
|
//
|
||||||
|
// The three recognisers this replaces were each built the same way: a stem list,
|
||||||
|
// an ask test, a device-noun list, and a bail-out list for the neighbouring
|
||||||
|
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
|
||||||
|
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
|
||||||
|
// and "какая температура в доме" share their only content word. isNetworkQuery
|
||||||
|
// matched "сети" as a whole token because the substring lives inside "посетил",
|
||||||
|
// so "сколько машин я посетил" read as a request to scan the LAN. Those are not
|
||||||
|
// bugs in the lists, they are the lists being asked to do semantics.
|
||||||
|
//
|
||||||
|
// So the seeds decide, the same way the personal boundary does
|
||||||
|
// (personalboundary.go), against the same embedder and the same query vector the
|
||||||
|
// turn already carries. One difference in the gate, and it is deliberate. The
|
||||||
|
// boundary claims on the sign of the difference, because there a false claim
|
||||||
|
// costs one honest "не знаю". Here a false claim runs a network scan, or names a
|
||||||
|
// capability as off on a box where it is simply not the subject — so a topic has
|
||||||
|
// to win by a margin, and the losing side of a thin call falls through to the
|
||||||
|
// next query source, which is what the narrow regexes were achieving.
|
||||||
|
//
|
||||||
|
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||||
|
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||||
|
// narrow now precisely because they are no longer the only answer.
|
||||||
|
|
||||||
|
// topicLabel — the subjects worth telling apart, plus the one that means none of
|
||||||
|
// them. topicOther is a real class and not a threshold: a question needs
|
||||||
|
// somewhere to lose TO, and "интернет не работает" losing to a set that contains
|
||||||
|
// complaints is a better statement than it failing a number.
|
||||||
|
type topicLabel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
topicWeather topicLabel = "weather"
|
||||||
|
topicHome topicLabel = "home"
|
||||||
|
topicNetwork topicLabel = "network"
|
||||||
|
topicOther topicLabel = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// topicMargin — how far a topic must clear the runner-up. Small, because the
|
||||||
|
// margins between neighbouring topics are small: measured on held-out
|
||||||
|
// utterances, a true weather question clears the home set by roughly 0.02 to
|
||||||
|
// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the
|
||||||
|
// asymmetry named above — this gate spends a scan, so a coin-flip falls
|
||||||
|
// through rather than acts.
|
||||||
|
const topicMargin = 0.01
|
||||||
|
|
||||||
|
// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a
|
||||||
|
// recogniser and has to be re-measured against TestONNXTopics, not eyeballed.
|
||||||
|
//
|
||||||
|
// Each set covers the phrasings its old regex covered, INCLUDING the ones it
|
||||||
|
// needed a bail-out list for: the weather set carries "какая температура на
|
||||||
|
// улице" and the home set "какая температура в доме", so the pair that forced
|
||||||
|
// isHomeQuery to exclude weather words by hand is now just two seeds sitting on
|
||||||
|
// their own sides.
|
||||||
|
var topicSeedSets = map[topicLabel][]string{
|
||||||
|
topicWeather: {
|
||||||
|
"какая сегодня погода",
|
||||||
|
"какая температура на улице",
|
||||||
|
"будет дождь сегодня",
|
||||||
|
"на улице холодно",
|
||||||
|
"прогноз погоды на завтра",
|
||||||
|
"сколько градусов сейчас",
|
||||||
|
"what is the weather like",
|
||||||
|
"is it going to rain today",
|
||||||
|
},
|
||||||
|
topicHome: {
|
||||||
|
"что включено в доме",
|
||||||
|
"какая температура в доме",
|
||||||
|
"свет в квартире горит",
|
||||||
|
"сколько лампочек включено дома",
|
||||||
|
"что у меня дома с датчиками",
|
||||||
|
"умный дом что сейчас работает",
|
||||||
|
"розетки в доме включены",
|
||||||
|
"what is on in the house",
|
||||||
|
},
|
||||||
|
topicNetwork: {
|
||||||
|
"какие устройства в сети",
|
||||||
|
"кто в сети сейчас",
|
||||||
|
"просканируй локальную сеть",
|
||||||
|
"сколько машин в сетке",
|
||||||
|
"покажи хосты в сети",
|
||||||
|
"какие адреса заняты в локальной сети",
|
||||||
|
"кто подключён к вайфаю",
|
||||||
|
"what devices are on the network",
|
||||||
|
},
|
||||||
|
topicOther: {
|
||||||
|
// Complaints, which are not requests to scan or to read the house.
|
||||||
|
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||||
|
// the noisy behaviour the bounds exist to prevent.
|
||||||
|
"интернет не работает",
|
||||||
|
"вайфай тормозит",
|
||||||
|
"свет погас",
|
||||||
|
// Statements. "я дома" was the reason isHomeQuery needed an ask test.
|
||||||
|
"я дома",
|
||||||
|
"я уже дома",
|
||||||
|
// The collision that made "сети" a whole-token match.
|
||||||
|
"сколько машин я посетил",
|
||||||
|
"сколько домов мы посмотрели",
|
||||||
|
// Ordinary questions, his and the world's, so a topic has something
|
||||||
|
// real to lose to rather than an arbitrary floor.
|
||||||
|
"почему небо синее",
|
||||||
|
"какая столица франции",
|
||||||
|
"что я говорил про бэкапы",
|
||||||
|
"что у меня сегодня по календарю",
|
||||||
|
"напомни мне позвонить маме",
|
||||||
|
"what did i say about backups",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// topicIndex holds the embedded seeds. Zero value is usable and means "not
|
||||||
|
// loaded yet"; a handler built without an embedder never loads and every caller
|
||||||
|
// uses its own floor instead.
|
||||||
|
type topicIndex struct {
|
||||||
|
once sync.Once
|
||||||
|
vecs map[topicLabel][][]float32
|
||||||
|
loaded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// load embeds every set once per process, on the QUERY side — a question
|
||||||
|
// compared with a question, for the reason personalBoundary.load gives.
|
||||||
|
func (x *topicIndex) load(ctx context.Context, emb router.Embedder) {
|
||||||
|
x.once.Do(func() {
|
||||||
|
if emb == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vecs := make(map[topicLabel][][]float32, len(topicSeedSets))
|
||||||
|
for label, seeds := range topicSeedSets {
|
||||||
|
out := make([][]float32, 0, len(seeds))
|
||||||
|
for _, s := range seeds {
|
||||||
|
v, err := router.EmbedQuery(ctx, emb, s)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
vecs[label] = out
|
||||||
|
}
|
||||||
|
x.vecs, x.loaded = vecs, true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// best returns the nearest label, how far it cleared the runner-up, and whether
|
||||||
|
// the seeds answered at all. ok is false when they are not loaded, which is the
|
||||||
|
// caller's signal to use its floor.
|
||||||
|
func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) {
|
||||||
|
if !x.loaded || len(vec) == 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
first, second := -1.0, -1.0
|
||||||
|
for l, seeds := range x.vecs {
|
||||||
|
top := -1.0
|
||||||
|
for _, s := range seeds {
|
||||||
|
if c := cosine(vec, s); c > top {
|
||||||
|
top = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case top > first:
|
||||||
|
label, first, second = l, top, first
|
||||||
|
case top > second:
|
||||||
|
second = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return label, first - second, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||||
|
// the embedder is there, which is every deployed box; floor is the source's own
|
||||||
|
// keyword test, which answers when they are not.
|
||||||
|
//
|
||||||
|
// A topic that wins without the margin is reported as a pass, and logged: it is
|
||||||
|
// the one outcome where the seeds and the old regexes are most likely to
|
||||||
|
// disagree, and a silent near-miss is how a recogniser drifts.
|
||||||
|
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||||
|
h.topics.load(ctx, h.embedder)
|
||||||
|
label, margin, ok := h.topics.best(t.vec)
|
||||||
|
if !ok {
|
||||||
|
return floor(t.dec.Utterance)
|
||||||
|
}
|
||||||
|
if label != want {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if margin < topicMargin {
|
||||||
|
log.Printf("voice: %q reads as %s by only %.4f; passing it on", t.dec.Utterance, want, margin)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the
|
||||||
|
// seeds, and every topic source has to keep working. This is the case that used
|
||||||
|
// to be the only one, so a regression here is the three recognisers going
|
||||||
|
// silent on a box with no embedder at all.
|
||||||
|
func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||||
|
h := &reactiveHandler{}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
utterance string
|
||||||
|
label topicLabel
|
||||||
|
floor func(string) bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"какая сегодня погода", topicWeather, isWeatherQuery, true},
|
||||||
|
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||||
|
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||||
|
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||||
|
{"я дома", topicHome, isHomeQuery, false},
|
||||||
|
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||||
|
} {
|
||||||
|
turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}}
|
||||||
|
if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want {
|
||||||
|
t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestONNXTopics — the number that matters, scored against the embedder homesrv
|
||||||
|
// actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary.
|
||||||
|
//
|
||||||
|
// Every case is held out: none of these strings is a seed. It asserts what the
|
||||||
|
// gate does, not what the raw scorer says — a label under topicMargin is not a
|
||||||
|
// claim, and one held-out case turns on exactly that.
|
||||||
|
//
|
||||||
|
// The first three rows are the collisions the old regexes needed hand-written
|
||||||
|
// bail-outs for: the temperature pair that made isHomeQuery exclude weather
|
||||||
|
// words, and the
|
||||||
|
// "посетил" substring that made isNetworkQuery match "сети" as a whole token.
|
||||||
|
func TestONNXTopics(t *testing.T) {
|
||||||
|
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||||
|
if lib == "" {
|
||||||
|
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||||
|
}
|
||||||
|
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||||
|
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("onnx embedder unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer emb.Close()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want topicLabel
|
||||||
|
}{
|
||||||
|
{"какая температура на улице?", topicWeather},
|
||||||
|
{"какая температура в доме?", topicHome},
|
||||||
|
{"сколько машин я посетил?", topicOther},
|
||||||
|
{"сколько сейчас градусов", topicWeather},
|
||||||
|
{"дождь будет вечером?", topicWeather},
|
||||||
|
{"тепло сегодня на улице?", topicWeather},
|
||||||
|
{"свет на кухне включен?", topicHome},
|
||||||
|
{"что сейчас включено дома", topicHome},
|
||||||
|
{"датчики в квартире что показывают", topicHome},
|
||||||
|
{"просканируй сеть", topicNetwork},
|
||||||
|
{"сколько устройств в локальной сети", topicNetwork},
|
||||||
|
{"кто сейчас в сетке", topicNetwork},
|
||||||
|
// The case the margin exists for. It reads as network by 0.0055, under
|
||||||
|
// topicMargin, so the gate passes it on — which is right: it is a
|
||||||
|
// complaint, and a scan she runs unasked is the behaviour the bounds
|
||||||
|
// prevent.
|
||||||
|
{"вайфай опять отвалился", topicOther},
|
||||||
|
{"я уже приехал домой", topicOther},
|
||||||
|
{"что я говорил про погоду в москве", topicOther},
|
||||||
|
{"напомни полить цветы", topicOther},
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &reactiveHandler{embedder: emb}
|
||||||
|
ctx := context.Background()
|
||||||
|
h.topics.load(ctx, emb)
|
||||||
|
if !h.topics.loaded {
|
||||||
|
t.Fatal("topic seeds did not load")
|
||||||
|
}
|
||||||
|
right := 0
|
||||||
|
for _, tc := range cases {
|
||||||
|
vec, err := router.EmbedQuery(ctx, emb, tc.utterance)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("embed %q: %v", tc.utterance, err)
|
||||||
|
}
|
||||||
|
label, margin, ok := h.topics.best(vec)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("best(%q) not ok", tc.utterance)
|
||||||
|
}
|
||||||
|
// What the gate would do, which is the thing under test: a label that
|
||||||
|
// does not clear the margin is not a claim.
|
||||||
|
got := label
|
||||||
|
if margin < topicMargin {
|
||||||
|
got = topicOther
|
||||||
|
}
|
||||||
|
if got == tc.want {
|
||||||
|
right++
|
||||||
|
} else {
|
||||||
|
t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want)
|
||||||
|
}
|
||||||
|
t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin)
|
||||||
|
}
|
||||||
|
t.Logf("topics: %d/%d", right, len(cases))
|
||||||
|
}
|
||||||
+15
-6
@@ -55,6 +55,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/crawl"
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
@@ -76,6 +77,11 @@ type reactiveHandler struct {
|
|||||||
tts tts.Synthesizer
|
tts tts.Synthesizer
|
||||||
router *router.Router
|
router *router.Router
|
||||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||||
|
// topics — the embedded seed sets behind the weather, house and LAN
|
||||||
|
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
|
||||||
|
// usable, loads on first query, and with no embedder it never loads and
|
||||||
|
// each source falls back to its own keyword test.
|
||||||
|
topics topicIndex
|
||||||
// boundary — the embedded seed sets behind the personal boundary
|
// boundary — the embedded seed sets behind the personal boundary
|
||||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||||
@@ -220,9 +226,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
|||||||
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
||||||
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
||||||
// HandlePushToTalk so text channels share the same routing logic.
|
// HandlePushToTalk so text channels share the same routing logic.
|
||||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||||||
log.Printf("voice: handleText: %q", text)
|
log.Printf("voice: handleText: %q", text)
|
||||||
return h.runTurn(ctx, text, sourceText)
|
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// turnSource — which channel this utterance arrived on, in the same provenance
|
// turnSource — which channel this utterance arrived on, in the same provenance
|
||||||
@@ -255,7 +261,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// early. He can be asked a question, walk off, come back and say "да" to a
|
// early. He can be asked a question, walk off, come back and say "да" to a
|
||||||
// confirm that is still parked; computing the notice after that return meant
|
// confirm that is still parked; computing the notice after that return meant
|
||||||
// he answered the confirm and never heard that the older request was let go.
|
// he answered the confirm and never heard that the older request was let go.
|
||||||
expiredNotice := h.clarifyExpiredNotice()
|
expiredNotice := h.clarifyExpiredNotice(ctx)
|
||||||
|
|
||||||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||||
@@ -351,7 +357,10 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||||
// stands.
|
// stands.
|
||||||
if dec.Clarify {
|
if dec.Clarify {
|
||||||
if question, asked := h.askClarify(dec); asked {
|
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
if question, asked := h.askClarify(ctx, dec); asked {
|
||||||
return withNotice(expiredNotice, question)
|
return withNotice(expiredNotice, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,8 +458,8 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
|||||||
// this arm was fixed for, so say what she can do instead.
|
// this arm was fixed for, so say what she can do instead.
|
||||||
return onlyNearDaysReply
|
return onlyNearDaysReply
|
||||||
}
|
}
|
||||||
dow := ruWeekdays[day.Weekday()]
|
dow := lexicon.Weekday(int(day.Weekday()))
|
||||||
month := ruMonths[day.Month()-1]
|
month := lexicon.MonthGenitive(int(day.Month()))
|
||||||
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||||||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||||
return "присутствие пока не подключено к голосовому запросу."
|
return "присутствие пока не подключено к голосовому запросу."
|
||||||
|
|||||||
+41
-6
@@ -238,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||||
// load, never revived. Clarify's parked question stays in memory only.
|
// load, never revived. Clarify's parked question stays in memory only, and
|
||||||
|
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||||
|
// a restart expires it, so the thread comes back and the open question does
|
||||||
|
// not.
|
||||||
var dialogueSessions *dialogue.SessionStore
|
var dialogueSessions *dialogue.SessionStore
|
||||||
if dataStore != nil {
|
if dataStore != nil {
|
||||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||||
@@ -379,7 +382,16 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
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.ListGrammars()...)
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
|
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||||
|
// explicit capture marker beats the model, which called it an act and
|
||||||
|
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||||
|
// marker never collides with a clock or agenda question.
|
||||||
|
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
@@ -393,11 +405,34 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedDir is the directory containing intent seed files. Each file is named
|
// seedDir is the directory containing intent seed files, relative to the repo
|
||||||
// <intent>.txt and contains one training example per line (blank lines and
|
// root. Each file is named <intent>.txt and holds one training example per
|
||||||
// lines starting with # are ignored). Relative to the working directory.
|
// line (blank lines and lines starting with # are ignored).
|
||||||
const seedDir = "models/seeds"
|
const seedDir = "models/seeds"
|
||||||
|
|
||||||
|
// seedPath resolves seedDir against the working directory, walking up until it
|
||||||
|
// finds it. The daemon runs from the repo root and the first candidate hits.
|
||||||
|
//
|
||||||
|
// A test does not: `go test ./cmd/mavend/` runs with the working directory at
|
||||||
|
// cmd/mavend, so every open failed and the simulator scenarios replayed a whole
|
||||||
|
// scripted day against a classifier holding zero examples (Vikunja #465). They
|
||||||
|
// passed, which is the part that matters — a green simulator was not exercising
|
||||||
|
// the routing the deploy runs, and a regression in the seed set could not have
|
||||||
|
// shown up there.
|
||||||
|
//
|
||||||
|
// Bounded at five levels, so a daemon started somewhere without the seeds logs
|
||||||
|
// the same failure it always did rather than walking to the filesystem root.
|
||||||
|
func seedPath() string {
|
||||||
|
dir := seedDir
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
dir = filepath.Join("..", dir)
|
||||||
|
}
|
||||||
|
return seedDir
|
||||||
|
}
|
||||||
|
|
||||||
// seedClassifier floors the embedded examples so the cold-boot path
|
// seedClassifier floors the embedded examples so the cold-boot path
|
||||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||||
@@ -422,11 +457,11 @@ func seedClassifier(c *router.Classifier) {
|
|||||||
}
|
}
|
||||||
total += n
|
total += n
|
||||||
}
|
}
|
||||||
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
|
log.Printf("voice: loaded %d seed examples from %s", total, seedPath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
||||||
path := filepath.Join(seedDir, string(intent)+".txt")
|
path := filepath.Join(seedPath(), string(intent)+".txt")
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open %s: %w", path, err)
|
return 0, fmt.Errorf("open %s: %w", path, err)
|
||||||
|
|||||||
+45
-33
@@ -1,10 +1,13 @@
|
|||||||
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
||||||
// this utterance ask about weather at all, and which city (if any) did it
|
// this utterance ask about weather at all, and which place (if any) did he
|
||||||
// name. Both are plain substring/lookup matching, not NLU — extend this file
|
// name. Both are plain keyword matching, not NLU — extend this file rather
|
||||||
// rather than voice.go for anything in that shape.
|
// than voice.go for anything in that shape.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// isWeatherQuery returns true if the utterance is about weather.
|
// isWeatherQuery returns true if the utterance is about weather.
|
||||||
func isWeatherQuery(u string) bool {
|
func isWeatherQuery(u string) bool {
|
||||||
@@ -19,40 +22,49 @@ func isWeatherQuery(u string) bool {
|
|||||||
strings.Contains(lower, "temperature")
|
strings.Contains(lower, "temperature")
|
||||||
}
|
}
|
||||||
|
|
||||||
// weatherCities — the city names an utterance may name explicitly, as
|
// weatherPlace — the place he named, after "в"/"во"/"in". One or two words,
|
||||||
// lowercase substrings mapped to the provider's spelling. This is a
|
// letters and dashes only, so "в Нижнем Новгороде" and "in New York" both
|
||||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
// come through whole and "в 5 утра" does not.
|
||||||
// nothing here is used unless he actually said it.
|
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||||
var weatherCities = map[string]string{
|
|
||||||
"москв": "Moscow",
|
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||||
"moscow": "Moscow",
|
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||||
"питер": "Saint Petersburg",
|
// "тепло в комнате" is the same question about the same room.
|
||||||
"spb": "Saint Petersburg",
|
var weatherNonPlaces = map[string]bool{
|
||||||
"петербур": "Saint Petersburg",
|
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||||
"лондон": "London",
|
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||||
"london": "London",
|
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||||
"париж": "Paris",
|
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||||
"paris": "Paris",
|
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||||
"берлин": "Berlin",
|
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||||
"berlin": "Berlin",
|
|
||||||
"нью-йорк": "New York",
|
|
||||||
"new york": "New York",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractWeatherLocation returns the city he named, or the configured default
|
// extractWeatherLocation returns the place he named, or the configured default
|
||||||
// when he named none. It returns "" when he named none AND no default is
|
// when he named none. It returns "" when he named none AND no default is
|
||||||
// configured — the caller must then say it does not know.
|
// configured — the caller must then say it does not know.
|
||||||
//
|
//
|
||||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
// It used to be a hand-written table of six cities in two spellings each
|
||||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
// (Vikunja #421). Anything outside it — Kazan, Tbilisi — was dropped silently
|
||||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
// and answered for the default location, which reads as a correct answer about
|
||||||
// .default_location is the only source of an unstated location.
|
// the wrong place. There is a geocoder behind this now: internal/weather
|
||||||
|
// already calls Open-Meteo's geocoding endpoint for every lookup, so any place
|
||||||
|
// it knows is a place he can ask about, and the table bought nothing.
|
||||||
|
//
|
||||||
|
// A named place that the geocoder cannot resolve is the caller's problem to
|
||||||
|
// report, not this function's to hide.
|
||||||
|
//
|
||||||
|
// It used to return "Moscow" when he named nothing. That is a made-up answer
|
||||||
|
// presented as fact. voice.weather.default_location is the only source of an
|
||||||
|
// unstated location.
|
||||||
func extractWeatherLocation(u, defaultLoc string) string {
|
func extractWeatherLocation(u, defaultLoc string) string {
|
||||||
lower := strings.ToLower(u)
|
m := weatherPlace.FindStringSubmatch(u)
|
||||||
for substr, name := range weatherCities {
|
if m == nil {
|
||||||
if strings.Contains(lower, substr) {
|
return defaultLoc
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return defaultLoc
|
place := strings.TrimSpace(m[1])
|
||||||
|
first := strings.ToLower(strings.Fields(place)[0])
|
||||||
|
if weatherNonPlaces[first] {
|
||||||
|
return defaultLoc
|
||||||
|
}
|
||||||
|
return place
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestExtractWeatherLocation — any place he names comes through, not just the
|
||||||
|
// six that used to be in a table (Vikunja #421).
|
||||||
|
func TestExtractWeatherLocation(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
def string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// The cities the table had, and the ones it silently dropped.
|
||||||
|
{"какая погода в Москве", "Berlin", "Москве"},
|
||||||
|
{"какая погода в Казани", "Berlin", "Казани"},
|
||||||
|
{"погода в Тбилиси?", "Berlin", "Тбилиси"},
|
||||||
|
{"what's the weather in New York", "Berlin", "New York"},
|
||||||
|
{"тепло в Нижнем Новгороде?", "Berlin", "Нижнем Новгороде"},
|
||||||
|
// He named nothing: the configured default, and nothing at all when
|
||||||
|
// there is no default.
|
||||||
|
{"какая сегодня погода", "Berlin", "Berlin"},
|
||||||
|
{"какая сегодня погода", "", ""},
|
||||||
|
// "в" followed by something that is not a place stays the default —
|
||||||
|
// the house sensors and the day words answer elsewhere.
|
||||||
|
{"тепло в комнате?", "Berlin", "Berlin"},
|
||||||
|
{"какая погода в выходные", "Berlin", "Berlin"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
|
||||||
|
t.Errorf("extractWeatherLocation(%q, %q) = %q, want %q", c.utterance, c.def, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,11 @@ type worldPhraser interface {
|
|||||||
// question about his meeting came back as a swimming competition in Nottingham.
|
// question about his meeting came back as a swimming competition in Nottingham.
|
||||||
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
||||||
// being down.
|
// being down.
|
||||||
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
//
|
||||||
|
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
|
||||||
|
// variants: this sentence names one specific gap and must not drift into a
|
||||||
|
// general "I don't know".
|
||||||
|
func worldGap() string { return phraser.WorldGap() }
|
||||||
|
|
||||||
// phraseWorld asks the world model, or reports the gap.
|
// phraseWorld asks the world model, or reports the gap.
|
||||||
//
|
//
|
||||||
@@ -54,7 +58,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
|
|||||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||||
return ""
|
return ""
|
||||||
case err != nil:
|
case err != nil:
|
||||||
|
// The resident phraser answers this call with its fallback text and the
|
||||||
|
// error together. Drop the text: these callers hold the passage itself
|
||||||
|
// and read it back better than "вот что я нашла: <passage>" does.
|
||||||
log.Printf("voice: %s: phrase: %v", name, err)
|
log.Printf("voice: %s: phrase: %v", name, err)
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
}
|
}
|
||||||
if reply != worldGap {
|
if reply != worldGap() {
|
||||||
t.Fatalf("reply = %q, want the named gap", reply)
|
t.Fatalf("reply = %q, want the named gap", reply)
|
||||||
}
|
}
|
||||||
if g.worldCalls != 1 {
|
if g.worldCalls != 1 {
|
||||||
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||||
}
|
}
|
||||||
if reply != "не знаю." {
|
if !phraser.IsUnknownFallback(reply) {
|
||||||
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
|||||||
// English in it.
|
// English in it.
|
||||||
func TestWorldGapIsInPersona(t *testing.T) {
|
func TestWorldGapIsInPersona(t *testing.T) {
|
||||||
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||||
if strings.Contains(worldGap, bad) {
|
if strings.Contains(worldGap(), bad) {
|
||||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
|
||||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ type fakeCore struct {
|
|||||||
revertErr error
|
revertErr error
|
||||||
|
|
||||||
// for handleNotifications tests
|
// for handleNotifications tests
|
||||||
nudgesErr error
|
nudgesErr error
|
||||||
|
attempts []ipc.DeliveryAttempt
|
||||||
|
attemptStatus string
|
||||||
|
|
||||||
// for handleHistory tests
|
// for handleHistory tests
|
||||||
historyFacts []ipc.Fact
|
historyFacts []ipc.Fact
|
||||||
@@ -77,7 +79,7 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
|||||||
return f.mcpServers, f.mcpErr
|
return f.mcpServers, f.mcpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
|
||||||
f.chatText = text
|
f.chatText = text
|
||||||
if f.chatErr != nil {
|
if f.chatErr != nil {
|
||||||
return "", f.chatErr
|
return "", f.chatErr
|
||||||
@@ -1251,3 +1253,35 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
|
|||||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) {
|
||||||
|
f.attemptStatus = status
|
||||||
|
return f.attempts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleNotifications_ShowsTheOutbox — the outbox was written and never
|
||||||
|
// read, so a dropped or failed send was invisible (Vikunja #390).
|
||||||
|
func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
|
||||||
|
done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC)
|
||||||
|
core := &fakeCore{
|
||||||
|
attempts: []ipc.DeliveryAttempt{
|
||||||
|
{Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped",
|
||||||
|
Created: done.Add(-30 * time.Second), Completed: &done},
|
||||||
|
{Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.attemptStatus != "dropped" {
|
||||||
|
t.Errorf("status filter = %q, want it passed through", core.attemptStatus)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("rendered outbox missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+55
-2
@@ -893,12 +893,59 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
|
|||||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The outbox, on the page that already answers "what did she send".
|
||||||
|
// A failed or dropped attempt is why she went quiet, and until now it was
|
||||||
|
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
attempts, err := core.DeliveryAttempts(ctx, status, 50)
|
||||||
|
if err != nil {
|
||||||
|
// The nudge list is still worth showing, so this is a note on the page
|
||||||
|
// rather than a dead page.
|
||||||
|
log.Printf("notifications: delivery attempts: %v", err)
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
|
if err := notificationsTmpl.Execute(w, map[string]any{
|
||||||
|
"Nudges": nudges,
|
||||||
|
"Attempts": deliveryRows(attempts),
|
||||||
|
"Status": status,
|
||||||
|
}); err != nil {
|
||||||
log.Printf("notifications template: %v", err)
|
log.Printf("notifications template: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliveryRow is one outbox line, with every timestamp already formatted so
|
||||||
|
// the template holds no date logic — same shape as taskRow.
|
||||||
|
type deliveryRow struct {
|
||||||
|
Kind string
|
||||||
|
Target string
|
||||||
|
Channel string
|
||||||
|
Status string
|
||||||
|
Created string
|
||||||
|
Completed string
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||||
|
out := make([]deliveryRow, 0, len(as))
|
||||||
|
for _, a := range as {
|
||||||
|
target := a.Rule
|
||||||
|
if target == "" && a.ReminderID != 0 {
|
||||||
|
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
|
||||||
|
}
|
||||||
|
row := deliveryRow{
|
||||||
|
Kind: a.Kind,
|
||||||
|
Target: target,
|
||||||
|
Channel: a.Channel,
|
||||||
|
Status: a.Status,
|
||||||
|
Created: a.Created.Format("02.01 15:04"),
|
||||||
|
}
|
||||||
|
if a.Completed != nil {
|
||||||
|
row.Completed = a.Completed.Format("15:04")
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
@@ -1678,7 +1725,13 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
|||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reply, err := core.Chat(r.Context(), text)
|
// One conversation id for the whole web chat, and a different one from
|
||||||
|
// telegram or the mic. A parked question belongs to the reach that was
|
||||||
|
// asked; before this, a clarify nobody answered on the web ate the next
|
||||||
|
// utterance spoken at the mic (Vikunja #466). This server has no
|
||||||
|
// per-browser session, so every browser tab is the same conversation —
|
||||||
|
// which is right for a single-owner box.
|
||||||
|
reply, err := core.Chat(r.Context(), "web", text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("chat api: %v", err)
|
log.Printf("chat api: %v", err)
|
||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -14,5 +14,27 @@
|
|||||||
<div>no notifications yet</div>
|
<div>no notifications yet</div>
|
||||||
<div class=hint>check back later or ask maven a question</div>
|
<div class=hint>check back later or ask maven a question</div>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
|
<h2>Delivery outbox</h2>
|
||||||
|
<p class=hint>
|
||||||
|
every send is recorded before it leaves, so a failure is visible rather than silent.
|
||||||
|
<a href="/notifications">all</a> ·
|
||||||
|
<a href="/notifications?status=dropped">dropped</a> ·
|
||||||
|
<a href="/notifications?status=failed">failed</a> ·
|
||||||
|
<a href="/notifications?status=pending">pending</a> ·
|
||||||
|
<a href="/notifications?status=unknown">unknown</a>
|
||||||
|
</p>
|
||||||
|
{{if .Attempts}}<div class=scroll><table>
|
||||||
|
<tr><th>started</th><th>kind</th><th>rule</th><th>channel</th><th>status</th><th>finished</th></tr>
|
||||||
|
{{range .Attempts}}<tr>
|
||||||
|
<td class=hint>{{.Created}}</td>
|
||||||
|
<td>{{.Kind}}</td>
|
||||||
|
<td class=key>{{.Target}}</td>
|
||||||
|
<td><span class=badge>{{.Channel}}</span></td>
|
||||||
|
<td class={{.Status}}>{{.Status}}</td>
|
||||||
|
<td class=hint>{{.Completed}}</td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<div>no delivery attempts{{if .Status}} with status {{.Status}}{{end}}</div>
|
||||||
|
</div>{{end}}
|
||||||
{{template "shellBottom"}}
|
{{template "shellBottom"}}
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -223,6 +223,30 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### A restart expires a parked question
|
||||||
|
|
||||||
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
restart; the clarify question parked behind it does not, and neither do the
|
||||||
|
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
|
||||||
|
|
||||||
|
Three reasons, in the order they settle it:
|
||||||
|
|
||||||
|
- The clock stops meaning anything. A parked question carries a 90s TTL and an
|
||||||
|
attempt count. A restart is a gap of unknown length, so a restored question is
|
||||||
|
either already dead or pretending to be young.
|
||||||
|
- Restoring the question restores the request behind it. He asked for something,
|
||||||
|
she asked back, and then the daemon went away. Acting on that minutes later,
|
||||||
|
against words he has probably given up on, is the misroute the stage 3 gate
|
||||||
|
exists to avoid.
|
||||||
|
- She does not announce it either. The expiry notice needs to know a question
|
||||||
|
was parked, and knowing that across a restart means storing it. One sentence,
|
||||||
|
in the rare window where he speaks within 90s of a restart, does not pay for a
|
||||||
|
marker that outlives the thing it describes. His next words route fresh, which
|
||||||
|
is the correct answer with or without the notice.
|
||||||
|
|
||||||
|
So the notice stays what it is: the in-process TTL case, where she really did
|
||||||
|
wait and really did let go.
|
||||||
|
|
||||||
### save-where — the two-memory routing axis
|
### save-where — the two-memory routing axis
|
||||||
|
|
||||||
One discriminator: **does the loop evaluate a predicate against it?**
|
One discriminator: **does the loop evaluate a predicate against it?**
|
||||||
@@ -293,6 +317,40 @@ don't improvise.** Destructive ones still gate behind confirm.
|
|||||||
Misroute correction is append-only and grows the router's examples with use —
|
Misroute correction is append-only and grows the router's examples with use —
|
||||||
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
same shape as `nudges.outcome` tuning cooldowns, no retrain.
|
||||||
|
|
||||||
|
#### Risk tiers, not one boolean
|
||||||
|
|
||||||
|
`Destructive` on a tool row is one bit set by whoever ticked the checkbox on
|
||||||
|
`/tools`. It is a mechanism, and it never said which acts are destructive,
|
||||||
|
whether a confirmed act stays confirmed, or what a new tool domain inherits.
|
||||||
|
`internal/tool/risk.go` is the policy (Vikunja #449). The tier is DERIVED from
|
||||||
|
the row, not stored, so it can be argued with in one place instead of being
|
||||||
|
whatever the last person to enable the tool believed.
|
||||||
|
|
||||||
|
| Tier | What it is | What it costs |
|
||||||
|
|---|---|---|
|
||||||
|
| `safe` | a read, or a change he can undo by saying the opposite | runs on first hearing |
|
||||||
|
| `destructive` | it changes something real and undoing it takes work | one confirm turn, every time |
|
||||||
|
| `irreversible` | the thing does not come back: a wipe, a format, a delete with no bin | voice may not authorise it at all |
|
||||||
|
|
||||||
|
Three rules fall out, and they are the part that was missing:
|
||||||
|
|
||||||
|
- **Which acts are destructive is not only the checkbox.** A house row always
|
||||||
|
is, because there is no read-only way to turn the heating off. A row whose
|
||||||
|
argv names one of the irreversible verbs always is, whatever the row says.
|
||||||
|
- **A confirmed act never stays confirmed.** At any tier. A confirmation binds
|
||||||
|
one capability, one target and one argument list, and it dies with the parked
|
||||||
|
turn (90s). "The same act again" is a new act. A sticky confirm is a standing
|
||||||
|
grant and nothing on the voice path may hold one.
|
||||||
|
- **A new domain inherits `destructive`, not `safe`.** A dispatch shape the
|
||||||
|
policy does not recognise gets the confirm turn. A domain argues its way down
|
||||||
|
to running freely; it never has to argue its way up to being gated.
|
||||||
|
|
||||||
|
The irreversible tier is refused rather than asked about, because a confirm
|
||||||
|
turn would be theatre: everything that proposed the act — an STT guess, a
|
||||||
|
router guess, a fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||||
|
none of it. She names the gap and he runs it himself. The row stays enabled;
|
||||||
|
refusing to run it from voice is not the same as taking it off the allowlist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voice pipeline (STT / TTS)
|
## Voice pipeline (STT / TTS)
|
||||||
@@ -630,6 +688,31 @@ add a new principle; it applied the existing one at smaller and smaller scope.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## A list is the fourth shape
|
||||||
|
|
||||||
|
Facts, notes and tasks were the three append-only shapes. `list_items` is the
|
||||||
|
fourth (Vikunja #453): an item, a status, and a list tag.
|
||||||
|
|
||||||
|
It is not a task. Milk is not work, nothing prioritises it, and the ranker must
|
||||||
|
not start counting groceries as outstanding errands. It is not a fact either,
|
||||||
|
because it claims nothing about the world. What it is, is a set that grows and
|
||||||
|
shrinks.
|
||||||
|
|
||||||
|
The property that makes the separate table worth it: no predicate reads a list.
|
||||||
|
Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
|
||||||
|
two people adding to the same list at once cost nothing — there is no order to
|
||||||
|
disagree about and no lifecycle past crossed-off.
|
||||||
|
|
||||||
|
The unique index is the tasks one, per list, and live rows only. Saying "молоко"
|
||||||
|
twice before the shop is one line; saying it again next week, after the last one
|
||||||
|
was crossed off, is a new line.
|
||||||
|
|
||||||
|
Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
|
||||||
|
All four are matched deterministically in `internal/router/list.go` and all four
|
||||||
|
run at stage 0, because an add and a read-back are cheap and should not depend on
|
||||||
|
the resident model having a good turn. Crossing one item off claims the turn only
|
||||||
|
when the list holds that item, which is what keeps "купил новый ноутбук" a note.
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
|
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ require (
|
|||||||
modernc.org/sqlite v1.54.0
|
modernc.org/sqlite v1.54.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/kami/hexis v0.0.0
|
require (
|
||||||
|
github.com/aaaton/golem/v4 v4.0.2
|
||||||
|
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110
|
||||||
|
github.com/kami/hexis v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/kami/praxis v0.0.0
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
github.com/aaaton/golem/v4 v4.0.0/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI=
|
||||||
|
github.com/aaaton/golem/v4 v4.0.2 h1:m4FvpSL8Zcv7XjmrKiBP7dp5FzhPCji9FcQRcH6T23k=
|
||||||
|
github.com/aaaton/golem/v4 v4.0.2/go.mod h1:OfK/S5v9Exsx1yO21WorREuIVV+Y5K2hygP0A9oJCCI=
|
||||||
|
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110 h1:aRLhKltXUyZg/7DoZE5PcNhETfTjMBXBEzD/JVRVVN4=
|
||||||
|
github.com/aaaton/golem/v4/dicts/ru v0.0.0-20250408131944-3488790fc110/go.mod h1:n14MqOgbLBidXRIvLw9H3/vFyE4+PcjVxYOu05f55R4=
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
|||||||
if fake.writes != 0 {
|
if fake.writes != 0 {
|
||||||
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
||||||
}
|
}
|
||||||
_, err = cli.Chat(context.Background(), "привет")
|
_, err = cli.Chat(context.Background(), "web", "привет")
|
||||||
if !errors.Is(err, ipc.ErrForbidden) {
|
if !errors.Is(err, ipc.ErrForbidden) {
|
||||||
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
|||||||
t.Fatalf("dial: %v", err)
|
t.Fatalf("dial: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = cli.Close() })
|
t.Cleanup(func() { _ = cli.Close() })
|
||||||
reply, err := cli.Chat(context.Background(), "привет")
|
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat: %v", err)
|
t.Fatalf("Chat: %v", err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
|||||||
return int64(r.writes), nil
|
return int64(r.writes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
|
||||||
r.chats++
|
r.chats++
|
||||||
return "echo: " + text, nil
|
return "echo: " + text, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ambient events — the work calendar read (Vikunja #126).
|
// Ambient events — the work calendar read (Vikunja #126).
|
||||||
@@ -48,18 +50,6 @@ type Notification struct {
|
|||||||
// reposting a notification for a meeting already under way.
|
// reposting a notification for a meeting already under way.
|
||||||
const ambientPastGrace = 2 * time.Hour
|
const ambientPastGrace = 2 * time.Hour
|
||||||
|
|
||||||
// dayWords maps the words that move a notification off Posted's day. Only
|
|
||||||
// explicit ones: an offset is a claim about which day, and guessing which day
|
|
||||||
// is exactly the guess this parse refuses to make.
|
|
||||||
var dayWords = map[string]int{
|
|
||||||
"завтра": 1,
|
|
||||||
"tomorrow": 1,
|
|
||||||
"сегодня": 0,
|
|
||||||
"today": 0,
|
|
||||||
"tonight": 0,
|
|
||||||
"послезавтра": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventFromNotification turns a notification into the event it describes, or
|
// EventFromNotification turns a notification into the event it describes, or
|
||||||
// reports false when it does not clearly describe one.
|
// reports false when it does not clearly describe one.
|
||||||
//
|
//
|
||||||
@@ -104,11 +94,16 @@ func EventFromNotification(n Notification) (Event, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dayOffset reports how many days off Posted's day the notification puts the
|
// dayOffset reports how many days off Posted's day the notification puts the
|
||||||
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
|
// event. Only explicit words move it: an offset is a claim about which day, and
|
||||||
|
// guessing which day is exactly the guess this parse refuses to make.
|
||||||
|
//
|
||||||
|
// The words are a closed class and live complete in internal/lexicon (Vikunja
|
||||||
|
// #525), which is how "позавчера" resolves here now — it never did while the map
|
||||||
|
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
|
||||||
func dayOffset(line string) int {
|
func dayOffset(line string) int {
|
||||||
for _, f := range strings.Fields(strings.ToLower(line)) {
|
for _, f := range strings.Fields(strings.ToLower(line)) {
|
||||||
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
|
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
|
||||||
if off, ok := dayWords[f]; ok {
|
if off, ok := lexicon.DayOffset(f); ok {
|
||||||
return off
|
return off
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +116,7 @@ func dayOffset(line string) int {
|
|||||||
func stripDayWords(s string) string {
|
func stripDayWords(s string) string {
|
||||||
out := make([]string, 0, 8)
|
out := make([]string, 0, 8)
|
||||||
for _, f := range strings.Fields(s) {
|
for _, f := range strings.Fields(s) {
|
||||||
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
|
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
|
|||||||
@@ -601,6 +601,9 @@ type MorningRoutineItemConfig struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
FactKey string `json:"fact_key"`
|
FactKey string `json:"fact_key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
// Optional — this one being skipped does not earn a nudge. Default false,
|
||||||
|
// so a routine written before 04-08-2026 keeps behaving as it did.
|
||||||
|
Optional bool `json:"optional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
||||||
@@ -1734,7 +1737,7 @@ func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
|||||||
for i, r := range mc {
|
for i, r := range mc {
|
||||||
items := make([]morning.Item, len(r.Items))
|
items := make([]morning.Item, len(r.Items))
|
||||||
for j, it := range r.Items {
|
for j, it := range r.Items {
|
||||||
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
|
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
|
||||||
}
|
}
|
||||||
weekdays := make([]time.Weekday, len(r.Weekdays))
|
weekdays := make([]time.Weekday, len(r.Weekdays))
|
||||||
for j, w := range r.Weekdays {
|
for j, w := range r.Weekdays {
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
|
|||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||||
|
//
|
||||||
|
// Memory only, deliberately, unlike SessionStore — a restart expires every
|
||||||
|
// parked question and she does not announce that it happened (Vikunja #385,
|
||||||
|
// written down in docs/design.md). The 90s TTL and the attempt count measure a
|
||||||
|
// pause in one conversation, and a restart is a gap of unknown length, so a
|
||||||
|
// restored question would either be dead already or lying about its age. His
|
||||||
|
// next words route fresh, which is the right answer with or without a notice.
|
||||||
|
// Do not give this store a persister without re-arguing that.
|
||||||
type ClarifyStore struct {
|
type ClarifyStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
questions map[string]*PendingQuestion
|
questions map[string]*PendingQuestion
|
||||||
|
|||||||
+35
-2
@@ -60,6 +60,19 @@ type Nudge struct {
|
|||||||
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the
|
||||||
|
// reader; Completed is nil while the attempt is still pending.
|
||||||
|
type DeliveryAttempt struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Rule string `json:"rule,omitempty"`
|
||||||
|
ReminderID int64 `json:"reminder_id,omitempty"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Completed *time.Time `json:"completed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Note — a recall/preference item; ranked by embedding cosine on query.
|
// Note — a recall/preference item; ranked by embedding cosine on query.
|
||||||
// Score is set by QueryNotes (0 on the write path).
|
// Score is set by QueryNotes (0 on the write path).
|
||||||
type Note struct {
|
type Note struct {
|
||||||
@@ -521,6 +534,12 @@ type outcomesReq struct {
|
|||||||
type nReq struct {
|
type nReq struct {
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliveryAttemptsReq — the outbox read. Status is empty for every status.
|
||||||
|
type deliveryAttemptsReq struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
N int `json:"n"`
|
||||||
|
}
|
||||||
type kindNReq struct {
|
type kindNReq struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
@@ -593,8 +612,14 @@ type MCPServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
||||||
|
//
|
||||||
|
// Conversation names the thread this utterance belongs to: a mavweb session, a
|
||||||
|
// telegram chat. It is opaque to the daemon and only has to be stable for one
|
||||||
|
// conversation and distinct across them. Empty is allowed and means "the
|
||||||
|
// unattributed text tap", which is what an old client sends.
|
||||||
type chatReq struct {
|
type chatReq struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Conversation string `json:"conversation,omitempty"`
|
||||||
}
|
}
|
||||||
type chatResp struct {
|
type chatResp struct {
|
||||||
Reply string `json:"reply"`
|
Reply string `json:"reply"`
|
||||||
@@ -679,6 +704,9 @@ type CoreAPI interface {
|
|||||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||||
|
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||||
|
// every status (Vikunja #390).
|
||||||
|
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||||
|
|
||||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||||
// own table so machine-rate traces never crowd out human-rate facts.
|
// own table so machine-rate traces never crowd out human-rate facts.
|
||||||
@@ -759,7 +787,12 @@ type CoreAPI interface {
|
|||||||
// Chat routes a text utterance through the reactive handler's core path
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
Chat(ctx context.Context, text string) (string, error)
|
//
|
||||||
|
// conversation names the thread. A parked clarifying question is held per
|
||||||
|
// conversation, so an unanswered question on one reach cannot eat the next
|
||||||
|
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||||
|
// tap and is still one conversation of its own, separate from the mic.
|
||||||
|
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||||
|
|
||||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||||
|
|||||||
+11
-2
@@ -68,6 +68,7 @@ var readOnlyMethods = map[Method]bool{
|
|||||||
MethodRecentActiveFacts: true,
|
MethodRecentActiveFacts: true,
|
||||||
MethodCalendarEvents: true,
|
MethodCalendarEvents: true,
|
||||||
MethodRecentNudges: true,
|
MethodRecentNudges: true,
|
||||||
|
MethodDeliveryAttempts: true,
|
||||||
MethodRecentEcoTraces: true,
|
MethodRecentEcoTraces: true,
|
||||||
MethodQueryNotes: true,
|
MethodQueryNotes: true,
|
||||||
MethodRecentNotes: true,
|
MethodRecentNotes: true,
|
||||||
@@ -373,6 +374,14 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
var out []DeliveryAttempt
|
||||||
|
if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||||
var out []Nudge
|
var out []Nudge
|
||||||
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
||||||
@@ -623,9 +632,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
var r chatResp
|
var r chatResp
|
||||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return r.Reply, nil
|
return r.Reply, nil
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = cli.Close() })
|
t.Cleanup(func() { _ = cli.Close() })
|
||||||
|
|
||||||
reply, err := cli.Chat(context.Background(), "привет")
|
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat: %v", err)
|
t.Fatalf("Chat: %v", err)
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,7 @@ type chatTestAPI struct {
|
|||||||
UnimplementedCoreAPI
|
UnimplementedCoreAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
|
||||||
if text == "привет" {
|
if text == "привет" {
|
||||||
return "и тебе привет!", nil
|
return "и тебе привет!", nil
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -173,6 +173,25 @@ func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]DeliveryAttempt, len(as))
|
||||||
|
for i, at := range as {
|
||||||
|
out[i] = DeliveryAttempt{
|
||||||
|
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||||
|
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||||
|
}
|
||||||
|
if at.HasComplete {
|
||||||
|
t := at.Completed
|
||||||
|
out[i].Completed = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||||
return id, mapErr(err)
|
return id, mapErr(err)
|
||||||
@@ -240,7 +259,7 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
|||||||
return newID, mapErr(err)
|
return newID, mapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
return "", errors.New("store: chat not available via direct store API")
|
return "", errors.New("store: chat not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,6 +882,16 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}),
|
}),
|
||||||
|
MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) {
|
||||||
|
out, err := api.DeliveryAttempts(ctx, p.Status, p.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []DeliveryAttempt{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}),
|
||||||
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||||
out, err := api.RecentNudges(ctx, p.N)
|
out, err := api.RecentNudges(ctx, p.N)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -974,7 +1003,7 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
return map[string]int64{"new_id": newID}, nil
|
return map[string]int64{"new_id": newID}, nil
|
||||||
}),
|
}),
|
||||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||||
reply, err := api.Chat(ctx, p.Text)
|
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||||
return chatResp{Reply: reply}, err
|
return chatResp{Reply: reply}, err
|
||||||
}),
|
}),
|
||||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st
|
|||||||
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
return nil, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
|||||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
return DayPlan{}, ErrNotImplemented
|
return DayPlan{}, ErrNotImplemented
|
||||||
}
|
}
|
||||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
return "", ErrNotImplemented
|
return "", ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
||||||
MethodCalendarEvents Method = "calendar_events"
|
MethodCalendarEvents Method = "calendar_events"
|
||||||
MethodRecentNudges Method = "recent_nudges"
|
MethodRecentNudges Method = "recent_nudges"
|
||||||
|
MethodDeliveryAttempts Method = "delivery_attempts"
|
||||||
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
||||||
MethodWriteNote Method = "write_note"
|
MethodWriteNote Method = "write_note"
|
||||||
MethodQueryNotes Method = "query_notes"
|
MethodQueryNotes Method = "query_notes"
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Package lexicon holds the Russian word sets that can be finished.
|
||||||
|
//
|
||||||
|
// A closed class has a fixed number of members: the language has as many
|
||||||
|
// interrogative pronouns as it has, and no utterance will ever contain a
|
||||||
|
// thirteenth month. Those sets belong in a data file, complete, and that is what
|
||||||
|
// this package is (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%").
|
||||||
|
//
|
||||||
|
// It is the first of three mechanisms that replaced hand-written Russian
|
||||||
|
// patterns, and the only one that answers with certainty. The other two are the
|
||||||
|
// embedder, for recognising an open set of phrasings, and a morphological
|
||||||
|
// dictionary, for questions about grammar. A list that can never be finished is
|
||||||
|
// a guess dressed as a rule and does not go here.
|
||||||
|
//
|
||||||
|
// What this package does NOT do is match. It hands out sets and lookups; the
|
||||||
|
// caller decides what to do with a hit, because "this token is an interrogative"
|
||||||
|
// and "this utterance is a question" are different claims.
|
||||||
|
package lexicon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed lexicon_ru_v1.json
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// ruFile is the versioned file this package reads. A new version is a new file,
|
||||||
|
// not an edit to this one, so a caller pinned to v1 keeps the words it was
|
||||||
|
// measured against.
|
||||||
|
const ruFile = "lexicon_ru_v1.json"
|
||||||
|
|
||||||
|
type lexiconFile struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Sets map[string]lexiSet `json:"sets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lexiSet struct {
|
||||||
|
Note string `json:"note"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
Values map[string]int `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ru is parsed once at init. A malformed embedded file is a build-time mistake
|
||||||
|
// that survived to runtime, and there is no sane degraded behaviour for "the
|
||||||
|
// months are missing", so it panics rather than answering with an empty set.
|
||||||
|
var ru = mustLoad()
|
||||||
|
|
||||||
|
func mustLoad() lexiconFile {
|
||||||
|
data, err := files.ReadFile(ruFile)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("lexicon: read %s: %v", ruFile, err))
|
||||||
|
}
|
||||||
|
var f lexiconFile
|
||||||
|
if err := json.Unmarshal(data, &f); err != nil {
|
||||||
|
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
|
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||||
|
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||||
|
"not_place_after_v",
|
||||||
|
} {
|
||||||
|
s, ok := f.Sets[name]
|
||||||
|
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||||
|
panic(fmt.Sprintf("lexicon: %s has no set %q", ruFile, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// words returns a copy of a word set, so a caller cannot edit the lexicon by
|
||||||
|
// holding onto what it was given.
|
||||||
|
func words(set string) []string {
|
||||||
|
src := ru.Sets[set].Words
|
||||||
|
out := make([]string, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interrogatives returns the question words, Russian and English.
|
||||||
|
func Interrogatives() []string { return words("interrogatives") }
|
||||||
|
|
||||||
|
// CaptureVerbs returns the imperatives that mean "record this".
|
||||||
|
func CaptureVerbs() []string { return words("capture_verbs") }
|
||||||
|
|
||||||
|
// NarrativeRequests returns the imperatives that mean "tell me about".
|
||||||
|
func NarrativeRequests() []string { return words("narrative_requests") }
|
||||||
|
|
||||||
|
// NotPlaceAfterV returns the words that follow "в" without naming a place.
|
||||||
|
func NotPlaceAfterV() []string { return words("not_place_after_v") }
|
||||||
|
|
||||||
|
// Cardinal reports the value of a spoken number word. The word is compared
|
||||||
|
// lowercased and trimmed, because it arrives from a tokenizer that may not have
|
||||||
|
// done either.
|
||||||
|
func Cardinal(word string) (int, bool) {
|
||||||
|
n, ok := ru.Sets["cardinals"].Values[norm(word)]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayOffset reports how many days a relative day word moves from today.
|
||||||
|
//
|
||||||
|
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
|
||||||
|
// return is the only way to tell a hit from a miss. Callers that used to switch
|
||||||
|
// on strings.Contains had to order "послезавтра" before "завтра" by hand,
|
||||||
|
// because one contains the other; a lookup has no such trap.
|
||||||
|
func DayOffset(word string) (int, bool) {
|
||||||
|
n, ok := ru.Sets["day_offsets"].Values[norm(word)]
|
||||||
|
return n, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayOffsetWords lists the relative day words themselves, sorted so the order is
|
||||||
|
// stable across builds — a caller that folds them into a regexp alternation would
|
||||||
|
// otherwise produce a different pattern every run. Map iteration order is why
|
||||||
|
// this sorts rather than the caller.
|
||||||
|
func DayOffsetWords() []string {
|
||||||
|
vals := ru.Sets["day_offsets"].Values
|
||||||
|
out := make([]string, 0, len(vals))
|
||||||
|
for w := range vals {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
|
||||||
|
// offset. Where two words appear, the one that moves furthest from today wins in
|
||||||
|
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
|
||||||
|
// and the longest-match rule that picking the first word would need is exactly
|
||||||
|
// what the old Contains switch got wrong.
|
||||||
|
func DayOffsetIn(text string) (int, bool) {
|
||||||
|
lower := norm(text)
|
||||||
|
best, found := 0, false
|
||||||
|
for word, n := range ru.Sets["day_offsets"].Values {
|
||||||
|
if !containsWord(lower, word) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || abs(n) > abs(best) {
|
||||||
|
best, found = n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday returns the Russian name of a weekday index, Sunday first, matching
|
||||||
|
// Go's time.Weekday. An index off the end returns "".
|
||||||
|
func Weekday(i int) string { return at("weekdays", i) }
|
||||||
|
|
||||||
|
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
|
||||||
|
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
|
||||||
|
func MonthGenitive(m int) string { return at("months_genitive", m) }
|
||||||
|
|
||||||
|
// HourSpoken returns an hour spelled out for the voice. 0 to 23.
|
||||||
|
func HourSpoken(h int) string { return at("hours_spoken", h) }
|
||||||
|
|
||||||
|
func at(set string, i int) string {
|
||||||
|
w := ru.Sets[set].Words
|
||||||
|
if i < 0 || i >= len(w) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return w[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||||
|
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsWord reports whether haystack holds needle on word boundaries. Go's
|
||||||
|
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
|
||||||
|
// checked here instead: a rune on either side must not be a letter or a digit.
|
||||||
|
func containsWord(haystack, needle string) bool {
|
||||||
|
if needle == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
from := 0
|
||||||
|
for {
|
||||||
|
i := strings.Index(haystack[from:], needle)
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i += from
|
||||||
|
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
from = i + len(needle)
|
||||||
|
if from >= len(haystack) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundaryBefore(s string, i int) bool {
|
||||||
|
if i == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeLastRuneInString(s[:i])
|
||||||
|
return !wordRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundaryAfter(s string, i int) bool {
|
||||||
|
if i >= len(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r, _ := utf8.DecodeRuneInString(s[i:])
|
||||||
|
return !wordRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "russian closed-class lexicons v1",
|
||||||
|
"notes": [
|
||||||
|
"Every set here is a CLOSED CLASS: the language has a fixed number of members and the list can be finished. That is why it is a list at all. A word list that can never be finished is a guess dressed as a rule, and it belongs with the embedder, not here (Vikunja #522).",
|
||||||
|
"Editing a word is a data change. No Go change, no rebuild of a pattern, no second copy to keep in step — month names used to live in two files and day offsets in three.",
|
||||||
|
"Interrogatives and capture verbs carry their English members too. He speaks both languages in one sentence and the router sees one utterance.",
|
||||||
|
"The sets are matched over tokens, never as substrings: \"что\" inside \"чтобы\" and \"как\" inside \"какао\" are not questions.",
|
||||||
|
"Order matters in weekdays, months and hours, and nowhere else. weekdays starts at Sunday because Go's time.Weekday does. months is 1-indexed with an empty slot at 0 for the same reason. hours is indexed by the hour itself.",
|
||||||
|
"A form missing from a closed set is a bug report, not a judgement call. Add it."
|
||||||
|
],
|
||||||
|
"sets": {
|
||||||
|
"interrogatives": {
|
||||||
|
"note": "The Russian interrogative pronouns and adverbs, declined, plus the English ones. Closed class: this is the whole list, and a question word outside it does not exist.",
|
||||||
|
"words": [
|
||||||
|
"что", "чего", "чему", "чем", "чём",
|
||||||
|
"кто", "кого", "кому", "кем", "ком",
|
||||||
|
"какой", "какая", "какое", "какие", "какого", "какому", "каким", "каких", "какими", "каком",
|
||||||
|
"который", "которая", "которое", "которые", "которого", "котором",
|
||||||
|
"чей", "чья", "чьё", "чьи",
|
||||||
|
"где", "куда", "откуда", "когда", "докуда",
|
||||||
|
"почему", "зачем", "отчего", "как", "сколько", "насколько", "каково",
|
||||||
|
"what", "who", "whom", "whose", "why", "when", "where", "which", "how"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capture_verbs": {
|
||||||
|
"note": "An explicit instruction to record something, in the imperative he actually speaks. Not a closed class in the grammatical sense, but a closed set of the commands Maven answers to — it is her vocabulary, and its members are decided here rather than discovered.",
|
||||||
|
"words": [
|
||||||
|
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани", "занеси", "внеси",
|
||||||
|
"note", "remember", "log", "save", "add"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"narrative_requests": {
|
||||||
|
"note": "\"Tell me about X\" asks for knowledge Maven does not hold about him. It carries no question mark and no interrogative, which is how \"расскажи про битву при Ватерлоо\" reached the fact store (#470).",
|
||||||
|
"words": [
|
||||||
|
"расскажи", "объясни", "опиши", "перечисли", "поясни",
|
||||||
|
"tell", "explain", "describe", "list"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cardinals": {
|
||||||
|
"note": "Number words as spoken, with the gender variants Russian requires: один/одна/одно and два/две agree with the noun that follows. Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
||||||
|
"values": {
|
||||||
|
"ноль": 0, "нуль": 0, "zero": 0,
|
||||||
|
"один": 1, "одна": 1, "одно": 1, "one": 1,
|
||||||
|
"два": 2, "две": 2, "two": 2,
|
||||||
|
"три": 3, "three": 3,
|
||||||
|
"четыре": 4, "four": 4,
|
||||||
|
"пять": 5, "five": 5,
|
||||||
|
"шесть": 6, "six": 6,
|
||||||
|
"семь": 7, "seven": 7,
|
||||||
|
"восемь": 8, "eight": 8,
|
||||||
|
"девять": 9, "nine": 9,
|
||||||
|
"десять": 10, "ten": 10,
|
||||||
|
"одиннадцать": 11, "eleven": 11,
|
||||||
|
"двенадцать": 12, "twelve": 12,
|
||||||
|
"тринадцать": 13, "thirteen": 13,
|
||||||
|
"четырнадцать": 14, "fourteen": 14,
|
||||||
|
"пятнадцать": 15, "fifteen": 15,
|
||||||
|
"шестнадцать": 16, "sixteen": 16,
|
||||||
|
"семнадцать": 17, "seventeen": 17,
|
||||||
|
"восемнадцать": 18, "eighteen": 18,
|
||||||
|
"девятнадцать": 19, "nineteen": 19,
|
||||||
|
"двадцать": 20, "twenty": 20,
|
||||||
|
"тридцать": 30, "thirty": 30,
|
||||||
|
"сорок": 40, "forty": 40,
|
||||||
|
"пятьдесят": 50, "fifty": 50,
|
||||||
|
"шестьдесят": 60, "sixty": 60,
|
||||||
|
"семьдесят": 70, "seventy": 70,
|
||||||
|
"восемьдесят": 80, "eighty": 80,
|
||||||
|
"девяносто": 90, "ninety": 90,
|
||||||
|
"сто": 100, "hundred": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"day_offsets": {
|
||||||
|
"note": "The words that name a day relative to today, and the number of days each one moves. Only explicit ones: an offset is a claim about which day, and guessing which day is the guess these callers refuse to make. Multi-word members are matched as a phrase.",
|
||||||
|
"values": {
|
||||||
|
"позавчера": -2,
|
||||||
|
"вчера": -1,
|
||||||
|
"yesterday": -1,
|
||||||
|
"сегодня": 0,
|
||||||
|
"today": 0,
|
||||||
|
"tonight": 0,
|
||||||
|
"завтра": 1,
|
||||||
|
"tomorrow": 1,
|
||||||
|
"послезавтра": 2,
|
||||||
|
"day after tomorrow": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"weekdays": {
|
||||||
|
"note": "Nominative, starting at Sunday so the index is Go's time.Weekday.",
|
||||||
|
"words": [
|
||||||
|
"воскресенье", "понедельник", "вторник", "среда",
|
||||||
|
"четверг", "пятница", "суббота"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"months_genitive": {
|
||||||
|
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
|
||||||
|
"words": [
|
||||||
|
"", "января", "февраля", "марта", "апреля", "мая", "июня",
|
||||||
|
"июля", "августа", "сентября", "октября", "ноября", "декабря"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hours_spoken": {
|
||||||
|
"note": "Hours spelled out for the voice: \"3 ч\" is fine on a screen and wrong out loud. Indexed by the hour, 0 to 23.",
|
||||||
|
"words": [
|
||||||
|
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
||||||
|
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
||||||
|
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
||||||
|
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"not_place_after_v": {
|
||||||
|
"note": "Words that follow the preposition \"в\" without naming a place, so \"в общем\" and \"в котором часу\" are not read as a city we do not know.",
|
||||||
|
"words": [
|
||||||
|
"данный", "данную", "данное", "этот", "эту", "это", "том", "той",
|
||||||
|
"котором", "которой", "какое", "какой", "который", "каком",
|
||||||
|
"общем", "точности", "курсе", "принципе", "итоге", "целом",
|
||||||
|
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
|
||||||
|
"начале", "конце", "середине", "течение", "течении"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package lexicon
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestClosedSetsAreComplete — the point of the package. A closed class can be
|
||||||
|
// finished, so the test names the members that were MISSING from the inline Go
|
||||||
|
// lists this package replaced (Vikunja #525) and every one of them has to be
|
||||||
|
// there. Add to this list when a form turns up unhandled.
|
||||||
|
func TestClosedSetsAreComplete(t *testing.T) {
|
||||||
|
inter := map[string]bool{}
|
||||||
|
for _, w := range Interrogatives() {
|
||||||
|
inter[w] = true
|
||||||
|
}
|
||||||
|
// Instrumental and prepositional cases of что and кто, and the declined
|
||||||
|
// какой. The old list had что/чего and nothing else, so "чем ты занята" and
|
||||||
|
// "в каком часу" carried no interrogative at all.
|
||||||
|
for _, w := range []string{"чем", "чём", "чему", "кем", "ком", "каком", "какими", "насколько"} {
|
||||||
|
if !inter[w] {
|
||||||
|
t.Errorf("interrogatives is missing %q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
word string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"ноль", 0}, {"одна", 1}, {"две", 2}, {"одиннадцать", 11},
|
||||||
|
{"пятнадцать", 15}, {"двадцать", 20}, {"сорок", 40}, {"девяносто", 90},
|
||||||
|
{"сто", 100}, {"twelve", 12},
|
||||||
|
} {
|
||||||
|
got, ok := Cardinal(tc.word)
|
||||||
|
if !ok || got != tc.want {
|
||||||
|
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := Cardinal("бэкап"); ok {
|
||||||
|
t.Error("Cardinal must not answer for a word that is not a number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
|
||||||
|
// replaced used strings.Contains in a switch, so "послезавтра" had to be tested
|
||||||
|
// before "завтра" by hand or the day after tomorrow read as tomorrow.
|
||||||
|
func TestDayOffsetHasNoOrderingTrap(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"послезавтра", 2, true},
|
||||||
|
{"завтра", 1, true},
|
||||||
|
{"сегодня", 0, true},
|
||||||
|
{"вчера", -1, true},
|
||||||
|
{"позавчера", -2, true},
|
||||||
|
{"встреча послезавтра в 14:30", 2, true},
|
||||||
|
{"Tomorrow at 09:00", 1, true},
|
||||||
|
{"не сегодня, а послезавтра", 2, true},
|
||||||
|
{"в четверг", 0, false},
|
||||||
|
{"завтраком", 0, false},
|
||||||
|
} {
|
||||||
|
got, ok := DayOffsetIn(tc.text)
|
||||||
|
if ok != tc.ok || (ok && got != tc.want) {
|
||||||
|
t.Errorf("DayOffsetIn(%q) = %d, %v; want %d, %v", tc.text, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "сегодня" is offset 0, which is also the zero value, so the second return
|
||||||
|
// is the only thing that separates a hit from a miss.
|
||||||
|
if n, ok := DayOffset("сегодня"); n != 0 || !ok {
|
||||||
|
t.Errorf("DayOffset(сегодня) = %d, %v; want 0, true", n, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIndexedSetsLineUpWithTheirCallers — weekdays start at Sunday because Go's
|
||||||
|
// time.Weekday does, and months are 1-indexed so a month number needs no
|
||||||
|
// arithmetic. Off-by-one here is a wrong date spoken out loud.
|
||||||
|
func TestIndexedSetsLineUpWithTheirCallers(t *testing.T) {
|
||||||
|
if got := Weekday(0); got != "воскресенье" {
|
||||||
|
t.Errorf("Weekday(0) = %q, want воскресенье", got)
|
||||||
|
}
|
||||||
|
if got := Weekday(1); got != "понедельник" {
|
||||||
|
t.Errorf("Weekday(1) = %q, want понедельник", got)
|
||||||
|
}
|
||||||
|
if got := MonthGenitive(1); got != "января" {
|
||||||
|
t.Errorf("MonthGenitive(1) = %q, want января", got)
|
||||||
|
}
|
||||||
|
if got := MonthGenitive(12); got != "декабря" {
|
||||||
|
t.Errorf("MonthGenitive(12) = %q, want декабря", got)
|
||||||
|
}
|
||||||
|
if got := MonthGenitive(0); got != "" {
|
||||||
|
t.Errorf("MonthGenitive(0) = %q, want the empty slot", got)
|
||||||
|
}
|
||||||
|
if got := HourSpoken(23); got != "двадцать три" {
|
||||||
|
t.Errorf("HourSpoken(23) = %q, want двадцать три", got)
|
||||||
|
}
|
||||||
|
for _, i := range []int{-1, 7, 13, 24} {
|
||||||
|
if got := Weekday(i); i >= 7 && got != "" {
|
||||||
|
t.Errorf("Weekday(%d) = %q, want empty", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := HourSpoken(24); got != "" {
|
||||||
|
t.Errorf("HourSpoken(24) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallerCannotEditTheLexicon — the sets are handed out as copies. A caller
|
||||||
|
// that sorted the slice it was given would otherwise reorder weekdays for
|
||||||
|
// everybody.
|
||||||
|
func TestCallerCannotEditTheLexicon(t *testing.T) {
|
||||||
|
first := Interrogatives()
|
||||||
|
first[0] = "мутировало"
|
||||||
|
if again := Interrogatives(); again[0] == "мутировало" {
|
||||||
|
t.Fatal("the lexicon handed out its own backing array")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-34
@@ -3,8 +3,11 @@ package memory
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
// 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]
|
day := weekdayRU[int(wd)%7]
|
||||||
acts := p.Weekly[wd]
|
acts := p.Weekly[wd]
|
||||||
if len(acts) > 0 {
|
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 {
|
if len(p.Everyday) > 0 {
|
||||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
return say.S(say.HabitWeekdaySame, map[string]string{
|
||||||
day, joinActivities(p.Everyday))
|
"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.
|
// 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]
|
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||||
switch {
|
switch {
|
||||||
case len(sat) > 0 && len(sun) > 0:
|
case len(sat) > 0 && len(sun) > 0:
|
||||||
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
return say.S(say.HabitWeekendBoth, map[string]string{
|
||||||
joinActivities(sat), joinActivities(sun))
|
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
|
||||||
|
})
|
||||||
case len(sat) > 0:
|
case len(sat) > 0:
|
||||||
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||||
joinActivities(sat))
|
|
||||||
case len(sun) > 0:
|
case len(sun) > 0:
|
||||||
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
||||||
joinActivities(sun))
|
|
||||||
case len(p.Everyday) > 0:
|
case len(p.Everyday) > 0:
|
||||||
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
||||||
joinActivities(p.Everyday))
|
|
||||||
}
|
}
|
||||||
return "по выходным я пока не вижу у тебя ничего постоянного."
|
return say.S(say.HabitWeekendNone, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
// 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.
|
// a year of them, and only one of those is worth believing.
|
||||||
func (p Profile) FormatOverallRU() string {
|
func (p Profile) FormatOverallRU() string {
|
||||||
if len(p.All) == 0 {
|
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
|
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
||||||
// is too short to name in days.
|
// is too short to name in days.
|
||||||
func (p Profile) spanRU() string {
|
func (p Profile) spanRU() string {
|
||||||
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
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
|
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||||
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
return say.S(say.HabitSpanDays, map[string]string{
|
||||||
}
|
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||||
|
})
|
||||||
// pluralDaysRU — the Russian count form of "день" for n.
|
|
||||||
func pluralDaysRU(n int) string {
|
|
||||||
switch {
|
|
||||||
case n%100 >= 11 && n%100 <= 14:
|
|
||||||
return "дней"
|
|
||||||
case n%10 == 1:
|
|
||||||
return "день"
|
|
||||||
case n%10 >= 2 && n%10 <= 4:
|
|
||||||
return "дня"
|
|
||||||
default:
|
|
||||||
return "дней"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
// 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
|
// come from the model, so an unglossed one is as likely to be
|
||||||
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
||||||
// not a sentence.
|
// 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 {
|
if !a.HasTypical {
|
||||||
parts[i] = gloss
|
parts[i] = gloss
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
parts[i] = say.S(say.HabitAt, map[string]string{
|
||||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
"gloss": gloss,
|
||||||
|
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(parts) == 1 {
|
if len(parts) == 1 {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
)
|
)
|
||||||
|
|
||||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
// 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 {
|
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)
|
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)
|
t.Errorf("empty profile reads %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q\nwant %q", 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)
|
t.Errorf("an unknown weekday reads %q", empty)
|
||||||
}
|
}
|
||||||
// Persona: she addresses him informally, never in the masculine about
|
// 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
|
// A day with nothing of its own says so rather than reciting water as if
|
||||||
// Wednesday were the reason for it.
|
// Wednesday were the reason for it.
|
||||||
wed := p.FormatWeekdayRU(time.Wednesday)
|
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)
|
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. "у меня пока нет
|
// The no-data weekday answer is about his week, not about her. "у меня пока нет
|
||||||
// ничего постоянного" answers a question nobody asked.
|
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
|
||||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
// 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())
|
p := BuildProfile(nil, behaviorNow())
|
||||||
got := p.FormatWeekdayRU(time.Wednesday)
|
got := p.FormatWeekdayRU(time.Wednesday)
|
||||||
if strings.Contains(got, "у меня") {
|
if strings.Contains(got, "у меня") {
|
||||||
t.Errorf("got %q", got)
|
t.Errorf("got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "у тебя") {
|
if !strings.Contains(got, "средам") {
|
||||||
t.Errorf("got %q, want an answer about him", 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.
|
// "по выходным" is a question about two days, answered as two days.
|
||||||
func TestFormatWeekendRU(t *testing.T) {
|
func TestFormatWeekendRU(t *testing.T) {
|
||||||
now := behaviorNow()
|
now := behaviorNow()
|
||||||
|
|||||||
@@ -90,9 +90,44 @@ func Load() (Fixture, error) {
|
|||||||
if len(f.Cases) == 0 {
|
if len(f.Cases) == 0 {
|
||||||
return Fixture{}, fmt.Errorf("fixture has no cases")
|
return Fixture{}, fmt.Errorf("fixture has no cases")
|
||||||
}
|
}
|
||||||
|
if err := checkIDs(f); err != nil {
|
||||||
|
return Fixture{}, err
|
||||||
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkIDs refuses a fixture where a case note and a filler note share an id.
|
||||||
|
//
|
||||||
|
// Every case is scored over its own notes plus the whole filler set, and the
|
||||||
|
// two stores disagree about what a repeated id means: the sqlite store upserts
|
||||||
|
// on it, the in-memory store appends. So one collision makes a case score
|
||||||
|
// differently on the two backends, and it reads as an embedder or gate
|
||||||
|
// difference, which is the one thing this harness exists to measure (Vikunja
|
||||||
|
// #386). It was dodged once by hand during #373 by renaming two ids.
|
||||||
|
//
|
||||||
|
// Checked in Load rather than in the test, so every caller of the fixture is
|
||||||
|
// covered and not only the one that remembers to look.
|
||||||
|
func checkIDs(f Fixture) error {
|
||||||
|
filler := make(map[string]bool, len(f.Filler))
|
||||||
|
for _, n := range f.Filler {
|
||||||
|
if n.ID == "" {
|
||||||
|
return fmt.Errorf("filler note with an empty id")
|
||||||
|
}
|
||||||
|
if filler[n.ID] {
|
||||||
|
return fmt.Errorf("duplicate filler note id %q", n.ID)
|
||||||
|
}
|
||||||
|
filler[n.ID] = true
|
||||||
|
}
|
||||||
|
for _, c := range f.Cases {
|
||||||
|
for _, n := range c.Notes {
|
||||||
|
if filler[n.ID] {
|
||||||
|
return fmt.Errorf("case %s: note id %q collides with a filler note", c.ID, n.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewStore builds an empty store for one case, plus a function to release it.
|
// NewStore builds an empty store for one case, plus a function to release it.
|
||||||
// A factory rather than a store because every case needs a clean index — notes
|
// A factory rather than a store because every case needs a clean index — notes
|
||||||
// from case A must not be visible to case B's query.
|
// from case A must not be visible to case B's query.
|
||||||
|
|||||||
@@ -337,3 +337,28 @@ func marginSweep(t *testing.T, emb router.Embedder, f Fixture) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFillerIDCollisionIsRefused — the guard that keeps a fixture edit from
|
||||||
|
// looking like a backend difference (Vikunja #386).
|
||||||
|
func TestFillerIDCollisionIsRefused(t *testing.T) {
|
||||||
|
f := Fixture{
|
||||||
|
SchemaVersion: SchemaVersion,
|
||||||
|
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "f1", Text: "..."}}}},
|
||||||
|
Filler: []StoredNote{{ID: "f1", Text: "..."}},
|
||||||
|
}
|
||||||
|
if err := checkIDs(f); err == nil {
|
||||||
|
t.Fatal("a case note reusing a filler id must be refused")
|
||||||
|
}
|
||||||
|
f.Filler = append(f.Filler, StoredNote{ID: "f1", Text: "..."})
|
||||||
|
if err := checkIDs(Fixture{SchemaVersion: SchemaVersion, Filler: f.Filler}); err == nil {
|
||||||
|
t.Fatal("a duplicate filler id must be refused")
|
||||||
|
}
|
||||||
|
ok := Fixture{
|
||||||
|
SchemaVersion: SchemaVersion,
|
||||||
|
Cases: []Case{{ID: "ru-001", Notes: []StoredNote{{ID: "n1", Text: "..."}}}},
|
||||||
|
Filler: []StoredNote{{ID: "f1", Text: "..."}},
|
||||||
|
}
|
||||||
|
if err := checkIDs(ok); err != nil {
|
||||||
|
t.Fatalf("a clean fixture must pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ type Item struct {
|
|||||||
Key string
|
Key string
|
||||||
FactKey string
|
FactKey string
|
||||||
Label string // RU text surfaced when this item is still missing.
|
Label string // RU text surfaced when this item is still missing.
|
||||||
|
// Optional — a missing one is not worth a nudge on its own.
|
||||||
|
//
|
||||||
|
// Every item was implicitly required until 04-08-2026, because there was
|
||||||
|
// no field, so a skipped stretch read exactly like skipped medication and
|
||||||
|
// #280's first behaviour could not hold (Vikunja #473). A checklist where
|
||||||
|
// everything is mandatory is a checklist he learns to ignore.
|
||||||
|
//
|
||||||
|
// It changes two things and nothing else: an all-optional routine never
|
||||||
|
// nudges, and a nudge that does fire names the optional stragglers after
|
||||||
|
// the required ones, in softer words. Evidence, the window and the day
|
||||||
|
// plan treat both kinds alike — a missing optional item is still missing.
|
||||||
|
Optional bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
|
// Routine — one daily checklist. WindowStart/WindowEnd are "HH:MM" local
|
||||||
@@ -60,12 +72,37 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Candidate — a routine that's due for its one-per-day nag: the window has
|
// Candidate — a routine that's due for its one-per-day nag: the window has
|
||||||
// reached NudgeAt and at least one item is still unevidenced.
|
// reached NudgeAt and at least one REQUIRED item is still unevidenced. Missing
|
||||||
|
// carries the optional stragglers too, so the one message she is allowed per
|
||||||
|
// day per routine can mention them; they never cause it.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
Routine Routine
|
Routine Routine
|
||||||
Missing []Item
|
Missing []Item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required reports the missing items that are not optional. The nudge fires on
|
||||||
|
// these; the rest ride along.
|
||||||
|
func Required(missing []Item) []Item {
|
||||||
|
var out []Item
|
||||||
|
for _, it := range missing {
|
||||||
|
if !it.Optional {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalOnly is the other half of Required.
|
||||||
|
func OptionalOnly(missing []Item) []Item {
|
||||||
|
var out []Item
|
||||||
|
for _, it := range missing {
|
||||||
|
if it.Optional {
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Validate reports the first structural problem with a routine set: missing
|
// Validate reports the first structural problem with a routine set: missing
|
||||||
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
// name/items, an unparseable HH:MM, an inverted window, a duplicate item key
|
||||||
// within a routine, or an out-of-range weekday. Called at config load so a
|
// within a routine, or an out-of-range weekday. Called at config load so a
|
||||||
@@ -191,7 +228,11 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
|
|||||||
missing = append(missing, it)
|
missing = append(missing, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(missing) == 0 {
|
// A day where only the optional items were skipped is a fine day, and
|
||||||
|
// nagging about it is what teaches him to stop listening (Vikunja
|
||||||
|
// #473). The optional ones still travel in Missing so the message can
|
||||||
|
// mention them when it is being sent anyway.
|
||||||
|
if len(Required(missing)) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
||||||
|
|||||||
@@ -182,3 +182,40 @@ func TestDueRespectsExplicitNudgeAt(t *testing.T) {
|
|||||||
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
|
t.Fatalf("expected candidate at explicit nudge_at, got %d", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOptionalItemsDoNotEarnANudge — behaviour 1 of #280, which could not hold
|
||||||
|
// while every item was implicitly required (Vikunja #473).
|
||||||
|
func TestOptionalItemsDoNotEarnANudge(t *testing.T) {
|
||||||
|
r := Routine{
|
||||||
|
Name: "утро",
|
||||||
|
WindowStart: "07:00",
|
||||||
|
WindowEnd: "10:00",
|
||||||
|
Items: []Item{
|
||||||
|
{Key: "meds", FactKey: "meds", Label: "таблетки"},
|
||||||
|
{Key: "stretch", FactKey: "stretch", Label: "растяжка", Optional: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
took := map[string]store.Fact{"meds": {Key: "meds", Ts: now.Add(-2 * time.Hour)}}
|
||||||
|
|
||||||
|
// Only the stretch was skipped: nothing to say.
|
||||||
|
if due := Due([]Routine{r}, took, map[string]time.Time{}, now); len(due) != 0 {
|
||||||
|
t.Fatalf("an optional item alone must not nudge, got %+v", due)
|
||||||
|
}
|
||||||
|
// The medication was skipped: she says so, and mentions the stretch too.
|
||||||
|
due := Due([]Routine{r}, map[string]store.Fact{}, map[string]time.Time{}, now)
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("a missing required item must nudge, got %+v", due)
|
||||||
|
}
|
||||||
|
if got := Required(due[0].Missing); len(got) != 1 || got[0].Key != "meds" {
|
||||||
|
t.Fatalf("Required = %+v, want the meds item alone", got)
|
||||||
|
}
|
||||||
|
if got := OptionalOnly(due[0].Missing); len(got) != 1 || got[0].Key != "stretch" {
|
||||||
|
t.Fatalf("OptionalOnly = %+v, want the stretch item alone", got)
|
||||||
|
}
|
||||||
|
// The window still reports it as missing — optional is not invisible.
|
||||||
|
st := Evaluate(r, map[string]store.Fact{}, now.Add(-time.Hour))
|
||||||
|
if len(st.Missing) != 2 {
|
||||||
|
t.Fatalf("Evaluate must still list both, got %+v", st.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
"github.com/kami/maven/internal/store"
|
"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
|
// it is over, and saying it was empty is a false statement about a day
|
||||||
// he just lived.
|
// he just lived.
|
||||||
if p.Rest {
|
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))
|
parts := make([]string, len(p.Items))
|
||||||
for i, it := range p.Items {
|
for i, it := range p.Items {
|
||||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||||
if it.Uncertain {
|
if it.Uncertain {
|
||||||
line = "похоже, " + line
|
line = say.S(say.PlanUncertain, map[string]string{"line": line})
|
||||||
}
|
}
|
||||||
parts[i] = 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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"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; " +
|
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||||
"10:30 — утро — осталось: витамины; " +
|
"10:30 — утро — осталось: витамины; " +
|
||||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||||
"18:30 — позвонить маме."
|
"18:30 — позвонить маме"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("got %q\nwant %q", 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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Package morph answers questions about Russian grammar from a dictionary.
|
||||||
|
//
|
||||||
|
// The second of the three mechanisms replacing hand-written Russian patterns
|
||||||
|
// (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the sets that
|
||||||
|
// can be finished; the embedder recognises an open set of phrasings; and this
|
||||||
|
// package answers the questions that are about grammar rather than meaning:
|
||||||
|
//
|
||||||
|
// - is this word a form of a verb, so it carries its own subject?
|
||||||
|
// - are these two tokens the same word in different cases?
|
||||||
|
//
|
||||||
|
// Three places used to answer those from a list of letter endings, and each list
|
||||||
|
// was wrong in a way its own comment admitted. "канал" read as a past-tense verb
|
||||||
|
// because it ends in -ал. A list of nineteen nouns ending in л existed only to
|
||||||
|
// suppress the false positives of "ends in л means masculine past tense", which
|
||||||
|
// is a pattern conceding it is wrong. Grammar is what a dictionary is for.
|
||||||
|
//
|
||||||
|
// Not the resident model. This has to be right every time, offline, in
|
||||||
|
// microseconds, and a 1.7B is neither reliable enough nor fast enough to ask.
|
||||||
|
//
|
||||||
|
// The dictionary is github.com/aaaton/golem's Russian data, vendored. It is
|
||||||
|
// embedded in the module, so a load failure is not a network problem and not a
|
||||||
|
// config problem — it is corrupt data that got past the build. Every function
|
||||||
|
// answers conservatively in that case rather than failing the turn, and says so
|
||||||
|
// in its own doc comment.
|
||||||
|
package morph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/aaaton/golem/v4"
|
||||||
|
"github.com/aaaton/golem/v4/dicts/ru"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
once sync.Once
|
||||||
|
lemma *golem.Lemmatizer
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// dict loads the lemmatizer on first use. Loading costs a few megabytes of maps,
|
||||||
|
// which is why it is not done at init: a daemon that never sees Russian never
|
||||||
|
// pays for it.
|
||||||
|
func dict() *golem.Lemmatizer {
|
||||||
|
once.Do(func() {
|
||||||
|
lemma, loadErr = golem.New(ru.New())
|
||||||
|
if loadErr != nil {
|
||||||
|
// Once, not per call: this is a permanent condition and a voice loop
|
||||||
|
// would otherwise fill the log with it at speech rate.
|
||||||
|
log.Printf("morph: russian dictionary unavailable, answering conservatively: %v", loadErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return lemma
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether the dictionary loaded. Callers do not need it to be
|
||||||
|
// correct — every function below has a defined answer without it — but a test
|
||||||
|
// that means to measure the dictionary should skip rather than pass vacuously.
|
||||||
|
func Available() bool {
|
||||||
|
dict()
|
||||||
|
return loadErr == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lemma returns the dictionary form of a word, or the word itself when the
|
||||||
|
// dictionary does not know it or could not load. An unknown word is its own
|
||||||
|
// lemma: "бэкап" is not in the dictionary and there is nothing better to say
|
||||||
|
// about it than what he said.
|
||||||
|
func Lemma(word string) string {
|
||||||
|
w := strings.ToLower(strings.TrimSpace(word))
|
||||||
|
if w == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
l := dict()
|
||||||
|
if l == nil {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
if got := l.Lemma(w); got != "" {
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// infinitiveEndings — how a Russian infinitive ends. This is not a stem pattern:
|
||||||
|
// it is applied to a LEMMA the dictionary returned, where the infinitive is the
|
||||||
|
// dictionary form of every verb by definition, so the test is about the
|
||||||
|
// dictionary's own output and not about the word he said.
|
||||||
|
//
|
||||||
|
// The reflexive forms are listed because a reflexive lemma keeps its particle:
|
||||||
|
// "тренировался" lemmatises to "тренироваться", which ends in "ся" rather than
|
||||||
|
// "ть".
|
||||||
|
var infinitiveEndings = []string{"ться", "тись", "чься", "ть", "ти", "чь"}
|
||||||
|
|
||||||
|
// IsVerbForm reports whether a word is some form of a verb — past tense, present,
|
||||||
|
// imperative, reflexive, participle. A verb carries its own subject and tense, so
|
||||||
|
// in Russian one verb is a whole sentence, which is what the callers care about.
|
||||||
|
//
|
||||||
|
// Without the dictionary this answers false: not knowing is not evidence that a
|
||||||
|
// word IS a verb, and the callers all treat false as the cautious direction.
|
||||||
|
func IsVerbForm(word string) bool {
|
||||||
|
if dict() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l := Lemma(word)
|
||||||
|
for _, e := range infinitiveEndings {
|
||||||
|
if strings.HasSuffix(l, e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameWord reports whether two tokens are the same word in different cases —
|
||||||
|
// "режим" and "режиме", "тихий" and "тихо". It is the test a stem-plus-endings
|
||||||
|
// comparison was approximating, and it draws the line the ending list could not:
|
||||||
|
// "тихонько" and "потихоньку" are different words, and the dictionary says so
|
||||||
|
// because it has never heard of either.
|
||||||
|
//
|
||||||
|
// Without the dictionary this falls back to exact equality, which is the
|
||||||
|
// narrowest honest answer.
|
||||||
|
func SameWord(a, b string) bool {
|
||||||
|
la, lb := Lemma(a), Lemma(b)
|
||||||
|
if la == "" || lb == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return la == lb
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package morph
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestVerbFormsAreVerbs — the question internal/router/singletoken.go asks. Every
|
||||||
|
// one of these is a whole sentence in Russian, because the verb carries its own
|
||||||
|
// subject, tense and gender.
|
||||||
|
func TestVerbFormsAreVerbs(t *testing.T) {
|
||||||
|
if !Available() {
|
||||||
|
t.Skip("russian dictionary unavailable")
|
||||||
|
}
|
||||||
|
for _, w := range []string{
|
||||||
|
"поужинал", "сходил", "выпил", "напомнил", "поняла", "сделала",
|
||||||
|
"пришёл", "начал", "работаешь", "занимаюсь",
|
||||||
|
// Reflexive: the lemma keeps its particle, so "тренироваться" ends in
|
||||||
|
// "ся" and not "ть". That is why the ending list carries both.
|
||||||
|
"тренировался", "проснулся",
|
||||||
|
} {
|
||||||
|
if !IsVerbForm(w) {
|
||||||
|
t.Errorf("IsVerbForm(%q) = false, want true (lemma %q)", w, Lemma(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNounsEndingInLAreNotVerbs — the list this package deleted. Nineteen nouns
|
||||||
|
// lived in internal/phraser/eval/checks.go as exceptions to "ends in л means
|
||||||
|
// masculine past tense", plus the ones internal/router/singletoken.go named as
|
||||||
|
// its own known errors. A list of exceptions to a pattern is the pattern
|
||||||
|
// conceding it is wrong, so all of them are here and none may be a verb.
|
||||||
|
func TestNounsEndingInLAreNotVerbs(t *testing.T) {
|
||||||
|
if !Available() {
|
||||||
|
t.Skip("russian dictionary unavailable")
|
||||||
|
}
|
||||||
|
for _, w := range []string{
|
||||||
|
"стол", "стул", "пол", "зал", "гол", "узел", "отдел", "файл", "канал",
|
||||||
|
"угол", "футбол", "вокзал", "металл", "интервал", "уровень", "мускул",
|
||||||
|
"апрель", "июль", "рубль",
|
||||||
|
// singletoken.go named these: "канал" read as past tense, and short
|
||||||
|
// nouns needed a length exemption to survive a two-letter suffix test.
|
||||||
|
"нос", "лес", "газ", "вода", "бэкап",
|
||||||
|
} {
|
||||||
|
if IsVerbForm(w) {
|
||||||
|
t.Errorf("IsVerbForm(%q) = true, want false (lemma %q)", w, Lemma(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSameWordDrawsTheLineTheEndingListCouldNot — the question
|
||||||
|
// cmd/mavend/quiet_toggle.go asks. Its comment describes exactly this: "тихий",
|
||||||
|
// "тихом" and "тихо" are one word inflected, while "тихонько" and "потихоньку"
|
||||||
|
// are different words. The dictionary says so; a list of 36 endings approximated
|
||||||
|
// it.
|
||||||
|
func TestSameWordDrawsTheLineTheEndingListCouldNot(t *testing.T) {
|
||||||
|
if !Available() {
|
||||||
|
t.Skip("russian dictionary unavailable")
|
||||||
|
}
|
||||||
|
for _, w := range []string{"тихий", "тихом", "тихо", "тише"} {
|
||||||
|
if !SameWord(w, "тихий") {
|
||||||
|
t.Errorf("SameWord(%q, тихий) = false, want true (lemma %q)", w, Lemma(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range []string{"тихонько", "потихоньку"} {
|
||||||
|
if SameWord(w, "тихий") {
|
||||||
|
t.Errorf("SameWord(%q, тихий) = true, want false", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, w := range []string{"режим", "режима", "режиме", "режимы"} {
|
||||||
|
if !SameWord(w, "режим") {
|
||||||
|
t.Errorf("SameWord(%q, режим) = false, want true (lemma %q)", w, Lemma(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if SameWord("режим", "тихий") {
|
||||||
|
t.Error("SameWord matched two unrelated words")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownWordIsItsOwnLemma — "бэкап" is not in the dictionary, and there is
|
||||||
|
// nothing better to say about it than what he said. Two spellings of an unknown
|
||||||
|
// word still compare equal, which is what the exact-equality fallback rests on.
|
||||||
|
func TestUnknownWordIsItsOwnLemma(t *testing.T) {
|
||||||
|
if got := Lemma("бэкап"); got != "бэкап" {
|
||||||
|
t.Errorf("Lemma(бэкап) = %q, want бэкап", got)
|
||||||
|
}
|
||||||
|
if got := Lemma(" БЭКАП "); got != "бэкап" {
|
||||||
|
t.Errorf("Lemma trims and lowercases: got %q", got)
|
||||||
|
}
|
||||||
|
if !SameWord("бэкап", "БЭКАП") {
|
||||||
|
t.Error("SameWord must still compare an unknown word with itself")
|
||||||
|
}
|
||||||
|
if got := Lemma(""); got != "" {
|
||||||
|
t.Errorf("Lemma(empty) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if SameWord("", "") {
|
||||||
|
t.Error("two empty tokens are not a word")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "russian capture acknowledgements v1",
|
||||||
|
"notes": [
|
||||||
|
"What she says after storing something he said, and what she says when storing it failed. 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.",
|
||||||
|
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
|
||||||
|
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
|
||||||
|
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
|
||||||
|
],
|
||||||
|
"entries": {
|
||||||
|
"ack_fact": {
|
||||||
|
"variants": ["записала факт.", "записала.", "запомнила."]
|
||||||
|
},
|
||||||
|
"ack_fact_key": {
|
||||||
|
"variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
|
||||||
|
},
|
||||||
|
"ack_fact_kv": {
|
||||||
|
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
|
||||||
|
},
|
||||||
|
"ack_note": {
|
||||||
|
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
||||||
|
},
|
||||||
|
"ack_reminder": {
|
||||||
|
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
||||||
|
},
|
||||||
|
"ack_act": {
|
||||||
|
"variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
|
||||||
|
},
|
||||||
|
"ack_task": {
|
||||||
|
"variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
|
||||||
|
},
|
||||||
|
"ack_task_urgent": {
|
||||||
|
"variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
|
||||||
|
},
|
||||||
|
"ack_task_duplicate": {
|
||||||
|
"variants": ["это уже в списке.", "такое уже есть в задачах."]
|
||||||
|
},
|
||||||
|
"ack_nudge": {
|
||||||
|
"variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
|
||||||
|
},
|
||||||
|
"ack_snooze": {
|
||||||
|
"variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
|
||||||
|
},
|
||||||
|
"ack_generic": {
|
||||||
|
"variants": ["приняла.", "поняла."]
|
||||||
|
},
|
||||||
|
"quiet_on": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["тихий режим включён. буду реже напоминать."]
|
||||||
|
},
|
||||||
|
"quiet_off": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["тихий режим выключен."]
|
||||||
|
},
|
||||||
|
"fail_fact": {
|
||||||
|
"variants": ["не получилось сохранить факт.", "факт не сохранился."]
|
||||||
|
},
|
||||||
|
"fail_note": {
|
||||||
|
"variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
|
||||||
|
},
|
||||||
|
"fail_reminder": {
|
||||||
|
"variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
|
||||||
|
},
|
||||||
|
"fail_reminder_time": {
|
||||||
|
"variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
|
||||||
|
},
|
||||||
|
"fail_task": {
|
||||||
|
"variants": ["не получилось записать задачу.", "задача не записалась."]
|
||||||
|
},
|
||||||
|
"fail_ack": {
|
||||||
|
"variants": ["не получилось отметить.", "не смогла отметить."]
|
||||||
|
},
|
||||||
|
"fail_snooze": {
|
||||||
|
"variants": ["не получилось отложить.", "не смогла отложить."]
|
||||||
|
},
|
||||||
|
"fail_quiet": {
|
||||||
|
"variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
|
||||||
|
},
|
||||||
|
"fail_fact_unparsed": {
|
||||||
|
"variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
// The capture acknowledgements — what she says after storing something he said,
|
||||||
|
// and what she says when storing it failed.
|
||||||
|
//
|
||||||
|
// They were string literals in eight files under cmd/mavend plus the stub
|
||||||
|
// replier in internal/voice. He hears them many times a day, which is exactly
|
||||||
|
// why they need variants and exactly why rewording one must not be a rebuild.
|
||||||
|
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
|
||||||
|
//
|
||||||
|
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
|
||||||
|
// and the value are interpolated here, so nothing he said lives in the copy.
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed ack_ru_v1.json
|
||||||
|
var ackJSON []byte
|
||||||
|
|
||||||
|
// AckSchemaVersion — this family's own version. A file that changes on a
|
||||||
|
// different day than the fallbacks cannot share their number (Vikunja #397).
|
||||||
|
const AckSchemaVersion = 1
|
||||||
|
|
||||||
|
// The entry keys. Namespaced by family, because the floor map behind a nil deck
|
||||||
|
// is process-wide.
|
||||||
|
const (
|
||||||
|
AckFact = "ack_fact"
|
||||||
|
AckFactKey = "ack_fact_key"
|
||||||
|
AckFactValue = "ack_fact_kv"
|
||||||
|
AckNote = "ack_note"
|
||||||
|
AckReminder = "ack_reminder"
|
||||||
|
AckAct = "ack_act"
|
||||||
|
AckTask = "ack_task"
|
||||||
|
AckTaskUrgent = "ack_task_urgent"
|
||||||
|
AckTaskDuplicate = "ack_task_duplicate"
|
||||||
|
AckNudge = "ack_nudge"
|
||||||
|
AckSnooze = "ack_snooze"
|
||||||
|
AckGeneric = "ack_generic"
|
||||||
|
AckQuietOn = "quiet_on"
|
||||||
|
AckQuietOff = "quiet_off"
|
||||||
|
|
||||||
|
FailFact = "fail_fact"
|
||||||
|
FailFactUnparsed = "fail_fact_unparsed"
|
||||||
|
FailNote = "fail_note"
|
||||||
|
FailReminder = "fail_reminder"
|
||||||
|
FailReminderTime = "fail_reminder_time"
|
||||||
|
FailTask = "fail_task"
|
||||||
|
FailAck = "fail_ack"
|
||||||
|
FailSnooze = "fail_snooze"
|
||||||
|
FailQuiet = "fail_quiet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ackKeys — every key the code requires the file to define.
|
||||||
|
var ackKeys = []string{
|
||||||
|
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
|
||||||
|
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
|
||||||
|
AckQuietOn, AckQuietOff,
|
||||||
|
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
|
||||||
|
FailTask, FailAck, FailSnooze, FailQuiet,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ackFloor — the literal each key falls back to when the file is unusable.
|
||||||
|
// These are the exact strings that lived in Go before this file existed.
|
||||||
|
var ackFloor = map[string]string{
|
||||||
|
AckFact: "записала факт.",
|
||||||
|
AckFactKey: "отметила: {key}",
|
||||||
|
AckFactValue: "отметила: {key} = {value}",
|
||||||
|
AckNote: "сохранила заметку.",
|
||||||
|
AckReminder: "напомню.",
|
||||||
|
AckAct: "ок, записала действие: {fn}",
|
||||||
|
AckTask: "записала: {text}",
|
||||||
|
AckTaskUrgent: "поняла, беру в работу: {text}",
|
||||||
|
AckTaskDuplicate: "это уже в списке.",
|
||||||
|
AckNudge: "отлично, отметила.",
|
||||||
|
AckSnooze: "хорошо, вернусь к этому позже.",
|
||||||
|
AckGeneric: "приняла.",
|
||||||
|
AckQuietOn: "тихий режим включён. буду реже напоминать.",
|
||||||
|
AckQuietOff: "тихий режим выключен.",
|
||||||
|
|
||||||
|
FailFact: "не получилось сохранить факт.",
|
||||||
|
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
|
||||||
|
FailNote: "не получилось сохранить заметку.",
|
||||||
|
FailReminder: "не получилось поставить напоминание.",
|
||||||
|
FailReminderTime: "не получилось разобрать время напоминания.",
|
||||||
|
FailTask: "не получилось записать задачу.",
|
||||||
|
FailAck: "не получилось отметить.",
|
||||||
|
FailSnooze: "не получилось отложить.",
|
||||||
|
FailQuiet: "не получилось переключить тихий режим.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
||||||
|
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 := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The three entries that exist to read his own words back. A variant
|
||||||
|
// without the placeholder would confirm the capture and drop what was
|
||||||
|
// captured, which reads as a successful save of nothing.
|
||||||
|
for _, req := range []struct{ key, ph string }{
|
||||||
|
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
|
||||||
|
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
|
||||||
|
} {
|
||||||
|
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Acks{d: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deck reads through a nil *Acks, which is the unloadable-file case.
|
||||||
|
func (a *Acks) deck() *say.Deck {
|
||||||
|
if a == nil {
|
||||||
|
return say.FloorDeck(ackFloor)
|
||||||
|
}
|
||||||
|
return a.d
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variants returns every line the file can produce, for the persona scorer.
|
||||||
|
func (a *Acks) Variants() []string { return a.deck().Variants() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
ackOnce sync.Once
|
||||||
|
acks *Acks
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAcks returns the shared instance, loading it on first use. A broken
|
||||||
|
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
|
||||||
|
func DefaultAcks() *Acks {
|
||||||
|
ackOnce.Do(func() {
|
||||||
|
a, err := LoadAcks(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acks = a
|
||||||
|
})
|
||||||
|
return acks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ack — one acknowledgement line, the way every caller says it.
|
||||||
|
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -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,197 @@
|
|||||||
|
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"
|
||||||
|
// ActNeedsAuthedSurface — the irreversible tier, local row or Hexis
|
||||||
|
// capability alike. Not a failure and not a refusal to help: a spoken "да"
|
||||||
|
// is the only authority the voice path can offer, and this is the one act
|
||||||
|
// it is not enough for (Vikunja #449, #523).
|
||||||
|
ActNeedsAuthedSurface = "act_needs_authed_surface"
|
||||||
|
|
||||||
|
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,
|
||||||
|
ActNeedsAuthedSurface,
|
||||||
|
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: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
||||||
|
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
|
||||||
|
|
||||||
|
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,145 @@
|
|||||||
|
{
|
||||||
|
"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": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||||
|
},
|
||||||
|
"act_needs_authed_surface": {
|
||||||
|
"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,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The check names, in report order. Every check is a string or length test — no
|
// The check names, in report order. Every check is a string or length test — no
|
||||||
@@ -67,6 +69,19 @@ func RunChecks(c Case, body, mood string) []Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feminine, HisGender and Address expose three checks one at a time, so the
|
||||||
|
// daemon can run them on a phrased message before he hears it (Vikunja #399).
|
||||||
|
// Only these three: they are unambiguous string tests with nothing to compare
|
||||||
|
// against, while length is path-specific and ontopic needs the fixture's
|
||||||
|
// expected fragments, which do not exist at runtime.
|
||||||
|
func Feminine(body string) Result { return checkFeminine(body) }
|
||||||
|
|
||||||
|
// HisGender — see checkHisGender.
|
||||||
|
func HisGender(body string) Result { return checkHisGender(body) }
|
||||||
|
|
||||||
|
// Address — see checkAddress.
|
||||||
|
func Address(body string) Result { return checkAddress(body) }
|
||||||
|
|
||||||
func checkMood(mood string) Result {
|
func checkMood(mood string) Result {
|
||||||
if Moods[mood] {
|
if Moods[mood] {
|
||||||
return Result{CheckMood, true, ""}
|
return Result{CheckMood, true, ""}
|
||||||
@@ -127,24 +142,25 @@ var masculinePredicative = map[string]bool{
|
|||||||
"обязан": true, "сам": true, "занят": true, "прав": true,
|
"обязан": true, "сам": true, "занят": true, "прав": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// nounsEndingInL — the false positives of "ends in л ⇒ masculine past tense".
|
// masculinePast reports whether a word is a masculine past-tense verb. Russian
|
||||||
// Small on purpose: it only has to cover nouns a nudge might actually use.
|
// past tense is gendered by suffix: -л for him, -ла for her. A small model with
|
||||||
var nounsEndingInL = map[string]bool{
|
// weak Russian defaults to the masculine form, which is the exact drift this
|
||||||
"стол": true, "стул": true, "пол": true, "зал": true, "гол": true,
|
// check measures.
|
||||||
"узел": true, "отдел": true, "файл": true, "канал": true, "угол": true,
|
//
|
||||||
"футбол": true, "вокзал": true, "металл": true, "интервал": true,
|
// The ending is only half the test, and the other half used to be a hand list of
|
||||||
"уровень": true, "мускул": true, "апрель": true, "июль": true, "рубль": true,
|
// nineteen nouns that end in л — стол, файл, апрель — kept "small on purpose",
|
||||||
}
|
// which means incomplete on purpose. A list of exceptions to a pattern is the
|
||||||
|
// pattern conceding it is wrong, so the second half is now a dictionary lookup:
|
||||||
// masculinePast reports whether a word looks like a masculine past-tense verb.
|
// ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is
|
||||||
// Russian past tense is gendered by suffix: -л (m), -ла (f). A 0.8B with weak
|
// correctly not a verb, and so are the ones it had not got round to.
|
||||||
// Russian defaults to the masculine form, which is the exact drift being
|
|
||||||
// measured.
|
|
||||||
func masculinePast(w string) bool {
|
func masculinePast(w string) bool {
|
||||||
if len([]rune(w)) < 3 || nounsEndingInL[w] {
|
if len([]rune(w)) < 3 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "лся")
|
if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return morph.IsVerbForm(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkFeminine(body string) Result {
|
func checkFeminine(body string) Result {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package eval
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFallbackPersona scores every line in every hand-written family on the
|
||||||
|
// persona checks the nudges already pass. These lines are heard out loud and
|
||||||
|
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
|
||||||
|
// would otherwise reach him with nothing in between.
|
||||||
|
//
|
||||||
|
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
||||||
|
// not nudges.
|
||||||
|
func TestFallbackPersona(t *testing.T) {
|
||||||
|
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFallbacks: %v", err)
|
||||||
|
}
|
||||||
|
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
|
||||||
|
// as addressing him as a woman, which is right for a nudge and wrong here:
|
||||||
|
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
|
||||||
|
// sentence. CheckFeminine still holds her side of the rule.
|
||||||
|
persona := map[string]bool{
|
||||||
|
CheckLang: true, CheckFeminine: true,
|
||||||
|
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
||||||
|
}
|
||||||
|
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAcks: %v", err)
|
||||||
|
}
|
||||||
|
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
for _, v := range variants {
|
||||||
|
// 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}", "{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") {
|
||||||
|
if persona[r.Name] && !r.Pass {
|
||||||
|
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,12 @@ type TalkCase struct {
|
|||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TalkSchemaVersion — the version this loader understands. Separate from the
|
||||||
|
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
|
||||||
|
// change on different days, and one shared constant would force a bump on the
|
||||||
|
// fixture that did not move.
|
||||||
|
const TalkSchemaVersion = 1
|
||||||
|
|
||||||
// TalkFixture — the versioned envelope, same gating as Fixture.
|
// TalkFixture — the versioned envelope, same gating as Fixture.
|
||||||
type TalkFixture struct {
|
type TalkFixture struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
|
|||||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||||
}
|
}
|
||||||
if f.SchemaVersion != SchemaVersion {
|
if f.SchemaVersion != TalkSchemaVersion {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
||||||
}
|
}
|
||||||
if len(f.Cases) == 0 {
|
if len(f.Cases) == 0 {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||||
|
|||||||
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||||
defer p.Close()
|
defer p.Close()
|
||||||
|
|
||||||
// Unreachable server is fatal here, not a logged warning, and that differs
|
// The model id names the run in the report. Since Vikunja #397 every path
|
||||||
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
// returns its errors, so a server that dies mid-run shows up in the Errors
|
||||||
// server there shows up honestly in the Errors column. PhraseChat and
|
// column instead of scoring as bad phrasing — the before-and-after probe that
|
||||||
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
// used to stand in for that is gone.
|
||||||
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
|
|
||||||
// a dead server produces a full report with 0 errors and a terrible score —
|
|
||||||
// a number that looks like bad phrasing and is really no phrasing at all.
|
|
||||||
// Refusing to score without a confirmed model is the only guard available
|
|
||||||
// until the phraser reports its failures (Vikunja #397).
|
|
||||||
model, err := llm.ModelID(ctx, base)
|
model, err := llm.ModelID(ctx, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
t.Fatalf("no model at %s: %v", base, err)
|
||||||
"and would report a plausible-looking result off a dead server", base, err)
|
|
||||||
}
|
}
|
||||||
t.Logf("scoring model %s at %s", model, base)
|
t.Logf("scoring model %s at %s", model, base)
|
||||||
|
|
||||||
@@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||||
|
|
||||||
// And again afterwards: the run takes minutes, and a server that died or got
|
// A run where nothing was phrased is not a low score, it is no measurement.
|
||||||
// OOM-killed halfway through would leave the first cases scored and the rest
|
if rep.Errors == rep.Total {
|
||||||
// silently canned. Checking only at the start would not catch that.
|
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
|
||||||
if _, err := llm.ModelID(ctx, base); err != nil {
|
}
|
||||||
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
|
if rep.Errors > 0 {
|
||||||
|
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isFallback — the text she says is picked from that entry's variants, so a test
|
||||||
|
// pins the entry rather than the wording. Pinning one line would make editing
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
||||||
|
// PhraseQuery keep the turn alive with canned text — and every one of those
|
||||||
|
// lines is also a legitimate reply, so the text alone cannot say which happened.
|
||||||
|
// The error is the only signal, and before Vikunja #397 it was dropped: the talk
|
||||||
|
// scorer reported a full run with zero errors off a server that answered nothing.
|
||||||
|
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
call func() (string, error)
|
||||||
|
key string
|
||||||
|
sources string
|
||||||
|
}{
|
||||||
|
{"chat", func() (string, error) {
|
||||||
|
return p.PhraseChat(context.Background(), "как дела", nil)
|
||||||
|
}, fbChat, ""},
|
||||||
|
{"knowledge", func() (string, error) {
|
||||||
|
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||||
|
}, fbQueryUnknown, ""},
|
||||||
|
{"evidence", func() (string, error) {
|
||||||
|
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||||
|
}, fbQuerySources, "два литра"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := c.call()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
|
||||||
|
}
|
||||||
|
if !isFallback(t, c.key, c.sources, got) {
|
||||||
|
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty answer is a failure too: the server is up and produced no tokens,
|
||||||
|
// which is not an answer and must not score as one.
|
||||||
|
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||||
|
|
||||||
|
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an empty response scored as an answer")
|
||||||
|
}
|
||||||
|
if !isFallback(t, fbQueryUnknown, "", got) {
|
||||||
|
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "empty") {
|
||||||
|
t.Errorf("error = %v; want it to name the empty response", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
// The phrasing fallbacks — what she says when the model gave her nothing usable.
|
||||||
|
//
|
||||||
|
// They were four string literals spread across phraser.go, llmphraser.go and
|
||||||
|
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
|
||||||
|
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
|
||||||
|
//
|
||||||
|
// The floor under the floor is deliberate. These strings exist because something
|
||||||
|
// already failed, so a broken template file must not be able to take the last
|
||||||
|
// words she has: every accessor falls back to the literal it replaced.
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed fallbacks_ru_v1.json
|
||||||
|
var fallbackJSON []byte
|
||||||
|
|
||||||
|
// FallbackSchemaVersion — the version this code understands. Its own constant,
|
||||||
|
// not shared with the nudge templates or the eval fixtures: two files that change
|
||||||
|
// on different days cannot be versioned by one number (Vikunja #397).
|
||||||
|
const FallbackSchemaVersion = 1
|
||||||
|
|
||||||
|
// The entry keys. Every one of them is read by a method below, so a typo in the
|
||||||
|
// file is caught at load rather than at the moment she needs the words.
|
||||||
|
const (
|
||||||
|
fbChat = "chat"
|
||||||
|
fbQueryUnknown = "query_unknown"
|
||||||
|
fbQuerySources = "query_sources"
|
||||||
|
fbWorldGap = "world_gap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fbKeys — every key the code requires the file to define.
|
||||||
|
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
||||||
|
|
||||||
|
// hardFloor — the literal each key falls back to when the file is unusable.
|
||||||
|
// These are the exact strings that lived in Go before this file existed.
|
||||||
|
var hardFloor = map[string]string{
|
||||||
|
fbChat: "даже не знаю, что сказать.",
|
||||||
|
fbQueryUnknown: "не знаю.",
|
||||||
|
fbQuerySources: "вот что я нашла: {sources}",
|
||||||
|
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||||
|
type Fallbacks struct{ d *say.Deck }
|
||||||
|
|
||||||
|
// LoadFallbacks reads the embedded file. Pass a source to make the picking
|
||||||
|
// reproducible in tests; nil seeds from the clock.
|
||||||
|
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||||
|
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Fallbacks{d: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||||
|
func (f *Fallbacks) deck() *say.Deck {
|
||||||
|
if f == nil {
|
||||||
|
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) }
|
||||||
|
|
||||||
|
// Unknown — a question she cannot answer and will not guess at.
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) }
|
||||||
|
|
||||||
|
// Variants returns every line the file can produce, for the persona scorer.
|
||||||
|
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
|
||||||
|
// one — and because a template file that is embedded and validated at load has
|
||||||
|
// nothing per-instance to configure.
|
||||||
|
var (
|
||||||
|
fallbackOnce sync.Once
|
||||||
|
fallbacks *Fallbacks
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultFallbacks returns the shared instance, loading it on first use. A
|
||||||
|
// broken file logs once and leaves a nil *Fallbacks, which still answers from
|
||||||
|
// hardFloor — a daemon must not fail to boot over its own copy deck.
|
||||||
|
func DefaultFallbacks() *Fallbacks {
|
||||||
|
fallbackOnce.Do(func() {
|
||||||
|
fb, err := LoadFallbacks(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fallbacks = fb
|
||||||
|
})
|
||||||
|
return fallbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatFallback — what she says when the chat path produced nothing.
|
||||||
|
func ChatFallback() string { return DefaultFallbacks().Chat() }
|
||||||
|
|
||||||
|
// UnknownFallback — what she says when she has no answer and will not invent one.
|
||||||
|
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
|
||||||
|
|
||||||
|
// SourcesFallback — read the sources back rather than ship a broken fragment.
|
||||||
|
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
|
||||||
|
|
||||||
|
// WorldGap — what he hears when the world model is configured and unreachable.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "russian phrasing fallbacks v1",
|
||||||
|
"notes": [
|
||||||
|
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
|
||||||
|
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
|
||||||
|
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
|
||||||
|
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
|
||||||
|
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
|
||||||
|
],
|
||||||
|
"entries": {
|
||||||
|
"chat": {
|
||||||
|
"variants": [
|
||||||
|
"даже не знаю, что сказать.",
|
||||||
|
"не могу найти слов.",
|
||||||
|
"мысль ускользнула, повтори?",
|
||||||
|
"у меня сейчас пусто в голове."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"query_unknown": {
|
||||||
|
"variants": [
|
||||||
|
"не знаю.",
|
||||||
|
"не знаю, честно.",
|
||||||
|
"тут я пас.",
|
||||||
|
"не скажу, не знаю."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"query_sources": {
|
||||||
|
"variants": [
|
||||||
|
"вот что я нашла: {sources}",
|
||||||
|
"нашла вот это: {sources}",
|
||||||
|
"есть только это: {sources}"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"world_gap": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": [
|
||||||
|
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The embedded file must load, or the daemon speaks from hardFloor and nobody
|
||||||
|
// finds out until he hears the wrong words.
|
||||||
|
func TestFallbacksLoad(t *testing.T) {
|
||||||
|
fb, err := LoadFallbacks(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFallbacks: %v", err)
|
||||||
|
}
|
||||||
|
if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
|
||||||
|
t.Errorf("FromSources = %q, want the sources in it", got)
|
||||||
|
}
|
||||||
|
if fb.WorldGap() != hardFloor[fbWorldGap] {
|
||||||
|
t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A broken or missing file must not take her last words away: every accessor
|
||||||
|
// answers from the literal it replaced.
|
||||||
|
func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
|
||||||
|
var fb *Fallbacks
|
||||||
|
if got := fb.Chat(); got != hardFloor[fbChat] {
|
||||||
|
t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
|
||||||
|
}
|
||||||
|
if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
|
||||||
|
t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
|
||||||
|
}
|
||||||
|
if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
|
||||||
|
t.Errorf("FromSources = %q", got)
|
||||||
|
}
|
||||||
|
if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
|
||||||
|
t.Errorf("WorldGap = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hearing the identical words every time a request fails is how a failure stops
|
||||||
|
// registering as one.
|
||||||
|
func TestFallbacksDoNotRepeat(t *testing.T) {
|
||||||
|
fb, err := LoadFallbacks(rand.NewSource(7))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFallbacks: %v", err)
|
||||||
|
}
|
||||||
|
prev := fb.Chat()
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
got := fb.Chat()
|
||||||
|
if got == prev {
|
||||||
|
t.Fatalf("chat repeated %q on turn %d", got, i)
|
||||||
|
}
|
||||||
|
prev = got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The acknowledgements load, fill his words into the frame, and answer from the
|
||||||
|
// floor when the file is gone.
|
||||||
|
func TestAcksLoad(t *testing.T) {
|
||||||
|
a, err := LoadAcks(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAcks: %v", err)
|
||||||
|
}
|
||||||
|
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
|
||||||
|
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
|
||||||
|
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
|
||||||
|
}
|
||||||
|
var nilAcks *Acks
|
||||||
|
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
|
||||||
|
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -26,6 +27,11 @@ import (
|
|||||||
|
|
||||||
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||||
|
|
||||||
|
// errEmptyResponse — the server answered and said nothing. Separate from a
|
||||||
|
// transport failure: the model is up and produced no tokens, which is still not
|
||||||
|
// an answer and must not score as one.
|
||||||
|
var errEmptyResponse = errors.New("phraser: empty response from the model")
|
||||||
|
|
||||||
type LLMPhraser struct {
|
type LLMPhraser struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -428,8 +434,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
||||||
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
// compose a natural answer. On any LLM error it returns the fallback text —
|
||||||
// LLM error — better to give the raw data than silence.
|
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
|
||||||
|
// together. The daemon uses the text and keeps the turn alive; a caller that is
|
||||||
|
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
|
||||||
|
// dead server scored as bad phrasing.
|
||||||
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
||||||
// Blank sources are no sources. A caller that hands over one empty string —
|
// Blank sources are no sources. A caller that hands over one empty string —
|
||||||
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
||||||
@@ -439,13 +448,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
if len(notes) == 0 {
|
if len(notes) == 0 {
|
||||||
sys, prompt := p.knowledgePrompt(utterance)
|
sys, prompt := p.knowledgePrompt(utterance)
|
||||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||||
if err != nil || resp == "" {
|
if err != nil {
|
||||||
return "не знаю.", nil
|
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
|
||||||
|
}
|
||||||
|
if resp == "" {
|
||||||
|
return UnknownFallback(), errEmptyResponse
|
||||||
}
|
}
|
||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
|
||||||
return "не знаю.", nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
@@ -457,13 +468,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if err != nil || perr != nil {
|
if err != nil || perr != nil {
|
||||||
// Read the notes out rather than ship a broken fragment.
|
// Read the notes out rather than ship a broken fragment.
|
||||||
if perr != nil {
|
cause := err
|
||||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
if cause == nil {
|
||||||
|
cause = perr
|
||||||
}
|
}
|
||||||
if len(notes) == 1 {
|
return SourcesFallback(strings.Join(notes, "; ")),
|
||||||
return "вот что я нашла: " + notes[0], nil
|
fmt.Errorf("phrase query (evidence): %w", cause)
|
||||||
}
|
|
||||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
@@ -472,8 +482,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
||||||
// message array from dialogue history + the current user utterance. Falls back
|
// message array from dialogue history + the current user utterance. On any LLM
|
||||||
// to a simple greeting on any LLM error — better to say something than nothing.
|
// error it returns both ChatFallback and the error, on the same rule as
|
||||||
|
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
|
||||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||||
msgs := []chatMsg{
|
msgs := []chatMsg{
|
||||||
@@ -490,13 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
|||||||
|
|
||||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("phraser: PhraseChat: %v", err)
|
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
|
||||||
return "поговорили.", nil
|
|
||||||
}
|
}
|
||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
log.Printf("phraser: PhraseChat: %v", perr)
|
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
|
||||||
return "поговорили.", nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -210,13 +212,11 @@ func capitalizeFirst(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
|
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
|
||||||
// Russian voice, so the number goes out as words.
|
// Russian voice, so the number goes out as words. Closed set, indexed by the
|
||||||
var hourWords = []string{
|
// hour, kept in internal/lexicon (Vikunja #525).
|
||||||
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
func hourWord(h int) string { return lexicon.HourSpoken(h) }
|
||||||
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
|
||||||
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
const hoursSpoken = 24
|
||||||
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три",
|
|
||||||
}
|
|
||||||
|
|
||||||
// hourPlural — час / часа / часов by Russian counting rules.
|
// hourPlural — час / часа / часов by Russian counting rules.
|
||||||
func hourPlural(h int) string {
|
func hourPlural(h int) string {
|
||||||
@@ -245,7 +245,7 @@ func ruSinceWords(d time.Duration) string {
|
|||||||
h++
|
h++
|
||||||
m = 0
|
m = 0
|
||||||
}
|
}
|
||||||
if h >= len(hourWords) {
|
if h >= hoursSpoken {
|
||||||
return "больше суток"
|
return "больше суток"
|
||||||
}
|
}
|
||||||
if h == 1 {
|
if h == 1 {
|
||||||
@@ -255,7 +255,7 @@ func ruSinceWords(d time.Duration) string {
|
|||||||
return "час"
|
return "час"
|
||||||
}
|
}
|
||||||
if m >= 15 {
|
if m >= 15 {
|
||||||
return hourWords[h] + " с половиной часа"
|
return hourWord(h) + " с половиной часа"
|
||||||
}
|
}
|
||||||
return hourWords[h] + " " + hourPlural(h)
|
return hourWord(h) + " " + hourPlural(h)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} }
|
|||||||
// prompted response from the model. The history parameter is accepted but
|
// prompted response from the model. The history parameter is accepted but
|
||||||
// ignored at the stub level (the production impl uses it for multi-turn).
|
// ignored at the stub level (the production impl uses it for multi-turn).
|
||||||
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
|
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
|
||||||
return "поговорили.", nil
|
return ChatFallback(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhraseQuery returns a deterministic summary of the best matching notes.
|
// PhraseQuery returns a deterministic summary of the best matching notes.
|
||||||
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
|
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
|
||||||
if len(notes) == 0 {
|
if len(notes) == 0 {
|
||||||
return "не знаю.", nil
|
return UnknownFallback(), nil
|
||||||
}
|
}
|
||||||
if len(notes) == 1 {
|
return SourcesFallback(strings.Join(notes, "; ")), nil
|
||||||
return "вот что я нашла: " + notes[0], nil
|
|
||||||
}
|
|
||||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close implements Phraser.Close (no-op for the stub).
|
// Close implements Phraser.Close (no-op for the stub).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
// The counted noun, for callers that already speak through this package.
|
||||||
|
//
|
||||||
|
// The rule itself lives in internal/say, next to the line files that carry the
|
||||||
|
// {word} placeholder, because internal/say sits under phraser in the import
|
||||||
|
// graph and internal/memory and internal/tasks need it too. These are the same
|
||||||
|
// three functions under the names cmd/mavend already calls.
|
||||||
|
|
||||||
|
import "github.com/kami/maven/internal/say"
|
||||||
|
|
||||||
|
// CountWord picks between the three forms a Russian count needs.
|
||||||
|
func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
|
||||||
|
|
||||||
|
// Degrees — the noun for a temperature.
|
||||||
|
func Degrees(temp float64) string { return say.Degrees(temp) }
|
||||||
|
|
||||||
|
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||||
|
func Devices(n int) string { return say.Devices(n) }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The bug the helper exists for: the weather line said "градусов" for every
|
||||||
|
// reading, which is wrong for 1-4 and for every number ending in 1-4.
|
||||||
|
func TestDegreesAgreeWithTheReading(t *testing.T) {
|
||||||
|
for temp, want := range map[float64]string{
|
||||||
|
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
|
||||||
|
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
|
||||||
|
22: "градуса", 25: "градусов", 101: "градус",
|
||||||
|
// Minus does not change the noun, and a reading rounds to the number
|
||||||
|
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
|
||||||
|
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
|
||||||
|
} {
|
||||||
|
if got := Degrees(temp); got != want {
|
||||||
|
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesAgreeWithTheCount(t *testing.T) {
|
||||||
|
for n, want := range map[int]string{
|
||||||
|
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||||
|
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||||
|
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||||
|
} {
|
||||||
|
if got := Devices(n); got != want {
|
||||||
|
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
// The query answers and gaps — what a query source says when it answers from
|
||||||
|
// something other than the model, and what it says when it has nothing.
|
||||||
|
//
|
||||||
|
// Third family on the shared deck (deck.go), after the fallbacks and the
|
||||||
|
// acknowledgements. They were literals spread across actions_query.go and
|
||||||
|
// netscan.go, where the largest single site held two dozen of them.
|
||||||
|
//
|
||||||
|
// QueryUnknown is not the phraser's UnknownFallback, even though the two read
|
||||||
|
// the same today. Here she looked and found nothing; there she failed to phrase
|
||||||
|
// an answer she had. Two files, two entries, so rewording one leaves the other.
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed query_ru_v1.json
|
||||||
|
var queryJSON []byte
|
||||||
|
|
||||||
|
// QuerySchemaVersion — this family's own version.
|
||||||
|
const QuerySchemaVersion = 1
|
||||||
|
|
||||||
|
// The entry keys.
|
||||||
|
const (
|
||||||
|
QueryUnknown = "query_unknown"
|
||||||
|
QueryOtherDay = "other_day"
|
||||||
|
QueryPersonalNone = "personal_none"
|
||||||
|
QueryFactWhen = "fact_when"
|
||||||
|
QueryFactValue = "fact_value"
|
||||||
|
QueryFound = "found"
|
||||||
|
QueryPageText = "page_text"
|
||||||
|
QueryPageBlocked = "page_blocked"
|
||||||
|
QueryPageEmpty = "page_empty"
|
||||||
|
QueryFeedsOff = "feeds_off"
|
||||||
|
QueryFeedsNew = "feeds_new"
|
||||||
|
QueryFeedsEmpty = "feeds_empty"
|
||||||
|
QueryFeedsTopic = "feeds_empty_topic"
|
||||||
|
QueryWeatherNow = "weather_now"
|
||||||
|
QueryWeatherOff = "weather_off"
|
||||||
|
QueryWeatherWhere = "weather_nolocation"
|
||||||
|
QueryNetEmpty = "net_empty"
|
||||||
|
QueryNetOff = "net_off"
|
||||||
|
QueryPageOff = "page_off"
|
||||||
|
|
||||||
|
QueryFailPlan = "fail_plan"
|
||||||
|
QueryFailNotes = "fail_notes"
|
||||||
|
QueryFailFeeds = "fail_feeds"
|
||||||
|
QueryFailCalendar = "fail_calendar"
|
||||||
|
QueryFailWeather = "fail_weather"
|
||||||
|
QueryFailAnswer = "fail_answer"
|
||||||
|
QueryFailPage = "fail_page"
|
||||||
|
QueryFailNetscan = "fail_netscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
var queryKeys = []string{
|
||||||
|
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
||||||
|
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
||||||
|
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
||||||
|
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
|
||||||
|
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
||||||
|
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryFloor — 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 queryFloor = map[string]string{
|
||||||
|
QueryUnknown: "ничего не нашла.",
|
||||||
|
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
|
||||||
|
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
|
||||||
|
QueryFactWhen: "записала это {when}",
|
||||||
|
QueryFactValue: "у меня записано: {key} — {value}",
|
||||||
|
QueryFound: "вот что я нашла: {text}",
|
||||||
|
QueryPageText: "вот что на странице: {text}",
|
||||||
|
QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
|
||||||
|
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||||
|
QueryFeedsOff: "ленты не настроены.",
|
||||||
|
QueryFeedsNew: "вот что нового: {items}",
|
||||||
|
QueryFeedsEmpty: "в лентах пока ничего нового.",
|
||||||
|
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
|
||||||
|
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
|
||||||
|
QueryWeatherOff: "погода не настроена.",
|
||||||
|
QueryWeatherWhere: "для какого города?",
|
||||||
|
QueryNetEmpty: "в сети никого не нашла.",
|
||||||
|
QueryNetOff: "сканирование сети не настроено.",
|
||||||
|
QueryPageOff: "я не читаю страницы — это не настроено.",
|
||||||
|
|
||||||
|
QueryFailPlan: "не получилось собрать план.",
|
||||||
|
QueryFailNotes: "не получилось посмотреть записи.",
|
||||||
|
QueryFailFeeds: "не получилось посмотреть ленты.",
|
||||||
|
QueryFailCalendar: "не получилось проверить календарь.",
|
||||||
|
QueryFailWeather: "не получилось узнать погоду.",
|
||||||
|
QueryFailAnswer: "не получилось найти ответ.",
|
||||||
|
QueryFailPage: "не получилось прочитать страницу.",
|
||||||
|
QueryFailNetscan: "не получилось просканировать сеть.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||||
|
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 := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The entries that exist to read something back. A variant without the
|
||||||
|
// placeholder would answer the question by dropping the answer.
|
||||||
|
for _, req := range []struct{ key, ph string }{
|
||||||
|
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
|
||||||
|
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
|
||||||
|
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
|
||||||
|
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
|
||||||
|
} {
|
||||||
|
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Queries{d: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deck reads through a nil *Queries, which is the unloadable-file case.
|
||||||
|
func (q *Queries) deck() *say.Deck {
|
||||||
|
if q == nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variants returns every line the file can produce, for the persona scorer.
|
||||||
|
func (q *Queries) Variants() []string { return q.deck().Variants() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
queryOnce sync.Once
|
||||||
|
queries *Queries
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultQueries returns the shared instance, loading it on first use. A broken
|
||||||
|
// file logs once and leaves a nil *Queries, which still answers from queryFloor.
|
||||||
|
func DefaultQueries() *Queries {
|
||||||
|
queryOnce.Do(func() {
|
||||||
|
q, err := LoadQueries(nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queries = q
|
||||||
|
})
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Q — one query line, the way every caller says it.
|
||||||
|
func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQueriesLoad(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range queryKeys {
|
||||||
|
if got := q.Say(k, nil); got == "" {
|
||||||
|
t.Errorf("%s says nothing", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bug: net_empty carried {tail} in every variant, and a scan that finished
|
||||||
|
// the whole range has no caveat to put there. Whatever the file says, an answer
|
||||||
|
// he can hear has to come out — never braces, never nothing.
|
||||||
|
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
for _, vars := range []map[string]string{nil, {"tail": ""}} {
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
got := q.Say(QueryNetEmpty, vars)
|
||||||
|
if got == "" || strings.ContainsAny(got, "{}") {
|
||||||
|
t.Fatalf("net_empty with vars %v said %q", vars, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half: a caveat he was given is not dropped for a shorter wording.
|
||||||
|
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
const tail = ", но успела посмотреть не всю сеть"
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
|
||||||
|
t.Fatalf("net_empty dropped the tail: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// query_unknown means she looked and found nothing. The phraser's fallback
|
||||||
|
// means she failed to phrase an answer she had. Two causes, two sentences, or
|
||||||
|
// the distinction the two files exist for is unobservable from the outside.
|
||||||
|
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
f, err := LoadFallbacks(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFallbacks: %v", err)
|
||||||
|
}
|
||||||
|
failures := map[string]bool{}
|
||||||
|
for _, v := range f.Variants() {
|
||||||
|
failures[v] = true
|
||||||
|
}
|
||||||
|
for _, v := range q.d.VariantsOf(QueryUnknown) {
|
||||||
|
if failures[v] {
|
||||||
|
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The weather line splits the count into a number and a noun, so a variant that
|
||||||
|
// says the temperature without {word} is the hardcoded "градусов" coming back.
|
||||||
|
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
for _, v := range q.d.VariantsOf(QueryWeatherNow) {
|
||||||
|
if strings.Contains(v, "градус") {
|
||||||
|
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got := q.Say(QueryWeatherNow, map[string]string{
|
||||||
|
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
|
||||||
|
})
|
||||||
|
if !strings.Contains(got, "1 градус,") {
|
||||||
|
t.Errorf("weather_now said %q, want the singular noun", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No line spoken to him names a config key. She asks instead.
|
||||||
|
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
|
||||||
|
q, err := LoadQueries(rand.NewSource(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadQueries: %v", err)
|
||||||
|
}
|
||||||
|
for _, v := range q.Variants() {
|
||||||
|
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
|
||||||
|
t.Errorf("variant %q says a config path out loud", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "russian query answers and gaps v1",
|
||||||
|
"notes": [
|
||||||
|
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
|
||||||
|
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||||
|
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
|
||||||
|
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
|
||||||
|
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
|
||||||
|
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
|
||||||
|
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
|
||||||
|
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
|
||||||
|
],
|
||||||
|
"entries": {
|
||||||
|
"query_unknown": {
|
||||||
|
"variants": ["ничего не нашла.", "искала — не нашла."]
|
||||||
|
},
|
||||||
|
"other_day": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["про другой день так не отвечу — спроси целиком."]
|
||||||
|
},
|
||||||
|
"personal_none": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||||
|
},
|
||||||
|
"fact_when": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["записала это {when}"]
|
||||||
|
},
|
||||||
|
"fact_value": {
|
||||||
|
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
|
||||||
|
},
|
||||||
|
"found": {
|
||||||
|
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
|
||||||
|
},
|
||||||
|
"page_text": {
|
||||||
|
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
|
||||||
|
},
|
||||||
|
"page_blocked": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
|
||||||
|
},
|
||||||
|
"page_empty": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["страница открылась, но читать там нечего."]
|
||||||
|
},
|
||||||
|
"feeds_off": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["ленты не настроены."]
|
||||||
|
},
|
||||||
|
"feeds_new": {
|
||||||
|
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
|
||||||
|
},
|
||||||
|
"feeds_empty": {
|
||||||
|
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
|
||||||
|
},
|
||||||
|
"feeds_empty_topic": {
|
||||||
|
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
|
||||||
|
},
|
||||||
|
"weather_now": {
|
||||||
|
"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": ["не получилось просканировать сеть."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user