06adc4702d
Assign provenance where the exact fn is produced: - grammar_fixed: praxis/task-status grammars hardcode fn - grammar_matcher: wakeword-act grammar invokes ActMatcher - extractor_raw: Extractor.Extract matches over raw utterance - extractor_llm_text: fillSlots LLM backfill matches cleaned text - fallback_matcher: ResolveActionCandidate runs the fallback matcher ResolveActionCandidate propagates Slots.ResolvedBy into ActionCandidate.ResolvedBy. No selection behavior changes.
397 lines
15 KiB
Go
397 lines
15 KiB
Go
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, ResolvedBy: ActionResolutionGrammarFixed},
|
||
}, true
|
||
},
|
||
}
|
||
}
|