diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e694cb5..e7888b0 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "regexp" "strings" "time" @@ -106,6 +107,11 @@ var querySources = []querySource{ {name: "embed", answer: (*reactiveHandler).queryEmbed}, {name: "memory", answer: (*reactiveHandler).queryMemory}, {name: "notes", answer: (*reactiveHandler).queryNotes}, + // THE BOUNDARY. Everything above answers from his own data; everything + // below answers from the world's. A question about him that got this far + // has no answer in his data, and no outside source can supply one, so this + // stops the walk rather than let the encyclopedia and the model guess. + {name: "personal", answer: (*reactiveHandler).queryPersonal}, // The offline encyclopedia, after everything of his and before anything on // the network. A question he can be answered from his own notes is answered // from his own notes; only what is left over is looked up. @@ -619,6 +625,65 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, return reply, true } +// queryPersonal — stop the walk on a question about him that his own data did +// not answer. +// +// Every source above this one reads something of his: his facts, his calendar, +// his tasks, his house, his notes. Everything below reads the world: an offline +// Wikipedia, a page he named, the model's own weights. The world does not know +// when his meeting is, and asked anyway it will produce something. +// +// It did. "во сколько у меня встреча" reached Kiwix on the deployed daemon, +// 01-08-2026; Wikipedia matched an article on the 2015 CPISRA World Games, and +// the phraser rendered it as "встреча у тебя в 2015 CPISRA World Games, где +// были соревнования по плаванию". Fluent, confident, and about a swimming +// competition in Nottingham. Saying "не знаю" is not a worse answer than that +// one — it is the only true one. +// +// Note this is also the privacy edge. The rule in CLAUDE.md is that only the +// utterance may leave the box, never his notes; a question that is ABOUT him +// carries his life in the utterance itself, so it is the one class that should +// not be sent to an upstream engine at all. The guard closes both holes with +// the same test. +func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) { + if !isPersonalQuery(t.dec.Utterance) { + return "", false + } + log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) + return "не знаю — не нашла у тебя такой записи.", true +} + +// personalMarkers — first-person POSSESSION, not first person generally. +// +// "у меня" and "мой" attach to a thing that is his, which is what makes the +// question unanswerable from outside. A bare "мне" or "я" does not: "как мне +// сварить борщ" and "что я могу посмотреть" are ordinary questions about the +// world that happen to mention the asker, and refusing those would be the +// opposite mistake. The narrow test is the point. +// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the +// Russian patterns spell the boundary out as "not a letter or a digit". The +// English ones keep \b, where it works. +var personalMarkers = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])у\s+меня([^\p{L}\p{N}]|$)`), + regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])мо(й|я|ё|е|и|его|ей|их|им|ими|ем|ю|ею)([^\p{L}\p{N}]|$)`), + regexp.MustCompile(`(?i)\bmy\b`), + regexp.MustCompile(`(?i)\bdo\s+i\s+have\b`), + regexp.MustCompile(`(?i)\bdid\s+i\b`), +} + +// isPersonalQuery reports whether the utterance asks about something of his. +func isPersonalQuery(utterance string) bool { + if utterance == "" { + return false + } + for _, re := range personalMarkers { + if re.MatchString(utterance) { + return true + } + } + return false +} + // queryGeneral — general knowledge from the phraser, the last source before // giving up. It always claims: either the model answers or Maven says she // doesn't know. diff --git a/cmd/mavend/actions_query_personal_test.go b/cmd/mavend/actions_query_personal_test.go new file mode 100644 index 0000000..e2547d9 --- /dev/null +++ b/cmd/mavend/actions_query_personal_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +func TestIsPersonalQuery(t *testing.T) { + for _, s := range []string{ + "во сколько у меня встреча", + "что у меня сегодня", + "когда мой следующий отпуск", + "где моя книга", + "сколько моих задач висит", + "when is my meeting", + "do i have anything today", + "did i take my vitamins", + } { + if !isPersonalQuery(s) { + t.Errorf("isPersonalQuery(%q) = false, want true", s) + } + } + for _, s := range []string{ + // First person without possession. These are questions about the + // world that merely mention the asker, and refusing them would be the + // opposite mistake. + "как мне сварить борщ", + "что я могу посмотреть вечером", + "почему небо синее", + "столица франции", + "how do i boil an egg", + "", + } { + if isPersonalQuery(s) { + t.Errorf("isPersonalQuery(%q) = true, want false", s) + } + } +} + +// kiwixTrapAPI stands in for the world. Nothing below the personal boundary +// should be consulted for a question about him, so the test asserts on the +// reply rather than on a call: reaching Kiwix or general knowledge produces a +// phrased answer, and refusing produces the honest one. +func personalHandler() *reactiveHandler { + return &reactiveHandler{ + api: ipc.UnimplementedCoreAPI{}, + now: func() time.Time { return contNow }, + // No phraser and no kiwix wiring: if the walk gets past the personal + // source it reaches queryGeneral, which returns "не знаю." with a nil + // phraser — a different string from the one this guard produces, so + // the two cases stay distinguishable. + } +} + +// The regression: "во сколько у меня встреча" reached Kiwix, Wikipedia matched +// an article on the 2015 CPISRA World Games, and the phraser reported it back +// as his meeting. Seen on the deployed daemon, 01-08-2026. +func TestPersonalQuestionIsNotSentToTheWorld(t *testing.T) { + h := personalHandler() + reply, ok := h.queryPersonal(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: "во сколько у меня встреча"}, + }) + if !ok { + t.Fatal("queryPersonal passed on a question about him") + } + if reply == "" { + t.Fatal("empty reply") + } +} + +func TestWorldQuestionsPassThroughTheBoundary(t *testing.T) { + h := personalHandler() + for _, u := range []string{"почему небо синее", "столица франции"} { + if _, ok := h.queryPersonal(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }); ok { + t.Errorf("queryPersonal claimed %q, want it to pass to the encyclopedia", u) + } + } +} + +// The boundary must sit above kiwix and general-knowledge and below every +// source that reads his own data. Asserted on the table itself: an ordering +// bug here is silent, because both arrangements answer, just from the wrong +// place. +func TestPersonalBoundarySitsBetweenHisDataAndTheWorld(t *testing.T) { + idx := map[string]int{} + for i, s := range querySources { + idx[s.name] = i + } + boundary, ok := idx["personal"] + if !ok { + t.Fatal("no personal source in the chain") + } + for _, his := range []string{"fact-by-key", "day-plan", "tasks", "calendar", "memory", "notes"} { + if i, ok := idx[his]; !ok || i > boundary { + t.Errorf("%q reads his own data and must run before the personal boundary", his) + } + } + for _, world := range []string{"kiwix", "web", "general-knowledge"} { + if i, ok := idx[world]; !ok || i < boundary { + t.Errorf("%q reads the world and must run after the personal boundary", world) + } + } +}