diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 6c5b306..b2f0ffb 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -240,7 +240,9 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter // The list side of the same exposure: a phrasing with no possessive in it // ("список дел") routed system and never reached queryTasks (Vikunja #467). grammars = append(grammars, router.TaskListGrammar()) + grammars = append(grammars, router.ListGrammars()...) grammars = append(grammars, router.ReminderGrammar()) + grammars = append(grammars, router.PraxisGrammars()...) grammars = append(grammars, router.TaskCaptureGrammar()) // "расскажи про X" is a world question the model called a fact, and the // rule goes last because it matches on the first word alone (Vikunja #498). diff --git a/internal/router/praxis.go b/internal/router/praxis.go new file mode 100644 index 0000000..570c50b --- /dev/null +++ b/internal/router/praxis.go @@ -0,0 +1,370 @@ +package router + +import ( + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" +) + +// Praxis reach, at stage 0 (Vikunja #516). +// +// Measured in docs/evals/2026-08-04-ecosystem-reach.md: Praxis reach was 0/12 on +// the held-out fixture, and structurally so. handlePraxisAct dispatches on exact +// equality between Slots.Fn and a capability alias, and the fn slot is filled by +// DefaultActMatcher from the deployment's enabled tool names — no Praxis alias is +// on that list, so no utterance could ever put one in the slot. The Russian +// aliases in praxisCapabilities read as if they matched speech; they are compared +// against a fn slot and never against an utterance. +// +// So the fn slot is what these rules fill. Same move AgendaQueryGrammars made for +// agenda questions, and for the stronger reason: a lifecycle verb decides whether +// an item is acknowledged or resolved, and those are different words in the +// contract. That is not a similarity guess to leave to an embedder. +// +// Deliberately not here: a bare "готово" or "принято". Both are ordinary speech — +// he says "готово" about the thing he just finished, not about a Praxis item — so +// a lifecycle rule requires an item reference as well as a verb. What that costs +// is that he must say which item; what it buys is that no ordinary sentence +// silently transitions one. + +// praxisLifecycleVerbs — the words that name a transition, per capability. Each +// word belongs to exactly one capability, and a sentence carrying two is refused +// below rather than guessed. +// +// Split into two columns, because the grammatical mood decides whether an item +// has to be named: +// +// - imperative: addressed to her. "закрой" and "игнорируй" are instructions +// and nothing else, so one claims the turn even with no item named — the +// capability then asks which пункт, which is the honest reply. +// - stative: a participle or a short adverb. "готово" and "принято" are how he +// reports his own day, so one of those needs an item reference beside it or +// it is not a Praxis turn at all. +// +// resolve is not acknowledge. ECOSYSTEM-SPEC §2.3 makes the distinction +// mechanical: "got it" acknowledges and "done" resolves, and Maven must not blur +// them just because both sound like agreement. +var praxisLifecycleVerbs = []struct { + fn string + imperative []string + stative []string +}{ + {"resolve_item", + []string{"закрой", "закрывай", "закрыть", "resolve", "close"}, + []string{"сделано", "сделан", "сделанный", "сделанное", "сделанным", "готово", "готов", "решено", "решён", "решен", "done", "resolved"}}, + {"acknowledge_item", + []string{"acknowledge", "ack"}, + []string{"принято", "принял", "приняла", "принять", "понял", "поняла"}}, + {"ignore_item", + []string{"игнорируй", "игнорировать", "пропусти", "пропустить", "ignore", "skip"}, + []string{"неважно"}}, + {"pin_item", + []string{"закрепи", "закрепить", "прикрепи", "pin"}, + nil}, +} + +// praxisMarkerVerbs — "отметь X как сделанное". The verb says record a state and +// the state is elsewhere in the sentence, so it cannot pick a capability on its +// own. With a state named, the state wins; with none, marking a пункт means +// acknowledging it, which is the weaker of the two transitions and the safer +// default. It is also a capture verb (internal/lexicon), which is why the marker +// alone is not enough to claim a turn. +var praxisMarkerVerbs = []string{"отметь", "отметить", "mark"} + +// praxisDemonstratives — the words that point at the item she just read out. +// "отметь это как сделанное" names no пункт and still names one item, so these +// stand in for the noun. They resolve in the daemon and only against a digest she +// actually spoke; a demonstrative with no list behind it falls through to the +// rest of the cascade rather than asking, because "я это сделал" is a sentence he +// says about his own day (Vikunja #516). +var praxisDemonstratives = []string{"это", "этот", "эту", "этим", "этого", "том", "that", "this", "it"} + +// praxisRefThis is the value slot for a demonstrative reference. Not a number, +// so it cannot be confused with a position, and not empty, so it cannot be +// confused with "he named no item". +const praxisRefThis = "this" + +// PraxisLifecycle — a parsed transition: which capability, and which item. +// +// Ref is either a Praxis item id he read off a screen, or a position in the list +// she last spoke: "1".."N" as a decimal string, or "last". Resolving a position +// to an id needs the list, which lives in the daemon, so the router names the +// position and does not pretend to know the id. +type PraxisLifecycle struct { + Fn string + Ref string +} + +var praxisItemIDPattern = regexp.MustCompile(`(?i)\b(item[_-][a-z0-9_-]+)`) + +// ParsePraxisLifecycle reads a lifecycle instruction: which transition, and which +// item. A stative word needs an item named beside it; an imperative does not. +func ParsePraxisLifecycle(text string) (PraxisLifecycle, bool) { + lower := strings.ToLower(strings.TrimSpace(text)) + if lower == "" { + return PraxisLifecycle{}, false + } + toks := praxisTokens(lower) + var fn string + imperative := false + for _, c := range praxisLifecycleVerbs { + imp := praxisHasToken(toks, c.imperative) + if !imp && !praxisHasLemma(toks, c.stative) { + continue + } + if fn != "" { + // "готово, принято" names two transitions and they are not the same + // state. Asking beats picking, so this declines and the act falls + // through to the model and the clarify gate behind it. + return PraxisLifecycle{}, false + } + fn, imperative = c.fn, imp + } + if fn == "" { + // The marker verb alone: "отметь пункт" records a state and names none, so + // it means the weaker transition. It still needs the item named, since + // "отметь" is also how he opens a note. + if !praxisHasToken(toks, praxisMarkerVerbs) { + return PraxisLifecycle{}, false + } + fn = "acknowledge_item" + } + ref := "" + if m := praxisItemIDPattern.FindStringSubmatch(lower); m != nil { + ref = m[1] + } else if !praxisNamesItem(toks) { + // No noun and no id. A demonstrative stands in for the noun and means the + // item she just read out. + if praxisHasToken(toks, praxisDemonstratives) { + return PraxisLifecycle{Fn: fn, Ref: praxisRefThis}, true + } + // Otherwise only a bare imperative claims the turn, with nothing in the + // slot, so the capability asks which пункт. Bare is the whole condition: + // "закрой шторы в комнате" is an imperative too and it closes the curtains + // through Hexis, so anything naming its own object is not this rule's. A + // stative word — "готово" — is him reporting his day and never claims. + if !imperative || !praxisBareCommand(toks) { + return PraxisLifecycle{}, false + } + return PraxisLifecycle{Fn: fn}, true + } else { + n, ok := praxisPosition(toks) + if !ok { + // "отметь пункт" names the verb and the noun and no item. The + // capability's own "какой пункт?" is the right answer, so claim it. + return PraxisLifecycle{Fn: fn}, true + } + if n == -1 { + ref = "last" + } else { + ref = strconv.Itoa(n) + } + } + return PraxisLifecycle{Fn: fn, Ref: ref}, true +} + +// praxisChangesPattern — "что изменилось?", "какие изменения?". An ask word plus +// a change noun, the same pair FeedQueryGrammar wants, and "что нового" is +// deliberately absent: the feeds source claims that one and should. +var praxisChangesPattern = regexp.MustCompile( + `(?i)^\s*(что|какие|покажи|расскажи|what)\s*(там|мне|has)?\s*(изменилось|изменения|изменени[а-я]*|нового в системе|changed|changes)(\s|[?!.]|$)`) + +// praxisEntityPattern — scoped attention, which needs a subject and nothing else +// would give it one. Two framings only, both carrying an explicit subject: +// "статус X" and "как дела у X". A bare "как дела?" is a greeting and matches +// neither, because the subject is required after "у". +// +// Narrow on purpose. This rule claims the turn at stage 0, and the capability +// answers "не знаю, что это" when Nexus has no such entity — which is the right +// answer for "статус муzick" and the wrong one for anything the query chain could +// have looked up. So the framings must be ones he would only use about a thing he +// expects Maven to know by name. +var praxisEntityPattern = regexp.MustCompile( + `(?i)^\s*(?:статус|status|как\s+дела\s+у|how\s+is)\s+(.+?)\s*[?!.]*$`) + +// praxisAttentionPattern — "что требует внимания?" and the two scoped forms of +// it. queryAttention answers the same question from the query chain, and this +// rule does not replace it: the chain covers every phrasing the model routes to +// IntentQuery, and this covers the three explicit ones, more directly and without +// spending a model call. They call the same capability, so they cannot disagree. +// +// "что нового" alone is absent, because the feeds own it. "что нового по +// проектам" is here, because a project is a Praxis scope and no feed has one. +var praxisAttentionPattern = regexp.MustCompile( + `(?i)^\s*(?:что|чего|what)\s+(?:сейчас\s+|там\s+|ещё\s+|еще\s+)?(?:требует\s+внимания|нового\s+по\s+(?:проектам|задачам|сервисам)|needs\s+attention)(\s|[?!.]|$)`) + +// PraxisGrammars — one stage-0 rule per Praxis capability that free speech can +// reach. Wired after the agenda and feed rules and before the capture marker. +func PraxisGrammars() []Grammar { + anything := regexp.MustCompile(`(?s)^(.*)$`) + return []Grammar{ + { + Name: "praxis-lifecycle", + Pattern: anything, + Build: func(m []string) (Decision, bool) { + c, ok := ParsePraxisLifecycle(m[1]) + if !ok { + return Decision{}, false + } + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: c.Fn, HasFn: true, Value: c.Ref}, + }, true + }, + }, + { + Name: "praxis-attention", + Pattern: praxisAttentionPattern, + Build: func([]string) (Decision, bool) { + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: "list_attention", HasFn: true}, + }, true + }, + }, + { + Name: "praxis-changes", + Pattern: praxisChangesPattern, + Build: func([]string) (Decision, bool) { + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: "list_changes", HasFn: true}, + }, true + }, + }, + { + Name: "praxis-entity-attention", + Pattern: praxisEntityPattern, + Build: func(m []string) (Decision, bool) { + subject := strings.TrimSpace(m[1]) + if subject == "" { + return Decision{}, false + } + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + // Text, not Value: entityAttentionCapability reads Value + // first and that slot means an item id everywhere else in + // the Praxis dispatch. + Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject}, + }, true + }, + }, + } +} + +// praxisPosition reads which item in the list a sentence names, with -1 for the +// last one. Three tries per token, in this order: +// +// 1. the ordinals set, which lists the forms it lists; +// 2. the same set by lemma, because Russian has more cases than a data file +// wants to spell out and "по первому пункту" is one of them; +// 3. the cardinals set, because "пункт три" is how a numbered list is read +// aloud and it names a position rather than a count. +// +// Earliest token wins, so "первый и второй" answers the first consistently +// rather than by map order. +func praxisPosition(toks []string) (int, bool) { + ords := lexicon.Ordinals() + for _, t := range toks { + if n, ok := lexicon.Ordinal(t); ok { + return n, true + } + for _, o := range ords { + if morph.SameWord(t, o.Word) { + return o.N, true + } + } + if n, ok := lexicon.Cardinal(t); ok && n > 0 { + return n, true + } + } + return 0, false +} + +// praxisTokens splits an utterance into bare words. Tokenizing rather than +// substring-matching, because "готов" is a substring of "готовлю" — he is cooking, +// not resolving an item — and Go's \b would not have caught that either, being +// ASCII-only next to Cyrillic. +func praxisTokens(lower string) []string { + return strings.FieldsFunc(lower, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' + }) +} + +// praxisHasToken reports whether any token equals one of the forms exactly. +// Exact, not by lemma: morph.SameWord makes "закрой" and "закрыл" one word, and +// only one of them is an instruction (the same trap quiet_toggle.go documents). +func praxisHasToken(toks, forms []string) bool { + for _, t := range toks { + for _, f := range forms { + if t == f { + return true + } + } + } + return false +} + +// praxisFiller — the words that can sit beside a bare command without giving it +// an object. Everything else is an object, and an imperative with an object is +// about that object. +var praxisFiller = []string{"пока", "уже", "давай", "это", "всё", "все", "ну", "и", "now", "then", "it"} + +// praxisBareCommand reports whether the sentence is a command and nothing else: +// every token is either a lifecycle word or filler. +func praxisBareCommand(toks []string) bool { + for _, t := range toks { + if praxisHasToken([]string{t}, praxisFiller) || praxisHasToken([]string{t}, praxisMarkerVerbs) { + continue + } + known := false + for _, c := range praxisLifecycleVerbs { + if praxisHasToken([]string{t}, c.imperative) || praxisHasLemma([]string{t}, c.stative) { + known = true + break + } + } + if !known { + return false + } + } + return true +} + +// praxisHasLemma is praxisHasToken by lemma, for the stative column only. +// "сделанный", "сделанным" and "сделано" are one word and mean one state, so the +// dictionary is the right test — and the imperative trap does not apply, because +// no form here is a command in the first place. +func praxisHasLemma(toks, forms []string) bool { + for _, t := range toks { + for _, f := range forms { + if t == f || morph.SameWord(t, f) { + return true + } + } + } + return false +} + +// praxisNamesItem reports whether a token is the item noun. This one IS matched +// by lemma: "пункт" is a noun, so every case of it means the same thing. +func praxisNamesItem(toks []string) bool { + for _, t := range toks { + if morph.SameWord(t, "пункт") || t == "item" || t == "items" || t == "entry" { + return true + } + } + return false +} diff --git a/internal/router/praxis_test.go b/internal/router/praxis_test.go new file mode 100644 index 0000000..cd8a67f --- /dev/null +++ b/internal/router/praxis_test.go @@ -0,0 +1,203 @@ +package router + +import "testing" + +// A lifecycle word alone is ordinary speech. Praxis mutations need an item named +// too, and the two agreement words mean different states (Vikunja #516). +func TestPraxisLifecycleNeedsAnItem(t *testing.T) { + for _, utt := range []string{ + "готово", + "принято", + "сделано, спасибо", + "понял", + "неважно", + "я всё сделал", + } { + if c, ok := ParsePraxisLifecycle(utt); ok { + t.Errorf("%q claimed as %+v; a bare lifecycle word must not transition an item", utt, c) + } + } +} + +func TestPraxisLifecycleVerbPicksTheCapability(t *testing.T) { + cases := []struct { + utt, fn, ref string + }{ + {"отметь второй пункт", "acknowledge_item", "2"}, + {"принято по первому пункту", "acknowledge_item", "1"}, + {"пункт три готово", "resolve_item", "3"}, + {"закрой последний пункт", "resolve_item", "last"}, + {"игнорируй второй пункт", "ignore_item", "2"}, + {"закрепи третий пункт", "pin_item", "3"}, + {"resolve item_ab12", "resolve_item", "item_ab12"}, + // The noun with no position: the capability asks which one, which is + // better than guessing and better than falling to the model. + {"отметь пункт", "acknowledge_item", ""}, + } + for _, c := range cases { + got, ok := ParsePraxisLifecycle(c.utt) + if !ok { + t.Errorf("%q was not claimed", c.utt) + continue + } + if got.Fn != c.fn || got.Ref != c.ref { + t.Errorf("%q = %+v, want fn=%s ref=%s", c.utt, got, c.fn, c.ref) + } + } +} + +// A demonstrative stands in for the item noun, because "отметь это как +// сделанное" is what he says to a digest she just read. Which item it is, is the +// daemon's question — the router only says that he pointed at one. +func TestPraxisLifecycleAcceptsADemonstrative(t *testing.T) { + for _, c := range []struct{ utt, fn string }{ + {"отметь это как сделанное", "resolve_item"}, + {"принято, я это видел", "acknowledge_item"}, + {"игнорировать это пока", "ignore_item"}, + {"закрепи это", "pin_item"}, + } { + got, ok := ParsePraxisLifecycle(c.utt) + if !ok { + t.Errorf("%q was not claimed", c.utt) + continue + } + if got.Fn != c.fn || got.Ref != "this" { + t.Errorf("%q = %+v, want fn=%s ref=this", c.utt, got, c.fn) + } + } +} + +func TestPraxisAttentionGrammar(t *testing.T) { + g := grammarByName(t, "praxis-attention") + for _, utt := range []string{"что требует внимания", "что сейчас требует внимания?", "что нового по проектам"} { + d, ok := matchGrammar(g, utt) + if !ok { + t.Errorf("%q was not claimed", utt) + continue + } + if d.Slots.Fn != "list_attention" { + t.Errorf("%q = fn %q", utt, d.Slots.Fn) + } + } + // The feeds own the unqualified form. + for _, utt := range []string{"что нового", "что нового в мире"} { + if _, ok := matchGrammar(g, utt); ok { + t.Errorf("%q belongs to the feeds, not Praxis", utt) + } + } +} + +// An imperative is addressed to her, so it claims the turn with an empty slot and +// the capability asks which пункт. A stative word in the same position does not. +func TestImperativeClaimsAndAsksButStativeDoesNot(t *testing.T) { + for _, c := range []struct{ utt, fn string }{ + {"закрывай", "resolve_item"}, + {"игнорируй пока", "ignore_item"}, + {"закрепи", "pin_item"}, + } { + got, ok := ParsePraxisLifecycle(c.utt) + if !ok || got.Fn != c.fn || got.Ref != "" { + t.Errorf("%q = %+v, %v; want fn=%s with an empty ref", c.utt, got, ok, c.fn) + } + } + // An imperative with an object of its own is about that object: "закрой + // шторы" closes the curtains through Hexis and is not a Praxis turn. + for _, utt := range []string{"закрой шторы в комнате", "закрой дверь", "пропусти песню"} { + if got, ok := ParsePraxisLifecycle(utt); ok { + t.Errorf("%q claimed as %+v; it names its own object", utt, got) + } + } + for _, utt := range []string{"готово", "принято", "неважно", "решено"} { + if got, ok := ParsePraxisLifecycle(utt); ok { + t.Errorf("%q claimed as %+v; a stative word needs an item named", utt, got) + } + } +} + +// "отметь" records a state and names none, so it cannot pick the transition by +// itself: with a state in the sentence the state wins, without one it is the +// weaker of the two. +func TestMarkerVerbTakesTheStateFromTheSentence(t *testing.T) { + if got, _ := ParsePraxisLifecycle("отметь второй пункт как сделанный"); got.Fn != "resolve_item" { + t.Errorf("a named state should win, got %+v", got) + } + if got, _ := ParsePraxisLifecycle("отметь второй пункт"); got.Fn != "acknowledge_item" { + t.Errorf("a marker with no state should acknowledge, got %+v", got) + } + // The marker is also a capture verb, so it must not claim a note. + if got, ok := ParsePraxisLifecycle("отметь что молоко закончилось"); ok { + t.Errorf("a note was claimed as a Praxis turn: %+v", got) + } +} + +// Two transitions in one sentence are not one transition. +func TestPraxisLifecycleRefusesTwoVerbs(t *testing.T) { + if c, ok := ParsePraxisLifecycle("первый пункт принято, готово"); ok { + t.Errorf("two lifecycle verbs resolved to %+v instead of declining", c) + } +} + +// "готов" must not fire inside "готовлю": tokens, not substrings. +func TestPraxisLifecycleDoesNotMatchInsideAWord(t *testing.T) { + if _, ok := ParsePraxisLifecycle("готовлю первый пункт меню"); ok { + t.Error("готовлю matched the resolve verb готов") + } +} + +func TestPraxisChangesGrammar(t *testing.T) { + g := grammarByName(t, "praxis-changes") + for _, utt := range []string{"что изменилось?", "какие изменения?", "покажи изменения", "what changed?"} { + if _, ok := matchGrammar(g, utt); !ok { + t.Errorf("%q was not claimed by praxis-changes", utt) + } + } + // The feeds source owns "что нового", and two paths to one answer disagree. + for _, utt := range []string{"что нового?", "что нового в мире?", "изменения погоды не волнуют"} { + if _, ok := matchGrammar(g, utt); ok { + t.Errorf("%q should not be a Praxis changes turn", utt) + } + } +} + +func TestPraxisEntityAttentionNeedsASubject(t *testing.T) { + g := grammarByName(t, "praxis-entity-attention") + for _, c := range []struct{ utt, subject string }{ + {"статус мавена", "мавена"}, + {"как дела у праксиса?", "праксиса"}, + {"status of hexis", "of hexis"}, + } { + d, ok := matchGrammar(g, c.utt) + if !ok { + t.Errorf("%q was not claimed", c.utt) + continue + } + if d.Slots.Fn != "entity_attention" || d.Slots.Text != c.subject { + t.Errorf("%q = fn %q text %q, want entity_attention / %q", c.utt, d.Slots.Fn, d.Slots.Text, c.subject) + } + } + // A greeting has no subject and must not reach Nexus. + for _, utt := range []string{"как дела?", "как дела у тебя", "статус", "привет"} { + if _, ok := matchGrammar(g, utt); ok && utt != "как дела у тебя" { + t.Errorf("%q claimed as scoped attention", utt) + } + } +} + +func grammarByName(t *testing.T, name string) Grammar { + t.Helper() + for _, g := range PraxisGrammars() { + if g.Name == name { + return g + } + } + t.Fatalf("no grammar named %q", name) + return Grammar{} +} + +func matchGrammar(g Grammar, utt string) (Decision, bool) { + m := g.Pattern.FindStringSubmatch(utt) + if m == nil { + return Decision{}, false + } + return g.Build(m) +}