Compare commits

..

16 Commits

Author SHA1 Message Date
claude 6878e12d37 Merge pull request 'Bug: a transient complaint is stored as a durable fact at confidence 1.00' (#132) from task/481-bug-a-transient-complaint-is-stored-as-a into master 2026-08-04 18:23:20 +02:00
claude 8d46ee39e0 Merge pull request 'Bug: the router transliterates Latin entity names into Cyrillic before Nexus sees them' (#131) from task/476-bug-the-router-transliterates-latin-enti into master 2026-08-04 18:23:16 +02:00
claude 6eba79b332 Merge pull request 'Decide whether a parked clarify question should survive a restart' (#130) from task/385-decide-whether-a-parked-clarify-question into master 2026-08-04 18:23:12 +02:00
claude 3f60ec3994 Merge pull request 'Backfill routines accepted before the fire-forever fix' (#129) from task/377-backfill-routines into master 2026-08-04 18:23:08 +02:00
claude 14ea06712e Merge pull request 'Weather: the 6-city match table has no geocoder behind it' (#128) from task/421-weather-geocoder into master 2026-08-04 18:23:03 +02:00
claude 42d3feadd2 Merge pull request 'No read path for delivery_attempts — the outbox is durable but invisible' (#127) from task/390-no-read-path-for-delivery-attempts into master 2026-08-04 18:22:58 +02:00
claude 2a2f706b74 Merge pull request 'Recall fixture: filler note ids can collide with case ids and split the two backends' (#126) from task/386-recall-fixture-filler-note-ids into master 2026-08-04 18:22:52 +02:00
claude e082e06868 Merge pull request 'Bug: morning.Item has no required/optional flag, so behaviour 1 of task 280 cannot hold' (#125) from task/473-bug-morning-item-has-no-required-flag into master 2026-08-04 18:22:45 +02:00
claude 88dc4e1383 Merge pull request 'Bug: make simulate routes with an empty seed set, so a green run proves less than it looks' (#124) from task/465-bug-make-simulate-routes-with-an-empty into master 2026-08-04 18:22:37 +02:00
claude 60759a991e Merge pull request 'Bug: spoken task capture is dead — the router calls the marker an act, and capture only rides the note intent' (#123) from task/467-bug-spoken-task-capture-is-dead into master 2026-08-04 18:22:28 +02:00
claude 9537346441 Merge pull request 'Bug: a pending clarify is global, so one unanswerable question swallows the next three utterances from anybody' (#122) from task/466-bug-a-pending-clarify-is-global-so-one-u into master 2026-08-04 18:21:49 +02:00
claude c6be818f13 Merge pull request 'Bug: pattern.Detect has no minimum-interval floor, so four fast taps mint a permanent false routine' (#121) from task/468-bug-pattern-detect-has-no-minimum-interv into master 2026-08-04 18:21:38 +02:00
claude b5575a9402 Merge pull request 'Bug: CheckFeminine flags second-person masculine verbs as self-reference' (#120) from task/462-bug-checkfeminine-flags-second-person-ma into master 2026-08-04 18:21:27 +02:00
claude fcda5e3d2c Merge pull request 'safeKey drops Cyrillic, so Russian calendar events on one day collide' (#119) from task/443-safekey-drops-cyrillic-so-russian-calend into master 2026-08-04 18:21:15 +02:00
claude bdafc82e35 Merge pull request 'llama-server core-dumps on every SIGTERM, so each mavgpud yield writes a core file' (#116) from task/491-llama-server-core-dumps-on-every-sigterm into master 2026-08-04 14:09:25 +02:00
claude c69023c310 Merge PR #117 into task/491 (V-383) 2026-08-04 14:08:59 +02:00
37 changed files with 55 additions and 1432 deletions
+3 -22
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 deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
all: build
@@ -74,7 +74,7 @@ run-web: build-web
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
deps-go: deps-sentinel
deps-go:
@mkdir -p deps/go
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
@@ -84,25 +84,6 @@ deps-go: deps-sentinel
done
$(GO) version
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
# Go's own compiler-error fixtures and died on files that are malformed on
# purpose ("unicode//utf8": double slash). A nested module is not part of the
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
# generated here rather than committed, and every target that populates deps/
# writes it.
deps-sentinel:
@mkdir -p deps
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
# go.mod, and a build target that edits the module file is a surprise.
# vendor/ is committed, so a tidy that drops a requirement must be followed by
# a re-vendor or the next build fails on "inconsistent vendoring".
tidy: deps-sentinel
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
# Run `gofmt -w` on whatever this prints.
@@ -197,7 +178,7 @@ run-tts: build-tts
./mavttsd -socket /tmp/maven/tts.sock \
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
deps: deps-sentinel deps-whisper deps-piper
deps: deps-whisper deps-piper
deps-whisper:
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
+3 -38
View File
@@ -81,22 +81,11 @@ var querySources = []querySource{
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{name: "tasks", answer: (*reactiveHandler).queryTasks},
// Next to "tasks" and for the same reason: "что требует внимания?" is a
// question about the operational state Praxis holds, and it used to fall
// through every source to the web search (Vikunja #475). Its matcher needs
// an attention marker, and it falls through when Praxis is not configured.
{name: "attention", answer: (*reactiveHandler).queryAttention},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{name: "money", answer: (*reactiveHandler).queryMoney},
// Also above the recall sources: "что я тебе говорил?" is a question about
// the facts he tapped in, and the notes pass would answer it with whatever
// note is nearest (Vikunja #456). Its matcher needs both halves of a
// history phrase and bails out when he names a topic, so "что я говорил
// про сервер" is still recall.
{name: "history", answer: (*reactiveHandler).queryHistory},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
// it by inventing news. Its matcher needs a feed noun plus an ask, so
@@ -151,13 +140,6 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
continue
}
if reply, ok := src.answer(h, ctx, t); ok {
// Which source claimed is the one thing about a query turn that was
// invisible from outside: /trace is the nudge-rule trace and carries
// no query-source field, so a wrong answer could not be told from a
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
// the utterance and the answer are already on the voice lines above
// and below this one.
log.Printf("voice: query claimed by source %q", src.name)
return reply
}
}
@@ -296,17 +278,9 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
return "", false
}
if !h.feedsOn {
// Claim only when nothing below can read the world. The reason this
// source used to claim unconditionally was that general knowledge would
// answer "что нового?" with an invented news bulletin — true, and it
// stopped being the only alternative on 2026-08-02, when live search
// took the lead. With SearXNG or the ZIMs configured, "что происходит
// в новостях про искусственный интеллект?" has a real answer below,
// and a configuration status is the wrong thing to say instead
// (Vikunja #474).
if h.search != nil || h.kiwix != nil {
return "", false
}
// Claim the turn rather than fall through: "не читаю ленты" is true, and
// letting general knowledge answer "что нового?" would be an invented
// news bulletin.
return "я пока не читаю ленты — они не настроены.", true
}
// By source, not the last 200 notes of any kind: a busy day of voice notes
@@ -343,15 +317,6 @@ 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
+1 -4
View File
@@ -24,10 +24,7 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
return "не получилось разобрать время напоминания."
}
}
// The body is what she says at the hour, so the marker and the time come
// out of it: the fire time is already a column, and "напомни" is an
// instruction that has been carried out (Vikunja #469).
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание."
-68
View File
@@ -1,68 +0,0 @@
package main
import (
"context"
"strings"
"github.com/kami/maven/internal/router"
)
// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a
// stem, because "внимание", "внимания" and "вниманию" are one word to him.
//
// "что нового" is deliberately absent: the feeds source claims it, and it
// still should — a question about news is a question about the feeds she
// reads. This list is about the operational state of his things.
var attentionMarkers = []string{
"внимани", "что требует", "что не так", "что важн", "что срочн",
"needs attention", "what needs looking",
}
// isAttentionQuery reports whether the utterance asks what needs looking at.
func isAttentionQuery(u string) bool {
s := strings.ToLower(strings.TrimSpace(u))
if s == "" {
return false
}
for _, m := range attentionMarkers {
if strings.Contains(s, m) {
return true
}
}
return false
}
// queryAttention answers "что требует внимания?" from Praxis.
//
// The capability was already built and already degraded correctly, and no
// utterance could reach it (Vikunja #475). Its aliases live on the act
// dispatch, and the question routes to IntentQuery, so it fell through every
// source to the web search and came back with an encyclopedia article about
// the concept of attention — worse than silence, because it reads as an
// answer.
//
// Placed above the recall sources and well above the personal boundary: this
// is operational state about his things, and a notes pass would otherwise
// answer it from whatever he once wrote about a server. An unconfigured or
// absent Praxis falls through rather than claiming the turn, the same
// convention queryHome and queryNetwork follow. A Praxis that is configured
// and down does claim it, and says it cannot reach the service — that is the
// degradation the ecosystem contract asks for, and it comes from the same
// handler the act path uses.
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
if !isAttentionQuery(t.dec.Utterance) {
return "", false
}
if h.ecosystem == nil || h.ecosystem.praxis == nil {
return "", false
}
reply := h.handlePraxisAct(ctx, router.Decision{
Utterance: t.dec.Utterance,
Intent: router.IntentAct,
Slots: router.Slots{Fn: "list_attention", HasFn: true},
})
if reply == "" {
return "", false
}
return reply, true
}
-77
View File
@@ -1,77 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/router"
)
func TestIsAttentionQuery(t *testing.T) {
for _, tc := range []struct {
text string
want bool
}{
{"что требует внимания", true},
{"на что обратить внимание?", true},
{"что не так?", true},
{"что важного?", true},
// The feeds source owns this one, and should keep owning it.
{"что нового?", false},
{"какая погода?", false},
{"", false},
} {
if got := isAttentionQuery(tc.text); got != tc.want {
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
// routes to IntentQuery, and every source used to pass, so a web search about
// the concept of attention answered it.
func TestAttentionQuestionReachesPraxis(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
"id": "item_1", "title": "disk almost full", "importance": 3.0,
}))
h := ecoHandler(t, nil, praxis, nil)
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
Intent: router.IntentQuery, Utterance: "что требует внимания",
}})
if !ok {
t.Fatal("the attention question must be claimed before the world sources")
}
if !strings.Contains(reply, "disk almost full") {
t.Fatalf("reply = %q, want the praxis item", reply)
}
}
// A configured Praxis that is down claims the turn and says so. Falling
// through here would answer an outage with an encyclopedia article.
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
ctx := context.Background()
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
h := ecoHandler(t, nil, praxis, nil)
praxis.SetFault(503)
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
Intent: router.IntentQuery, Utterance: "что требует внимания",
}})
if !ok || !strings.Contains(reply, "не могу") {
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
}
}
// No Praxis configured means no claim: the rest of the chain still runs.
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
h, _ := newFactGateHandler(t, time.Now())
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
Intent: router.IntentQuery, Utterance: "что требует внимания",
}}); ok {
t.Fatal("an unconfigured praxis must not claim the turn")
}
}
+16 -9
View File
@@ -33,10 +33,19 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
router.IntentAct: {dialogue.SlotFn},
}
// The questions themselves live in clarifytemplates.go, one list per slot,
// picked by attempt (Vikunja #457). The first ask is the short one this map
// used to hold; a re-ask says it differently, because a question he already
// failed to answer is the worst one to repeat unchanged.
// clarifyQuestions — one short question per missing slot.
//
// These are fixed templates, not model output. The resident model is a 0.8B; it
// would wander, and a question whose wording changes every time is harder to
// answer than a blunt one that always reads the same. They are infinitive
// questions, so there is no gender agreement to get wrong; the feminine
// self-reference lives in the reply she gives when she drops the request.
var clarifyQuestions = map[dialogue.Slot]string{
dialogue.SlotTime: "Когда?",
dialogue.SlotText: "О чём напомнить?",
dialogue.SlotKey: "Что записать?",
dialogue.SlotFn: "Что сделать?",
}
// clarifyGaveUp — she is out of questions and still does not have the slot. She
// says so out loud: dropping the request in silence would leave him thinking it
@@ -138,7 +147,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
if len(missing) == 0 {
return "", "", false
}
q, ok := clarifyQuestionFor(missing[0], 1)
q, ok := clarifyQuestions[missing[0]]
if !ok {
return "", "", false
}
@@ -257,9 +266,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
if len(remaining) == 0 {
return "", false
}
// Attempts+1 is the question she is about to ask, and the budget is shared
// with the re-ask path, so the second gap is worded like a second try.
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
question, ok := clarifyQuestions[remaining[0]]
if !ok || !q.CanAsk() {
return "", false
}
@@ -283,7 +290,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, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
question = clarifyQuestions[q.Missing[0]]
}
if question == "" || !q.CanAsk() {
h.clarifyStore.Delete(dialogueIDOf(ctx))
+8 -22
View File
@@ -156,14 +156,8 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
if !handled {
t.Fatalf("answer %d must be consumed as an answer", i)
}
// The wording changes with the attempt (Vikunja #457): repeating a
// question he already failed to answer is the worst way to ask it.
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
if reply != want {
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
}
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
if reply != "Когда?" {
t.Fatalf("attempt %d should ask again, got %q", i, reply)
}
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
t.Fatalf("attempt %d must leave the question armed", i)
@@ -355,11 +349,8 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
if !handled {
t.Fatal("the answer must be consumed as an answer")
}
// Second gap, second attempt, so it is the second wording of the time
// question — the attempt budget is shared between the two paths.
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
if reply != want {
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
if reply != "Когда?" {
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
}
q := h.clarifyStore.Get(voiceDialogueID, h.now())
if q == nil {
@@ -420,9 +411,8 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
eval.CheckCringe: true,
}
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
lines = append(lines, clarifyMissedVariants...)
for _, variants := range clarifyQuestionVariants {
lines = append(lines, variants...)
for _, q := range clarifyQuestions {
lines = append(lines, q)
}
for _, line := range lines {
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
@@ -507,12 +497,8 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
// The hour is the fire time, not a word in the body: the body is what she
// says at the hour, and the time expression is stripped out of it
// (Vikunja #469). Clobbering the parked request would show up here as a
// reminder that fires at some other time than the one he asked for.
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
if !strings.Contains(reminders[0].Payload, "11") {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
}
}
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// The clarify copy deck (Vikunja #457).
//
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
// that sentence word for word. A question he already failed to answer is the
// worst one to ask again unchanged: the second wording is the one that tells
// him which part she missed.
//
// Fixed templates, not model output, for the reason clarifyQuestions has always
// given: the resident model would wander, and a question whose wording changes
// at random is harder to answer than a blunt one. What changes here is that the
// wording varies with the attempt rather than with a die roll — the first ask is
// short, the second names the gap, the third spells it out.
//
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
// constants compiled into the daemon, so there is no file that can drift out of
// step with the code that reads it.
//
// Persona holds: infinitive and imperative questions, so there is no gender
// agreement to get wrong, "ты" throughout, and no pet names.
var clarifyQuestionVariants = map[dialogue.Slot][]string{
dialogue.SlotTime: {
"Когда?",
"Во сколько напомнить?",
"Скажи время — например, «в семь вечера» или «через час».",
},
dialogue.SlotText: {
"О чём напомнить?",
"Что сказать тебе в это время?",
"Скажи одной фразой, о чём напомнить.",
},
dialogue.SlotKey: {
"Что записать?",
"Что именно отметить?",
"Назови, что записать — например, «выпил воды».",
},
dialogue.SlotFn: {
"Что сделать?",
"Какое действие выполнить?",
"Назови действие — я умею только то, что ты мне разрешил.",
},
}
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
// PendingQuestion.Attempts counts it; anything past the list uses the last and
// most explicit phrasing rather than wrapping round to the short one, because
// wrapping would ask the same short question he has already not answered.
//
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
// same statement however it is worded, and a re-ask is not.
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
variants, ok := clarifyQuestionVariants[slot]
if !ok || len(variants) == 0 {
return "", false
}
i := attempt - 1
if i < 0 {
i = 0
}
if i >= len(variants) {
i = len(variants) - 1
}
return variants[i], true
}
// clarifyMissedVariants — she is asking for the whole utterance again, because
// the gate fired on an intent with nothing identifiable to ask about (note,
// query, chat, system are not in wantedSlots).
//
// Rotated like the expiry lines and for the same reason: this is the line he
// hears whenever she misses him completely, so it is a line that repeats, and
// the same sentence every time is what makes a house assistant sound like a
// kiosk. All of them say the same two things — she did not catch it, and he
// should say it again — because the wording may vary and the meaning may not.
var clarifyMissedVariants = []string{
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
"Я тебя не разобрала. Повтори, пожалуйста.",
"Не уловила. Скажи это по-другому?",
"Прости, не поняла — попробуй сказать иначе.",
}
// clarifyMissedFor picks a wording by the utterance itself, so the same words
// asked twice get the same answer and two different misses sound different.
//
// A hash, not rand: a test that drives an utterance twice must not depend on a
// die roll, and the point of rotating is only that consecutive misses differ.
func clarifyMissedFor(utterance string) string {
var sum int
for _, r := range utterance {
sum += int(r)
}
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
}
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
// into a question. Returns "" for a decision that is not a clarify, so the
// caller keeps its own reply.
func clarifyMissedLine(dec router.Decision) string {
if !dec.Clarify {
return ""
}
return clarifyMissedFor(dec.Utterance)
}
-63
View File
@@ -1,63 +0,0 @@
package main
import (
"testing"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// Every slot she can ask about has a wording for every attempt she is allowed,
// and no two attempts on one slot read the same. A deck with a repeated line is
// the defect this deck exists to fix (Vikunja #457).
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
for slot, variants := range clarifyQuestionVariants {
seen := map[string]bool{}
for _, v := range variants {
if v == "" {
t.Errorf("%s: empty wording in the deck", slot)
}
if seen[v] {
t.Errorf("%s: repeated wording %q", slot, v)
}
seen[v] = true
}
for attempt := 1; attempt <= len(variants); attempt++ {
got, ok := clarifyQuestionFor(slot, attempt)
if !ok || got != variants[attempt-1] {
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
}
}
}
}
// Past the end she keeps the most explicit wording. Wrapping round would ask
// the short question he has already not answered twice.
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
for _, attempt := range []int{0, 4, 9} {
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
}
}
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
t.Error("an unknown slot must have no question")
}
}
// The missed line is stable for one utterance and absent for a decision that is
// not a clarify.
func TestClarifyMissedLine(t *testing.T) {
d := router.Decision{Clarify: true, Utterance: "мгм"}
first := clarifyMissedLine(d)
if first == "" || first != clarifyMissedLine(d) {
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
}
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
t.Errorf("a decision that is not a clarify got %q", got)
}
// The empty utterance still gets a line: she has to say something.
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
t.Error("an empty utterance must still be answered out loud")
}
}
-19
View File
@@ -194,22 +194,3 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
t.Fatal("the poller fetched a private address")
}
}
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
// deployed box has no feeds block and does have SearXNG, and "что происходит
// сейчас в новостях про искусственный интеллект?" got a configuration status
// instead of the live answer sitting one source below.
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
h := buildFeedHandler(t, false)
h.search = &searchWiring{max: 3, runes: 1500}
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
}
// With nothing below that reads the world, the honest status is still said:
// general knowledge would otherwise answer with an invented bulletin.
h.search = nil
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
}
}
-117
View File
@@ -1,117 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
// (Vikunja #456).
//
// Read-only over the facts that already exist. No new mechanism and no new
// storage: everything he tapped in is already a row with a source and a
// timestamp, and this only reads them back.
// historyMarkers — the ways he asks what he told her. Each entry is a pair of
// substrings that must BOTH appear, because either half alone is a different
// question: "что я говорил про сервер" is a recall question the notes pass
// answers better, and "что ты записала" with no "что" is not a question at all.
var historyMarkers = [][2]string{
{"что я", "говорил"},
{"что я", "сказал"},
{"что я", "рассказ"},
{"что ты", "записал"},
{"что ты", "запомнил"},
{"что я", "отмечал"},
{"что я", "отметил"},
{"what did i", "tell"},
{"what did you", "record"},
}
// historyRecall — the word that turns a history question into a recall
// question. "что я говорил про сервер" names a topic, and the notes pass
// answers a topic far better than a list of the last five facts does.
var historyRecall = []string{" про ", " об ", " о ", " about "}
// isHistoryQuery reports whether he is asking what he told her.
func isHistoryQuery(u string) bool {
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
if s == " " {
return false
}
for _, r := range historyRecall {
if strings.Contains(s, r) {
return false
}
}
for _, pair := range historyMarkers {
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
return true
}
}
return false
}
// historyScan — how many recent facts are read before filtering. Deliberately
// larger than historyReadOut: a poller writing every few minutes would
// otherwise push everything he said out of the window, the same way his own
// notes used to crowd out the feed headlines.
const historyScan = 100
// historyReadOut — how many she says out loud. Five is what fits in one spoken
// breath; the rest are on /history, which is the surface for reading a list.
const historyReadOut = 5
// historyWindow — how far back "recently" reaches. A day, because the question
// is about this conversation and not about the archive.
const historyWindow = 24 * time.Hour
// queryHistory answers what he told her, from the facts he tapped in.
//
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
// relay is a thing she learned, not a thing he said, and reading those back
// under "что я тебе говорил?" would put words in his mouth.
//
// Placed with the other sources that read his own rows and above the recall
// pass: the notes pass would otherwise answer this from whatever note happens
// to be nearest, which reads as an answer and is not one.
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
if !isHistoryQuery(t.dec.Utterance) {
return "", false
}
facts, err := h.api.RecentFacts(ctx, historyScan)
if err != nil {
log.Printf("voice: history: recent facts: %v", err)
return "не получилось посмотреть, что ты говорил.", true
}
cutoff := h.now().Add(-historyWindow)
var said []string
for _, f := range facts {
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
continue
}
said = append(said, historyLine(f.Key, f.Value, f.Ts))
if len(said) == historyReadOut {
break
}
}
if len(said) == 0 {
// Claim the turn rather than fall through. "ничего не говорил" is the
// true answer, and recall would answer it with an old note instead.
return "за последние сутки ты мне ничего такого не говорил.", true
}
return "ты говорил: " + strings.Join(said, "; "), true
}
// historyLine — one fact as she says it. The hour and minute, because the day
// is already bounded by historyWindow and a date would be noise.
func historyLine(key, value string, ts time.Time) string {
what := key
if value != "" {
what = key + " — " + value
}
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
}
-104
View File
@@ -1,104 +0,0 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
)
// historyAPI serves a fixed set of recent facts.
type historyAPI struct {
ipc.UnimplementedCoreAPI
facts []ipc.Fact
calls int
}
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
a.calls++
return a.facts, nil
}
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
api := &historyAPI{facts: facts}
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
}
func askHistory(h *reactiveHandler, u string) (string, bool) {
return h.queryHistory(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
})
}
func TestIsHistoryQuery(t *testing.T) {
for _, tc := range []struct {
text string
want bool
}{
{"что я тебе говорил?", true},
{"что ты записала сегодня?", true},
{"что я отмечал?", true},
// A named topic is a recall question, and the notes pass answers it
// better than a list of the last five facts does.
{"что я говорил про сервер?", false},
{"что у меня сегодня?", false},
{"", false},
} {
if got := isHistoryQuery(tc.text); got != tc.want {
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
}
}
}
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, api := historyHandler(now,
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
// Learned, not said: a poller writing this back under "что я тебе
// говорил?" would put words in his mouth.
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
// Older than the window.
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
)
reply, ok := askHistory(h, "что я тебе говорил?")
if !ok {
t.Fatal("the history question must be claimed before the recall sources")
}
if !strings.Contains(reply, "water") {
t.Errorf("reply = %q, want the fact he tapped in", reply)
}
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
t.Errorf("reply = %q, want only what he said inside the window", reply)
}
if api.calls != 1 {
t.Errorf("RecentFacts called %d times, want 1", api.calls)
}
}
// Nothing said is an answer of its own. Falling through would hand the question
// to recall, which answers it with an old note.
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
h, _ := historyHandler(now)
reply, ok := askHistory(h, "что я тебе говорил?")
if !ok || !strings.Contains(reply, "ничего") {
t.Fatalf("reply = %q, ok = %v", reply, ok)
}
}
// Five is what fits in one spoken breath; the rest are on /history.
func TestHistoryStopsAtFive(t *testing.T) {
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
var facts []ipc.Fact
for i := 0; i < 12; i++ {
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
}
h, _ := historyHandler(now, facts...)
reply, _ := askHistory(h, "что ты записала?")
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
}
}
-53
View File
@@ -1,53 +0,0 @@
package main
import (
"regexp"
"strings"
)
// reminderMarker — the words that open a reminder. Stripped because they are
// the instruction, not the thing to say at the hour.
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
// reminderTimeWords — the time expressions a reminder carries, removed from
// the body because the fire time is already a column. Ordered longest-first
// where two could match the same words, so "через полтора часа" does not leave
// "полтора" behind.
//
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
// boundaries here are written out as whitespace or an end of string — the same
// trap the agenda grammars hit.
var reminderTimeWords = []*regexp.Regexp{
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
}
// reminderBody is what she says at the hour.
//
// The whole utterance used to be stored, so /reminders read "напомни завтра в
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
// agenda recited the marker back at him (Vikunja #469). The fire time is
// already a column, and the marker is an instruction that was carried out.
//
// Falls back to the fuller text whenever stripping would leave nothing: an
// empty body is a reminder that fires and says nothing, which is worse than a
// wordy one.
func reminderBody(utterance, text string) string {
body := strings.TrimSpace(text)
if body == "" {
body = strings.TrimSpace(utterance)
}
stripped := reminderMarker.ReplaceAllString(body, "")
for _, re := range reminderTimeWords {
stripped = re.ReplaceAllString(stripped, " ")
}
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
stripped = strings.Trim(stripped, " ,;:—-")
if stripped == "" {
return body
}
return stripped
}
-21
View File
@@ -1,21 +0,0 @@
package main
import "testing"
func TestReminderBody(t *testing.T) {
for _, tc := range []struct{ utterance, text, want string }{
// The row from the QA sitting: the whole utterance was the body.
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
// Nothing left after stripping ⇒ keep what there was. A reminder that
// fires and says nothing is worse than a wordy one.
{"напомни завтра", "завтра", "завтра"},
{"", "", ""},
} {
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
}
}
}
+1 -6
View File
@@ -24,12 +24,7 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
// answer from the stub, which is what keeps a turn from breaking on the model.
func (r *llmReplier) Reply(d router.Decision) string {
if d.Clarify {
// The deck, not the stub's single sentence: a clarify she cannot turn
// into a question is the line he hears most often when she misses him,
// and it used to be the same words every time (Vikunja #457). Still no
// model call — this text has to be right every time, and it is not worth
// a generation to say something this small.
return clarifyMissedLine(d)
return r.stub.Reply(d)
}
out, err := r.p.PhraseReply(context.Background(), d)
if err != nil || out == "" {
+2 -14
View File
@@ -37,21 +37,9 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
}
// A clarify never reaches the model, and since Vikunja #457 it is answered from
// the clarify deck rather than the stub's single sentence.
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
func TestLLMReplierClarifyUsesStub(t *testing.T) {
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
if got == "я всё поняла" {
t.Fatal("a clarify must not be phrased by the model")
}
if want := clarifyMissedFor("мгм"); got != want {
t.Errorf("on clarify: got %q, want %q", got, want)
}
// Two different misses do not sound identical.
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
}
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
-3
View File
@@ -382,9 +382,6 @@ 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
+1 -33
View File
@@ -1,11 +1,6 @@
package main
import (
"context"
"testing"
"github.com/kami/maven/internal/router"
)
import "testing"
// TestExtractWeatherLocation — any place he names comes through, not just the
// six that used to be in a table (Vikunja #421).
@@ -36,30 +31,3 @@ 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")
}
}
+1 -14
View File
@@ -23,7 +23,6 @@ import (
"syscall"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -40,27 +39,15 @@ func run(args []string) error {
model := flag.String("model", "", "path to piper onnx model file")
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
flag.CommandLine.Parse(args)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
// Read before the handler is built: a dictionary he asked for and that
// cannot be read is a startup failure, not a warning. Saying names wrong
// in silence is the thing it exists to stop.
lex, err := tts.LoadLexicon(*lexiconPath)
if err != nil {
return err
}
if lex.Size() > 0 {
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
}
var s worker.Synthesizer
if *piperBin != "" && *model != "" {
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
} else {
log.Printf("mavttsd: no piper/model specified, using stub handler")
-16
View File
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -118,18 +117,3 @@ func abs(n int) int {
}
return n
}
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
// data, so nothing else would catch a trailing comma before the voice did.
func TestShippedLexiconLoads(t *testing.T) {
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
if err != nil {
t.Fatalf("deploy/tts-lexicon.json: %v", err)
}
if lex.Size() < 10 {
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
}
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
t.Error("the shipped dictionary did not rewrite a name it lists")
}
}
+2 -15
View File
@@ -9,7 +9,6 @@ import (
"os/exec"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/worker"
)
@@ -19,20 +18,15 @@ type piperHandler struct {
configPath string
espeakData string
tashkeelModel string
// lexicon rewrites service ids and Latin names into the spelling the
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
// dictionary rewrites nothing.
lexicon *tts.Lexicon
}
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
return &piperHandler{
piperPath: piperPath,
modelPath: modelPath,
configPath: modelPath + ".json",
espeakData: espeakData,
tashkeelModel: tashkeelModel,
lexicon: lexicon,
}
}
@@ -72,14 +66,7 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
}
// The dictionary is applied here, at the last edge before the voice: every
// caller's text passes through this one point, and nothing upstream has to
// know how a name is spelled out loud.
text := req.Text
if h.lexicon != nil {
text = h.lexicon.Apply(text)
}
if _, err := io.WriteString(stdin, text); err != nil {
if _, err := io.WriteString(stdin, req.Text); err != nil {
stdin.Close()
stdout.Close()
_ = cmd.Wait()
+1 -46
View File
@@ -946,51 +946,6 @@ 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)
@@ -1004,7 +959,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
log.Printf("reminders template: %v", err)
}
}
+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>{{.Created}}</td>
<td>{{.Fires}}</td>
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=text-max>{{.Text}}</td>
<td class=text-max>{{.Payload}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// The page showed the storage envelope and the UTC instant (Vikunja #469).
func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
rows := reminderRows([]ipc.Reminder{{
CreatedTs: fire.Add(-time.Hour),
FireTs: fire,
Status: "pending",
Payload: `{"text":"выпить таблетки"}`,
}})
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1", len(rows))
}
if rows[0].Text != "выпить таблетки" {
t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
}
if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
}
if strings.Contains(rows[0].Text, "{") {
t.Errorf("Text still carries JSON: %q", rows[0].Text)
}
}
// A payload that is not the envelope is his own words, so it is shown as it is.
func TestReminderTextKeepsPlainPayload(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{`{"text":"позвонить маме"}`, "позвонить маме"},
{" полить цветы ", "полить цветы"},
{`{"body":"nope"}`, `{"body":"nope"}`},
{"", ""},
} {
if got := reminderText(tc.in); got != tc.want {
t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"Maven": "Мэйвен",
"Nexus": "Нексус",
"Praxis": "Праксис",
"Hexis": "Хексис",
"Vikunja": "Викунья",
"SearXNG": "сёрчиксэнджи",
"Kiwix": "Кивикс",
"Gitea": "Гитея",
"Home Assistant": "Хоум Ассистент",
"Docker": "Докер",
"Telegram": "Телеграм",
"ntfy": "энтифай",
"homesrv": "хоумсёрв",
"workpc": "воркписи",
"whisper": "виспер",
"piper": "пайпер",
"llama-server": "лама сервер",
"Qwen": "Квен",
"CalDAV": "калдав",
"IMAP": "аймап",
"API": "эй-пи-ай",
"CPU": "си-пи-ю",
"GPU": "джи-пи-ю",
"RAM": "оперативная память",
"SSD": "эс-эс-ди",
"uptime": "аптайм",
"backup": "бэкап",
"deploy": "деплой"
}
-7
View File
@@ -90,13 +90,6 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
Without `-piper` it runs as a stub.
`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat
JSON object of name to Russian spelling, applied to the text just before piper
reads it. It is how `Vikunja` is said as a word rather than spelled out, and how
`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that
is set and unreadable stops mavttsd rather than letting it say names wrong in
silence. Adding a name needs a restart of mavttsd and nothing else.
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
No CGo, no deps; builds with stock Go.
+5
View File
@@ -15,6 +15,7 @@ require github.com/kami/hexis v0.0.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kami/praxis v0.0.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -24,4 +25,8 @@ require (
modernc.org/memory v1.11.0 // indirect
)
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
-3
View File
@@ -81,9 +81,6 @@ 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,7 +236,6 @@ 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
@@ -1,55 +0,0 @@
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
@@ -1,97 +0,0 @@
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
@@ -1,38 +0,0 @@
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())
}
}
+4 -39
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(SpellOutDigits(text)))
s := strings.ToLower(strings.TrimSpace(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,22 +204,14 @@ 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. 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).
// "в <clock>" anywhere — mirror of the English "at" scan.
for i := 0; i+1 < len(toks); i++ {
if toks[i] != "в" {
continue
}
t, ok := parseClock(toks[i+1], now)
if !ok {
continue
if t, ok := parseClock(toks[i+1], now); ok {
return t, true, nil
}
if i+2 < len(toks) {
t = applyRuQualifier(t, toks[i+2], now)
}
return t, true, nil
}
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
@@ -484,30 +476,3 @@ 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
}
+1 -27
View File
@@ -208,39 +208,13 @@ 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 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
// agendaQueryBuild — shared Build for the agenda grammars. 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) {
-128
View File
@@ -1,128 +0,0 @@
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
@@ -1,85 +0,0 @@
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,6 +15,8 @@ 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
@@ -77,3 +79,4 @@ 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