06adc4702d
Assign provenance where the exact fn is produced: - grammar_fixed: praxis/task-status grammars hardcode fn - grammar_matcher: wakeword-act grammar invokes ActMatcher - extractor_raw: Extractor.Extract matches over raw utterance - extractor_llm_text: fillSlots LLM backfill matches cleaned text - fallback_matcher: ResolveActionCandidate runs the fallback matcher ResolveActionCandidate propagates Slots.ResolvedBy into ActionCandidate.ResolvedBy. No selection behavior changes.
395 lines
16 KiB
Go
395 lines
16 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, ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, 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, ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, 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, ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, true
|
||
},
|
||
},
|
||
{
|
||
Name: "praxis-service-attention",
|
||
Decide: praxisServiceAttentionDecision,
|
||
},
|
||
{
|
||
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, ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, true
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// praxisServiceAttentionDecision recognises the colloquial operational frame
|
||
// "что там с X" only for the four services Maven's own architecture names.
|
||
// The generic frame is deliberately not claimed: "что там с погодой" belongs
|
||
// to weather and "что там с бэкапами" may need recall, network and attention.
|
||
// Arbitrary entity names remain Nexus's open-set responsibility.
|
||
func praxisServiceAttentionDecision(utterance string) (Decision, bool) {
|
||
tokens := praxisTokens(strings.ToLower(strings.TrimSpace(utterance)))
|
||
if len(tokens) != 4 || tokens[0] != "что" || tokens[1] != "там" ||
|
||
(tokens[2] != "с" && tokens[2] != "со") ||
|
||
!hasExactWord(lexicon.EcosystemServices(), tokens[3]) {
|
||
return Decision{}, false
|
||
}
|
||
return Decision{
|
||
Stage: 0,
|
||
Intent: IntentAct,
|
||
Confidence: 1,
|
||
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3], ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, 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
|
||
}
|