Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 761cf9f3e0 | |||
| 1c8a32c3fd | |||
| 96b474223d | |||
| 42d7a39c49 | |||
| bad3fa4035 | |||
| d819fc09f0 | |||
| c35979d9f9 | |||
| f3c0540b42 | |||
| 5b4192acb5 | |||
| 16d94894b7 | |||
| ae8d38fc31 | |||
| b2521988e1 | |||
| dae123adac | |||
| 1c9ddbbea2 | |||
| 3f2782f5b7 | |||
| 865623ef3e | |||
| 4fdce3ca2c | |||
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 |
@@ -189,18 +189,6 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||
explicit `(\s|[?!.]|$)`.
|
||||
|
||||
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||||
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||||
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||||
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||||
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||||
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||||
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||||
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||||
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||
llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
@@ -74,7 +74,7 @@ run-web: build-web
|
||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||
deps-go: deps-sentinel
|
||||
deps-go:
|
||||
@mkdir -p deps/go
|
||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||
@@ -84,25 +84,6 @@ deps-go: deps-sentinel
|
||||
done
|
||||
$(GO) version
|
||||
|
||||
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
|
||||
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
|
||||
# Go's own compiler-error fixtures and died on files that are malformed on
|
||||
# purpose ("unicode//utf8": double slash). A nested module is not part of the
|
||||
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
|
||||
# generated here rather than committed, and every target that populates deps/
|
||||
# writes it.
|
||||
deps-sentinel:
|
||||
@mkdir -p deps
|
||||
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||
|
||||
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||
# go.mod, and a build target that edits the module file is a surprise.
|
||||
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||
# a re-vendor or the next build fails on "inconsistent vendoring".
|
||||
tidy: deps-sentinel
|
||||
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
|
||||
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
|
||||
|
||||
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||
# Run `gofmt -w` on whatever this prints.
|
||||
@@ -197,7 +178,7 @@ run-tts: build-tts
|
||||
./mavttsd -socket /tmp/maven/tts.sock \
|
||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||
|
||||
deps: deps-sentinel deps-whisper deps-piper
|
||||
deps: deps-whisper deps-piper
|
||||
|
||||
deps-whisper:
|
||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
|
||||
+3
-2
@@ -16,6 +16,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"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 {
|
||||
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)
|
||||
return "отлично, отметила.", true
|
||||
return phraser.Ack(phraser.AckNudge, nil), true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -57,14 +58,15 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("voice: chat: %v", err)
|
||||
return "поговорили."
|
||||
}
|
||||
if reply == "" {
|
||||
return phraser.ChatFallback()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
@@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||
// The row is enabled and the backend is gone. Drafting a proposal
|
||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||
// question.
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
return phraser.A(phraser.ActServerDown, nil)
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
return phraser.A(phraser.ActWithdrawn, nil)
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "готово."
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// it for recall, and let pattern detection propose a routine.
|
||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
@@ -38,21 +39,6 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
q.Slots.Value = ""
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||
// recall reads a self row back later as if it were still true — the same
|
||||
// class of row that outranked live search in #470. The sentence describes a
|
||||
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||
// and anything about him are both left alone by the test.
|
||||
if router.IsTransientComplaint(dec.Utterance) {
|
||||
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||
dec.Utterance, dec.Slots.Key)
|
||||
c := dec
|
||||
c.Intent = router.IntentChat
|
||||
c.Slots.Key, c.Slots.HasKey = "", false
|
||||
c.Slots.Value = ""
|
||||
return h.actionChat(ctx, c)
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
@@ -77,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
factID, err := h.api.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
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
|
||||
// write). Facts aren't in the notes table, so this is the only recall path
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
if err != nil {
|
||||
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).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
|
||||
+54
-82
@@ -81,22 +81,11 @@ var querySources = []querySource{
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||
// question about the operational state Praxis holds, and it used to fall
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// 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
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||
// history phrase and bails out when he names a topic, so "что я говорил
|
||||
// про сервер" is still recall.
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
@@ -151,13 +140,6 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
continue
|
||||
}
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
// Which source claimed is the one thing about a query turn that was
|
||||
// invisible from outside: /trace is the nudge-rule trace and carries
|
||||
// no query-source field, so a wrong answer could not be told from a
|
||||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||
// the utterance and the answer are already on the voice lines above
|
||||
// and below this one.
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -165,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||
// 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
|
||||
@@ -185,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// 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.
|
||||
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
|
||||
// with the resolved key in context.
|
||||
@@ -215,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
@@ -260,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
@@ -296,18 +278,10 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
// answer "что нового?" with an invented news bulletin — true, and it
|
||||
// stopped being the only alternative on 2026-08-02, when live search
|
||||
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||||
// в новостях про искусственный интеллект?" has a real answer below,
|
||||
// and a configuration status is the wrong thing to say instead
|
||||
// (Vikunja #474).
|
||||
if h.search != nil || h.kiwix != nil {
|
||||
return "", false
|
||||
}
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||
}
|
||||
// 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 "в
|
||||
@@ -315,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
@@ -332,26 +306,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
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 — "что у меня сегодня?", "планы на завтра?"
|
||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||
// source can be tested at a fixed time like the rest.
|
||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// A day word is all this source matches on, so any question that merely
|
||||
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||||
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||||
// far more often than the calendar is, and the weather source sits right
|
||||
// below, so the calendar steps aside on weather wording — the same bail-out
|
||||
// queryHome already does for the same reason.
|
||||
if isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -359,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
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
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
@@ -402,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
// The recogniser already matched, so this is a question about HIS LAN
|
||||
// and there is no scanner to answer it. Falling through sent it to the
|
||||
// search leg, which answered with a paragraph about routers in general
|
||||
// and put his network question on an upstream engine (Vikunja #479).
|
||||
// A missing capability names itself.
|
||||
return phraser.Q(phraser.QueryNetOff, nil), true
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
@@ -417,24 +385,23 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
if loc == "" {
|
||||
// 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.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
return "погода не настроена.", 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
|
||||
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||
}
|
||||
if err != nil {
|
||||
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),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
}
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
@@ -444,7 +411,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
@@ -485,7 +452,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
|
||||
// stored.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -507,7 +480,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if err != nil {
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -532,7 +505,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -554,25 +527,24 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||
// where it was never turned on the older behaviour is right: the model
|
||||
// answers the question as if the URL had not been said. Announcing a
|
||||
// configuration status is for a capability that exists and failed, not
|
||||
// for one he never asked for.
|
||||
return "", false
|
||||
// He named a URL, so the question is about that page and nothing else
|
||||
// can answer it. The older comment here argued for falling through and
|
||||
// letting the model answer as if the URL had not been said; that is a
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||
}
|
||||
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 question he actually asked. She answers the question, she does not
|
||||
@@ -582,7 +554,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// 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
|
||||
}
|
||||
@@ -648,7 +620,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// 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
|
||||
}
|
||||
@@ -729,7 +701,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// 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
|
||||
}
|
||||
@@ -759,7 +731,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
||||
return "", false
|
||||
}
|
||||
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.
|
||||
@@ -827,15 +799,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
||||
if h.phraser == nil {
|
||||
// No model of any size. That is not the workstation being asleep, so it
|
||||
// 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)
|
||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||
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 == "" {
|
||||
return "не знаю.", true
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -21,16 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return "не получилось разобрать время напоминания."
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
}
|
||||
}
|
||||
// The body is what she says at the hour, so the marker and the time come
|
||||
// out of it: the fire time is already a column, and "напомни" is an
|
||||
// instruction that has been carried out (Vikunja #469).
|
||||
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return "не получилось поставить напоминание."
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
@@ -41,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
return phraser.Ack(phraser.FailTask, nil), true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||
}
|
||||
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 — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
@@ -71,16 +71,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", true
|
||||
}
|
||||
ranked := tasks.Rank(taskItems(live), h.now())
|
||||
// Bind what she is about to say, in the order she says it, so "второй"
|
||||
// means the second task he heard (ordinal.go).
|
||||
spoken := tasks.Spoken(ranked)
|
||||
cands := make([]dialogue.Candidate, 0, len(spoken))
|
||||
for _, r := range spoken {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(cands)
|
||||
return tasks.FormatRU(ranked), true
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a
|
||||
// stem, because "внимание", "внимания" and "вниманию" are one word to him.
|
||||
//
|
||||
// "что нового" is deliberately absent: the feeds source claims it, and it
|
||||
// still should — a question about news is a question about the feeds she
|
||||
// reads. This list is about the operational state of his things.
|
||||
var attentionMarkers = []string{
|
||||
"внимани", "что требует", "что не так", "что важн", "что срочн",
|
||||
"needs attention", "what needs looking",
|
||||
}
|
||||
|
||||
// isAttentionQuery reports whether the utterance asks what needs looking at.
|
||||
func isAttentionQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range attentionMarkers {
|
||||
if strings.Contains(s, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryAttention answers "что требует внимания?" from Praxis.
|
||||
//
|
||||
// The capability was already built and already degraded correctly, and no
|
||||
// utterance could reach it (Vikunja #475). Its aliases live on the act
|
||||
// dispatch, and the question routes to IntentQuery, so it fell through every
|
||||
// source to the web search and came back with an encyclopedia article about
|
||||
// the concept of attention — worse than silence, because it reads as an
|
||||
// answer.
|
||||
//
|
||||
// Placed above the recall sources and well above the personal boundary: this
|
||||
// is operational state about his things, and a notes pass would otherwise
|
||||
// answer it from whatever he once wrote about a server. An unconfigured or
|
||||
// absent Praxis falls through rather than claiming the turn, the same
|
||||
// convention queryHome and queryNetwork follow. A Praxis that is configured
|
||||
// and down does claim it, and says it cannot reach the service — that is the
|
||||
// degradation the ecosystem contract asks for, and it comes from the same
|
||||
// handler the act path uses.
|
||||
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isAttentionQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return "", false
|
||||
}
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Utterance: t.dec.Utterance,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||
})
|
||||
if reply == "" {
|
||||
return "", false
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestIsAttentionQuery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"что требует внимания", true},
|
||||
{"на что обратить внимание?", true},
|
||||
{"что не так?", true},
|
||||
{"что важного?", true},
|
||||
// The feeds source owns this one, and should keep owning it.
|
||||
{"что нового?", false},
|
||||
{"какая погода?", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := isAttentionQuery(tc.text); got != tc.want {
|
||||
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
|
||||
// routes to IntentQuery, and every source used to pass, so a web search about
|
||||
// the concept of attention answered it.
|
||||
func TestAttentionQuestionReachesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}})
|
||||
if !ok {
|
||||
t.Fatal("the attention question must be claimed before the world sources")
|
||||
}
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("reply = %q, want the praxis item", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A configured Praxis that is down claims the turn and says so. Falling
|
||||
// through here would answer an outage with an encyclopedia article.
|
||||
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
praxis.SetFault(503)
|
||||
|
||||
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}})
|
||||
if !ok || !strings.Contains(reply, "не могу") {
|
||||
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// No Praxis configured means no claim: the rest of the chain still runs.
|
||||
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||
}}); ok {
|
||||
t.Fatal("an unconfigured praxis must not claim the turn")
|
||||
}
|
||||
}
|
||||
+29
-22
@@ -33,10 +33,19 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
|
||||
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||
// used to hold; a re-ask says it differently, because a question he already
|
||||
// failed to answer is the worst one to repeat unchanged.
|
||||
// clarifyQuestions — one short question per missing slot.
|
||||
//
|
||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
||||
// would wander, and a question whose wording changes every time is harder to
|
||||
// answer than a blunt one that always reads the same. They are infinitive
|
||||
// questions, so there is no gender agreement to get wrong; the feminine
|
||||
// self-reference lives in the reply she gives when she drops the request.
|
||||
var clarifyQuestions = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "Когда?",
|
||||
dialogue.SlotText: "О чём напомнить?",
|
||||
dialogue.SlotKey: "Что записать?",
|
||||
dialogue.SlotFn: "Что сделать?",
|
||||
}
|
||||
|
||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||
// says so out loud: dropping the request in silence would leave him thinking it
|
||||
@@ -97,11 +106,11 @@ func trimClarifyExpired(s string) string {
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||
// only reported here — the words themselves still go on to be routed fresh.
|
||||
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
@@ -138,7 +147,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||
q, ok := clarifyQuestions[missing[0]]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -148,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
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -156,7 +165,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -183,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -197,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// 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
|
||||
@@ -208,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -252,18 +261,16 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
// 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
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
@@ -280,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// 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
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||
question = clarifyQuestions[q.Missing[0]]
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
@@ -295,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
q.Slots = merged
|
||||
q.Attempts++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
h.clarifyStore.Put(voiceDialogueID, q)
|
||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||
return question
|
||||
}
|
||||
|
||||
+26
-125
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
t.Fatal("a fact with no key should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a first question")
|
||||
}
|
||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||
@@ -156,14 +156,8 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Fatalf("attempt %d must leave the question armed", i)
|
||||
@@ -191,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||
@@ -205,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// First answer parses, but re-park it by hand as if she had asked again:
|
||||
@@ -238,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
@@ -266,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
@@ -290,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||
} {
|
||||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||
}
|
||||
}
|
||||
@@ -302,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||
// TTL: she must say the old request is gone AND still answer the new words.
|
||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "как дела")
|
||||
reply := h.handleText(ctx, "как дела")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -346,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -355,11 +349,8 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed as an answer")
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
@@ -389,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
@@ -420,9 +411,8 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
lines = append(lines, clarifyMissedVariants...)
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
lines = append(lines, variants...)
|
||||
for _, q := range clarifyQuestions {
|
||||
lines = append(lines, q)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||
@@ -450,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// 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.
|
||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// A confirm parked with a longer life than the question, so only the
|
||||
@@ -461,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "нет")
|
||||
reply := h.handleText(ctx, "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
@@ -471,96 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the subject question: his answer must fill the empty slot,
|
||||
// not replace the request. Slots.Text used to be the whole raw utterance for
|
||||
// every intent, so the branch that fills a text slot could only ever overwrite
|
||||
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
||||
// what to say at it, and the reminder that lands has both.
|
||||
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
if reply == clarifyGaveUp {
|
||||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||||
}
|
||||
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
if err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||
}
|
||||
// The hour is the fire time, not a word in the body: the body is what she
|
||||
// says at the hour, and the time expression is stripped out of it
|
||||
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||||
// reminder that fires at some other time than the one he asked for.
|
||||
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||||
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The clarify copy deck (Vikunja #457).
|
||||
//
|
||||
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||
// that sentence word for word. A question he already failed to answer is the
|
||||
// worst one to ask again unchanged: the second wording is the one that tells
|
||||
// him which part she missed.
|
||||
//
|
||||
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||
// given: the resident model would wander, and a question whose wording changes
|
||||
// at random is harder to answer than a blunt one. What changes here is that the
|
||||
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||
// short, the second names the gap, the third spells it out.
|
||||
//
|
||||
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||
// constants compiled into the daemon, so there is no file that can drift out of
|
||||
// step with the code that reads it.
|
||||
//
|
||||
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
dialogue.SlotTime: {
|
||||
"Когда?",
|
||||
"Во сколько напомнить?",
|
||||
"Скажи время — например, «в семь вечера» или «через час».",
|
||||
},
|
||||
dialogue.SlotText: {
|
||||
"О чём напомнить?",
|
||||
"Что сказать тебе в это время?",
|
||||
"Скажи одной фразой, о чём напомнить.",
|
||||
},
|
||||
dialogue.SlotKey: {
|
||||
"Что записать?",
|
||||
"Что именно отметить?",
|
||||
"Назови, что записать — например, «выпил воды».",
|
||||
},
|
||||
dialogue.SlotFn: {
|
||||
"Что сделать?",
|
||||
"Какое действие выполнить?",
|
||||
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||
// most explicit phrasing rather than wrapping round to the short one, because
|
||||
// wrapping would ask the same short question he has already not answered.
|
||||
//
|
||||
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||
// same statement however it is worded, and a re-ask is not.
|
||||
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||
variants, ok := clarifyQuestionVariants[slot]
|
||||
if !ok || len(variants) == 0 {
|
||||
return "", false
|
||||
}
|
||||
i := attempt - 1
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
if i >= len(variants) {
|
||||
i = len(variants) - 1
|
||||
}
|
||||
return variants[i], true
|
||||
}
|
||||
|
||||
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||
// query, chat, system are not in wantedSlots).
|
||||
//
|
||||
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||
// the same sentence every time is what makes a house assistant sound like a
|
||||
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||
// should say it again — because the wording may vary and the meaning may not.
|
||||
var clarifyMissedVariants = []string{
|
||||
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||
"Не уловила. Скажи это по-другому?",
|
||||
"Прости, не поняла — попробуй сказать иначе.",
|
||||
}
|
||||
|
||||
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||
// asked twice get the same answer and two different misses sound different.
|
||||
//
|
||||
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||
func clarifyMissedFor(utterance string) string {
|
||||
var sum int
|
||||
for _, r := range utterance {
|
||||
sum += int(r)
|
||||
}
|
||||
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||
}
|
||||
|
||||
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||
// caller keeps its own reply.
|
||||
func clarifyMissedLine(dec router.Decision) string {
|
||||
if !dec.Clarify {
|
||||
return ""
|
||||
}
|
||||
return clarifyMissedFor(dec.Utterance)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||
// the defect this deck exists to fix (Vikunja #457).
|
||||
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||
for slot, variants := range clarifyQuestionVariants {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range variants {
|
||||
if v == "" {
|
||||
t.Errorf("%s: empty wording in the deck", slot)
|
||||
}
|
||||
if seen[v] {
|
||||
t.Errorf("%s: repeated wording %q", slot, v)
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||
got, ok := clarifyQuestionFor(slot, attempt)
|
||||
if !ok || got != variants[attempt-1] {
|
||||
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||
// the short question he has already not answered twice.
|
||||
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||
for _, attempt := range []int{0, 4, 9} {
|
||||
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||
}
|
||||
}
|
||||
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||
t.Error("an unknown slot must have no question")
|
||||
}
|
||||
}
|
||||
|
||||
// The missed line is stable for one utterance and absent for a decision that is
|
||||
// not a clarify.
|
||||
func TestClarifyMissedLine(t *testing.T) {
|
||||
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||
first := clarifyMissedLine(d)
|
||||
if first == "" || first != clarifyMissedLine(d) {
|
||||
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||
}
|
||||
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||
t.Errorf("a decision that is not a clarify got %q", got)
|
||||
}
|
||||
// The empty utterance still gets a line: she has to say something.
|
||||
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||
t.Error("an empty utterance must still be answered out loud")
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon where page reading was never turned on — the default — answers the
|
||||
// question the way it did before the capability existed. Claiming the turn to
|
||||
// report a configuration status is for something that exists and failed.
|
||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||
// A daemon where page reading was never turned on names the gap. He asked
|
||||
// about one page, nothing else on the box can read it, and the old behaviour
|
||||
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||
if !ok {
|
||||
t.Fatal("an unconfigured crawler let the page question fall through")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
||||
t.Errorf("got %q, want the gap named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +160,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||
t.Errorf("reply = %q, want the honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -144,10 +145,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "ничего не требует внимания."
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
@@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
}
|
||||
}
|
||||
return "требует внимания: " + strings.Join(parts, "; ")
|
||||
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// listChangesCapability reads the recent-changes feed.
|
||||
@@ -192,10 +193,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
return phraser.A(phraser.ChangesFail, nil)
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return "нет изменений."
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
@@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
typ, _ := c["change_type"].(string)
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||
}
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
@@ -230,12 +231,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return "про что именно спросить?"
|
||||
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||
// than quietly answering about something else.
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
return phraser.A(phraser.EcoNoNexus, nil)
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
@@ -248,15 +249,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, nil)
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
}
|
||||
if entityID == "" {
|
||||
return "не знаю такой сущности."
|
||||
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
@@ -268,7 +269,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
@@ -279,7 +280,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
@@ -303,9 +304,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
@@ -370,7 +371,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
@@ -514,31 +515,28 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
|
||||
// Resolve the utterance text as an entity reference through Nexus. An
|
||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||
// The name comes from entityReferenceText, not straight from the Text slot:
|
||||
// the model transliterates Latin names as it routes (Vikunja #476).
|
||||
subject := entityReferenceText(dec)
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||
if err != nil {
|
||||
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(dec.Slots.Text)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, nil)
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
// independently, never a silent all-clear).
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
map[string]any{"subject": redactSubject(subject)})
|
||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
||||
return ""
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||
@@ -553,9 +551,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
return phraser.A(phraser.EcoDenied, nil)
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -572,21 +570,10 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
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
|
||||
for i, c := range caps {
|
||||
name := strings.ToLower(c.Name)
|
||||
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 {
|
||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
||||
matches = append(matches, &caps[i])
|
||||
}
|
||||
}
|
||||
@@ -598,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
for _, m := range matches {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||
}
|
||||
matched := matches[0]
|
||||
|
||||
@@ -616,7 +603,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
|
||||
}
|
||||
|
||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||
@@ -636,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
@@ -644,31 +631,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return "команда выполнена для " + 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)
|
||||
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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._-]*)*`)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// entityReferenceText is the name Nexus is asked to resolve.
|
||||
//
|
||||
// Normally that is the router's Text slot, which is 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 —
|
||||
// then the longest Latin run in his own words is the reference. 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.
|
||||
func entityReferenceText(dec router.Decision) string {
|
||||
text := dec.Slots.Text
|
||||
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||
return text
|
||||
}
|
||||
longest := ""
|
||||
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||
if len(m) > len(longest) {
|
||||
longest = m
|
||||
}
|
||||
}
|
||||
longest = strings.TrimSpace(longest)
|
||||
// A single stray letter is not a name.
|
||||
if len(longest) < 2 {
|
||||
return text
|
||||
}
|
||||
return longest
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestEntityReferenceText pins when his own words win over the model's.
|
||||
func TestEntityReferenceText(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: "muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "it kept the name, so nothing to repair",
|
||||
utterance: "перезапусти muzick indexer",
|
||||
text: "перезагрузить muzick indexer",
|
||||
want: "перезагрузить muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "an all-Russian entity name is not a rewrite",
|
||||
utterance: "перезапусти домашний сервер",
|
||||
text: "перезагрузить домашний сервер",
|
||||
want: "перезагрузить домашний сервер",
|
||||
},
|
||||
{
|
||||
name: "an English turn never enters the recovery",
|
||||
utterance: "restart muzick indexer",
|
||||
text: "restart muzick indexer",
|
||||
want: "restart muzick indexer",
|
||||
},
|
||||
{
|
||||
name: "the longest Latin run is the name",
|
||||
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||
text: "перезагрузить нгинкс",
|
||||
want: "muzick-indexer",
|
||||
},
|
||||
{
|
||||
name: "one stray letter is not a name",
|
||||
utterance: "перезапусти сервер a",
|
||||
text: "перезагрузить сервер",
|
||||
want: "перезагрузить сервер",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||
if got := entityReferenceText(dec); got != tc.want {
|
||||
t.Fatalf("entityReferenceText = %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")
|
||||
}
|
||||
}
|
||||
@@ -123,48 +123,3 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
}
|
||||
return vec
|
||||
}
|
||||
|
||||
// The write half of #481: a complaint about a thing is a state of the
|
||||
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||
// it comes back on recall as if the network were still down.
|
||||
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "сеть какая-то медленная",
|
||||
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||
t.Fatal("a passing complaint was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
}
|
||||
}
|
||||
|
||||
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||
// over the question gate.
|
||||
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запомни что интернет не работает",
|
||||
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -194,22 +194,3 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
|
||||
// deployed box has no feeds block and does have SearXNG, and "что происходит
|
||||
// сейчас в новостях про искусственный интеллект?" got a configuration status
|
||||
// instead of the live answer sitting one source below.
|
||||
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
|
||||
h := buildFeedHandler(t, false)
|
||||
h.search = &searchWiring{max: 3, runes: 1500}
|
||||
|
||||
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
|
||||
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
|
||||
}
|
||||
// With nothing below that reads the world, the honest status is still said:
|
||||
// general knowledge would otherwise answer with an invented bulletin.
|
||||
h.search = nil
|
||||
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-50
@@ -1,64 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||
// (Vikunja #456).
|
||||
//
|
||||
// Read-only over the facts that already exist. No new mechanism and no new
|
||||
// storage: everything he tapped in is already a row with a source and a
|
||||
// timestamp, and this only reads them back.
|
||||
|
||||
// historyMarkers — the ways he asks what he told her. Each entry is a pair of
|
||||
// substrings that must BOTH appear, because either half alone is a different
|
||||
// question: "что я говорил про сервер" is a recall question the notes pass
|
||||
// answers better, and "что ты записала" with no "что" is not a question at all.
|
||||
var historyMarkers = [][2]string{
|
||||
{"что я", "говорил"},
|
||||
{"что я", "сказал"},
|
||||
{"что я", "рассказ"},
|
||||
{"что ты", "записал"},
|
||||
{"что ты", "запомнил"},
|
||||
{"что я", "отмечал"},
|
||||
{"что я", "отметил"},
|
||||
{"what did i", "tell"},
|
||||
{"what did you", "record"},
|
||||
}
|
||||
|
||||
// historyRecall — the word that turns a history question into a recall
|
||||
// question. "что я говорил про сервер" names a topic, and the notes pass
|
||||
// answers a topic far better than a list of the last five facts does.
|
||||
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkers {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// historyScan — how many recent facts are read before filtering. Deliberately
|
||||
// larger than historyReadOut: a poller writing every few minutes would
|
||||
// otherwise push everything he said out of the window, the same way his own
|
||||
// notes used to crowd out the feed headlines.
|
||||
const historyScan = 100
|
||||
|
||||
// historyReadOut — how many she says out loud. Five is what fits in one spoken
|
||||
// breath; the rest are on /history, which is the surface for reading a list.
|
||||
const historyReadOut = 5
|
||||
|
||||
// historyWindow — how far back "recently" reaches. A day, because the question
|
||||
// is about this conversation and not about the archive.
|
||||
const historyWindow = 24 * time.Hour
|
||||
|
||||
// queryHistory answers what he told her, from the facts he tapped in.
|
||||
//
|
||||
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
|
||||
// relay is a thing she learned, not a thing he said, and reading those back
|
||||
// under "что я тебе говорил?" would put words in his mouth.
|
||||
//
|
||||
// Placed with the other sources that read his own rows and above the recall
|
||||
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||
// to be nearest, which reads as an answer and is not one.
|
||||
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHistoryQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||
if err != nil {
|
||||
log.Printf("voice: history: recent facts: %v", err)
|
||||
return "не получилось посмотреть, что ты говорил.", true
|
||||
}
|
||||
cutoff := h.now().Add(-historyWindow)
|
||||
var said []string
|
||||
for _, f := range facts {
|
||||
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
said = append(said, historyLine(f.Key, f.Value, f.Ts))
|
||||
if len(said) == historyReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(said) == 0 {
|
||||
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||
// true answer, and recall would answer it with an old note instead.
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
return "ты говорил: " + strings.Join(said, "; "), true
|
||||
}
|
||||
|
||||
// historyLine — one fact as she says it. The hour and minute, because the day
|
||||
// is already bounded by historyWindow and a date would be noise.
|
||||
func historyLine(key, value string, ts time.Time) string {
|
||||
what := key
|
||||
if value != "" {
|
||||
what = key + " — " + value
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// historyAPI serves a fixed set of recent facts.
|
||||
type historyAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
return a.facts, nil
|
||||
}
|
||||
|
||||
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
|
||||
api := &historyAPI{facts: facts}
|
||||
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
|
||||
}
|
||||
|
||||
func askHistory(h *reactiveHandler, u string) (string, bool) {
|
||||
return h.queryHistory(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsHistoryQuery(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"что я тебе говорил?", true},
|
||||
{"что ты записала сегодня?", true},
|
||||
{"что я отмечал?", true},
|
||||
// A named topic is a recall question, and the notes pass answers it
|
||||
// better than a list of the last five facts does.
|
||||
{"что я говорил про сервер?", false},
|
||||
{"что у меня сегодня?", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := isHistoryQuery(tc.text); got != tc.want {
|
||||
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, api := historyHandler(now,
|
||||
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
|
||||
// Learned, not said: a poller writing this back under "что я тебе
|
||||
// говорил?" would put words in his mouth.
|
||||
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
|
||||
// Older than the window.
|
||||
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
|
||||
)
|
||||
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok {
|
||||
t.Fatal("the history question must be claimed before the recall sources")
|
||||
}
|
||||
if !strings.Contains(reply, "water") {
|
||||
t.Errorf("reply = %q, want the fact he tapped in", reply)
|
||||
}
|
||||
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
|
||||
t.Errorf("reply = %q, want only what he said inside the window", reply)
|
||||
}
|
||||
if api.calls != 1 {
|
||||
t.Errorf("RecentFacts called %d times, want 1", api.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing said is an answer of its own. Falling through would hand the question
|
||||
// to recall, which answers it with an old note.
|
||||
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now)
|
||||
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Five is what fits in one spoken breath; the rest are on /history.
|
||||
func TestHistoryStopsAtFive(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
var facts []ipc.Fact
|
||||
for i := 0; i < 12; i++ {
|
||||
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
|
||||
}
|
||||
h, _ := historyHandler(now, facts...)
|
||||
reply, _ := askHistory(h, "что ты записала?")
|
||||
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
|
||||
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"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
|
||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
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
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
@@ -116,7 +117,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
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)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
@@ -169,3 +171,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
|
||||
// An unconfigured scanner names the gap instead of declining the turn.
|
||||
//
|
||||
// Falling through sent "какие устройства в сети?" to the search leg, which
|
||||
// answered with a paragraph about routers in general — and put a question about
|
||||
// his own LAN on an upstream engine, which the personal boundary exists to
|
||||
// prevent (Vikunja #479).
|
||||
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||
h := &reactiveHandler{}
|
||||
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an unconfigured scanner let the question fall through to search")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
||||
t.Errorf("got %q, want the gap named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Ordinal selection over a list she just read (Vikunja #448).
|
||||
//
|
||||
// The dialogue session already carried the intent, the slots and the history.
|
||||
// What it did not carry was the list: she recited five tasks, he said "второй",
|
||||
// and there was nothing for that word to point at, so it routed as a fresh
|
||||
// utterance and meant nothing.
|
||||
//
|
||||
// Candidates are bound when she speaks the list, in the order she spoke it (see
|
||||
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
|
||||
// query, and the list can change between two turns.
|
||||
//
|
||||
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
|
||||
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||
// wrong guess here closes work he never finished.
|
||||
|
||||
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||
// because Russian declines them: "первый", "первую", "первое".
|
||||
var candidateOrdinals = []struct {
|
||||
word string
|
||||
nth int
|
||||
}{
|
||||
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||
{"first", 1}, {"second", 2}, {"third", 3},
|
||||
}
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||
var candidateLast = []string{"последн", "last"}
|
||||
|
||||
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||
// A negative result means the last one.
|
||||
func parseOrdinal(text string) (int, bool) {
|
||||
// Token by token, not substring: " 1" would otherwise match inside
|
||||
// "напомни в 15:00" and turn a reminder into a selection.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
for _, w := range candidateLast {
|
||||
if strings.HasPrefix(tok, w) {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
for _, o := range candidateOrdinals {
|
||||
// Prefix, because Russian declines them: "первый", "первую".
|
||||
if strings.HasPrefix(tok, o.word) {
|
||||
return o.nth, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// candidateVerbs — what he wants done with the one he picked. Nothing here is
|
||||
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||
// provenance the /tasks page shows.
|
||||
var candidateVerbs = []struct {
|
||||
words []string
|
||||
status string
|
||||
say string
|
||||
}{
|
||||
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
|
||||
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
|
||||
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
|
||||
}
|
||||
|
||||
func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(text))
|
||||
for _, v := range candidateVerbs {
|
||||
for _, w := range v.words {
|
||||
if strings.Contains(s, w) {
|
||||
return v.status, v.say, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// offerCandidates records the list she just read, so his next words can pick
|
||||
// from it. Best effort: no session store, or a session that expired between the
|
||||
// question and the answer, means the words route normally.
|
||||
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
nth, ok := parseOrdinal(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if nth < 0 {
|
||||
nth = len(sess.Candidates)
|
||||
}
|
||||
if nth > len(sess.Candidates) {
|
||||
// Claim the turn: he is picking from her list and named a position she
|
||||
// did not read. Routing it fresh would answer something else entirely.
|
||||
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||
}
|
||||
pick := sess.Candidates[nth-1]
|
||||
status, say, hasVerb := parseCandidateVerb(text)
|
||||
if !hasVerb || pick.Kind != "task" {
|
||||
// Read it back and keep the list: naming one is often the first half of
|
||||
// a sentence, and the second half is the next turn.
|
||||
return pick.Label, true
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||
return "не получилось изменить задачу.", true
|
||||
}
|
||||
// Spent: the list she read is no longer the list, and a second ordinal
|
||||
// against it would close the wrong task.
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{"второй", 2, true},
|
||||
{"вторую сделал", 2, true},
|
||||
{"первую убери", 1, true},
|
||||
{"последнюю не надо", -1, true},
|
||||
{"3", 3, true},
|
||||
{"the second one", 2, true},
|
||||
// No position named.
|
||||
{"какие у меня задачи", 0, false},
|
||||
{"", 0, false},
|
||||
// A digit inside a time is not a position.
|
||||
{"напомни в 15:00", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseOrdinal(c.text)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||
t.Error("an ordinal with no list behind it was claimed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||
}
|
||||
// Still live: naming one is often the first half of a sentence.
|
||||
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||
t.Error("the list was spent by a read-back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||
}
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
for _, task := range live {
|
||||
if task.ID == ids[0] {
|
||||
t.Fatalf("task %d is still live after he closed it", task.ID)
|
||||
}
|
||||
}
|
||||
// Spent: a second ordinal against a list that no longer holds would close
|
||||
// the wrong task.
|
||||
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||
t.Error("the list survived the pick it was spent on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||
if !handled || !strings.Contains(reply, "1") {
|
||||
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||
}
|
||||
}
|
||||
|
||||
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
|
||||
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var ids []int64
|
||||
for _, text := range texts {
|
||||
res, err := st.CaptureTask(ctx, store.Task{
|
||||
Text: text,
|
||||
Source: "tap:voice",
|
||||
Status: store.TaskOpen,
|
||||
CreatedTs: ordinalNow,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("capture task: %v", err)
|
||||
}
|
||||
ids = append(ids, res.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(cands)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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"},
|
||||
})
|
||||
reply := askQuery(t, h, q)
|
||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
||||
t.Errorf("reply %q, want %q", reply, want)
|
||||
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||
t.Errorf("reply %q, want the note read back", reply)
|
||||
}
|
||||
// One text, the winning memory's — the answer came from the memory
|
||||
// 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.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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -87,38 +85,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||
// the instruction, not the thing to say at the hour.
|
||||
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
|
||||
|
||||
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||
// the body because the fire time is already a column. Ordered longest-first
|
||||
// where two could match the same words, so "через полтора часа" does not leave
|
||||
// "полтора" behind.
|
||||
//
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||
// boundaries here are written out as whitespace or an end of string — the same
|
||||
// trap the agenda grammars hit.
|
||||
var reminderTimeWords = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
|
||||
}
|
||||
|
||||
// reminderBody is what she says at the hour.
|
||||
//
|
||||
// The whole utterance used to be stored, so /reminders read "напомни завтра в
|
||||
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
|
||||
// agenda recited the marker back at him (Vikunja #469). The fire time is
|
||||
// already a column, and the marker is an instruction that was carried out.
|
||||
//
|
||||
// Falls back to the fuller text whenever stripping would leave nothing: an
|
||||
// empty body is a reminder that fires and says nothing, which is worse than a
|
||||
// wordy one.
|
||||
func reminderBody(utterance, text string) string {
|
||||
body := strings.TrimSpace(text)
|
||||
if body == "" {
|
||||
body = strings.TrimSpace(utterance)
|
||||
}
|
||||
stripped := reminderMarker.ReplaceAllString(body, "")
|
||||
for _, re := range reminderTimeWords {
|
||||
stripped = re.ReplaceAllString(stripped, " ")
|
||||
}
|
||||
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
|
||||
stripped = strings.Trim(stripped, " ,;:—-")
|
||||
if stripped == "" {
|
||||
return body
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReminderBody(t *testing.T) {
|
||||
for _, tc := range []struct{ utterance, text, want string }{
|
||||
// The row from the QA sitting: the whole utterance was the body.
|
||||
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
|
||||
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
|
||||
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
|
||||
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
|
||||
// Nothing left after stripping ⇒ keep what there was. A reminder that
|
||||
// fires and says nothing is worse than a wordy one.
|
||||
{"напомни завтра", "завтра", "завтра"},
|
||||
{"", "", ""},
|
||||
} {
|
||||
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
|
||||
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Conversation repair (Vikunja #455).
|
||||
//
|
||||
// The classifier has been able to learn from a correction since it was
|
||||
// written — CorrectMisroute appends the utterance as a new example for the
|
||||
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||
// did not.
|
||||
//
|
||||
// This is the reachable half. He says she got it wrong and names what it
|
||||
// should have been, she redoes the previous utterance under that intent, and
|
||||
// she says out loud that the correction landed — because a correction he
|
||||
// cannot see is indistinguishable from one that was dropped.
|
||||
//
|
||||
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||
// it as one files the correction itself as a note.
|
||||
|
||||
// routedTurn — the previous utterance and where it went, which is all a
|
||||
// correction needs to point at.
|
||||
type routedTurn struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||
// forgotten.
|
||||
const repairWindow = 5 * time.Minute
|
||||
|
||||
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||
// treating it as a correction would rewrite the last turn every time he used
|
||||
// the word.
|
||||
var repairMarkers = []string{
|
||||
"не так поняла",
|
||||
"неправильно поняла",
|
||||
"ты не поняла",
|
||||
"не поняла меня",
|
||||
"ты ошиблась",
|
||||
"это не",
|
||||
"а не",
|
||||
"не про то",
|
||||
"got it wrong",
|
||||
"not a ",
|
||||
"that was wrong",
|
||||
}
|
||||
|
||||
// repairIntents — the words he uses for each intent. Prefixes, because Russian
|
||||
// declines them: "заметка", "заметку", "заметкой".
|
||||
var repairIntents = []struct {
|
||||
word string
|
||||
intent router.Intent
|
||||
say string
|
||||
}{
|
||||
{"заметк", router.IntentNote, "заметка"},
|
||||
{"напоминани", router.IntentReminder, "напоминание"},
|
||||
{"напомнить", router.IntentReminder, "напоминание"},
|
||||
{"факт", router.IntentFact, "факт"},
|
||||
{"вопрос", router.IntentQuery, "вопрос"},
|
||||
{"команд", router.IntentAct, "команда"},
|
||||
{"note", router.IntentNote, "заметка"},
|
||||
{"reminder", router.IntentReminder, "напоминание"},
|
||||
{"fact", router.IntentFact, "факт"},
|
||||
{"question", router.IntentQuery, "вопрос"},
|
||||
}
|
||||
|
||||
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||
// the intent it should have been.
|
||||
//
|
||||
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||
// front of.
|
||||
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||
// of a sentence is an ordinary word.
|
||||
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||
for _, m := range repairMarkers {
|
||||
if marked || strings.Contains(s, m) {
|
||||
marked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !marked {
|
||||
return "", "", false
|
||||
}
|
||||
best, say, at := router.Intent(""), "", -1
|
||||
for _, w := range repairIntents {
|
||||
i := strings.Index(s, w.word)
|
||||
if i < 0 || negatedAt(s, i) {
|
||||
continue
|
||||
}
|
||||
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
|
||||
if at < 0 || i < at {
|
||||
best, say, at = w.intent, w.say, i
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return best, say, true
|
||||
}
|
||||
|
||||
// negatedAt reports whether the word at i is the one he is ruling out. Only
|
||||
// the words immediately before it are read, so "не напоминание, а заметка"
|
||||
// negates the first and leaves the second alone.
|
||||
func negatedAt(s string, i int) bool {
|
||||
before := strings.TrimSpace(s[:i])
|
||||
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
|
||||
}
|
||||
|
||||
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||
// wrong about.
|
||||
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
last := h.lastRouted
|
||||
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||
// twice would otherwise redo the same request twice.
|
||||
h.lastRouted = nil
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||
// classifier, redo the request under the corrected intent, and say so.
|
||||
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||
corrected, say, ok := parseRepair(text)
|
||||
if !ok || h.router == nil {
|
||||
return "", false
|
||||
}
|
||||
last := h.takeLastTurn()
|
||||
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||
return "", false
|
||||
}
|
||||
if last.intent == corrected {
|
||||
// She already did what he is asking for. Correcting the classifier
|
||||
// here would teach it the label it produced, and redoing the request
|
||||
// would file it twice.
|
||||
return "", false
|
||||
}
|
||||
learned := true
|
||||
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||
// The redo is still worth doing: he asked for something and it did not
|
||||
// happen. Only the learning half is lost, and he is told so.
|
||||
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||
learned = false
|
||||
}
|
||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: last.utterance,
|
||||
Stage: 2,
|
||||
Intent: corrected,
|
||||
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||
}
|
||||
// A reminder's Text is what she says at the hour and stays empty when it
|
||||
// was not spoken, so the gap is asked about rather than filled with the
|
||||
// whole sentence. Everywhere else the utterance IS the payload.
|
||||
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||
dec.Slots.Text = last.utterance
|
||||
}
|
||||
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// repairLine — what she says before redoing it, so the correction is visible
|
||||
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||
// call, he did not complain about her.
|
||||
func repairLine(say string, learned bool) string {
|
||||
if !learned {
|
||||
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||
}
|
||||
return "поняла, это " + say + " — запомнила."
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want router.Intent
|
||||
ok bool
|
||||
}{
|
||||
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||
{"нет, это заметка", router.IntentNote, true},
|
||||
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||
// No marker: an ordinary request that happens to name an intent.
|
||||
{"запиши заметку купить хлеб", "", false},
|
||||
{"напомни мне про заметку", "", false},
|
||||
// A marker with no intent named: nothing to correct to.
|
||||
{"ты не так поняла", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, _, ok := parseRepair(c.utterance)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||
// previous utterance is filed under the intent he named, the classifier keeps
|
||||
// it as an example, and he hears that it landed.
|
||||
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
cls := router.NewClassifier(emb)
|
||||
h.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||
if !handled {
|
||||
t.Fatal("a spoken correction was not handled")
|
||||
}
|
||||
if !strings.Contains(reply, "заметка") {
|
||||
t.Errorf("the correction is not named out loud: %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "не вышло") {
|
||||
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||
}
|
||||
|
||||
ex := cls.Examples(router.IntentNote)
|
||||
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("recent notes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||
}
|
||||
_ = now
|
||||
}
|
||||
|
||||
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||
h, _, now := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
ctx := context.Background()
|
||||
|
||||
// Nothing said yet.
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction with no previous turn was handled")
|
||||
}
|
||||
// Said, but long ago.
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("a correction outside the window was handled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairIsSpentOnce(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the first correction was not handled")
|
||||
}
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("the same turn was corrected twice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||
// is nothing to teach and redoing it would file the request a second time.
|
||||
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
h.recordTurn("купить хлеб", router.IntentNote)
|
||||
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||
t.Error("a correction to the intent she already used was handled")
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,11 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||
func (r *llmReplier) Reply(d router.Decision) string {
|
||||
if d.Clarify {
|
||||
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||
// into a question is the line he hears most often when she misses him,
|
||||
// and it used to be the same words every time (Vikunja #457). Still no
|
||||
// model call — this text has to be right every time, and it is not worth
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if err != nil || out == "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -29,28 +30,26 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
|
||||
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||
// the clarify deck rather than the stub's single sentence.
|
||||
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
if got == "я всё поняла" {
|
||||
t.Fatal("a clarify must not be phrased by the model")
|
||||
}
|
||||
if want := clarifyMissedFor("мгм"); got != want {
|
||||
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||
}
|
||||
// Two different misses do not sound identical.
|
||||
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1043,26 +1043,3 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"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)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: summary: %v", err)
|
||||
return "не смогла достучаться до дома.", true
|
||||
return phraser.A(phraser.HomeUnreachable, nil), true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return "дом ничего не отдаёт.", true
|
||||
return phraser.A(phraser.HomeEmpty, nil), true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
@@ -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
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
@@ -185,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
return phraser.A(phraser.HomeDark, map[string]string{
|
||||
"count": strconv.Itoa(dark),
|
||||
"word": hostWord(dark),
|
||||
}), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"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 {
|
||||
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)
|
||||
return "хорошо, вернусь к этому позже.", true
|
||||
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||
}
|
||||
|
||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||
|
||||
+8
-27
@@ -176,9 +176,6 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
t.queueNudge(ctx, cand, state, now)
|
||||
} else {
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err == nil {
|
||||
pn = guardNudge(pn, *cand)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
} else {
|
||||
@@ -764,7 +761,11 @@ func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state
|
||||
facts := t.gatherMorningFacts(ctx)
|
||||
|
||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||
body := morningNudgeBody(cand)
|
||||
labels := make([]string, len(cand.Missing))
|
||||
for i, it := range cand.Missing {
|
||||
labels[i] = it.Label
|
||||
}
|
||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, strings.Join(labels, ", "))
|
||||
pn := delivery.PhrasedNudge{
|
||||
Candidate: loop.Candidate{
|
||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||
@@ -780,26 +781,6 @@ 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
|
||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||
// decision) and morningStatus (read-only query) so the two paths can never
|
||||
@@ -1005,7 +986,7 @@ type daemonAPI struct {
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
}
|
||||
@@ -1021,11 +1002,11 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
|
||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
||||
t.Fatalf("seed service_down: %v", err)
|
||||
}
|
||||
sink := &fakeSink{}
|
||||
|
||||
+4
-31
@@ -161,7 +161,6 @@ type reactiveHandler struct {
|
||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||
mu sync.Mutex
|
||||
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||
pending *pendingAct
|
||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||
@@ -221,9 +220,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
||||
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
||||
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
||||
// HandlePushToTalk so text channels share the same routing logic.
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
log.Printf("voice: handleText: %q", text)
|
||||
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
|
||||
return h.runTurn(ctx, text, sourceText)
|
||||
}
|
||||
|
||||
// turnSource — which channel this utterance arrived on, in the same provenance
|
||||
@@ -256,7 +255,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
|
||||
// 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.
|
||||
expiredNotice := h.clarifyExpiredNotice(ctx)
|
||||
expiredNotice := h.clarifyExpiredNotice()
|
||||
|
||||
// 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
|
||||
@@ -300,22 +299,6 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||
// turn and names what it should have been (repair.go). Before routing,
|
||||
// like the confirm and clarify turns: routing the correction as a fresh
|
||||
// utterance files the correction itself instead of fixing anything.
|
||||
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||
// and keeps routing.
|
||||
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||
// previous turn instead (continuation.go): the intent is the part it is
|
||||
// missing, so no amount of routing recovers it, and the model's guess
|
||||
@@ -368,21 +351,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||
// stands.
|
||||
if dec.Clarify {
|
||||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
if question, asked := h.askClarify(ctx, dec); asked {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
return withNotice(expiredNotice, question)
|
||||
}
|
||||
}
|
||||
|
||||
// Remember what this turn was routed as, so the next utterance can correct
|
||||
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||
// is nothing yet to be wrong about.
|
||||
if !dec.Clarify {
|
||||
h.recordTurn(text, dec.Intent)
|
||||
}
|
||||
|
||||
// 8. action — execute the decision's intent. errors here surface as
|
||||
// short reply text (the user wants to know the action didn't land);
|
||||
// the round-trip stays alive.
|
||||
|
||||
+9
-47
@@ -238,10 +238,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
// 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
|
||||
// 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.
|
||||
// load, never revived. Clarify's parked question stays in memory only.
|
||||
var dialogueSessions *dialogue.SessionStore
|
||||
if dataStore != nil {
|
||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||
@@ -382,22 +379,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||
// routed system and answered "пока не умею" (Vikunja #474).
|
||||
grammars = append(grammars, router.FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
||||
// question first and a narrative request second (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// 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())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
// X" is a world question the model called a fact (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
@@ -411,34 +396,11 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
})
|
||||
}
|
||||
|
||||
// seedDir is the directory containing intent seed files, relative to the repo
|
||||
// root. Each file is named <intent>.txt and holds one training example per
|
||||
// line (blank lines and lines starting with # are ignored).
|
||||
// seedDir is the directory containing intent seed files. Each file is named
|
||||
// <intent>.txt and contains one training example per line (blank lines and
|
||||
// lines starting with # are ignored). Relative to the working directory.
|
||||
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
|
||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||
@@ -463,11 +425,11 @@ func seedClassifier(c *router.Classifier) {
|
||||
}
|
||||
total += n
|
||||
}
|
||||
log.Printf("voice: loaded %d seed examples from %s", total, seedPath())
|
||||
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
|
||||
}
|
||||
|
||||
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
||||
path := filepath.Join(seedPath(), string(intent)+".txt")
|
||||
path := filepath.Join(seedDir, string(intent)+".txt")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s: %w", path, err)
|
||||
|
||||
+33
-45
@@ -1,13 +1,10 @@
|
||||
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
||||
// this utterance ask about weather at all, and which place (if any) did he
|
||||
// name. Both are plain keyword matching, not NLU — extend this file rather
|
||||
// than voice.go for anything in that shape.
|
||||
// this utterance ask about weather at all, and which city (if any) did it
|
||||
// name. Both are plain substring/lookup matching, not NLU — extend this file
|
||||
// rather than voice.go for anything in that shape.
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// isWeatherQuery returns true if the utterance is about weather.
|
||||
func isWeatherQuery(u string) bool {
|
||||
@@ -22,49 +19,40 @@ func isWeatherQuery(u string) bool {
|
||||
strings.Contains(lower, "temperature")
|
||||
}
|
||||
|
||||
// weatherPlace — the place he named, after "в"/"во"/"in". One or two words,
|
||||
// letters and dashes only, so "в Нижнем Новгороде" and "in New York" both
|
||||
// come through whole and "в 5 утра" does not.
|
||||
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||
|
||||
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||
// "тепло в комнате" is the same question about the same room.
|
||||
var weatherNonPlaces = map[string]bool{
|
||||
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||
// weatherCities — the city names an utterance may name explicitly, as
|
||||
// lowercase substrings mapped to the provider's spelling. This is a
|
||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
||||
// nothing here is used unless he actually said it.
|
||||
var weatherCities = map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the place he named, or the configured default
|
||||
// extractWeatherLocation returns the city he named, or the configured default
|
||||
// when he named none. It returns "" when he named none AND no default is
|
||||
// configured — the caller must then say it does not know.
|
||||
//
|
||||
// It used to be a hand-written table of six cities in two spellings each
|
||||
// (Vikunja #421). Anything outside it — Kazan, Tbilisi — was dropped silently
|
||||
// and answered for the default location, which reads as a correct answer about
|
||||
// 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.
|
||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
||||
// .default_location is the only source of an unstated location.
|
||||
func extractWeatherLocation(u, defaultLoc string) string {
|
||||
m := weatherPlace.FindStringSubmatch(u)
|
||||
if m == nil {
|
||||
return defaultLoc
|
||||
lower := strings.ToLower(u)
|
||||
for substr, name := range weatherCities {
|
||||
if strings.Contains(lower, substr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
place := strings.TrimSpace(m[1])
|
||||
first := strings.ToLower(strings.Fields(place)[0])
|
||||
if weatherNonPlaces[first] {
|
||||
return defaultLoc
|
||||
}
|
||||
return place
|
||||
return defaultLoc
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня
|
||||
// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches
|
||||
// on a day word alone, and it sits above the weather source.
|
||||
func TestCalendarStepsAsideForWeather(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
for _, u := range []string{
|
||||
"какая сегодня погода в Москве?",
|
||||
"будет дождь завтра?",
|
||||
"сколько градусов сегодня?",
|
||||
} {
|
||||
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||
}
|
||||
}
|
||||
if api.events != 0 {
|
||||
t.Errorf("CalendarEvents called %d times for weather questions, want 0", api.events)
|
||||
}
|
||||
// The agenda question it exists for still reaches it.
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"},
|
||||
}); !ok {
|
||||
t.Fatal("the calendar stopped answering the agenda question")
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,11 @@ type worldPhraser interface {
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
@@ -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)
|
||||
return ""
|
||||
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)
|
||||
return ""
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
if g.worldCalls != 1 {
|
||||
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||
// English in it.
|
||||
func TestWorldGapIsInPersona(t *testing.T) {
|
||||
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||
if strings.Contains(worldGap, bad) {
|
||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
||||
if strings.Contains(worldGap(), bad) {
|
||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
||||
if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-17
@@ -24,12 +24,7 @@ type runner struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ready bool
|
||||
// yielding — stop() has sent the signal and the exit that follows is ours.
|
||||
// llama-server aborts on SIGTERM (its static teardown throws, upstream
|
||||
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
|
||||
// "signal: aborted" and used to log identically (Vikunja #491).
|
||||
yielding bool
|
||||
http *http.Client
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||
@@ -75,18 +70,13 @@ func (r *runner) start() error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cmd, r.ready, r.yielding = cmd, false, false
|
||||
r.cmd, r.ready = cmd, false
|
||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
r.mu.Lock()
|
||||
yielded := r.yielding
|
||||
r.cmd, r.ready, r.yielding = nil, false, false
|
||||
r.cmd, r.ready = nil, false
|
||||
r.mu.Unlock()
|
||||
if yielded {
|
||||
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
||||
return
|
||||
}
|
||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
||||
}()
|
||||
return nil
|
||||
@@ -100,10 +90,6 @@ func (r *runner) stop(grace time.Duration) {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmd
|
||||
r.ready = false
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
// The exit that follows is ours, not a crash.
|
||||
r.yielding = true
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeServer writes an executable standing in for llama-server: it ignores
|
||||
// SIGTERM the way the real one effectively does — by dying messily rather than
|
||||
// cleanly — and reports a non-zero status.
|
||||
func fakeServer(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "fake-llama-server")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A deliberate stop is a yield, and the log has to say so.
|
||||
//
|
||||
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
|
||||
// status of a routine yield is identical to that of a real crash. Reading the
|
||||
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
||||
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
||||
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
||||
if err := r.start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.yielding {
|
||||
t.Error("a freshly started server is already marked as yielding")
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
r.stop(2 * time.Second)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !r.running() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the child outlived stop")
|
||||
}
|
||||
|
||||
// Stopping when nothing is running must not arm the flag for the next child.
|
||||
// The next exit after that would be a real crash logged as a yield.
|
||||
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
||||
r := newRunner("/nonexistent", nil, "")
|
||||
r.stop(10 * time.Millisecond)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.yielding {
|
||||
t.Error("stop armed the yield flag with no child running")
|
||||
}
|
||||
}
|
||||
+21
-71
@@ -140,10 +140,6 @@ type poller struct {
|
||||
wgIface string
|
||||
wgCmd string
|
||||
|
||||
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
||||
// disappears from the gauge can be marked unknown instead of staying down.
|
||||
kumaSeen map[string]string
|
||||
|
||||
// zen is nil unless a token file was configured — money tracking is a
|
||||
// capability, off by default like weather and telegram.
|
||||
zen *zenmoney.Client
|
||||
@@ -337,96 +333,50 @@ func maxSeverity(a netdataAlarms) string {
|
||||
return sev
|
||||
}
|
||||
|
||||
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
||||
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
||||
|
||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
||||
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
||||
// service is down. The aggregate this used to write could not, which is why
|
||||
// the rule shipped disabled.
|
||||
var (
|
||||
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
||||
)
|
||||
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
||||
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
||||
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
||||
// only needs "something is down".
|
||||
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||
|
||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states := kumaMonitors(body)
|
||||
if len(states) == 0 {
|
||||
down, seen := kumaAnyDown(body)
|
||||
if !seen {
|
||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||
}
|
||||
var firstErr error
|
||||
for name, val := range states {
|
||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err // one bad monitor must not blind the rest
|
||||
}
|
||||
val := "up"
|
||||
if down {
|
||||
val = "down"
|
||||
}
|
||||
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
||||
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
||||
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
||||
// the next poll that still lacks the monitor says nothing new either.
|
||||
for name := range p.kumaSeen {
|
||||
if _, still := states[name]; !still {
|
||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
p.kumaSeen = states
|
||||
return firstErr
|
||||
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
||||
}
|
||||
|
||||
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
||||
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
||||
const kumaSource = "poll:uptimekuma"
|
||||
|
||||
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
||||
// so it stays as kuma spells it rather than being slugged into something else.
|
||||
func kumaFactKey(name string) string { return "service_down:" + name }
|
||||
|
||||
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
||||
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
||||
// line matched at all (wrong endpoint, or auth rejected before the body).
|
||||
// A line with no monitor_name label is skipped: a fact nobody can name is
|
||||
// exactly the thing this replaced.
|
||||
func kumaMonitors(body []byte) map[string]string {
|
||||
out := make(map[string]string)
|
||||
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
||||
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
||||
// lines matched at all (wrong endpoint or auth rejected before the body).
|
||||
func kumaAnyDown(body []byte) (down, seen bool) {
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
nm := kumaName.FindStringSubmatch(m[1])
|
||||
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
v, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
||||
// a monitor paused in kuma should silence that monitor, not page him.
|
||||
func kumaState(v float64) string {
|
||||
switch v {
|
||||
case 0:
|
||||
return "down"
|
||||
case 1:
|
||||
return "up"
|
||||
case 2:
|
||||
return "pending"
|
||||
case 3:
|
||||
return "maintenance"
|
||||
default:
|
||||
return "unknown"
|
||||
if v == 0 {
|
||||
down = true
|
||||
}
|
||||
}
|
||||
return down, seen
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
+12
-36
@@ -35,46 +35,22 @@ func TestMaxSeverity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
||||
func TestKumaAnyDown(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want map[string]string
|
||||
body string
|
||||
down, seen bool
|
||||
}{
|
||||
{"empty body", "", map[string]string{}},
|
||||
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
||||
{
|
||||
"one up one down",
|
||||
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
||||
map[string]string{"web": "up", "db": "down"},
|
||||
},
|
||||
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
||||
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
||||
{
|
||||
"other labels do not hide the name",
|
||||
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
||||
map[string]string{"ci": "down"},
|
||||
},
|
||||
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
||||
{"", false, false},
|
||||
{`monitor_status{monitor_name="web"} 1`, false, true},
|
||||
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
||||
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
||||
{`# HELP monitor_status ...`, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := kumaMonitors([]byte(c.body))
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
||||
}
|
||||
for k, v := range c.want {
|
||||
if got[k] != v {
|
||||
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
||||
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
||||
t.Errorf("kumaFactKey = %q", got)
|
||||
down, seen := kumaAnyDown([]byte(c.body))
|
||||
if down != c.down || seen != c.seen {
|
||||
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-14
@@ -23,7 +23,6 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -40,27 +39,15 @@ func run(args []string) error {
|
||||
model := flag.String("model", "", "path to piper onnx model file")
|
||||
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
||||
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
||||
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
|
||||
|
||||
flag.CommandLine.Parse(args)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
defer stop()
|
||||
|
||||
// Read before the handler is built: a dictionary he asked for and that
|
||||
// cannot be read is a startup failure, not a warning. Saying names wrong
|
||||
// in silence is the thing it exists to stop.
|
||||
lex, err := tts.LoadLexicon(*lexiconPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lex.Size() > 0 {
|
||||
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
|
||||
}
|
||||
|
||||
var s worker.Synthesizer
|
||||
if *piperBin != "" && *model != "" {
|
||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
|
||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
|
||||
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
||||
} else {
|
||||
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -118,18 +117,3 @@ func abs(n int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
|
||||
// data, so nothing else would catch a trailing comma before the voice did.
|
||||
func TestShippedLexiconLoads(t *testing.T) {
|
||||
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("deploy/tts-lexicon.json: %v", err)
|
||||
}
|
||||
if lex.Size() < 10 {
|
||||
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
|
||||
}
|
||||
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
|
||||
t.Error("the shipped dictionary did not rewrite a name it lists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os/exec"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
@@ -19,20 +18,15 @@ type piperHandler struct {
|
||||
configPath string
|
||||
espeakData string
|
||||
tashkeelModel string
|
||||
// lexicon rewrites service ids and Latin names into the spelling the
|
||||
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
|
||||
// dictionary rewrites nothing.
|
||||
lexicon *tts.Lexicon
|
||||
}
|
||||
|
||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
|
||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
|
||||
return &piperHandler{
|
||||
piperPath: piperPath,
|
||||
modelPath: modelPath,
|
||||
configPath: modelPath + ".json",
|
||||
espeakData: espeakData,
|
||||
tashkeelModel: tashkeelModel,
|
||||
lexicon: lexicon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,14 +66,7 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
||||
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
||||
}
|
||||
|
||||
// The dictionary is applied here, at the last edge before the voice: every
|
||||
// caller's text passes through this one point, and nothing upstream has to
|
||||
// know how a name is spelled out loud.
|
||||
text := req.Text
|
||||
if h.lexicon != nil {
|
||||
text = h.lexicon.Apply(text)
|
||||
}
|
||||
if _, err := io.WriteString(stdin, text); err != nil {
|
||||
if _, err := io.WriteString(stdin, req.Text); err != nil {
|
||||
stdin.Close()
|
||||
stdout.Close()
|
||||
_ = cmd.Wait()
|
||||
|
||||
@@ -139,13 +139,7 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleAmbientAuth(t *testing.T) {
|
||||
// posted_at carries the local offset, and the clock reading inside the text
|
||||
// sits twenty minutes after it. A bare "Z" here would make the reading
|
||||
// stale by the test machine's own offset and the handler would answer 202
|
||||
// no-meeting, which says nothing about the auth this test is checking
|
||||
// (Vikunja #482).
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
||||
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
|
||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||
|
||||
newReq := func(hdr, val string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||
|
||||
@@ -54,9 +54,7 @@ type fakeCore struct {
|
||||
revertErr error
|
||||
|
||||
// for handleNotifications tests
|
||||
nudgesErr error
|
||||
attempts []ipc.DeliveryAttempt
|
||||
attemptStatus string
|
||||
nudgesErr error
|
||||
|
||||
// for handleHistory tests
|
||||
historyFacts []ipc.Fact
|
||||
@@ -79,7 +77,7 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
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
|
||||
if f.chatErr != nil {
|
||||
return "", f.chatErr
|
||||
@@ -1253,35 +1251,3 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-101
@@ -893,104 +893,12 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
|
||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||
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")
|
||||
if err := notificationsTmpl.Execute(w, map[string]any{
|
||||
"Nudges": nudges,
|
||||
"Attempts": deliveryRows(attempts),
|
||||
"Status": status,
|
||||
}); err != nil {
|
||||
if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||
// timestamps already in his clock.
|
||||
//
|
||||
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||
type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Text string
|
||||
}
|
||||
|
||||
// reminderText unwraps the {"text":...} payload the router writes.
|
||||
//
|
||||
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||
func reminderText(payload string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||
if t, ok := m["text"]; ok {
|
||||
if s, isStr := t.(string); isStr && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
out = append(out, reminderRow{
|
||||
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||
Status: r.Status,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
||||
@@ -1004,7 +912,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
|
||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
|
||||
log.Printf("reminders template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1770,13 +1678,7 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
reply, err := core.Chat(r.Context(), text)
|
||||
if err != nil {
|
||||
log.Printf("chat api: %v", err)
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
|
||||
@@ -14,27 +14,5 @@
|
||||
<div>no notifications yet</div>
|
||||
<div class=hint>check back later or ask maven a question</div>
|
||||
</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"}}
|
||||
</html>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{{if .Reminders}}<div class=scroll><table>
|
||||
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
|
||||
{{range .Reminders}}<tr>
|
||||
<td class=hint>{{.Created}}</td>
|
||||
<td>{{.Fires}}</td>
|
||||
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
|
||||
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=text-max>{{.Payload}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The page showed the storage envelope and the UTC instant (Vikunja #469).
|
||||
func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
|
||||
fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
|
||||
rows := reminderRows([]ipc.Reminder{{
|
||||
CreatedTs: fire.Add(-time.Hour),
|
||||
FireTs: fire,
|
||||
Status: "pending",
|
||||
Payload: `{"text":"выпить таблетки"}`,
|
||||
}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Text != "выпить таблетки" {
|
||||
t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
|
||||
}
|
||||
if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
|
||||
t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
|
||||
}
|
||||
if strings.Contains(rows[0].Text, "{") {
|
||||
t.Errorf("Text still carries JSON: %q", rows[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// A payload that is not the envelope is his own words, so it is shown as it is.
|
||||
func TestReminderTextKeepsPlainPayload(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{`{"text":"позвонить маме"}`, "позвонить маме"},
|
||||
{" полить цветы ", "полить цветы"},
|
||||
{`{"body":"nope"}`, `{"body":"nope"}`},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := reminderText(tc.in); got != tc.want {
|
||||
t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -8,12 +8,12 @@
|
||||
"//disabled_rules": [
|
||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||
"water, meal, break, service_down, netdata_critical.",
|
||||
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
||||
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
||||
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
||||
"stays down is one nudge, not one every fifteen minutes."
|
||||
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
||||
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
||||
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
||||
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
||||
],
|
||||
"disabled_rules": [],
|
||||
"disabled_rules": ["service_down"],
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
|
||||
@@ -19,10 +19,6 @@ RestartSec=5
|
||||
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=60
|
||||
# llama-server aborts inside its own static teardown on SIGTERM, so every
|
||||
# routine yield used to write a multi-gigabyte core into systemd-coredump
|
||||
# (Vikunja #491). Yielding is meant to happen several times a day.
|
||||
LimitCORE=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"Maven": "Мэйвен",
|
||||
"Nexus": "Нексус",
|
||||
"Praxis": "Праксис",
|
||||
"Hexis": "Хексис",
|
||||
"Vikunja": "Викунья",
|
||||
"SearXNG": "сёрчиксэнджи",
|
||||
"Kiwix": "Кивикс",
|
||||
"Gitea": "Гитея",
|
||||
"Home Assistant": "Хоум Ассистент",
|
||||
"Docker": "Докер",
|
||||
"Telegram": "Телеграм",
|
||||
"ntfy": "энтифай",
|
||||
"homesrv": "хоумсёрв",
|
||||
"workpc": "воркписи",
|
||||
"whisper": "виспер",
|
||||
"piper": "пайпер",
|
||||
"llama-server": "лама сервер",
|
||||
"Qwen": "Квен",
|
||||
"CalDAV": "калдав",
|
||||
"IMAP": "аймап",
|
||||
"API": "эй-пи-ай",
|
||||
"CPU": "си-пи-ю",
|
||||
"GPU": "джи-пи-ю",
|
||||
"RAM": "оперативная память",
|
||||
"SSD": "эс-эс-ди",
|
||||
"uptime": "аптайм",
|
||||
"backup": "бэкап",
|
||||
"deploy": "деплой"
|
||||
}
|
||||
@@ -223,65 +223,6 @@ Not alternatives — layers:
|
||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||
|
||||
#### "второй" points at the list she just read
|
||||
|
||||
Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the
|
||||
slots and the history, and not the list. She recited five tasks, he said
|
||||
"второй", and the word had nothing to point at.
|
||||
|
||||
`Session.Candidates` holds what she just offered, bound at the moment she speaks
|
||||
it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would
|
||||
resolve the word against a fresh query, and the list changes between two turns.
|
||||
`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the
|
||||
candidate's kind.
|
||||
|
||||
An ordinal with no verb is read back, not acted on — "второй" names a task, it
|
||||
does not say what to do with it. With a verb ("первую сделал", "последнюю
|
||||
убери") the task moves and the list is spent, because a second ordinal against a
|
||||
list that no longer holds closes the wrong work. A position she never read is
|
||||
answered with how many she did read, not routed as a fresh sentence.
|
||||
|
||||
#### Saying she got it wrong is a feature
|
||||
|
||||
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
|
||||
corrected utterance as a new classifier example, and until now nothing in the
|
||||
daemon called it, so the mechanism existed and the behaviour did not.
|
||||
|
||||
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
|
||||
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
|
||||
things happen in one turn: the classifier learns the utterance under the named
|
||||
intent, the request is redone under it, and she says the correction landed. The
|
||||
utterance he is correcting TO is the one with no "не" in front of it.
|
||||
|
||||
Read before routing, next to the confirm and clarify turns, because a correction
|
||||
routed as a fresh utterance files the correction itself. One turn is correctable
|
||||
once, inside five minutes, and only turns she acted on — a clarify asked instead
|
||||
of acting, so there is nothing yet to be wrong about.
|
||||
|
||||
#### 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
|
||||
|
||||
One discriminator: **does the loop evaluate a predicate against it?**
|
||||
@@ -424,29 +365,6 @@ lives in `source`; rules trust provenance.
|
||||
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
||||
trigger.
|
||||
|
||||
#### A fact per monitor, not an aggregate
|
||||
|
||||
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
|
||||
It used to fold the whole gauge into a single boolean, and the nudge could then
|
||||
only say that something on homesrv was down. That is not something he can act
|
||||
on, so the rule shipped disabled.
|
||||
|
||||
Three things follow from the split:
|
||||
|
||||
- The key set is no longer known at wiring time. A rule declares
|
||||
`WantPrefixes` and the gatherer resolves the family per tick, which is the
|
||||
only prefix read in the loop.
|
||||
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
|
||||
silenced nothing, because some other monitor kept the boolean at "down".
|
||||
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
|
||||
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
|
||||
|
||||
The rule is also edge-triggered: it fires on a transition it has not already
|
||||
nudged about (`State.NudgedSince`). A polled fact is written only when the
|
||||
value changes, but the predicate reads the current value, so without the edge
|
||||
check a service that stays down qualifies on every tick and cooldown is the
|
||||
only brake.
|
||||
|
||||
### Presence — concrete scoring
|
||||
|
||||
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
||||
|
||||
@@ -90,13 +90,6 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
|
||||
|
||||
Without `-piper` it runs as a stub.
|
||||
|
||||
`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat
|
||||
JSON object of name to Russian spelling, applied to the text just before piper
|
||||
reads it. It is how `Vikunja` is said as a word rather than spelled out, and how
|
||||
`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that
|
||||
is set and unreadable stops mavttsd rather than letting it say names wrong in
|
||||
silence. Adding a name needs a restart of mavttsd and nothing else.
|
||||
|
||||
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
|
||||
|
||||
No CGo, no deps; builds with stock Go.
|
||||
|
||||
@@ -15,6 +15,7 @@ require github.com/kami/hexis v0.0.0
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -24,4 +25,8 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
|
||||
|
||||
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
|
||||
|
||||
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||
|
||||
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
||||
if fake.writes != 0 {
|
||||
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
||||
}
|
||||
_, err = cli.Chat(context.Background(), "web", "привет")
|
||||
_, err = cli.Chat(context.Background(), "привет")
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
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.Cleanup(func() { _ = cli.Close() })
|
||||
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||
reply, err := cli.Chat(context.Background(), "привет")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
||||
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++
|
||||
return "echo: " + text, nil
|
||||
}
|
||||
|
||||
@@ -68,19 +68,10 @@ var dayWords = map[string]int{
|
||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||
// time gets DefaultReminderDuration.
|
||||
//
|
||||
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
|
||||
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
|
||||
// none, so the zone has to come from somewhere else. A relay that posts
|
||||
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
|
||||
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
|
||||
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
|
||||
// honest reading of a bare wall clock.
|
||||
func EventFromNotification(n Notification) (Event, bool) {
|
||||
if n.Posted.IsZero() {
|
||||
return Event{}, false
|
||||
}
|
||||
n.Posted = n.Posted.In(time.Local)
|
||||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||
start, end, ok := parseTimeRange(line)
|
||||
if !ok {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A bare clock reading in a notification is read in the daemon's zone, so every
|
||||
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
|
||||
// the offset the 18:30 bug was measured at, so a regression shows up as four
|
||||
// hours rather than as nothing at all on a UTC runner.
|
||||
func TestMain(m *testing.M) {
|
||||
time.Local = time.FixedZone("+04", 4*3600)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestEventFromNotification(t *testing.T) {
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -108,10 +98,10 @@ func TestEventFromNotification(t *testing.T) {
|
||||
if !ev.End.After(ev.Start) {
|
||||
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||
}
|
||||
// The event lands on the day the phone showed it, in the daemon's
|
||||
// zone — the clock reading is a wall clock, not an instant.
|
||||
if ev.Start.Location() != time.Local {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
|
||||
// The event lands on the day the phone showed it, in the phone's
|
||||
// location — not shifted into UTC.
|
||||
if ev.Start.Location() != posted.Location() {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||
}
|
||||
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||
@@ -125,7 +115,8 @@ func TestEventFromNotification(t *testing.T) {
|
||||
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
||||
// wrong meeting stored is worse than nothing stored.
|
||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
|
||||
loc := time.FixedZone("+04", 4*3600)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -200,7 +191,7 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Title: "Завтра Планёрка 09:00",
|
||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
|
||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
@@ -210,31 +201,6 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
|
||||
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
|
||||
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
|
||||
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
|
||||
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Package: "com.slack",
|
||||
Title: "Standup",
|
||||
Text: "созвон в 14:30",
|
||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if got := ev.Start.Format("15:04"); got != "14:30" {
|
||||
t.Errorf("start = %s, want 14:30 local", got)
|
||||
}
|
||||
if ev.Start.Location() != time.Local {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
|
||||
}
|
||||
if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
|
||||
t.Errorf("fact key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||
t.Error("a notification with no posted_at has no date to sit on")
|
||||
@@ -245,7 +211,7 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Title: "Планёрка 10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
|
||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Fact sources. A calendar event reaches the store as a
|
||||
@@ -154,20 +153,14 @@ func Overlapping(events []Event, from, to time.Time) []Event {
|
||||
return out
|
||||
}
|
||||
|
||||
// safeKey makes a summary safe to use inside a fact key: letters and digits in
|
||||
// any script, plus dashes, with space and underscore folded to a dash.
|
||||
//
|
||||
// It kept ASCII only until 04-08-2026, and dropped everything else. His
|
||||
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
|
||||
// "--" and produced the same key on the same day — the second event of the day
|
||||
// silently overwrote the first (Vikunja #443). Letting the letters through is
|
||||
// what makes the key identify the event. Migration #18 drops the keys written
|
||||
// under the old rule; they are re-derived on the next poll.
|
||||
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||
// the day prefix carries the identity and this only disambiguates within a day.
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '_':
|
||||
b.WriteRune('-')
|
||||
|
||||
@@ -139,9 +139,6 @@ func TestSafeKey(t *testing.T) {
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
|
||||
{"Встреча с Аней", "Встреча-с-Аней"},
|
||||
{"Обед с мамой", "Обед-с-мамой"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := safeKey(tt.in); got != tt.want {
|
||||
@@ -266,19 +263,3 @@ func TestSourceTrust(t *testing.T) {
|
||||
t.Errorf("Sources() = %v", Sources())
|
||||
}
|
||||
}
|
||||
|
||||
// Two Russian events on one day must not share a key. They did: safeKey kept
|
||||
// ASCII only, so both summaries collapsed to their spaces and the second event
|
||||
// overwrote the first in the store (Vikunja #443).
|
||||
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
|
||||
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
|
||||
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
|
||||
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
|
||||
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
|
||||
}
|
||||
// The day prefix still has to survive, because the store range-scans on it.
|
||||
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
|
||||
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +149,10 @@ type Config struct {
|
||||
//
|
||||
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
||||
// this only subtracts. It exists because a rule can be right in principle
|
||||
// and useless in practice. service_down was the case that forced it: it
|
||||
// could not name the service it was nudging about, so being told "a service
|
||||
// on homesrv is down" every fifteen minutes was noise with no action
|
||||
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
||||
// enabled again. The escape hatch stays.
|
||||
// and useless in practice — kuma's service_down cannot name the service it
|
||||
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
||||
// down" every fifteen minutes is noise with no action attached. Turning it
|
||||
// off beats learning to ignore her.
|
||||
//
|
||||
// A disabled rule is never gathered for, never evaluated, and never
|
||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||
@@ -602,9 +601,6 @@ type MorningRoutineItemConfig struct {
|
||||
Key string `json:"key"`
|
||||
FactKey string `json:"fact_key"`
|
||||
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
|
||||
@@ -1738,7 +1734,7 @@ func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
||||
for i, r := range mc {
|
||||
items := make([]morning.Item, len(r.Items))
|
||||
for j, it := range r.Items {
|
||||
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
|
||||
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
|
||||
}
|
||||
weekdays := make([]time.Weekday, len(r.Weekdays))
|
||||
for j, w := range r.Weekdays {
|
||||
|
||||
@@ -57,14 +57,6 @@ func (q *PendingQuestion) CanAsk() bool {
|
||||
|
||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||
// 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 {
|
||||
mu sync.RWMutex
|
||||
questions map[string]*PendingQuestion
|
||||
|
||||
@@ -42,27 +42,12 @@ type Turn struct {
|
||||
Text string // raw utterance
|
||||
}
|
||||
|
||||
// Candidate — one item she just read out loud, kept so his next words can
|
||||
// pick it ("второй", "первую сделал"). Vikunja #448.
|
||||
//
|
||||
// Bound at the moment she speaks the list, not resolved afterwards: the list
|
||||
// can change between two turns, and "второй" means the second thing she said,
|
||||
// not the second row of a fresh query.
|
||||
type Candidate struct {
|
||||
Kind string // what it is, e.g. "task" — the resolver dispatches on this
|
||||
Ref int64 // the row it points at
|
||||
Label string // what she called it, so she can repeat it back
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Intent Intent
|
||||
Slots Slots
|
||||
Timestamp time.Time
|
||||
TTL time.Duration
|
||||
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
|
||||
// Candidates — the list she just offered, in the order she said it. Empty
|
||||
// on every turn that offered no choice, which is most of them.
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
func (s *Session) IsExpired(now time.Time) bool {
|
||||
@@ -158,26 +143,6 @@ func (s *SessionStore) Put(id string, sess *Session) {
|
||||
s.save(id, sess)
|
||||
}
|
||||
|
||||
// SetCandidates attaches a just-spoken list to the live session.
|
||||
//
|
||||
// In place rather than through Put, because the turn was already remembered by
|
||||
// the time the answer was built: replacing the session here would drop the
|
||||
// slots the next follow-up inherits. No session, no candidates — a choice with
|
||||
// no turn behind it has nothing to be a choice about.
|
||||
func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) {
|
||||
s.mu.Lock()
|
||||
sess, ok := s.sessions[id]
|
||||
if ok && !sess.IsExpired(now) {
|
||||
sess.Candidates = cands
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
s.save(id, sess)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(id string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, id)
|
||||
|
||||
+2
-35
@@ -60,19 +60,6 @@ type Nudge struct {
|
||||
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.
|
||||
// Score is set by QueryNotes (0 on the write path).
|
||||
type Note struct {
|
||||
@@ -534,12 +521,6 @@ type outcomesReq struct {
|
||||
type nReq struct {
|
||||
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 {
|
||||
Kind string `json:"kind"`
|
||||
N int `json:"n"`
|
||||
@@ -612,14 +593,8 @@ type MCPServerStatus struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Text string `json:"text"`
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
@@ -704,9 +679,6 @@ type CoreAPI interface {
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, 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
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
@@ -787,12 +759,7 @@ type CoreAPI interface {
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// 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)
|
||||
Chat(ctx context.Context, text string) (string, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
|
||||
+2
-11
@@ -68,7 +68,6 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodRecentActiveFacts: true,
|
||||
MethodCalendarEvents: true,
|
||||
MethodRecentNudges: true,
|
||||
MethodDeliveryAttempts: true,
|
||||
MethodRecentEcoTraces: true,
|
||||
MethodQueryNotes: true,
|
||||
MethodRecentNotes: true,
|
||||
@@ -374,14 +373,6 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT
|
||||
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) {
|
||||
var out []Nudge
|
||||
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
||||
@@ -632,9 +623,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||
var r chatResp
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Reply, nil
|
||||
|
||||
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { _ = cli.Close() })
|
||||
|
||||
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||
reply, err := cli.Chat(context.Background(), "привет")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
@@ -417,7 +417,7 @@ type chatTestAPI struct {
|
||||
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 == "привет" {
|
||||
return "и тебе привет!", nil
|
||||
}
|
||||
|
||||
+2
-31
@@ -173,25 +173,6 @@ func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
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) {
|
||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||
return id, mapErr(err)
|
||||
@@ -259,7 +240,7 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return newID, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", errors.New("store: chat not available via direct store API")
|
||||
}
|
||||
|
||||
@@ -882,16 +863,6 @@ var methodTable = map[Method]handlerFunc{
|
||||
}
|
||||
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) {
|
||||
out, err := api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
@@ -1003,7 +974,7 @@ var methodTable = map[Method]handlerFunc{
|
||||
return map[string]int64{"new_id": newID}, nil
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
reply, err := api.Chat(ctx, p.Text)
|
||||
return chatResp{Reply: reply}, err
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
|
||||
@@ -68,9 +68,6 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st
|
||||
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
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) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
@@ -144,6 +141,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ const (
|
||||
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
||||
MethodCalendarEvents Method = "calendar_events"
|
||||
MethodRecentNudges Method = "recent_nudges"
|
||||
MethodDeliveryAttempts Method = "delivery_attempts"
|
||||
MethodRecentEcoTraces Method = "recent_ecosystem_traces"
|
||||
MethodWriteNote Method = "write_note"
|
||||
MethodQueryNotes Method = "query_notes"
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{Now: now, Presence: store.Away,
|
||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||
}
|
||||
r := ServiceDownRule() // Sev4 ops
|
||||
passed, blocked, d := ExplainGate(s, r)
|
||||
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
},
|
||||
}
|
||||
cand, trace := ExplainTick(s, DefaultRules())
|
||||
|
||||
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||
|
||||
@@ -91,20 +91,6 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
return State{}, nil, err
|
||||
}
|
||||
|
||||
// prefix families — the keys a rule cannot name at wiring time (one fact
|
||||
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
|
||||
for _, r := range g.rules {
|
||||
for _, p := range r.WantPrefixes {
|
||||
fam, err := g.store.LatestFactsByPrefix(ctx, p)
|
||||
if err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
for _, f := range fam {
|
||||
facts[f.Key] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// last nudge per rule + cooldown-until derived from the active cooldown.
|
||||
// "active" = the feedback tuner's persisted base if one exists, else the
|
||||
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The gatherer is the only impure piece, and a rule over a prefix has no keys
|
||||
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
|
||||
// facts reach the snapshot, and the rule fires on the one that is down.
|
||||
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
for key, val := range map[string]string{
|
||||
"service_down:db": "down",
|
||||
"service_down:web": "up",
|
||||
} {
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
||||
t.Fatalf("SetValue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
rules := []Rule{ServiceDownRule()}
|
||||
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if _, ok := st.Facts["service_down:db"]; !ok {
|
||||
t.Fatalf("prefix family not gathered: %v", st.Facts)
|
||||
}
|
||||
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
|
||||
t.Fatalf("DownServices = %v, want [db]", got)
|
||||
}
|
||||
if !rules[0].Predicate(st) {
|
||||
t.Fatal("the rule must fire on a gathered per-monitor fact")
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
||||
Presence: store.Away,
|
||||
QuietHours: true,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||
},
|
||||
}
|
||||
if got := Tick(s, DefaultRules()); got != nil {
|
||||
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
|
||||
+13
-55
@@ -28,13 +28,6 @@ type Rule struct {
|
||||
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
||||
// needs and the gate will skip the rule when any are missing.
|
||||
InertWhenNoData []string
|
||||
|
||||
// WantPrefixes — key prefixes whose whole family the gatherer must load.
|
||||
// InertWhenNoData names keys that exist at wiring time; a rule over a key
|
||||
// set that is only known at read time (one fact per kuma monitor) declares
|
||||
// the prefix here instead. Prefixes never make a rule inert: an empty
|
||||
// family is the predicate's own "no data" case.
|
||||
WantPrefixes []string
|
||||
}
|
||||
|
||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||
@@ -105,58 +98,23 @@ func BreakRule() Rule {
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
|
||||
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
|
||||
const ServiceDownPrefix = "service_down:"
|
||||
|
||||
// ServiceDownSource — kuma is the source of truth for service up/down. The
|
||||
// rule is provenance-scoped: a poller writing under a different source cannot
|
||||
// forge the trigger.
|
||||
const ServiceDownSource = "poll:uptimekuma"
|
||||
|
||||
// DownServices — the monitors currently reading "down", by name, in key order.
|
||||
//
|
||||
// Pure, and the rule and the phraser both call it, so the message can never
|
||||
// name a service the predicate did not fire on.
|
||||
func DownServices(s State) []string {
|
||||
var out []string
|
||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||
if f.Source == ServiceDownSource && f.Value == `"down"` {
|
||||
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
|
||||
//
|
||||
// It used to read one aggregate `service_down` fact, which is why it was
|
||||
// disabled in deploy: the nudge could say that something on homesrv was down
|
||||
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
|
||||
// kuma now silences that monitor rather than nothing.
|
||||
//
|
||||
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
|
||||
// for a day qualifies on every tick and cooldown alone is the only brake.
|
||||
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
|
||||
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
|
||||
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
|
||||
// a compromised poller writing under a different source can't forge the trigger.
|
||||
func ServiceDownRule() Rule {
|
||||
return Rule{
|
||||
Name: "service_down",
|
||||
Severity: Sev4,
|
||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||
WantPrefixes: []string{ServiceDownPrefix},
|
||||
Name: "service_down",
|
||||
Severity: Sev4,
|
||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||
InertWhenNoData: []string{"service_down"},
|
||||
Predicate: func(s State) bool {
|
||||
var newest time.Time
|
||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
||||
continue
|
||||
}
|
||||
if f.Ts.After(newest) {
|
||||
newest = f.Ts
|
||||
}
|
||||
f, ok := s.Fact("service_down")
|
||||
if !ok || f.Ts.IsZero() {
|
||||
return false
|
||||
}
|
||||
if newest.IsZero() {
|
||||
return false // nothing down, or no data at all → shut up
|
||||
}
|
||||
return !s.NudgedSince("service_down", newest)
|
||||
// value is json `"down"`; trivial check keyed off source provenance.
|
||||
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+15
-80
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
|
||||
{
|
||||
name: "service_down fires on a kuma down fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet when kuma says up",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
|
||||
{
|
||||
name: "service_down quiet on a zero-timestamp fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||
want: false,
|
||||
},
|
||||
// forgery attempts — right value, wrong writer.
|
||||
{
|
||||
name: "service_down refuses a forgery from the netdata poller",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from ambient audio",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from the user's own voice",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a source that only looks like kuma",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses an unquoted down value",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
@@ -312,11 +312,9 @@ func TestOpsRulePredicates(t *testing.T) {
|
||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||
// even if its predicate happens to check.
|
||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||
// A rule over a key set that only exists at read time declares a prefix
|
||||
// instead — the gatherer still needs to be told what to load.
|
||||
for _, r := range DefaultRules() {
|
||||
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
|
||||
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
|
||||
if len(r.InertWhenNoData) == 0 {
|
||||
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,11 +377,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
||||
// hidden state, no clock reads.
|
||||
func TestPredicatesArePure(t *testing.T) {
|
||||
s := stateWith(map[string]store.Fact{
|
||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
})
|
||||
for _, r := range DefaultRules() {
|
||||
first := r.Predicate(s)
|
||||
@@ -436,66 +434,3 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
||||
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- per-monitor service_down -----------------------
|
||||
|
||||
// The rule must name what fired on it, and the phraser reads the same helper,
|
||||
// so a service that is up can never be spoken as down.
|
||||
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
||||
s := State{
|
||||
Now: refTime(),
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
||||
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
||||
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
||||
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
||||
},
|
||||
}
|
||||
got := DownServices(s)
|
||||
want := []string{"db", "vault"} // key order, so speech is stable
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
|
||||
// the aggregate stayed "down" and pausing achieved nothing.
|
||||
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
||||
base := map[string]store.Fact{
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
||||
}
|
||||
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||
t.Fatal("web is still down, the rule must fire")
|
||||
}
|
||||
delete(base, "service_down:web")
|
||||
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||
t.Fatal("only a paused monitor is left, the rule must be quiet")
|
||||
}
|
||||
}
|
||||
|
||||
// Edge-triggered: he is told once per transition. A service that stays down
|
||||
// for a day used to qualify on every tick, with cooldown as the only brake.
|
||||
func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
||||
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
|
||||
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
|
||||
if !ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("first sight of the transition must fire")
|
||||
}
|
||||
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
|
||||
if ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("already told about this transition, must be quiet")
|
||||
}
|
||||
// A second service goes down after that nudge — a new edge, so it fires.
|
||||
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
||||
if !ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("a later transition must fire again")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -97,32 +95,6 @@ func (s State) Fact(key string) (store.Fact, bool) {
|
||||
return f, true
|
||||
}
|
||||
|
||||
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
|
||||
// by key so a caller that names them speaks them in a stable order. Facts with
|
||||
// a zero Ts are skipped, the same "no data" rule Fact applies.
|
||||
func (s State) FactsUnder(prefix string) []store.Fact {
|
||||
var out []store.Fact
|
||||
for k, f := range s.Facts {
|
||||
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// NudgedSince reports whether rule already sent a nudge at or after ts.
|
||||
//
|
||||
// It is what makes a rule edge-triggered. A polled fact is written only when
|
||||
// the value changes, so its Ts is the moment the service went down — but the
|
||||
// predicate reads the current value, so a service that stays down keeps
|
||||
// qualifying forever and cooldown alone only slows the repetition. Asking
|
||||
// whether he was already told about THIS transition stops it.
|
||||
func (s State) NudgedSince(rule string, ts time.Time) bool {
|
||||
n, ok := s.LastNudge[rule]
|
||||
return ok && !n.Ts.Before(ts)
|
||||
}
|
||||
|
||||
// Since returns the duration since the latest fact for key, or (0,false).
|
||||
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||||
func (s State) Since(key string) (time.Duration, bool) {
|
||||
|
||||
+30
-20
@@ -3,8 +3,11 @@ package memory
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||
@@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
day := weekdayRU[int(wd)%7]
|
||||
acts := p.Weekly[wd]
|
||||
if len(acts) > 0 {
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
|
||||
}
|
||||
if len(p.Everyday) > 0 {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
return say.S(say.HabitWeekdaySame, map[string]string{
|
||||
"day": day, "items": joinActivities(p.Everyday),
|
||||
})
|
||||
}
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
|
||||
}
|
||||
|
||||
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
||||
@@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string {
|
||||
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||
switch {
|
||||
case len(sat) > 0 && len(sun) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
||||
joinActivities(sat), joinActivities(sun))
|
||||
return say.S(say.HabitWeekendBoth, map[string]string{
|
||||
"sat": joinActivities(sat), "sun": joinActivities(sun),
|
||||
})
|
||||
case len(sat) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
||||
joinActivities(sat))
|
||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||
case len(sun) > 0:
|
||||
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
||||
joinActivities(sun))
|
||||
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
||||
case len(p.Everyday) > 0:
|
||||
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
joinActivities(p.Everyday))
|
||||
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
||||
}
|
||||
return "по выходным я пока не вижу у тебя ничего постоянного."
|
||||
return say.S(say.HabitWeekendNone, nil)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
||||
@@ -378,19 +380,23 @@ func (p Profile) FormatWeekendRU() string {
|
||||
// a year of them, and only one of those is worth believing.
|
||||
func (p Profile) FormatOverallRU() string {
|
||||
if len(p.All) == 0 {
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
return say.S(say.HabitOverallNone, nil)
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
|
||||
return say.S(say.HabitOverall, map[string]string{
|
||||
"items": joinActivities(p.All), "span": p.spanRU(),
|
||||
})
|
||||
}
|
||||
|
||||
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
||||
// is too short to name in days.
|
||||
func (p Profile) spanRU() string {
|
||||
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
||||
return "по записям за сегодня"
|
||||
return say.S(say.HabitSpanToday, nil)
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
||||
return say.S(say.HabitSpanDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
|
||||
})
|
||||
}
|
||||
|
||||
// pluralDaysRU — the Russian count form of "день" for n.
|
||||
@@ -423,14 +429,18 @@ func joinActivities(acts []Activity) string {
|
||||
// come from the model, so an unglossed one is as likely to be
|
||||
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
||||
// not a sentence.
|
||||
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||
gloss = say.S(say.HabitUnglossed, map[string]string{
|
||||
"key": strings.ReplaceAll(a.Key, "_", " "),
|
||||
})
|
||||
}
|
||||
if !a.HasTypical {
|
||||
parts[i] = gloss
|
||||
continue
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
parts[i] = say.S(say.HabitAt, map[string]string{
|
||||
"gloss": gloss,
|
||||
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
|
||||
})
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||
@@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||
}
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
|
||||
t.Errorf("empty profile reads %q", got)
|
||||
}
|
||||
}
|
||||
@@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
// A day with nothing of its own says so rather than reciting water as if
|
||||
// Wednesday were the reason for it.
|
||||
wed := p.FormatWeekdayRU(time.Wednesday)
|
||||
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
|
||||
if !say.IsS(say.HabitWeekdaySame, map[string]string{
|
||||
"day": "средам", "items": "пьёшь воду около 13:30",
|
||||
}, wed) {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,44 +90,9 @@ func Load() (Fixture, error) {
|
||||
if len(f.Cases) == 0 {
|
||||
return Fixture{}, fmt.Errorf("fixture has no cases")
|
||||
}
|
||||
if err := checkIDs(f); err != nil {
|
||||
return Fixture{}, err
|
||||
}
|
||||
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.
|
||||
// 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.
|
||||
|
||||
@@ -337,28 +337,3 @@ func marginSweep(t *testing.T, emb router.Embedder, f Fixture) 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,18 +30,6 @@ type Item struct {
|
||||
Key string
|
||||
FactKey string
|
||||
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
|
||||
@@ -72,37 +60,12 @@ type Status struct {
|
||||
}
|
||||
|
||||
// Candidate — a routine that's due for its one-per-day nag: the window has
|
||||
// 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.
|
||||
// reached NudgeAt and at least one item is still unevidenced.
|
||||
type Candidate struct {
|
||||
Routine Routine
|
||||
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
|
||||
// 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
|
||||
@@ -228,11 +191,7 @@ func Due(routines []Routine, facts map[string]store.Fact, last map[string]time.T
|
||||
missing = append(missing, it)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
if len(missing) == 0 {
|
||||
continue
|
||||
}
|
||||
if prev, seen := last[r.Name]; seen && sameDay(prev, now) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user