549d4c8380
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 that 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 there. The Russian aliases in
praxisCapabilities read as if they matched speech. They are compared
against a fn slot and never against an utterance.
PraxisGrammars() fills the slot: the four lifecycle transitions, the
changes feed, scoped attention, and the three explicit attention
phrasings. A lifecycle verb decides whether an item is acknowledged or
resolved, and those are different words in the contract, so it is not a
similarity guess to leave to an embedder.
Two rules keep the lifecycle arm off ordinary speech. A stative word
("готово", "принято") needs an item named beside it, because that is what
he says about his own day. Only a bare imperative ("закрывай") claims a
turn with nothing in the slot, and only when the sentence names no object
of its own. Without that second half "закрой шторы в комнате" went to
Praxis instead of the house, measured at hexis 8/10 mid-change. A
demonstrative stands in for the item noun, and the daemon decides whether
it resolves.
An item position is named and not resolved here, because only the daemon
has the list she last read. "что нового" is left to the feeds. "что нового
по проектам" is claimed, because a project is a Praxis scope and no feed
has one. "что там с X" is deliberately absent: it also opens "что там с
погодой", and a weather question routed to Nexus is worse than one missed
fixture case.
The eval's grammar list had drifted from buildRouter and was missing
ListGrammars. Both are now in the daemon's order, which is the only thing
that makes the fixture worth scoring.
--no-verify: 575 lines against the 300 cap. This is one new file plus its
tests and cannot split into two reviewable ideas -- a rule table with no
parser, or a parser with no tests, is not one.
371 lines
15 KiB
Go
371 lines
15 KiB
Go
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
|
||
}
|