| task | from | due | captured | |||
|---|---|---|---|---|---|---|
| task | why | from | due | captured | ||
| {{.Text}} | +{{.Why}} | {{.Source}} | {{.Due}} | {{.Created}} | diff --git a/cmd/mavweb/tasks_test.go b/cmd/mavweb/tasks_test.go index cdd5da7..625143f 100644 --- a/cmd/mavweb/tasks_test.go +++ b/cmd/mavweb/tasks_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tasks" ) // fakeTaskCore serves the /tasks handler: a canned list plus a log of the @@ -164,3 +165,61 @@ func TestHandleTasksNoCore(t *testing.T) { t.Errorf("status = %d, want 503", rec.Code) } } + +// The open list is ordered by the ranker, and the reason is shown so the page +// says why a task is first instead of asking him to trust the order. +func TestHandleTasksOrdersOpenByRank(t *testing.T) { + now := time.Now() + due := now + core := &fakeTaskCore{tasks: []ipc.Task{ + {ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now}, + {ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due}, + }} + rec := httptest.NewRecorder() + handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) + body := rec.Body.String() + if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") { + t.Error("want the dated task rendered first") + } + if !strings.Contains(body, "сегодня") { + t.Error("want the ranker's reason shown in the why column") + } +} + +// A candidate is ranked into place but never carries a priority reason: its due +// date is Maven's reading of a mail, not something he stated. +func TestHandleTasksHidesCandidateReason(t *testing.T) { + now := time.Now() + due := now + core := &fakeTaskCore{tasks: []ipc.Task{ + {ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due}, + }} + rec := httptest.NewRecorder() + handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) + if strings.Contains(rec.Body.String(), "сегодня") { + t.Error("a candidate must not be shown with a priority reason") + } +} + +func TestApplyTaskPostCarriesWeight(t *testing.T) { + core := &fakeTaskCore{created: true} + form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTasks(httptest.NewRecorder(), req, core) + if len(core.captured) != 1 || core.captured[0].Weight != 3 { + t.Fatalf("captured = %+v, want weight 3", core.captured) + } +} + +// Out of range clamps rather than 400s; a non-number is a real client error. +func TestApplyTaskPostClampsWeight(t *testing.T) { + core := &fakeTaskCore{created: true} + form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}} + req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handleTasks(httptest.NewRecorder(), req, core) + if core.captured[0].Weight != tasks.MaxWeight { + t.Errorf("weight = %d, want the cap", core.captured[0].Weight) + } +} diff --git a/internal/router/task.go b/internal/router/task.go index 248627a..293dce9 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -38,11 +38,33 @@ var taskCapturePrefixes = []string{ "new task", } +// TaskCapture — a parsed capture: the task itself, plus the importance he +// stated out loud if he stated one (Vikunja #129). Weight 0 means he said +// nothing about importance, which the ranker treats as exactly that — no +// urgency is inferred from the wording. +type TaskCapture struct { + Text string + Weight int +} + +// urgencyMarkers — the words that set a weight, strongest first. Only these +// two rungs: "срочно" is a deadline he has not named, "важно" is a preference, +// and a third shade of urgent would be a distinction he never makes out loud. +var urgencyMarkers = []struct { + word string + weight int +}{ + {"срочно", 3}, + {"urgent", 3}, + {"важно", 2}, + {"important", 2}, +} + // ParseTaskCapture reports whether an utterance explicitly files a task, and // returns the task text with the marker stripped. A marker with nothing after it // is not a capture (there is no task in "добавь в задачи") — the caller falls // through to whatever it would otherwise have done with the turn. -func ParseTaskCapture(text string) (string, bool) { +func ParseTaskCapture(text string) (TaskCapture, bool) { trimmed := strings.TrimSpace(text) lower := strings.ToLower(trimmed) best := "" @@ -52,7 +74,7 @@ func ParseTaskCapture(text string) (string, bool) { } } if best == "" { - return "", false + return TaskCapture{}, false } // Cut on the rune length of the matched prefix. ToLower does not change the // byte length of Russian or English letters, so the index carries over. @@ -60,10 +82,35 @@ func ParseTaskCapture(text string) (string, bool) { rest = strings.TrimLeft(rest, ":—- ") rest = strings.TrimSpace(rest) rest = strings.TrimRight(rest, ".!") + rest, weight := stripUrgency(rest) if rest == "" { - return "", false + return TaskCapture{}, false } - return rest, true + return TaskCapture{Text: rest, Weight: weight}, true +} + +// stripUrgency pulls a leading or trailing urgency word out of the task text +// and returns the weight it implies. Only at the edges: "срочно оплатить +// интернет" and "оплатить интернет срочно" are the same instruction, while +// "позвонить в срочную помощь" is a task whose text happens to contain the +// stem, and cutting a word out of the middle of it would mangle the task. +// +// The word is removed from the text, because the list should read "оплатить +// интернет (важно)" and not "важно оплатить интернет (важно)". +func stripUrgency(text string) (string, int) { + for _, m := range urgencyMarkers { + lower := strings.ToLower(text) + switch { + case strings.HasPrefix(lower, m.word+" "): + return strings.TrimSpace(text[len(m.word):]), m.weight + case strings.HasSuffix(lower, " "+m.word): + return strings.TrimSpace(text[:len(text)-len(m.word)]), m.weight + case lower == m.word: + // Nothing but the marker — no task in it. + return "", 0 + } + } + return text, 0 } // taskListWords — the nouns that make a question be about the task list. diff --git a/internal/router/task_test.go b/internal/router/task_test.go index 2fb6984..0f5f2b9 100644 --- a/internal/router/task_test.go +++ b/internal/router/task_test.go @@ -4,28 +4,36 @@ import "testing" func TestParseTaskCapture(t *testing.T) { cases := []struct { - in string - text string - ok bool + in string + text string + weight int + ok bool }{ - {"добавь в задачи купить молоко", "купить молоко", true}, - {"Добавь в список дел: позвонить в банк", "позвонить в банк", true}, - {"запиши задачу починить кран.", "починить кран", true}, - {"новая задача — оплатить интернет", "оплатить интернет", true}, - {"add a task buy milk", "buy milk", true}, + {"добавь в задачи купить молоко", "купить молоко", 0, true}, + {"Добавь в список дел: позвонить в банк", "позвонить в банк", 0, true}, + {"запиши задачу починить кран.", "починить кран", 0, true}, + {"новая задача — оплатить интернет", "оплатить интернет", 0, true}, + {"add a task buy milk", "buy milk", 0, true}, + // Urgency he stated out loud, leading or trailing, stripped from the text. + {"добавь в задачи срочно оплатить интернет", "оплатить интернет", 3, true}, + {"добавь в задачи оплатить интернет срочно", "оплатить интернет", 3, true}, + {"новая задача важно позвонить маме", "позвонить маме", 2, true}, + // The stem inside the task text is part of the task, not a marker. + {"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true}, // A marker with nothing after it files nothing. - {"добавь в задачи", "", false}, - {"новая задача", "", false}, + {"добавь в задачи", "", 0, false}, + {"новая задача", "", 0, false}, + {"добавь в задачи срочно", "", 0, false}, // Not a capture: he is talking, not filing. - {"надо бы поспать", "", false}, - {"я не добавил молоко в список", "", false}, - {"какие у меня задачи?", "", false}, - {"", "", false}, + {"надо бы поспать", "", 0, false}, + {"я не добавил молоко в список", "", 0, false}, + {"какие у меня задачи?", "", 0, false}, + {"", "", 0, false}, } for _, c := range cases { - text, ok := ParseTaskCapture(c.in) - if ok != c.ok || text != c.text { - t.Errorf("ParseTaskCapture(%q) = (%q, %v), want (%q, %v)", c.in, text, ok, c.text, c.ok) + got, ok := ParseTaskCapture(c.in) + if ok != c.ok || got.Text != c.text || got.Weight != c.weight { + t.Errorf("ParseTaskCapture(%q) = (%+v, %v), want (%q, w=%d, %v)", c.in, got, ok, c.text, c.weight, c.ok) } } } diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go new file mode 100644 index 0000000..24c31d7 --- /dev/null +++ b/internal/tasks/rank.go @@ -0,0 +1,238 @@ +// Package tasks ranks captured work (Vikunja #129). +// +// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his +// tasks matters most would produce a fluent opinion about his life with no +// basis in anything, and a confidently wrong priority is worse than no +// priority at all — the same reasoning as the behaviour profile in +// internal/memory, which counts instead of summarising. +// +// So: four signals, all of them things he told her, and a reason string naming +// the one that decided each row. Nothing here invents urgency. A task with no +// due date and no weight scores nothing and sits where its age puts it, which +// is the honest answer to "which of these matters?" when he never said. +// +// Ranking is a READ. It sorts and renders; it never writes, schedules or +// announces. Maven is not a nag: a task rising to the top of this list is not a +// reason to speak, only the order she recites in when asked. +package tasks + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Status values, mirroring internal/store so a caller can rank ipc.Task rows +// without importing the store. +const ( + StatusCandidate = "candidate" + StatusOpen = "open" +) + +// Item — one task to rank. The subset of a task that ranking depends on; +// callers map their own row type onto it. +type Item struct { + ID int64 + Text string + Status string + Created time.Time + Due *time.Time + Weight int +} + +// Ranked — one task with its score and the reason that decided it. +type Ranked struct { + Item + Score float64 + // Reason — the dominant signal, in Russian, for the page and the spoken + // list. Empty when nothing distinguished this task: no due date, no + // weight, not old. Saying "потому что" about a task he never prioritised + // would be making something up. + Reason string +} + +// Scoring weights. Deliberately coarse round numbers: this is a knob, not +// math, and the only property that has to hold is the ordering between classes +// (overdue beats today beats this week beats undated). +const ( + scoreOverdue = 100 // he already missed it + scoreOverduePer = 5 // per further day late, capped + scoreOverdueCap = 40 + scoreDueToday = 60 + scoreDueTomorrow = 40 + scoreDueWeek = 20 + scoreDueLater = 5 + scorePerWeight = 15 // "срочно" / "важно" / the web form's select + scorePerWeekOld = 1 // so nothing rots at the bottom forever + scoreAgeCap = 10 + // MaxWeight — the highest importance hint capture accepts. Three rungs is + // as many as anyone can rank by hand honestly. + MaxWeight = 3 +) + +// Rank scores every item and returns them ordered: confirmed work first, then +// candidates, each by score descending, oldest first on a tie. +// +// Candidates never outrank open work, whatever their due date. A task Maven +// derived from something she read is a suggestion until he confirms it, and +// putting her guess above his own stated work would be reading his priorities +// back to him wrong. +func Rank(items []Item, now time.Time) []Ranked { + out := make([]Ranked, 0, len(items)) + for _, it := range items { + score, reason := score(it, now) + out = append(out, Ranked{Item: it, Score: score, Reason: reason}) + } + sort.SliceStable(out, func(i, j int) bool { + ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate + if ci != cj { + return !ci // open before candidate + } + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return out[i].Created.Before(out[j].Created) // oldest first, FIFO + }) + return out +} + +// score — the per-item scoring function. Returns the score and the dominant +// reason. Deadline beats weight when both are present: a date is a fact about +// the world, a weight is how he felt when he filed it. +func score(it Item, now time.Time) (float64, string) { + var total float64 + reason := "" + + if it.Due != nil { + days := dayDelta(*it.Due, now) + switch { + case days < 0: + late := -days + bonus := float64(late * scoreOverduePer) + if bonus > scoreOverdueCap { + bonus = scoreOverdueCap + } + total += scoreOverdue + bonus + reason = "просрочено" + if late == 1 { + reason = "просрочено на день" + } else if late > 1 { + reason = fmt.Sprintf("просрочено на %d дн.", late) + } + case days == 0: + total += scoreDueToday + reason = "сегодня" + case days == 1: + total += scoreDueTomorrow + reason = "завтра" + case days <= 7: + total += scoreDueWeek + reason = fmt.Sprintf("через %d дн.", days) + default: + total += scoreDueLater + } + } + + w := it.Weight + if w > MaxWeight { + w = MaxWeight + } + if w > 0 { + total += float64(w * scorePerWeight) + if reason == "" { + reason = "важно" + } + } + + if !it.Created.IsZero() { + weeks := int(now.Sub(it.Created).Hours() / (24 * 7)) + if weeks > 0 { + age := float64(weeks * scorePerWeekOld) + if age > scoreAgeCap { + age = scoreAgeCap + } + total += age + if reason == "" && weeks >= 2 { + reason = "давно в списке" + } + } + } + return total, reason +} + +// dayDelta — calendar days from now to due, in due's own location. Whole days, +// not hours: a task due today is due today whether it is 09:00 or 23:00, and an +// hours-based comparison would call this evening's task "overdue" all afternoon. +func dayDelta(due, now time.Time) int { + loc := due.Location() + d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc) + n := now.In(loc) + n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) + return int(d.Sub(n).Hours() / 24) +} + +// SpokenLimit — how many tasks the spoken list names before it summarises the +// rest. A recital of twenty items is noise; five is a list he can hold. +const SpokenLimit = 5 + +// FormatRU renders a ranked list the way Maven says it. Confirmed work first, +// with the reason attached where there is one; candidates named as +// unconfirmed, never recited as his work. +// +// One renderer for the voice reply and the web page, for the same reason +// DayPlan.Spoken is built core-side: two formatters drift, and then she says +// one order and shows another. +func FormatRU(ranked []Ranked) string { + var open, cands []Ranked + for _, r := range ranked { + if r.Status == StatusCandidate { + cands = append(cands, r) + } else { + open = append(open, r) + } + } + if len(open) == 0 && len(cands) == 0 { + return "задач нет." + } + + var b strings.Builder + if len(open) > 0 { + b.WriteString("сначала: ") + b.WriteString(joinRU(open, SpokenLimit, true)) + b.WriteString(".") + } + if len(cands) > 0 { + if b.Len() > 0 { + b.WriteString(" ") + } + b.WriteString("ещё я нашла, но ты не подтвердил: ") + b.WriteString(joinRU(cands, SpokenLimit, false)) + b.WriteString(".") + } + return b.String() +} + +// joinRU lists up to limit tasks, then says how many are left. withReasons +// attaches the parenthesised reason — candidates are listed bare, since their +// due dates are Maven's reading of a mail and not something he stated. +func joinRU(rs []Ranked, limit int, withReasons bool) string { + shown := rs + rest := 0 + if len(rs) > limit { + shown, rest = rs[:limit], len(rs)-limit + } + parts := make([]string, 0, len(shown)) + for _, r := range shown { + if withReasons && r.Reason != "" { + parts = append(parts, r.Text+" ("+r.Reason+")") + } else { + parts = append(parts, r.Text) + } + } + s := strings.Join(parts, "; ") + if rest > 0 { + s += fmt.Sprintf("; и ещё %d", rest) + } + return s +} diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go new file mode 100644 index 0000000..227c3e1 --- /dev/null +++ b/internal/tasks/rank_test.go @@ -0,0 +1,177 @@ +package tasks + +import ( + "strings" + "testing" + "time" +) + +func at(y int, m time.Month, d int) *time.Time { + t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + return &t +} + +func now() time.Time { return time.Date(2026, 8, 1, 14, 0, 0, 0, time.UTC) } + +func texts(rs []Ranked) []string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.Text + } + return out +} + +func TestRankOrdersByDeadline(t *testing.T) { + items := []Item{ + {ID: 1, Text: "через неделю", Status: StatusOpen, Due: at(2026, 8, 7), Created: now()}, + {ID: 2, Text: "просрочено", Status: StatusOpen, Due: at(2026, 7, 28), Created: now()}, + {ID: 3, Text: "без срока", Status: StatusOpen, Created: now()}, + {ID: 4, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}, + {ID: 5, Text: "завтра", Status: StatusOpen, Due: at(2026, 8, 2), Created: now()}, + } + got := texts(Rank(items, now())) + want := []string{"просрочено", "сегодня", "завтра", "через неделю", "без срока"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v", got, want) + } + } +} + +func TestRankCandidatesNeverOutrankOpenWork(t *testing.T) { + items := []Item{ + {ID: 1, Text: "его задача", Status: StatusOpen, Created: now()}, + // Everything about this one screams urgent — and it is still a guess. + {ID: 2, Text: "из письма", Status: StatusCandidate, Due: at(2026, 7, 1), Weight: 3, Created: now()}, + } + got := Rank(items, now()) + if got[0].Text != "его задача" { + t.Errorf("order = %v, want his own work first", texts(got)) + } +} + +func TestRankWeightLiftsUndatedWork(t *testing.T) { + items := []Item{ + {ID: 1, Text: "обычная", Status: StatusOpen, Created: now()}, + {ID: 2, Text: "важная", Status: StatusOpen, Weight: 2, Created: now()}, + } + got := Rank(items, now()) + if got[0].Text != "важная" { + t.Errorf("order = %v, want the weighted task first", texts(got)) + } + if got[0].Reason != "важно" { + t.Errorf("reason = %q, want важно", got[0].Reason) + } + // A deadline still beats a weight: a date is a fact, a weight is a feeling. + items = append(items, Item{ID: 3, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}) + got = Rank(items, now()) + if got[0].Text != "сегодня" { + t.Errorf("order = %v, want the dated task first", texts(got)) + } +} + +func TestRankOldestFirstOnATie(t *testing.T) { + old := now().AddDate(0, 0, -3) + items := []Item{ + {ID: 1, Text: "новая", Status: StatusOpen, Created: now()}, + {ID: 2, Text: "старая", Status: StatusOpen, Created: old}, + } + got := Rank(items, now()) + if got[0].Text != "старая" { + t.Errorf("order = %v, want FIFO on equal urgency", texts(got)) + } +} + +func TestRankNoInventedReason(t *testing.T) { + got := Rank([]Item{{ID: 1, Text: "что-то", Status: StatusOpen, Created: now()}}, now()) + if got[0].Reason != "" { + t.Errorf("reason = %q — nothing distinguished this task, so there is nothing to say", got[0].Reason) + } + if got[0].Score != 0 { + t.Errorf("score = %v, want 0", got[0].Score) + } +} + +func TestRankAgeIsCappedAndNamed(t *testing.T) { + items := []Item{ + {ID: 1, Text: "прошлогодняя", Status: StatusOpen, Created: now().AddDate(-1, 0, 0)}, + {ID: 2, Text: "трёхнедельная", Status: StatusOpen, Created: now().AddDate(0, 0, -21)}, + } + got := Rank(items, now()) + if got[0].Score != scoreAgeCap { + t.Errorf("oldest score = %v, want the cap %v", got[0].Score, float64(scoreAgeCap)) + } + if got[0].Reason != "давно в списке" { + t.Errorf("reason = %q", got[0].Reason) + } +} + +// A task due at 23:00 today is due today, not overdue since this morning. +func TestRankDueTodayIsNotOverdue(t *testing.T) { + due := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) + got := Rank([]Item{{ID: 1, Text: "вечером", Status: StatusOpen, Due: &due, Created: now()}}, now()) + if got[0].Reason != "сегодня" { + t.Errorf("reason = %q, want сегодня", got[0].Reason) + } +} + +func TestRankOverdueDaysAreCounted(t *testing.T) { + got := Rank([]Item{ + {ID: 1, Text: "вчера", Status: StatusOpen, Due: at(2026, 7, 31), Created: now()}, + {ID: 2, Text: "давно", Status: StatusOpen, Due: at(2026, 7, 20), Created: now()}, + }, now()) + if got[0].Text != "давно" { + t.Errorf("order = %v, want the later-overdue task first", texts(got)) + } + if got[0].Reason != "просрочено на 12 дн." { + t.Errorf("reason = %q", got[0].Reason) + } + if got[1].Reason != "просрочено на день" { + t.Errorf("reason = %q", got[1].Reason) + } +} + +func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) { + ranked := Rank([]Item{ + {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}, + {ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now()}, + {ID: 3, Text: "продлить страховку", Status: StatusCandidate, Due: at(2026, 7, 1), Created: now()}, + }, now()) + got := FormatRU(ranked) + if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") { + t.Errorf("reply = %q", got) + } + if !strings.Contains(got, "не подтвердил: продлить страховку") { + t.Errorf("candidate not named as unconfirmed: %q", got) + } + // A candidate's due date is Maven's reading of a mail, not his statement. + if strings.Contains(got, "продлить страховку (") { + t.Errorf("a candidate must be listed without a reason: %q", got) + } + // Persona: nothing masculine, no pet names, informal address only. + for _, bad := range []string{"рад ", "понял ", "милый", "дорогой", "вам", "ваши"} { + if strings.Contains(got, bad) { + t.Errorf("reply %q contains %q", got, bad) + } + } +} + +func TestFormatRUCapsTheSpokenList(t *testing.T) { + var items []Item + for i := 0; i < SpokenLimit+3; i++ { + items = append(items, Item{ID: int64(i), Text: "задача", Status: StatusOpen, Created: now()}) + } + got := FormatRU(Rank(items, now())) + if !strings.Contains(got, "и ещё 3") { + t.Errorf("reply = %q, want the tail summarised", got) + } + if strings.Count(got, "задача") != SpokenLimit { + t.Errorf("reply = %q, want exactly %d named", got, SpokenLimit) + } +} + +func TestFormatRUEmpty(t *testing.T) { + if got := FormatRU(nil); got != "задач нет." { + t.Errorf("reply = %q", got) + } +}