Merge the board status change and the stall counts (#184)

This commit is contained in:
2026-08-05 19:54:21 +04:00
16 changed files with 675 additions and 3 deletions
+8
View File
@@ -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 != "" {
+90 -1
View File
@@ -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
+87
View File
@@ -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)
}
}
+4
View File
@@ -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: "расскажи про
+2 -1
View File
@@ -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)
}
}
+12
View File
@@ -21,6 +21,18 @@
</form>
</section>
{{if .Stalls}}
<section class=card>
<h2 class=card-title>shapes</h2>
<!-- Counts, and nothing about what they mean (V-512). Whether a task should be
dropped is his call and Maven does not have an opinion to show here. -->
<div class=scroll><table>
<tr><th>count</th><th>shape</th></tr>
{{range .Stalls}}<tr><td>{{.N}}</td><td>{{.Line}}</td></tr>{{end}}
</table></div>
</section>
{{end}}
{{if .Candidates}}
<section class=card>
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
+12 -1
View File
@@ -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.
+16
View File
@@ -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",
"передумал", "передумала", "неактуально"
]
}
}
}
+1
View File
@@ -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).
+2
View File
@@ -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 },
+178
View File
@@ -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
},
}
}
+68
View File
@@ -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)
}
}
+15
View File
@@ -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}"},
+13
View File
@@ -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": ["просрочено"]
+99
View File
@@ -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, "; ") + "."
}
+68
View File
@@ -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)
}
}
}