fe489dff6d
attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.
Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".
The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.
Routing fixture 58/82, unchanged from both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
9.6 KiB
Go
293 lines
9.6 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, say.CountWord(rest, "задача", "задачи", "задач"))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Spoken — the tasks FormatRU actually named, in the order it named them
|
|
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
|
|
// an ordinal resolves against is built here and not by a caller guessing how
|
|
// the renderer split and truncated it.
|
|
func Spoken(ranked []Ranked) []Ranked {
|
|
var open, cands []Ranked
|
|
for _, r := range ranked {
|
|
if r.Status == StatusCandidate {
|
|
cands = append(cands, r)
|
|
} else {
|
|
open = append(open, r)
|
|
}
|
|
}
|
|
out := make([]Ranked, 0, 2*SpokenLimit)
|
|
for _, group := range [][]Ranked{open, cands} {
|
|
if len(group) > SpokenLimit {
|
|
group = group[:SpokenLimit]
|
|
}
|
|
out = append(out, group...)
|
|
}
|
|
return out
|
|
}
|