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