the self-care recognisers read closed classes, not stems (V-586)
DefaultFactParser matched Russian by hand-written stem substring: "вод", "пил", "душ", "еда" and eleven more, with a helper whose own comment said it would use a morphology lib "until misfires actually bite". That is the fourth mechanism CLAUDE.md says does not exist, and it ran on every fact turn through both wirings in cmd/mavend/voicewire.go. Five closed classes move to internal/lexicon — water nouns and drink verbs, meal words, shower, break, sleep — and internal/morph does the inflection. Three dictionary quirks are carried as data rather than worked around in code, each with its reason in the set's note: "вода" and "водой" lemmatise to two different lemmas, "пил" lemmatises to the saw, and "спал" to "спасть". Shower is matched exactly rather than by lemma, because the dictionary makes "душ" and "душа" one word and only one of them is washing. The accusative of an inanimate noun is its nominative, so exact matching costs nothing he says. NOT behaviour-preserving, deliberately. Rejected now: "пилот", "водитель", "заводить", "душа", "душно", "беда", "победа". "есть" and "ел" are left out of the meal set on purpose — "есть новости по бэкапу" is a question. The vestigial "ate"/"backup" guard goes with the substring era that needed it. Measured on the RU routing fixture, classifier+ONNX arm (91 cases): 64/91 (70.3%) before and after, same failing cases. The LLM arm was not measured — no llama-server reachable from here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+36
-23
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
|
||||
@@ -123,23 +124,22 @@ type DefaultFactParser struct{}
|
||||
|
||||
func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
// Maven is ru-first (voice, tts). Each case carries the English tokens AND
|
||||
// Russian stems — matched by prefix (hasStem) because Russian inflects
|
||||
// (воды/воду/вода share "вод"), so exact-token matching would miss most
|
||||
// real utterances and silently drop the capture.
|
||||
toks := strings.Fields(s)
|
||||
// Maven is ru-first (voice, tts), and Russian inflects, so the words each
|
||||
// case reads are closed classes in internal/lexicon and the inflection is
|
||||
// morph's job (V-586). What stood here was a fourth mechanism: hand-written
|
||||
// stems matched as substrings, so "пилот" was drinking, "водитель" was
|
||||
// water, "душно" was a shower and "победа" was a meal.
|
||||
switch {
|
||||
case (containsWord(s, "water") && containsWord(s, "drank")) ||
|
||||
(hasRoot(s, "вод") && (hasRoot(s, "пил") || hasRoot(s, "пью") || hasRoot(s, "пей"))):
|
||||
case anyLemma(toks, lexicon.WaterNouns()) && anyLemma(toks, lexicon.DrinkVerbs()):
|
||||
return "water", `"drank"`, true
|
||||
case containsWord(s, "meal") || (containsWord(s, "ate") && !containsWord(s, "backup")) || containsWord(s, "lunch") || containsWord(s, "dinner") ||
|
||||
hasRoot(s, "поел") || hasRoot(s, "поесть") || hasRoot(s, "куша") || hasRoot(s, "обед") || hasRoot(s, "ужин") || hasRoot(s, "завтрак") || hasRoot(s, "еда"):
|
||||
case anyLemma(toks, lexicon.MealWords()):
|
||||
return "meal", `"ate"`, true
|
||||
case containsWord(s, "shower") || hasRoot(s, "душ"):
|
||||
case anyExact(toks, lexicon.ShowerWords()):
|
||||
return "shower", `"took"`, true
|
||||
case containsWord(s, "break") || hasRoot(s, "перерыв") || hasRoot(s, "отдох"):
|
||||
case anyLemma(toks, lexicon.BreakWords()):
|
||||
return "break", `"took"`, true
|
||||
case containsWord(s, "slept") || containsWord(s, "sleep") ||
|
||||
hasRoot(s, "спал") || hasRoot(s, "выспал"):
|
||||
case anyLemma(toks, lexicon.SleepWords()):
|
||||
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
||||
return "sleep", strconv.Quote(v), true
|
||||
}
|
||||
@@ -148,18 +148,31 @@ func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// hasRoot — substring match on the whole utterance. Russian inflects with BOTH
|
||||
// prefixes and suffixes (вы-пил, по-пил, пил-и), so a prefix test misses the
|
||||
// verb; the root as a substring catches all forms. A rare over-match (пил in
|
||||
// пилот) is fine at this floor. ponytail: substring roots over a morphology lib
|
||||
// until misfires actually bite.
|
||||
func hasRoot(s, root string) bool { return strings.Contains(s, root) }
|
||||
// anyLemma reports whether any token is one of the set's words, in any case or
|
||||
// tense. Exact equality first because morph falls back to it without the
|
||||
// dictionary, and because a member the dictionary lemmatises oddly is carried
|
||||
// in the set as its surface form.
|
||||
func anyLemma(toks []string, set []string) bool {
|
||||
for _, tok := range toks {
|
||||
t := cleanWord(tok)
|
||||
for _, w := range set {
|
||||
if t == w || morph.SameWord(t, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsWord — whole-token membership (avoids "breakfast" matching "break").
|
||||
func containsWord(s, w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if tok == w {
|
||||
return true
|
||||
// anyExact is anyLemma without the grammar, for a set whose members share a
|
||||
// lemma with a word that means something else. Only ShowerWords needs it.
|
||||
func anyExact(toks []string, set []string) bool {
|
||||
for _, tok := range toks {
|
||||
t := cleanWord(tok)
|
||||
for _, w := range set {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,24 @@ func TestDefaultFactParserRU(t *testing.T) {
|
||||
{"немного отдохнул", "break", true},
|
||||
{"поспал шесть часов", "sleep", true},
|
||||
{"перезапусти nginx", "", false}, // an act, not a fact
|
||||
// Inflections the substring stems caught and a lemma test must keep.
|
||||
{"только что выпил кружку воды", "water", true},
|
||||
{"воды попил наконец", "water", true},
|
||||
{"пью воду", "water", true},
|
||||
{"поужинал", "meal", true},
|
||||
{"отметь что я позавтракал овсянкой", "meal", true},
|
||||
{"сходил в душ", "shower", true},
|
||||
{"отдыхал час", "break", true},
|
||||
{"выспался", "sleep", true},
|
||||
// Misfires the substring stems accepted. Rejecting them is the point of
|
||||
// V-586: none of these is a fact about his day.
|
||||
{"я пилот", "", false},
|
||||
{"водитель приехал", "", false},
|
||||
{"надо заводить машину", "", false},
|
||||
{"у меня душа болит", "", false},
|
||||
{"в комнате душно", "", false},
|
||||
{"это была беда", "", false},
|
||||
{"победа наша", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
k, _, ok := p.Parse(c.utterance)
|
||||
|
||||
Reference in New Issue
Block a user