Bug: an unconfigured capability does not name the gap, it lets the question escape to web search #167

Closed
claude wants to merge 20 commits from task/528-sweep-tail-the-three-files-on-467 into task/527-embedder-open-set-phrasings-stop-being-r
9 changed files with 239 additions and 85 deletions
Showing only changes of commit 70fb7c030b - Show all commits
+6 -3
View File
@@ -7,8 +7,10 @@ import (
"github.com/kami/maven/internal/router"
)
// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a
// stem, because "внимание", "внимания" and "вниманию" are one word to him.
// attentionMarkers — the offline floor under the attention topic (topics.go).
// The seeds decide when the embedder is there; this answers when it is not, and
// it stays a substring list on purpose for the reason the other floors do: a
// narrow test made blind beats a broad guess made blind.
//
// "что нового" is deliberately absent: the feeds source claims it, and it
// still should — a question about news is a question about the feeds she
@@ -19,6 +21,7 @@ var attentionMarkers = []string{
}
// isAttentionQuery reports whether the utterance asks what needs looking at.
// Called through turnIsAbout, never directly.
func isAttentionQuery(u string) bool {
s := strings.ToLower(strings.TrimSpace(u))
if s == "" {
@@ -50,7 +53,7 @@ func isAttentionQuery(u string) bool {
// degradation the ecosystem contract asks for, and it comes from the same
// handler the act path uses.
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
if !isAttentionQuery(t.dec.Utterance) {
if !h.turnIsAbout(ctx, t, topicAttend, isAttentionQuery) {
return "", false
}
if h.ecosystem == nil || h.ecosystem.praxis == nil {
+43 -31
View File
@@ -5,7 +5,10 @@ import (
"log"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/router"
)
@@ -44,33 +47,28 @@ const repairWindow = 5 * time.Minute
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
// treating it as a correction would rewrite the last turn every time he used
// the word.
var repairMarkers = []string{
"не так поняла",
"неправильно поняла",
"ты не поняла",
"не поняла меня",
"ты ошиблась",
"это не",
"а не",
"не про то",
"got it wrong",
"not a ",
"that was wrong",
}
//
// From the lexicon, and staying a list rather than becoming seeds (Vikunja
// #528). This runs pre-route, before the turn vector exists, and a correction
// redoes the previous request — so a near-miss would act on something he never
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
var repairMarkers = lexicon.RepairMarkers()
// repairIntents — the words he uses for each intent. Prefixes, because Russian
// declines them: "заметка", "заметку", "заметкой".
// 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
// compares the words themselves (Vikunja #528).
var repairIntents = []struct {
word string
intent router.Intent
say string
}{
{"заметк", router.IntentNote, "заметка"},
{"напоминани", router.IntentReminder, "напоминание"},
{"заметка", router.IntentNote, "заметка"},
{"напоминание", router.IntentReminder, "напоминание"},
{"напомнить", router.IntentReminder, "напоминание"},
{"факт", router.IntentFact, "факт"},
{"вопрос", router.IntentQuery, "вопрос"},
{"команд", router.IntentAct, "команда"},
{"команда", router.IntentAct, "команда"},
{"note", router.IntentNote, "заметка"},
{"reminder", router.IntentReminder, "напоминание"},
{"fact", router.IntentFact, "факт"},
@@ -101,15 +99,28 @@ func parseRepair(utterance string) (router.Intent, string, bool) {
if !marked {
return "", "", false
}
// Over tokens, not byte offsets. The negation test used to read the string
// immediately before a match, which meant it could only see "не" spelled
// exactly there; a token list makes the previous word plain to read.
toks := repairTokens(s)
best, say, at := router.Intent(""), "", -1
for _, w := range repairIntents {
i := strings.Index(s, w.word)
if i < 0 || negatedAt(s, i) {
continue
for i, tok := range toks {
if at >= 0 && i > at {
break
}
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
if at < 0 || i < at {
best, say, at = w.intent, w.say, i
for _, w := range repairIntents {
if !morph.SameWord(tok, w.word) {
continue
}
if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") {
// The one he is ruling out: "не напоминание, а заметка".
continue
}
// Leftmost wins: "это заметка, а не напоминание" corrects to the
// first.
if at < 0 || i < at {
best, say, at = w.intent, w.say, i
}
}
}
if at < 0 {
@@ -118,12 +129,13 @@ func parseRepair(utterance string) (router.Intent, string, bool) {
return best, say, true
}
// negatedAt reports whether the word at i is the one he is ruling out. Only
// the words immediately before it are read, so "не напоминание, а заметка"
// negates the first and leaves the second alone.
func negatedAt(s string, i int) bool {
before := strings.TrimSpace(s[:i])
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
// repairTokens splits a correction into lowercase word tokens. Punctuation goes,
// because "это заметка, а не напоминание" glues a comma to the word the negation
// test has to look past.
func repairTokens(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// recordTurn keeps the utterance a correction would point at. Only turns she
+32
View File
@@ -117,3 +117,35 @@ func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
t.Error("a correction to the intent she already used was handled")
}
}
// TestRepairIntentWordCollisions — the prefix list matched more than the word
// (Vikunja #528). "команд" is inside "командировка" and "факт" inside
// "фактически", and either one used to name an intent she would redo the turn
// under.
func TestRepairIntentWordCollisions(t *testing.T) {
for _, s := range []string{
"нет, это про командировку",
"нет, фактически всё нормально",
} {
if _, _, ok := parseRepair(s); ok {
t.Errorf("parseRepair(%q) claimed a correction", s)
}
}
// The declined forms the prefixes existed to cover still work, and the
// negated half is still skipped.
for _, tc := range []struct {
utterance string
want router.Intent
}{
{"нет, это заметка", router.IntentNote},
{"ты не так поняла, это заметку надо было", router.IntentNote},
{"нет, это напоминание, а не заметка", router.IntentReminder},
{"нет, не напоминание, а заметка", router.IntentNote},
{"нет, это командой было", router.IntentAct},
} {
got, _, ok := parseRepair(tc.utterance)
if !ok || got != tc.want {
t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want)
}
}
}
+34 -8
View File
@@ -8,14 +8,14 @@ import (
"github.com/kami/maven/internal/router"
)
// Which subject is this question about — the weather, the house, the LAN, or
// none of them. Third of the three mechanisms replacing hand-written Russian
// Which subject is this question about — the weather, the house, the LAN, what
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
// sets that can be finished and internal/morph answers the grammar questions;
// this is for the sets that can never be finished, because "is this about the
// house" is a question about meaning and no word list closes it.
//
// The three recognisers this replaces were each built the same way: a stem list,
// The recognisers this replaces were each built the same way: a stem list,
// an ask test, a device-noun list, and a bail-out list for the neighbouring
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
@@ -33,6 +33,10 @@ import (
// to win by a margin, and the losing side of a thin call falls through to the
// next query source, which is what the narrow regexes were achieving.
//
// A fourth subject joined on the same day: isAttentionQuery, "что требует
// внимания", which is Praxis's operational state and reached the web search
// before the source existed (Vikunja #475).
//
// The regexes stay as the offline floor, unchanged, for a handler with no
// embedder or a turn whose vector never got computed. They are allowed to remain
// narrow now precisely because they are no longer the only answer.
@@ -47,6 +51,7 @@ const (
topicWeather topicLabel = "weather"
topicHome topicLabel = "home"
topicNetwork topicLabel = "network"
topicAttend topicLabel = "attention"
topicOther topicLabel = "other"
)
@@ -97,6 +102,21 @@ var topicSeedSets = map[topicLabel][]string{
"кто подключён к вайфаю",
"what devices are on the network",
},
topicAttend: {
"что требует внимания",
"что не так сейчас",
"на что мне посмотреть",
"что важное я пропустил",
"есть что-то срочное",
"что там висит нерешённое",
// With a thing named. Every other seed asks in the abstract, and the
// attention question he actually asks names his services or his
// projects.
"что не так с сервисами",
"что там с моими проектами",
"what needs attention",
"what needs looking at right now",
},
topicOther: {
// Complaints, which are not requests to scan or to read the house.
// isNetworkQuery's comment names this one: a scan she runs unasked is
@@ -117,6 +137,8 @@ var topicSeedSets = map[topicLabel][]string{
"что я говорил про бэкапы",
"что у меня сегодня по календарю",
"напомни мне позвонить маме",
// An attention question is about the state of his things; this is not.
"что ты умеешь",
"what did i say about backups",
},
}
@@ -183,9 +205,12 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
// the embedder is there, which is every deployed box; floor is the source's own
// keyword test, which answers when they are not.
//
// A topic that wins without the margin is reported as a pass, and logged: it is
// the one outcome where the seeds and the old regexes are most likely to
// disagree, and a silent near-miss is how a recogniser drifts.
// A topic that wins WITHOUT the margin is handed to the floor rather than
// claimed or dropped, and the near-miss is logged. That is the cascade shape
// again: the better test leads, the offline one always answers, and a thin call
// is exactly where the cheap high-precision test earns its place. Measured, the
// one held-out case that lands there is "вайфай опять отвалился", which reads as
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
h.topics.load(ctx, h.embedder)
label, margin, ok := h.topics.best(t.vec)
@@ -196,8 +221,9 @@ func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want to
return false
}
if margin < topicMargin {
log.Printf("voice: %q reads as %s by only %.4f; passing it on", t.dec.Utterance, want, margin)
return false
claimed := floor(t.dec.Utterance)
log.Printf("voice: %q reads as %s by only %.4f; the keyword floor says %v", t.dec.Utterance, want, margin, claimed)
return claimed
}
return true
}
+34 -27
View File
@@ -11,8 +11,8 @@ import (
// TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the
// seeds, and every topic source has to keep working. This is the case that used
// to be the only one, so a regression here is the three recognisers going
// silent on a box with no embedder at all.
// to be the only one, so a regression here is all four recognisers going silent
// on a box with no embedder at all.
func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
h := &reactiveHandler{}
for _, tc := range []struct {
@@ -24,6 +24,7 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
{"какая сегодня погода", topicWeather, isWeatherQuery, true},
{"что включено в доме?", topicHome, isHomeQuery, true},
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
{"что требует внимания?", topicAttend, isAttentionQuery, true},
{"почему небо синее", topicWeather, isWeatherQuery, false},
{"я дома", topicHome, isHomeQuery, false},
{"интернет не работает", topicNetwork, isNetworkQuery, false},
@@ -39,13 +40,13 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
// actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary.
//
// Every case is held out: none of these strings is a seed. It asserts what the
// gate does, not what the raw scorer says — a label under topicMargin is not a
// claim, and one held-out case turns on exactly that.
// gate does, not what the raw scorer says — a label under topicMargin is handed
// to the keyword floor, and one case turns on exactly that.
//
// The first three rows are the collisions the old regexes needed hand-written
// bail-outs for: the temperature pair that made isHomeQuery exclude weather
// words, and the
// "посетил" substring that made isNetworkQuery match "сети" as a whole token.
// words, and the "посетил" substring that made isNetworkQuery match "сети" as a
// whole token.
func TestONNXTopics(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
@@ -61,27 +62,30 @@ func TestONNXTopics(t *testing.T) {
cases := []struct {
utterance string
want topicLabel
floor func(string) bool
}{
{"какая температура на улице?", topicWeather},
{"какая температура в доме?", topicHome},
{"сколько машин я посетил?", topicOther},
{"сколько сейчас градусов", topicWeather},
{"дождь будет вечером?", topicWeather},
{"тепло сегодня на улице?", topicWeather},
{"свет на кухне включен?", topicHome},
{"что сейчас включено дома", topicHome},
{"датчики в квартире что показывают", topicHome},
{"просканируй сеть", topicNetwork},
{"сколько устройств в локальной сети", topicNetwork},
{"кто сейчас в сетке", topicNetwork},
// The case the margin exists for. It reads as network by 0.0055, under
// topicMargin, so the gate passes it on — which is right: it is a
// complaint, and a scan she runs unasked is the behaviour the bounds
// prevent.
{"вайфай опять отвалился", topicOther},
{"я уже приехал домой", topicOther},
{"что я говорил про погоду в москве", topicOther},
{"напомни полить цветы", topicOther},
{"какая температура на улице?", topicWeather, isWeatherQuery},
{"какая температура в доме?", topicHome, isHomeQuery},
{"сколько машин я посетил?", topicOther, nil},
{"сколько сейчас градусов", topicWeather, isWeatherQuery},
{"дождь будет вечером?", topicWeather, isWeatherQuery},
{"тепло сегодня на улице?", topicWeather, isWeatherQuery},
{"свет на кухне включен?", topicHome, isHomeQuery},
{"что сейчас включено дома", topicHome, isHomeQuery},
{"датчики в квартире что показывают", topicHome, isHomeQuery},
{"просканируй сеть", topicNetwork, isNetworkQuery},
{"сколько устройств в локальной сети", topicNetwork, isNetworkQuery},
{"кто сейчас в сетке", topicNetwork, isNetworkQuery},
// The case the margin exists for. It reads as network by 0.0055, and
// isNetworkQuery says no, so it stays the complaint it is — a scan she
// runs unasked is the behaviour the bounds prevent.
{"вайфай опять отвалился", topicOther, isNetworkQuery},
{"я уже приехал домой", topicOther, nil},
{"что я говорил про погоду в москве", topicOther, nil},
{"напомни полить цветы", topicOther, nil},
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
{"что не так с базой данных", topicAttend, isAttentionQuery},
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
}
h := &reactiveHandler{embedder: emb}
@@ -101,10 +105,13 @@ func TestONNXTopics(t *testing.T) {
t.Fatalf("best(%q) not ok", tc.utterance)
}
// What the gate would do, which is the thing under test: a label that
// does not clear the margin is not a claim.
// does not clear the margin is handed to that source's keyword floor.
got := label
if margin < topicMargin {
got = topicOther
if tc.floor != nil && tc.floor(tc.utterance) {
got = label
}
}
if got == tc.want {
right++
+10
View File
@@ -91,6 +91,16 @@ func CaptureVerbs() []string { return words("capture_verbs") }
// NarrativeRequests returns the imperatives that mean "tell me about".
func NarrativeRequests() []string { return words("narrative_requests") }
// RepairMarkers lists the ways he says the previous turn was routed wrong. See
// the set's own note for why this one is a list and not a seed set.
func RepairMarkers() []string { return words("repair_markers") }
// 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
// fact he meant to store is the worse mistake.
func FirstPerson() []string { return words("first_person") }
// NotPlaceAfterV returns the words that follow "в" without naming a place.
func NotPlaceAfterV() []string { return words("not_place_after_v") }
+16
View File
@@ -109,6 +109,22 @@
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три"
]
},
"repair_markers": {
"note": "The ways he says she got it wrong. A closed set of Maven's own vocabulary, like capture_verbs: its members are decided here rather than discovered. NOT the embedder, and that is deliberate — a correction rewrites the previous turn and runs pre-route, before the turn vector exists, so a near-miss would redo a request he did not make. Phrases, matched as substrings.",
"words": [
"не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня",
"ты ошиблась", "это не", "а не", "не про то",
"got it wrong", "not a ", "that was 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": [
"я", "меня", "мне", "мной", "мною",
"мы", "нас", "нам", "нами",
"i", "me", "my", "mine", "we", "us", "our"
]
},
"not_place_after_v": {
"note": "Words that follow the preposition \"в\" without naming a place, so \"в общем\" and \"в котором часу\" are not read as a city we do not know.",
"words": [
+33 -16
View File
@@ -1,20 +1,33 @@
package router
import "strings"
import (
"strings"
// transientStems — the states a thing is in for an afternoon. Compared as
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
// "медленный" and "медленно" without listing them.
var transientStems = []string{
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
"отвал", "падает", "упал", "сдох", "греется", "перегре",
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// transientWords — the states a thing is in for an afternoon, as dictionary
// forms. They used to be prefixes, which is how a prefix list always goes wrong:
// "лаг" matched "лагерь" and "падает" matched nothing else it inflected into.
// morph.SameWord compares the words themselves (Vikunja #528).
//
// Russian aspect pairs are two separate verbs, so a slot lists both where both
// are said. English members have no dictionary here and fall back to exact
// comparison, which is what they had.
var transientWords = []string{
"медленный", "медленно", "тормозить", "тормоз", "лагать", "лаг",
"зависать", "зависнуть", "виснуть", "глючить", "барахлить",
"отваливаться", "отвалиться", "падать", "упасть", "сдохнуть",
"греться", "перегреваться", "перегреться",
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
}
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
// "не работает", "не грузит", "не открывается". Prefixes again.
var brokenVerbs = []string{
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
// brokenWords — what "не ..." is denying when the sentence is a complaint.
// Dictionary forms, same reason.
var brokenWords = []string{
"работать", "пахать", "грузить", "грузиться", "открываться",
"включаться", "коннектиться", "подключаться",
"work", "load", "connect", "respond",
}
@@ -22,7 +35,11 @@ var brokenVerbs = []string{
// thing. Their presence turns the test off, because losing a fact he meant to
// store is worse than keeping a complaint: "я сломал руку" is durable, and
// "интернет не работает" is not.
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
//
// A closed class, so it comes from the lexicon: the first-person pronoun has a
// fixed number of forms and this file was the third place they were typed out
// (Vikunja #528).
var selfMarkers = lexicon.FirstPerson()
// IsTransientComplaint reports whether text observes a passing state of some
// thing rather than recording a fact.
@@ -54,8 +71,8 @@ func IsTransientComplaint(text string) bool {
}
}
for _, tok := range toks {
for _, stem := range transientStems {
if strings.HasPrefix(tok, stem) {
for _, w := range transientWords {
if morph.SameWord(tok, w) {
return true
}
}
@@ -68,8 +85,8 @@ func IsTransientComplaint(text string) bool {
continue
}
for j := i + 1; j < len(toks) && j <= i+2; j++ {
for _, v := range brokenVerbs {
if strings.HasPrefix(toks[j], v) {
for _, v := range brokenWords {
if morph.SameWord(toks[j], v) {
return true
}
}
+31
View File
@@ -33,3 +33,34 @@ func TestIsTransientComplaint(t *testing.T) {
}
}
}
// TestComplaintPrefixCollisions — what the prefix list got wrong and the
// dictionary does not (Vikunja #528). Each of these contains a word that starts
// with one of the old stems and is a different word.
func TestComplaintPrefixCollisions(t *testing.T) {
for _, s := range []string{
// "лаг" matched "лагерь".
"детский лагерь под москвой",
// "падает" was literal, but "падеж" and "падение" start on "пад".
"падение цен на квартиры",
// "отвал" matched "отвальная".
"отвальная в пятницу",
} {
if IsTransientComplaint(s) {
t.Errorf("IsTransientComplaint(%q) = true, want false", s)
}
}
// And the real complaints still read as complaints, in the inflections the
// prefixes were there to cover.
for _, s := range []string{
"сеть какая-то медленная",
"интернет не работает",
"nextcloud тормозит",
"диск сдохнет скоро",
"сервис не открывается",
} {
if !IsTransientComplaint(s) {
t.Errorf("IsTransientComplaint(%q) = false, want true", s)
}
}
}