Rank captured tasks by what he actually said (#129)
Ordering is computed, not generated. Asking a 1.7B which of his tasks
matters most produces a fluent opinion with no basis in anything, and a
confidently wrong priority is worse than none — same posture as the
behaviour profile in internal/memory, which counts instead of summarising.
internal/tasks is a pure package (no ipc, no store, no cgo) holding the
score, the order and the Russian rendering, so the spoken list and the
/tasks page cannot drift. Four signals, all of them things he stated:
deadline (overdue > today > tomorrow > this week), stated urgency, age
with a cap so nothing rots at the bottom, and confirmed work always
ahead of mail-derived candidates. A task with no due date and no weight
scores nothing and carries no reason string — inventing a "потому что"
about a priority he never set is the failure mode this avoids.
Capture now picks up urgency he says out loud ("добавь в задачи срочно
оплатить интернет"), stripping the marker from the task text, and the web
add form offers the same three rungs. Ranking is a read: it sorts and
renders, never writes, schedules or announces.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user