Merge Praxis reach at stage 0 (V-516)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user