Merge branch 'fix/g05' into fix/integrated

This commit is contained in:
kami
2026-08-01 14:18:11 +04:00
27 changed files with 1330 additions and 244 deletions
+31 -16
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"log" "log"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
@@ -28,12 +29,18 @@ import (
// tracking is not connected". It never computes, estimates or rounds a total of // tracking is not connected". It never computes, estimates or rounds a total of
// its own — an invented number about his money is the worst thing this could do. // its own — an invented number about his money is the worst thing this could do.
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) { func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
window, ok := router.ParseMoneyQuery(t.dec.Utterance) q, ok := router.ParseMoneyQuery(t.dec.Utterance)
if !ok { if !ok {
return "", false return "", false
} }
if q.Window == router.MoneyUnsupported {
// Two windows are stored and no others. Answering "сколько я потратил
// вчера?" with the month-to-date total answers a different question
// with a real number, which is the shape of a lie he cannot spot.
return "я храню только сегодняшние траты и за этот месяц.", true
}
key, phrase := zenmoney.KeySpentMonth, "в этом месяце" key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
if window == router.MoneyToday { if q.Window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня" key, phrase = zenmoney.KeySpentToday, "сегодня"
} }
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source) fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
@@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: money fact: decode: %v", err) log.Printf("voice: money fact: decode: %v", err)
return "не получилось прочитать траты.", true return "не получилось прочитать траты.", true
} }
now := h.now()
if q.Window == router.MoneyToday && !val.CoversDay(now) {
// The day window rolled over and the poller had nothing to write,
// because he has not spent anything yet today. The fact is fresh by ts
// and covers yesterday, so no staleness check can catch it — only the
// window stamp inside the value can.
return "сегодня пока ничего не вижу.", true
}
reply := val.FormatRU(phrase) reply := val.FormatRU(phrase)
if q.Income {
reply = val.FormatIncomeRU(phrase)
}
if reply == "" { if reply == "" {
return "по тратам пока нечего сказать.", true return "по тратам пока нечего сказать.", true
} }
// A stale fact is reported as stale rather than spoken as today's number. // A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter { // The age is measured from when the figure was last READ, not from when it
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true // last changed: a month with no spending in it does not go stale.
asOf := val.AsOf
if asOf.IsZero() {
asOf = fact.Ts
}
if now.Sub(asOf) > zenmoney.StaleAfter {
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
} }
return reply, true return reply, true
} }
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. // isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
func isNoFactErr(err error) bool { func isNoFactErr(err error) bool {
for e := err; e != nil; { return errors.Is(err, ipc.ErrNoFact)
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
} }
+89
View File
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt) t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
} }
} }
// The day window rolls over at midnight and the poller writes nothing until the
// first spend of the new day, so the last money_today fact is fresh by ts and
// covers yesterday. No staleness check can catch that.
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
if !ok {
t.Fatal("want a fact value")
}
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
})
if !claimed {
t.Fatal("expected the source to claim it")
}
if strings.Contains(reply, "1749.5") {
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
}
}
// Ts advances only when the number moves, so a quiet month used to be reported
// as stale while being current. The read stamp inside the value is what the
// staleness check means.
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
val, _ := sum.Value(moneyNow().Add(-time.Hour))
// The fact itself last CHANGED three days ago: nothing was spent since.
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
})
if strings.Contains(reply, "данные от") {
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
}
}
// Two windows are stored and no others. Answering "вчера" with the
// month-to-date total answers a different question with a real number.
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
api := &moneyAPI{}
h := &reactiveHandler{api: api, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
})
if !ok {
t.Fatal("a money question must be claimed, not passed to recall")
}
if !strings.Contains(reply, "только") {
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
}
if api.callCnt != 0 {
t.Error("a window she does not keep must not read a fact")
}
}
// "сколько я заработал" reads the same fact and must lead with the income.
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
from, _ := zenmoney.MonthWindow(moneyNow())
sum := zenmoney.Summary{
From: from,
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
Count: 2,
}
val, _ := sum.Value(moneyNow())
api := &moneyAPI{fact: ipc.Fact{
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
Source: zenmoney.Source, Ts: moneyNow(),
}}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
})
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
t.Errorf("reply = %q, want the income he asked about first", reply)
}
}
+6
View File
@@ -42,6 +42,12 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
log.Printf("voice: capture task: %v", err) log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true return "не получилось записать задачу.", true
} }
if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug.
return "поняла, беру в работу: " + cap.Text, true
}
if !resp.Created { if !resp.Created {
return "это уже в списке.", true return "это уже в списке.", true
} }
+28 -1
View File
@@ -19,6 +19,7 @@ type taskAPI struct {
captured []ipc.CaptureTaskReq captured []ipc.CaptureTaskReq
created bool created bool
promoted bool
capErr error capErr error
tasks []ipc.Task tasks []ipc.Task
@@ -31,7 +32,7 @@ func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.Ca
if a.capErr != nil { if a.capErr != nil {
return ipc.CaptureTaskResp{}, a.capErr return ipc.CaptureTaskResp{}, a.capErr
} }
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
} }
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -225,3 +226,29 @@ func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt) t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
} }
} }
// Saying a task out loud that Maven had only proposed is a confirmation. She
// used to answer "это уже в списке" and then read it back, in the same
// conversation, as something he had not confirmed.
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
api := &taskAPI{promoted: true}
h := taskHandler(api)
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
})
if !ok {
t.Fatal("an explicit capture must claim the turn")
}
if strings.Contains(reply, "уже в списке") {
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
}
if !strings.Contains(reply, "продлить страховку") {
t.Errorf("reply = %q, want the task named back", reply)
}
// Persona: feminine, informal.
for _, bad := range []string{"рад ", "вы ", "ваш"} {
if strings.Contains(reply, bad) {
t.Errorf("reply %q contains %q", reply, bad)
}
}
}
+20 -6
View File
@@ -82,9 +82,11 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus
// reader's header filter is what keeps the resident model off newsletters. // reader's header filter is what keeps the resident model off newsletters.
// //
// Every candidate is captured with Status "candidate", Source "email:<mailbox>" // Every candidate is captured with Status "candidate", Source "email:<mailbox>"
// and the subject as Evidence. CaptureTask dedupes on normalised text among // and the subject as Evidence, under an ExternalID naming the message and the
// live rows, so a mailbox re-read after a restart produces Created=0 rather // span it was extracted from. That key is unique over every row whatever its
// than a second copy of every task. // status, so a mailbox re-read after a restart produces Created=0 — and, more
// to the point, a task he already marked done is not re-proposed the next time
// the same unread message is read again.
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) { func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
msg := email.Message{ msg := email.Message{
UID: req.UID, UID: req.UID,
@@ -124,15 +126,16 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
// something she read is a suggestion until he confirms it on /tasks. // something she read is a suggestion until he confirms it on /tasks.
Status: store.TaskCandidate, Status: store.TaskCandidate,
} }
t.ExternalID = mailExternalID(source, req.UID, c.Text)
if due, ok := email.ParseDue(c.Due); ok { if due, ok := email.ParseDue(c.Due); ok {
t.Due = &due t.Due = &due
} }
id, created, err := m.st.CaptureTask(ctx, t) res, err := m.st.CaptureTask(ctx, t)
if err != nil { if err != nil {
return resp, fmt.Errorf("mail intake: capture: %w", err) return resp, fmt.Errorf("mail intake: capture: %w", err)
} }
resp.TaskIDs = append(resp.TaskIDs, id) resp.TaskIDs = append(resp.TaskIDs, res.ID)
if created { if res.Created {
resp.Created++ resp.Created++
// Only a row that was actually created. CaptureTask dedupes on // Only a row that was actually created. CaptureTask dedupes on
// normalised text among live rows, so a mailbox re-read after a // normalised text among live rows, so a mailbox re-read after a
@@ -165,3 +168,14 @@ func truncateRunes(s string, n int) string {
} }
return string(r[:n]) + "…" return string(r[:n]) + "…"
} }
// mailExternalID names the message and the span a candidate was extracted
// from. The mailbox and UID identify the message; the normalised text
// identifies which of the candidates in it this is, so a message yielding two
// tasks gets two keys and a re-read of it gets neither twice.
//
// UIDs are stable per mailbox, and a mailbox that renumbers (UIDVALIDITY
// changing) re-proposes its tasks once, which is the safe direction.
func mailExternalID(source string, uid uint32, text string) string {
return fmt.Sprintf("%s#%d:%s", source, uid, store.NormalizeTaskText(text))
}
+33 -15
View File
@@ -28,6 +28,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) {
// //
// Both windows are read from one diff call each. Two calls an hour against an // Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching. // API whose whole job is this is not worth caching.
//
// The write is UNCONDITIONAL, unlike every other poll in this file. The
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
// for money that made ts mean "last changed" while the reader was asking it "as
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
// that was current. The value now carries its own read stamp, so it differs
// every poll anyway and there is nothing left for the dedupe to catch.
// moneyWindow — one fact key and the period it covers. // moneyWindow — one fact key and the period it covers.
type moneyWindow struct { type moneyWindow struct {
key string key string
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
} }
continue continue
} }
val, ok := sum.Value() val, ok := sum.Value(now)
if !ok { if !ok {
// Nothing read. Silence, not a zero. // Nothing read. Silence, not a zero. The last good fact stays, and
// the window stamp inside it is what stops core reciting yesterday's
// day total as today's after midnight.
continue continue
} }
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil { if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
firstErr = err firstErr = err
} }
} }
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
return nil return nil
} }
// writeMoneyFact writes a money fact every poll, with no value comparison. See
// the comment above pollZenmoney for why this one does not go through
// writeIfChangedRaw.
//
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so // isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean. // errors.Is is the right check.
func isNoFact(err error) bool { func isNoFact(err error) bool {
for e := err; e != nil; { return errors.Is(err, ipc.ErrNoFact)
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
} }
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) { func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
+37 -9
View File
@@ -18,6 +18,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"time" "time"
@@ -904,6 +905,17 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
} }
} }
// now — the wall clock, indirected so the task page can be rendered at a fixed
// instant in a test. internal/tasks is pure and the daemon path already ranks
// through a clock it is handed; the page had no reason to be the one surface
// that could only be tested at whatever time it happened to run.
var now = time.Now
// resolvedShown — how many finished tasks the page renders. The list is
// history, it only grows, and the rows below the first screen are read by
// nobody.
const resolvedShown = 50
// taskRow is one line on /tasks, with every timestamp already formatted so the // taskRow is one line on /tasks, with every timestamp already formatted so the
// template holds no date logic. // template holds no date logic.
type taskRow struct { type taskRow struct {
@@ -915,6 +927,7 @@ type taskRow struct {
Due string Due string
Created string Created string
Resolved string Resolved string
ResolvedBy string
// Why — the ranker's reason for this row's position (Vikunja #129), in // Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest // Russian, empty when nothing distinguished the task. Blank is the honest
// rendering: he never said this one mattered more. // rendering: he never said this one mattered more.
@@ -962,6 +975,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// rows keep store order (newest first) — ranking finished work is pointless. // rows keep store order (newest first) — ranking finished work is pointless.
var live []tasks.Item var live []tasks.Item
var resolved []taskRow var resolved []taskRow
resolvedTotal := 0
for _, t := range all { for _, t := range all {
switch t.Status { switch t.Status {
case "candidate", "open": case "candidate", "open":
@@ -970,10 +984,18 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
}) })
default: default:
resolvedTotal++
// Finished work is history, and the history only grows. The page
// showed every row that ever existed, which is a page that gets
// slower every month for a section nobody reads past the top of.
if len(resolved) >= resolvedShown {
continue
}
resolved = append(resolved, taskRow{ resolved = append(resolved, taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
ResolvedBy: t.ResolvedBy,
}) })
} }
} }
@@ -982,7 +1004,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
byID[t.ID] = t byID[t.ID] = t
} }
var cands, open []taskRow var cands, open []taskRow
for _, r := range tasks.Rank(live, time.Now()) { for _, r := range tasks.Rank(live, now()) {
t := byID[r.ID] t := byID[r.ID]
row := taskRow{ row := taskRow{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
@@ -1005,7 +1027,8 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
Candidates []taskRow Candidates []taskRow
Open []taskRow Open []taskRow
Resolved []taskRow Resolved []taskRow
}{msg, errMsg, cands, open, resolved}); err != nil { ResolvedMore bool
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
log.Printf("tasks render: %v", err) log.Printf("tasks render: %v", err)
} }
} }
@@ -1020,12 +1043,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if text == "" { if text == "" {
return "", errors.New("empty task text") return "", errors.New("empty task text")
} }
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()} req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
// Importance is his, stated on the form. Out-of-range values are // Importance is his, stated on the form. Out-of-range values are
// clamped rather than rejected — a bad select is not worth a 400. // clamped rather than rejected — a bad select is not worth a 400.
if v := r.FormValue("weight"); v != "" { if v := r.FormValue("weight"); v != "" {
var wgt int // strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 { // form value is not a place to accept trailing garbage.
wgt, err := strconv.Atoi(v)
if err != nil || wgt < 0 {
return "", fmt.Errorf("bad weight %q", v) return "", fmt.Errorf("bad weight %q", v)
} }
if wgt > tasks.MaxWeight { if wgt > tasks.MaxWeight {
@@ -1034,7 +1059,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
req.Weight = wgt req.Weight = wgt
} }
if d := r.FormValue("due"); d != "" { if d := r.FormValue("due"); d != "" {
due, err := time.ParseInLocation("2006-01-02", d, time.Local) due, err := time.ParseInLocation("2006-01-02", d, now().Location())
if err != nil { if err != nil {
return "", fmt.Errorf("bad due date %q", d) return "", fmt.Errorf("bad due date %q", d)
} }
@@ -1044,14 +1069,17 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.Promoted {
return "confirmed a candidate maven had found", nil
}
if !resp.Created { if !resp.Created {
return "already on the list", nil return "already on the list", nil
} }
return "added task", nil return "added task", nil
} }
var id int64 id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 { if err != nil {
return "", errors.New("invalid id") return "", errors.New("invalid id")
} }
var status, msg string var status, msg string
@@ -1065,7 +1093,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
default: default:
return "", fmt.Errorf("unknown action %q", action) return "", fmt.Errorf("unknown action %q", action)
} }
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil { if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
return "", err return "", err
} }
return msg, nil return msg, nil
+7 -2
View File
@@ -9,6 +9,9 @@
<input type=hidden name=action value=add> <input type=hidden name=action value=add>
<input type=text name=text placeholder="что нужно сделать" size=44 required> <input type=text name=text placeholder="что нужно сделать" size=44 required>
<input type=date name=due title="due date (optional)"> <input type=date name=due title="due date (optional)">
<!-- weight 1 is skipped on purpose: the two rungs here are the two words she
recognises out loud ("важно", "срочно"), so the form and the spoken markers
mean the same thing. -->
<select name=weight title="importance (optional)"> <select name=weight title="importance (optional)">
<option value=0>normal</option> <option value=0>normal</option>
<option value=2>важно</option> <option value=2>важно</option>
@@ -43,7 +46,7 @@
<section class=card> <section class=card>
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2> <h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div> <div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
{{if .Open}}<div class=scroll><table> {{if .Open}}<div class=scroll><table>
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr> <tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr> {{range .Open}}<tr>
@@ -72,12 +75,14 @@
<section class=card> <section class=card>
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2> <h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
<div class=scroll><table> <div class=scroll><table>
<tr><th>task</th><th>status</th><th>when</th></tr> <tr><th>task</th><th>status</th><th>when</th><th>by</th></tr>
{{range .Resolved}}<tr> {{range .Resolved}}<tr>
<td class=text-max>{{.Text}}</td> <td class=text-max>{{.Text}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td> <td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=muted>{{.Resolved}}</td> <td class=muted>{{.Resolved}}</td>
<td class=hint>{{.ResolvedBy}}</td>
</tr>{{end}}</table></div> </tr>{{end}}</table></div>
{{if .ResolvedMore}}<div class=hint>only the {{len .Resolved}} most recent are shown.</div>{{end}}
</section> </section>
{{end}} {{end}}
{{template "shellBottom"}} {{template "shellBottom"}}
+119 -3
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
statusID int64 statusID int64
statusVal string statusVal string
statusBy string
statusErr error statusErr error
promoted bool
} }
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
@@ -42,11 +46,11 @@ func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (i
if f.captureErr != nil { if f.captureErr != nil {
return ipc.CaptureTaskResp{}, f.captureErr return ipc.CaptureTaskResp{}, f.captureErr
} }
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
} }
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error { func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
f.statusID, f.statusVal = id, status f.statusID, f.statusVal, f.statusBy = id, status, by
return f.statusErr return f.statusErr
} }
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
t.Errorf("weight = %d, want the cap", core.captured[0].Weight) t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
} }
} }
// The page ranked with the wall clock while the daemon path ranked with a clock
// it was handed, so this was the one surface that could only be tested at
// whatever time it happened to run.
func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
old := now
now = func() time.Time { return fixed }
t.Cleanup(func() { now = old })
// Due tomorrow, local time, stored the way the store hands it back: UTC.
due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
core := &fakeTaskCore{tasks: []ipc.Task{
{ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
}}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
body := rec.Body.String()
if !strings.Contains(body, "завтра") {
t.Errorf("why column does not say завтра: %q", why(body))
}
if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
t.Error("a task due tomorrow was ranked as today's or overdue")
}
}
// why is a crude excerpt of the rendered why column, for a readable failure.
func why(body string) string {
i := strings.Index(body, "<td class=hint>")
if i < 0 {
return body
}
j := i + 200
if j > len(body) {
j = len(body)
}
return body[i:j]
}
// The resolved section rendered every row that ever existed.
func TestHandleTasksBoundsResolved(t *testing.T) {
base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
var rows []ipc.Task
for i := 0; i < resolvedShown+10; i++ {
ts := base.Add(time.Duration(i) * time.Minute)
rows = append(rows, ipc.Task{
ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
CreatedTs: ts, Resolved: &ts,
})
}
core := &fakeTaskCore{tasks: rows}
rec := httptest.NewRecorder()
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
body := rec.Body.String()
if n := strings.Count(body, "задача "); n != resolvedShown {
t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
}
if !strings.Contains(body, "most recent are shown") {
t.Error("the page must say it is showing only part of the history")
}
}
// A capture over a candidate is a confirmation, not a duplicate.
func TestHandleTasksAddSaysPromoted(t *testing.T) {
core := &fakeTaskCore{promoted: true}
form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
t.Error("a promoted capture must not read as a duplicate")
}
}
// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
core := &fakeTaskCore{created: true}
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handleTasks(rec, req, core)
if len(core.captured) != 0 {
t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
}
if !strings.Contains(rec.Body.String(), "bad weight") {
t.Error("error not surfaced on the page")
}
core = &fakeTaskCore{}
form = url.Values{"action": {"done"}, "id": {"42junk"}}
req = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.statusID != 0 {
t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
}
}
// A resolution says what resolved it: resolved_ts recorded when and never by
// what.
func TestHandleTasksRecordsTheCaller(t *testing.T) {
core := &fakeTaskCore{}
form := url.Values{"action": {"done"}, "id": {"42"}}
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
handleTasks(httptest.NewRecorder(), req, core)
if core.statusBy != "tap:web" {
t.Errorf("resolved by %q, want tap:web", core.statusBy)
}
}
+11 -2
View File
@@ -93,6 +93,14 @@ func Requirement(m ipc.Method) Authority {
return AuthWrite return AuthWrite
case ipc.MethodWriteFact: case ipc.MethodWriteFact:
return AuthWrite return AuthWrite
case ipc.MethodSetTaskStatus:
// Resolving a task is NOT additive, which is what separates it from
// capture. Capture at AuthRead can only put a line on a list he reads
// himself; SetTaskStatus at AuthRead would let any enrolled module —
// mavpoll, mavsttd — mark every open task done and clear the list out
// from under him. Same reasoning as WriteFact: a module gets to add to
// its own corner, not to erase his.
return AuthWrite
case ipc.MethodAssertStepUp: case ipc.MethodAssertStepUp:
return AuthRead return AuthRead
case ipc.MethodLatestFact, case ipc.MethodLatestFact,
@@ -109,10 +117,11 @@ func Requirement(m ipc.Method) Authority {
// module write, not an allowlist mutation and not a new standing reason // module write, not an allowlist mutation and not a new standing reason
// for Maven to speak — nothing in the tick loop reads tasks. It stays // for Maven to speak — nothing in the tick loop reads tasks. It stays
// at AuthRead, the same rung as CreateReminder, which is the closest // at AuthRead, the same rung as CreateReminder, which is the closest
// existing analogue. // existing analogue. SetTaskStatus is NOT here: see the AuthWrite case
// above, because resolving is the one task move that destroys
// something.
ipc.MethodCaptureTask, ipc.MethodCaptureTask,
ipc.MethodListTasks, ipc.MethodListTasks,
ipc.MethodSetTaskStatus,
// Mail ingestion (Vikunja #246). AuthRead because of what the method can // Mail ingestion (Vikunja #246). AuthRead because of what the method can
// produce: candidate tasks and nothing else. It cannot write a fact, set a // produce: candidate tasks and nothing else. It cannot write a fact, set a
// reminder, or touch the tool allowlist, so a compromised mail reader can // reminder, or touch the tool allowlist, so a compromised mail reader can
+22 -5
View File
@@ -106,10 +106,12 @@ type Task struct {
Text string `json:"text"` Text string `json:"text"`
Source string `json:"source"` Source string `json:"source"`
Evidence string `json:"evidence,omitempty"` Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Due *time.Time `json:"due,omitempty"` Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"` Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"` Resolved *time.Time `json:"resolved,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
} }
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes // CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
@@ -118,14 +120,22 @@ type Task struct {
// //
// An extractor that reads mail sets Source "email:<account>", Status // An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject // "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It must NOT set Status "open" — work Maven inferred from something she // line). It cannot set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page. Capture // read is a suggestion until the owner confirms it on the /tasks page, and the
// is idempotent on normalised text among live tasks, so re-reading the same // store refuses an open capture from a derived source rather than trusting the
// mailbox is free. // caller to have read this paragraph.
//
// ExternalID is what makes re-reading free for such a source, and it is
// REQUIRED of one. Text dedupe only covers live rows, because a voice capture
// of the same errand next week is a new task. A mailbox has no such signal: it
// hands back the same immutable message forever, so a task he already finished
// would come back as a fresh candidate on the next poll. ExternalID is unique
// over every row whatever its status: message id plus the extracted span.
type CaptureTaskReq struct { type CaptureTaskReq struct {
Text string `json:"text"` Text string `json:"text"`
Source string `json:"source"` Source string `json:"source"`
Evidence string `json:"evidence,omitempty"` Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"` Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"` Weight int `json:"weight,omitempty"`
@@ -138,6 +148,10 @@ type CaptureTaskReq struct {
type CaptureTaskResp struct { type CaptureTaskResp struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Created bool `json:"created"` Created bool `json:"created"`
// Promoted — this capture turned an existing candidate into open work. He
// stated out loud something Maven had only proposed, which is a
// confirmation, and the caller says so rather than "уже в списке".
Promoted bool `json:"promoted,omitempty"`
} }
// IngestMailReq — one message a mail reader has fetched, handed to core for // IngestMailReq — one message a mail reader has fetched, handed to core for
@@ -400,6 +414,9 @@ type setTaskStatusReq struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Status string `json:"status"` Status string `json:"status"`
Ts time.Time `json:"ts"` Ts time.Time `json:"ts"`
// By — the caller making the move, in the source vocabulary. Recorded on
// the row so a resolved task says what resolved it.
By string `json:"by,omitempty"`
} }
// idReq — methods keyed by a single id. // idReq — methods keyed by a single id.
@@ -631,7 +648,7 @@ type CoreAPI interface {
ListTasks(ctx context.Context, status string) ([]Task, error) ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped, // SetTaskStatus moves a task forward once: candidate→open|dropped,
// open→done|dropped. Any other move is refused. // open→done|dropped. Any other move is refused.
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
// TickTrace returns the most recent tick's rule trace. The daemon caches // TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not // this after every tick; the store adapter returns an error (trace is not
+2 -2
View File
@@ -459,8 +459,8 @@ func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
return r.Tasks, nil return r.Tasks, nil
} }
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error { func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil) return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
} }
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod // IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
+8 -5
View File
@@ -254,11 +254,12 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
} }
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) { func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
id, created, err := a.s.CaptureTask(ctx, store.Task{ res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts, CreatedTs: req.Ts,
Text: req.Text, Text: req.Text,
Source: req.Source, Source: req.Source,
Evidence: req.Evidence, Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status, Status: req.Status,
Due: req.Due, Due: req.Due,
Weight: req.Weight, Weight: req.Weight,
@@ -266,7 +267,7 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
if err != nil { if err != nil {
return CaptureTaskResp{}, mapErr(err) return CaptureTaskResp{}, mapErr(err)
} }
return CaptureTaskResp{ID: id, Created: created}, nil return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
} }
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) { func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
@@ -282,17 +283,19 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
Text: t.Text, Text: t.Text,
Source: t.Source, Source: t.Source,
Evidence: t.Evidence, Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status, Status: t.Status,
Due: t.Due, Due: t.Due,
Weight: t.Weight, Weight: t.Weight,
Resolved: t.ResolvedTs, Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
} }
} }
return out, nil return out, nil
} }
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error { func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts)) return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
} }
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
@@ -871,7 +874,7 @@ var methodTable = map[Method]handlerFunc{
return listTasksResp{Tasks: out}, nil return listTasksResp{Tasks: out}, nil
}), }),
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error { MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts) return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
}), }),
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) { MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
out, err := api.ListProposedRoutines(ctx) out, err := api.ListProposedRoutines(ctx)
+1 -1
View File
@@ -98,7 +98,7 @@ func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq)
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) { func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
return nil, ErrNotImplemented return nil, ErrNotImplemented
} }
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error { func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
return ErrNotImplemented return ErrNotImplemented
} }
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
+45 -12
View File
@@ -16,12 +16,29 @@ const (
MoneyNone MoneyWindow = iota MoneyNone MoneyWindow = iota
MoneyToday MoneyToday
MoneyMonth MoneyMonth
// MoneyUnsupported — a money question over a window nothing is stored for
// ("вчера", "на прошлой неделе"). Claimed, not answered: the poller keeps
// today and the month, and answering a question about yesterday with the
// month-to-date total is worse than saying she does not keep it.
MoneyUnsupported
) )
// MoneyQuery — a parsed money question. Income is set when he asked what he
// EARNED rather than what he spent; the two read the same fact and differ only
// in which half of it leads the answer.
type MoneyQuery struct {
Window MoneyWindow
Income bool
}
// incomeNouns — the words that make a money question be about income.
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
// moneyNouns — the words that make a question be about his money. // moneyNouns — the words that make a question be about his money.
var moneyNouns = []string{ var moneyNouns = []string{
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов", "потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
"заработал", "потрачено", енег", "spend", "spent", "expenses", "заработал", "заработала", оход", "доходы", "потрачено", "денег",
"spend", "spent", "expenses", "earned", "income",
} }
// ParseMoneyQuery reports whether an utterance asks about spending or income, // ParseMoneyQuery reports whether an utterance asks about spending or income,
@@ -30,11 +47,14 @@ var moneyNouns = []string{
// //
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день // Narrow on purpose. A money noun alone is not enough — "я потратил весь день
// на это" is him talking about his day, so an amount word or an explicit // на это" is him talking about his day, so an amount word or an explicit
// question word has to be there too. // question word has to be there too. The two evidence halves are INDEPENDENT:
func ParseMoneyQuery(text string) (MoneyWindow, bool) { // "траты" and "расходы" used to sit in both lists, so either word alone
// satisfied the whole gate and "у меня в этом месяце большие траты", a
// statement, came back with a figure.
func ParseMoneyQuery(text string) (MoneyQuery, bool) {
toks := planTokens(text) toks := planTokens(text)
if len(toks) == 0 { if len(toks) == 0 {
return MoneyNone, false return MoneyQuery{}, false
} }
hasNoun := false hasNoun := false
for _, t := range toks { for _, t := range toks {
@@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) {
} }
} }
if !hasNoun { if !hasNoun {
return MoneyNone, false return MoneyQuery{}, false
} }
// "весь день", "время", "силы" — spending that is not money. // "весь день", "время", "силы" — spending that is not money.
for _, t := range toks { for _, t := range toks {
switch t { switch t {
case "день", "дня", "время", "времени", "силы", "сил", "нервы": case "день", "дня", "время", "времени", "силы", "сил", "нервы":
return MoneyNone, false return MoneyQuery{}, false
} }
} }
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") || asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") || hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы") hasTok(toks, "мои")
if !asking { if !asking {
return MoneyNone, false return MoneyQuery{}, false
}
income := false
for _, t := range toks {
for _, n := range incomeNouns {
if t == n {
income = true
}
}
} }
lower := strings.ToLower(text) lower := strings.ToLower(text)
switch { switch {
// Windows nothing is stored for, named explicitly so they are refused
// rather than silently answered with the month.
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
strings.Contains(lower, "week"),
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"): case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
return MoneyToday, true return MoneyQuery{Window: MoneyToday, Income: income}, true
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
return MoneyMonth, true
} }
return MoneyMonth, true return MoneyQuery{Window: MoneyMonth, Income: income}, true
} }
+11 -3
View File
@@ -15,17 +15,25 @@ func TestParseMoneyQuery(t *testing.T) {
{"какие у меня расходы за месяц", MoneyMonth, true}, {"какие у меня расходы за месяц", MoneyMonth, true},
{"how much did I spend today", MoneyToday, true}, {"how much did I spend today", MoneyToday, true},
{"сколько я заработал в этом месяце", MoneyMonth, true}, {"сколько я заработал в этом месяце", MoneyMonth, true},
// Windows nothing is stored for are claimed and refused, never answered
// with the month-to-date figure.
{"сколько я потратил вчера?", MoneyUnsupported, true},
{"сколько я потратил на прошлой неделе?", MoneyUnsupported, true},
{"how much did I spend yesterday", MoneyUnsupported, true},
// Not about money. // Not about money.
{"я потратил весь день на это", MoneyNone, false}, {"я потратил весь день на это", MoneyNone, false},
// A statement, not a question: the noun and the ask must be independent
// evidence, and "траты" used to satisfy both halves on its own.
{"у меня в этом месяце большие траты", MoneyNone, false},
{"потратил много сил", MoneyNone, false}, {"потратил много сил", MoneyNone, false},
{"какая погода?", MoneyNone, false}, {"какая погода?", MoneyNone, false},
{"я купил молоко", MoneyNone, false}, {"я купил молоко", MoneyNone, false},
{"", MoneyNone, false}, {"", MoneyNone, false},
} }
for _, c := range cases { for _, c := range cases {
w, ok := ParseMoneyQuery(c.in) q, ok := ParseMoneyQuery(c.in)
if ok != c.ok || w != c.window { if ok != c.ok || q.Window != c.window {
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok) t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, q.Window, ok, c.window, c.ok)
} }
} }
} }
+81 -12
View File
@@ -47,7 +47,10 @@ func ParseTaskCapture(text string) (TaskCapture, bool) {
rest := strings.TrimSpace(trimmed[len(best):]) rest := strings.TrimSpace(trimmed[len(best):])
rest = strings.TrimLeft(rest, ":—- ") rest = strings.TrimLeft(rest, ":—- ")
rest = strings.TrimSpace(rest) rest = strings.TrimSpace(rest)
rest = strings.TrimRight(rest, ".!") // The question mark goes too. Whisper punctuates dictated Russian, and
// "добавь в задачи позвонить в банк?" must not store the mark or carry it
// into the dedupe key.
rest = strings.TrimRight(rest, ".!?")
rest, weight := stripUrgency(rest) rest, weight := stripUrgency(rest)
if rest == "" { if rest == "" {
return TaskCapture{}, false return TaskCapture{}, false
@@ -55,30 +58,86 @@ func ParseTaskCapture(text string) (TaskCapture, bool) {
return TaskCapture{Text: rest, Weight: weight}, true return TaskCapture{Text: rest, Weight: weight}, true
} }
// urgencyIntensifiers — words that may sit between the edge and the marker.
// "очень срочно оплатить интернет" is the marker at the edge with one word in
// front of it, and it means exactly what "срочно оплатить интернет" means.
var urgencyIntensifiers = []string{"очень", "прям", "прямо", "really", "very", "super"}
// urgencyEdgeTrim — punctuation to ignore around an edge token and to clean off
// the remainder afterwards.
const urgencyEdgeTrim = " .,;:!?—-"
// stripUrgency pulls a leading or trailing urgency word out of the task text // stripUrgency pulls a leading or trailing urgency word out of the task text
// and returns the weight it implies. Only at the edges: "срочно оплатить // and returns the weight it implies. Only at the edges: "срочно оплатить
// интернет" and "оплатить интернет срочно" are the same instruction, while // интернет" and "оплатить интернет срочно" are the same instruction, while
// "позвонить в срочную помощь" is a task whose text happens to contain the // "позвонить в срочную помощь" is a task whose text happens to contain the
// stem, and cutting a word out of the middle of it would mangle the task. // stem, and cutting a word out of the middle of it would mangle the task.
// //
// Matched as a TOKEN, not as a fixed prefix or suffix string. The old shape
// required exactly one space before a trailing marker, so "оплатить интернет,
// срочно" — which is what whisper produces from dictated Russian — kept weight
// 0 and stored the comma and the word as part of the task, polluting the dedupe
// key with the very flag he was trying to set.
//
// The word is removed from the text, because the list should read "оплатить // The word is removed from the text, because the list should read "оплатить
// интернет (важно)" and not "важно оплатить интернет (важно)". // интернет (важно)" and not "важно оплатить интернет (важно)".
func stripUrgency(text string) (string, int) { func stripUrgency(text string) (string, int) {
fields := strings.Fields(text)
if len(fields) == 0 {
return text, 0
}
for _, m := range urgencyMarkers { for _, m := range urgencyMarkers {
lower := strings.ToLower(text) // Strongest marker first (task_phrases.go sorts them), leading edge
switch { // before trailing, so a text carrying both keeps the stronger one.
case strings.HasPrefix(lower, m.Word+" "): if lo, hi, ok := urgencySpan(fields, m.Word); ok {
return strings.TrimSpace(text[len(m.Word):]), m.Weight rest := strings.Join(append(append([]string{}, fields[:lo]...), fields[hi+1:]...), " ")
case strings.HasSuffix(lower, " "+m.Word): rest = strings.Trim(rest, urgencyEdgeTrim)
return strings.TrimSpace(text[:len(text)-len(m.Word)]), m.Weight if rest == "" {
case lower == m.Word:
// Nothing but the marker — no task in it. // Nothing but the marker — no task in it.
return "", 0 return "", 0
} }
return rest, m.Weight
}
} }
return text, 0 return text, 0
} }
// urgencySpan finds the marker at either edge, allowing intensifiers between
// the edge and the marker, and returns the inclusive token range to cut.
func urgencySpan(fields []string, word string) (lo, hi int, ok bool) {
for i := 0; i < len(fields); i++ {
if isUrgencyToken(fields[i], word) {
return 0, i, true
}
if !isIntensifier(fields[i]) {
break
}
}
for i := len(fields) - 1; i >= 0; i-- {
if isUrgencyToken(fields[i], word) {
return i, len(fields) - 1, true
}
if !isIntensifier(fields[i]) {
break
}
}
return 0, 0, false
}
func isUrgencyToken(tok, word string) bool {
return strings.Trim(strings.ToLower(tok), urgencyEdgeTrim) == word
}
func isIntensifier(tok string) bool {
t := strings.Trim(strings.ToLower(tok), urgencyEdgeTrim)
for _, w := range urgencyIntensifiers {
if t == w {
return true
}
}
return false
}
// IsTaskListQuery reports whether an utterance asks for the outstanding task // IsTaskListQuery reports whether an utterance asks for the outstanding task
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел". // list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
// //
@@ -94,14 +153,24 @@ func IsTaskListQuery(text string) bool {
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) { if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
return false return false
} }
// "что мне нужно сделать" / "что мне делать" — no task noun at all. // "что мне нужно сделать" / "чем мне заняться" — no task noun at all, so
if (hasTok(toks, "что") || hasTok(toks, "чем")) && // the pronoun is what carries the meaning. Without it these rules claimed
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) { // every question with a verb in them: "что нужно сделать чтобы перезапустить
// сервер?" and "what does docker do?" both answered "задач нет." from ahead
// of recall and the model, which is the failure the source ordering exists
// to avoid, pointed the other way.
//
// A "с"/"со" object excludes them too: "что мне сделать с этим файлом" has
// the pronoun and is still a question about a file.
if !hasTok(toks, "с") && !hasTok(toks, "со") {
if hasTok(toks, "мне") && (hasTok(toks, "что") || hasTok(toks, "чем")) &&
(hasTok(toks, "сделать") || hasTok(toks, "делать") || hasTok(toks, "заняться")) {
return true return true
} }
if hasTok(toks, "what") && hasTok(toks, "do") { if hasTok(toks, "what") && hasTok(toks, "do") && hasTok(toks, "i") && !hasTok(toks, "you") {
return true return true
} }
}
hasNoun := false hasNoun := false
for _, t := range toks { for _, t := range toks {
for _, w := range taskListWords { for _, w := range taskListWords {
+2
View File
@@ -21,6 +21,7 @@
], ],
"capture_prefixes": [ "capture_prefixes": [
"добавь в задачи", "добавь в задачи",
"добавь в тудушки",
"добавь в список задач", "добавь в список задач",
"добавь в список дел", "добавь в список дел",
"добавь в список", "добавь в список",
@@ -28,6 +29,7 @@
"запиши в задачи", "запиши в задачи",
"запиши задачу", "запиши задачу",
"новая задача", "новая задача",
"поставь задачу",
"в задачи", "в задачи",
"add a task", "add a task",
"add task", "add task",
+19
View File
@@ -20,6 +20,17 @@ func TestParseTaskCapture(t *testing.T) {
{"новая задача важно позвонить маме", "позвонить маме", 2, true}, {"новая задача важно позвонить маме", "позвонить маме", 2, true},
// The stem inside the task text is part of the task, not a marker. // The stem inside the task text is part of the task, not a marker.
{"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true}, {"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true},
// Whisper punctuates dictated Russian. The marker used to be missed as
// soon as anything sat next to it, and then it stayed in the task text
// and in the dedupe key — the exact task he was trying to flag.
{"добавь в задачи оплатить интернет, срочно", "оплатить интернет", 3, true},
{"добавь в задачи очень срочно оплатить интернет", "оплатить интернет", 3, true},
{"добавь в задачи оплатить интернет — важно", "оплатить интернет", 2, true},
// A dictated question mark is not part of the task.
{"добавь в задачи позвонить в банк?", "позвонить в банк", 0, true},
// The phrasings he uses that the prefix list did not have.
{"поставь задачу вынести мусор", "вынести мусор", 0, true},
{"добавь в тудушки купить лампочки", "купить лампочки", 0, true},
// A marker with nothing after it files nothing. // A marker with nothing after it files nothing.
{"добавь в задачи", "", 0, false}, {"добавь в задачи", "", 0, false},
{"новая задача", "", 0, false}, {"новая задача", "", 0, false},
@@ -47,6 +58,7 @@ func TestIsTaskListQuery(t *testing.T) {
"задачи", "задачи",
"мои задачи", "мои задачи",
"what should I do", "what should I do",
"что мне делать?",
} }
for _, s := range yes { for _, s := range yes {
if !IsTaskListQuery(s) { if !IsTaskListQuery(s) {
@@ -55,6 +67,13 @@ func TestIsTaskListQuery(t *testing.T) {
} }
no := []string{ no := []string{
"как дела?", "как дела?",
// No task noun and no pronoun: these fired ahead of recall and the
// model, and answered a question about a file or a server with
// "задач нет."
"что нужно сделать чтобы перезапустить сервер?",
"что мне сделать с этим файлом?",
"what does docker do?",
"what do you do?",
"какая погода?", "какая погода?",
"напомни мне позвонить маме в шесть", "напомни мне позвонить маме в шесть",
"я сделал зарядку", "я сделал зарядку",
+17
View File
@@ -160,6 +160,23 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open'); CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`, CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
// #15 — external identity and resolution attribution for tasks.
//
// ext_id is the identity of the thing a derived task was extracted FROM
// (message id plus the extracted span), and its unique index covers EVERY
// row, not just the live ones. The live-only norm index is right for
// voice, where him saying the errand again is the recurrence signal. It is
// wrong for a mailbox: mavmaild is a read-only reader, nothing marks a
// message read, so a task he already finished would be re-extracted from
// the same immutable text on the next poll and land back on his list as a
// fresh candidate, forever.
//
// resolved_by records which caller moved the task. resolved_ts said when
// and never by what, so a wrong resolution left no trace at all.
`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
} }
// migrate applies every migration with a number greater than the DB's current // migrate applies every migration with a number greater than the DB's current
+135 -34
View File
@@ -48,16 +48,34 @@ const (
// //
// Due is optional. Weight is an explicit importance hint (0 = none), which the // Due is optional. Weight is an explicit importance hint (0 = none), which the
// prioritiser reads; capture never invents one. // prioritiser reads; capture never invents one.
// ExternalID is the identity of the thing this task was derived FROM — a
// message id plus the extracted span, for a source that re-reads the same
// immutable text forever. Empty for anything he stated himself.
//
// ResolvedBy names the caller that moved the task to its terminal state, in the
// source vocabulary. Empty while the task is live.
type Task struct { type Task struct {
ID int64 ID int64
CreatedTs time.Time CreatedTs time.Time
Text string Text string
Source string Source string
Evidence string Evidence string
ExternalID string
Status string Status string
Due *time.Time Due *time.Time
Weight int Weight int
ResolvedTs *time.Time ResolvedTs *time.Time
ResolvedBy string
}
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
// existing candidate this capture turned into open work: he stated out loud a
// task Maven had only proposed, which is a confirmation, and the caller says so
// instead of "уже в списке".
type CaptureResult struct {
ID int64
Created bool
Promoted bool
} }
var ( var (
@@ -69,19 +87,49 @@ var (
// liveTaskStatuses — the two statuses that count as outstanding work. // liveTaskStatuses — the two statuses that count as outstanding work.
var liveTaskStatuses = []string{TaskCandidate, TaskOpen} var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
// CaptureTask inserts a task, or returns the existing live task when the same // derivedSourcePrefixes — provenance that means "Maven read this somewhere",
// work is already outstanding. created reports which happened, so a caller can // as opposed to "he said it". A task from one of these is a candidate and
// tell the owner "уже в списке" instead of pretending it wrote something. // nothing else; see CaptureTask.
var derivedSourcePrefixes = []string{"email:"}
// IsDerivedSource reports whether a task source means Maven inferred the task
// from something she read rather than being told it.
func IsDerivedSource(source string) bool {
for _, p := range derivedSourcePrefixes {
if strings.HasPrefix(source, p) {
return true
}
}
return false
}
// CaptureTask inserts a task, or returns the existing one when the same work is
// already there. The result says which happened, so a caller can tell the owner
// "уже в списке" instead of pretending it wrote something.
// //
// Dedupe is on the normalised text among LIVE rows only (see the partial unique // Two dedupe keys, because voice and mail have different intake semantics:
// index in migration #14): a weekly errand can be captured again once the last //
// one is done, but a mail that gets re-read produces no second row. This is the // - ExternalID, unique over EVERY row whatever its status. A source that
// property the email intake depends on — it may call CaptureTask for every // re-reads the same immutable text forever must never resurrect work he has
// message it extracts from, as often as it likes, without growing the list. // already finished. This is the property the email intake depends on: it
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) { // may call CaptureTask for every message it extracts from, as often as it
// likes, without growing the list.
// - The normalised text among LIVE rows only (the partial unique index in
// migration #14), for anything with no external identity. A weekly errand
// captured again once the last one is done must produce a new row, because
// him saying it again IS the recurrence signal.
//
// A capture with Status open over an existing candidate PROMOTES it. Stating
// the work out loud is a confirmation, and leaving it a candidate would have
// Maven read it back as something he never confirmed.
//
// A derived source may only ever capture a candidate. The doc on the intake
// seam said "must NOT set Status open"; this is where that stops being an
// honour system, so a compromised reader cannot file work he never reviewed.
func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error) {
text := strings.TrimSpace(t.Text) text := strings.TrimSpace(t.Text)
if text == "" { if text == "" {
return 0, false, ErrTaskEmpty return CaptureResult{}, ErrTaskEmpty
} }
status := t.Status status := t.Status
if status == "" { if status == "" {
@@ -90,7 +138,10 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool
if status != TaskCandidate && status != TaskOpen { if status != TaskCandidate && status != TaskOpen {
// Capturing straight into a resolved state is meaningless — a task is // Capturing straight into a resolved state is meaningless — a task is
// captured live and moved later. // captured live and moved later.
return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status) return CaptureResult{}, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
}
if status == TaskOpen && IsDerivedSource(t.Source) {
return CaptureResult{}, fmt.Errorf("%w: derived source %q may only capture a candidate", ErrTaskStatus, t.Source)
} }
norm := NormalizeTaskText(text) norm := NormalizeTaskText(text)
created2 := t.CreatedTs created2 := t.CreatedTs
@@ -101,31 +152,69 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool
if t.Due != nil { if t.Due != nil {
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true} due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
} }
var ext sql.NullString
if e := strings.TrimSpace(t.ExternalID); e != "" {
ext = sql.NullString{String: e, Valid: true}
}
// Untargeted DO NOTHING: either unique index may be the one that fires, and
// the lookup below sorts out which.
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight) `INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
VALUES (?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`, ON CONFLICT DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight) created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
if err != nil { if err != nil {
return 0, false, fmt.Errorf("capture task: %w", err) return CaptureResult{}, fmt.Errorf("capture task: %w", err)
} }
if n, err := res.RowsAffected(); err != nil { if n, err := res.RowsAffected(); err != nil {
return 0, false, fmt.Errorf("capture task: rows affected: %w", err) return CaptureResult{}, fmt.Errorf("capture task: rows affected: %w", err)
} else if n > 0 { } else if n > 0 {
id, err := res.LastInsertId() id, err := res.LastInsertId()
if err != nil { if err != nil {
return 0, false, fmt.Errorf("capture task: last insert id: %w", err) return CaptureResult{}, fmt.Errorf("capture task: last insert id: %w", err)
} }
return id, true, nil return CaptureResult{ID: id, Created: true}, nil
} }
// Already live — hand back the row that won. // Something already holds one of the keys. External identity first: that
existing, err := s.lookupLiveTaskByNorm(ctx, norm) // row may be resolved, in which case the answer is "already handled", not a
if err != nil { // new task.
return 0, false, err var existing Task
if ext.Valid {
existing, err = s.lookupTaskByExternalID(ctx, ext.String)
if err != nil && !errors.Is(err, ErrTaskNotFound) {
return CaptureResult{}, err
} }
return existing.ID, false, nil }
if existing.ID == 0 {
existing, err = s.lookupLiveTaskByNorm(ctx, norm)
if err != nil {
return CaptureResult{}, err
}
}
if status == TaskOpen && existing.Status == TaskCandidate {
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
}
return CaptureResult{ID: existing.ID, Promoted: true}, nil
}
return CaptureResult{ID: existing.ID}, nil
}
// lookupTaskByExternalID finds a task by the identity of what it was derived
// from, in ANY status. Resolved rows count: the whole point of the key is that
// re-reading the mail that produced a finished task produces nothing.
func (s *Store) lookupTaskByExternalID(ctx context.Context, ext string) (Task, error) {
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE ext_id = ?`, ext)
t, err := scanTask(row)
if errors.Is(err, sql.ErrNoRows) {
return Task{}, ErrTaskNotFound
}
if err != nil {
return Task{}, fmt.Errorf("lookup task by external id: %w", err)
}
return t, nil
} }
// lookupLiveTaskByNorm finds the outstanding task with this normalised text. // lookupLiveTaskByNorm finds the outstanding task with this normalised text.
@@ -142,7 +231,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
return t, nil return t, nil
} }
const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks` const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
// LookupTask returns one task by id. // LookupTask returns one task by id.
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) { func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
@@ -157,9 +246,15 @@ func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
return t, nil return t, nil
} }
// ListTasks returns tasks in one status, newest first. An empty status returns // MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a
// every row; "live" returns candidate + open, which is what every read path // person keeps by hand and never approaches this; the resolved set grows for as
// that means "outstanding work" wants. // long as the box runs, and an unbounded read of it is a page that gets slower
// every month. Newest first, so the bound drops the oldest finished work.
const MaxTaskRows = 500
// ListTasks returns tasks in one status, newest first, at most MaxTaskRows of
// them. An empty status returns every row; "live" returns candidate + open,
// which is what every read path that means "outstanding work" wants.
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) { func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
q := taskSelect q := taskSelect
var args []any var args []any
@@ -172,7 +267,7 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
q += ` WHERE status = ?` q += ` WHERE status = ?`
args = append(args, status) args = append(args, status)
} }
q += ` ORDER BY created_ts DESC, id DESC` q += fmt.Sprintf(` ORDER BY created_ts DESC, id DESC LIMIT %d`, MaxTaskRows)
rows, err := s.db.QueryContext(ctx, q, args...) rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil { if err != nil {
@@ -201,8 +296,14 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and // ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
// tools use: an answered question is not answered twice. // tools use: an answered question is not answered twice.
// //
// Resolving frees the dedupe key, which is the point: the work can recur. // Resolving frees the NORM dedupe key, which is the point: the work can recur
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error { // when he says it again. It does not free an external identity — see
// CaptureTask for why a re-read mailbox must not resurrect finished work.
//
// by names the caller making the move, in the source vocabulary ("tap:web",
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
// says what resolved it.
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
var from []string var from []string
switch status { switch status {
case TaskOpen: case TaskOpen:
@@ -222,9 +323,9 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true} resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
} }
q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` + q := `UPDATE tasks SET status = ?, resolved_ts = ?, resolved_by = ? WHERE id = ? AND status IN (?` +
strings.Repeat(",?", len(from)-1) + `)` strings.Repeat(",?", len(from)-1) + `)`
args := []any{status, resolved, id} args := []any{status, resolved, by, id}
for _, f := range from { for _, f := range from {
args = append(args, f) args = append(args, f)
} }
@@ -271,7 +372,7 @@ func scanTask(sc scanner) (Task, error) {
var t Task var t Task
var created int64 var created int64
var due, resolved sql.NullInt64 var due, resolved sql.NullInt64
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil { if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
return Task{}, err return Task{}, err
} }
t.CreatedTs = time.UnixMilli(created).UTC() t.CreatedTs = time.UnixMilli(created).UTC()
+182 -27
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -12,24 +13,24 @@ func TestCaptureTaskDedupesLiveWork(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now}) first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !created { if !first.Created {
t.Fatal("first capture must create a row") t.Fatal("first capture must create a row")
} }
// Same work, different casing and punctuation — one task, not two. // Same work, different casing and punctuation — one task, not two.
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now}) again, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "tap:web", CreatedTs: now})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if created { if again.Created {
t.Error("second capture of the same live work must not create a row") t.Error("second capture of the same live work must not create a row")
} }
if again != id { if again.ID != first.ID {
t.Errorf("dedupe returned id %d, want the existing %d", again, id) t.Errorf("dedupe returned id %d, want the existing %d", again.ID, first.ID)
} }
live, err := st.ListTasks(ctx, "live") live, err := st.ListTasks(ctx, "live")
@@ -46,20 +47,22 @@ func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now}) first, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil { id := first.ID
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The dedupe key is free again: a recurring errand must be capturable. // The dedupe key is free again: a recurring errand must be capturable.
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)}) second, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !created || id2 == id { id2 := second.ID
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id) if !second.Created || id2 == id {
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", second.Created, id2, id)
} }
live, err := st.ListTasks(ctx, "live") live, err := st.ListTasks(ctx, "live")
if err != nil { if err != nil {
@@ -76,7 +79,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour) due := now.Add(48 * time.Hour)
id, _, err := st.CaptureTask(ctx, Task{ res, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку", Text: "продлить страховку",
Source: "email:kami", Source: "email:kami",
Evidence: "Re: страховой полис истекает", Evidence: "Re: страховой полис истекает",
@@ -88,7 +91,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := st.LookupTask(ctx, id) got, err := st.LookupTask(ctx, res.ID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -114,22 +117,23 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now}) res, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
cand := res.ID
// candidate → done is not a legal move: he has to confirm it first. // candidate → done is not a legal move: he has to confirm it first.
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) { if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err) t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
} }
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil { if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil { if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Already resolved — a second resolve must not move it again. // Already resolved — a second resolve must not move it again.
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) { if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour), "tap:web"); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err) t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
} }
got, err := st.LookupTask(ctx, cand) got, err := st.LookupTask(ctx, cand)
@@ -147,14 +151,15 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) { func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
ctx := context.Background() ctx := context.Background()
st := newTestStore(t) st := newTestStore(t)
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"}) res, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) { id := res.ID
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→candidate err = %v, want ErrTaskStatus", err) t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
} }
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) { if err := st.SetTaskStatus(ctx, id, "urgent", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→urgent err = %v, want ErrTaskStatus", err) t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
} }
} }
@@ -162,7 +167,7 @@ func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
func TestCaptureTaskRejectsEmptyText(t *testing.T) { func TestCaptureTaskRejectsEmptyText(t *testing.T) {
ctx := context.Background() ctx := context.Background()
st := newTestStore(t) st := newTestStore(t)
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) { if _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
t.Errorf("err = %v, want ErrTaskEmpty", err) t.Errorf("err = %v, want ErrTaskEmpty", err)
} }
} }
@@ -172,10 +177,10 @@ func TestListTasksFiltersByStatus(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now}) open1, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)}) _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)}) done, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil { if err := st.SetTaskStatus(ctx, done.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -190,7 +195,7 @@ func TestListTasksFiltersByStatus(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(opens) != 1 || opens[0].ID != open1 { if len(opens) != 1 || opens[0].ID != open1.ID {
t.Fatalf("open = %+v", opens) t.Fatalf("open = %+v", opens)
} }
all, err := st.ListTasks(ctx, "") all, err := st.ListTasks(ctx, "")
@@ -219,3 +224,153 @@ func TestNormalizeTaskText(t *testing.T) {
} }
} }
} }
// A mail the reader keeps seeing must not resurrect work he already finished.
// The norm key alone frees on resolve, which is right for voice and wrong for a
// mailbox: mavmaild never marks anything read, so the same message is extracted
// again on every poll, forever.
func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
mail := Task{
Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
ExternalID: "email:kami#412:продлить страховку", CreatedTs: now,
}
first, err := st.CaptureTask(ctx, mail)
if err != nil {
t.Fatal(err)
}
if !first.Created {
t.Fatal("first capture must create a row")
}
// He confirms it and does it.
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(2*time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// The next poll reads the same message again.
mail.CreatedTs = now.AddDate(0, 0, 1)
again, err := st.CaptureTask(ctx, mail)
if err != nil {
t.Fatal(err)
}
if again.Created {
t.Error("re-reading the same mail created a second task after the first was done")
}
if again.ID != first.ID {
t.Errorf("id = %d, want the resolved row %d", again.ID, first.ID)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 0 {
t.Fatalf("live = %+v, want nothing: he already did this", live)
}
}
// Stating out loud a task Maven only proposed is a confirmation. Leaving it a
// candidate had her read it straight back as something he had not confirmed.
func TestCaptureTaskPromotesCandidate(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
cand, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
ExternalID: "email:kami#7:продлить страховку", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
spoken, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку", Source: "tap:voice", Status: TaskOpen,
CreatedTs: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
if spoken.Created {
t.Error("capture over a live candidate must not create a second row")
}
if !spoken.Promoted {
t.Error("capture with status open over a candidate must promote it")
}
got, err := st.LookupTask(ctx, cand.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskOpen {
t.Errorf("status = %q, want open", got.Status)
}
if got.ResolvedTs != nil {
t.Errorf("resolved_ts = %v, want nil: the task is still live", got.ResolvedTs)
}
}
// A derived source may only ever file a candidate. The intake seam documented
// this and nothing enforced it, so a caller could skip review entirely.
func TestCaptureTaskRefusesOpenFromDerivedSource(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
_, err := st.CaptureTask(ctx, Task{Text: "оплатить счёт", Source: "email:kami", Status: TaskOpen})
if !errors.Is(err, ErrTaskStatus) {
t.Errorf("err = %v, want ErrTaskStatus", err)
}
}
// resolved_ts said when a task was resolved and never by what.
func TestSetTaskStatusRecordsWho(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{Text: "выкинуть мусор", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:voice"); err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.ResolvedBy != "tap:voice" {
t.Errorf("resolved_by = %q, want tap:voice", got.ResolvedBy)
}
}
// The resolved history only grows; an unbounded read of it is a page that gets
// slower every month.
func TestListTasksIsBounded(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
for i := 0; i < MaxTaskRows+5; i++ {
res, err := st.CaptureTask(ctx, Task{
Text: fmt.Sprintf("задача %d", i), Source: "tap:voice",
CreatedTs: now.Add(time.Duration(i) * time.Minute),
})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
}
all, err := st.ListTasks(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(all) != MaxTaskRows {
t.Fatalf("all = %d rows, want the %d-row bound", len(all), MaxTaskRows)
}
if all[0].Text != fmt.Sprintf("задача %d", MaxTaskRows+4) {
t.Errorf("first = %q, want the newest", all[0].Text)
}
}
+41 -9
View File
@@ -62,7 +62,10 @@ const (
scoreDueToday = 60 scoreDueToday = 60
scoreDueTomorrow = 40 scoreDueTomorrow = 40
scoreDueWeek = 20 scoreDueWeek = 20
scoreDueLater = 5 // 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 scorePerWeight = 15 // "срочно" / "важно" / the web form's select
scorePerWeekOld = 1 // so nothing rots at the bottom forever scorePerWeekOld = 1 // so nothing rots at the bottom forever
scoreAgeCap = 10 scoreAgeCap = 10
@@ -141,7 +144,13 @@ func score(it Item, now time.Time) (float64, string) {
if w > 0 { if w > 0 {
total += float64(w * scorePerWeight) total += float64(w * scorePerWeight)
if reason == "" { 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 = "важно" reason = "важно"
if w >= MaxWeight {
reason = "срочно"
}
} }
} }
@@ -161,15 +170,23 @@ func score(it Item, now time.Time) (float64, string) {
return total, reason return total, reason
} }
// dayDelta — calendar days from now to due, in due's own location. Whole days, // dayDelta — calendar days from now to due, in NOW's location. Whole days, not
// not hours: a task due today is due today whether it is 09:00 or 23:00, and an // 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. // 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 { func dayDelta(due, now time.Time) int {
loc := due.Location() loc := now.Location()
d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc) d := due.In(loc)
n := now.In(loc) dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) nn := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return int(d.Sub(n).Hours() / 24) return int(dd.Sub(nn).Hours() / 24)
} }
// SpokenLimit — how many tasks the spoken list names before it summarises the // SpokenLimit — how many tasks the spoken list names before it summarises the
@@ -232,7 +249,22 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
} }
s := strings.Join(parts, "; ") s := strings.Join(parts, "; ")
if rest > 0 { if rest > 0 {
s += fmt.Sprintf("; и ещё %d", rest) // With the noun. Spoken, a bare number trails off mid-sentence.
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
} }
return s 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 "задач"
}
+68
View File
@@ -175,3 +175,71 @@ func TestFormatRUEmpty(t *testing.T) {
t.Errorf("reply = %q", got) t.Errorf("reply = %q", got)
} }
} }
// A due date read back from the store is a UTC instant, so comparing calendar
// days in ITS location put every date a day out east of Greenwich: the row said
// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
// itself while the due column one cell over said otherwise.
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
tz := time.FixedZone("UTC+4", 4*3600)
// Entered on the web form as 2026-08-02 local, stored and read back as UTC.
due := time.Date(2026, 8, 2, 0, 0, 0, 0, tz).UTC()
local := time.Date(2026, 8, 1, 10, 0, 0, 0, tz)
got := Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, local)
if got[0].Reason != "завтра" {
t.Errorf("reason = %q, want завтра on the day before", got[0].Reason)
}
// The morning of the due date itself.
onTheDay := time.Date(2026, 8, 2, 10, 0, 0, 0, tz)
got = Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, onTheDay)
if got[0].Reason != "сегодня" {
t.Errorf("reason = %q, want сегодня on the due date", got[0].Reason)
}
if got[0].Score != scoreDueToday {
t.Errorf("score = %v, want %v", got[0].Score, float64(scoreDueToday))
}
}
// "срочно" and "важно" are two rungs and the read-back said "важно" for both,
// which reports a word he did not say.
func TestRankNamesTheUrgencyHeStated(t *testing.T) {
got := Rank([]Item{
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Weight: 3, Created: now()},
{ID: 2, Text: "починить кран", Status: StatusOpen, Weight: 2, Created: now()},
}, now())
if got[0].Reason != "срочно" {
t.Errorf("reason = %q, want срочно", got[0].Reason)
}
if got[1].Reason != "важно" {
t.Errorf("reason = %q, want важно", got[1].Reason)
}
}
// The package doc guarantees a class ordering. Age used to invert it: an
// undated task at the age cap outscored a dated one three weeks out.
func TestRankDatedWorkBeatsAgeAlone(t *testing.T) {
got := Rank([]Item{
{ID: 1, Text: "старьё", Status: StatusOpen, Created: now().AddDate(0, 0, -70)},
{ID: 2, Text: "через три недели", Status: StatusOpen, Due: at(2026, 8, 22), Created: now()},
}, now())
if got[0].Text != "через три недели" {
t.Errorf("order = %v, want the dated task first", texts(got))
}
}
// A bare "и ещё 5" trails off when spoken.
func TestFormatRUTailCarriesTheNoun(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()})
}
if got := FormatRU(Rank(items, now())); !strings.Contains(got, "и ещё 3 задачи") {
t.Errorf("reply = %q, want the count with its noun", got)
}
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
if got := pluralTasksRU(n); got != want {
t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
}
}
}
+84 -11
View File
@@ -27,6 +27,7 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -40,6 +41,14 @@ type Client struct {
BaseURL string BaseURL string
Token string Token string
HTTP *http.Client HTTP *http.Client
// instruments — id → short title, fetched once from a cursor-zero diff and
// kept for the process lifetime. Currencies do not change; the reason this
// cache exists is that a windowed diff only returns objects changed since
// the cursor, so a day window almost never carries the instrument rows the
// transactions in it point at.
mu sync.Mutex
instruments map[int64]string
} }
// New returns a client with a bounded HTTP timeout. An empty token is a // New returns a client with a bounded HTTP timeout. An empty token is a
@@ -124,14 +133,77 @@ type Summary struct {
// since then, which for a "this month" window is everything filed this month. // since then, which for a "this month" window is everything filed this month.
// The caveat, deliberately accepted: a transaction he EDITED this month but // The caveat, deliberately accepted: a transaction he EDITED this month but
// dated last month arrives too, and is then excluded by date — so editing old // dated last month arrives too, and is then excluded by date — so editing old
// records cannot inflate this month's total. The reverse case (a transaction // records cannot inflate this month's total.
// dated this month, filed and last changed before `from`) cannot exist. //
// The reverse case is real and undercounts: a transaction dated inside the
// window but last CHANGED before `from` never arrives. A planned transaction
// entered last month and dated this month is exactly that, and it goes missing
// from the total. Widening the cursor would mean pulling his whole history
// every poll, so the total is "what was filed or touched in the window", and
// that is the honest reading of it.
func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) { func (c *Client) Since(ctx context.Context, from, to time.Time) (Summary, error) {
resp, err := c.diff(ctx, from.Unix()) resp, err := c.diff(ctx, from.Unix())
if err != nil { if err != nil {
return Summary{}, err return Summary{}, err
} }
return summarize(resp, from, to), nil names := c.currencyNames(ctx, resp)
return summarize(resp, names, from, to), nil
}
// currencyNames resolves instrument ids to short titles. The window's own diff
// first, then — only if a transaction in it points at an instrument the window
// did not carry — one cursor-zero diff, cached for the process lifetime.
//
// A failed instrument fetch is not an error: the summary is still every number
// the API returned, and an amount whose currency cannot be named is dropped
// from the spoken string rather than read out as "1749.5 ?".
func (c *Client) currencyNames(ctx context.Context, resp diffResponse) map[int64]string {
names := map[int64]string{}
for _, in := range resp.Instrument {
names[in.ID] = in.ShortTitle
}
missing := false
for _, t := range resp.Transaction {
if t.Deleted {
continue
}
for _, id := range []int64{t.OutcomeInstrmnt, t.IncomeInstrument} {
if id != 0 && names[id] == "" {
missing = true
}
}
}
if !missing {
return names
}
for id, title := range c.allInstruments(ctx) {
if names[id] == "" {
names[id] = title
}
}
return names
}
// allInstruments fetches every instrument once, from a cursor-zero diff, and
// caches it. The response also carries transactions, which are decoded and
// dropped: this is the one call in the package that reads more of his financial
// life than the question needs, and it happens at most once per process.
func (c *Client) allInstruments(ctx context.Context) map[int64]string {
c.mu.Lock()
defer c.mu.Unlock()
if c.instruments != nil {
return c.instruments
}
resp, err := c.diff(ctx, 0)
if err != nil {
// Not cached: a network failure is not a fact about his currencies.
return nil
}
c.instruments = make(map[int64]string, len(resp.Instrument))
for _, in := range resp.Instrument {
c.instruments[in.ID] = in.ShortTitle
}
return c.instruments
} }
func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) { func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse, error) {
@@ -179,11 +251,7 @@ func (c *Client) diff(ctx context.Context, serverTimestamp int64) (diffResponse,
// transfers and currency exchanges (income and outcome both non-zero — moving // transfers and currency exchanges (income and outcome both non-zero — moving
// his own money between his own accounts is not spending), and anything dated // his own money between his own accounts is not spending), and anything dated
// outside the window. // outside the window.
func summarize(resp diffResponse, from, to time.Time) Summary { func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
cur := map[int64]string{}
for _, in := range resp.Instrument {
cur[in.ID] = in.ShortTitle
}
spent := map[string]float64{} spent := map[string]float64{}
earned := map[string]float64{} earned := map[string]float64{}
count := 0 count := 0
@@ -217,14 +285,19 @@ func summarize(resp diffResponse, from, to time.Time) Summary {
} }
} }
// UnknownCurrency — the label for an instrument id nothing could name. It
// survives into the Summary so a caller can see that a bucket exists; the
// renderer drops it rather than reading "?" aloud as a currency.
const UnknownCurrency = "?"
// currency names the instrument, or says it does not know. An unknown id keeps // currency names the instrument, or says it does not know. An unknown id keeps
// the amount rather than dropping it: a sum without a currency label is still // its bucket in the Summary rather than being folded into a named one: a sum is
// his money, and silently discarding it would understate the total. // only checkable against his bank if every amount in it is in one currency.
func currency(names map[int64]string, id int64) string { func currency(names map[int64]string, id int64) string {
if s := names[id]; s != "" { if s := names[id]; s != "" {
return s return s
} }
return "?" return UnknownCurrency
} }
// sortMoney gives the amounts a stable order (largest first) so the rendered // sortMoney gives the amounts a stable order (largest first) so the rendered
+98 -2
View File
@@ -98,7 +98,7 @@ func TestSinceEmptyWindowIsNotAZero(t *testing.T) {
if !s.Empty() { if !s.Empty() {
t.Fatalf("summary = %+v, want empty", s) t.Fatalf("summary = %+v, want empty", s)
} }
if _, ok := s.Value(); ok { if _, ok := s.Value(time.Now()); ok {
t.Error("an empty summary must not produce a fact value") t.Error("an empty summary must not produce a fact value")
} }
} }
@@ -138,7 +138,7 @@ func TestMonthAndDayWindows(t *testing.T) {
func TestFactValueRoundTripAndFormat(t *testing.T) { func TestFactValueRoundTripAndFormat(t *testing.T) {
s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3} s := Summary{Spent: []Money{{"RUB", 1749.5}}, Earned: []Money{{"RUB", 3000}}, Count: 3}
raw, ok := s.Value() raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
if !ok { if !ok {
t.Fatal("want a fact value") t.Fatal("want a fact value")
} }
@@ -176,3 +176,99 @@ func TestFormatAmountKeepsTheTruth(t *testing.T) {
} }
} }
} }
// The day window rolls over at midnight and the first spend of the new day may
// be hours away, so the last good money_today fact keeps a fresh ts while
// covering yesterday. Only the window stamp inside the value can tell.
func TestFactValueCoversDay(t *testing.T) {
from, _ := DayWindow(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
s := Summary{From: from, Spent: []Money{{"RUB", 1749.5}}, Count: 1}
raw, ok := s.Value(time.Date(2026, 8, 1, 22, 0, 0, 0, time.UTC))
if !ok {
t.Fatal("want a fact value")
}
v, err := ParseFactValue(raw)
if err != nil {
t.Fatal(err)
}
if !v.CoversDay(time.Date(2026, 8, 1, 23, 59, 0, 0, time.UTC)) {
t.Error("the same day must be covered")
}
if v.CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
t.Error("yesterday's day total must not count as today's")
}
if (FactValue{}).CoversDay(time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)) {
t.Error("a value with no window stamp must fail closed")
}
if v.AsOf.IsZero() {
t.Error("the value must carry when it was read, not only when it changed")
}
}
// The instrument rows are only in the diff when they changed since the cursor,
// which for a day window they usually have not. An amount whose currency
// nothing could name is dropped from the spoken string rather than read out
// as "1749.5 ?".
func TestUnknownCurrencyIsNotSpoken(t *testing.T) {
v := FactValue{Spent: []Money{{UnknownCurrency, 1749.5}}, Count: 1}
if got := v.FormatRU("сегодня"); got != "" {
t.Errorf("reply = %q, want nothing said about an unlabelled amount", got)
}
v = FactValue{Spent: []Money{{"RUB", 100}, {UnknownCurrency, 1749.5}}, Count: 2}
got := v.FormatRU("сегодня")
if strings.Contains(got, UnknownCurrency) {
t.Errorf("reply = %q, want no %q currency", got, UnknownCurrency)
}
if !strings.Contains(got, "100 RUB") {
t.Errorf("reply = %q, want the amount that does have a currency", got)
}
}
// A day diff cursored at midnight usually carries no instrument rows at all.
// The client fetches them once from a cursor-zero diff instead of labelling
// every amount "?".
func TestSinceResolvesCurrencyFromASeparateDiff(t *testing.T) {
body, err := os.ReadFile("testdata/diff.json")
if err != nil {
t.Fatal(err)
}
var full diffResponse
if err := json.Unmarshal(body, &full); err != nil {
t.Fatal(err)
}
windowed := diffResponse{ServerTimestamp: full.ServerTimestamp, Transaction: full.Transaction}
instrumentsOnly := diffResponse{ServerTimestamp: full.ServerTimestamp, Instrument: full.Instrument}
zeroCursorCalls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req diffRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
out := windowed
if req.ServerTimestamp == 0 {
zeroCursorCalls++
out = instrumentsOnly
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}))
defer srv.Close()
c, _ := New("tok", srv.URL, time.Second)
s, err := c.Since(context.Background(), aug(1), aug(6))
if err != nil {
t.Fatal(err)
}
for _, m := range s.Spent {
if m.Currency == UnknownCurrency {
t.Fatalf("spent = %+v, want every amount named", s.Spent)
}
}
// Cached for the process lifetime: a second window does not refetch.
if _, err := c.Since(context.Background(), aug(1), aug(6)); err != nil {
t.Fatal(err)
}
if zeroCursorCalls != 1 {
t.Errorf("cursor-zero diffs = %d, want exactly 1", zeroCursorCalls)
}
}
+77 -11
View File
@@ -20,29 +20,61 @@ const (
// asks, never a reason to speak. Maven is not a nag, least of all about money. // asks, never a reason to speak. Maven is not a nag, least of all about money.
const Source = "poll:zenmoney" const Source = "poll:zenmoney"
// FactValue — the JSON stored in a money fact. A wire shape of its own rather // FactValue — the JSON stored in a money fact.
// than the Summary struct so From/To (which carry a timezone and a clock) stay //
// out of the store; the key already says which window it is. // From and AsOf are both here because the fact's own Ts can express neither.
//
// - From is the first instant of the window the figure covers. The day fact
// is only true for the day it was read on, and after midnight the poller
// has nothing new to write until the first spend of the new day, so the
// previous day's total sits there as the latest money_today looking
// perfectly fresh. Without From, "сколько я потратил сегодня?" at 09:00
// answered with yesterday's spending.
// - AsOf is when the figure was last READ, not when it last changed. The
// poller used to skip a write when the value was byte-identical, so a quiet
// stretch left Ts pointing at the last time the number moved and the answer
// came back prefixed "данные от 30.07" while being current and correct.
type FactValue struct { type FactValue struct {
Spent []Money `json:"spent"` Spent []Money `json:"spent"`
Earned []Money `json:"earned"` Earned []Money `json:"earned"`
Count int `json:"count"` Count int `json:"count"`
From time.Time `json:"from,omitempty"`
AsOf time.Time `json:"as_of,omitempty"`
} }
// Value encodes the summary for the facts table. Returns ok=false for an empty // Value encodes the summary for the facts table, stamped with the instant it
// summary: no transactions read means no fact written, so that a failed or // was read. Returns ok=false for an empty summary: no transactions read means
// empty poll can never be recited back to him as a zero. // no fact written, so that a failed or empty poll can never be recited back to
func (s Summary) Value() (string, bool) { // him as a zero.
func (s Summary) Value(asOf time.Time) (string, bool) {
if s.Empty() { if s.Empty() {
return "", false return "", false
} }
b, err := json.Marshal(FactValue{Spent: s.Spent, Earned: s.Earned, Count: s.Count}) b, err := json.Marshal(FactValue{
Spent: s.Spent, Earned: s.Earned, Count: s.Count,
From: s.From, AsOf: asOf,
})
if err != nil { if err != nil {
return "", false return "", false
} }
return string(b), true return string(b), true
} }
// CoversDay reports whether this value's window starts at now's midnight, in
// now's location. A day total whose window has rolled over is not a stale
// figure to be prefixed with a date, it is an answer to a different question,
// and it must not be spoken as today's.
//
// A value written before From existed has a zero From and fails the check,
// which is the safe direction: the next poll rewrites it.
func (v FactValue) CoversDay(now time.Time) bool {
if v.From.IsZero() {
return false
}
f := v.From.In(now.Location())
return f.Year() == now.Year() && f.Month() == now.Month() && f.Day() == now.Day()
}
// ParseFactValue decodes a stored money fact. // ParseFactValue decodes a stored money fact.
func ParseFactValue(raw string) (FactValue, error) { func ParseFactValue(raw string) (FactValue, error) {
var v FactValue var v FactValue
@@ -59,15 +91,41 @@ func ParseFactValue(raw string) (FactValue, error) {
// No commentary. She reports the figure and stops: an opinion about his // No commentary. She reports the figure and stops: an opinion about his
// spending is exactly the nagging Maven is not for. // spending is exactly the nagging Maven is not for.
func (v FactValue) FormatRU(window string) string { func (v FactValue) FormatRU(window string) string {
return v.formatRU(window, false)
}
// FormatIncomeRU is FormatRU with the income read first, for a question that
// asked about income ("сколько я заработал в этом месяце?"). Same figures, same
// refusal to comment; only the order of the two halves differs, so the number
// he asked for is the number she says first.
func (v FactValue) FormatIncomeRU(window string) string {
return v.formatRU(window, true)
}
func (v FactValue) formatRU(window string, incomeFirst bool) string {
if v.Count == 0 { if v.Count == 0 {
return "" return ""
} }
var parts []string spent, earned := "", ""
if len(v.Spent) > 0 { if len(v.Spent) > 0 {
parts = append(parts, "потратил "+joinMoney(v.Spent)) if s := joinMoney(v.Spent); s != "" {
spent = "потратил " + s
}
} }
if len(v.Earned) > 0 { if len(v.Earned) > 0 {
parts = append(parts, "получил "+joinMoney(v.Earned)) if s := joinMoney(v.Earned); s != "" {
earned = "получил " + s
}
}
order := []string{spent, earned}
if incomeFirst {
order = []string{earned, spent}
}
var parts []string
for _, p := range order {
if p != "" {
parts = append(parts, p)
}
} }
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
@@ -75,9 +133,17 @@ func (v FactValue) FormatRU(window string) string {
return window + " ты " + strings.Join(parts, ", ") + "." return window + " ты " + strings.Join(parts, ", ") + "."
} }
// joinMoney renders the amounts, DROPPING any whose currency could not be
// named. "сегодня ты потратил 1749.5 ?." is not something to read aloud, and a
// figure with no currency on it is not a figure he can check against his bank.
// An amount silently missing is the lesser wrong: the alternative is speaking a
// number whose units Maven does not know.
func joinMoney(ms []Money) string { func joinMoney(ms []Money) string {
parts := make([]string, 0, len(ms)) parts := make([]string, 0, len(ms))
for _, m := range ms { for _, m := range ms {
if m.Currency == UnknownCurrency || m.Currency == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency)) parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
} }
return strings.Join(parts, " и ") return strings.Join(parts, " и ")