Merge pull request 'Conversation repair: name the misroute-correction mechanism as a feature' (#140) from task/455-conversation-repair-name-the-misroute-co into master
This commit was merged in pull request #140.
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
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