Files
claude a97764c5f7 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>
2026-08-15 17:18:48 +04:00

397 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
"github.com/kami/maven/internal/store"
)
// A spoken status change over the task list (Vikunja #512, step 4 of
// docs/plans/15-board-surface.md).
//
// Capture and the list read were built; moving a task was only possible in the
// two turns after she read one out, through resolveCandidate — "первую сделал"
// against the bound list. Naming the task instead of its position reached
// nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn,
// and the gate asked "Что сделать?".
//
// Same shape TaskCaptureGrammar uses, for the same reason: no eighth intent, so
// the grammar matches broadly and a deterministic parser inside Build decides.
// The task's own warning applies — every such grammar runs its parser ahead of
// the resident model on every turn, so this is the last one that is free.
//
// TaskStatusFn is the fn slot the daemon dispatches on. Not a Hexis capability
// and not a Praxis one: the board is Maven's own store, so actionAct intercepts
// this name before either ecosystem client sees it.
const TaskStatusFn = "task_status"
// TaskStatus — a parsed status change. Status is a store task status, and Text
// is the task he named, empty when he named none ("закрой задачу"), which is a
// turn the daemon claims and answers by asking which.
type TaskStatus struct {
Status string
Text string
}
// taskStatusNouns — the noun that makes this a board turn rather than ordinary
// speech. Required, and it is the whole reason this rule is safe to run on every
// utterance: "готово" alone is him reporting his day, "убери" alone is a request
// about the room, and neither names the list.
//
// "дело" is deliberately absent. "в чём дело" and "дело в том" are ordinary
// speech, and "список дел" is already a list query.
var taskStatusNouns = []string{"task", "tasks", "todo", "todos"}
// 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.
//
// 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) || IsCommandProhibition(text) {
return TaskStatus{}, false
}
status := ""
statusAt := len(toks)
for _, c := range []struct {
status string
commands []string
states []string
}{
{store.TaskDone, lexicon.TaskDoneCommands(), lexicon.TaskDoneStates()},
{store.TaskDropped, lexicon.TaskDropCommands(), lexicon.TaskDropStates()},
} {
at, ok := taskStatusTransitionIndex(toks, c.commands, c.states)
if !ok {
continue
}
if status != "" {
// Two transitions in one sentence. They are different rows on the
// page, so this declines and the cascade answers.
return TaskStatus{}, false
}
status = c.status
statusAt = at
}
if status == "" {
return TaskStatus{}, false
}
// 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
// Russian noun is matched by lemma, because a noun means the same thing in every
// case and he says "из задач", "задачу", "задача" for one list.
func taskStatusNamesBoard(toks []string) bool {
for _, t := range toks {
if taskStatusIsBoardNoun(t) {
return true
}
}
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
}
}
}
return false
}
// 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 0, 0, false
}
// 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
//
// отметь задачу про бэкапы как сделанную
//
// 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 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 {
for _, w := range words {
if tok == w {
return true
}
}
return false
}
// TaskStatusGrammar — stage 0 for a spoken status change. Wired after the Praxis
// rules and before the capture marker: Praxis claims a bare "закрой" and this
// rule requires the board noun, so the two cannot collide, and the capture
// marker must not read "убери из задач купить молоко" as a new task.
func TaskStatusGrammar() Grammar {
return Grammar{
Name: "task-status",
Decide: func(utterance string) (Decision, bool) {
c, ok := ParseTaskStatus(utterance)
if !ok {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentAct,
Confidence: 1.0,
// Value carries the transition and Text the task he named,
// which is the pairing handlePraxisAct uses for an item and its
// reference. Empty Text is a claim, not a refusal: the daemon
// asks which task, having the list she does not.
Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text},
}, true
},
}
}