bf6ccf9aea
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.
239 lines
7.3 KiB
Go
239 lines
7.3 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"
|
|
"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
|
|
}
|