diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c89c3e9..b192892 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -129,6 +129,11 @@ var querySources = []querySource{ // 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. + // A question about her sits just above the boundary, because it has no + // answer below one: refusing it as his says "не нашла у тебя такой записи" + // about her own description, and letting it through asks SearXNG about + // somebody else's assistant (Vikunja #555). + {name: "self", answer: (*reactiveHandler).querySelf}, {name: "personal", answer: (*reactiveHandler).queryPersonal}, // The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats // a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at diff --git a/cmd/mavend/self.go b/cmd/mavend/self.go new file mode 100644 index 0000000..58afaa9 --- /dev/null +++ b/cmd/mavend/self.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "log" + "regexp" + + "github.com/kami/maven/internal/phraser" +) + +// A question about her — "что ты умеешь", "кто ты" — used to have no answer at +// all (Vikunja #555). It reached the personal boundary, which claimed it as his +// and said "не знаю — не нашла у тебя такой записи", because the boundary knows +// two sides and this is neither: her own description is not his data and it is +// not the world's either. Letting it past the boundary is no better, because +// then SearXNG answers about somebody else's assistant. +// +// The description does NOT live in the note store. Notes are his. A note about +// her sitting in his index would come back for "что я записал", would be fed to +// the digestion worker as something he said, and would be recalled by vector +// proximity for questions that are not about her at all. It is her own text, so +// it lives here, in one place, and it is the only copy. +// +// This source sits ABOVE the boundary, because a question about her never had +// an answer below it. + +// selfDescription — what she is and what this box actually does. Frozen text, +// and the one rule for editing it: name only what is really wired. Anything +// that depends on config — the house, the LAN, the feeds, telegram, search — is +// named as depending on what he allowed, never claimed outright. Inventing a +// capability here is the same defect as inventing a fact, and it is worse than +// silence because he would plan around it. +// +// Written in her own voice, feminine, addressing him informally, because it is +// handed to the phraser as the evidence for the answer and the phraser will +// keep the words it is given. +const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` + + `и наружу уходит только поисковый запрос — больше ничего. + +Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` + + `об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` + + `отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` + + `офлайновой энциклопедии. + +Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` + + `список покупок, погода, телеграм. Если что-то из этого не настроено, я ` + + `скажу об этом прямо, а не буду выдумывать ответ. + +Говорю по-русски и по-английски.` + +// selfSeeds — the questions this source claims. Scoring data like every other +// topic set: editing one moves the recogniser and has to be re-measured against +// TestONNXTopics. +// +// All of them are about HER — what she is, what she can do, who made her. The +// neighbouring set is topicAttend, "что требует внимания", which asks about the +// state of his things; the two share almost nothing but the second person. +var selfSeeds = []string{ + "что ты умеешь", + "что ты можешь делать", + "кто ты такая", + "расскажи о себе", + "какие у тебя возможности", + // Added after measuring: it won self by 0.0002, under the margin, and the + // floor does not carry it — "способна" names no verb the floor matches. + "на что ты способна", + "чем ты можешь помочь", + "what can you do", + "who are you", +} + +// selfFloor — the offline floor, for a handler with no embedder or a turn whose +// vector never got computed. Narrow on purpose, like every other floor here: it +// answers only when the seeds cannot, and a broad guess made blind is worse +// than a narrow one. +// +// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the +// Russian patterns spell the boundary out. +var selfPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`), + regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`), + regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`), + regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`), + regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`), +} + +func selfFloor(utterance string) bool { + for _, re := range selfPatterns { + if re.MatchString(utterance) { + return true + } + } + return false +} + +// querySelf answers a question about her from selfDescription. The description +// goes through the phraser as evidence so the answer is shaped to what he +// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls +// back to the text itself, which is already readable, if the model is down. +func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) { + if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) { + return "", false + } + var reply string + if h.phraser != nil { + var err error + reply, err = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{selfDescription}) + if err != nil { + log.Printf("voice: phrase self: %v", err) + } + } + if reply == "" { + reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription}) + } + return reply, true +} diff --git a/cmd/mavend/self_test.go b/cmd/mavend/self_test.go new file mode 100644 index 0000000..816201f --- /dev/null +++ b/cmd/mavend/self_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/router" +) + +// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which +// is what answers with no embedder. Narrow on purpose, so the rows that must +// NOT match are the point. +func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) { + claimed := []string{ + "что ты умеешь", + "что ты можешь", + "а что ты умеешь?", + "кто ты", + "кто ты такая?", + "расскажи о себе", + "what can you do", + "who are you", + } + for _, u := range claimed { + if !selfFloor(u) { + t.Errorf("%q is a question about her and the floor missed it", u) + } + } + declined := []string{ + "что у меня сегодня", + "расскажи про байкал", + "кто изобрёл телефон", + "что требует внимания", + "запиши что я пил воду", + // The floor spells its own word boundaries out, because Go's \b never + // fires next to a Cyrillic letter. Without that these would match. + "кто тыкал в розетку", + "расскажи о себестоимости", + } + for _, u := range declined { + if selfFloor(u) { + t.Errorf("%q is not about her and the floor claimed it", u) + } + } +} + +// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls +// to the floor, and the answer has to be the description rather than silence. +func TestSelfSourceAnswersFromTheDescription(t *testing.T) { + h := personalHandler() + reply, claimed := h.querySelf(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "что ты умеешь"}, + }) + if !claimed { + t.Fatal("a question about her must be claimed above the boundary") + } + if !strings.Contains(reply, "напоминания") { + t.Errorf("the answer must come from the description: %q", reply) + } + if _, claimed := h.querySelf(context.Background(), &queryTurn{ + dec: router.Decision{Utterance: "почему небо синее"}, + }); claimed { + t.Error("a world question must pass this source") + } +} + +// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out, +// so the same rules the phrasing eval enforces apply to it. Feminine +// self-reference, informal address, no pet names. +func TestSelfDescriptionHoldsThePersona(t *testing.T) { + lower := strings.ToLower(selfDescription) + for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} { + if strings.Contains(lower, bad) { + t.Errorf("the description breaks the persona on %q", bad) + } + } + for _, want := range []string{"тво", "ты"} { + if !strings.Contains(lower, want) { + t.Errorf("the description must address him directly, missing %q", want) + } + } +} + +// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes +// this text safe to read out. Every capability that depends on config has to be +// named as depending on it, and inventing one here is the same defect as +// inventing a fact. +func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) { + conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):] + for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} { + if !strings.Contains(conditional, cap) { + t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap) + } + } +} diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go index 322a8b6..acf6316 100644 --- a/cmd/mavend/topics.go +++ b/cmd/mavend/topics.go @@ -59,6 +59,7 @@ const ( topicAttend topicLabel = "attention" topicFeed topicLabel = "feeds" topicList topicLabel = "list" + topicSelf topicLabel = "self" topicOther topicLabel = "other" ) @@ -155,6 +156,14 @@ var topicSeedSets = map[topicLabel][]string{ "what is on my shopping list", "read me the grocery list", }, + // Questions about her (Vikunja #555). The set lives in self.go beside the + // description it unlocks, so the two are edited together — a seed claiming + // a question the description does not answer is the failure mode. + // + // "что ты умеешь" was a topicOther seed until this existed, put there so an + // attention question had something to lose to. It is a self seed now, and + // it cannot be both: a phrasing on two sides never clears the margin. + topicSelf: selfSeeds, topicOther: { // A task question is not a list read-back. They collide on "что у меня", // and the list has its own table to lose to as well. @@ -185,8 +194,6 @@ var topicSeedSets = map[topicLabel][]string{ "что я говорил про бэкапы", "что у меня сегодня по календарю", "напомни мне позвонить маме", - // An attention question is about the state of his things; this is not. - "что ты умеешь", "what did i say about backups", // World questions that name a day (Vikunja #553). Weather was the only // topic whose seeds carry a day word — four of its eight do — so every diff --git a/cmd/mavend/topics_test.go b/cmd/mavend/topics_test.go index b999558..10ae58d 100644 --- a/cmd/mavend/topics_test.go +++ b/cmd/mavend/topics_test.go @@ -124,6 +124,14 @@ func TestONNXTopics(t *testing.T) { {"как устроен телефон внутри", topicOther, isNetworkQuery}, // The control: a real scan is still a scan. {"какие устройства подключены к вайфаю", topicNetwork, isNetworkQuery}, + // Questions about her (Vikunja #555), held out from selfSeeds. + {"а что ты вообще умеешь делать", topicSelf, selfFloor}, + {"какие у тебя навыки", topicSelf, selfFloor}, + {"расскажи мне о себе", topicSelf, selfFloor}, + {"what are you able to do", topicSelf, selfFloor}, + // The control: an attention question is about the state of his things, + // and it is the neighbour these seeds could have taken. + {"что требует внимания у меня в сервисах", topicAttend, isAttentionQuery}, } h := &reactiveHandler{recall: recallWiring{embedder: emb}}