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 }