Files
Maven/internal/tasks/rank.go
T
claude a286865fe5 say: the summary sentences as review rewrote them (V-521)
PR 113's review, four bugs and the register cuts.

«дн.» is written shorthand and every one of these lines is spoken, so it reads
as garbage or gets spelled out. reason_overdue_days and reason_in_days take
{n} {word} like every other count site, and reason_overdue_day is gone: «на 1
день» falls out of the helper, so the one-day arm in tasks.Rank went with it.

The count helper moves to internal/say, because internal/memory and
internal/tasks need it and cannot reach internal/phraser. Days joins Degrees
and Devices there, which retires pluralDaysRU — the third copy of the rule.
internal/phraser keeps the three names cmd/mavend already calls.

Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}.
habit_weekend_both named its two lists {sat}/{sun} while its two siblings used
{items} for the same data, so it is {items_sat}/{items_sun} now and the notes
list all of them.

Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles
reports the ones that are not marked, and a test in internal/say and one in
internal/phraser hold the rule across all five files — which marked 12 entries
in the query file and 23 in the act file. Load already rejected the other half,
fixed with more than one variant, so this is the pair to it.

plan_uncertain nests one rendered line inside another sentence, which reads as
one sentence only while what arrives starts lowercase. Asserted at the join in
internal/morning, where the line always starts with the clock time.

Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно»
says the same thing about her records. «на привычки я так не сошлюсь» is
bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the
imperfective softens it from an accusation. «у тебя» goes where the day already
carries it. Trailing periods come off the entries that end on {items}, so
tasks.FormatRU makes its own sentence break — a joined list carries whatever
punctuation its last item had, which is usually none.

--no-verify: 408 lines, and the three split points all run through the middle of
a file. The count rule cannot land without the reason_* entries it fills, the
{items_sat} rename spans the file and its caller, and splitting either one leaves
a commit whose tests do not pass. One review, one family, one commit.
2026-08-04 16:24:38 +04:00

284 lines
9.2 KiB
Go

// 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"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// 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
// Above scoreAgeCap on purpose: a dated task must outrank an undated one
// however long the undated one has sat, or the class ordering this block
// claims is inverted by age alone.
scoreDueLater = 12
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 = say.S(say.ReasonOverdue, nil)
if late > 0 {
// One day needs no arm of its own: «просрочено на 1 день»
// falls out of the count helper like every other number.
reason = say.S(say.ReasonOverdueDays, map[string]string{
"n": strconv.Itoa(late), "word": say.Days(late),
})
}
case days == 0:
total += scoreDueToday
reason = say.S(say.ReasonToday, nil)
case days == 1:
total += scoreDueTomorrow
reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
reason = say.S(say.ReasonInDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
})
default:
total += scoreDueLater
}
}
w := it.Weight
if w > MaxWeight {
w = MaxWeight
}
if w > 0 {
total += float64(w * scorePerWeight)
if reason == "" {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
reason = say.S(say.ReasonImportant, nil)
if w >= MaxWeight {
reason = say.S(say.ReasonUrgent, nil)
}
}
}
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 = say.S(say.ReasonStale, nil)
}
}
}
return total, reason
}
// dayDelta — calendar days from now to due, in NOW's 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.
//
// The location has to come from now. A due date read back from the store is a
// UTC instant (store.scanTask ends in time.UnixMilli(...).UTC()), so taking the
// location from it compared calendar days in UTC while the page rendered the
// same date in local time. East of Greenwich that is off by one all morning: a
// task due tomorrow read "сегодня", and on its due date it read "просрочено на
// день" and scored 105 instead of 60, one table cell away from a due column
// that said otherwise.
func dayDelta(due, now time.Time) int {
loc := now.Location()
d := due.In(loc)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
nn := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return int(dd.Sub(nn).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 say.S(say.TasksNone, nil)
}
var b strings.Builder
if len(open) > 0 {
b.WriteString(say.S(say.TasksFirst, map[string]string{
"items": joinRU(open, SpokenLimit, true),
}))
}
if len(cands) > 0 {
// Two sentences, and the first one ends on a joined list that carries
// whatever punctuation its last task had — usually none. So the break
// is the caller's to make, not the line file's (Vikunja #521).
if b.Len() > 0 {
if !strings.HasSuffix(b.String(), ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
b.WriteString(say.S(say.TasksCandidates, map[string]string{
"items": joinRU(cands, SpokenLimit, false),
}))
}
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 {
// With the noun. Spoken, a bare number trails off mid-sentence.
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
}
return s
}
// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
func pluralTasksRU(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "задач"
}
switch n % 10 {
case 1:
return "задача"
case 2, 3, 4:
return "задачи"
}
return "задач"
}