Merge Praxis reach at stage 0 (V-516)

This commit is contained in:
2026-08-05 13:11:24 +04:00
13 changed files with 1044 additions and 1 deletions
+14
View File
@@ -214,6 +214,20 @@ New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
llama-server in that run), so judge it again before quoting a cascade number.
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
of the house. A demonstrative ("отметь это как сделанное") resolves against
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
the cascade rather than transitioning the wrong item.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
+72
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
@@ -105,6 +106,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
ctx = withCorrelationID(ctx, newCorrelationID())
}
px := h.ecosystem.praxis
dec, ok := h.resolveSurfacedPosition(dec)
if !ok {
// A demonstrative with no digest behind it. "я это сделал" is a sentence
// about his day, so the rest of the cascade gets it back rather than
// hearing "какой пункт?" for something that was never about a пункт.
return ""
}
for _, capability := range praxisCapabilities {
for _, alias := range capability.aliases() {
if alias == dec.Slots.Fn {
@@ -170,6 +178,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
}
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
var spoken []string
for _, item := range items {
title, _ := item["title"].(string)
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
@@ -194,11 +203,15 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
// a failed surface call must not block delivering the digest.
if id, ok := item["id"].(string); ok && id != "" {
// Recorded in the order she says them, and only for items she could
// say: an item skipped above has no position in what he heard (#516).
spoken = append(spoken, id)
if _, err := px.Surface(ctx, id); err != nil {
log.Printf("ecosystem: praxis surface %s: %v", id, err)
}
}
}
h.rememberSurfaced(spoken)
if len(parts) == 0 {
// Praxis returned items and not one of them could be said. "ничего не
// требует внимания" is the honest answer; the list line would render as
@@ -835,3 +848,62 @@ func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisCli
}
return ""
}
// rememberSurfaced records the item ids she just read out, replacing whatever the
// previous digest left. Called with the ids in speaking order (Vikunja #516).
func (h *reactiveHandler) rememberSurfaced(ids []string) {
h.mu.Lock()
defer h.mu.Unlock()
h.surfacedItems = ids
}
// resolveSurfacedPosition turns a positional item reference into a Praxis item
// id, using the list she last read out.
//
// The router names a position and not an id, because only the daemon has the
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
// left alone, since "item_ab12" is already one.
//
// The second return says whether the turn is still Praxis's. A position that
// names nothing keeps the turn and clears the slot, so the capability answers its
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
// because "я это сделал" was probably never about a пункт at all. "это" also
// needs the list to hold exactly one item: pointing at one of five is a guess,
// and a wrong guess here transitions the wrong item.
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
ref := dec.Slots.Value
if ref == "" || strings.HasPrefix(ref, "item") {
return dec, true
}
h.mu.Lock()
ids := h.surfacedItems
h.mu.Unlock()
idx := -1
switch {
case ref == "this":
if len(ids) != 1 {
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
return dec, false
}
idx = 0
case ref == "last":
idx = len(ids) - 1
default:
n, err := strconv.Atoi(ref)
if err != nil || n < 1 {
// Neither a position nor an id: leave it for the capability to
// reject rather than silently rewriting what he said.
return dec, true
}
idx = n - 1
}
if idx < 0 || idx >= len(ids) {
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
dec.Slots.Value = ""
return dec, true
}
dec.Slots.Value = ids[idx]
return dec, true
}
+8
View File
@@ -178,6 +178,14 @@ func (fs *fakeServer) Requests() []capturedRequest {
return out
}
// ResetRequests drops the captured requests, so a test can assert about one
// turn without subtracting the setup turn's calls.
func (fs *fakeServer) ResetRequests() {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.requests = nil
}
func jsonHandler(status int, body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"context"
"strings"
"testing"
)
// "отметь второй пункт" names a position, and only the daemon knows which item
// that is. The router fills the value slot with "2"; this is where it becomes an
// item id (Vikunja #516).
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_a","title":"диск заканчивается"},
{"id":"item_b","title":"бэкап не прошёл"},
{"id":"item_c","title":"сертификат истекает"}
]`)
h := newPraxisTestHandler(t, praxis)
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
t.Fatal("attention returned nothing")
}
cases := []struct{ ref, wantItem string }{
{"2", "item_b"},
{"1", "item_a"},
{"last", "item_c"},
}
for _, c := range cases {
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
if !strings.Contains(reply, "принято") {
t.Errorf("ref %q: reply %q", c.ref, reply)
}
if !requestedPathContaining(praxis, c.wantItem) {
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
}
}
}
// A position past the end must not acknowledge the wrong item. It asks.
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
if !strings.Contains(reply, "какой пункт") {
t.Errorf("a position with no item should ask, got %q", reply)
}
if requestedPathContaining(praxis, "item_a") {
t.Error("the only surfaced item was resolved for a position that did not name it")
}
}
// No digest yet means no positions. Nothing is mutated.
func TestPositionWithNoSpokenListAsks(t *testing.T) {
praxis := newFakePraxis(t, `[]`)
h := newPraxisTestHandler(t, praxis)
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
if !strings.Contains(reply, "какой пункт") {
t.Errorf("want the ask, got %q", reply)
}
}
// An explicit id is not a position and passes through untouched.
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
if !requestedPathContaining(praxis, "item_zz") {
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
}
}
// An item Praxis sent without a title is never spoken, so it holds no position.
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_silent"},
{"id":"item_said","title":"бэкап не прошёл"}
]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
if !requestedPathContaining(praxis, "item_said") {
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
}
}
// The item id travels in the POST body, so that is what these read.
func paths(f *fakeServer) []string {
var out []string
for _, r := range f.Requests() {
out = append(out, r.Path+" "+string(r.Body))
}
return out
}
func requestedPathContaining(f *fakeServer, want string) bool {
for _, r := range f.Requests() {
if strings.Contains(string(r.Body), want) {
return true
}
}
return false
}
// "отметь это как сделанное" after a one-item digest points at that item.
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
if !strings.Contains(reply, "принято") {
t.Errorf("reply %q", reply)
}
if !requestedPathContaining(praxis, "item_only") {
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
}
}
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
// item. The turn goes back to the cascade instead.
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
praxis := newFakePraxis(t, `[
{"id":"item_a","title":"диск"},
{"id":"item_b","title":"бэкап"}
]`)
h := newPraxisTestHandler(t, praxis)
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
praxis.ResetRequests()
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
t.Errorf("want a fall-through, got %q", reply)
}
for _, p := range paths(praxis) {
if strings.Contains(p, "resolve") {
t.Error("an ambiguous demonstrative resolved an item anyway")
}
}
}
// "я это сделал" with no digest behind it is a sentence about his day.
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
praxis := newFakePraxis(t, `[]`)
h := newPraxisTestHandler(t, praxis)
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
t.Errorf("want a fall-through, got %q", reply)
}
}
+9
View File
@@ -158,6 +158,15 @@ type reactiveHandler struct {
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
// surfacedItems — the Praxis item ids she last read out, in the order she
// read them, so "отметь второй пункт" has a second pункт to mean (Vikunja
// #516). Same single-slot posture as pending above: the next attention digest
// replaces the list, because a position only refers to the last one spoken.
// No TTL — a stale position resolves to an item that Praxis will report as
// already acknowledged, which is a harmless answer, unlike a stale
// confirmation that would execute something.
surfacedItems []string
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
}
+4
View File
@@ -392,6 +392,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
grammars = append(grammars, router.TaskListGrammar())
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Before the capture marker, because "отметь" is a capture verb and "отметь
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
grammars = append(grammars, router.PraxisGrammars()...)
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
// rewrote the task text (Vikunja #467). After the rules above because a
+63
View File
@@ -0,0 +1,63 @@
# Praxis reach at stage 0, 2026-08-05
Vikunja #516. Measured with `make eval-reach` on the held-out ecosystem fixture
(`internal/router/eval/ru_ecosystem_v1.json`, 30 cases), classifier + ONNX embedder,
no llama-server in the run. The LLM arm was not measured, so judge a cascade
number again before quoting one.
## Result
| | before | after |
|---|---|---|
| overall | 16/30 (53.3%) | 27/30 (90.0%) |
| by want: praxis | 0/12 | 11/12 |
| by want: hexis | 9/10 | 9/10 |
| by want: none | 7/8 | 7/8 |
| by tag: lifecycle | 0/5 | 5/5 |
| by tag: attention | 0/7 | 6/7 |
| by tag: reading | 0/7 | 6/7 |
| wrong praxis arm | 0 | 0 |
| p50 latency | 20.6ms | 16.5ms |
## Why it was zero
Not a tuning gap. `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 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.
`PraxisGrammars()` (`internal/router/praxis.go`) fills the slot at stage 0, wired in
`buildRouter` before the capture marker because "отметь" is a capture verb.
## The three misses that remain
- `eco-ru-006` "запусти бэкап на нексусе", a Hexis case, routed note. Pre-existing.
- `eco-ru-028` "выключи", reached Hexis, should have asked. Pre-existing.
- `eco-ru-021` "что там с нексусом" wants scoped attention. Deliberately not
claimed. "что там с X" also opens "что там с погодой". Routing a weather
question to Nexus is worse than one missed fixture case.
## Two judgement calls worth re-arguing
**A lifecycle word alone does not transition an item.** "готово" is what he says
about the thing he just finished. So the rules split lifecycle words by mood. An
imperative he says to her ("закрывай") claims the turn bare, and the capability
asks which пункт. A stative ("готово", "принято") needs an item named beside it.
The bare-imperative arm also requires that nothing else in the sentence is being
acted on. "закрой шторы в комнате" is an imperative too. Without that guard it took
a house command to Praxis, measured at hexis 8/10 mid-change.
**A demonstrative resolves only against a one-item digest.** "отметь это как
сделанное" points at what she just read. `resolveSurfacedPosition` maps it to an id
only when exactly one item was spoken. With two or more it gives the turn back to
the cascade rather than transitioning one of them at random. With no digest at all
it gives the turn back too, because "я это сделал" was never about a пункт.
## Routing fixture
`make eval-router`, same run: classifier + ONNX 60/84 (71.4% full and intent-only),
0 false clarifies, 6 missed clarifies (the known `amb-*` set). No failure in that
list comes from a stage-0 decision. Every one carries a classifier confidence score.
+75 -1
View File
@@ -61,7 +61,7 @@ func mustLoad() lexiconFile {
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
}
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs",
} {
@@ -120,6 +120,58 @@ func Cardinal(word string) (int, bool) {
return n, ok
}
// Ordinal reports the 1-based position a position word names, with -1 for the
// last one. Same lookup shape as Cardinal, and the same reason: "второй" and
// "вторым" are one position, and a caller matching stems would also match
// "вторник".
func Ordinal(word string) (int, bool) {
n, ok := ru.Sets["ordinals"].Values[norm(word)]
return n, ok
}
// Ordinals returns the position words with their positions, sorted, so a caller
// that needs a form this set does not list can ask a morphological dictionary
// whether one of these is the same word. Sorted because map order is not stable
// and a caller folding these into a pattern would otherwise build a different one
// every run.
func Ordinals() []struct {
Word string
N int
} {
vals := ru.Sets["ordinals"].Values
out := make([]struct {
Word string
N int
}, 0, len(vals))
for w, n := range vals {
out = append(out, struct {
Word string
N int
}{w, n})
}
sort.Slice(out, func(i, j int) bool { return out[i].Word < out[j].Word })
return out
}
// OrdinalIn reports the position word that comes FIRST in a sentence, so a
// caller does not have to tokenize before asking. Word-boundary matched for the
// reason above, and earliest-wins rather than first-found: map iteration order
// would otherwise answer "отметь первый и второй" differently between runs.
func OrdinalIn(text string) (int, bool) {
lower := norm(text)
best, at := 0, -1
for w, n := range ru.Sets["ordinals"].Values {
i := indexWord(lower, w)
if i < 0 || (at >= 0 && i > at) {
continue
}
// Two different words cannot match at one offset: both ends are
// boundary-checked, so no key is a prefix of another as matched.
best, at = n, i
}
return best, at >= 0
}
// DayOffset reports how many days a relative day word moves from today.
//
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
@@ -192,6 +244,28 @@ func abs(n int) int {
return n
}
// indexWord is containsWord returning where the match starts, or -1.
func indexWord(haystack, needle string) int {
if needle == "" {
return -1
}
from := 0
for {
i := strings.Index(haystack[from:], needle)
if i < 0 {
return -1
}
i += from
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
return i
}
from = i + len(needle)
if from >= len(haystack) {
return -1
}
}
}
// containsWord reports whether haystack holds needle on word boundaries. Go's
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
// checked here instead: a rune on either side must not be a letter or a digit.
+16
View File
@@ -37,6 +37,22 @@
"tell", "explain", "describe", "list"
]
},
"ordinals": {
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers.",
"values": {
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
"третий": 3, "третья": 3, "третье": 3, "третьего": 3, "третью": 3, "третьим": 3, "третьей": 3, "third": 3,
"четвёртый": 4, "четвертый": 4, "четвёртая": 4, "четвертая": 4, "четвёртого": 4, "четвертого": 4, "четвёртую": 4, "четвертую": 4, "четвёртым": 4, "четвертым": 4, "fourth": 4,
"пятый": 5, "пятая": 5, "пятое": 5, "пятого": 5, "пятую": 5, "пятым": 5, "пятой": 5, "fifth": 5,
"шестой": 6, "шестая": 6, "шестое": 6, "шестого": 6, "шестую": 6, "шестым": 6, "sixth": 6,
"седьмой": 7, "седьмая": 7, "седьмое": 7, "седьмого": 7, "седьмую": 7, "седьмым": 7, "seventh": 7,
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
}
},
"cardinals": {
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
"values": {
+48
View File
@@ -112,3 +112,51 @@ func TestCallerCannotEditTheLexicon(t *testing.T) {
t.Fatal("the lexicon handed out its own backing array")
}
}
// The positions carry gender and oblique forms, because "второй пункт" and
// "закрепи вторым" name one position (Vikunja #516). "last" is a position and not
// a count, so it is -1 rather than a large number.
func TestOrdinalsSpanGenderAndCase(t *testing.T) {
for _, w := range []string{"второй", "вторая", "второе", "вторым", "второго", "second"} {
n, ok := Ordinal(w)
if !ok || n != 2 {
t.Errorf("Ordinal(%q) = %d, %v; want 2, true", w, n, ok)
}
}
for _, w := range []string{"последний", "последнюю", "last"} {
if n, ok := Ordinal(w); !ok || n != -1 {
t.Errorf("Ordinal(%q) = %d, %v; want -1, true", w, n, ok)
}
}
// A weekday shares a stem with a position and is not one.
if n, ok := Ordinal("вторник"); ok {
t.Errorf("Ordinal(\"вторник\") = %d; a weekday is not a position", n)
}
}
// Earliest wins, not map order: the same sentence must answer the same way twice.
func TestOrdinalInTakesTheFirstPosition(t *testing.T) {
for i := 0; i < 50; i++ {
n, ok := OrdinalIn("отметь первый и второй пункт")
if !ok || n != 1 {
t.Fatalf("run %d: OrdinalIn = %d, %v; want 1, true", i, n, ok)
}
}
if _, ok := OrdinalIn("отметь пункт"); ok {
t.Error("a sentence with no position reported one")
}
}
// Ordinals is the escape hatch for the cases the file does not list, so it must
// hand out every entry and hand out the same order twice.
func TestOrdinalsListIsCompleteAndStable(t *testing.T) {
a, b := Ordinals(), Ordinals()
if len(a) != len(ru.Sets["ordinals"].Values) {
t.Errorf("Ordinals returned %d of %d entries", len(a), len(ru.Sets["ordinals"].Values))
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("Ordinals order is not stable at %d: %v vs %v", i, a[i], b[i])
}
}
}
+2
View File
@@ -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).
+370
View File
@@ -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
}
+203
View File
@@ -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)
}