diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index b20cb21..ea024a8 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -24,6 +24,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st } } + // The board is Maven's own store, so a spoken status change is answered here + // and never offered to an ecosystem client (Vikunja #512). First, because + // task_status is on no allowlist and no capability registry: reaching either + // of them would answer a turn about his own task list with a gap. + if dec.Slots.Fn == router.TaskStatusFn { + return h.resolveTaskStatus(ctx, dec) + } + // Praxis ecosystem tools: intercept before the system command executor. if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn { if reply := h.handlePraxisAct(ctx, dec); reply != "" { diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 69462ce..b73ceb2 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "strings" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" @@ -81,7 +82,95 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) } h.offerCandidates(ctx, cands) - return tasks.FormatRU(ranked), true + reply := tasks.FormatRU(ranked) + // The counted shapes, after the list and only when there are any (V-512). + // They answer "what is going wrong with this list" without assessing any of + // it, and they are said here rather than announced: no tick rule reads them. + if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" { + if !strings.HasSuffix(reply, ".") { + reply += "." + } + reply += " " + stalls + } + return reply, true +} + +// resolveTaskStatus moves a task he named out loud (Vikunja #512). +// +// The position path already worked: resolveCandidate answers "первую сделал" +// against the list she just read. This is the other half — naming the task +// instead of its position, which reached no code at all before the stage-0 rule +// in internal/router/taskstatus.go filled the fn slot. +// +// Three answers besides the move, and none of them guesses. No match says so. A +// match on more than one asks which, because closing the wrong task is work he +// never finished being marked done. No task named asks which too, since the +// router claims the turn without the referent and the list lives here. +func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string { + live, err := h.api.ListTasks(ctx, "live") + if err != nil { + log.Printf("voice: task status: list: %v", err) + return "не получилось посмотреть задачи." + } + if dec.Slots.Text == "" { + return "какую задачу?" + } + match := matchTaskText(live, dec.Slots.Text) + switch len(match) { + case 0: + return "не нашла такой задачи." + case 1: + default: + return "у тебя несколько подходящих — какую именно?" + } + pick := match[0] + status := dec.Slots.Value + // A candidate is work Maven proposed and he never confirmed, and the store + // refuses candidate → done: the legal move is to open it first. Saying it is + // done IS the confirmation, so both writes happen rather than the turn + // naming a gap about a distinction he did not make. + if pick.Status == store.TaskCandidate && status == store.TaskDone { + if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil { + log.Printf("voice: task status: promote %d: %v", pick.ID, err) + return "не получилось изменить задачу." + } + } + if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil { + log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err) + return "не получилось изменить задачу." + } + log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status) + if status == store.TaskDropped { + return "убрала: " + pick.Text + } + return "закрыла: " + pick.Text +} + +// matchTaskText finds the live tasks he could have meant. +// +// Normalised containment, either direction, over store.NormalizeTaskText — the +// same key capture dedupes on, so a task he can file twice is a task he can name +// twice. Either direction because he shortens what he said ("молоко" for +// "купить молоко") as often as he pads it. +// +// Deliberately not fuzzy. A ranked best guess would always return exactly one +// answer, and the one thing this must be able to say is that it is not sure. +func matchTaskText(live []ipc.Task, named string) []ipc.Task { + want := store.NormalizeTaskText(named) + if want == "" { + return nil + } + var out []ipc.Task + for _, t := range live { + have := store.NormalizeTaskText(t.Text) + if have == "" { + continue + } + if strings.Contains(have, want) || strings.Contains(want, have) { + out = append(out, t) + } + } + return out } // taskItems maps wire rows onto the ranker's input. Written here rather than in diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 5f38bba..d371b10 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -26,6 +26,9 @@ type taskAPI struct { tasks []ipc.Task listArg string listErr error + + moved []setStatusCall + moveErr error } func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) { @@ -253,3 +256,87 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) { } } } + +// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he +// says is done can be shown to take both legal moves. +type setStatusCall struct { + id int64 + status string + by string +} + +func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error { + a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by}) + return a.moveErr +} + +func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) { + api := &taskAPI{tasks: []ipc.Task{ + {ID: 7, Text: "купить молоко", Status: "open"}, + {ID: 8, Text: "оплатить интернет", Status: "open"}, + }} + h := taskHandler(api) + reply := h.resolveTaskStatus(context.Background(), router.Decision{ + Intent: router.IntentAct, + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"}, + }) + if api.listArg != "live" { + t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg) + } + if len(api.moved) != 1 { + t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved) + } + if api.moved[0].id != 7 || api.moved[0].status != "done" { + t.Errorf("moved %+v, want id 7 → done", api.moved[0]) + } + if !strings.Contains(reply, "купить молоко") { + t.Errorf("reply = %q, want the task named back", reply) + } +} + +func TestResolveTaskStatusRefusesToGuess(t *testing.T) { + cases := []struct { + name string + tasks []ipc.Task + named string + want string + }{ + {"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"}, + {"two matches", []ipc.Task{ + {ID: 7, Text: "купить молоко", Status: "open"}, + {ID: 8, Text: "купить молоко и хлеб", Status: "open"}, + }, "купить молоко", "несколько"}, + {"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + api := &taskAPI{tasks: c.tasks} + h := taskHandler(api) + reply := h.resolveTaskStatus(context.Background(), router.Decision{ + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named}, + }) + if len(api.moved) != 0 { + t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved) + } + if !strings.Contains(reply, c.want) { + t.Errorf("reply = %q, want it to contain %q", reply, c.want) + } + }) + } +} + +func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) { + // The store refuses candidate → done. Saying it is done is the confirmation + // the candidate was waiting for, so the arm makes both legal moves. + api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}} + h := taskHandler(api) + h.resolveTaskStatus(context.Background(), router.Decision{ + Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"}, + }) + if len(api.moved) != 2 { + t.Fatalf("moved %+v, want open then done", api.moved) + } + if api.moved[0].status != "open" || api.moved[1].status != "done" { + t.Errorf("moved %+v, want open then done", api.moved) + } +} diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 75ccc36..520d300 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -400,6 +400,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // explicit capture marker beats the model, which called it an act and // rewrote the task text (Vikunja #467). After the rules above because a // marker never collides with a clock or agenda question. + // After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the + // board noun), and before the capture marker, which would otherwise read + // "убери из задач купить молоко" as a new task (Vikunja #512). + grammars = append(grammars, router.TaskStatusGrammar()) grammars = append(grammars, router.TaskCaptureGrammar()) // After the capture marker, so "запиши" still wins over "расскажи", and // last overall because it matches on the first word alone: "расскажи про diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a36b70a..510faca 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -979,11 +979,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tasksTmpl.Execute(w, struct { Msg, Err string + Stalls []tasks.Stall Candidates []taskRow Open []taskRow Resolved []taskRow ResolvedMore bool - }{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { + }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { log.Printf("tasks render: %v", err) } } diff --git a/cmd/mavweb/tasks.html b/cmd/mavweb/tasks.html index 70f6797..e6daba3 100644 --- a/cmd/mavweb/tasks.html +++ b/cmd/mavweb/tasks.html @@ -21,6 +21,18 @@ +{{if .Stalls}} +
+

shapes

+ +
+ +{{range .Stalls}}{{end}} +
countshape
{{.N}}{{.Line}}
+
+{{end}} + {{if .Candidates}}

found, not confirmed {{len .Candidates}}

diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index ab06327..1889d7c 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -64,7 +64,7 @@ func mustLoad() lexiconFile { "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", "not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour", - "filler_particles", + "filler_particles", "task_done_words", "task_drop_words", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -113,6 +113,17 @@ func PartsOfDay() []string { return words("parts_of_day") } // ReminderVerbs returns the imperatives that open a reminder. func ReminderVerbs() []string { return words("reminder_verbs") } +// TaskDoneWords returns the words that finish a task, and TaskDropWords the +// words that abandon one. Two sets rather than one with a value, because the +// store records which of the two happened and the caller has to say so. +// +// Both mix moods on purpose, and the caller must match them the way the sets' +// notes say: an imperative exactly, a stative by lemma. +func TaskDoneWords() []string { return words("task_done_words") } + +// TaskDropWords — see TaskDoneWords. +func TaskDropWords() []string { return words("task_drop_words") } + // IsFillerParticle reports whether a word can never be the subject of a // request: a particle, a politeness word, or the first-person object. See the // set's own note for why this is not a stopword list. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index b3ee915..8feaf89 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -181,6 +181,22 @@ "давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной", "please", "just", "hey", "me" ] + }, + "task_done_words": { + "note": "The ways he says a task is finished, split by mood the way the Praxis lifecycle words are (Vikunja #512). The imperatives are addressed to her and are matched exactly, because morph.SameWord makes \"закрой\" and \"закрыл\" one word and only one of them is an instruction. The statives report his own day and are matched by lemma, since \"сделано\", \"сделана\" and \"сделанную\" are one state. Closed because these are her vocabulary for one transition, not a discovery about Russian.", + "words": [ + "закрой", "закройте", "закрыть", "заверши", "завершить", "close", "finish", + "сделано", "сделал", "сделала", "выполнено", "выполнил", "выполнила", + "готово", "готова", "закрыл", "закрыла", "done", "finished" + ] + }, + "task_drop_words": { + "note": "The ways he abandons a task rather than finishing it (Vikunja #512). Same two moods as task_done_words and the same matching rule. Separate from the done words because the store records which of the two happened and /tasks shows it: dropped work he chose to stop is not work he did.", + "words": [ + "убери", "уберите", "убрать", "удали", "удалить", "отмени", "отменить", + "drop", "remove", "cancel", + "передумал", "передумала", "неактуально" + ] } } } diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index b2f0ffb..b3ea537 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -243,6 +243,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter grammars = append(grammars, router.ListGrammars()...) grammars = append(grammars, router.ReminderGrammar()) grammars = append(grammars, router.PraxisGrammars()...) + grammars = append(grammars, router.TaskStatusGrammar()) grammars = append(grammars, router.TaskCaptureGrammar()) // "расскажи про X" is a world question the model called a fact, and the // rule goes last because it matches on the first word alone (Vikunja #498). diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 5fbef0b..18de608 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -70,6 +70,8 @@ { "id": "ru-act-004", "utterance": "включи вытяжку", "lang": "ru", "intent": "act", "want_fn": true }, { "id": "ru-act-005", "utterance": "запусти бэкап сейчас", "lang": "ru", "intent": "act", "want_fn": true }, { "id": "ru-act-006", "utterance": "закрой жалюзи", "lang": "ru", "intent": "act", "want_fn": true }, + { "id": "ru-act-020", "utterance": "закрой задачу купить молоко", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "a spoken status change over the board. Routed act with no allowlisted fn until V-512, so the gate asked \"Что сделать?\"; TaskStatusGrammar fills the fn slot with task_status and actionAct answers it from Maven's own store" }, + { "id": "ru-act-021", "utterance": "убери из задач оплатить интернет", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "the drop half of the same rule. Dropped work he chose to stop is not work he did, so the two status sets are separate lexicons" }, { "id": "en-act-001", "utterance": "maven, restart the media server", "lang": "en", "intent": "act", "want_fn": true, "tags": ["wake-token"] }, { "id": "en-act-002", "utterance": "turn off the kitchen light", "lang": "en", "intent": "act", "want_fn": true }, diff --git a/internal/router/taskstatus.go b/internal/router/taskstatus.go new file mode 100644 index 0000000..08a19be --- /dev/null +++ b/internal/router/taskstatus.go @@ -0,0 +1,178 @@ +package router + +import ( + "regexp" + "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"} + +// 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", "как"} + +// 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. +func ParseTaskStatus(text string) (TaskStatus, bool) { + toks := praxisTokens(strings.ToLower(strings.TrimSpace(text))) + if len(toks) == 0 || !taskStatusNamesBoard(toks) { + return TaskStatus{}, false + } + status := "" + for _, c := range []struct { + status string + words []string + }{ + {store.TaskDone, lexicon.TaskDoneWords()}, + {store.TaskDropped, lexicon.TaskDropWords()}, + } { + if !taskStatusHasWord(toks, c.words) { + 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 + } + if status == "" { + return TaskStatus{}, false + } + return TaskStatus{Status: status, Text: taskStatusReferent(toks)}, true +} + +// 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 morph.SameWord(t, "задача") { + return true + } + for _, n := range taskStatusNouns { + if t == n { + return true + } + } + } + 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 + } + } + } + return 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. +// +// 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) + } + } + return strings.Join(out, " ") +} + +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", + Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`), + Build: func(m []string) (Decision, bool) { + c, ok := ParseTaskStatus(m[1]) + 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 + }, + } +} diff --git a/internal/router/taskstatus_test.go b/internal/router/taskstatus_test.go new file mode 100644 index 0000000..0131667 --- /dev/null +++ b/internal/router/taskstatus_test.go @@ -0,0 +1,68 @@ +package router + +import "testing" + +func TestParseTaskStatus(t *testing.T) { + cases := []struct { + utterance string + ok bool + status string + text string + }{ + // The shapes that reached nothing before this rule. + {"закрой задачу купить молоко", true, "done", "купить молоко"}, + {"задачу купить молоко сделал", true, "done", "купить молоко"}, + {"убери из задач купить молоко", true, "dropped", "купить молоко"}, + {"убери из моих задач купить молоко", true, "dropped", "купить молоко"}, + {"отмени задачу оплатить интернет", true, "dropped", "оплатить интернет"}, + {"task buy milk done", true, "done", "buy milk"}, + // The referent may be missing. The turn is still his, and the daemon has + // the list to ask about. + {"закрой задачу", true, "done", ""}, + {"убери задачу", true, "dropped", ""}, + // No board noun: ordinary speech, and every one of these means something + // else. "закрой" alone belongs to Praxis. + {"готово", false, "", ""}, + {"закрой", false, "", ""}, + {"убери со стола", false, "", ""}, + {"я всё сделал", false, "", ""}, + {"закрой шторы в комнате", false, "", ""}, + // The board noun with no status word is a list query, not a move. + {"какие у меня задачи", false, "", ""}, + {"добавь в задачи купить молоко", false, "", ""}, + // Two transitions in one sentence. Asking beats picking. + {"задачу купить молоко готово убери", false, "", ""}, + {"", false, "", ""}, + } + for _, c := range cases { + got, ok := ParseTaskStatus(c.utterance) + if ok != c.ok { + t.Errorf("ParseTaskStatus(%q) ok = %v, want %v", c.utterance, ok, c.ok) + continue + } + if !ok { + continue + } + if got.Status != c.status || got.Text != c.text { + t.Errorf("ParseTaskStatus(%q) = %+v, want status %q text %q", c.utterance, got, c.status, c.text) + } + } +} + +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") + } + if dec.Intent != IntentAct || !dec.Slots.HasFn || dec.Slots.Fn != TaskStatusFn { + t.Fatalf("decision = %+v, want act with fn %q", dec, TaskStatusFn) + } + if dec.Slots.Value != "done" || dec.Slots.Text != "купить молоко" { + t.Fatalf("slots = %+v, want value done text \"купить молоко\"", dec.Slots) + } +} diff --git a/internal/say/summary.go b/internal/say/summary.go index e4f6791..0ae0cfa 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -36,6 +36,13 @@ const ( TasksFirst = "tasks_first" TasksCandidates = "tasks_candidates" + // The counted stall shapes on /tasks and in the spoken list (V-512). Each + // one states a count and nothing about what it means: "лежит дольше десяти + // дней" is arithmetic, "стоит бросить" would be a judgement she may not make. + StallOverdue = "stall_overdue" + StallSitting = "stall_sitting" + StallUnconfirmed = "stall_unconfirmed" + ReasonOverdue = "reason_overdue" ReasonOverdueDays = "reason_overdue_days" ReasonToday = "reason_today" @@ -64,6 +71,7 @@ const ( var summaryKeys = []string{ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, TasksNone, TasksFirst, TasksCandidates, + StallOverdue, StallSitting, StallUnconfirmed, ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow, ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale, HabitWeekday, HabitWeekdaySame, HabitWeekdayNone, @@ -86,6 +94,10 @@ var summaryFloor = map[string]string{ TasksFirst: "сначала: {items}", TasksCandidates: "нашла ещё, но ты не подтверждал: {items}", + StallOverdue: "{n} {word} просрочено", + StallSitting: "{n} {word} лежит дольше {days} {dayword}", + StallUnconfirmed: "{n} {word} ждёт подтверждения", + ReasonOverdue: "просрочено", ReasonOverdueDays: "просрочено на {n} {word}", ReasonToday: "сегодня", @@ -128,6 +140,9 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, {PlanUncertain, "{line}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, + {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, + {StallSitting, "{n}"}, {StallSitting, "{days}"}, + {StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"}, {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"}, {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index daed24e..74048fc 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -44,6 +44,19 @@ "variants": ["нашла ещё, но ты не подтверждал: {items}"] }, + "stall_overdue": { + "fixed": true, + "variants": ["{n} {word} просрочено"] + }, + "stall_sitting": { + "fixed": true, + "variants": ["{n} {word} лежит дольше {days} {dayword}"] + }, + "stall_unconfirmed": { + "fixed": true, + "variants": ["{n} {word} ждёт подтверждения"] + }, + "reason_overdue": { "fixed": true, "variants": ["просрочено"] diff --git a/internal/tasks/stall.go b/internal/tasks/stall.go new file mode 100644 index 0000000..63ff9ad --- /dev/null +++ b/internal/tasks/stall.go @@ -0,0 +1,99 @@ +package tasks + +import ( + "fmt" + "strings" + "time" + + "github.com/kami/maven/internal/say" +) + +// Counted stall shapes (Vikunja #512, step 5 of docs/plans/15-board-surface.md). +// +// She may count. She may not assess. Every shape here is arithmetic over rows he +// can see — how many, how long, how many undated — and none of it says whether a +// task matters, whether a blocker is real, or whether something should be +// dropped. That is the line internal/memory/behavior.go already drew for habits +// and the reason is the same: a 1.7B asked to judge will agree fluently and +// launder a guess into a decision. +// +// Not a nag either. Nothing here is read by the tick loop; the counts go on +// /tasks and into the answer when he asks for the list. tickLoop.dayPlan +// deliberately does not read tasks — keep it that way. + +// StallDays — how long a live task has to have sat before it is counted as +// sitting. Ten days rather than a week, because a task captured on a Friday and +// still open the next Friday is an ordinary week, not a stall. +// +// Measured from Created, which is the only clock a live row carries: the store +// stamps resolved_ts and nothing else, so "no state change in eleven days" is +// exactly "captured eleven days ago and still live". That is a narrower claim +// than the plan's wording and it is the one the data supports. +const StallDays = 10 + +// Stall — one counted shape: how many tasks, and the sentence that says what +// they have in common. N is always ≥ 1; a shape with no tasks in it is not +// returned, because "нет просроченных" is a reassurance nobody asked for. +type Stall struct { + N int + Line string +} + +// Stalls counts the shapes present in a set of live tasks, in a fixed order: +// overdue first, then sitting, then unconfirmed. Fixed because the order is what +// he reads first, and sorting by count would move the sections around every time +// one number changed. +// +// Resolved tasks are not passed in and would not be counted if they were: this +// is a statement about outstanding work. +func Stalls(items []Item, now time.Time) []Stall { + var overdue, sitting, unconfirmed int + for _, it := range items { + if it.Status == StatusCandidate { + unconfirmed++ + // A candidate is Maven's reading of something she read. Counting it as + // overdue would put her own guess about a deadline in a number he is + // meant to act on. + continue + } + if it.Due != nil && it.Due.Before(now) { + overdue++ + } + if now.Sub(it.Created) >= StallDays*24*time.Hour { + sitting++ + } + } + var out []Stall + add := func(n int, key string, args map[string]string) { + if n == 0 { + return + } + out = append(out, Stall{N: n, Line: say.S(key, args)}) + } + add(overdue, say.StallOverdue, map[string]string{ + "n": fmt.Sprint(overdue), "word": say.CountWord(overdue, "задача", "задачи", "задач"), + }) + add(sitting, say.StallSitting, map[string]string{ + "n": fmt.Sprint(sitting), "word": say.CountWord(sitting, "задача", "задачи", "задач"), + "days": fmt.Sprint(StallDays), "dayword": say.Days(StallDays), + }) + add(unconfirmed, say.StallUnconfirmed, map[string]string{ + "n": fmt.Sprint(unconfirmed), "word": say.CountWord(unconfirmed, "задача", "задачи", "задач"), + }) + return out +} + +// StallsRU joins the shapes into one sentence for the spoken list, or "" when +// there are none. Empty on purpose: the list read is the answer to his question, +// and appending "ничего не залежалось" to it every time is a nag with a friendly +// face. +func StallsRU(stalls []Stall) string { + if len(stalls) == 0 { + return "" + } + parts := make([]string, 0, len(stalls)) + for _, s := range stalls { + parts = append(parts, s.Line) + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/tasks/stall_test.go b/internal/tasks/stall_test.go new file mode 100644 index 0000000..7a3ddec --- /dev/null +++ b/internal/tasks/stall_test.go @@ -0,0 +1,68 @@ +package tasks + +import ( + "strings" + "testing" + "time" +) + +func stallNow() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) } + +func TestStallsCountsTheThreeShapes(t *testing.T) { + now := stallNow() + day := 24 * time.Hour + yesterday := now.Add(-day) + items := []Item{ + // Overdue and sitting at once: it counts in both, because they are two + // different things wrong with one task. + {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-20 * day), Due: &yesterday}, + {ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-12 * day)}, + {ID: 3, Text: "позвонить маме", Status: StatusOpen, Created: now.Add(-time.Hour)}, + {ID: 4, Text: "продлить домен", Status: StatusCandidate, Created: now.Add(-30 * day), Due: &yesterday}, + } + got := Stalls(items, now) + if len(got) != 3 { + t.Fatalf("shapes = %+v, want overdue, sitting, unconfirmed", got) + } + if got[0].N != 1 { + t.Errorf("overdue = %d, want 1 — a candidate's due date is Maven's reading of a mail", got[0].N) + } + if got[1].N != 2 { + t.Errorf("sitting = %d, want 2", got[1].N) + } + if got[2].N != 1 { + t.Errorf("unconfirmed = %d, want 1", got[2].N) + } + line := StallsRU(got) + for _, want := range []string{"просрочено", "лежит", "подтверждения"} { + if !strings.Contains(line, want) { + t.Errorf("StallsRU = %q, want it to mention %q", line, want) + } + } +} + +func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) { + now := stallNow() + items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-time.Hour)}} + if got := Stalls(items, now); len(got) != 0 { + t.Fatalf("shapes = %+v, want none", got) + } + // "ничего не залежалось" appended to every list read is a nag with a + // friendly face, so the empty case renders as nothing at all. + if got := StallsRU(nil); got != "" { + t.Errorf("StallsRU(nil) = %q, want empty", got) + } +} + +func TestStallsCountsNoJudgement(t *testing.T) { + // The line this shape may not cross. Every sentence states a count; none of + // them says whether the work matters or should be dropped. + now := stallNow() + items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-40 * 24 * time.Hour)}} + line := StallsRU(Stalls(items, now)) + for _, banned := range []string{"стоит", "лучше", "надо", "брось", "важно"} { + if strings.Contains(line, banned) { + t.Errorf("StallsRU = %q — %q is an assessment, and counting is the whole licence here", line, banned) + } + } +}