diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 855bf4b..9a8fc93 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -15,14 +15,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s if !dec.Slots.HasKey { return "не разобрала, что записать — попробуй иначе." } + // A question is never a fact about him (#470). "какая последняя версия + // языка Go?" used to land here, and the value stored was whatever the + // model invented for it, at confidence 1.00, indexed for recall under the + // question's own text. Two such rows then claimed seven unrelated world + // questions through recall and silently disabled world answering. + // + // The routing error itself is not fixed here — the answer is to answer. + // Sending the turn down the query chain is what he asked for anyway, and + // it costs a mis-routed capture nothing: an explicit "запиши ..." is not + // question-shaped, so it never takes this branch. + if router.IsQuestionShaped(dec.Utterance) { + log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query", + dec.Utterance, dec.Slots.Key) + q := dec + q.Intent = router.IntentQuery + // The key the model extracted is its guess at what to store, not a + // fact he has. Left in place, queryFactByKey would read it back and + // claim the turn before any real source ran. + q.Slots.Key, q.Slots.HasKey = "", false + q.Slots.Value = "" + return h.actionQuery(ctx, q) + } now := h.now() req := ipc.WriteFactReq{ - Ts: now, - Kind: "self", - Key: dec.Slots.Key, - Value: dec.Slots.Value, - Source: "tap:voice", - Confidence: 1.0, + Ts: now, + Kind: "self", + Key: dec.Slots.Key, + Value: dec.Slots.Value, + Source: "tap:voice", + // Not 1.00 unconditionally any more (#470). A value he said is + // evidence; a value the model supplied for words he never said is a + // guess, and writing a guess at full confidence is the same mistake + // the act path already refuses under "LLM output is not + // authorization". + Confidence: factConfidence(dec.Utterance, dec.Slots.Value), // Subject: the key doubles as the entity-resolution candidate — // a voice-tapped fact's key is usually the thing/person it's // about ("espresso_machine", "kate"), so queueing it for Nexus diff --git a/cmd/mavend/factgate.go b/cmd/mavend/factgate.go new file mode 100644 index 0000000..d552f58 --- /dev/null +++ b/cmd/mavend/factgate.go @@ -0,0 +1,82 @@ +package main + +import ( + "log" + "strings" + "unicode" +) + +// ungroundedConfidence — what a self fact is worth when its value appears +// nowhere in what he said. Below `query_min_score` is not the point (recall +// gates on vector distance, not on this number); the point is that +// `/history` and every future reader can tell a value he said from a value +// the model supplied. +const ungroundedConfidence = 0.6 + +// factConfidence scores a self fact by whether its value is grounded in the +// utterance it came from. Grounded stays 1.00, which is what a tapped fact +// has always been worth. Ungrounded drops, and says so in the log. +// +// An empty value is grounded by definition: the key alone carries the fact +// ("поужинал"), and there is nothing for the model to have invented. +func factConfidence(utterance, value string) float64 { + if strings.TrimSpace(value) == "" { + return 1.0 + } + if valueGrounded(utterance, value) { + return 1.0 + } + log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f", + value, utterance, ungroundedConfidence) + return ungroundedConfidence +} + +// valueGrounded reports whether every word of value traces back to a word he +// actually said. The comparison is on a 4-rune prefix, so the model's +// normalization survives ("пил воду" → "вода") while an invented value +// ("1.20" for a question about Go) does not. +func valueGrounded(utterance, value string) bool { + said := factTokens(utterance) + words := factTokens(value) + if len(words) == 0 { + return true + } + for _, w := range words { + if !anyTokenMatches(said, w) { + return false + } + } + return true +} + +func anyTokenMatches(said []string, w string) bool { + for _, s := range said { + if s == w || sameStem(s, w) { + return true + } + } + return false +} + +// sameStem is inflection tolerance and nothing more: it compares all but the +// last rune of the shorter word, and never fewer than three. Russian marks +// case on the ending, so "пил воду" and the stored "вода" are the same word he +// said, while "1.20" and "версия" are not. A word of three runes or fewer must +// match outright, where a shorter prefix would match half the language. +func sameStem(a, b string) bool { + ar, br := []rune(a), []rune(b) + shorter := min(len(ar), len(br)) + n := shorter - 1 + if n < 3 || len(ar) < n || len(br) < n { + return false + } + return string(ar[:n]) == string(br[:n]) +} + +// factTokens lowercases and splits on everything that is not a letter or a +// digit, the same shape planTokens uses in the router. +func factTokens(s string) []string { + return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go new file mode 100644 index 0000000..89cca2e --- /dev/null +++ b/cmd/mavend/factgate_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/voice" +) + +func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) { + t.Helper() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + emb := router.NewHashEmbedder(1024) + h := &reactiveHandler{ + api: api, + embedder: emb, + router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil), + replier: voice.NewStubReplier(), + now: func() time.Time { return now }, + memStore: memory.NewInMemoryStore(), + dataStore: st, + } + return h, api +} + +// The write half of #470: a question routed to IntentFact must not become a +// fact about him, and must not leave a vector behind for recall to serve. +func TestActionFact_QuestionIsNotWritten(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + reply := h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "какая последняя версия языка Go?", + Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`}, + }) + + if _, err := api.LatestFact(ctx, "go_version"); err == nil { + t.Fatal("a question was stored as a fact about him") + } + hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3) + if err != nil { + t.Fatalf("memory search: %v", err) + } + if len(hits) != 0 { + t.Fatalf("the question was indexed for recall: %+v", hits) + } + // It went down the query chain instead. Nothing is configured to answer a + // world question in this harness, so "не знаю." is the honest outcome — + // what matters is that the turn was answered, not stored. + if reply == "" { + t.Fatal("the turn was neither stored nor answered") + } +} + +// The capture that must survive the gate: an explicit instruction to record, +// even though it contains an interrogative. +func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "запиши что я пил воду", + Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`}, + }) + + f, err := api.LatestFact(ctx, "water") + if err != nil { + t.Fatalf("an explicit capture was refused: %v", err) + } + if f.Confidence != 1.0 { + t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence) + } +} + +func TestFactConfidence(t *testing.T) { + cases := []struct { + utterance, value string + want float64 + }{ + {"запиши что я пил воду", `"вода"`, 1.0}, + {"я выпил кофе", `"кофе"`, 1.0}, + {"поужинал", "", 1.0}, + {"отметь что я полил кактус", `"полил кактус"`, 1.0}, + {"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence}, + {"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence}, + } + for _, c := range cases { + if got := factConfidence(c.utterance, c.value); got != c.want { + t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want) + } + } +} + +func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 { + t.Helper() + vec, err := router.EmbedQuery(context.Background(), h.embedder, text) + if err != nil { + t.Fatalf("embed %q: %v", text, err) + } + return vec +}