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:
+269
-51
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user