Rank captured tasks by what he actually said (#129) #61

Closed
claude wants to merge 1 commits from overnight/task-priority into overnight/task-capture
Contributor

What changed

New pure package internal/tasks (no ipc, no store, no cgo) that scores and
orders captured tasks and renders the Russian recital:

  • Rank(items, now) — confirmed work first, then mail-derived candidates; within
    each group by score desc, oldest-first on a tie (FIFO).
  • Signals, all of them things he stated: deadline (overdue > today > tomorrow >
    this week > later), stated urgency (Weight, 3 rungs, MaxWeight), and age
    with a cap so nothing rots at the bottom forever.
  • Ranked.Reason names the one signal that decided the row (просрочено на 12 дн.,
    сегодня, важно, давно в списке) and is empty when nothing distinguished
    the task.
  • FormatRU — one renderer for voice and web: сначала: … . for his work with
    reasons attached, ещё я нашла, но ты не подтвердил: … for candidates listed
    bare, задач нет. when empty, capped at SpokenLimit=5 with an "и ещё N" tail.

Wiring:

  • internal/router/task.goParseTaskCapture now returns TaskCapture{Text, Weight}
    and strips a leading/trailing срочно/важно/urgent/important out of the task
    text. Edges only: "позвонить в срочную помощь" keeps its wording and weight 0.
  • cmd/mavend/actions_task.goqueryTasks recites tasks.FormatRU(tasks.Rank(...));
    capture passes the stated weight through.
  • cmd/mavweb — the live rows on /tasks are ordered by the same ranker, with a
    "why" column from Ranked.Reason, and the add form gained an importance select
    (normal / важно / срочно). Out-of-range weights clamp instead of 400ing.

No new intent, no new IPC method, no config: this is ordering over the store that
landed in #130.

Why

The ordering is computed rather than asked of the model on purpose. A 1.7B would
answer "which of these matters most?" fluently and baselessly, and a confidently
wrong priority is worse than no priority — the same reasoning as the behaviour
profile in internal/memory. Where he said nothing, the ranker says nothing: no
reason string, score 0, position by age.

Candidates never outrank his own work whatever their due date, and a candidate is
listed without a reason, because its deadline is Maven's reading of a mail and not
something he stated.

Ranking is a READ. It sorts and renders; it never writes, schedules or announces.
Maven is not a nag — a task reaching the top of this list is not a reason to speak,
only the order she recites when asked.

Persona: recital is feminine and informal ("я нашла", "ты не подтвердил"), no pet
names; a test asserts the absence of "рад ", "понял ", "милый", "дорогой", "вам",
"ваши".

How verified

  • make build exit 0, make test exit 0 (go vet + -race across ./internal/...
    ./cmd/...).
  • internal/tasks/rank_test.go — deadline ordering, candidates never outrank open
    work, weight lifts undated work but a deadline beats a weight, FIFO ties, no
    invented reason, age cap, "due at 23:00 today is not overdue", overdue day
    counting, FormatRU reasons/candidate separation/persona/spoken cap/empty.
  • internal/router/task_test.go — urgency stripped leading and trailing, mid-text
    stem left alone, bare marker files nothing.
  • cmd/mavend/actions_task_test.go — stated urgency reaches CaptureTaskReq.Weight,
    recital is in priority order with the reason named.
  • cmd/mavweb/tasks_test.go — open rows rendered in rank order with the reason,
    candidate reason hidden, weight passed through and clamped.

Vikunja #129

## What changed New pure package `internal/tasks` (no `ipc`, no `store`, no cgo) that scores and orders captured tasks and renders the Russian recital: - `Rank(items, now)` — confirmed work first, then mail-derived candidates; within each group by score desc, oldest-first on a tie (FIFO). - Signals, all of them things he stated: deadline (overdue > today > tomorrow > this week > later), stated urgency (`Weight`, 3 rungs, `MaxWeight`), and age with a cap so nothing rots at the bottom forever. - `Ranked.Reason` names the one signal that decided the row (`просрочено на 12 дн.`, `сегодня`, `важно`, `давно в списке`) and is **empty** when nothing distinguished the task. - `FormatRU` — one renderer for voice and web: `сначала: … .` for his work with reasons attached, `ещё я нашла, но ты не подтвердил: …` for candidates listed bare, `задач нет.` when empty, capped at `SpokenLimit=5` with an "и ещё N" tail. Wiring: - `internal/router/task.go` — `ParseTaskCapture` now returns `TaskCapture{Text, Weight}` and strips a leading/trailing `срочно`/`важно`/`urgent`/`important` out of the task text. Edges only: "позвонить в срочную помощь" keeps its wording and weight 0. - `cmd/mavend/actions_task.go` — `queryTasks` recites `tasks.FormatRU(tasks.Rank(...))`; capture passes the stated weight through. - `cmd/mavweb` — the live rows on `/tasks` are ordered by the same ranker, with a "why" column from `Ranked.Reason`, and the add form gained an importance select (normal / важно / срочно). Out-of-range weights clamp instead of 400ing. No new intent, no new IPC method, no config: this is ordering over the store that landed in #130. ## Why The ordering is computed rather than asked of the model on purpose. A 1.7B would answer "which of these matters most?" fluently and baselessly, and a confidently wrong priority is worse than no priority — the same reasoning as the behaviour profile in `internal/memory`. Where he said nothing, the ranker says nothing: no reason string, score 0, position by age. Candidates never outrank his own work whatever their due date, and a candidate is listed without a reason, because its deadline is Maven's reading of a mail and not something he stated. Ranking is a READ. It sorts and renders; it never writes, schedules or announces. Maven is not a nag — a task reaching the top of this list is not a reason to speak, only the order she recites when asked. Persona: recital is feminine and informal ("я нашла", "ты не подтвердил"), no pet names; a test asserts the absence of "рад ", "понял ", "милый", "дорогой", "вам", "ваши". ## How verified - `make build` exit 0, `make test` exit 0 (`go vet` + `-race` across `./internal/...` `./cmd/...`). - `internal/tasks/rank_test.go` — deadline ordering, candidates never outrank open work, weight lifts undated work but a deadline beats a weight, FIFO ties, no invented reason, age cap, "due at 23:00 today is not overdue", overdue day counting, `FormatRU` reasons/candidate separation/persona/spoken cap/empty. - `internal/router/task_test.go` — urgency stripped leading and trailing, mid-text stem left alone, bare marker files nothing. - `cmd/mavend/actions_task_test.go` — stated urgency reaches `CaptureTaskReq.Weight`, recital is in priority order with the reason named. - `cmd/mavweb/tasks_test.go` — open rows rendered in rank order with the reason, candidate reason hidden, weight passed through and clamped. Vikunja #129
claude added 1 commit 2026-08-01 00:42:26 +02:00
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.
kami reviewed 2026-08-01 10:34:01 +02:00
@@ -41,0 +50,4 @@
// 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 {
Owner

again - those can be moved in a config file. kinda.

again - those can be moved in a config file. kinda.
claude reviewed 2026-08-01 11:37:15 +02:00
claude left a comment
Author
Contributor

One renderer for the voice reply and the page is the right structure. The reason string is what makes the ranking auditable instead of a black box. Refusing to ask the model which task matters, on the same grounds as the counted behaviour profile, is consistent and correct. Blanking a candidate's reason so her reading of a mail is never shown as his priority is a good detail. dayDelta in whole days rather than hours is right.

7f42cc7 moved the urgency markers into task_phrases.json with the same words and weights. The scoring, the reason strings and the ranker are untouched, so nothing below is fixed on the tip.

1. Every due date is off by one day, and the page contradicts itself about it.

store.scanTask builds Due through millisToTime, which ends in time.UnixMilli(v.Int64).UTC(). So a due date always comes back in UTC. dayDelta then takes loc := due.Location(), which is that UTC, and compares calendar days there.

Walk the web form on a UTC+04 box. He adds a task due 2026-08-02. applyTaskPost parses it with time.ParseInLocation(..., time.Local), giving 2026-08-02T00:00+04, stored as 2026-08-01T20:00Z. He loads /tasks on 1 August at 10:00 local, which is 06:00Z. dayDelta computes d = 1 August and n = 1 August, returns 0, and the row says "сегодня" for a task due tomorrow.

The next morning is worse. On 2 August at 10:00 local, n = 2 August and d = 1 August, so days = -1. The row reads "просрочено на день" on the due date itself, and scores 105 instead of 60.

The same row's due column says "02 Aug", because fmtTaskDate calls .Local() and dayDelta does not. One line of the table tells him two different things about one date.

Taking loc from due is what breaks it. A date-only due value has no meaningful location once it has been through the store. Compare in now's location instead, or store due as a plain date string and stop pretending it is an instant.

2. She downgrades "срочно" to "важно" when she reads the list back.

score sets reason = "важно" for any positive weight. urgencyMarkers maps "срочно" to 3 and "важно" to 2, and both land on the same string.

He says "добавь в задачи срочно оплатить интернет". stripUrgency takes the word out of the text and sets weight 3, correctly. Ask for the list and she says "сначала: оплатить интернет (важно)". The word he chose was stronger, the store holds the stronger value, and the read-back is the weaker one.

The reason string is the one place the ranking explains itself. This is the one case where it reports a word he did not say. Two strings for the two rungs, keyed off the weight.

3. The page promises nothing is guessed, and then ranks by age.

The new hint promises "the deadlines and the urgency you gave, nothing guessed". score adds scorePerWeekOld per week since capture, up to scoreAgeCap. It stamps "давно в списке" on anything two weeks old with no other signal. Age is neither a deadline nor urgency he gave. It is the ranker's own opinion that old work should rise.

I am not arguing against the signal. The package doc is honest about it. The hint on the page is not, and the hint is the part he reads.

There is a second-order effect worth naming. The package doc states the class ordering it guarantees: "overdue beats today beats this week beats undated". A task due in three weeks scores scoreDueLater, 5. An undated task captured ten weeks ago scores the age cap, 10. The undated one wins, which is the stated ordering inverted. Either drop scoreDueLater below the age floor, or stop claiming the class ordering.

4. stripUrgency misses the urgency word as soon as anything sits next to it.

The prefix arm requires the marker to start the text. The suffix arm requires exactly one space before it at the end.

"добавь в задачи оплатить интернет, срочно" ends in ", срочно". The suffix check tests for " срочно" and fails. Weight stays 0, and the word stays in the task text. The list then reads "оплатить интернет, срочно" with no reason and no score. That is the exact task he was trying to flag. A comma there is not exotic: whisper punctuates dictated Russian.

"очень срочно оплатить интернет" fails the prefix arm for the same reason. Both cases store the marker as part of the task text, so it also pollutes the dedupe key.

Trimming the edge tokens rather than matching a fixed string covers both. It also keeps the mid-sentence protection the comment argues for.

Smaller notes:

  • handleTasks ranks with time.Now() while queryTasks ranks with h.now(). The daemon path is testable at a fixed clock and the page path is not. internal/tasks is pure, so the page has no reason to reach for the wall clock directly.
  • applyTaskPost uses fmt.Sscanf(v, "%d", &wgt), which accepts "3junk" as 3. The values come from a select, so this only matters for a hand-rolled POST, but the same call is used for id.
  • joinRU appends "и ещё 5" with a bare number and no noun. Spoken, that trails off. "и ещё 5 задач" is one word longer and finishes the sentence.
  • The web form offers weights 0, 2 and 3, skipping 1, so the select and the spoken markers agree. Worth a comment on the template: the gap looks like an oversight and is not.
  • Rank puts every candidate below every open task, and FormatRU splits them into separate sentences anyway. The cross-status comparator only has an effect if some future caller renders the flat slice. Not wrong, just currently unobservable.
One renderer for the voice reply and the page is the right structure. The reason string is what makes the ranking auditable instead of a black box. Refusing to ask the model which task matters, on the same grounds as the counted behaviour profile, is consistent and correct. Blanking a candidate's reason so her reading of a mail is never shown as his priority is a good detail. `dayDelta` in whole days rather than hours is right. `7f42cc7` moved the urgency markers into `task_phrases.json` with the same words and weights. The scoring, the reason strings and the ranker are untouched, so nothing below is fixed on the tip. **1. Every due date is off by one day, and the page contradicts itself about it.** `store.scanTask` builds `Due` through `millisToTime`, which ends in `time.UnixMilli(v.Int64).UTC()`. So a due date always comes back in UTC. `dayDelta` then takes `loc := due.Location()`, which is that UTC, and compares calendar days there. Walk the web form on a UTC+04 box. He adds a task due 2026-08-02. `applyTaskPost` parses it with `time.ParseInLocation(..., time.Local)`, giving 2026-08-02T00:00+04, stored as 2026-08-01T20:00Z. He loads `/tasks` on 1 August at 10:00 local, which is 06:00Z. `dayDelta` computes `d` = 1 August and `n` = 1 August, returns 0, and the row says "сегодня" for a task due tomorrow. The next morning is worse. On 2 August at 10:00 local, `n` = 2 August and `d` = 1 August, so `days` = -1. The row reads "просрочено на день" on the due date itself, and scores 105 instead of 60. The same row's due column says "02 Aug", because `fmtTaskDate` calls `.Local()` and `dayDelta` does not. One line of the table tells him two different things about one date. Taking `loc` from `due` is what breaks it. A date-only due value has no meaningful location once it has been through the store. Compare in `now`'s location instead, or store due as a plain date string and stop pretending it is an instant. **2. She downgrades "срочно" to "важно" when she reads the list back.** `score` sets `reason = "важно"` for any positive weight. `urgencyMarkers` maps "срочно" to 3 and "важно" to 2, and both land on the same string. He says "добавь в задачи срочно оплатить интернет". `stripUrgency` takes the word out of the text and sets weight 3, correctly. Ask for the list and she says "сначала: оплатить интернет (важно)". The word he chose was stronger, the store holds the stronger value, and the read-back is the weaker one. The reason string is the one place the ranking explains itself. This is the one case where it reports a word he did not say. Two strings for the two rungs, keyed off the weight. **3. The page promises nothing is guessed, and then ranks by age.** The new hint promises "the deadlines and the urgency you gave, nothing guessed". `score` adds `scorePerWeekOld` per week since capture, up to `scoreAgeCap`. It stamps "давно в списке" on anything two weeks old with no other signal. Age is neither a deadline nor urgency he gave. It is the ranker's own opinion that old work should rise. I am not arguing against the signal. The package doc is honest about it. The hint on the page is not, and the hint is the part he reads. There is a second-order effect worth naming. The package doc states the class ordering it guarantees: "overdue beats today beats this week beats undated". A task due in three weeks scores `scoreDueLater`, 5. An undated task captured ten weeks ago scores the age cap, 10. The undated one wins, which is the stated ordering inverted. Either drop `scoreDueLater` below the age floor, or stop claiming the class ordering. **4. `stripUrgency` misses the urgency word as soon as anything sits next to it.** The prefix arm requires the marker to start the text. The suffix arm requires exactly one space before it at the end. "добавь в задачи оплатить интернет, срочно" ends in ", срочно". The suffix check tests for " срочно" and fails. Weight stays 0, and the word stays in the task text. The list then reads "оплатить интернет, срочно" with no reason and no score. That is the exact task he was trying to flag. A comma there is not exotic: whisper punctuates dictated Russian. "очень срочно оплатить интернет" fails the prefix arm for the same reason. Both cases store the marker as part of the task text, so it also pollutes the dedupe key. Trimming the edge tokens rather than matching a fixed string covers both. It also keeps the mid-sentence protection the comment argues for. Smaller notes: - `handleTasks` ranks with `time.Now()` while `queryTasks` ranks with `h.now()`. The daemon path is testable at a fixed clock and the page path is not. `internal/tasks` is pure, so the page has no reason to reach for the wall clock directly. - `applyTaskPost` uses `fmt.Sscanf(v, "%d", &wgt)`, which accepts "3junk" as 3. The values come from a `select`, so this only matters for a hand-rolled POST, but the same call is used for `id`. - `joinRU` appends "и ещё 5" with a bare number and no noun. Spoken, that trails off. "и ещё 5 задач" is one word longer and finishes the sentence. - The web form offers weights 0, 2 and 3, skipping 1, so the select and the spoken markers agree. Worth a comment on the template: the gap looks like an oversight and is not. - `Rank` puts every candidate below every open task, and `FormatRU` splits them into separate sentences anyway. The cross-status comparator only has an effect if some future caller renders the flat slice. Not wrong, just currently unobservable.
kami closed this pull request 2026-08-01 14:51:46 +02:00
Owner

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kami/Maven#61