Add seven stage 0 frames and tighten three more (V-720)

MavenHelpGrammar keeps "как отменить напоминание" on SourceSelf, where the
answer names the command Maven accepts, instead of leaking to search.
PublicCurrentVersionGrammar anchors an explicitly current release on
SourceWorld and declines first-person ownership.

AmbiguousFragmentGrammar refuses filler plus an unresolved demonstrative
rather than letting a statistical head invent context.
ImplicitElapsedQueryGrammar reads Russian question word order in "давно я
не тренировался" as recall; the declarative order stays a statement.
ReminderCancellationReportGrammar keeps "я отменил напоминание" in the
non-mutating chat lane.

CommandProhibitionGrammar routes a direct negative command to a sentinel
fn that can never collide with an enabled tool. ActHasEntityTarget stops a
bare verb or a demonstrative-only tail from crossing into Nexus.

Praxis attention now accepts "что там с X" for the four service names only.
taskstatus separates command mood from result words so a first-person
report cannot mutate the board. question.go exports the open-question and
locative shapes the recall gate reads.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:18:48 +04:00
parent 9944ec8c58
commit a97764c5f7
30 changed files with 1616 additions and 78 deletions
+43
View File
@@ -0,0 +1,43 @@
package router
import "github.com/kami/maven/internal/lexicon"
// ActHasEntityTarget reports whether an act names something that Nexus may be
// asked to resolve. A local zero-argument tool may still be valid; this gate is
// only about crossing into the ecosystem, where an entity is mandatory.
//
// A matched function carries its target in Args. An unmatched entity act is
// the Hexis discovery lane, so it must at least contain a verb plus a subject.
// A bare verb and a demonstrative-only tail carry no target evidence and stay
// local/clarify instead of sending free ambiguity to Nexus.
func ActHasEntityTarget(decision Decision) bool {
if decision.Intent != IntentAct {
return false
}
if decision.Slots.HasFn {
if len(decision.Slots.Args) > 0 {
return hasNamedEntityToken(decision.Slots.Args)
}
// A resident-model act may name the accepted verb and its target in
// Text while leaving Args empty. HasFn establishes that the first token
// is the operation; only a meaningful tail can establish the entity.
tokens := planTokens(decision.Slots.Text)
return len(tokens) > 1 && hasNamedEntityToken(tokens[1:])
}
tokens := planTokens(decision.Slots.Text)
if len(tokens) < 2 {
return false
}
return hasNamedEntityToken(tokens[1:])
}
func hasNamedEntityToken(tokens []string) bool {
references := lexicon.UnresolvedReferences()
for _, token := range tokens {
if lexicon.IsFillerParticle(token) || hasExactWord(references, token) {
continue
}
return true
}
return false
}
+69
View File
@@ -0,0 +1,69 @@
package router
import "testing"
func TestActHasEntityTargetRequiresNamedTargetEvidence(t *testing.T) {
cases := []struct {
name string
dec Decision
want bool
}{
{
name: "matched function and argument",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "restart", HasFn: true, Args: []string{"nginx"}, Text: "restart nginx",
}},
want: true,
},
{
name: "model function and target text",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "restart", HasFn: true, Text: "перезапусти гитею",
}},
want: true,
},
{
name: "matched function alone",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Text: "выключи",
}},
want: false,
},
{
name: "matched function with politeness only",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Args: []string{"пожалуйста"}, Text: "выключи пожалуйста",
}},
want: false,
},
{
name: "matched function with anaphora only",
dec: Decision{Intent: IntentAct, Slots: Slots{
Fn: "выключи", HasFn: true, Args: []string{"его"}, Text: "выключи его",
}},
want: false,
},
{
name: "unmatched entity act",
dec: Decision{Intent: IntentAct, Slots: Slots{Text: "перезапусти muzick indexer"}},
want: true,
},
{
name: "unmatched verb alone",
dec: Decision{Intent: IntentAct, Slots: Slots{Text: "перезапусти"}},
want: false,
},
{
name: "unresolved demonstrative",
dec: Decision{Intent: IntentAct, Slots: Slots{Text: "сделай это"}},
want: false,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := ActHasEntityTarget(testCase.dec); got != testCase.want {
t.Errorf("ActHasEntityTarget(%+v) = %v, want %v", testCase.dec, got, testCase.want)
}
})
}
}
+193
View File
@@ -0,0 +1,193 @@
package router
import (
"strings"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// ProhibitedActFn is the stage-0 fn used for a command whose only authority is
// negative: the user explicitly told Maven not to perform it. It is not an
// executable tool name. The daemon consumes it as a deterministic no-op before
// any local or ecosystem executor is considered.
const ProhibitedActFn = "prohibited_act"
// CommandProhibition is the structural evidence carried by a direct negative
// command. Body begins with the verb governed by the prohibition, with address
// and politeness framing removed. Keeping the body available lets tests and
// future policy distinguish the scope without recovering it from substrings.
type CommandProhibition struct {
Body []string
}
// ParseCommandProhibition recognises an addressed prohibition at command
// position. It is deliberately a token grammar, not a search for "не"/"not":
// first-person reports ("я не закрыл"), questions and a negative clause after
// another request never grant or revoke execution authority.
//
// The small scope exceptions are semantic command frames of their own. "не
// забудь напомнить" / "don't forget to remind" is an affirmative reminder,
// and "не мог бы ты ..." is ordinary modal politeness. They must not be
// flattened into a refusal merely because their first surface token is
// negative. A forget frame is exempt only when it actually contains Maven's
// reminder verb; "не забудь закрыть задачу" remains an ambiguous action and is
// conservatively refused rather than allowed to mutate the board.
func ParseCommandProhibition(text string) (CommandProhibition, bool) {
tokens := commandFrameTokens(text)
for len(tokens) > 0 && commandLead(tokens[0]) {
tokens = tokens[1:]
}
if len(tokens) < 2 {
return CommandProhibition{}, false
}
body := tokens
switch {
case len(tokens) >= 3 && tokens[0] == "только" && tokens[1] == "не":
body = tokens[2:]
case len(tokens) >= 6 && tokens[0] == "ни" && tokens[1] == "в" && tokens[2] == "коем" && tokens[3] == "случае" && tokens[4] == "не":
body = tokens[5:]
case len(tokens) >= 5 && tokens[0] == "ни" && tokens[1] == "за" && tokens[2] == "что" && tokens[3] == "не":
body = tokens[4:]
case tokens[0] == "не":
body = tokens[1:]
case tokens[0] == "никогда":
body = tokens[1:]
if len(body) > 0 && body[0] == "не" {
body = body[1:]
}
case tokens[0] == "don't" || tokens[0] == "dont":
body = tokens[1:]
case len(tokens) >= 3 && tokens[0] == "do" && tokens[1] == "not":
body = tokens[2:]
case tokens[0] == "never":
body = tokens[1:]
default:
return CommandProhibition{}, false
}
if len(body) == 0 || affirmativeNegativeFrame(body) || selfStateReport(body) {
return CommandProhibition{}, false
}
return CommandProhibition{Body: append([]string(nil), body...)}, true
}
// IsCommandProhibition is the execution-belt predicate. Callers use the same
// structural evidence at routing, dialogue and executor boundaries so a model
// cannot recover authority by changing the intent or rewriting the text slot.
func IsCommandProhibition(text string) bool {
_, ok := ParseCommandProhibition(text)
return ok
}
// CommandProhibitionGrammar gives a direct prohibition a deterministic stage-0
// route. The sentinel is intentionally an Act: it reaches the same daemon
// policy as a model-routed action, but can never collide with an enabled tool.
func CommandProhibitionGrammar() Grammar {
return Grammar{
Name: "command-prohibition",
Decide: func(utterance string) (Decision, bool) {
if !IsCommandProhibition(utterance) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentAct,
Confidence: 1,
Slots: Slots{
Fn: ProhibitedActFn,
HasFn: true,
},
}, true
},
}
}
// commandFrameTokens keeps apostrophes inside a word so don't is one grammar
// token. Every other punctuation rune is a boundary. This is intentionally
// local rather than reusing praxisTokens, whose underscore/hyphen policy is for
// item ids, not spoken command mood.
func commandFrameTokens(text string) []string {
var out []string
var word []rune
flush := func() {
for len(word) > 0 && word[len(word)-1] == '\'' {
word = word[:len(word)-1]
}
if len(word) > 0 {
out = append(out, string(word))
}
word = word[:0]
}
for _, r := range strings.ToLower(text) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
word = append(word, r)
case (r == '\'' || r == '') && len(word) > 0:
word = append(word, '\'')
default:
flush()
}
}
flush()
return out
}
func commandLead(token string) bool {
if lexicon.IsFillerParticle(token) {
return true
}
switch token {
case "maven", "мавен", "мавена", "мэйвен", "мейвен", "майвен", "мэвен":
return true
default:
return false
}
}
// selfStateReport declines a negation whose head describes the speaker rather
// than an action Maven could take. "ну не знаю" is an unclear answer to a parked
// question and must reach the clarify ladder; consuming it as a prohibition
// ended the dialogue with "хорошо, не буду" and dropped the pending reminder.
func selfStateReport(body []string) bool {
for _, verb := range lexicon.SelfStateVerbs() {
if body[0] == verb || morph.SameWord(body[0], verb) {
return true
}
}
return false
}
func affirmativeNegativeFrame(body []string) bool {
if len(body) == 0 {
return false
}
// Negative-polarity modal politeness: the complete bounded frame "не мог
// бы ты ..." / "не могли бы вы ..." asks for the nested action; a bare
// "не мог перезапустить" is instead a report and must not erase the safety
// belt merely because its modal has the same lemma.
if len(body) >= 4 && morph.SameWord(body[0], "мочь") && body[1] == "бы" &&
(body[2] == "ты" || body[2] == "вы") {
return true
}
if body[0] != "forget" && !morph.SameWord(body[0], "забыть") {
return false
}
// The exception is the nested reminder request itself, not a reminder word
// somewhere later in an unrelated sentence. English may carry infinitival
// "to" between the two verbs; Russian does not need a joiner.
at := 1
if at < len(body) && body[at] == "to" {
at++
}
if at >= len(body) {
return false
}
for _, reminderVerb := range lexicon.ReminderVerbs() {
if body[at] == reminderVerb || morph.SameWord(body[at], reminderVerb) {
return true
}
}
return false
}
+67
View File
@@ -0,0 +1,67 @@
package router
import "testing"
func TestParseCommandProhibitionUsesCommandPositionAndScope(t *testing.T) {
for _, utterance := range []string{
"не отменяй напоминание про молоко",
"не закрой задачу купить молоко",
"не закрыть задачу купить молоко",
"никогда не перезапускай nginx",
"только не удаляй будильник",
"ни в коем случае не перезапускай nginx",
"ни за что не закрывай задачу",
"Maven, пожалуйста, не удаляй будильник",
"don't restart nginx",
"dont close the task",
"do not cancel the milk reminder",
"never remove the task",
} {
if got, ok := ParseCommandProhibition(utterance); !ok || len(got.Body) == 0 {
t.Errorf("ParseCommandProhibition(%q) = %+v, %v; want direct prohibition", utterance, got, ok)
}
}
for _, utterance := range []string{
"я не закрыл задачу купить молоко",
"I did not close the task",
"можно ли не отменять напоминание",
"проверь диск, но ничего не удаляй",
"не забудь напомнить мне завтра про молоко",
"don't forget to remind me about milk",
"не мог бы ты отменить напоминание про молоко",
"donut restart nginx",
"noteworthy restart nginx",
} {
if got, ok := ParseCommandProhibition(utterance); ok {
t.Errorf("ParseCommandProhibition(%q) = %+v; not a direct prohibited mutation", utterance, got)
}
}
for _, utterance := range []string{
"не мог перезапустить nginx",
"не могла закрыть задачу купить молоко",
"не могли удалить напоминание",
"не забудь сначала задачу, потом напомнить про молоко",
} {
if _, ok := ParseCommandProhibition(utterance); !ok {
t.Errorf("%q must retain the fail-closed execution belt", utterance)
}
}
// This idiom does not contain a reminder verb. Treating its nested board
// transition as affirmative would be unsafe, so the execution belt wins.
if _, ok := ParseCommandProhibition("не забудь закрыть задачу купить молоко"); !ok {
t.Fatal("an ambiguous don't-forget board transition must fail closed")
}
}
func TestCommandProhibitionGrammarEmitsANonExecutableSentinel(t *testing.T) {
decision, matched, accepted := CommandProhibitionGrammar().Evaluate("don't restart nginx")
if !matched || !accepted || decision.Intent != IntentAct {
t.Fatalf("decision=%+v matched=%v accepted=%v; want deterministic act refusal", decision, matched, accepted)
}
if !decision.Slots.HasFn || decision.Slots.Fn != ProhibitedActFn {
t.Fatalf("slots=%+v, want non-executable fn %q", decision.Slots, ProhibitedActFn)
}
}
+49
View File
@@ -0,0 +1,49 @@
package router
import "github.com/kami/maven/internal/lexicon"
// PublicCurrentVersionGrammar anchors an explicitly current software/product
// release on the world side. This is the temporal-public analogue of the
// definition grammar: his notes may still be looked up first, but a personal
// boundary scorer must not turn "latest Go version" into private data and stop
// live search.
func PublicCurrentVersionGrammar() Grammar {
return Grammar{Name: "public-current-version", Decide: publicCurrentVersionDecision}
}
func publicCurrentVersionDecision(utterance string) (Decision, bool) {
if !IsPublicCurrentVersionQuestion(utterance) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1,
Source: SourceWorld,
}, true
}
// IsPublicCurrentVersionQuestion is the reusable boundary predicate. The two
// required lexical classes describe the frame, while the product/topic stays
// open. Any first-person ownership makes it private/local and declines.
func IsPublicCurrentVersionQuestion(utterance string) bool {
if !IsQuestionShaped(utterance) {
return false
}
tokens := planTokens(utterance)
for _, token := range tokens {
if hasExactWord(lexicon.FirstPerson(), token) || hasExactWord(lexicon.PersonalPossessives(), token) {
return false
}
}
hasNoun, hasMarker := false, false
for _, token := range tokens {
if sameAsAny(token, lexicon.CurrentVersionNouns()) {
hasNoun = true
}
if sameAsAny(token, lexicon.CurrentVersionMarkers()) {
hasMarker = true
}
}
return hasNoun && hasMarker
}
+34
View File
@@ -0,0 +1,34 @@
package router
import "testing"
func TestPublicCurrentVersionQuestionNamesTheWorld(t *testing.T) {
g := PublicCurrentVersionGrammar()
for _, utterance := range []string{
"какая последняя версия языка Go?",
"какой сейчас актуальный релиз PostgreSQL?",
"what is the latest supported Ubuntu release?",
} {
decision, matched, accepted := g.Evaluate(utterance)
if !matched || !accepted || decision.Intent != IntentQuery || decision.Source != SourceWorld {
t.Errorf("%q = %+v, matched=%v accepted=%v; want world query", utterance, decision, matched, accepted)
}
}
}
func TestPublicCurrentVersionQuestionRefusesPrivateOrIncompleteFrames(t *testing.T) {
g := PublicCurrentVersionGrammar()
for _, utterance := range []string{
"какая версия Go у меня установлена?",
"какая последняя версия моего документа?",
"какой текущий релиз нашего приложения?",
"какая актуальная версия своей схемы?",
"what is the latest version of my app?",
"последняя версия Go вышла вчера",
"какая версия будет следующей?",
} {
if decision, matched, accepted := g.Evaluate(utterance); matched || accepted {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
+4 -4
View File
@@ -118,12 +118,12 @@ var PraxisAliases = map[string]string{
// three are configured. It mirrors actionAct in cmd/mavend/actions_act.go and
// hexisBeforeClarify in cmd/mavend/ecosystem_acts.go, in their order:
//
// 1. A clarified act with text and no fn reaches Hexis before the clarify
// 1. A clarified act with a named entity target and no fn reaches Hexis before the clarify
// question is ever asked. That path runs on the raw slots, so the matcher
// does not get to fill fn first.
// 2. Otherwise the act matcher may earn a fn from the text slot.
// 3. A fn that is a Praxis capability alias dispatches to Praxis.
// 4. Non-empty text reaches Hexis.
// 4. An act with a named entity target reaches Hexis.
// 5. Anything else stays inside Maven.
//
// It returns the service and, for Praxis, the capability the fn landed on.
@@ -132,7 +132,7 @@ func Reach(d router.Decision, m router.ActMatcher) (Service, string) {
return ServiceNone, ""
}
if d.Clarify {
if !d.Slots.HasFn && d.Slots.Text != "" {
if !d.Slots.HasFn && router.ActHasEntityTarget(d) {
return ServiceHexis, ""
}
return ServiceNone, ""
@@ -148,7 +148,7 @@ func Reach(d router.Decision, m router.ActMatcher) (Service, string) {
return ServicePraxis, capability
}
}
if d.Slots.Text != "" {
if router.ActHasEntityTarget(d) {
return ServiceHexis, ""
}
return ServiceNone, ""
+7 -2
View File
@@ -107,10 +107,15 @@ func TestReachDerivation(t *testing.T) {
want: ServiceNone,
},
{
name: "a clarified act with text still reaches hexis",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}},
name: "a clarified act with a named entity still reaches hexis",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "перезапусти muzick indexer"}},
want: ServiceHexis,
},
{
name: "a clarified act with only an unresolved reference stays local",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Text: "выключи это"}},
want: ServiceNone,
},
{
name: "a clarified act that already has a fn does not",
dec: router.Decision{Intent: router.IntentAct, Clarify: true, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "перезапусти"}},
+63
View File
@@ -0,0 +1,63 @@
package router
import (
"strings"
"github.com/kami/maven/internal/lexicon"
)
// AmbiguousFragmentGrammar refuses an utterance which only points at context
// that is not present. The statistical heads usually catch these, but a missed
// refusal is the dangerous direction: "ну это" must not become a confident
// chat answer, and "сделай это" must not become an act.
//
// This is a structural whole-utterance rule. Demonstratives inside a sentence
// remain ordinary language: "это резервный ключ" names a subject and does not
// match. Only filler plus unresolved-reference words can reach this lane.
func AmbiguousFragmentGrammar() Grammar {
return Grammar{Name: "ambiguous-fragment", Decide: ambiguousFragmentDecision}
}
func ambiguousFragmentDecision(utterance string) (Decision, bool) {
if !thinReferenceFragment(utterance) {
return Decision{}, false
}
return Decision{
Stage: 3,
Intent: IntentChat,
Confidence: 0,
Clarify: true,
}, true
}
// thinReferenceFragment reports whether every content word merely points at
// something omitted. It requires at least one reference word so a politeness
// utterance such as "пожалуйста" stays social rather than becoming a refusal.
func thinReferenceFragment(utterance string) bool {
tokens := planTokens(strings.TrimSpace(utterance))
if len(tokens) == 0 {
return false
}
references := lexicon.UnresolvedReferences()
hasReference := false
for _, token := range tokens {
if lexicon.IsFillerParticle(token) {
continue
}
if hasExactWord(references, token) {
hasReference = true
continue
}
return false
}
return hasReference
}
func hasExactWord(words []string, token string) bool {
for _, word := range words {
if token == word {
return true
}
}
return false
}
+35
View File
@@ -0,0 +1,35 @@
package router
import "testing"
func TestAmbiguousFragmentGrammarRefusesOnlyMissingReferences(t *testing.T) {
g := AmbiguousFragmentGrammar()
for _, utterance := range []string{
"ну это",
"потом",
"это, пожалуйста",
"the thing from earlier",
"just that",
} {
decision, matched, accepted := g.Evaluate(utterance)
if !matched || !accepted || !decision.Clarify {
t.Errorf("%q = %+v, matched=%v accepted=%v; want a refusal", utterance, decision, matched, accepted)
}
}
}
func TestAmbiguousFragmentGrammarLeavesSentencesAndSocialTurnsAlone(t *testing.T) {
g := AmbiguousFragmentGrammar()
for _, utterance := range []string{
"это резервный ключ",
"сделай это",
"напомни про это завтра",
"пожалуйста",
"ну привет",
"that server is down",
} {
if decision, matched, accepted := g.Evaluate(utterance); matched || accepted {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package router
import (
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// HelpTopic is a Maven-owned operation the user is asking how to perform.
type HelpTopic string
const (
HelpUnknown HelpTopic = ""
HelpReminderCancel HelpTopic = "reminder_cancel"
HelpTaskDrop HelpTopic = "task_drop"
)
// MavenHelpGrammar keeps questions about using Maven's own mutation surfaces
// local (Vikunja V-720). A safe parser declining the mutation is only half the job: sending
// "как отменить напоминание" to web search still answers about somebody else's
// product. The operation verb and Maven-owned object together anchor SourceSelf.
func MavenHelpGrammar() Grammar {
return Grammar{Name: "maven-help", Decide: mavenHelpDecision}
}
func mavenHelpDecision(utterance string) (Decision, bool) {
if LocalHelpTopic(utterance) == HelpUnknown {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1,
Source: SourceSelf,
}, true
}
// LocalHelpTopic parses the two currently supported cancellation surfaces. It
// is intentionally object-bound: "как убрать царапину" is world advice, while
// the same verb applied to a task or reminder asks how to use Maven.
func LocalHelpTopic(utterance string) HelpTopic {
tokens := commandFrameTokens(utterance)
modal := localHelpModal(tokens)
if (!IsQuestionShaped(utterance) && !modal) || CarriesCaptureVerb(utterance) {
return HelpUnknown
}
hasHow := hasTok(tokens, "как") || hasTok(tokens, "how") || modal
if !hasHow {
return HelpUnknown
}
hasDropVerb := false
for _, token := range tokens {
if sameAsAny(token, lexicon.ReminderCancelReportVerbs()) || sameAsAny(token, lexicon.TaskDropWords()) {
hasDropVerb = true
break
}
}
if !hasDropVerb {
return HelpUnknown
}
hasReminder, hasTask := false, false
for _, token := range tokens {
if sameAsAny(token, lexicon.ReminderNouns()) {
hasReminder = true
}
if morph.SameWord(token, "задача") || token == "task" || token == "tasks" {
hasTask = true
}
}
if hasReminder == hasTask {
return HelpUnknown
}
if hasReminder {
return HelpReminderCancel
}
return HelpTaskDrop
}
// localHelpModal recognises permission/ability questions about the caller's own
// use of Maven. "can/could I" is help; "can you" is an action request and must
// continue to the command cascade. Russian impersonal "можно (ли)" carries the
// same product-help meaning. Exact leading tokens keep a modal inside a report
// or task title from stealing the turn.
func localHelpModal(tokens []string) bool {
for len(tokens) > 0 && commandLead(tokens[0]) {
tokens = tokens[1:]
}
if len(tokens) >= 2 && tokens[0] == "можно" {
return true
}
return len(tokens) >= 3 && (tokens[0] == "can" || tokens[0] == "could") && tokens[1] == "i"
}
+43
View File
@@ -0,0 +1,43 @@
package router
import "testing"
func TestMavenHelpGrammarKeepsFeatureHowToLocal(t *testing.T) {
g := MavenHelpGrammar()
for _, testCase := range []struct {
utterance string
topic HelpTopic
}{
{"как отменить напоминание про молоко?", HelpReminderCancel},
{"как удалить будильник на девять?", HelpReminderCancel},
{"как отменить задачу настроить бэкапы?", HelpTaskDrop},
{"how do I remove a task?", HelpTaskDrop},
{"можно ли отменить напоминание?", HelpReminderCancel},
{"can I cancel a reminder?", HelpReminderCancel},
{"could I cancel a task?", HelpTaskDrop},
} {
decision, matched, accepted := g.Evaluate(testCase.utterance)
if !matched || !accepted || decision.Intent != IntentQuery || decision.Source != SourceSelf {
t.Errorf("%q = %+v, matched=%v accepted=%v; want self query", testCase.utterance, decision, matched, accepted)
}
if got := LocalHelpTopic(testCase.utterance); got != testCase.topic {
t.Errorf("LocalHelpTopic(%q) = %q, want %q", testCase.utterance, got, testCase.topic)
}
}
}
func TestMavenHelpGrammarDoesNotStealCommandsOrWorldAdvice(t *testing.T) {
g := MavenHelpGrammar()
for _, utterance := range []string{
"отмени напоминание про молоко",
"убери из задач настроить бэкапы",
"как убрать царапину с моего стола?",
"как работает напоминание?",
"запиши как отменить задачу",
"can you cancel a reminder",
} {
if decision, matched, accepted := g.Evaluate(utterance); matched || accepted {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package router
import "github.com/kami/maven/internal/morph"
// ImplicitElapsedQueryGrammar recognises the Russian question shape which asks
// how long it has been without an explicit question word. Word order carries
// the distinction: "давно я не тренировался" asks Maven to look back, while
// "я давно не тренировался" is a statement about the owner.
func ImplicitElapsedQueryGrammar() Grammar {
return Grammar{Name: "implicit-elapsed-query", Decide: implicitElapsedQueryDecision}
}
func implicitElapsedQueryDecision(utterance string) (Decision, bool) {
tokens := planTokens(utterance)
if len(tokens) < 4 || tokens[0] != "давно" || (tokens[1] != "я" && tokens[1] != "мы") {
return Decision{}, false
}
if CarriesCaptureVerb(utterance) || carriesReminderVerbTokens(tokens) {
return Decision{}, false
}
negated := false
for _, token := range tokens[2:] {
if token == "не" {
negated = true
continue
}
if morph.IsVerbForm(token) {
if !negated {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1,
Source: SourceRecall,
}, true
}
}
return Decision{}, false
}
+26
View File
@@ -0,0 +1,26 @@
package router
import "testing"
func TestImplicitElapsedQueryUsesWordOrderAndNegation(t *testing.T) {
g := ImplicitElapsedQueryGrammar()
for _, utterance := range []string{
"давно я не тренировался",
"давно мы не виделись?",
} {
decision, matched, accepted := g.Evaluate(utterance)
if !matched || !accepted || decision.Intent != IntentQuery || decision.Source != SourceRecall {
t.Errorf("%q = %+v, matched=%v accepted=%v; want recall query", utterance, decision, matched, accepted)
}
}
for _, utterance := range []string{
"я давно не тренировался",
"давно не работает сервер",
"давно я тренировался",
"запиши: давно я не тренировался",
} {
if decision, matched, accepted := g.Evaluate(utterance); matched || accepted {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package router
import (
"strings"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// ParseNoteCapture extracts the user's note body from a leading capture
// command. It is the durable-write boundary: the router's model may label a
// turn as a note, but it may not rewrite what the notes table holds.
//
// The parser is structural, not a phrase pattern. It tracks Unicode word spans
// in the original utterance, accepts an optional wake word and leading
// particles, then requires one exact imperative from the capture lexicon. It
// removes only that command frame and returns the untouched remainder. A
// capture verb later in an ordinary sentence does not authorize a rewrite.
//
// Russian "что" is removed only when morphology proves that what follows is a
// clause with an inflected verb. When the evidence is ambiguous, as in "что
// такое TCP" or "что купить к ужину", it remains part of the note.
func ParseNoteCapture(utterance string) (string, bool) {
text := strings.TrimSpace(utterance)
if stripped, ok := StripWakeToken(text); ok {
text = stripped
}
words := noteCaptureWords(text)
if len(words) == 0 {
return "", false
}
i := 0
for i < len(words) && lexicon.IsFillerParticle(words[i].text) {
i++
}
if i >= len(words) || !isCaptureImperative(words[i].text) {
return "", false
}
end := words[i].end
i++
for i < len(words) && lexicon.IsCaptureFrameParticle(words[i].text) {
end = words[i].end
i++
}
if i < len(words) && words[i].text == "что" && hasInflectedClauseVerb(words[i+1:]) {
end = words[i].end
}
body := strings.TrimLeftFunc(text[end:], isNoteCaptureDelimiter)
if strings.TrimSpace(body) == "" {
return "", false
}
return body, true
}
type noteCaptureWord struct {
text string
start, end int
}
// noteCaptureWords tokenizes only far enough to locate safe cut points. Byte
// offsets keep the returned body in the user's original case and punctuation.
func noteCaptureWords(text string) []noteCaptureWord {
var out []noteCaptureWord
start := -1
for at, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
if start < 0 {
start = at
}
continue
}
if start >= 0 {
out = append(out, noteCaptureWord{
text: strings.ToLower(text[start:at]), start: start, end: at,
})
start = -1
}
}
if start >= 0 {
out = append(out, noteCaptureWord{
text: strings.ToLower(text[start:]), start: start, end: len(text),
})
}
return out
}
func isCaptureImperative(word string) bool {
for _, candidate := range captureVerbs {
if word == candidate {
return true
}
}
return false
}
func hasInflectedClauseVerb(words []noteCaptureWord) bool {
for _, word := range words {
if morph.IsVerbForm(word.text) && morph.Lemma(word.text) != word.text {
return true
}
}
return false
}
func isNoteCaptureDelimiter(r rune) bool {
if unicode.IsSpace(r) {
return true
}
switch r {
case ',', ':', ';', '.', '!', '?', '-', '', '—':
return true
default:
return false
}
}
+41
View File
@@ -0,0 +1,41 @@
package router
import "testing"
func TestParseNoteCaptureExtractsOnlyALeadingCommandFrame(t *testing.T) {
cases := []struct {
name string
utterance string
want string
ok bool
}{
{"colon", "запомни: запасной ключ лежит в синей коробке", "запасной ключ лежит в синей коробке", true},
{"case and politeness", "Запиши, пожалуйста: Кофе закончился.", "Кофе закончился.", true},
{"wake word", "Maven, remember: backup runs at 03:00", "backup runs at 03:00", true},
{"leading particles", "ну пожалуйста запомни — пароль в сейфе", "пароль в сейфе", true},
{"enclitic", "запиши-ка: ключ у двери", "ключ у двери", true},
{"quoted body", "сохрани: «Ключ — в ящике». ", "«Ключ — в ящике».", true},
{"meaningful conjunction", "запомни: и это важно", "и это важно", true},
{"question word is content", "запиши: что такое TCP?", "что такое TCP?", true},
{"infinitive question is content", "запиши что купить к ужину", "что купить к ужину", true},
{"ordinary note", "кофе закончился", "", false},
{"embedded command", "у меня новый ноутбук, запиши это", "", false},
{"past-tense report", "запомнил пароль от роутера", "", false},
{"empty command", "запомни: ...", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := ParseNoteCapture(tc.utterance)
if ok != tc.ok || got != tc.want {
t.Fatalf("ParseNoteCapture(%q) = %q, %v; want %q, %v", tc.utterance, got, ok, tc.want, tc.ok)
}
})
}
}
func TestParseNoteCaptureDropsProvenRussianComplementizer(t *testing.T) {
got, ok := ParseNoteCapture("запомни, что кофе закончился")
if !ok || got != "кофе закончился" {
t.Fatalf("got %q, %v; want an inflected clause without the capture complementizer", got, ok)
}
}
+24
View File
@@ -242,6 +242,10 @@ func PraxisGrammars() []Grammar {
}, true
},
},
{
Name: "praxis-service-attention",
Decide: praxisServiceAttentionDecision,
},
{
Name: "praxis-entity-attention",
Pattern: praxisEntityPattern,
@@ -264,6 +268,26 @@ func PraxisGrammars() []Grammar {
}
}
// praxisServiceAttentionDecision recognises the colloquial operational frame
// "что там с X" only for the four services Maven's own architecture names.
// The generic frame is deliberately not claimed: "что там с погодой" belongs
// to weather and "что там с бэкапами" may need recall, network and attention.
// Arbitrary entity names remain Nexus's open-set responsibility.
func praxisServiceAttentionDecision(utterance string) (Decision, bool) {
tokens := praxisTokens(strings.ToLower(strings.TrimSpace(utterance)))
if len(tokens) != 4 || tokens[0] != "что" || tokens[1] != "там" ||
(tokens[2] != "с" && tokens[2] != "со") ||
!hasExactWord(lexicon.EcosystemServices(), tokens[3]) {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentAct,
Confidence: 1,
Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3]},
}, true
}
// praxisPosition reads which item in the list a sentence names, with -1 for the
// last one. Three tries per token, in this order:
//
+26 -5
View File
@@ -183,6 +183,30 @@ func TestPraxisEntityAttentionNeedsASubject(t *testing.T) {
}
}
func TestPraxisServiceAttentionClaimsOnlyArchitectureServices(t *testing.T) {
g := grammarByName(t, "praxis-service-attention")
for _, utterance := range []string{
"что там с нексусом?",
"что там с праксисом",
"что там с хексисом!",
"что там с мавеном",
} {
decision, ok := matchGrammar(g, utterance)
if !ok || decision.Slots.Fn != "entity_attention" || decision.Slots.Text == "" {
t.Errorf("%q = %+v, ok=%v; want scoped attention", utterance, decision, ok)
}
}
for _, utterance := range []string{
"что там с погодой?",
"что там с бэкапами?",
"что там с сервером?",
} {
if decision, ok := matchGrammar(g, utterance); ok {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
func grammarByName(t *testing.T, name string) Grammar {
t.Helper()
for _, g := range PraxisGrammars() {
@@ -195,9 +219,6 @@ func grammarByName(t *testing.T, name string) Grammar {
}
func matchGrammar(g Grammar, utt string) (Decision, bool) {
m := g.Pattern.FindStringSubmatch(utt)
if m == nil {
return Decision{}, false
}
return g.Build(m)
decision, _, accepted := g.Evaluate(utt)
return decision, accepted
}
+54
View File
@@ -22,6 +22,7 @@ import (
// and "как" inside "какао" are not questions.
var (
interrogatives = lexicon.Interrogatives()
locativeQuestions = lexicon.LocativeInterrogatives()
narrativeRequests = lexicon.NarrativeRequests()
captureVerbs = lexicon.CaptureVerbs()
)
@@ -66,6 +67,59 @@ func IsQuestionShaped(text string) bool {
if strings.HasSuffix(t, "?") {
return true
}
return hasOpenQuestionTokens(toks)
}
// IsOpenQuestionShaped reports whether the words themselves ask for
// information: an interrogative ("где", "как", "which") or a narrative
// request ("расскажи", "explain"). A trailing question mark alone does not
// qualify. That distinction matters to recall: punctuation can turn an
// ordinary first-person proposition into a polar question, but it is not
// evidence that an unrelated stored note answers it.
//
// Capture verbs keep the same precedence as IsQuestionShaped, so "запиши что
// я пил" remains a write request even though it contains an interrogative.
func IsOpenQuestionShaped(text string) bool {
t := strings.TrimSpace(text)
if t == "" {
return false
}
toks := planTokens(t)
for _, v := range captureVerbs {
if hasTok(toks, v) {
return false
}
}
return hasOpenQuestionTokens(toks)
}
// IsLocativeQuestionShaped reports the open-question frame whose answer must
// locate the object or event named by the user. Recall treats that named target
// as evidence: a high cosine to an unrelated single stored note is not enough
// to answer "where is my passport?" with the location of a spare key (V-719).
//
// The words are the complete locative subset of the interrogative lexicon,
// and capture verbs retain precedence exactly as in IsQuestionShaped.
func IsLocativeQuestionShaped(text string) bool {
t := strings.TrimSpace(text)
if t == "" {
return false
}
toks := planTokens(t)
for _, v := range captureVerbs {
if hasTok(toks, v) {
return false
}
}
for _, w := range locativeQuestions {
if hasTok(toks, w) {
return true
}
}
return false
}
func hasOpenQuestionTokens(toks []string) bool {
for _, w := range interrogatives {
if hasTok(toks, w) {
return true
+50
View File
@@ -46,3 +46,53 @@ func TestIsQuestionShapedIsTokenized(t *testing.T) {
}
}
}
func TestIsOpenQuestionShapedDistinguishesWordsFromPunctuation(t *testing.T) {
for _, text := range []string{
"где лежит запасной ключ?",
"как я восстановил конфиги",
"which colour scheme do i like",
"расскажи про домашний сервер",
} {
if !IsOpenQuestionShaped(text) {
t.Errorf("IsOpenQuestionShaped(%q) = false, want an explicit information request", text)
}
}
for _, text := range []string{
"я отменил напоминание про молоко?",
"сервер работает?",
"запиши что я пил воду?",
} {
if IsOpenQuestionShaped(text) {
t.Errorf("IsOpenQuestionShaped(%q) = true; punctuation alone is not an open question", text)
}
}
// Existing callers still need polar punctuation to count as a question.
if !IsQuestionShaped("сервер работает?") {
t.Error("IsQuestionShaped stopped recognising a polar question")
}
}
func TestIsLocativeQuestionShaped(t *testing.T) {
for _, text := range []string{
"где мой паспорт?",
"куда я спрятал второй ключ",
"откуда берётся токен",
"докуда идёт автобус",
"where is the big disk mounted",
} {
if !IsLocativeQuestionShaped(text) {
t.Errorf("IsLocativeQuestionShaped(%q) = false, want true", text)
}
}
for _, text := range []string{
"во сколько я обычно засыпаю",
"which colour scheme do i like",
"сервер работает?",
"запиши где лежит ключ",
} {
if IsLocativeQuestionShaped(text) {
t.Errorf("IsLocativeQuestionShaped(%q) = true, want false", text)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package router
import (
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// ReminderCancellationReportGrammar keeps a completed-action statement out of
// recall. The mutation lane accepts only an imperative at the start of the
// turn; "я отменил напоминание" is instead a first-person report and must not
// be reinterpreted as either a cancellation request or a memory question.
func ReminderCancellationReportGrammar() Grammar {
return Grammar{Name: "reminder-cancellation-report", Decide: reminderCancellationReportDecision}
}
func reminderCancellationReportDecision(utterance string) (Decision, bool) {
if IsQuestionShaped(utterance) || CarriesCaptureVerb(utterance) {
return Decision{}, false
}
tokens := planTokens(utterance)
i := 0
for i < len(tokens) && lexicon.IsFillerParticle(tokens[i]) {
i++
}
if i >= len(tokens) || (tokens[i] != "я" && tokens[i] != "i") {
return Decision{}, false
}
i++
firstVerb := ""
for ; i < len(tokens); i++ {
if morph.IsVerbForm(tokens[i]) || hasExactWord(lexicon.ReminderCancelReportVerbs(), tokens[i]) {
firstVerb = tokens[i]
break
}
}
if firstVerb == "" || !sameAsAny(firstVerb, lexicon.ReminderCancelReportVerbs()) {
return Decision{}, false
}
hasReminder := false
for _, token := range tokens {
if sameAsAny(token, lexicon.ReminderNouns()) {
hasReminder = true
break
}
}
if !hasReminder {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentChat,
Confidence: 1,
Slots: Slots{Text: utterance},
}, true
}
func sameAsAny(token string, forms []string) bool {
for _, form := range forms {
if token == form || morph.SameWord(token, form) {
return true
}
}
return false
}
+33
View File
@@ -0,0 +1,33 @@
package router
import "testing"
func TestReminderCancellationReportIsChatAndNeverAMutation(t *testing.T) {
g := ReminderCancellationReportGrammar()
for _, utterance := range []string{
"я отменил напоминание про молоко",
"ну я удалил будильник на девять",
"I cancelled the reminder",
"я не отменил напоминание про врача",
} {
decision, matched, accepted := g.Evaluate(utterance)
if !matched || !accepted || decision.Intent != IntentChat || decision.Slots.HasFn {
t.Errorf("%q = %+v, matched=%v accepted=%v; want non-mutating chat", utterance, decision, matched, accepted)
}
}
}
func TestReminderCancellationReportDeclinesRequestsAndQuestions(t *testing.T) {
g := ReminderCancellationReportGrammar()
for _, utterance := range []string{
"отмени напоминание про молоко",
"как отменить напоминание про молоко?",
"я хочу отменить напоминание про молоко",
"запомни: я отменил напоминание про молоко",
"он сказал: я отменил напоминание",
} {
if decision, matched, accepted := g.Evaluate(utterance); matched || accepted {
t.Errorf("%q was claimed as %+v", utterance, decision)
}
}
}
+16
View File
@@ -140,6 +140,15 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
Clarify: res.Clarify,
}
r.fillSlots(ctx, &d, now)
// The clarify head relearned the English assumption that one word
// cannot be a sentence. Russian verbs carry subject and tense, and a
// deterministic fact parser which also found a key gives both halves
// of a complete write. That structural evidence outranks this one
// learned veto; question-shaped turns remain untouched.
clarifyOverruled := d.Clarify && completeParsedSingleVerbFact(d)
if clarifyOverruled {
d.Clarify = false
}
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.NeverAsked, Reason: "the routing heads answered",
@@ -151,6 +160,8 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
outcome, reason := decision.Won, ""
if d.Clarify {
outcome, reason = decision.Thinned, "the clarify head says there is too little here to act on"
} else if clarifyOverruled {
reason = "a parsed single-token Russian verb is a complete fact"
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantHeads,
string(d.Intent), d.Confidence, outcome, reason))
@@ -244,6 +255,11 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
return d, nil
}
func completeParsedSingleVerbFact(decision Decision) bool {
return decision.Intent == IntentFact && decision.Slots.HasKey &&
completeSingleVerb(decision.Utterance) && !IsQuestionShaped(decision.Utterance)
}
// fillMatchedSlots — run stage-2 extraction over a decision some earlier
// claimant produced, and fill only the slots that claimant left empty. A
// matched value always wins: the claimant read the sentence, the extractor
+13 -1
View File
@@ -42,7 +42,19 @@ func thinSingleToken(utterance string) bool {
if completeSingles[w] {
return false
}
return !morph.IsVerbForm(w)
return !completeSingleVerb(utterance)
}
// completeSingleVerb is the positive half of thinSingleToken. Kept separate so
// the routing heads can reconcile a learned clarify with the same grammatical
// fact the model-side gate already trusts: one Russian verb is a whole clause.
func completeSingleVerb(utterance string) bool {
fields := strings.Fields(utterance)
if len(fields) != 1 {
return false
}
word := strings.ToLower(strings.Trim(fields[0], ".,!?;:—-\"'«»()"))
return word != "" && morph.IsVerbForm(word)
}
// completeSingles — one-word utterances that need no second half. Greetings,
+17
View File
@@ -31,3 +31,20 @@ func TestThinSingleTokenIgnoresMultiWord(t *testing.T) {
}
}
}
func TestCompleteParsedSingleVerbFactNeedsGrammarAndAKey(t *testing.T) {
if !completeParsedSingleVerbFact(Decision{
Intent: IntentFact, Utterance: "поужинал", Slots: Slots{Key: "meal", HasKey: true},
}) {
t.Fatal("a parsed one-word Russian fact was not complete")
}
for _, decision := range []Decision{
{Intent: IntentFact, Utterance: "вода", Slots: Slots{Key: "water", HasKey: true}},
{Intent: IntentFact, Utterance: "поужинал"},
{Intent: IntentQuery, Utterance: "поужинал", Slots: Slots{Key: "meal", HasKey: true}},
} {
if completeParsedSingleVerbFact(decision) {
t.Errorf("incomplete decision was accepted: %+v", decision)
}
}
}
+22 -1
View File
@@ -22,24 +22,45 @@ package router
// the fixture can also run them one at a time and see which of them contend for
// the same utterance, which the cascade hides by stopping at the first match.
func StageZeroGrammars(acts ActMatcher) []Grammar {
grammars := DefaultGrammars(acts)
// Negative authority gets first refusal. This structural grammar reads the
// command frame, not a substring, and emits a non-executable sentinel. It
// must precede the wake-word allowlist fast path: an unusually permissive
// matcher may recognise the verb inside "Maven, don't restart nginx", but
// an allowlist match cannot turn an explicit prohibition into permission.
grammars := []Grammar{CommandProhibitionGrammar()}
grammars = append(grammars, DefaultGrammars(acts)...)
// A refusal, not a guessed intent. It is safe ahead of every positive rule
// because it accepts only filler plus unresolved-reference words.
grammars = append(grammars, AmbiguousFragmentGrammar())
grammars = append(grammars, SystemTimeDateGrammars()...)
// After the time/date rules on purpose: "какой сегодня день" is a clock
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, AgendaQueryGrammars()...)
// Russian can ask "how long since" through word order rather than a
// question word. This must land in recall before a statistical head reads
// the same past-tense verb as a fact.
grammars = append(grammars, ImplicitElapsedQueryGrammar())
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
// routed system and answered "пока не умею" (Vikunja #474).
// After the agenda rules, which are the narrower claim, and BEFORE the feed
// and list rules, which are not: "что такое лента" is a definition question
// and the feed rule would take it on the noun alone (V-655).
grammars = append(grammars, WorldQueryGrammars()...)
// Questions about operating Maven herself are local product help. They sit
// beside the world anchors because both decide which side of the personal
// boundary owns an answer, in opposite directions.
grammars = append(grammars, MavenHelpGrammar())
grammars = append(grammars, FeedQueryGrammar())
// 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, TaskListGrammar())
grammars = append(grammars, ListGrammars()...)
grammars = append(grammars, ReminderGrammar())
// A first-person report about a cancellation is neither another mutation
// nor a memory query. The imperative cancellation pre-route has already had
// first refusal before the router runs.
grammars = append(grammars, ReminderCancellationReportGrammar())
// 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).
+269 -51
View File
@@ -1,7 +1,6 @@
package router
import (
"regexp"
"strings"
"github.com/kami/maven/internal/lexicon"
@@ -45,34 +44,43 @@ type TaskStatus struct {
// speech, and "список дел" is already a list query.
var taskStatusNouns = []string{"task", "tasks", "todo", "todos"}
// taskStatusFillers — the words to ignore when what is left over is the task he
// named. Prepositions and the possessive, because "убери из моих задач купить
// молоко" names the same task as "убери задачу купить молоко".
var taskStatusFillers = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"}
// taskStatusTrailingFrame — grammar words that may remain after the named task.
// Words before the board noun are excluded by bounds; this set is used only at
// the trailing edge, so a title such as "сходить в банк" keeps its preposition.
var taskStatusTrailingFrame = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"}
// taskStatusTopicFrame reuses the closed possessive/topic grammar already used
// to identify a committed reminder's subject. It describes the noun phrase,
// not the reminder itself, so "задача про бэкапы" has the same boundary.
var taskStatusTopicFrame = lexicon.ReminderCancelFrame()
// ParseTaskStatus reads a status change over the board: which transition, and
// which task.
//
// Three conditions, all required. A task noun, so no ordinary sentence claims
// the turn. Exactly one status class, because "готово, убери" names two and
// asking beats picking. And a status word that is either an imperative in the
// exact form he said it or a stative by lemma — the trap quiet_toggle.go
// documents, where "закрой" and "закрыл" are one lemma and only one is a
// command.
// Four conditions, all required. A task noun, so no ordinary sentence claims
// the turn. Exactly one status class, because two transitions mean asking beats
// picking. Exact command vocabulary at command position, OR a result state by
// lemma inside an independently authorised "mark task as state" frame. The
// split is load-bearing: "закрой" and "закрыл" share a lemma, while only one is
// addressed to Maven. Finally, questions and direct prohibitions decline before
// the stage-0 decision can expose a write slot.
func ParseTaskStatus(text string) (TaskStatus, bool) {
toks := praxisTokens(strings.ToLower(strings.TrimSpace(text)))
if len(toks) == 0 || !taskStatusNamesBoard(toks) {
if len(toks) == 0 || !taskStatusNamesBoard(toks) || IsCommandProhibition(text) {
return TaskStatus{}, false
}
status := ""
statusAt := len(toks)
for _, c := range []struct {
status string
words []string
status string
commands []string
states []string
}{
{store.TaskDone, lexicon.TaskDoneWords()},
{store.TaskDropped, lexicon.TaskDropWords()},
{store.TaskDone, lexicon.TaskDoneCommands(), lexicon.TaskDoneStates()},
{store.TaskDropped, lexicon.TaskDropCommands(), lexicon.TaskDropStates()},
} {
if !taskStatusHasWord(toks, c.words) {
at, ok := taskStatusTransitionIndex(toks, c.commands, c.states)
if !ok {
continue
}
if status != "" {
@@ -81,11 +89,67 @@ func ParseTaskStatus(text string) (TaskStatus, bool) {
return TaskStatus{}, false
}
status = c.status
statusAt = at
}
if status == "" {
return TaskStatus{}, false
}
return TaskStatus{Status: status, Text: taskStatusReferent(toks)}, true
// An explicit result from the other transition still makes the sentence
// contradictory even when it is not, by itself, mutation authority:
// "задача готова, убери" says both completed and dropped. Decline instead
// of silently privileging the one imperative. Result vocabulary is used
// here only as conflict evidence, never to authorize a write.
if (status == store.TaskDone && taskStatusHasState(toks, lexicon.TaskDropStates())) ||
(status == store.TaskDropped && taskStatusHasState(toks, lexicon.TaskDoneStates())) {
return TaskStatus{}, false
}
if taskStatusQuestionShaped(text, toks, statusAt, status) {
return TaskStatus{}, false
}
return TaskStatus{Status: status, Text: taskStatusReferent(toks, statusAt)}, true
}
// taskStatusQuestionShaped keeps questions out of the mutating stage-0 rule.
// The general IsQuestionShaped predicate lets an explicit capture verb win —
// "запиши что я пил воду" is a write, not a query. That precedence cannot be
// reused here: TaskStatusGrammar runs before capture, and
// "запиши как отменить задачу" must remain a capture/query rather than become
// a task deletion merely because its later infinitive is in TaskDropWords.
//
// A question mark is conclusive. Without punctuation, a closed interrogative
// or narrative token before the status word makes the status word the subject
// of a question ("как отменить задачу", "объясни как закрыть задачу"). A token
// after an already stated change may belong to the stored task's name —
// "отмени задачу узнать когда рейс" — so it is not enough to reverse a clear
// command. Hyphenated indefinite pronouns remain one token under praxisTokens,
// hence "отмени задачу купить что-нибудь" is not mistaken for a question.
func taskStatusQuestionShaped(text string, toks []string, statusAt int, status string) bool {
if strings.Contains(text, "?") && !taskStatusPoliteModalAt(toks, statusAt) {
return true
}
for i, tok := range toks {
if i >= statusAt || !taskStatusQuestionToken(tok) {
continue
}
// "отметь задачу как сделанную" uses как as a state marker, not
// an interrogative. The marker verb and board noun must both precede
// it, and a stative resolve word must follow it; this deliberately
// does not excuse "отметь как отменить задачу".
if tok == "как" && status == store.TaskDone && taskStatusDoneMarker(toks, i, statusAt) {
continue
}
return true
}
return false
}
func taskStatusQuestionToken(tok string) bool {
return taskStatusIn(tok, interrogatives) || taskStatusIn(tok, narrativeRequests)
}
func taskStatusDoneMarker(toks []string, at, statusAt int) bool {
markerAt, joinAt, ok := taskStatusMarkerFrame(toks, statusAt)
return ok && joinAt == at && markerAt < joinAt
}
// taskStatusNamesBoard reports whether the sentence names the task list. The
@@ -93,11 +157,36 @@ func ParseTaskStatus(text string) (TaskStatus, bool) {
// case and he says "из задач", "задачу", "задача" for one list.
func taskStatusNamesBoard(toks []string) bool {
for _, t := range toks {
if morph.SameWord(t, "задача") {
if taskStatusIsBoardNoun(t) {
return true
}
for _, n := range taskStatusNouns {
if t == n {
}
return false
}
// taskStatusTransitionIndex separates authority from state. A command form is
// exact and must occupy the command head; a result word may use morphology only
// after an explicit marker command has already supplied authority.
func taskStatusTransitionIndex(toks, commands, states []string) (int, bool) {
for i, tok := range toks {
if taskStatusIn(tok, commands) && taskStatusCommandHead(toks, i) {
return i, true
}
}
for i, tok := range toks {
for _, state := range states {
if (tok == state || morph.SameWord(tok, state)) && taskStatusStateFrame(toks, i) {
return i, true
}
}
}
return 0, false
}
func taskStatusHasState(toks, states []string) bool {
for _, tok := range toks {
for _, state := range states {
if tok == state || morph.SameWord(tok, state) {
return true
}
}
@@ -105,40 +194,170 @@ func taskStatusNamesBoard(toks []string) bool {
return false
}
// taskStatusHasWord matches a status word the way its set's note requires: an
// imperative exactly, a stative by lemma. It cannot tell the two columns apart
// from the data, so it tries the exact form first and then the lemma — which
// costs the imperative trap back, except that both columns of one set mean the
// SAME transition. "закрой" and "закрыл" are one lemma and, here, one status.
func taskStatusHasWord(toks, words []string) bool {
for _, t := range toks {
for _, w := range words {
if t == w || morph.SameWord(t, w) {
return true
// taskStatusCommandHead proves that the transition is addressed rather than a
// plan/report containing an infinitive. Filler and Maven's address may lead a
// command. Russian also permits the board noun first ("задачу X закрой") and
// the bounded negative-polarity politeness frame "не мог бы ты закрыть".
func taskStatusCommandHead(toks []string, at int) bool {
if at < 0 || at >= len(toks) {
return false
}
start := 0
for start < at && commandLead(toks[start]) {
start++
}
if start == at {
return true
}
if taskStatusPoliteModalPrefix(toks[start:at]) {
return true
}
return start < at && taskStatusIsBoardNoun(toks[start])
}
func taskStatusPoliteModalPrefix(prefix []string) bool {
return len(prefix) == 4 && prefix[0] == "не" && morph.SameWord(prefix[1], "мочь") &&
prefix[2] == "бы" && (prefix[3] == "ты" || prefix[3] == "вы")
}
func taskStatusPoliteModalAt(toks []string, at int) bool {
start := 0
for start < at && commandLead(toks[start]) {
start++
}
return at >= start && taskStatusPoliteModalPrefix(toks[start:at])
}
// taskStatusStateFrame proves the result word is subordinate to an exact
// marker command. A bare "задача готова" or "я сделал задачу" is a report and
// carries no mutation authority, even though it names both board and state.
func taskStatusStateFrame(toks []string, statusAt int) bool {
_, _, ok := taskStatusMarkerFrame(toks, statusAt)
return ok
}
func taskStatusMarkerFrame(toks []string, statusAt int) (markerAt, joinAt int, ok bool) {
for join := statusAt - 1; join >= 0; join-- {
if toks[join] != "как" && toks[join] != "as" {
continue
}
for marker := 0; marker < join; marker++ {
if !taskStatusIn(toks[marker], praxisMarkerVerbs) || !taskStatusCommandHead(toks, marker) {
continue
}
if taskStatusNamesBoard(toks[marker+1:join]) || taskStatusNamesBoard(toks[:marker]) {
return marker, join, true
}
}
}
return false
return 0, 0, false
}
// taskStatusReferent is what is left after the status words, the board noun and
// the fillers: the task he named, or "" when he named none.
// taskStatusReferent reads the task out of the command frame, rather than
// subtracting every word that can occur in that frame. Subtraction mangles a
// real title such as "сходить в банк", and in the marker shape
//
// Word order is kept, because the leftover is matched against stored task text
// and he says the task the way he first said it.
func taskStatusReferent(toks []string) string {
done, drop := lexicon.TaskDoneWords(), lexicon.TaskDropWords()
var out []string
for _, t := range toks {
switch {
case taskStatusHasWord([]string{t}, done), taskStatusHasWord([]string{t}, drop):
case morph.SameWord(t, "задача"), taskStatusIn(t, taskStatusNouns):
case taskStatusIn(t, taskStatusFillers), lexicon.IsFillerParticle(t):
default:
out = append(out, t)
// отметь задачу про бэкапы как сделанную
//
// it left the framing verb in the identity. The board noun and resolved status
// word give this grammar real boundaries. The dedicated marker frame handles
// both word orders around "отметь"; everything else keeps the words between
// the board noun and status (or after the noun when the imperative leads).
func taskStatusReferent(toks []string, statusAt int) string {
if ref, ok := taskStatusMarkerReferent(toks, statusAt); ok {
return strings.Join(taskStatusTrimReferent(ref), " ")
}
boardAt, ok := taskStatusBoardIndex(toks, statusAt)
if !ok {
return ""
}
lo, hi := boardAt+1, len(toks)
if statusAt > boardAt {
hi = statusAt
}
if lo > hi {
return ""
}
return strings.Join(taskStatusTrimReferent(toks[lo:hi]), " ")
}
// taskStatusMarkerReferent recognises the already-validated
// "mark task X as done" frame and returns X. A leading marker wins over any
// marker-shaped verb inside X ("отметь задачу отметить выходные ...").
// With postposed Russian word order, the last marker before "как" closes X.
func taskStatusMarkerReferent(toks []string, statusAt int) ([]string, bool) {
for joinAt := statusAt - 1; joinAt >= 0; joinAt-- {
if toks[joinAt] != "как" && toks[joinAt] != "as" {
continue
}
if !taskStatusDoneMarker(toks, joinAt, statusAt) {
continue
}
// Canonical order: marker, board noun, referent, state joiner.
for markerAt := 0; markerAt < joinAt; markerAt++ {
if !taskStatusIn(toks[markerAt], praxisMarkerVerbs) {
continue
}
for boardAt := markerAt + 1; boardAt < joinAt; boardAt++ {
if taskStatusIsBoardNoun(toks[boardAt]) {
return toks[boardAt+1 : joinAt], true
}
}
}
// Postposed order: board noun, referent, marker, state joiner.
for markerAt := joinAt - 1; markerAt >= 0; markerAt-- {
if !taskStatusIn(toks[markerAt], praxisMarkerVerbs) {
continue
}
for boardAt := markerAt - 1; boardAt >= 0; boardAt-- {
if taskStatusIsBoardNoun(toks[boardAt]) {
return toks[boardAt+1 : markerAt], true
}
}
}
}
return strings.Join(out, " ")
return nil, false
}
func taskStatusBoardIndex(toks []string, statusAt int) (int, bool) {
// A leading imperative owns the first board noun after it. Looking there
// first avoids treating a later "задача" inside the title as the frame.
for i := statusAt + 1; i < len(toks); i++ {
if taskStatusIsBoardNoun(toks[i]) {
return i, true
}
}
for i := 0; i < statusAt && i < len(toks); i++ {
if taskStatusIsBoardNoun(toks[i]) {
return i, true
}
}
return 0, false
}
func taskStatusIsBoardNoun(tok string) bool {
if morph.SameWord(tok, "задача") {
return true
}
return taskStatusIn(tok, taskStatusNouns)
}
// taskStatusTrimReferent removes only words at the identity's edges. The topic
// and possessive words are the same closed noun/subject frame reminder
// cancellation already uses. Keeping interior words is load-bearing: Russian
// task titles routinely contain prepositions.
func taskStatusTrimReferent(toks []string) []string {
for len(toks) > 0 && taskStatusIn(toks[0], taskStatusTopicFrame) {
toks = toks[1:]
}
for len(toks) > 0 && (taskStatusIn(toks[len(toks)-1], taskStatusTrailingFrame) ||
taskStatusIn(toks[len(toks)-1], taskStatusTopicFrame) || lexicon.IsFillerParticle(toks[len(toks)-1])) {
toks = toks[:len(toks)-1]
}
return toks
}
func taskStatusIn(tok string, words []string) bool {
@@ -156,10 +375,9 @@ func taskStatusIn(tok string, words []string) bool {
// marker must not read "убери из задач купить молоко" as a new task.
func TaskStatusGrammar() Grammar {
return Grammar{
Name: "task-status",
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
Build: func(m []string) (Decision, bool) {
c, ok := ParseTaskStatus(m[1])
Name: "task-status",
Decide: func(utterance string) (Decision, bool) {
c, ok := ParseTaskStatus(utterance)
if !ok {
return Decision{}, false
}
+84 -9
View File
@@ -11,11 +11,45 @@ func TestParseTaskStatus(t *testing.T) {
}{
// The shapes that reached nothing before this rule.
{"закрой задачу купить молоко", true, "done", "купить молоко"},
{"задачу купить молоко сделал", true, "done", "купить молоко"},
{"задачу купить молоко закрой", true, "done", "купить молоко"},
{"убери из задач купить молоко", true, "dropped", "купить молоко"},
{"убери из моих задач купить молоко", true, "dropped", "купить молоко"},
{"отмени задачу оплатить интернет", true, "dropped", "оплатить интернет"},
{"task buy milk done", true, "done", "buy milk"},
{"please close task buy milk", true, "done", "buy milk"},
{"не мог бы ты закрыть задачу купить молоко?", true, "done", "купить молоко"},
// Referents come from the bounded command frame. Interior prepositions
// remain part of a real title; marker verbs and topic prepositions do not.
{"закрой задачу сходить в банк", true, "done", "сходить в банк"},
{"mark task about backups as done", true, "done", "backups"},
// Question-shaped prose containing the same change word is not an
// instruction. This parser feeds a mutating stage-0 decision, so its
// cautious direction is to decline and let the query cascade answer.
{"как отменить задачу купить хлеб?", false, "", ""},
{"как отменить задачу купить хлеб", false, "", ""},
{"отменить задачу купить хлеб? пожалуйста", false, "", ""},
{"how do I cancel task buy bread", false, "", ""},
{"что будет если удалить задачу купить хлеб", false, "", ""},
{"объясни как закрыть задачу купить хлеб", false, "", ""},
// Capture verbs normally outrank question shape. They do not get that
// exception here, because this mutating grammar runs before capture.
{"запиши как отменить задачу купить хлеб", false, "", ""},
{"отметь как отменить задачу купить хлеб", false, "", ""},
// The established state-marker command is not a question: как means
// "as" inside a closed marker frame here.
{"отметь задачу про бэкапы как сделано", true, "done", "бэкапы"},
{"отметь задачу про бэкапы как сделанную", true, "done", "бэкапы"},
// A marker-shaped verb can itself belong to the title. Frame position,
// not global word subtraction, decides which occurrence is control.
{"отметь задачу отметить выходные как сделанную", true, "done", "отметить выходные"},
{"задачу отметить выходные отметь как сделанную", true, "done", "отметить выходные"},
// Token boundaries matter in both directions. Interrogative roots inside
// a word or an indefinite compound do not turn a direct command into a
// question, and an interrogative in the already-commanded task title is
// part of its name.
{"отмени задачу купить какао", true, "dropped", "купить какао"},
{"закрой задачу чтобы купить хлеб", true, "done", "чтобы купить хлеб"},
{"отмени задачу купить что-нибудь", true, "dropped", "купить что-нибудь"},
{"отмени задачу узнать когда рейс", true, "dropped", "узнать когда рейс"},
// The referent may be missing. The turn is still his, and the daemon has
// the list to ask about.
{"закрой задачу", true, "done", ""},
@@ -27,6 +61,23 @@ func TestParseTaskStatus(t *testing.T) {
{"убери со стола", false, "", ""},
{"я всё сделал", false, "", ""},
{"закрой шторы в комнате", false, "", ""},
// Reports, plans and desires name a transition but do not address Maven.
// The old mixed-mood accessor lemma-matched every one into a write.
{"я закрыл задачу купить молоко", false, "", ""},
{"я отменил задачу купить молоко", false, "", ""},
{"I closed the task buy milk", false, "", ""},
{"I cancelled the task buy milk", false, "", ""},
{"надо закрыть задачу купить молоко", false, "", ""},
{"я хочу закрыть задачу купить молоко", false, "", ""},
{"I want to close task buy milk", false, "", ""},
// Direct negative authority and the ambiguous don't-forget board idiom
// fail closed before any transition slot is exposed.
{"не закрывай задачу купить молоко", false, "", ""},
{"не закрой задачу купить молоко", false, "", ""},
{"не закрыть задачу купить молоко", false, "", ""},
{"don't close task buy milk", false, "", ""},
{"never cancel task buy milk", false, "", ""},
{"не забудь закрыть задачу купить молоко", false, "", ""},
// The board noun with no status word is a list query, not a move.
{"какие у меня задачи", false, "", ""},
{"добавь в задачи купить молоко", false, "", ""},
@@ -51,13 +102,9 @@ func TestParseTaskStatus(t *testing.T) {
func TestTaskStatusGrammarFillsTheFnSlot(t *testing.T) {
g := TaskStatusGrammar()
m := g.Pattern.FindStringSubmatch("закрой задачу купить молоко")
if m == nil {
t.Fatal("pattern did not match")
}
dec, ok := g.Build(m)
if !ok {
t.Fatal("Build declined")
dec, matched, ok := g.Evaluate("закрой задачу купить молоко")
if !matched || !ok {
t.Fatal("structural grammar declined")
}
if dec.Intent != IntentAct || !dec.Slots.HasFn || dec.Slots.Fn != TaskStatusFn {
t.Fatalf("decision = %+v, want act with fn %q", dec, TaskStatusFn)
@@ -66,3 +113,31 @@ func TestTaskStatusGrammarFillsTheFnSlot(t *testing.T) {
t.Fatalf("slots = %+v, want value done text \"купить молоко\"", dec.Slots)
}
}
func TestTaskStatusGrammarAcceptsInflectedDoneMarker(t *testing.T) {
g := TaskStatusGrammar()
dec, matched, accepted := g.Evaluate("отметь задачу про бэкапы как сделанную")
if !matched || !accepted {
t.Fatalf("inflected state-marker command matched=%v accepted=%v", matched, accepted)
}
if dec.Intent != IntentAct || dec.Slots.Fn != TaskStatusFn || dec.Slots.Value != "done" || dec.Slots.Text != "бэкапы" {
t.Fatalf("decision = %+v, want task_status/done with the backup task referent", dec)
}
}
func TestTaskStatusGrammarDeclinesQuestionsWithoutMutationSlots(t *testing.T) {
g := TaskStatusGrammar()
for _, utterance := range []string{
"как отменить задачу купить хлеб?",
"how do I drop task buy bread",
"запиши как отменить задачу купить хлеб",
} {
dec, matched, accepted := g.Evaluate(utterance)
if matched || accepted {
t.Errorf("TaskStatusGrammar(%q) matched=%v accepted=%v; a structural refusal must reach the query cascade", utterance, matched, accepted)
}
if dec.Slots.HasFn || dec.Slots.Fn != "" {
t.Errorf("TaskStatusGrammar(%q) exposed mutation slots: %+v", utterance, dec.Slots)
}
}
}
+18
View File
@@ -48,9 +48,27 @@ func WorldQueryGrammars() []Grammar {
Pattern: arithmeticQueryPattern,
Build: queryTo(SourceWorld),
},
PublicCurrentVersionGrammar(),
}
}
// WorldQueryDecision applies the literal world-side grammars outside the full
// cascade. It is used at defensive reconstruction boundaries (a question that
// reached the fact handler) so the same structural evidence can be restored
// without trusting the model that misrouted it.
func WorldQueryDecision(utterance string) (Decision, bool) {
for _, grammar := range WorldQueryGrammars() {
decision, _, accepted := grammar.Evaluate(utterance)
if !accepted {
continue
}
decision.Utterance = utterance
decision.SourceAnchored = decision.Source != SourceUnknown
return decision, true
}
return Decision{}, false
}
// definitionQueryPattern — anchored at the start, because "напомни узнать что
// такое TCP" is a reminder that happens to contain the frame.
//
+2 -5
View File
@@ -19,6 +19,7 @@ func TestAWorldQuestionNamesTheWorld(t *testing.T) {
{"сколько будет 17 на 23?", "arithmetic-query"},
{"посчитай 2+2", "arithmetic-query"},
{"сколько будет 5 умножить на 6", "arithmetic-query"},
{"какая последняя версия Go?", "public-current-version"},
}
for _, c := range cases {
dec, rule, ok := matchWorldQuery(c.utterance)
@@ -76,11 +77,7 @@ func TestNamingTheWorldFillsNoSlot(t *testing.T) {
func matchWorldQuery(utterance string) (Decision, string, bool) {
for _, g := range WorldQueryGrammars() {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil {
continue
}
if dec, ok := g.Build(m); ok {
if dec, _, ok := g.Evaluate(utterance); ok {
return dec, g.Name, true
}
}