Compare commits

...

4 Commits

Author SHA1 Message Date
claude 1558233665 build: make go mod tidy runnable, and drop two dead requirements (V-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 its
parent, so deps/go.mod ends the walk in three lines. deps/ is gitignored, so
the sentinel is generated by `make deps-sentinel`, which deps-go and deps now
depend on.

The tidy it makes possible drops github.com/kami/praxis, which no file
imports — Praxis is reached over HTTP, by contract. Its replace directive and
the unused nexus one went with it, so a build no longer expects two sibling
checkouts that nothing reads. vendor/ is committed, so `make tidy` re-vendors
in the same breath: a tidy alone leaves the next build failing on
"inconsistent vendoring".

Not wired into `make test`. A build target that rewrites go.mod is a surprise.

Verified: `make build` produces all 9 binaries and `make test` is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:14:07 +04:00
claude 5afa2dfb38 mavttsd: a pronunciation dictionary, so she says the names right (V-458)
piper reads a Russian sentence with a Russian voice, and a Latin service id
inside it comes out spelled, mangled or read as if it were a Russian word:
"Vikunja", "SearXNG", "homesrv". The lever available is the text, so the
dictionary maps a name to how it should be spelled for the voice to say it,
and mavttsd applies it at the last edge before piper — every caller's text
passes through that one point, and nothing upstream has to know how a name
sounds.

Data, not code. deploy/tts-lexicon.json ships 29 names; adding one needs a
restart of mavttsd and no rebuild of the daemon that produced the text. Off
unless -lexicon is set, like every other optional capability, and a path that
is set and unreadable stops startup — saying names wrong in silence is the
failure it exists to remove.

Two details worth keeping: the alternation is sorted longest-first, or "Home
Assistant" reads as "Хоум Assistant"; and the boundaries are written out
rather than left to \b, which is ASCII-only and never fires next to a
Cyrillic letter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:11:10 +04:00
claude 0f361d2034 mavend: answer what he told her, from the facts he already tapped in (V-456)
Read-only over rows that exist. No new mechanism and no new storage: every
fact he tapped in already carries a source and a timestamp, and the history
source only reads them back.

Only "tap:" sources, and only the last day. A fact written by a poller, an
inference or the ambient relay is a thing she learned rather than a thing he
said, and reading those back under "что я тебе говорил?" would put words in
his mouth. Five at a time, which is what fits in one spoken breath — the rest
are on /history, which is the surface for reading a list.

Above the recall sources, with the others that read his own rows: the notes
pass would otherwise answer this from whatever note is nearest, which reads
as an answer and is not one. The matcher wants both halves of a history
phrase and steps aside when he names a topic, so "что я говорил про сервер"
stays a recall question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:07:43 +04:00
claude 19242ef73b mavend: a clarify asks differently the second time (V-457)
Every clarify turn said one sentence per gap, and a re-ask repeated it word
for word. A question he already failed to answer is the worst one to ask
again unchanged: the second wording is what tells him which part she missed.

clarifytemplates.go holds three wordings per slot, picked by attempt rather
than at random — short first, then naming the gap, then spelling it out with
an example. Past the end she keeps the most explicit one instead of wrapping
back to the short question he has already not answered.

The intents with nothing identifiable to ask about (note, query, chat,
system) kept the stub's single "не совсем поняла — можешь переформулировать?",
which is the line he hears whenever she misses him completely. Four wordings
now, picked by a hash of the utterance so one question asked twice reads the
same and two different misses do not.

Still no model call on this path: the resident model would wander, and this
text has to be right every time. No schema_version either, unlike the nudge
templates — these are Go constants, so no file can drift out of step with the
code that reads it. The persona test already in clarify_test.go covers the
new lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:04:59 +04:00
19 changed files with 761 additions and 39 deletions
+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 \
+6
View File
@@ -91,6 +91,12 @@ var querySources = []querySource{
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{name: "money", answer: (*reactiveHandler).queryMoney},
// Also above the recall sources: "что я тебе говорил?" is a question about
// the facts he tapped in, and the notes pass would answer it with whatever
// note is nearest (Vikunja #456). Its matcher needs both halves of a
// history phrase and bails out when he names a topic, so "что я говорил
// про сервер" is still recall.
{name: "history", answer: (*reactiveHandler).queryHistory},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
// it by inventing news. Its matcher needs a feed noun plus an ask, so
+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))
+16 -6
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") {
+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")
}
}
+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)
}
}
+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
@@ -37,9 +37,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "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")
}
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
+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()
+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": "деплой"
}
+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.
-5
View File
@@ -15,7 +15,6 @@ require github.com/kami/hexis v0.0.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kami/praxis v0.0.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -25,8 +24,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
+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)
}
}
-3
View File
@@ -15,8 +15,6 @@ github.com/google/uuid
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
## explicit; go 1.25.5
github.com/kami/hexis/pkg/client
# github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
## explicit; go 1.23
# github.com/mattn/go-isatty v0.0.20
## explicit; go 1.15
github.com/mattn/go-isatty
@@ -79,4 +77,3 @@ modernc.org/memory
modernc.org/sqlite
modernc.org/sqlite/lib
modernc.org/sqlite/vtab
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus