Compare commits

...

12 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
claude 26d6d71588 mavend, router: stop three sources claiming turns the world should answer (V-474)
The querySources order predates the 2026-08-02 ruling that live search leads.

An unconfigured feeds source claimed every news question and answered with a
configuration status, so "что происходит сейчас в новостях про искусственный
интеллект?" never reached the search sitting one source below. It now claims
only when neither SearXNG nor the ZIMs are configured, which is the case the
"не читаю ленты" line was written for — general knowledge would otherwise
invent a bulletin.

The calendar matches on a day word alone and sits above the weather, so
"какая сегодня погода в Москве?" answered "на 02.08.2026 ничего нет." It now
steps aside on weather wording, the same bail-out queryHome already does.

"что нового в лентах?" routed system and answered "пока не умею", while the
same question worded with "новостях" worked. FeedQueryGrammar routes it to
query at stage 0, requiring an ask word and a feed noun so the bare greeting
"что нового?" stays a greeting. Wired in the eval too, since the fixture is
only worth anything while its grammar set is the daemon's.

Also: the claiming source is now logged. /trace is the nudge-rule trace and
carries no query-source field, so a wrong answer could not be told apart from
a wrongly-ordered chain.

Kiwix having no live coverage is filed separately as V-508 — it is a decision
about search quality, not an ordering fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:00:58 +04:00
claude df58710141 reminders: store what she says, show it in his clock (V-469)
Two of the four defects on the task.

The stored payload was the whole utterance, so /reminders and the agenda
recited "напомни завтра в 9 утра выпить таблетки" where the reminder is
"выпить таблетки". The marker is an instruction that was already carried out
and the hour is already a column, so reminderBody strips both, and falls back
to the unstripped body whenever stripping would leave nothing — a reminder
that fires and says nothing is worse than a wordy one.

The page rendered the raw {"text":...} envelope and the UTC instant. Both are
now done in mavweb: reminderRows unwraps the payload and formats through
Local(). The unwrap is a copy of store.ReminderText rather than a call to it,
because mavweb builds without CGO and internal/store carries the sqlite
driver — the ipc DTOs are decoupled from the store on purpose.

TestClarifySubjectAnswerFillsRatherThanClobbers asserted the hour survived as
a word in the payload. It now asserts the fire time, which is where the hour
lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:53:20 +04:00
claude 442d3ec08e router: read the hour he said out loud (V-469)
"напомни мне позвонить маме в семь вечера" answered "не получилось разобрать
время напоминания", while "в 19:00" set the reminder. Reminders arrive through
speech, and speech says the hour in words, so this was the ordinary case
failing and the typed one working.

SpellOutDigits rewrites a spoken number as digits, but only when a time word
stands beside it — "в три часа" becomes "в 3 часа" and "купить три яблока" is
left alone. Both parsers see it: dateparser already rewrites "7 вечера" to
"7 pm" and never saw a digit to rewrite, and the stub floor now reads the
qualifier itself.
2026-08-04 03:46:45 +04:00
claude e481ad4930 mavend: a question about attention reaches Praxis (V-475)
The capability was built, wired and degrading correctly, and no utterance
could reach it. Its aliases sit on the act dispatch, "что требует внимания"
routes to a query, and every query source passed — so the turn fell to the
web search and came back with an article about the concept of attention.
That reads as an answer, which is worse than silence.

queryAttention sits next to "tasks", above the recall sources and well above
the personal boundary: it is operational state about his things, and a notes
pass would otherwise answer from whatever he once wrote about a server. It
calls the same handler the act path calls, so the outage string comes free.

An absent or unconfigured Praxis falls through instead of claiming the turn,
like queryHome and queryNetwork. A configured Praxis that is down claims it
and names the gap. "что нового" is left to the feeds source.
2026-08-04 03:44:48 +04:00
claude 3d8224fb04 router, mavend: a complaint about a thing is not a fact about him (V-481)
"сеть какая-то медленная" and "интернет не работает" were written as `self`
rows at confidence 1.00. Recall reads a self row back later as if it were
still true, and that is the class of row that outranked live search in #470 —
so a slow afternoon becomes a standing belief about his network.

IsTransientComplaint is the same shape as IsQuestionShaped: deterministic,
offline, and off by default in the two cases where losing a real capture would
cost more than keeping a complaint. An explicit "запомни ..." wins, because he
asked. A first-person marker wins, because "я сломал руку" is durable and the
test is meant for sentences about things.

She answers the turn as chat instead of storing it. actionChat now has the
same nil-phraser floor the other model callers have.

The second defect filed here — a reply body of literally "{" — was closed by
the errBrokenJSON path in V-397 and needs nothing further.
2026-08-04 03:42:07 +04:00
claude 990a4a99e9 ecosystem: ask Nexus for the name he actually said (V-476)
Two defects in one logged line, both of which put a working capability out
of reach of every utterance.

The resident model rewrites as it routes, and on the way it transliterates:
"перезапусти muzick indexer" came back as "перезагрузить музик индексер", so
Nexus was asked to resolve a service nobody has ever named. entityReferenceText
takes the longest Latin run out of his own words, but only when the Text slot
has lost every Latin letter the utterance had — an English turn and a Russian
entity name are both left alone, and reversing the transliteration is not
attempted.

The second half: the stage-3 gate thins an act that matched no allowlisted fn,
and that question was the whole turn, so handleHexisAct never ran. Hexis is
where an act with no local fn belongs, so it gets one chance before she asks,
and a "" back still leaves her asking. With no ecosystem wired nothing changes.
Capability matching reads the phrase as the haystack when there is no fn,
because no capability name contains "restart status muzick indexer".

Authority is untouched: ambiguity still stops, a mutating capability still
goes through the spoken confirm.
2026-08-04 03:38:20 +04:00
claude 5b622389c5 dialogue: a restart expires the parked question (V-385)
The decision, not a behaviour change: ClarifyStore stays in memory, and she
does not announce the loss either.

The TTL and the attempt count measure a pause in one conversation. A restart
is a gap of unknown length, so a restored question is either dead already or
lying about its age, and the request behind it is one he has likely given up
on. Announcing it would mean storing a marker that outlives the thing it
describes, to say one sentence in the rare window where he speaks within 90s
of a restart. His next words route fresh, which is right either way.

Written down in docs/design.md, pinned at both ends by a comment, and held by
a test that builds a second handler over the same store.
2026-08-04 03:33:19 +04:00
claude a820a95ebb store: wake the routines accepted before the fire-forever fix (V-377)
Routines accepted before Vikunja #366 carry accepted_ts NULL and a live
reminder row. The tick loop reads accepted_ts to decide when a routine is
next due, so those rows have been silent since the fix landed, while the
reminder they still point at keeps firing on its own schedule.

Migration #19 cancels that reminder first, then dates the acceptance from
created_ts and lets the reminder id go. Order matters: the second update
clears the id the first one needs.
2026-08-04 03:29:51 +04:00
50 changed files with 2001 additions and 61 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 \
+3
View File
@@ -57,6 +57,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()
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
if err != nil {
+15
View File
@@ -38,6 +38,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,11 +81,22 @@ var querySources = []querySource{
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{name: "tasks", answer: (*reactiveHandler).queryTasks},
// Next to "tasks" and for the same reason: "что требует внимания?" is a
// question about the operational state Praxis holds, and it used to fall
// through every source to the web search (Vikunja #475). Its matcher needs
// an attention marker, and it falls through when Praxis is not configured.
{name: "attention", answer: (*reactiveHandler).queryAttention},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{name: "money", answer: (*reactiveHandler).queryMoney},
// Also above the recall sources: "что я тебе говорил?" is a question about
// the facts he tapped in, and the notes pass would answer it with whatever
// note is nearest (Vikunja #456). Its matcher needs both halves of a
// history phrase and bails out when he names a topic, so "что я говорил
// про сервер" is still recall.
{name: "history", answer: (*reactiveHandler).queryHistory},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
// it by inventing news. Its matcher needs a feed noun plus an ask, so
@@ -140,6 +151,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
}
}
@@ -278,9 +296,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 "я пока не читаю ленты — они не настроены.", true
}
// By source, not the last 200 notes of any kind: a busy day of voice notes
@@ -317,6 +343,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
@@ -24,7 +24,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
return "не получилось разобрать время напоминания."
}
}
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 "не получилось поставить напоминание."
+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))
+42 -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())
}
}
@@ -530,3 +544,23 @@ func TestClarifyIsPerConversation(t *testing.T) {
func voiceCtx() context.Context {
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
}
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
// question dies with the process, and she does not claim to have let it go —
// the words that follow are routed as a fresh request. Restarting is modelled
// the way the daemon does it, by building a second handler over the same store.
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
h, _, _ := newClarifyHandler(t)
ctx := voiceCtx()
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
t.Fatal("expected a question before the restart")
}
restarted, _, _ := newClarifyHandler(t)
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
t.Fatal("a question parked before the restart must not eat the next utterance")
}
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
}
}
+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 -5
View File
@@ -514,11 +514,14 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
// Resolve the utterance text as an entity reference through Nexus. An
// ambiguous match must stop and clarify — never guess a mutation target.
// The name comes from entityReferenceText, not straight from the Text slot:
// the model transliterates Latin names as it routes (Vikunja #476).
subject := entityReferenceText(dec)
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
@@ -535,7 +538,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
map[string]any{"subject": redactSubject(dec.Slots.Text)})
map[string]any{"subject": redactSubject(subject)})
return ""
}
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
@@ -569,10 +572,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
verbLower := strings.ToLower(verb)
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
// indexer"), which no capability name ever contains. Read it the other way
// round then: the phrase is the haystack and the capability name is what we
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
// fn is a single verb and containment already means what it says.
loose := !dec.Slots.HasFn
var matches []*hexisclient.Capability
for i, c := range caps {
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
name := strings.ToLower(c.Name)
hit := strings.Contains(name, verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
if loose && name != "" && strings.Contains(verbLower, name) {
hit = true
}
if hit {
matches = append(matches, &caps[i])
}
}
@@ -632,3 +646,29 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
})
return "команда выполнена для " + displayName + "."
}
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
// asks what to do.
//
// The stage-3 gate thins an act that never matched an allowlisted fn, so
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
// path was never entered — the capability existed and no utterance could reach
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
// the verb is matched against the capabilities Hexis registers for the entity,
// not against the allowlist.
//
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
// only when Hexis is wired — a box with no ecosystem asks the question it
// always asked. A "" back means Nexus knew no such entity or Hexis had no
// matching capability, and then she asks after all. Authority is unchanged:
// resolution stops on ambiguity and a mutating capability still goes through
// the spoken confirm in handleHexisAct.
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
if h.ecosystem == nil || h.ecosystem.hexis == nil {
return ""
}
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
return ""
}
return h.handleHexisAct(ctx, dec)
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"regexp"
"strings"
"unicode"
"github.com/kami/maven/internal/router"
)
// latinRun matches a run of Latin-script words — the shape a service, host or
// project name takes in a Russian sentence. Digits, dot, dash and underscore
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
// hasLatin reports whether s carries a Latin letter.
func hasLatin(s string) bool {
for _, r := range s {
if unicode.In(r, unicode.Latin) {
return true
}
}
return false
}
// entityReferenceText is the name Nexus is asked to resolve.
//
// Normally that is the router's Text slot, which is the verb phrase the model
// wrote. But the resident model rewrites a Russian utterance as it routes, and
// on the way it transliterates: "перезапусти muzick indexer" came back as
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
// service nobody has ever named, so the act cannot resolve its target even
// with every gate open.
//
// The recovery is deliberately narrow. Only when the utterance holds a Latin
// run and the model's Text holds none has a name certainly been rewritten —
// then the longest Latin run in his own words is the reference. Anything else
// keeps the Text slot, so an English utterance and a Russian entity name are
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
// surface form he said is right there, and guessing at a reverse mapping would
// invent a second name to be wrong about.
func entityReferenceText(dec router.Decision) string {
text := dec.Slots.Text
if hasLatin(text) || !hasLatin(dec.Utterance) {
return text
}
longest := ""
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
if len(m) > len(longest) {
longest = m
}
}
longest = strings.TrimSpace(longest)
// A single stray letter is not a name.
if len(longest) < 2 {
return text
}
return longest
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// TestEntityReferenceText pins when his own words win over the model's.
func TestEntityReferenceText(t *testing.T) {
for _, tc := range []struct {
name string
utterance string
text string
want string
}{
{
name: "the model transliterated the name",
utterance: "перезапусти muzick indexer",
text: "перезагрузить музик индексер",
want: "muzick indexer",
},
{
name: "it kept the name, so nothing to repair",
utterance: "перезапусти muzick indexer",
text: "перезагрузить muzick indexer",
want: "перезагрузить muzick indexer",
},
{
name: "an all-Russian entity name is not a rewrite",
utterance: "перезапусти домашний сервер",
text: "перезагрузить домашний сервер",
want: "перезагрузить домашний сервер",
},
{
name: "an English turn never enters the recovery",
utterance: "restart muzick indexer",
text: "restart muzick indexer",
want: "restart muzick indexer",
},
{
name: "the longest Latin run is the name",
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
text: "перезагрузить нгинкс",
want: "muzick-indexer",
},
{
name: "one stray letter is not a name",
utterance: "перезапусти сервер a",
text: "перезагрузить сервер",
want: "перезагрузить сервер",
},
} {
t.Run(tc.name, func(t *testing.T) {
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
if got := entityReferenceText(dec); got != tc.want {
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
}
})
}
}
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
// router hands over a transliterated Text, and Nexus must still be asked about
// the service that exists.
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
}
h.handleHexisAct(ctx, dec)
reqs := nexus.Requests()
if len(reqs) == 0 {
t.Fatal("nexus was never asked")
}
body := string(reqs[0].Body)
if !strings.Contains(body, "muzick indexer") {
t.Fatalf("nexus resolve body = %s, want the name he said", body)
}
}
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
// stage-3 gate thins an act with no allowlisted fn, and that question used to
// be the whole turn, so the Hexis path was unreachable from voice or chat.
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "restart status muzick indexer"},
}
reply := h.hexisBeforeClarify(ctx, dec)
if reply == "" {
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
}
if hexis.Count("", "/api/v1") == 0 {
t.Fatal("hexis was never contacted")
}
}
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
// she asks exactly what she asked before.
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
h, _, _ := newClarifyHandler(t)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "перезагрузить музик индексер"},
}
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
t.Fatalf("no hexis must mean no reply, got %q", reply)
}
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
t.Fatal("she must still ask what to do")
}
}
+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
@@ -194,3 +194,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)
}
}
+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)
}
}
}
+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) {
+3
View File
@@ -351,6 +351,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// and park the request (clarify.go); otherwise the replier's canned reply
// stands.
if dec.Clarify {
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
return withNotice(expiredNotice, question)
}
+7 -1
View File
@@ -238,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
// Store-backed when the daemon passes a store, so a restart mid-conversation
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
// load, never revived. Clarify's parked question stays in memory only.
// load, never revived. Clarify's parked question stays in memory only, and
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
// a restart expires it, so the thread comes back and the open question does
// not.
var dialogueSessions *dialogue.SessionStore
if dataStore != nil {
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
@@ -379,6 +382,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
grammars = append(grammars, router.FeedQueryGrammar())
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
+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()
+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": "деплой"
}
+24
View File
@@ -223,6 +223,30 @@ Not alternatives — layers:
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
7 intents (`fact, reminder, note, query, act, chat, system`).
#### A restart expires a parked question
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
restart; the clarify question parked behind it does not, and neither do the
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
Three reasons, in the order they settle it:
- The clock stops meaning anything. A parked question carries a 90s TTL and an
attempt count. A restart is a gap of unknown length, so a restored question is
either already dead or pretending to be young.
- Restoring the question restores the request behind it. He asked for something,
she asked back, and then the daemon went away. Acting on that minutes later,
against words he has probably given up on, is the misroute the stage 3 gate
exists to avoid.
- She does not announce it either. The expiry notice needs to know a question
was parked, and knowing that across a restart means storing it. One sentence,
in the rare window where he speaks within 90s of a restart, does not pay for a
marker that outlives the thing it describes. His next words route fresh, which
is the correct answer with or without the notice.
So the notice stays what it is: the in-process TTL case, where she really did
wait and really did let go.
### save-where — the two-memory routing axis
One discriminator: **does the loop evaluate a predicate against it?**
+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
+8
View File
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
// ClarifyStore holds the parked questions. Same shape and locking as
// SessionStore: keyed by dialogue id, expired entries dropped on read.
//
// Memory only, deliberately, unlike SessionStore — a restart expires every
// parked question and she does not announce that it happened (Vikunja #385,
// written down in docs/design.md). The 90s TTL and the attempt count measure a
// pause in one conversation, and a restart is a gap of unknown length, so a
// restored question would either be dead already or lying about its age. His
// next words route fresh, which is the right answer with or without a notice.
// Do not give this store a persister without re-arguing that.
type ClarifyStore struct {
mu sync.RWMutex
questions map[string]*PendingQuestion
+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 —
+1
View File
@@ -236,6 +236,7 @@ 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.FeedQueryGrammar())
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+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)
}
}
}
+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())
}
}
+39 -4
View File
@@ -176,7 +176,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.
@@ -204,14 +204,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) / "через полчаса".
@@ -476,3 +484,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
}
+27 -1
View File
@@ -208,13 +208,39 @@ func AgendaQueryGrammars() []Grammar {
}
}
// 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 ("в
// среду", "на среде"), which is why the stems carry an inflection tail rather
// than a fixed ending.
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
// 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
@@ -219,6 +219,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
// #19 — unstick the routines accepted before the fire-forever fix
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
// NULL and a live one-shot reminder behind, and the tick loop skips a row
// with no accepted_ts, so every non-weekly routine accepted before that fix
// has been silent ever since.
//
// Three statements, in this order, per stuck row: adopt created_ts as the
// acceptance time, cancel the reminder that is still holding the schedule,
// then let go of it. Cancelling before clearing matters — clearing first
// loses the only pointer to the reminder and leaves it to fire on its own.
//
// created_ts rather than a fresh timestamp because a migration has no
// clock, and because the first interval should be measured from when he
// said yes. A routine whose interval has already elapsed nudges on the next
// tick, which is what being unstuck looks like.
//
// Weekly rows are included deliberately. Theirs was the case that kept
// working, because the cron reminder reschedules itself — so leaving them
// alone would give them both a cron reminder and a tick-loop schedule for
// one habit, and he would hear it twice.
`UPDATE reminders
SET status = 'cancelled'
WHERE status = 'pending'
AND id IN (SELECT reminder_id FROM proposed_routines
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
UPDATE proposed_routines
SET accepted_ts = created_ts, reminder_id = NULL
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
}
// migrate applies every migration with a number greater than the DB's current
+67
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"testing"
"time"
)
func userVersion(t *testing.T, s *Store) int {
@@ -80,3 +81,69 @@ func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
}
}
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
// and they have been silent ever since (Vikunja #377). The migration touches
// live reminders, which is why it is tested against a real store.
func TestStuckRoutinesAreBackfilled(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
if err != nil {
t.Fatal(err)
}
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
if err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
created.UnixMilli(), rem); err != nil {
t.Fatal(err)
}
// An already-healthy accepted row, and a still-open proposal: neither is
// this migration's business.
if _, err := s.db.ExecContext(ctx,
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
created.UnixMilli(), created.UnixMilli()); err != nil {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
t.Fatalf("migration 19: %v", err)
}
accepted, err := s.ListAcceptedRoutines(ctx)
if err != nil || len(accepted) != 2 {
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
}
stuck := accepted[0]
if stuck.Object != "plants" {
stuck = accepted[1]
}
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
}
if stuck.ReminderID != nil {
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
}
// The reminder it was holding is cancelled, and nothing else is.
var status string
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
t.Fatal(err)
}
if status != ReminderCancelled {
t.Fatalf("linked reminder status = %q, want cancelled", status)
}
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "pending" {
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
}
}
+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