Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d32eae8aac | |||
| 63b645b405 | |||
| 0e82cb442f | |||
| d94ed2e630 | |||
| c8f74c39d6 | |||
| 44b8793e2f | |||
| a4b4733767 | |||
| 8f168ab811 | |||
| eb129c2fad | |||
| 0d5bd0a9f0 | |||
| 4d97280d74 | |||
| e5ec4abe04 | |||
| 7688dfde66 |
@@ -296,11 +296,18 @@ beside it, writing `routing_traces` (migration #23). The utterance is stored in
|
||||
clear, because a 384-dimension vector of a short sentence is substantially
|
||||
recoverable and storing vectors instead would be a privacy claim we cannot
|
||||
support. What makes it safe is the same thing that makes the fact store safe.
|
||||
Retention is 14 days, enforced on write. Nothing reads it outward, and the rule
|
||||
Retention is 14 days, enforced on write and again on start, so a box that goes
|
||||
quiet does not keep every row. Nothing reads it outward, and the rule
|
||||
that his notes and facts are never search input covers this table. `Store.Wipe`
|
||||
deletes it with everything else. A correction (V-630) is promoted out into a
|
||||
seed-shaped row and kept, because a label is not a transcript. The transcript
|
||||
still expires. Adding a rung to the ladder
|
||||
seed-shaped row in `routing_labels` (migration #24) and kept, because a label is
|
||||
not a transcript. The transcript still expires. The gesture that writes one is
|
||||
two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the
|
||||
trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no
|
||||
target is a usable negative, so naming the intent is never required. The target
|
||||
is one of the seven intents and never free text. Only `/chat` offers it: the wire
|
||||
op assumes no browser, but telegram and voice do not call it yet, and
|
||||
`docs/plans/22-correcting-a-turn.md` says why voice is the hard one. Adding a rung to the ladder
|
||||
in `runTurn` means adding its name to `preRouteLadder` in
|
||||
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
||||
|
||||
|
||||
@@ -60,6 +60,17 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrUnknownTarget):
|
||||
// The verb reached a tool and the tail did not reach a target, so
|
||||
// nothing ran. Saying which word she could not place is the whole
|
||||
// answer: he either renames it or gives the row an alias that
|
||||
// carries the target, and both are one turn away (V-634).
|
||||
word := ""
|
||||
var unknown *tool.UnknownTargetError
|
||||
if errors.As(err, &unknown) {
|
||||
word = unknown.Target
|
||||
}
|
||||
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
@@ -93,3 +104,4 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
}
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ import (
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// check that walks the ladder would have to run the ladder.
|
||||
var preRouteLadder = []string{
|
||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
|
||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair",
|
||||
"repair-negative", "ordinal",
|
||||
}
|
||||
|
||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,11 @@ type routedTurn struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
at time.Time
|
||||
// traceID — the persisted trace of this turn, stamped after the fact by
|
||||
// stampLastTurn. 0 when nothing persisted, and then a spoken correction
|
||||
// still teaches the classifier: the durable label is the half that needs a
|
||||
// row to point at (V-636).
|
||||
traceID int64
|
||||
}
|
||||
|
||||
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||
@@ -54,6 +60,13 @@ const repairWindow = 5 * time.Minute
|
||||
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||
var repairMarkers = lexicon.RepairMarkers()
|
||||
|
||||
// repairNegatives — "she got it wrong" with no target. Matched against the whole
|
||||
// utterance, because these are complete sentences and the markers above are
|
||||
// fragments: "это не" needs an intent word after it, "не так поняла" does not.
|
||||
// Substring matching here would claim "не так" out of any sentence containing it
|
||||
// (V-636).
|
||||
var repairNegatives = lexicon.RepairNegatives()
|
||||
|
||||
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||
@@ -147,6 +160,18 @@ func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||
}
|
||||
|
||||
// stampLastTurn attaches the trace id to the turn a correction would point at.
|
||||
// It cannot be done in recordTurn: the trace is written when the turn ends, and
|
||||
// recordTurn runs in the middle of it.
|
||||
func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
|
||||
return
|
||||
}
|
||||
h.lastRouted.traceID = traceID
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -157,6 +182,56 @@ func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveUntargetedRepair handles the cheap half of a spoken correction: he says
|
||||
// she got it wrong and does not say what it should have been (V-636).
|
||||
//
|
||||
// It is worth having on its own. V-630 made the target optional on the web for
|
||||
// the same reason: a turn marked wrong with no target is a usable negative, and
|
||||
// requiring the target would cost the correction he was willing to give. Voice
|
||||
// needs it more than the web does — naming an intent aloud means saying
|
||||
// "заметка" or "факт", which is Maven's vocabulary and not his.
|
||||
//
|
||||
// Nothing is redone and the classifier is not taught. There is no target, so
|
||||
// there is nothing to redo it as and nothing to teach. Only the label is written,
|
||||
// and she says so, because a correction he cannot see reads as one that was
|
||||
// dropped.
|
||||
func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text string) (string, bool) {
|
||||
if !isRepairNegative(text) {
|
||||
return "", false
|
||||
}
|
||||
last := h.takeLastTurn()
|
||||
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||
return "", false
|
||||
}
|
||||
if last.traceID == 0 {
|
||||
// No row to point at, so there is no label to write and nothing this
|
||||
// resolver can do. Routing the words normally is the honest outcome.
|
||||
return "", false
|
||||
}
|
||||
h.labelCorrection(ctx, last, "")
|
||||
log.Printf("voice: repair — %q marked wrong, no target given", last.utterance)
|
||||
return phraser.A(phraser.RepairNoted, nil), true
|
||||
}
|
||||
|
||||
// isRepairNegative matches the whole utterance, minus a leading "нет" and any
|
||||
// trailing punctuation. "нет, не так" is the shortest one he says.
|
||||
func isRepairNegative(utterance string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
s = strings.TrimRight(s, " .!?")
|
||||
for _, p := range []string{"нет,", "нет", "no,", "no"} {
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(s, p)); rest != s && rest != "" {
|
||||
s = rest
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, n := range repairNegatives {
|
||||
if s == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -182,6 +257,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
||||
learned = false
|
||||
}
|
||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||
h.labelCorrection(ctx, last, string(corrected))
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: last.utterance,
|
||||
@@ -207,3 +283,24 @@ func repairLine(say string, learned bool) string {
|
||||
}
|
||||
return "поняла, это " + say + " — запомнила."
|
||||
}
|
||||
|
||||
// labelCorrection promotes a spoken correction into routing_labels, the same
|
||||
// table the /chat gesture writes (V-630, V-636).
|
||||
//
|
||||
// Two sinks and not one, because they keep different things. CorrectMisroute
|
||||
// appends a classifier seed, which is what makes the NEXT turn better today.
|
||||
// The label is what a fitted head trains on later, it survives the 14-day
|
||||
// transcript, and until now only the web produced any. A sample that only ever
|
||||
// held typed turns would skew to whatever he happens to be at a keyboard for,
|
||||
// and voice is where the hard cases are.
|
||||
//
|
||||
// Best-effort and silent. He has already been told the correction landed, and a
|
||||
// second sink failing is not his problem to hear about.
|
||||
func (h *reactiveHandler) labelCorrection(ctx context.Context, last *routedTurn, shouldBe string) {
|
||||
if h.api == nil || last == nil || last.traceID == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.api.CorrectTurn(ctx, last.traceID, shouldBe); err != nil {
|
||||
log.Printf("voice: repair: could not label trace %d: %v", last.traceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||
@@ -149,3 +150,95 @@ func TestRepairIntentWordCollisions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// V-636. A spoken correction lands in the same table the /chat gesture writes,
|
||||
// so the sample is not limited to the turns he happened to type.
|
||||
func TestSpokenCorrectionWritesTheLabel(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||
Ts: h.now(), Utterance: "купить хлеб", Intent: "fact", Source: "tap:voice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
h.stampLastTurn("купить хлеб", id)
|
||||
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the correction was not handled")
|
||||
}
|
||||
labels, err := st.RoutingLabels(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].Was != "fact" || labels[0].ShouldBe != "note" {
|
||||
t.Fatalf("labels %+v: the spoken correction did not land as a pair", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// The cheap half, which voice needs more than the web does: naming an intent
|
||||
// aloud means saying "заметка", which is her vocabulary and not his.
|
||||
func TestUntargetedSpokenCorrection(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
seed := func(utterance string) int64 {
|
||||
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||
Ts: h.now(), Utterance: utterance, Intent: "query", Source: "tap:voice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.recordTurn(utterance, router.IntentQuery)
|
||||
h.stampLastTurn(utterance, id)
|
||||
return id
|
||||
}
|
||||
|
||||
seed("поужинал")
|
||||
reply, handled := h.resolveUntargetedRepair(ctx, "нет, не так")
|
||||
if !handled {
|
||||
t.Fatal("«нет, не так» was not read as a correction")
|
||||
}
|
||||
if reply == "" {
|
||||
t.Error("a correction he cannot hear reads as one that was dropped")
|
||||
}
|
||||
labels, err := st.RoutingLabels(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].ShouldBe != "" || labels[0].Was != "query" {
|
||||
t.Fatalf("labels %+v: want one untargeted negative naming what she chose", labels)
|
||||
}
|
||||
|
||||
// Outside the window it is a fresh sentence, not a verdict.
|
||||
seed("поужинал ещё раз")
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveUntargetedRepair(ctx, "не так"); handled {
|
||||
t.Error("a correction outside the window was handled")
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-utterance, never a substring. This is the difference between the
|
||||
// negatives and the markers, and getting it wrong would claim any sentence with
|
||||
// "не так" in it.
|
||||
func TestRepairNegativeIsTheWholeUtterance(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"не так поняла", "нет, не так", "ты ошиблась", "неправильно", "wrong", "no, that was wrong",
|
||||
} {
|
||||
if !isRepairNegative(s) {
|
||||
t.Errorf("%q is not read as a correction", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"это не важно", "напомни не так поздно", "а не завтра", "не так, а вот так — это заметка",
|
||||
"", "нет",
|
||||
} {
|
||||
if isRepairNegative(s) {
|
||||
t.Errorf("%q was read as a correction", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
@@ -38,6 +39,44 @@ func traceSink(s *store.Store) traceWriter {
|
||||
return s
|
||||
}
|
||||
|
||||
// The trace id rides the context, the same seam querysource.go uses and for the
|
||||
// same reason: handleText answers every reach through one string, and threading
|
||||
// a second value through the whole action dispatch would change a signature the
|
||||
// mic, telegram and the web all share. A caller that wants the id asks for a
|
||||
// sink; the mic path does not, and pays nothing.
|
||||
type traceIDKey struct{}
|
||||
|
||||
type traceIDSink struct {
|
||||
mu sync.Mutex
|
||||
id int64
|
||||
}
|
||||
|
||||
func (s *traceIDSink) note(id int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
}
|
||||
|
||||
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
|
||||
func (s *traceIDSink) ID() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
// withTraceIDSink returns a context that collects the persisted trace id, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
|
||||
sink := &traceIDSink{}
|
||||
return context.WithValue(ctx, traceIDKey{}, sink), sink
|
||||
}
|
||||
|
||||
func noteTraceID(ctx context.Context, id int64) {
|
||||
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
|
||||
sink.note(id)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
|
||||
// alone is not enough: a box that goes quiet for a month keeps every row until
|
||||
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
|
||||
@@ -58,7 +97,8 @@ func pruneTracesOnStart(s *store.Store, now time.Time) {
|
||||
//
|
||||
// Errors are logged and swallowed. A trace is diagnostic and training data, and
|
||||
// a failed insert must never change what the owner hears.
|
||||
func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Record, src turnSource) {
|
||||
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
|
||||
ctx := turnCtx
|
||||
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
|
||||
return
|
||||
}
|
||||
@@ -85,9 +125,19 @@ func (h *reactiveHandler) persistDecision(ctx context.Context, rec *decision.Rec
|
||||
Outcome: wonAt(rec, decision.StageAction),
|
||||
Claims: claims,
|
||||
}
|
||||
if _, err := h.traces.WriteRoutingTrace(ctx, tr); err != nil {
|
||||
id, err := h.traces.WriteRoutingTrace(ctx, tr)
|
||||
if err != nil {
|
||||
log.Printf("routing trace: write: %v", err)
|
||||
return
|
||||
}
|
||||
// The id goes back to whoever asked for it, so /chat can offer a correction
|
||||
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
|
||||
// not the detached one above: the sink belongs to the caller's turn.
|
||||
noteTraceID(turnCtx, id)
|
||||
// And the spoken path, which has no reply to hang a badge on: a correction
|
||||
// said out loud points at the previous turn, so it needs that turn's row
|
||||
// (V-636, repair.go).
|
||||
h.stampLastTurn(rec.Utterance, id)
|
||||
}
|
||||
|
||||
// wonIntent — what the winning claimant made the turn. Read from the claim
|
||||
|
||||
@@ -77,3 +77,49 @@ func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
|
||||
}
|
||||
|
||||
var _ traceWriter = (*store.Store)(nil)
|
||||
|
||||
// The trace id rides back to the caller, which is what makes a correction one
|
||||
// gesture: /chat already has the id, so saying "that was wrong" costs a button
|
||||
// and no lookup (V-630).
|
||||
func TestTurnHandsBackItsTraceID(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
ctx, sink := withTraceIDSink(context.Background())
|
||||
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
id := sink.ID()
|
||||
if id == 0 {
|
||||
t.Fatal("no trace id came back, so /chat can offer no correction")
|
||||
}
|
||||
// And it names the turn that just ran, so the correction lands on the right
|
||||
// utterance.
|
||||
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
|
||||
t.Fatalf("labels %+v, want the turn that just ran", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn nobody asked the id of costs nothing, which is the mic path.
|
||||
func TestTurnWithNoSinkStillPersists(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -97,10 +97,19 @@ func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.Ch
|
||||
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
||||
}
|
||||
ctx, sink := withQuerySourceSink(ctx)
|
||||
// The trace id rides back the same way (V-630), so /chat can offer a
|
||||
// correction on the turn it is already showing. 0 when nothing persisted.
|
||||
ctx, traces := withTraceIDSink(ctx)
|
||||
reply := d.chatFn(ctx, conversation, text)
|
||||
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
|
||||
return ipc.ChatReply{Reply: reply, Source: sink.Name(), TraceID: traces.ID()}, nil
|
||||
}
|
||||
|
||||
// CorrectTurn is NOT overridden here, and that is deliberate (V-630). Every other
|
||||
// diagnostic on this type exists because the daemon holds something the store
|
||||
// cannot answer from a table. A correction is a table, so the embedded store
|
||||
// adapter is already the right answer and a second implementation here would be
|
||||
// a second place for it to drift.
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
|
||||
@@ -365,6 +365,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4d-ii. and the same correction without a target — "нет, не так" (V-636).
|
||||
// After the targeted one, which is the narrower claim: an utterance that
|
||||
// names an intent is answered by redoing the request, and this rung only
|
||||
// gets the ones that name nothing.
|
||||
if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||
|
||||
+105
-2
@@ -2,12 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,21 @@ type chatMsg struct {
|
||||
// Source — the query source that claimed the turn, shown as a badge beside
|
||||
// the reply. Empty for a turn no source claimed (V-539).
|
||||
Source string
|
||||
// TraceID anchors the correction gesture (V-630). Non-zero ⇒ the turn was
|
||||
// persisted and can be corrected in one click. 0 ⇒ no correction is offered,
|
||||
// which is honest: a box with no database has no turn to correct.
|
||||
TraceID int64
|
||||
// Corrected — the owner already corrected this turn, so the page says thank
|
||||
// you instead of offering the buttons again.
|
||||
Corrected string
|
||||
}
|
||||
|
||||
// correctionTargets — the seven public intents, in the order the buttons are
|
||||
// shown. Read from internal/router rather than typed out, so a new intent cannot
|
||||
// exist without a way to correct a turn into it.
|
||||
var correctionTargets = []router.Intent{
|
||||
router.IntentFact, router.IntentNote, router.IntentReminder,
|
||||
router.IntentQuery, router.IntentAct, router.IntentChat, router.IntentSystem,
|
||||
}
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
@@ -38,12 +56,21 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
||||
id, _ := strconv.ParseInt(r.URL.Query().Get("t"), 10, 64)
|
||||
msgs = append(msgs, chatMsg{
|
||||
Role: "assistant", Text: reply, Source: r.URL.Query().Get("s"),
|
||||
TraceID: id, Corrected: r.URL.Query().Get("c"),
|
||||
})
|
||||
}
|
||||
// UserText rides beside the messages so the correction form can hand the
|
||||
// conversation back on the redirect: this page has no session and no JS, so
|
||||
// what is on screen is what the query params carry.
|
||||
renderPage(w, chatTmpl, struct {
|
||||
Error string
|
||||
Messages []chatMsg
|
||||
}{Messages: msgs})
|
||||
Targets []router.Intent
|
||||
UserText string
|
||||
}{Messages: msgs, Targets: correctionTargets, UserText: r.URL.Query().Get("q")})
|
||||
}
|
||||
|
||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||
@@ -88,5 +115,81 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
if reply.Source != "" {
|
||||
dest += "&s=" + url.QueryEscape(reply.Source)
|
||||
}
|
||||
// The trace id rides along so the reply can carry a correction gesture
|
||||
// (V-630). Absent when nothing persisted, and the page then offers none.
|
||||
if reply.TraceID != 0 {
|
||||
dest += "&t=" + strconv.FormatInt(reply.TraceID, 10)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleCorrectAPI records that the last turn was routed wrongly (V-630).
|
||||
//
|
||||
// A correction is the only supervised signal this box gets, and everything else
|
||||
// in the trace accumulates on its own. So the gesture has to cost nothing: one
|
||||
// POST from the reply he is already looking at, carrying the trace id and
|
||||
// optionally the intent it should have been. An unstated target is accepted,
|
||||
// because a turn marked wrong with no target is still a usable negative.
|
||||
//
|
||||
// Step-up gated like POST /api/chat, and that costs the gesture nothing: he
|
||||
// tapped to send the turn he is now correcting, so the session is already up.
|
||||
// It is gated because trace ids are sequential integers and this writes the one
|
||||
// table the routing heads (V-546) will be fitted on. A caller who can guess an
|
||||
// id could otherwise mislabel turns he never corrected.
|
||||
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "correct") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "trace_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
shouldBe := strings.TrimSpace(r.FormValue("should_be"))
|
||||
// Only one of the seven, or nothing. Free text here would put an unroutable
|
||||
// label in the one table V-632 fits prototypes from.
|
||||
if shouldBe != "" && !isCorrectionTarget(shouldBe) {
|
||||
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
|
||||
log.Printf("correct turn %d: %v", id, err)
|
||||
// A turn past the retention bound is gone, and saying so is different
|
||||
// from saying the write broke.
|
||||
if errors.Is(err, ipc.ErrNoSuchTrace) {
|
||||
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "correction failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
stamp := shouldBe
|
||||
if stamp == "" {
|
||||
stamp = "wrong"
|
||||
}
|
||||
// Back to the conversation he was in, with the turn still on screen. The
|
||||
// query params carry it, so the correction is preserved by re-sending them.
|
||||
dest := "/chat?q=" + url.QueryEscape(r.FormValue("q")) +
|
||||
"&r=" + url.QueryEscape(r.FormValue("rep")) + "&c=" + url.QueryEscape(stamp)
|
||||
if s := r.FormValue("s"); s != "" {
|
||||
dest += "&s=" + url.QueryEscape(s)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// isCorrectionTarget — one of the seven, and nothing else.
|
||||
func isCorrectionTarget(s string) bool {
|
||||
for _, t := range correctionTargets {
|
||||
if string(t) == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
|
||||
{{if and (eq .Role "assistant") .TraceID}}
|
||||
{{if .Corrected}}
|
||||
<div class=chat-correct><span class="badge badge-ok" title="the label is kept; the transcript still expires in 14 days">corrected: {{.Corrected}}</span></div>
|
||||
{{else}}
|
||||
<form method=post action=/api/correct class=chat-correct>
|
||||
<input type=hidden name=trace_id value="{{.TraceID}}">
|
||||
<input type=hidden name=q value="{{$.UserText}}">
|
||||
<input type=hidden name=rep value="{{.Text}}">
|
||||
<input type=hidden name=s value="{{.Source}}">
|
||||
<button class="btn btn-sm" title="wrong, and I am not saying what it was">wrong</button>
|
||||
<span class=chat-correct-label>should have been:</span>
|
||||
{{range $.Targets}}<button class="btn btn-sm btn-muted" name=should_be value="{{.}}">{{.}}</button>{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// correctCore records the correction the handler sends.
|
||||
type correctCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
traceID int64
|
||||
shouldBe string
|
||||
called bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *correctCore) CorrectTurn(_ context.Context, traceID int64, shouldBe string) error {
|
||||
c.called, c.traceID, c.shouldBe = true, traceID, shouldBe
|
||||
return c.err
|
||||
}
|
||||
|
||||
func postCorrect(form url.Values) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/correct", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
// The full gesture: wrong, and it should have been a fact.
|
||||
func TestCorrectAPIWithTarget(t *testing.T) {
|
||||
core := &correctCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{
|
||||
"trace_id": {"42"}, "should_be": {"fact"}, "q": {"поужинал"}, "rep": {"поняла"},
|
||||
}), core, stepUpSession(), false)
|
||||
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status %d, want 303; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if core.traceID != 42 || core.shouldBe != "fact" {
|
||||
t.Errorf("corrected trace %d to %q", core.traceID, core.shouldBe)
|
||||
}
|
||||
// The turn stays on screen, and the page says it was corrected.
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "c=fact") || !strings.Contains(loc, "q=") {
|
||||
t.Errorf("redirect %q loses the turn or the correction", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// The cheap half. A turn marked wrong with no target is still a usable negative,
|
||||
// and it must not cost more to give than the full answer.
|
||||
func TestCorrectAPIWithNoTarget(t *testing.T) {
|
||||
core := &correctCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core, stepUpSession(), false)
|
||||
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status %d, want 303", rr.Code)
|
||||
}
|
||||
if !core.called || core.shouldBe != "" {
|
||||
t.Errorf("called=%v shouldBe=%q, want an untargeted negative recorded", core.called, core.shouldBe)
|
||||
}
|
||||
if !strings.Contains(rr.Header().Get("Location"), "c=wrong") {
|
||||
t.Errorf("redirect %q does not say the turn was marked wrong", rr.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Free text here would put an unroutable label in the one table V-632 fits
|
||||
// prototypes from.
|
||||
func TestCorrectAPIRejectsUnknownTarget(t *testing.T) {
|
||||
core := &correctCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core, stepUpSession(), false)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400", rr.Code)
|
||||
}
|
||||
if core.called {
|
||||
t.Error("wrote a label for a target that is not one of the seven")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectAPINeedsTraceID(t *testing.T) {
|
||||
for _, form := range []url.Values{{}, {"trace_id": {"0"}}, {"trace_id": {"nope"}}} {
|
||||
core := &correctCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(form), core, stepUpSession(), false)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("form %v: status %d, want 400", form, rr.Code)
|
||||
}
|
||||
if core.called {
|
||||
t.Errorf("form %v: reached the core", form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A write that broke is not a turn that expired, and the two must not read the
|
||||
// same to the owner deciding whether to correct again.
|
||||
func TestCorrectAPIReportsFailure(t *testing.T) {
|
||||
core := &correctCore{err: errors.New("disk is full")}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
|
||||
if rr.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d, want 502", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A trace past the retention bound is gone, and the surface says that.
|
||||
func TestCorrectAPIExpiredTurn(t *testing.T) {
|
||||
core := &correctCore{err: ipc.ErrNoSuchTrace}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectAPIPostOnly(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}, stepUpSession(), false)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status %d, want 405", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Every one of the seven intents has a button, so a new intent cannot exist with
|
||||
// no way to correct a turn into it.
|
||||
func TestCorrectionTargetsAreTheSeven(t *testing.T) {
|
||||
if len(correctionTargets) != 7 {
|
||||
t.Fatalf("%d targets, want the seven public intents", len(correctionTargets))
|
||||
}
|
||||
for _, want := range []string{"fact", "note", "reminder", "query", "act", "chat", "system"} {
|
||||
if !isCorrectionTarget(want) {
|
||||
t.Errorf("%s is not offered", want)
|
||||
}
|
||||
}
|
||||
if isCorrectionTarget("") {
|
||||
t.Error("empty is not a target: it is the absence of one, handled separately")
|
||||
}
|
||||
}
|
||||
|
||||
// Trace ids are sequential, so a caller who cannot assert step-up must not be
|
||||
// able to label a turn the owner never corrected.
|
||||
func TestCorrectAPINeedsStepUp(t *testing.T) {
|
||||
core := &correctCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, nil, true)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", rr.Code)
|
||||
}
|
||||
if core.called {
|
||||
t.Error("wrote a label with no step-up")
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ func main() {
|
||||
mux.HandleFunc("/routines", gatedPage(handleRoutines))
|
||||
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
|
||||
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
|
||||
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
@@ -702,6 +702,11 @@ details[open] > summary { margin-bottom: var(--space-1); }
|
||||
.chat-form { display: flex; gap: var(--space-2); }
|
||||
.chat-form input { flex: 1; }
|
||||
.chat-scroll { max-height: 60vh; overflow-y: auto; margin-bottom: var(--space-4); }
|
||||
/* The correction gesture (V-630). Wraps on a phone rather than scrolling: it is
|
||||
one row of small buttons, and a gesture that has to be panned to is not one. */
|
||||
.chat-correct { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1);
|
||||
padding: 0 var(--space-3) var(--space-2); margin-top: calc(-1 * var(--space-1)); margin-bottom: var(--space-2); }
|
||||
.chat-correct-label { font-size: var(--fs-xs); color: var(--text-machine); margin-left: var(--space-2); }
|
||||
|
||||
/* ── Key-value grid ── */
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Correcting a turn
|
||||
|
||||
Last verified: 06-08-2026 @ 0d5bd0a
|
||||
|
||||
V-630, under V-628. Reads with `21-persisting-the-routing-trace.md`.
|
||||
|
||||
## Why a gesture and not a form
|
||||
|
||||
The routing trace (V-629) stores every turn. Almost all of them routed correctly, so
|
||||
almost all of them teach nothing. A correction is the only high-value supervised signal
|
||||
the box produces. It is also the only one that costs the owner something to give.
|
||||
|
||||
So the design constraint is the cost, not the schema. One gesture beside the reply. No
|
||||
form and no separate page.
|
||||
|
||||
It is step-up gated like the chat POST beside it, which costs nothing: he tapped to send
|
||||
the turn he is correcting. It is gated because trace ids are sequential integers, and this
|
||||
is the one table the routing heads will be fitted on.
|
||||
|
||||
## Two things to capture, and only one of them is required
|
||||
|
||||
A correction has two halves.
|
||||
|
||||
- This turn was wrong.
|
||||
- It should have been *this*.
|
||||
|
||||
The second is worth much more. It names which boundary moved, and it is what a fitted
|
||||
head trains against. But requiring it would price out the first, and a turn marked wrong
|
||||
with no target is still a usable negative. So the target is optional. The trace carries
|
||||
`wrong` when he did not say.
|
||||
|
||||
The target is one of the seven intents and never free text. V-632 fits prototypes from
|
||||
that table. An unroutable label would enter it, and a label nothing can score is worse
|
||||
than no label.
|
||||
|
||||
## Where the label lives
|
||||
|
||||
`routing_labels`, migration #24, keyed unique on the utterance. A second correction of
|
||||
the same sentence replaces the first, because his later answer is the one he meant.
|
||||
|
||||
It is a separate table from `routing_traces` on purpose. The transcript expires after 14
|
||||
days. The label does not. A label is a sentence, an intent and an encoder id. That is not
|
||||
a transcript, and the reversal in doc 21 rests on the distinction.
|
||||
|
||||
`was` is stored beside `should_be`. The pair is what names the confusion. A label with no
|
||||
`was` cannot say which boundary moved.
|
||||
|
||||
## Reach
|
||||
|
||||
`CorrectTurn(traceID, shouldBe)` takes no browser and no session. The trace id rides back
|
||||
on `ipc.ChatReply` through the same context sink the query source badge uses. Nothing in
|
||||
the seam assumes the web.
|
||||
|
||||
Only `/chat` offers the gesture today. That is a gap, named rather than closed. If the web
|
||||
is the only place to correct a turn, the sample skews to whatever the owner types at. Voice
|
||||
is where the hard cases are. Telegram has the obvious shape, an inline keyboard on the
|
||||
reply. Voice does not. Inventing a spoken correction grammar would put a recogniser in
|
||||
front of the one signal that exists to fix recognisers. Both are follow-on work.
|
||||
|
||||
## What is not decided
|
||||
|
||||
Whether the owner ever wants to see the labels he gave. Nothing reads the table outward
|
||||
yet. `/trace` shows the ring, which is 25 turns and in memory, and a labels view is a
|
||||
different page with a different question.
|
||||
+21
-2
@@ -724,8 +724,15 @@ type chatReq struct {
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
TraceID int64 `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
// correctTurnReq — the owner correcting one persisted turn (V-630).
|
||||
type correctTurnReq struct {
|
||||
TraceID int64 `json:"trace_id"`
|
||||
ShouldBe string `json:"should_be,omitempty"`
|
||||
}
|
||||
|
||||
// ChatReply — one text turn's answer plus which query source claimed it.
|
||||
@@ -738,6 +745,12 @@ type chatResp struct {
|
||||
type ChatReply struct {
|
||||
Reply string
|
||||
Source string
|
||||
// TraceID is the persisted routing trace for this turn (V-629), and it is
|
||||
// what makes a correction one gesture: the surface already has the id, so
|
||||
// saying "that was wrong" costs a button and no lookup. 0 ⇒ nothing was
|
||||
// persisted, which is a box with no database, and the surface offers no
|
||||
// correction rather than a broken one.
|
||||
TraceID int64
|
||||
}
|
||||
|
||||
type proposeToolReq struct {
|
||||
@@ -937,6 +950,12 @@ var ErrTaskDuplicate = errors.New("ipc: another live task already has this text"
|
||||
// is down" must not read the same to a caller deciding whether to store an id.
|
||||
var ErrNoEntity = errors.New("ipc: no such entity")
|
||||
|
||||
// ErrNoSuchTrace — the turn a correction names is not in routing_traces. Given
|
||||
// a wire twin because it is the expected outcome of correcting a turn older than
|
||||
// the retention bound, and "that turn is gone" and "the database is broken" must
|
||||
// not read the same to the surface offering the gesture.
|
||||
var ErrNoSuchTrace = errors.New("ipc: no such routing trace")
|
||||
|
||||
// ErrTaskResolved — a resolved task is not editable.
|
||||
var ErrTaskResolved = errors.New("ipc: task is resolved")
|
||||
|
||||
|
||||
@@ -247,6 +247,8 @@ func hydrate(e *RpcError) error {
|
||||
return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
|
||||
case codeToolNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
|
||||
case codeNoSuchTrace:
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchTrace, e.Message)
|
||||
case codeUnknownMethod:
|
||||
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
|
||||
case codeBadParams:
|
||||
@@ -661,7 +663,11 @@ func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source, TraceID: r.TraceID}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return c.call(ctx, MethodCorrectTurn, correctTurnReq{TraceID: traceID, ShouldBe: shouldBe}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
|
||||
@@ -176,6 +176,14 @@ type SystemAPI interface {
|
||||
// has run since the daemon started.
|
||||
TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error)
|
||||
|
||||
// CorrectTurn records that one persisted turn was routed wrongly, and what
|
||||
// it should have been (V-630). shouldBe empty means "wrong, target
|
||||
// unstated", which is a usable negative and must not cost more to give than
|
||||
// the full answer. Unlike TurnDecisions this DOES reach a table, because a
|
||||
// correction is the only supervised signal the box gets and it has to
|
||||
// outlive the trace that carried it.
|
||||
CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
|
||||
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
||||
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
||||
|
||||
@@ -514,7 +514,10 @@ var methodTable = map[Method]handlerFunc{
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source}, err
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source, TraceID: reply.TraceID}, err
|
||||
}),
|
||||
MethodCorrectTurn: withParams(func(ctx context.Context, api CoreAPI, p correctTurnReq) (struct{}, error) {
|
||||
return struct{}{}, api.CorrectTurn(ctx, p.TraceID, p.ShouldBe)
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
return api.TickTrace(ctx)
|
||||
|
||||
@@ -213,6 +213,13 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||
}
|
||||
|
||||
// CorrectTurn — unlike TickTrace and TurnDecisions this one is a table, so the
|
||||
// store adapter answers it for real (V-630). A correction has to land whether
|
||||
// the caller reached the daemon or the store directly.
|
||||
func (a *storeAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return mapErr(a.s.CorrectTurn(ctx, traceID, shouldBe, time.Now()))
|
||||
}
|
||||
|
||||
// TurnDecisions — same story as TickTrace: the arbitration record is a daemon
|
||||
// ring, not a table, so there is nothing here to read it from (V-564).
|
||||
func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
|
||||
@@ -412,6 +419,8 @@ func mapErr(err error) error {
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrNoSuchTrace):
|
||||
return ErrNoSuchTrace
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
case errors.Is(err, store.ErrTaskDuplicate):
|
||||
|
||||
@@ -144,6 +144,10 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64,
|
||||
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodTurnDecisions Method = "turn_decisions"
|
||||
MethodCorrectTurn Method = "correct_turn"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodMCPServers Method = "mcp_servers"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
@@ -125,6 +126,7 @@ const (
|
||||
codeReminderMissing = "reminder_not_found"
|
||||
codeReminderState = "reminder_state"
|
||||
codeToolNotFound = "tool_not_found"
|
||||
codeNoSuchTrace = "no_such_trace"
|
||||
codeUnknownMethod = "unknown_method"
|
||||
codeBadParams = "bad_params"
|
||||
codeForbidden = "forbidden"
|
||||
@@ -160,6 +162,8 @@ func codeOf(err error) string {
|
||||
return codeReminderState
|
||||
case errors.Is(err, ErrToolNotFound):
|
||||
return codeToolNotFound
|
||||
case errors.Is(err, ErrNoSuchTrace):
|
||||
return codeNoSuchTrace
|
||||
case errors.Is(err, ErrUnknownMethod):
|
||||
return codeUnknownMethod
|
||||
case errors.Is(err, ErrBadParams):
|
||||
|
||||
@@ -97,6 +97,11 @@ func NarrativeRequests() []string { return words("narrative_requests") }
|
||||
// the set's own note for why this one is a list and not a seed set.
|
||||
func RepairMarkers() []string { return words("repair_markers") }
|
||||
|
||||
// RepairNegatives lists the ways he says the previous turn was wrong without
|
||||
// saying what it should have been. Matched against the whole utterance, never as
|
||||
// substrings — see the set's own note.
|
||||
func RepairNegatives() []string { return words("repair_negatives") }
|
||||
|
||||
// FirstPerson lists every form of the first-person pronoun. Callers use it to
|
||||
// decide that a sentence is about him: internal/router/complaint.go keeps a
|
||||
// complaint out of the fact store unless one of these appears, because losing a
|
||||
|
||||
@@ -147,6 +147,10 @@
|
||||
"got it wrong", "not a ", "that was wrong"
|
||||
]
|
||||
},
|
||||
"repair_negatives": {
|
||||
"note": "The ways he says she got it wrong WITHOUT saying what it should have been. Matched against the WHOLE utterance, not as substrings, which is what keeps them apart from repair_markers: \u0022\u044d\u0442\u043e \u043d\u0435\u0022 is a fragment that needs an intent word after it, while these are complete sentences. A member that could appear inside an ordinary sentence does not belong here.",
|
||||
"words": ["не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня", "ты ошиблась", "не так", "неправильно", "это неправильно", "that was wrong", "got it wrong", "you got it wrong", "wrong"]
|
||||
},
|
||||
"first_person": {
|
||||
"note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.",
|
||||
"words": [
|
||||
|
||||
@@ -45,6 +45,14 @@ const (
|
||||
// is the only authority the voice path can offer, and this is the one act
|
||||
// it is not enough for (Vikunja #449, #523).
|
||||
ActNeedsAuthedSurface = "act_needs_authed_surface"
|
||||
// ActUnknownTarget — the verb reached a tool and the target did not reach
|
||||
// anything. Named rather than run, because the alias match swallowed the verb
|
||||
// and handed on the next word of the sentence (V-634).
|
||||
ActUnknownTarget = "act_unknown_target"
|
||||
// RepairNoted — he said the turn was wrong and did not say what it should
|
||||
// have been. She confirms the label landed and does not ask, because the
|
||||
// answer would be one of her own intent names (V-636).
|
||||
RepairNoted = "repair_noted"
|
||||
|
||||
EcoDenied = "eco_denied"
|
||||
EcoDown = "eco_down"
|
||||
@@ -76,7 +84,7 @@ const (
|
||||
var actKeys = []string{
|
||||
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
|
||||
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
|
||||
ActNeedsAuthedSurface,
|
||||
ActNeedsAuthedSurface, ActUnknownTarget, RepairNoted,
|
||||
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
|
||||
AttentionNone, AttentionList, AttentionFail,
|
||||
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
|
||||
@@ -102,6 +110,8 @@ var actFloor = map[string]string{
|
||||
ActServerDown: "инструмент есть, но сервер не подключён.",
|
||||
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
|
||||
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
||||
RepairNoted: "поняла, отметила, что ответила не так.",
|
||||
ActUnknownTarget: "«{name}» — не знаю такой цели. назови её как в системе.",
|
||||
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
|
||||
|
||||
EcoDenied: "{name} отклоняет доступ, проверь токен.",
|
||||
|
||||
@@ -60,6 +60,14 @@
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"repair_noted": {
|
||||
"fixed": true,
|
||||
"variants": ["поняла, отметила, что ответила не так."]
|
||||
},
|
||||
"act_unknown_target": {
|
||||
"fixed": true,
|
||||
"variants": ["«{name}» — не знаю такой цели. назови её как в системе."]
|
||||
},
|
||||
"act_needs_authed_surface": {
|
||||
"fixed": true,
|
||||
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
|
||||
|
||||
@@ -330,6 +330,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
claims TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
|
||||
// #24 — the corrected pairs (V-630). Separate from routing_traces on
|
||||
// purpose, and this is the whole retention argument: a trace is a transcript
|
||||
// and expires in 14 days, while a correction is a label the owner wrote by
|
||||
// hand and is the only supervised signal the box will ever get. Promoting it
|
||||
// out at the moment he writes it means the label survives the transcript
|
||||
// that carried it.
|
||||
//
|
||||
// should_be may be empty. "That was wrong" with no target is a usable
|
||||
// negative and must not cost more to give than the full answer would.
|
||||
//
|
||||
// UNIQUE(utterance) so correcting the same sentence twice replaces the
|
||||
// label rather than stacking two. His second answer is the one he meant.
|
||||
`CREATE TABLE IF NOT EXISTS routing_labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
utterance TEXT NOT NULL UNIQUE,
|
||||
was TEXT NOT NULL DEFAULT '',
|
||||
should_be TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
encoder_id TEXT NOT NULL DEFAULT ''
|
||||
);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoSuchTrace — the trace the correction names is gone or never existed.
|
||||
// Held apart from a write failure because it is the expected outcome of
|
||||
// correcting a turn older than the 14-day bound, and the surface should say that
|
||||
// rather than report a broken database.
|
||||
var ErrNoSuchTrace = errors.New("no such routing trace")
|
||||
|
||||
// RoutingLabel is one correction: what he said, what she made of it, and what it
|
||||
// should have been. It is the only supervised signal in the box, so it outlives
|
||||
// the trace it came from (V-630, docs/plans/22-correcting-a-turn.md).
|
||||
type RoutingLabel struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Utterance string `json:"utterance"`
|
||||
// Was is the intent the cascade chose. Kept beside the target because the
|
||||
// pair is what names the confusion, and a label with no "was" cannot say
|
||||
// which boundary moved.
|
||||
Was string `json:"was"`
|
||||
// ShouldBe is the owner's target, and may be empty. "That was wrong, I am
|
||||
// not going to tell you what it was" is a usable negative, and requiring the
|
||||
// target would cost the cheap half of the gesture.
|
||||
ShouldBe string `json:"should_be"`
|
||||
Source string `json:"source"`
|
||||
EncoderID string `json:"encoder_id"`
|
||||
}
|
||||
|
||||
// CorrectTurn records the owner's correction of one persisted turn. It promotes
|
||||
// the pair into routing_labels and stamps the trace, both in one transaction:
|
||||
// a stamped trace with no label would lose the signal when the trace expires,
|
||||
// and a label with no stamp would let the same turn be corrected twice.
|
||||
//
|
||||
// shouldBe empty is allowed and means "wrong, target unstated".
|
||||
func (s *Store) CorrectTurn(ctx context.Context, traceID int64, shouldBe string, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("correct turn: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var utterance, was, source, encoderID string
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT utterance, intent, source, encoder_id FROM routing_traces WHERE id = ?`,
|
||||
traceID).Scan(&utterance, &was, &source, &encoderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNoSuchTrace
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("correct turn: read trace: %w", err)
|
||||
}
|
||||
|
||||
shouldBe = strings.TrimSpace(shouldBe)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO routing_labels (ts, utterance, was, should_be, source, encoder_id)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(utterance) DO UPDATE SET
|
||||
ts = excluded.ts, was = excluded.was, should_be = excluded.should_be,
|
||||
source = excluded.source, encoder_id = excluded.encoder_id`,
|
||||
now.UnixMilli(), utterance, was, shouldBe, source, encoderID); err != nil {
|
||||
return fmt.Errorf("correct turn: write label: %w", err)
|
||||
}
|
||||
// The stamp is what the trace itself carries: "corrected", or the target he
|
||||
// gave. It expires with the trace, and that is fine — the label above is the
|
||||
// durable half.
|
||||
stamp := shouldBe
|
||||
if stamp == "" {
|
||||
stamp = "wrong"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE routing_traces SET correction = ? WHERE id = ?`, stamp, traceID); err != nil {
|
||||
return fmt.Errorf("correct turn: stamp trace: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("correct turn: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RoutingLabels returns the newest n corrections, newest first. Nothing prunes
|
||||
// them: 31 modes and 9 of them with no example at all is the problem this table
|
||||
// exists to solve, and a label is a few dozen bytes.
|
||||
func (s *Store) RoutingLabels(ctx context.Context, n int) ([]RoutingLabel, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, utterance, was, should_be, source, encoder_id
|
||||
FROM routing_labels ORDER BY id DESC LIMIT ?`, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("routing labels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RoutingLabel
|
||||
for rows.Next() {
|
||||
var l RoutingLabel
|
||||
var tsMilli int64
|
||||
if err := rows.Scan(&l.ID, &tsMilli, &l.Utterance, &l.Was, &l.ShouldBe,
|
||||
&l.Source, &l.EncoderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Ts = time.UnixMilli(tsMilli).UTC()
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedTrace(t *testing.T, s *Store, utterance, intent string, now time.Time) int64 {
|
||||
t.Helper()
|
||||
id, err := s.WriteRoutingTrace(context.Background(), RoutingTrace{
|
||||
Ts: now, Utterance: utterance, Intent: intent, Source: "tap:text", EncoderID: "e5-small",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// The label carries the pair, and it is what survives the transcript.
|
||||
func TestCorrectTurnPromotesTheLabel(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
id := seedTrace(t, s, "поужинал", "query", now)
|
||||
|
||||
if err := s.CorrectTurn(ctx, id, "fact", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("got %d labels, want 1", len(labels))
|
||||
}
|
||||
l := labels[0]
|
||||
if l.Utterance != "поужинал" || l.Was != "query" || l.ShouldBe != "fact" {
|
||||
t.Errorf("label %+v: the pair is what names the confusion", l)
|
||||
}
|
||||
if l.EncoderID != "e5-small" {
|
||||
t.Errorf("encoder_id %q: a fitted distance means nothing without the body", l.EncoderID)
|
||||
}
|
||||
// The trace is stamped too, so the same turn cannot be corrected twice into
|
||||
// two labels without the surface knowing.
|
||||
traces, err := s.RecentRoutingTraces(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if traces[0].Correction != "fact" {
|
||||
t.Errorf("trace correction %q, want fact", traces[0].Correction)
|
||||
}
|
||||
}
|
||||
|
||||
// "Wrong, and I am not telling you what it was" is the cheap half of the
|
||||
// gesture, and it must not cost more than the full answer.
|
||||
func TestCorrectTurnWithNoTarget(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
id := seedTrace(t, s, "закрывай", "act", now)
|
||||
|
||||
if err := s.CorrectTurn(ctx, id, " ", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].ShouldBe != "" {
|
||||
t.Fatalf("labels %+v: an untargeted negative is still a label", labels)
|
||||
}
|
||||
traces, _ := s.RecentRoutingTraces(ctx, 10)
|
||||
if traces[0].Correction != "wrong" {
|
||||
t.Errorf("trace correction %q, want wrong", traces[0].Correction)
|
||||
}
|
||||
}
|
||||
|
||||
// His second answer is the one he meant, so a re-correction replaces.
|
||||
func TestCorrectTurnTwiceReplaces(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
|
||||
first := seedTrace(t, s, "поужинал", "query", now)
|
||||
second := seedTrace(t, s, "поужинал", "chat", now.Add(time.Minute))
|
||||
|
||||
if err := s.CorrectTurn(ctx, first, "note", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CorrectTurn(ctx, second, "fact", now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := s.RoutingLabels(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("got %d labels for one sentence, want 1", len(labels))
|
||||
}
|
||||
if labels[0].ShouldBe != "fact" || labels[0].Was != "chat" {
|
||||
t.Errorf("label %+v, want the second correction", labels[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A turn past the 14-day bound cannot be corrected, and the surface has to be
|
||||
// able to say that rather than report a broken database.
|
||||
func TestCorrectTurnUnknownTrace(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
err := s.CorrectTurn(context.Background(), 999, "fact", time.Now())
|
||||
if !errors.Is(err, ErrNoSuchTrace) {
|
||||
t.Fatalf("err %v, want ErrNoSuchTrace", err)
|
||||
}
|
||||
labels, _ := s.RoutingLabels(context.Background(), 10)
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("wrote %d labels for a trace that does not exist", len(labels))
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
@@ -73,8 +74,27 @@ var (
|
||||
// confirm turn that would help: asking again would imply the second answer
|
||||
// changes the outcome.
|
||||
ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
|
||||
// ErrUnknownTarget — the act matched a tool and the target it carries cannot
|
||||
// be one. A process row's args become argv for a real program, and a unit,
|
||||
// container or host is named in ASCII on this box, so a Cyrillic tail is a
|
||||
// word from the sentence rather than a target. Held apart from every failure
|
||||
// above because the command never ran: forwarding it would spend a confirm
|
||||
// turn on an act that cannot succeed, and then report the program's own
|
||||
// confusion as if she had tried something sensible (V-634).
|
||||
ErrUnknownTarget = errors.New("the act names a target the system cannot have")
|
||||
)
|
||||
|
||||
// UnknownTargetError carries the word the executor could not place, because the
|
||||
// reply names it: "«роутер» — не знаю такой цели" is actionable and "не
|
||||
// получилось" sends him to the log. errors.Is(err, ErrUnknownTarget) holds.
|
||||
type UnknownTargetError struct{ Target string }
|
||||
|
||||
func (e *UnknownTargetError) Error() string {
|
||||
return fmt.Sprintf("%s: %q", ErrUnknownTarget, e.Target)
|
||||
}
|
||||
|
||||
func (e *UnknownTargetError) Unwrap() error { return ErrUnknownTarget }
|
||||
|
||||
// MCPCaller is the seam for an act that is an MCP tool call rather than a
|
||||
// process (Vikunja #251). internal/mcp.Manager satisfies it via CallPositional.
|
||||
// nil ⇒ MCP is not configured, and an MCP row refuses to run rather than
|
||||
@@ -144,6 +164,16 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
|
||||
if t.Status != "enabled" {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
// A process row's args become argv, so the target has to be able to exist.
|
||||
// Checked before the confirm gate below, because asking "выполнить X?" about
|
||||
// an act that cannot run spends a turn on nothing (V-634). The other two
|
||||
// dispatches are exempt: an MCP tool may take Russian text as an argument,
|
||||
// since a task title is not a target, and a house row drops the spoken args.
|
||||
if !isMCPRow(t.Cmd) && !isHouseRow(t.Cmd) {
|
||||
if bad, ok := firstUnknownTarget(args); !ok {
|
||||
return "", &UnknownTargetError{Target: bad}
|
||||
}
|
||||
}
|
||||
// The tier decides, not the column (Vikunja #449). RiskOf reads the row and
|
||||
// answers the three questions the boolean never did: which acts are
|
||||
// destructive, whether a confirm sticks (it never does), and what an
|
||||
@@ -260,3 +290,41 @@ func (m *Matcher) Allowlist() []string { return m.names() }
|
||||
func (m *Matcher) Match(utterance string) (string, []string, bool) {
|
||||
return router.DefaultActMatcher{Fns: m.names(), Aliases: m.aliases}.Match(utterance)
|
||||
}
|
||||
|
||||
// firstUnknownTarget reports whether every arg could name something on this box,
|
||||
// and returns the first that could not.
|
||||
//
|
||||
// The check is the script, not a word list: this is not a fourth Russian
|
||||
// mechanism (CLAUDE.md § "Russian patterns"). A systemd unit, a container, a
|
||||
// host and a path are written in ASCII, so a non-ASCII rune in an argv element
|
||||
// means the alias match swallowed the verb and handed on the next word of the
|
||||
// sentence. "перезагрузи роутер" is the case: restart is a real tool and
|
||||
// "роутер" is a real word, and `systemctl restart роутер` is neither.
|
||||
//
|
||||
// Every process row this box enables takes a system identifier (systemctl,
|
||||
// docker, journalctl, df). A process row that legitimately wanted Russian text
|
||||
// would want a different dispatch, not a hole in this check.
|
||||
//
|
||||
// It deliberately does not try to guess the right target. Identity is Nexus's
|
||||
// (CLAUDE.md § "The ecosystem"), and a target Nexus resolves reaches Hexis
|
||||
// through handleHexisAct before this executor is asked.
|
||||
func firstUnknownTarget(args []string) (string, bool) {
|
||||
for _, a := range args {
|
||||
for _, r := range a {
|
||||
if r > unicode.MaxASCII {
|
||||
return a, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
func isMCPRow(cmd []string) bool {
|
||||
_, _, ok := mcp.ParseCmd(cmd)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isHouseRow(cmd []string) bool {
|
||||
_, _, ok := smarthome.ParseCmd(cmd)
|
||||
return ok
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -320,3 +321,55 @@ func TestExecEmptyCmdRefuses(t *testing.T) {
|
||||
t.Fatal("a row with no cmd ran a program named by the utterance")
|
||||
}
|
||||
}
|
||||
|
||||
// V-634. The alias match resolves the verb and hands on the next word of the
|
||||
// sentence, so "перезагрузи роутер" became `systemctl restart роутер`: a real
|
||||
// tool, a real word, and a target that cannot exist on this box.
|
||||
func TestExecRefusesATargetTheSystemCannotHave(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"restart": {Name: "restart", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
|
||||
"drop": {Name: "drop", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
|
||||
}}
|
||||
ran := false
|
||||
e := NewExecutor(api, 0)
|
||||
e.run = func(context.Context, []string) (string, error) { ran = true; return "ok", nil }
|
||||
|
||||
_, err := e.Exec(context.Background(), "restart", []string{"роутер"}, false)
|
||||
if !errors.Is(err, ErrUnknownTarget) {
|
||||
t.Fatalf("err = %v, want ErrUnknownTarget", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("the program was called with a target that cannot exist")
|
||||
}
|
||||
// The word is in the error, because a reply naming no word sends him to the log.
|
||||
if !strings.Contains(err.Error(), "роутер") {
|
||||
t.Errorf("err %v does not name the word she could not place", err)
|
||||
}
|
||||
// Ahead of the confirm gate: asking about an act that cannot run spends a
|
||||
// turn on nothing.
|
||||
if _, err := e.Exec(context.Background(), "drop", []string{"база"}, false); !errors.Is(err, ErrUnknownTarget) {
|
||||
t.Errorf("destructive row: err = %v, want ErrUnknownTarget before ErrNeedsConfirm", err)
|
||||
}
|
||||
// An ASCII target still runs, unchanged.
|
||||
if _, err := e.Exec(context.Background(), "restart", []string{"nginx"}, false); err != nil {
|
||||
t.Errorf("restart nginx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An MCP argument is not a target. A task title is Russian and always was.
|
||||
func TestExecMCPRowKeepsRussianArgs(t *testing.T) {
|
||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||
"vikunja_create": {
|
||||
Name: "vikunja_create", Status: "enabled",
|
||||
Cmd: []string{"mcp", "vikunja", "create_task"},
|
||||
},
|
||||
}}
|
||||
m := &fakeMCP{out: "создала"}
|
||||
e := NewExecutor(api, time.Second).WithMCP(m)
|
||||
if _, err := e.Exec(context.Background(), "vikunja_create", []string{"купить хлеб"}, false); err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
if len(m.args) != 1 || m.args[0] != "купить хлеб" {
|
||||
t.Fatalf("args = %v, want the Russian title forwarded", m.args)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user