diff --git a/internal/router/acttarget.go b/internal/router/acttarget.go new file mode 100644 index 0000000..7982944 --- /dev/null +++ b/internal/router/acttarget.go @@ -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 +} diff --git a/internal/router/acttarget_test.go b/internal/router/acttarget_test.go new file mode 100644 index 0000000..728bb51 --- /dev/null +++ b/internal/router/acttarget_test.go @@ -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) + } + }) + } +} diff --git a/internal/router/commandframe.go b/internal/router/commandframe.go new file mode 100644 index 0000000..360bbc3 --- /dev/null +++ b/internal/router/commandframe.go @@ -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 +} diff --git a/internal/router/commandframe_test.go b/internal/router/commandframe_test.go new file mode 100644 index 0000000..1945972 --- /dev/null +++ b/internal/router/commandframe_test.go @@ -0,0 +1,67 @@ +package router + +import "testing" + +func TestParseCommandProhibitionUsesCommandPositionAndScope(t *testing.T) { + for _, utterance := range []string{ + "не отменяй напоминание про молоко", + "не закрой задачу купить молоко", + "не закрыть задачу купить молоко", + "никогда не перезапускай nginx", + "только не удаляй будильник", + "ни в коем случае не перезапускай nginx", + "ни за что не закрывай задачу", + "Maven, пожалуйста, не удаляй будильник", + "don't restart nginx", + "don’t 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) + } +} diff --git a/internal/router/currentversion.go b/internal/router/currentversion.go new file mode 100644 index 0000000..cf15db0 --- /dev/null +++ b/internal/router/currentversion.go @@ -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 +} diff --git a/internal/router/currentversion_test.go b/internal/router/currentversion_test.go new file mode 100644 index 0000000..432a591 --- /dev/null +++ b/internal/router/currentversion_test.go @@ -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) + } + } +} diff --git a/internal/router/eval/reach.go b/internal/router/eval/reach.go index eee6828..efbc9e7 100644 --- a/internal/router/eval/reach.go +++ b/internal/router/eval/reach.go @@ -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, "" diff --git a/internal/router/eval/reach_test.go b/internal/router/eval/reach_test.go index 2513da1..56bacf4 100644 --- a/internal/router/eval/reach_test.go +++ b/internal/router/eval/reach_test.go @@ -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: "перезапусти"}}, diff --git a/internal/router/fragment.go b/internal/router/fragment.go new file mode 100644 index 0000000..7e213fb --- /dev/null +++ b/internal/router/fragment.go @@ -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 +} diff --git a/internal/router/fragment_test.go b/internal/router/fragment_test.go new file mode 100644 index 0000000..2620be6 --- /dev/null +++ b/internal/router/fragment_test.go @@ -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) + } + } +} diff --git a/internal/router/help.go b/internal/router/help.go new file mode 100644 index 0000000..0565e3d --- /dev/null +++ b/internal/router/help.go @@ -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" +} diff --git a/internal/router/help_test.go b/internal/router/help_test.go new file mode 100644 index 0000000..ac2e5aa --- /dev/null +++ b/internal/router/help_test.go @@ -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) + } + } +} diff --git a/internal/router/implicitquery.go b/internal/router/implicitquery.go new file mode 100644 index 0000000..827a955 --- /dev/null +++ b/internal/router/implicitquery.go @@ -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 +} diff --git a/internal/router/implicitquery_test.go b/internal/router/implicitquery_test.go new file mode 100644 index 0000000..53c49f9 --- /dev/null +++ b/internal/router/implicitquery_test.go @@ -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) + } + } +} diff --git a/internal/router/notecapture.go b/internal/router/notecapture.go new file mode 100644 index 0000000..815fbbe --- /dev/null +++ b/internal/router/notecapture.go @@ -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 + } +} diff --git a/internal/router/notecapture_test.go b/internal/router/notecapture_test.go new file mode 100644 index 0000000..97910cf --- /dev/null +++ b/internal/router/notecapture_test.go @@ -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) + } +} diff --git a/internal/router/praxis.go b/internal/router/praxis.go index 570c50b..0d411ea 100644 --- a/internal/router/praxis.go +++ b/internal/router/praxis.go @@ -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: // diff --git a/internal/router/praxis_test.go b/internal/router/praxis_test.go index cd8a67f..75274c2 100644 --- a/internal/router/praxis_test.go +++ b/internal/router/praxis_test.go @@ -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 } diff --git a/internal/router/question.go b/internal/router/question.go index b50a050..06f8415 100644 --- a/internal/router/question.go +++ b/internal/router/question.go @@ -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 diff --git a/internal/router/question_test.go b/internal/router/question_test.go index 27125ac..19fddb2 100644 --- a/internal/router/question_test.go +++ b/internal/router/question_test.go @@ -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) + } + } +} diff --git a/internal/router/reminderreport.go b/internal/router/reminderreport.go new file mode 100644 index 0000000..292b823 --- /dev/null +++ b/internal/router/reminderreport.go @@ -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 +} diff --git a/internal/router/reminderreport_test.go b/internal/router/reminderreport_test.go new file mode 100644 index 0000000..2702932 --- /dev/null +++ b/internal/router/reminderreport_test.go @@ -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) + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 3dfa119..94d9bfa 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 diff --git a/internal/router/singletoken.go b/internal/router/singletoken.go index e5714bc..edbddb7 100644 --- a/internal/router/singletoken.go +++ b/internal/router/singletoken.go @@ -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, diff --git a/internal/router/singletoken_test.go b/internal/router/singletoken_test.go index d20cd25..8fe9c4a 100644 --- a/internal/router/singletoken_test.go +++ b/internal/router/singletoken_test.go @@ -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) + } + } +} diff --git a/internal/router/stagezero.go b/internal/router/stagezero.go index 4cccf63..56e780e 100644 --- a/internal/router/stagezero.go +++ b/internal/router/stagezero.go @@ -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). diff --git a/internal/router/taskstatus.go b/internal/router/taskstatus.go index 08a19be..140f9ab 100644 --- a/internal/router/taskstatus.go +++ b/internal/router/taskstatus.go @@ -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 } diff --git a/internal/router/taskstatus_test.go b/internal/router/taskstatus_test.go index 0131667..9b97fc2 100644 --- a/internal/router/taskstatus_test.go +++ b/internal/router/taskstatus_test.go @@ -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) + } + } +} diff --git a/internal/router/worldquery.go b/internal/router/worldquery.go index c458daf..f489552 100644 --- a/internal/router/worldquery.go +++ b/internal/router/worldquery.go @@ -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. // diff --git a/internal/router/worldquery_test.go b/internal/router/worldquery_test.go index bd353d0..6450b1a 100644 --- a/internal/router/worldquery_test.go +++ b/internal/router/worldquery_test.go @@ -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 } }