Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bbffa4f37 | |||
| 8b76dc50d3 | |||
| a1324e679f | |||
| 82ef1b0110 | |||
| 897dcf847a | |||
| 8b9e8e9f4e | |||
| b338d9bb40 | |||
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| bdafc82e35 | |||
| c69023c310 | |||
| 6a85e71077 | |||
| bf6c2bf1a6 | |||
| 1558233665 | |||
| 5afa2dfb38 |
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
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
|
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.
|
# 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_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||||
deps-go:
|
deps-go: deps-sentinel
|
||||||
@mkdir -p deps/go
|
@mkdir -p deps/go
|
||||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||||
@@ -84,6 +84,25 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(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
|
# 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.
|
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||||
# Run `gofmt -w` on whatever this prints.
|
# Run `gofmt -w` on whatever this prints.
|
||||||
@@ -178,7 +197,7 @@ run-tts: build-tts
|
|||||||
./mavttsd -socket /tmp/maven/tts.sock \
|
./mavttsd -socket /tmp/maven/tts.sock \
|
||||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||||
|
|
||||||
deps: deps-whisper deps-piper
|
deps: deps-sentinel deps-whisper deps-piper
|
||||||
|
|
||||||
deps-whisper:
|
deps-whisper:
|
||||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversation repair (Vikunja #455).
|
||||||
|
//
|
||||||
|
// The classifier has been able to learn from a correction since it was
|
||||||
|
// written — CorrectMisroute appends the utterance as a new example for the
|
||||||
|
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||||
|
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||||
|
// did not.
|
||||||
|
//
|
||||||
|
// This is the reachable half. He says she got it wrong and names what it
|
||||||
|
// should have been, she redoes the previous utterance under that intent, and
|
||||||
|
// she says out loud that the correction landed — because a correction he
|
||||||
|
// cannot see is indistinguishable from one that was dropped.
|
||||||
|
//
|
||||||
|
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||||
|
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||||
|
// it as one files the correction itself as a note.
|
||||||
|
|
||||||
|
// routedTurn — the previous utterance and where it went, which is all a
|
||||||
|
// correction needs to point at.
|
||||||
|
type routedTurn struct {
|
||||||
|
utterance string
|
||||||
|
intent router.Intent
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||||
|
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||||
|
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||||
|
// forgotten.
|
||||||
|
const repairWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||||
|
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||||
|
// treating it as a correction would rewrite the last turn every time he used
|
||||||
|
// the word.
|
||||||
|
var repairMarkers = []string{
|
||||||
|
"не так поняла",
|
||||||
|
"неправильно поняла",
|
||||||
|
"ты не поняла",
|
||||||
|
"не поняла меня",
|
||||||
|
"ты ошиблась",
|
||||||
|
"это не",
|
||||||
|
"а не",
|
||||||
|
"не про то",
|
||||||
|
"got it wrong",
|
||||||
|
"not a ",
|
||||||
|
"that was wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairIntents — the words he uses for each intent. Prefixes, because Russian
|
||||||
|
// declines them: "заметка", "заметку", "заметкой".
|
||||||
|
var repairIntents = []struct {
|
||||||
|
word string
|
||||||
|
intent router.Intent
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{"заметк", router.IntentNote, "заметка"},
|
||||||
|
{"напоминани", router.IntentReminder, "напоминание"},
|
||||||
|
{"напомнить", router.IntentReminder, "напоминание"},
|
||||||
|
{"факт", router.IntentFact, "факт"},
|
||||||
|
{"вопрос", router.IntentQuery, "вопрос"},
|
||||||
|
{"команд", router.IntentAct, "команда"},
|
||||||
|
{"note", router.IntentNote, "заметка"},
|
||||||
|
{"reminder", router.IntentReminder, "напоминание"},
|
||||||
|
{"fact", router.IntentFact, "факт"},
|
||||||
|
{"question", router.IntentQuery, "вопрос"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||||
|
// the intent it should have been.
|
||||||
|
//
|
||||||
|
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||||
|
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||||
|
// front of.
|
||||||
|
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||||
|
if s == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||||
|
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||||
|
// of a sentence is an ordinary word.
|
||||||
|
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||||
|
for _, m := range repairMarkers {
|
||||||
|
if marked || strings.Contains(s, m) {
|
||||||
|
marked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
best, say, at := router.Intent(""), "", -1
|
||||||
|
for _, w := range repairIntents {
|
||||||
|
i := strings.Index(s, w.word)
|
||||||
|
if i < 0 || negatedAt(s, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
|
||||||
|
if at < 0 || i < at {
|
||||||
|
best, say, at = w.intent, w.say, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return best, say, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// negatedAt reports whether the word at i is the one he is ruling out. Only
|
||||||
|
// the words immediately before it are read, so "не напоминание, а заметка"
|
||||||
|
// negates the first and leaves the second alone.
|
||||||
|
func negatedAt(s string, i int) bool {
|
||||||
|
before := strings.TrimSpace(s[:i])
|
||||||
|
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||||
|
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||||
|
// wrong about.
|
||||||
|
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
last := h.lastRouted
|
||||||
|
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||||
|
// twice would otherwise redo the same request twice.
|
||||||
|
h.lastRouted = nil
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||||
|
// classifier, redo the request under the corrected intent, and say so.
|
||||||
|
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||||
|
corrected, say, ok := parseRepair(text)
|
||||||
|
if !ok || h.router == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
last := h.takeLastTurn()
|
||||||
|
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if last.intent == corrected {
|
||||||
|
// She already did what he is asking for. Correcting the classifier
|
||||||
|
// here would teach it the label it produced, and redoing the request
|
||||||
|
// would file it twice.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
learned := true
|
||||||
|
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||||
|
// The redo is still worth doing: he asked for something and it did not
|
||||||
|
// happen. Only the learning half is lost, and he is told so.
|
||||||
|
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||||
|
learned = false
|
||||||
|
}
|
||||||
|
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: last.utterance,
|
||||||
|
Stage: 2,
|
||||||
|
Intent: corrected,
|
||||||
|
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||||
|
}
|
||||||
|
// A reminder's Text is what she says at the hour and stays empty when it
|
||||||
|
// was not spoken, so the gap is asked about rather than filled with the
|
||||||
|
// whole sentence. Everywhere else the utterance IS the payload.
|
||||||
|
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||||
|
dec.Slots.Text = last.utterance
|
||||||
|
}
|
||||||
|
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairLine — what she says before redoing it, so the correction is visible
|
||||||
|
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||||
|
// call, he did not complain about her.
|
||||||
|
func repairLine(say string, learned bool) string {
|
||||||
|
if !learned {
|
||||||
|
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||||
|
}
|
||||||
|
return "поняла, это " + say + " — запомнила."
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||||
|
{"нет, это заметка", router.IntentNote, true},
|
||||||
|
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||||
|
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||||
|
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||||
|
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||||
|
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||||
|
// No marker: an ordinary request that happens to name an intent.
|
||||||
|
{"запиши заметку купить хлеб", "", false},
|
||||||
|
{"напомни мне про заметку", "", false},
|
||||||
|
// A marker with no intent named: nothing to correct to.
|
||||||
|
{"ты не так поняла", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, _, ok := parseRepair(c.utterance)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||||
|
// previous utterance is filed under the intent he named, the classifier keeps
|
||||||
|
// it as an example, and he hears that it landed.
|
||||||
|
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||||
|
h, st, now := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
cls := router.NewClassifier(emb)
|
||||||
|
h.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("a spoken correction was not handled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "заметка") {
|
||||||
|
t.Errorf("the correction is not named out loud: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "не вышло") {
|
||||||
|
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := cls.Examples(router.IntentNote)
|
||||||
|
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||||
|
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||||
|
}
|
||||||
|
notes, err := st.RecentNotes(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent notes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||||
|
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||||
|
h, _, now := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Nothing said yet.
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction with no previous turn was handled")
|
||||||
|
}
|
||||||
|
// Said, but long ago.
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
*now = now.Add(repairWindow + time.Minute)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction outside the window was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairIsSpentOnce(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
h.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||||
|
t.Fatal("the first correction was not handled")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("the same turn was corrected twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||||
|
// is nothing to teach and redoing it would file the request a second time.
|
||||||
|
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
h.recordTurn("купить хлеб", router.IntentNote)
|
||||||
|
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction to the intent she already used was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ type reactiveHandler struct {
|
|||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||||
pending *pendingAct
|
pending *pendingAct
|
||||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||||
@@ -299,6 +300,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
return withNotice(expiredNotice, reply)
|
return withNotice(expiredNotice, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||||
|
// turn and names what it should have been (repair.go). Before routing,
|
||||||
|
// like the confirm and clarify turns: routing the correction as a fresh
|
||||||
|
// utterance files the correction itself instead of fixing anything.
|
||||||
|
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||||
// previous turn instead (continuation.go): the intent is the part it is
|
// previous turn instead (continuation.go): the intent is the part it is
|
||||||
// missing, so no amount of routing recovers it, and the model's guess
|
// missing, so no amount of routing recovers it, and the model's guess
|
||||||
@@ -359,6 +368,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember what this turn was routed as, so the next utterance can correct
|
||||||
|
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||||
|
// is nothing yet to be wrong about.
|
||||||
|
if !dec.Clarify {
|
||||||
|
h.recordTurn(text, dec.Intent)
|
||||||
|
}
|
||||||
|
|
||||||
// 8. action — execute the decision's intent. errors here surface as
|
// 8. action — execute the decision's intent. errors here surface as
|
||||||
// short reply text (the user wants to know the action didn't land);
|
// short reply text (the user wants to know the action didn't land);
|
||||||
// the round-trip stays alive.
|
// the round-trip stays alive.
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"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")
|
model := flag.String("model", "", "path to piper onnx model file")
|
||||||
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
||||||
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
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)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
defer stop()
|
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
|
var s worker.Synthesizer
|
||||||
if *piperBin != "" && *model != "" {
|
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)
|
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,3 +118,18 @@ func abs(n int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
|
||||||
|
// data, so nothing else would catch a trailing comma before the voice did.
|
||||||
|
func TestShippedLexiconLoads(t *testing.T) {
|
||||||
|
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy/tts-lexicon.json: %v", err)
|
||||||
|
}
|
||||||
|
if lex.Size() < 10 {
|
||||||
|
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
|
||||||
|
}
|
||||||
|
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
|
||||||
|
t.Error("the shipped dictionary did not rewrite a name it lists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,15 +19,20 @@ type piperHandler struct {
|
|||||||
configPath string
|
configPath string
|
||||||
espeakData string
|
espeakData string
|
||||||
tashkeelModel 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{
|
return &piperHandler{
|
||||||
piperPath: piperPath,
|
piperPath: piperPath,
|
||||||
modelPath: modelPath,
|
modelPath: modelPath,
|
||||||
configPath: modelPath + ".json",
|
configPath: modelPath + ".json",
|
||||||
espeakData: espeakData,
|
espeakData: espeakData,
|
||||||
tashkeelModel: tashkeelModel,
|
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)
|
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()
|
stdin.Close()
|
||||||
stdout.Close()
|
stdout.Close()
|
||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
|
|||||||
@@ -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": "деплой"
|
||||||
|
}
|
||||||
@@ -223,6 +223,23 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### Saying she got it wrong is a feature
|
||||||
|
|
||||||
|
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
|
||||||
|
corrected utterance as a new classifier example, and until now nothing in the
|
||||||
|
daemon called it, so the mechanism existed and the behaviour did not.
|
||||||
|
|
||||||
|
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
|
||||||
|
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
|
||||||
|
things happen in one turn: the classifier learns the utterance under the named
|
||||||
|
intent, the request is redone under it, and she says the correction landed. The
|
||||||
|
utterance he is correcting TO is the one with no "не" in front of it.
|
||||||
|
|
||||||
|
Read before routing, next to the confirm and clarify turns, because a correction
|
||||||
|
routed as a fresh utterance files the correction itself. One turn is correctable
|
||||||
|
once, inside five minutes, and only turns she acted on — a clarify asked instead
|
||||||
|
of acting, so there is nothing yet to be wrong about.
|
||||||
|
|
||||||
#### A restart expires a parked question
|
#### A restart expires a parked question
|
||||||
|
|
||||||
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
|
|||||||
|
|
||||||
Without `-piper` it runs as a stub.
|
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)
|
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
|
||||||
|
|
||||||
No CGo, no deps; builds with stock Go.
|
No CGo, no deps; builds with stock Go.
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ require github.com/kami/hexis v0.0.0
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -25,8 +24,4 @@ require (
|
|||||||
modernc.org/memory v1.11.0 // indirect
|
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
|
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
|||||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||||
// time, introspectable, no model surgery.
|
// time, introspectable, no model surgery.
|
||||||
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
||||||
|
if r == nil || r.classifier == nil {
|
||||||
|
// The LLM router can run with no classifier wired. The correction has
|
||||||
|
// nowhere to land then, and the caller redoes the request anyway.
|
||||||
|
return errors.New("router: no classifier to correct")
|
||||||
|
}
|
||||||
return r.classifier.AddExample(ctx, corrected, utterance)
|
return r.classifier.AddExample(ctx, corrected, utterance)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
-3
@@ -15,8 +15,6 @@ github.com/google/uuid
|
|||||||
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||||
## explicit; go 1.25.5
|
## explicit; go 1.25.5
|
||||||
github.com/kami/hexis/pkg/client
|
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
|
# github.com/mattn/go-isatty v0.0.20
|
||||||
## explicit; go 1.15
|
## explicit; go 1.15
|
||||||
github.com/mattn/go-isatty
|
github.com/mattn/go-isatty
|
||||||
@@ -79,4 +77,3 @@ modernc.org/memory
|
|||||||
modernc.org/sqlite
|
modernc.org/sqlite
|
||||||
modernc.org/sqlite/lib
|
modernc.org/sqlite/lib
|
||||||
modernc.org/sqlite/vtab
|
modernc.org/sqlite/vtab
|
||||||
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
|
|
||||||
|
|||||||
Reference in New Issue
Block a user