Merge task/467 so the sweep tail can reach the three mechanisms (V-528)

attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.

Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".

The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.

Routing fixture 58/82, unchanged from both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:00:32 +04:00
62 changed files with 2658 additions and 80 deletions
+12
View File
@@ -189,6 +189,18 @@ 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
+22 -3
View File
@@ -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 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 deps-sentinel tidy 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-go: deps-sentinel
@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,6 +84,25 @@ deps-go:
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.
@@ -178,7 +197,7 @@ run-tts: build-tts
./mavttsd -socket /tmp/maven/tts.sock \
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
deps: deps-whisper deps-piper
deps: deps-sentinel deps-whisper deps-piper
deps-whisper:
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
+3
View File
@@ -58,6 +58,9 @@ 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.
+15
View File
@@ -39,6 +39,21 @@ 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,
+38 -3
View File
@@ -81,6 +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
@@ -91,6 +96,12 @@ var querySources = []querySource{
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
{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
@@ -145,6 +156,13 @@ 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
}
}
@@ -283,9 +301,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
return "", false
}
if !h.feedsOn {
// Claim the turn rather than fall through: "не читаю ленты" is true, and
// letting general knowledge answer "что нового?" would be an invented
// news bulletin.
// 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 phraser.Q(phraser.QueryFeedsOff, nil), true
}
// By source, not the last 200 notes of any kind: a busy day of voice notes
@@ -322,6 +348,15 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
// 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
+4 -1
View File
@@ -25,7 +25,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
return phraser.Ack(phraser.FailReminderTime, nil)
}
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
// 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)) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return phraser.Ack(phraser.FailReminder, nil)
+11 -1
View File
@@ -4,6 +4,7 @@ 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"
@@ -71,7 +72,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: list tasks: %v", err)
return "не получилось посмотреть задачи.", true
}
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), 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
}
// taskItems maps wire rows onto the ranker's input. Written here rather than in
+68
View File
@@ -0,0 +1,68 @@
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
}
+77
View File
@@ -0,0 +1,77 @@
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")
}
}
+9 -16
View File
@@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
router.IntentAct: {dialogue.SlotFn},
}
// 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: "Что сделать?",
}
// 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.
// 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
@@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
if len(missing) == 0 {
return "", "", false
}
q, ok := clarifyQuestions[missing[0]]
q, ok := clarifyQuestionFor(missing[0], 1)
if !ok {
return "", "", false
}
@@ -266,7 +257,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
if len(remaining) == 0 {
return "", false
}
question, ok := clarifyQuestions[remaining[0]]
// 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)
if !ok || !q.CanAsk() {
return "", false
}
@@ -290,7 +283,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
question := ""
if len(q.Missing) > 0 {
question = clarifyQuestions[q.Missing[0]]
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
}
if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
+22 -8
View File
@@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
if !handled {
t.Fatalf("answer %d must be consumed as an answer", i)
}
if reply != "Когда?" {
t.Fatalf("attempt %d should ask again, got %q", i, reply)
// 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 h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
t.Fatalf("attempt %d must leave the question armed", i)
@@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
if !handled {
t.Fatal("the answer must be consumed as an answer")
}
if reply != "Когда?" {
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
// 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)
}
q := h.clarifyStore.Get(voiceDialogueID, h.now())
if q == nil {
@@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
eval.CheckCringe: true,
}
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
for _, q := range clarifyQuestions {
lines = append(lines, q)
lines = append(lines, clarifyMissedVariants...)
for _, variants := range clarifyQuestionVariants {
lines = append(lines, variants...)
}
for _, line := range lines {
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
@@ -497,8 +507,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
if !strings.Contains(reminders[0].Payload, "11") {
t.Fatalf("the answer clobbered the original request: %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())
}
}
+109
View File
@@ -0,0 +1,109 @@
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)
}
+63
View File
@@ -0,0 +1,63 @@
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")
}
}
+45
View File
@@ -123,3 +123,48 @@ 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)
}
}
+19
View File
@@ -200,3 +200,22 @@ 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)
}
}
+117
View File
@@ -0,0 +1,117 @@
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"))
}
+104
View File
@@ -0,0 +1,104 @@
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)
}
}
+145
View File
@@ -0,0 +1,145 @@
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
}
+128
View File
@@ -0,0 +1,128 @@
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)
}
+53
View File
@@ -0,0 +1,53 @@
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
}
+21
View File
@@ -0,0 +1,21 @@
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)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
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 + " — запомнила."
}
+119
View File
@@ -0,0 +1,119 @@
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")
}
}
+6 -1
View File
@@ -24,7 +24,12 @@ 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 {
return r.stub.Reply(d)
// 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)
}
out, err := r.p.PhraseReply(context.Background(), d)
if err != nil || out == "" {
+14 -2
View File
@@ -38,9 +38,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
}
func TestLLMReplierClarifyUsesStub(t *testing.T) {
// 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) {
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
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")
}
}
// assertAck — the stub picks between variants now, so two calls to it are not
+24
View File
@@ -167,6 +167,7 @@ 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
@@ -305,6 +306,22 @@ 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
@@ -365,6 +382,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
}
}
// 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.
+10 -3
View File
@@ -382,9 +382,12 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
// question first and a narrative request second (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
// 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())
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
@@ -392,6 +395,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// 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.NarrativeQueryGrammars()...)
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
+33 -1
View File
@@ -1,6 +1,11 @@
package main
import "testing"
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).
@@ -31,3 +36,30 @@ func TestExtractWeatherLocation(t *testing.T) {
}
}
}
// 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")
}
}
+14 -1
View File
@@ -23,6 +23,7 @@ import (
"syscall"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -39,15 +40,27 @@ 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)
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
} else {
log.Printf("mavttsd: no piper/model specified, using stub handler")
+16
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -117,3 +118,18 @@ 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")
}
}
+15 -2
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -18,15 +19,20 @@ 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) *piperHandler {
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
return &piperHandler{
piperPath: piperPath,
modelPath: modelPath,
configPath: modelPath + ".json",
espeakData: espeakData,
tashkeelModel: tashkeelModel,
lexicon: lexicon,
}
}
@@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
}
if _, err := io.WriteString(stdin, req.Text); err != nil {
// 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 {
stdin.Close()
stdout.Close()
_ = cmd.Wait()
+7 -1
View File
@@ -139,7 +139,13 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
}
func TestHandleAmbientAuth(t *testing.T) {
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
// 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))
newReq := func(hdr, val string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
+46 -1
View File
@@ -946,6 +946,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
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)
@@ -959,7 +1004,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": reminders}); err != nil {
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
log.Printf("reminders template: %v", err)
}
}
+3 -3
View File
@@ -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>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
<td class=hint>{{.Created}}</td>
<td>{{.Fires}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=text-max>{{.Payload}}</td>
<td class=text-max>{{.Text}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
+46
View File
@@ -0,0 +1,46 @@
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)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"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": "деплой"
}
+35
View File
@@ -223,6 +223,41 @@ 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
+7
View File
@@ -90,6 +90,13 @@ 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.
-4
View File
@@ -28,8 +28,4 @@ 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
+9
View File
@@ -58,10 +58,19 @@ const ambientPastGrace = 2 * time.Hour
// 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 {
+43 -9
View File
@@ -1,12 +1,22 @@
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.FixedZone("+04", 4*3600))
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
tests := []struct {
name string
@@ -98,10 +108,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 phone's
// location — not shifted into UTC.
if ev.Start.Location() != posted.Location() {
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
// 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)
}
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)
@@ -115,8 +125,7 @@ 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) {
loc := time.FixedZone("+04", 4*3600)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
tests := []struct {
name string
@@ -191,7 +200,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.UTC),
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
})
if !ok {
t.Fatal("expected an event")
@@ -201,6 +210,31 @@ 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")
@@ -211,7 +245,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.UTC),
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
})
if !ok {
t.Fatal("expected an event")
+35
View File
@@ -42,12 +42,27 @@ 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 {
@@ -143,6 +158,26 @@ 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)
+21 -2
View File
@@ -91,6 +91,15 @@ type Config struct {
NCtx int
Timeout time.Duration
// StartupTimeout bounds the wait for llama-server to print the address it
// listens on. A config field and not a constant because the box may
// legitimately need longer: a cold 1.7B loading off a spinning disk can
// outrun a minute, and until this existed that returned "server did not
// start within 60s" with no way to raise it.
//
// 0 ⇒ defaultStartupTimeout.
StartupTimeout time.Duration
// CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate
// this box. Measured on homesrv 2026-08-03: the server's own default limit is
// 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB
@@ -140,6 +149,8 @@ func DefaultConfig(modelPath string) Config {
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
CacheRAMMiB: 512,
Timeout: 30 * time.Second,
StartupTimeout: defaultStartupTimeout,
}
}
@@ -266,7 +277,15 @@ func llamaArgs(cfg Config) []string {
return args
}
// defaultStartupTimeout — the wait for llama-server's listen line when Config
// does not set one. A cold model load off disk is the slow part.
const defaultStartupTimeout = 60 * time.Second
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
startupTimeout := cfg.StartupTimeout
if startupTimeout <= 0 {
startupTimeout = defaultStartupTimeout
}
p := &llamaProc{}
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
@@ -344,8 +363,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
case <-ctx.Done():
return fail(ctx.Err())
case <-time.After(60 * time.Second):
return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String()))
case <-time.After(startupTimeout):
return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String()))
}
}
+31
View File
@@ -181,6 +181,37 @@ exit 1`)
t.Fatalf("err = %v, want context.Canceled", err)
}
})
// The last arm of the startup race, and the one most likely to leak: a
// llama-server still loading a model is alive, so giving up on it without
// killing and reaping it orphans a process holding the GPU. Testable at all
// because Config.StartupTimeout replaced a hardcoded 60s (Vikunja #323).
t.Run("startup timeout", func(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "pid")
bin := fakeLlama(t, fmt.Sprintf(`echo $$ > %s
while : ; do sleep 1 ; done`, pidPath))
cfg := testCfg(bin)
cfg.StartupTimeout = 200 * time.Millisecond
_, err := startLlamaProc(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "did not start within 200ms") {
t.Fatalf("err = %v, want the startup-timeout arm naming the timeout", err)
}
raw, readErr := os.ReadFile(pidPath)
if readErr != nil {
t.Fatalf("fake server never recorded its pid: %v", readErr)
}
pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw)))
if convErr != nil {
t.Fatalf("pid file = %q: %v", raw, convErr)
}
// Killed, and reaped: a zombie still answers signal 0, so this asserts
// the Wait ran too.
if err := syscall.Kill(pid, 0); err == nil {
t.Errorf("llama-server %d survived the startup timeout", pid)
}
})
}
func TestNewLLMPhraserSpawns(t *testing.T) {
+79
View File
@@ -0,0 +1,79 @@
package router
import "strings"
// transientStems — the states a thing is in for an afternoon. Compared as
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
// "медленный" and "медленно" without listing them.
var transientStems = []string{
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
"отвал", "падает", "упал", "сдох", "греется", "перегре",
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
}
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
// "не работает", "не грузит", "не открывается". Prefixes again.
var brokenVerbs = []string{
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
"work", "load", "connect", "respond",
}
// selfMarkers — the words that make a sentence about him rather than about a
// thing. Their presence turns the test off, because losing a fact he meant to
// store is worse than keeping a complaint: "я сломал руку" is durable, and
// "интернет не работает" is not.
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
// IsTransientComplaint reports whether text observes a passing state of some
// thing rather than recording a fact.
//
// It exists because "сеть какая-то медленная" and "интернет не работает" were
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
// where recall reads them back later as if they were still true. A complaint
// describes a moment; the fact store describes him.
//
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
// explicit capture verb wins over everything, because "запомни что интернет
// не работает" is an instruction and not a passing remark. A first-person
// marker also turns it off — the test is meant to catch a sentence about a
// thing, and it errs toward storing.
func IsTransientComplaint(text string) bool {
t := strings.TrimSpace(text)
if t == "" {
return false
}
toks := planTokens(strings.ToLower(t))
for _, v := range captureVerbs {
if hasTok(toks, v) {
return false
}
}
for _, m := range selfMarkers {
if hasTok(toks, m) {
return false
}
}
for _, tok := range toks {
for _, stem := range transientStems {
if strings.HasPrefix(tok, stem) {
return true
}
}
}
// "не" plus a verb of working, in either order of the two tokens that
// follow it — "не работает" and "не очень работает" both deny the same
// thing.
for i, tok := range toks {
if tok != "не" && tok != "not" && tok != "isn" {
continue
}
for j := i + 1; j < len(toks) && j <= i+2; j++ {
for _, v := range brokenVerbs {
if strings.HasPrefix(toks[j], v) {
return true
}
}
}
}
return false
}
+35
View File
@@ -0,0 +1,35 @@
package router
import "testing"
func TestIsTransientComplaint(t *testing.T) {
for _, tc := range []struct {
text string
want bool
}{
// The two rows from the QA run that named this bug.
{"сеть какая-то медленная", true},
{"интернет не работает", true},
{"вайфай тормозит", true},
{"сервер завис", true},
{"the wifi is slow", true},
// An instruction wins: he asked for it to be written down.
{"запомни что интернет не работает", false},
{"запиши что сеть медленная", false},
// About him, so it stays a fact even when it sounds like a complaint.
{"я сломал руку", false},
{"мне медленно думается", false},
// Ordinary captures must not be touched.
{"поужинал", false},
{"выпил воды", false},
{"машина на парковке", false},
{"", false},
} {
if got := IsTransientComplaint(tc.text); got != tc.want {
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
+3
View File
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
// or dateparser is unavailable, falls back to the stub parser. Returns
// (time, true, nil) on success; (zero, false, nil) when no date is found.
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
// Speech says the hour in words, and neither this parser nor the stub
// reads "в семь вечера" (Vikunja #469). Both see the digits instead.
text = SpellOutDigits(text)
t, ok, err := p.parseWithPython(ctx, text, now)
if err != nil {
// python3 missing, dateparser not installed, or process failure —
+8 -1
View File
@@ -236,8 +236,15 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.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())
grammars = append(grammars, router.ReminderGrammar())
grammars = append(grammars, router.TaskCaptureGrammar())
// "расскажи про X" is a world question the model called a fact, and the
// rule goes last because it matches on the first word alone (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
+2
View File
@@ -25,6 +25,8 @@
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
+55
View File
@@ -0,0 +1,55 @@
package router
import (
"context"
"testing"
)
func feedRouter(t *testing.T) *Router {
t.Helper()
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
r.grammars = append(r.grammars, FeedQueryGrammar())
return r
}
// The documented utterance of task 258 step 1 routed system and answered
// "пока не умею отвечать на этот вопрос.", while the same question worded with
// "новостях" worked (Vikunja #474).
func TestFeedQuestionsRouteToQuery(t *testing.T) {
r := feedRouter(t)
for _, u := range []string{
"что нового в лентах?",
"что в лентах?",
"расскажи что в новостных лентах",
"покажи ленту",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Intent != IntentQuery {
t.Errorf("route(%q) = %s, want query", u, d.Intent)
}
}
}
// The greeting and the statement keep their intents. "что нового?" is the most
// common opener in the language, and a rule that claimed it would answer hello
// with a configuration status.
func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) {
r := feedRouter(t)
for _, u := range []string{
"что нового?",
"у меня новая лента в инстаграме",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Stage == 0 {
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package router
import (
"context"
"testing"
)
// narrativeRouter wires the grammars in the order the daemon wires them
// (voicewire.go), with the narrative rule last — so a test that passes here is
// a test of the deployed precedence, not of the rule in isolation.
func narrativeRouter(t *testing.T) *Router {
t.Helper()
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
r.grammars = append(r.grammars, TaskListGrammar())
r.grammars = append(r.grammars, TaskCaptureGrammar())
r.grammars = append(r.grammars, NarrativeQueryGrammars()[1])
return r
}
// "расскажи про X" and "что дальше?" carried no question mark and no
// interrogative, so nothing at stage 0 claimed them and the model called both
// facts (Vikunja #498, point 1 of #470). The fact write is contained now, but
// the round trip and the wrong fixture score are not.
func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) {
r := narrativeRouter(t)
for _, u := range []string{
"расскажи про битву при Ватерлоо",
"расскажи мне про Юникод",
"объясни как работает tcp",
"опиши Самару",
"перечисли планеты",
"tell me about the fall of Rome",
"что дальше?",
"и что там дальше",
"что дальше",
"what's next?",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Intent != IntentQuery {
t.Errorf("route(%q) = %s, want query", u, d.Intent)
}
if d.Stage != 0 {
t.Errorf("route(%q) decided at stage %d, want 0 — the point is to skip the model", u, d.Stage)
}
}
}
// The narrative rule must not take a turn that belongs to something else. A
// capture marker wins because it is what he said, and asking her for a joke is
// chat: the query chain has no source that answers it.
func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
r := narrativeRouter(t)
for _, u := range []string{
"расскажи анекдот",
"расскажи о себе",
"расскажи шутку",
"расскажи",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Stage == 0 && d.Intent == IntentQuery {
t.Errorf("route(%q) was claimed as a world question at stage 0", u)
}
}
}
// The topic reaches the query chain without the verb that introduced it: the
// search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо".
func TestNarrativeGrammarKeepsTheTopic(t *testing.T) {
r := narrativeRouter(t)
d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow())
if err != nil {
t.Fatal(err)
}
if got, want := d.Slots.Text, "битву при Ватерлоо"; got != want {
t.Errorf("text = %q, want %q", got, want)
}
}
+97
View File
@@ -0,0 +1,97 @@
package router
import "strings"
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
// that follow them. Every case ending he might say is listed rather than
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
// prefix rule short enough to cover them also matches "семья".
//
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
// восемь", "в половине шестого"). Anything larger is said in digits.
var ruNumerals = map[string]string{
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
"два": "2", "две": "2", "двух": "2",
"три": "3", "трёх": "3", "трех": "3",
"четыре": "4", "четырёх": "4", "четырех": "4",
"пять": "5", "пяти": "5",
"шесть": "6", "шести": "6",
"семь": "7", "семи": "7",
"восемь": "8", "восьми": "8",
"девять": "9", "девяти": "9",
"десять": "10", "десяти": "10",
"одиннадцать": "11", "одиннадцати": "11",
"двенадцать": "12", "двенадцати": "12",
"тринадцать": "13", "тринадцати": "13",
"четырнадцать": "14", "четырнадцати": "14",
"пятнадцать": "15", "пятнадцати": "15",
"шестнадцать": "16", "шестнадцати": "16",
"семнадцать": "17", "семнадцати": "17",
"восемнадцать": "18", "восемнадцати": "18",
"девятнадцать": "19", "девятнадцати": "19",
"двадцать": "20", "двадцати": "20",
"тридцать": "30", "тридцати": "30",
"сорок": "40", "сорока": "40",
"пятьдесят": "50", "пятидесяти": "50",
}
// numeralContext — the words that make a numeral a time. A numeral is only
// rewritten when one of these sits next to it, so "три яблока" in a note is
// left alone and "в три часа" is not.
var numeralContext = map[string]bool{
"в": true, "во": true, "к": true, "около": true, "на": true,
"часа": true, "часов": true, "час": true, "часу": true,
"утра": true, "вечера": true, "дня": true, "ночи": true,
"минут": true, "минуты": true, "минуту": true,
"at": true, "by": true,
}
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
// shape they know.
//
// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00"
// parsed fine (Vikunja #469). Speech is where reminders come from, and speech
// says the hour in words, so this is not a long-tail case — it is the ordinary
// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python
// script already does; it does not read "в семь вечера".
//
// Conservative by construction: a numeral is only rewritten when a time word
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
// and a note or a fact carrying a spoken number is untouched.
func SpellOutDigits(text string) string {
toks := strings.Fields(text)
if len(toks) == 0 {
return text
}
out := make([]string, len(toks))
copy(out, toks)
for i, tok := range toks {
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
digit, ok := ruNumerals[key]
if !ok {
continue
}
// "час" and "часу" are the hour noun as often as they are the number
// one, and rewriting "в час дня" to "в 1 дня" is right either way. What
// must not happen is rewriting the noun that gives another numeral its
// context: "в семь часов" must keep "часов".
if !hasTimeNeighbour(toks, i) {
continue
}
out[i] = digit
}
return strings.Join(out, " ")
}
// hasTimeNeighbour reports whether the token before or after i is a time word.
func hasTimeNeighbour(toks []string, i int) bool {
for _, j := range []int{i - 1, i + 1} {
if j < 0 || j >= len(toks) {
continue
}
if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] {
return true
}
}
return false
}
+38
View File
@@ -0,0 +1,38 @@
package router
import (
"context"
"testing"
"time"
)
func TestSpellOutDigits(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
{"в три часа дня", "в 3 часа дня"},
{"напомни в половине шестого", "напомни в половине шестого"},
{"через двадцать минут", "через 20 минут"},
// Untouched: no time word stands beside the number.
{"купить три яблока", "купить три яблока"},
{"семь раз отмерь", "семь раз отмерь"},
{"напомни в 19:00", "напомни в 19:00"},
{"", ""},
} {
if got := SpellOutDigits(tc.in); got != tc.want {
t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The utterance from the QA sitting that named this bug: the numeric form
// parsed and the spoken form did not.
func TestStubParsesASpokenHour(t *testing.T) {
now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local)
got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now)
if err != nil || !ok {
t.Fatalf("Parse ok=%v err=%v, want a time", ok, err)
}
if got.Hour() != 19 {
t.Fatalf("hour = %d, want 19", got.Hour())
}
}
+6
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"errors"
"log"
"time"
)
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
// time, introspectable, no model surgery.
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
if r == nil || r.classifier == nil {
// The LLM router can run with no classifier wired. The correction has
// nowhere to land then, and the caller redoes the request anyway.
return errors.New("router: no classifier to correct")
}
return r.classifier.AddExample(ctx, corrected, utterance)
}
+39 -4
View File
@@ -178,7 +178,7 @@ func afterWord(s, w string) string {
type StubDateTimeParser struct{}
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
s := strings.ToLower(strings.TrimSpace(text))
s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
toks := strings.Fields(s)
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
// expression from surrounding text; the stub does the same naively.
@@ -206,14 +206,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
// --- Russian time expressions (stub floor; dateparser replaces) ---
// "в <clock>" anywhere — mirror of the English "at" scan.
// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
// with SpellOutDigits in front of this that is what "в семь вечера" reads
// as too (Vikunja #469).
for i := 0; i+1 < len(toks); i++ {
if toks[i] != "в" {
continue
}
if t, ok := parseClock(toks[i+1], now); ok {
return t, true, nil
t, ok := parseClock(toks[i+1], now)
if !ok {
continue
}
if i+2 < len(toks) {
t = applyRuQualifier(t, toks[i+2], now)
}
return t, true, nil
}
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
@@ -465,3 +473,30 @@ func midnight(now time.Time, days int) time.Time {
y, m, d := now.AddDate(0, 0, days).Date()
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
}
// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
// qualifiers need no arithmetic, they only confirm the hour as spoken.
//
// The date is recomputed rather than shifted, so an hour that parseClock
// already pushed to tomorrow does not land two days out.
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
h := t.Hour()
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
case "вечера", "дня":
if h < 12 {
h += 12
}
case "утра", "ночи":
if h == 12 {
h = 0
}
default:
return t
}
out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
if !out.After(now) {
out = out.Add(24 * time.Hour)
}
return out
}
+76 -11
View File
@@ -199,6 +199,20 @@ func AgendaQueryGrammars() []Grammar {
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "что дальше?" — the rest of the day, with no possessive and no
// plan word for the rules above to anchor on, so neither claimed
// it and the model called it a fact (Vikunja #498). The predicate
// for the same utterance already exists as IsRestOfDayQuery, one
// layer down in the query chain; this is what gets the turn there.
//
// "и что там дальше" and "что потом дальше" are the same question,
// and "what's next" splits into two tokens, hence the optional
// middles rather than plain adjacency.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// A named event with no calendar word at all: "когда планёрка?",
// "во сколько созвон". He is asking when something on his calendar
@@ -211,6 +225,11 @@ func AgendaQueryGrammars() []Grammar {
}
}
// chatNarrativeTopics — the things "расскажи X" asks for that are not
// questions about the world. She is being asked to entertain or to describe
// herself, and the query chain has no source for either.
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
// carry no question mark and no interrogative, and so reached the resident
// model with nothing deterministic in front of them (Vikunja #498).
@@ -255,15 +274,27 @@ var entertainmentNouns = []string{
"joke", "jokes", "story",
}
// narrativeQueryBuild — the narrative shape is a query unless he also said one
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
// asking for a note, and stage 0 must not take either off the cascade.
// narrativeQueryBuild — the narrative shape is a query carrying its topic,
// unless he also said one of the capture verbs, asked for entertainment, or
// asked about her. "расскажи и запиши" is him asking for a note, and stage 0
// must not take either off the cascade.
//
// The topic goes into Slots.Text rather than the whole utterance: the query
// chain looks things up by it, and "расскажи мне про Ватерлоо" is a question
// about Ватерлоо.
func narrativeQueryBuild(m []string) (Decision, bool) {
rest := ""
if len(m) > 3 {
rest = m[3]
topic := strings.TrimSpace(m[2])
// "расскажи" with nothing after it is a conversational opener, and there is
// no topic to look up.
if topic == "" {
return Decision{}, false
}
for _, t := range planTokens(rest) {
// Against the whole utterance, not the topic: "о себе" has its preposition
// eaten by the pattern, leaving a bare "себе".
if chatNarrativeTopics.MatchString(m[0]) {
return Decision{}, false
}
for _, t := range planTokens(topic) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
@@ -275,7 +306,12 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
}
}
}
return agendaQueryBuild(m)
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
}, true
}
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
@@ -285,9 +321,36 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
//
// Anchored at the start, which was the point of the old literal and still is:
// "запиши что мне рассказали" is a capture, and a narrative verb buried
// mid-utterance is not the shape.
// mid-utterance is not the shape. The dative and the preposition are eaten so
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
// "Ватерлоо".
var narrativeQueryPattern = regexp.MustCompile(
`(?i)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(\s+(.*))?$`)
`(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") +
`)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`)
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
// IntentQuery so it reaches queryFeeds.
//
// Same shape of defect as the agenda grammars: the model calls it system, and
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
// answered "пока не умею отвечать на этот вопрос." while the same question
// worded with "новостях" worked (Vikunja #474).
//
// An ask word at the front and a feed noun after it are both required, which
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
// — the most common opener in the language — and vagueNouns in feeds.go exists
// to keep it out of the feed reader; routing it to query here would put it
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
// stays the statement it is.
func FeedQueryGrammar() Grammar {
return Grammar{
Name: "feed-query",
// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
// to a Cyrillic letter.
Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`),
Build: agendaQueryBuild,
}
}
// dayWordPattern — the day words an agenda question can name. Weekdays appear
// in the accusative and prepositional forms the questions actually use ("в
@@ -345,7 +408,9 @@ func isCyrillic(s string) bool {
return s != ""
}
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
// which all do the same single thing: keep the utterance out of IntentSystem
// and let the query chain decide who answers it. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
// matchers decide the rest.
func agendaQueryBuild(m []string) (Decision, bool) {
+29
View File
@@ -248,3 +248,32 @@ func TaskCaptureGrammar() Grammar {
},
}
}
// TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне
// нужно сделать" (Vikunja #467).
//
// The same exposure the capture marker had, pointed the other way. IsTaskListQuery
// is a deterministic lookup that lives inside a query source, so it is only
// consulted once the turn is already IntentQuery. A phrasing the model calls
// system or note never reaches it, and "пока не умею" is what he hears — the
// failure the agenda and feed rules were written for.
//
// Placed after the agenda rules, which already send "какие у меня задачи" to
// query. What this adds is the phrasings with no possessive in them.
func TaskListGrammar() Grammar {
return Grammar{
Name: "task-list-query",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
if !IsTaskListQuery(m[1]) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: strings.TrimSpace(m[1])},
}, true
},
}
}
+24
View File
@@ -119,3 +119,27 @@ func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
}
}
}
// TestTaskListGrammarClaimsTheAsk — a list question answered before the model,
// including the phrasings with no possessive that used to route elsewhere.
func TestTaskListGrammarClaimsTheAsk(t *testing.T) {
g := TaskListGrammar()
claimed := []string{"какие у меня задачи", "список дел", "что мне нужно сделать"}
for _, u := range claimed {
m := g.Pattern.FindStringSubmatch(u)
if m == nil {
t.Fatalf("%q did not match the grammar pattern", u)
}
d, ok := g.Build(m)
if !ok || d.Intent != IntentQuery {
t.Errorf("%q built %+v ok=%v; want a query", u, d, ok)
}
}
passed := []string{"как дела", "напомни купить хлеб", "что docker делает"}
for _, u := range passed {
m := g.Pattern.FindStringSubmatch(u)
if _, ok := g.Build(m); ok {
t.Errorf("%q was claimed as a task list", u)
}
}
}
+23
View File
@@ -267,3 +267,26 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
}
return s
}
// Spoken — the tasks FormatRU actually named, in the order it named them
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
// an ordinal resolves against is built here and not by a caller guessing how
// the renderer split and truncated it.
func Spoken(ranked []Ranked) []Ranked {
var open, cands []Ranked
for _, r := range ranked {
if r.Status == StatusCandidate {
cands = append(cands, r)
} else {
open = append(open, r)
}
}
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
group = group[:SpokenLimit]
}
out = append(out, group...)
}
return out
}
+128
View File
@@ -0,0 +1,128 @@
package tts
import (
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// Pronunciation dictionary (Vikunja #458).
//
// piper reads a Russian sentence with a Russian voice, and a Latin service id
// inside that sentence comes out as letters or as noise: "Vikunja" is spelled
// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The
// fix is not a code change per name — it is a file of replacements applied to
// the text before piper sees it.
//
// Spelling, not phonemes. piper has no lexicon input of its own here, so the
// only lever is the text, and the entry for a name is how it should be spelled
// in Russian for the voice to say it right. That also means a wrong entry is
// visible: it is a word, and it is read out loud.
//
// The dictionary is data, so it ships as a file rather than a table in Go. A
// name added to it needs no rebuild and no deploy of the daemon that owns the
// text — only a restart of mavttsd, which is the process that reads it.
// Lexicon rewrites names into the spelling the voice reads correctly.
//
// The zero value is usable and rewrites nothing, so a daemon with no dictionary
// configured behaves exactly as it did before this existed.
type Lexicon struct {
// Rules are held in one alternation rather than as a map, so a text is
// scanned once however many entries there are, and the longest name wins
// where two overlap ("Home Assistant" before "Home").
re *regexp.Regexp
// by lower-cased name, because the match is case-insensitive and the
// replacement is not derived from what was matched.
by map[string]string
}
// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling.
//
// {"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"}
//
// An empty path returns an empty Lexicon and no error — the dictionary is off
// unless configured, like every other optional capability. A path that is set
// and unreadable IS an error: he asked for it, and silently saying names wrong
// is the failure this exists to remove.
func LoadLexicon(path string) (*Lexicon, error) {
if strings.TrimSpace(path) == "" {
return &Lexicon{}, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
}
var entries map[string]string
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
}
return NewLexicon(entries), nil
}
// NewLexicon builds a lexicon from entries already in memory.
func NewLexicon(entries map[string]string) *Lexicon {
names := make([]string, 0, len(entries))
by := make(map[string]string, len(entries))
for name, say := range entries {
name = strings.TrimSpace(name)
if name == "" || strings.TrimSpace(say) == "" {
continue
}
names = append(names, name)
by[strings.ToLower(name)] = say
}
if len(names) == 0 {
return &Lexicon{}
}
// Longest first: "Home Assistant" must match before "Home" does, and Go's
// regexp alternation is leftmost-first, not longest-match.
sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = regexp.QuoteMeta(n)
}
// The boundaries are written out rather than left to \b, which is ASCII-only
// and never fires next to a Cyrillic letter — so "в Vikunja," would not
// match with \b on the left in a Russian sentence.
pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])`
return &Lexicon{re: regexp.MustCompile(pattern), by: by}
}
// Apply rewrites every name in the text. Text with no name in it comes back
// unchanged and untouched.
func (l *Lexicon) Apply(text string) string {
if l == nil || l.re == nil || text == "" {
return text
}
// Twice, because two names separated by a single space share the character
// between them and one pass consumes it: "Nexus Praxis" would leave the
// second name alone otherwise.
out := l.replaceOnce(text)
return l.replaceOnce(out)
}
func (l *Lexicon) replaceOnce(text string) string {
return l.re.ReplaceAllStringFunc(text, func(m string) string {
groups := l.re.FindStringSubmatch(m)
if groups == nil {
return m
}
say, ok := l.by[strings.ToLower(groups[2])]
if !ok {
return m
}
return groups[1] + say + groups[3]
})
}
// Size reports how many names are loaded, for the startup log line.
func (l *Lexicon) Size() int {
if l == nil {
return 0
}
return len(l.by)
}
+85
View File
@@ -0,0 +1,85 @@
package tts
import (
"os"
"path/filepath"
"testing"
)
func TestLexiconRewritesNames(t *testing.T) {
lex := NewLexicon(map[string]string{
"Vikunja": "Викунья",
"Home Assistant": "Хоум Ассистент",
"Home": "Хоум",
"GPU": "джи-пи-ю",
})
for _, tc := range []struct{ in, want string }{
{"задача в Vikunja готова", "задача в Викунья готова"},
// Case-insensitive: the router and the model both change the case of a
// name on the way through.
{"открой vikunja.", "открой Викунья."},
// Longest first, or "Home Assistant" is read as "Хоум Assistant".
{"Home Assistant не отвечает", "Хоум Ассистент не отвечает"},
// Two names in a row share the space between them, which one pass
// would consume.
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
// Not a word boundary: a name inside a longer token is left alone.
{"vikunjaless", "vikunjaless"},
{"ничего не совпало", "ничего не совпало"},
{"", ""},
} {
if got := lex.Apply(tc.in); got != tc.want {
t.Errorf("Apply(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The zero value and an unconfigured path rewrite nothing, so a daemon with no
// dictionary behaves as it did before this existed.
func TestLexiconOffByDefault(t *testing.T) {
var zero *Lexicon
if got := zero.Apply("Vikunja"); got != "Vikunja" {
t.Errorf("nil lexicon rewrote %q", got)
}
lex, err := LoadLexicon("")
if err != nil {
t.Fatalf("LoadLexicon(\"\"): %v", err)
}
if lex.Size() != 0 || lex.Apply("Vikunja") != "Vikunja" {
t.Errorf("empty path produced a live lexicon of %d names", lex.Size())
}
}
// A path he set and that cannot be read is a startup failure. Saying names
// wrong in silence is what the dictionary exists to stop.
func TestLexiconLoadErrors(t *testing.T) {
if _, err := LoadLexicon(filepath.Join(t.TempDir(), "nope.json")); err == nil {
t.Error("a missing dictionary must be an error")
}
bad := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadLexicon(bad); err == nil {
t.Error("an unparseable dictionary must be an error")
}
}
func TestLexiconRoundTripsAFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "lex.json")
if err := os.WriteFile(path, []byte(`{"Praxis":"Праксис"," ":"skipped","Nexus":""}`), 0o644); err != nil {
t.Fatal(err)
}
lex, err := LoadLexicon(path)
if err != nil {
t.Fatalf("LoadLexicon: %v", err)
}
// Blank names and blank spellings are dropped: an entry that says nothing
// would delete the word it matched.
if lex.Size() != 1 {
t.Fatalf("Size = %d, want 1", lex.Size())
}
if got := lex.Apply("Praxis молчит"); got != "Праксис молчит" {
t.Errorf("Apply = %q", got)
}
}
-2
View File
@@ -83,5 +83,3 @@ modernc.org/memory
modernc.org/sqlite
modernc.org/sqlite/lib
modernc.org/sqlite/vtab
# github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus