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))
}
+34 -16
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
} }
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so // writeMoneyFact writes a money fact every poll, with no value comparison. See
// errors.Is is the right check; keep a helper so the switch above reads clean. // the comment above pollZenmoney for why this one does not go through
func isNoFact(err error) bool { // writeIfChangedRaw.
for e := err; e != nil; { //
if e == ipc.ErrNoFact { // The log line names the key only, never the figures: mavpoll's log is not the
return true // place his spending ends up.
} func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
u, ok := e.(interface{ Unwrap() error }) if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
if !ok { Ts: now, Kind: "env", Key: key, Value: jsonVal,
return false Source: zenmoney.Source, Confidence: 1.0,
} }); err != nil {
e = u.Unwrap() return fmt.Errorf("write %s: %w", key, err)
} }
return false log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check.
func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact)
} }
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) { func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
+49 -21
View File
@@ -18,6 +18,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"time" "time"
@@ -904,17 +905,29 @@ 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 {
ID int64 ID int64
Text string Text string
Source string Source string
Evidence string Evidence string
Status string Status string
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,
@@ -1001,11 +1023,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tasksTmpl.Execute(w, struct { if err := tasksTmpl.Execute(w, struct {
Msg, Err string Msg, Err string
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
+38 -21
View File
@@ -101,15 +101,17 @@ type WriteFactReq struct {
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived // "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself. // task came from, empty for anything he stated himself.
type Task struct { type Task struct {
ID int64 `json:"id"` ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"` CreatedTs time.Time `json:"created_ts"`
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"`
Status string `json:"status"` ExternalID string `json:"external_id,omitempty"`
Due *time.Time `json:"due,omitempty"` Status string `json:"status"`
Weight int `json:"weight,omitempty"` Due *time.Time `json:"due,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"` Weight int `json:"weight,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,18 +120,26 @@ 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"`
Status string `json:"status,omitempty"` // "" ⇒ open ExternalID string `json:"external_id,omitempty"`
Due *time.Time `json:"due,omitempty"` Status string `json:"status,omitempty"` // "" ⇒ open
Weight int `json:"weight,omitempty"` Due *time.Time `json:"due,omitempty"`
Ts time.Time `json:"ts"` Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
} }
// CaptureTaskResp — Created is false when the same live task already existed, // CaptureTaskResp — Created is false when the same live task already existed,
@@ -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
+24 -21
View File
@@ -254,19 +254,20 @@ 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,
Status: req.Status, ExternalID: req.ExternalID,
Due: req.Due, Status: req.Status,
Weight: req.Weight, Due: req.Due,
Weight: req.Weight,
}) })
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) {
@@ -277,22 +278,24 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
out := make([]Task, len(ts)) out := make([]Task, len(ts))
for i, t := range ts { for i, t := range ts {
out[i] = Task{ out[i] = Task{
ID: t.ID, ID: t.ID,
CreatedTs: t.CreatedTs, CreatedTs: t.CreatedTs,
Text: t.Text, Text: t.Text,
Source: t.Source, Source: t.Source,
Evidence: t.Evidence, Evidence: t.Evidence,
Status: t.Status, ExternalID: t.ExternalID,
Due: t.Due, Status: t.Status,
Weight: t.Weight, Due: t.Due,
Resolved: t.ResolvedTs, Weight: t.Weight,
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)
} }
} }
} }
+86 -17
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,13 +153,23 @@ 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: "что нужно сделать чтобы перезапустить
return true // сервер?" and "what does docker do?" both answered "задач нет." from ahead
} // of recall and the model, which is the failure the source ordering exists
if hasTok(toks, "what") && hasTok(toks, "do") { // to avoid, pointed the other way.
return true //
// 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
}
if hasTok(toks, "what") && hasTok(toks, "do") && hasTok(toks, "i") && !hasTok(toks, "you") {
return true
}
} }
hasNoun := false hasNoun := false
for _, t := range toks { for _, t := range toks {
+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)
}
}
+44 -12
View File
@@ -62,10 +62,13 @@ 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
scorePerWeight = 15 // "срочно" / "важно" / the web form's select // however long the undated one has sat, or the class ordering this block
scorePerWeekOld = 1 // so nothing rots at the bottom forever // claims is inverted by age alone.
scoreAgeCap = 10 scoreDueLater = 12
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
scorePerWeekOld = 1 // so nothing rots at the bottom forever
scoreAgeCap = 10
// MaxWeight — the highest importance hint capture accepts. Three rungs is // MaxWeight — the highest importance hint capture accepts. Three rungs is
// as many as anyone can rank by hand honestly. // as many as anyone can rank by hand honestly.
MaxWeight = 3 MaxWeight = 3
@@ -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)
}
}
+80 -14
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, " и ")