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 (
"context"
"errors"
"log"
"github.com/kami/maven/internal/ipc"
@@ -28,12 +29,18 @@ import (
// 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.
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 {
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, "в этом месяце"
if window == router.MoneyToday {
if q.Window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня"
}
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)
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)
if q.Income {
reply = val.FormatIncomeRU(phrase)
}
if reply == "" {
return "по тратам пока нечего сказать.", true
}
// A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
// The age is measured from when the figure was last READ, not from when it
// 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
}
// 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 {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
return errors.Is(err, ipc.ErrNoFact)
}
+89
View File
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
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)
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 {
return "это уже в списке.", true
}
+28 -1
View File
@@ -19,6 +19,7 @@ type taskAPI struct {
captured []ipc.CaptureTaskReq
created bool
promoted bool
capErr error
tasks []ipc.Task
@@ -31,7 +32,7 @@ func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.Ca
if a.capErr != nil {
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) {
@@ -225,3 +226,29 @@ func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
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.
//
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
// and the subject as Evidence. CaptureTask dedupes on normalised text among
// live rows, so a mailbox re-read after a restart produces Created=0 rather
// than a second copy of every task.
// and the subject as Evidence, under an ExternalID naming the message and the
// span it was extracted from. That key is unique over every row whatever its
// 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) {
msg := email.Message{
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.
Status: store.TaskCandidate,
}
t.ExternalID = mailExternalID(source, req.UID, c.Text)
if due, ok := email.ParseDue(c.Due); ok {
t.Due = &due
}
id, created, err := m.st.CaptureTask(ctx, t)
res, err := m.st.CaptureTask(ctx, t)
if err != nil {
return resp, fmt.Errorf("mail intake: capture: %w", err)
}
resp.TaskIDs = append(resp.TaskIDs, id)
if created {
resp.TaskIDs = append(resp.TaskIDs, res.ID)
if res.Created {
resp.Created++
// Only a row that was actually created. CaptureTask dedupes on
// 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]) + "…"
}
// 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 (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"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
// 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.
type moneyWindow struct {
key string
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
}
continue
}
val, ok := sum.Value()
val, ok := sum.Value(now)
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
}
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
}
}
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean.
func isNoFact(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
// writeMoneyFact writes a money fact every poll, with no value comparison. See
// the comment above pollZenmoney for why this one does not go through
// writeIfChangedRaw.
//
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
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) {
+49 -21
View File
@@ -18,6 +18,7 @@ import (
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"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
// template holds no date logic.
type taskRow struct {
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
ID int64
Text string
Source string
Evidence string
Status string
Due string
Created string
Resolved string
ResolvedBy string
// Why — the ranker's reason for this row's position (Vikunja #129), in
// Russian, empty when nothing distinguished the task. Blank is the honest
// 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.
var live []tasks.Item
var resolved []taskRow
resolvedTotal := 0
for _, t := range all {
switch t.Status {
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,
})
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{
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
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
}
var cands, open []taskRow
for _, r := range tasks.Rank(live, time.Now()) {
for _, r := range tasks.Rank(live, now()) {
t := byID[r.ID]
row := taskRow{
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")
if err := tasksTmpl.Execute(w, struct {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
}{msg, errMsg, cands, open, resolved}); err != nil {
Msg, Err string
Candidates []taskRow
Open []taskRow
Resolved []taskRow
ResolvedMore bool
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
log.Printf("tasks render: %v", err)
}
}
@@ -1020,12 +1043,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
if 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
// clamped rather than rejected — a bad select is not worth a 400.
if v := r.FormValue("weight"); v != "" {
var wgt int
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
// 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)
}
if wgt > tasks.MaxWeight {
@@ -1034,7 +1059,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
req.Weight = wgt
}
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 {
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 {
return "", err
}
if resp.Promoted {
return "confirmed a candidate maven had found", nil
}
if !resp.Created {
return "already on the list", nil
}
return "added task", nil
}
var id int64
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil {
return "", errors.New("invalid id")
}
var status, msg string
@@ -1065,7 +1093,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
default:
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 msg, nil
+7 -2
View File
@@ -9,6 +9,9 @@
<input type=hidden name=action value=add>
<input type=text name=text placeholder="что нужно сделать" size=44 required>
<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)">
<option value=0>normal</option>
<option value=2>важно</option>
@@ -43,7 +46,7 @@
<section class=card>
<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>
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
{{range .Open}}<tr>
@@ -72,12 +75,14 @@
<section class=card>
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
<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>
<td class=text-max>{{.Text}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=muted>{{.Resolved}}</td>
<td class=hint>{{.ResolvedBy}}</td>
</tr>{{end}}</table></div>
{{if .ResolvedMore}}<div class=hint>only the {{len .Resolved}} most recent are shown.</div>{{end}}
</section>
{{end}}
{{template "shellBottom"}}
+119 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
statusID int64
statusVal string
statusBy string
statusErr error
promoted bool
}
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 {
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 {
f.statusID, f.statusVal = id, status
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
f.statusID, f.statusVal, f.statusBy = id, status, by
return f.statusErr
}
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
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
case ipc.MethodWriteFact:
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:
return AuthRead
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
// for Maven to speak — nothing in the tick loop reads tasks. It stays
// 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.MethodListTasks,
ipc.MethodSetTaskStatus,
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
// 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
+38 -21
View File
@@ -101,15 +101,17 @@ type WriteFactReq struct {
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
// task came from, empty for anything he stated himself.
type Task struct {
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Resolved *time.Time `json:"resolved,omitempty"`
ID int64 `json:"id"`
CreatedTs time.Time `json:"created_ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status"`
Due *time.Time `json:"due,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
@@ -118,18 +120,26 @@ type Task struct {
//
// An extractor that reads mail sets Source "email:<account>", Status
// "candidate", and Evidence to whatever makes the task reviewable (the subject
// line). It must NOT set Status "open" — work Maven inferred from something she
// read is a suggestion until the owner confirms it on the /tasks page. Capture
// is idempotent on normalised text among live tasks, so re-reading the same
// mailbox is free.
// 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, and the
// store refuses an open capture from a derived source rather than trusting the
// 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 {
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
Text string `json:"text"`
Source string `json:"source"`
Evidence string `json:"evidence,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Status string `json:"status,omitempty"` // "" ⇒ open
Due *time.Time `json:"due,omitempty"`
Weight int `json:"weight,omitempty"`
Ts time.Time `json:"ts"`
}
// CaptureTaskResp — Created is false when the same live task already existed,
@@ -138,6 +148,10 @@ type CaptureTaskReq struct {
type CaptureTaskResp struct {
ID int64 `json:"id"`
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
@@ -400,6 +414,9 @@ type setTaskStatusReq struct {
ID int64 `json:"id"`
Status string `json:"status"`
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.
@@ -631,7 +648,7 @@ type CoreAPI interface {
ListTasks(ctx context.Context, status string) ([]Task, error)
// SetTaskStatus moves a task forward once: candidate→open|dropped,
// 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
// 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
}
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
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, By: by}, nil)
}
// 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) {
id, created, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
res, err := a.s.CaptureTask(ctx, store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
ExternalID: req.ExternalID,
Status: req.Status,
Due: req.Due,
Weight: req.Weight,
})
if err != nil {
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) {
@@ -277,22 +278,24 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
out := make([]Task, len(ts))
for i, t := range ts {
out[i] = Task{
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ID: t.ID,
CreatedTs: t.CreatedTs,
Text: t.Text,
Source: t.Source,
Evidence: t.Evidence,
ExternalID: t.ExternalID,
Status: t.Status,
Due: t.Due,
Weight: t.Weight,
Resolved: t.ResolvedTs,
ResolvedBy: t.ResolvedBy,
}
}
return out, nil
}
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
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, by))
}
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
@@ -871,7 +874,7 @@ var methodTable = map[Method]handlerFunc{
return listTasksResp{Tasks: out}, nil
}),
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) {
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) {
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
}
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
+45 -12
View File
@@ -16,12 +16,29 @@ const (
MoneyNone MoneyWindow = iota
MoneyToday
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.
var moneyNouns = []string{
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
"заработал", "потрачено", енег", "spend", "spent", "expenses",
"заработал", "заработала", оход", "доходы", "потрачено", "денег",
"spend", "spent", "expenses", "earned", "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 — "я потратил весь день
// на это" is him talking about his day, so an amount word or an explicit
// question word has to be there too.
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
// question word has to be there too. The two evidence halves are INDEPENDENT:
// "траты" 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)
if len(toks) == 0 {
return MoneyNone, false
return MoneyQuery{}, false
}
hasNoun := false
for _, t := range toks {
@@ -45,27 +65,40 @@ func ParseMoneyQuery(text string) (MoneyWindow, bool) {
}
}
if !hasNoun {
return MoneyNone, false
return MoneyQuery{}, false
}
// "весь день", "время", "силы" — spending that is not money.
for _, t := range toks {
switch t {
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
return MoneyNone, false
return MoneyQuery{}, false
}
}
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
hasTok(toks, "мои")
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)
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"):
return MoneyToday, true
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
return MoneyMonth, true
return MoneyQuery{Window: MoneyToday, Income: income}, 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},
{"how much did I spend today", MoneyToday, 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.
{"я потратил весь день на это", 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},
}
for _, c := range cases {
w, ok := ParseMoneyQuery(c.in)
if ok != c.ok || w != c.window {
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
q, ok := ParseMoneyQuery(c.in)
if ok != c.ok || q.Window != c.window {
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.TrimLeft(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)
if rest == "" {
return TaskCapture{}, false
@@ -55,30 +58,86 @@ func ParseTaskCapture(text string) (TaskCapture, bool) {
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
// and returns the weight it implies. Only at the edges: "срочно оплатить
// интернет" and "оплатить интернет срочно" are the same instruction, while
// "позвонить в срочную помощь" is a task whose text happens to contain the
// 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 "оплатить
// интернет (важно)" and not "важно оплатить интернет (важно)".
func stripUrgency(text string) (string, int) {
fields := strings.Fields(text)
if len(fields) == 0 {
return text, 0
}
for _, m := range urgencyMarkers {
lower := strings.ToLower(text)
switch {
case strings.HasPrefix(lower, m.Word+" "):
return strings.TrimSpace(text[len(m.Word):]), m.Weight
case strings.HasSuffix(lower, " "+m.Word):
return strings.TrimSpace(text[:len(text)-len(m.Word)]), m.Weight
case lower == m.Word:
// Nothing but the marker — no task in it.
return "", 0
// Strongest marker first (task_phrases.go sorts them), leading edge
// before trailing, so a text carrying both keeps the stronger one.
if lo, hi, ok := urgencySpan(fields, m.Word); ok {
rest := strings.Join(append(append([]string{}, fields[:lo]...), fields[hi+1:]...), " ")
rest = strings.Trim(rest, urgencyEdgeTrim)
if rest == "" {
// Nothing but the marker — no task in it.
return "", 0
}
return rest, m.Weight
}
}
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
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
//
@@ -94,13 +153,23 @@ func IsTaskListQuery(text string) bool {
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
return false
}
// "что мне нужно сделать" / "что мне делать" — no task noun at all.
if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
return true
}
if hasTok(toks, "what") && hasTok(toks, "do") {
return true
// "что мне нужно сделать" / "чем мне заняться" — no task noun at all, so
// the pronoun is what carries the meaning. Without it these rules claimed
// every question with a verb in them: "что нужно сделать чтобы перезапустить
// сервер?" and "what does docker do?" both answered "задач нет." from ahead
// of recall and the model, which is the failure the source ordering exists
// to avoid, pointed the other way.
//
// A "с"/"со" object excludes them too: "что мне сделать с этим файлом" has
// the pronoun and is still a question about a file.
if !hasTok(toks, "с") && !hasTok(toks, "со") {
if hasTok(toks, "мне") && (hasTok(toks, "что") || hasTok(toks, "чем")) &&
(hasTok(toks, "сделать") || hasTok(toks, "делать") || hasTok(toks, "заняться")) {
return true
}
if hasTok(toks, "what") && hasTok(toks, "do") && hasTok(toks, "i") && !hasTok(toks, "you") {
return true
}
}
hasNoun := false
for _, t := range toks {
+2
View File
@@ -21,6 +21,7 @@
],
"capture_prefixes": [
"добавь в задачи",
"добавь в тудушки",
"добавь в список задач",
"добавь в список дел",
"добавь в список",
@@ -28,6 +29,7 @@
"запиши в задачи",
"запиши задачу",
"новая задача",
"поставь задачу",
"в задачи",
"add a task",
"add task",
+19
View File
@@ -20,6 +20,17 @@ func TestParseTaskCapture(t *testing.T) {
{"новая задача важно позвонить маме", "позвонить маме", 2, true},
// The stem inside the task text is part of the task, not a marker.
{"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 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.
{"добавь в задачи", "", 0, false},
{"новая задача", "", 0, false},
@@ -47,6 +58,7 @@ func TestIsTaskListQuery(t *testing.T) {
"задачи",
"мои задачи",
"what should I do",
"что мне делать?",
}
for _, s := range yes {
if !IsTaskListQuery(s) {
@@ -55,6 +67,13 @@ func TestIsTaskListQuery(t *testing.T) {
}
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 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
+135 -34
View File
@@ -48,16 +48,34 @@ const (
//
// Due is optional. Weight is an explicit importance hint (0 = none), which the
// 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 {
ID int64
CreatedTs time.Time
Text string
Source string
Evidence string
ExternalID string
Status string
Due *time.Time
Weight int
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 (
@@ -69,19 +87,49 @@ var (
// liveTaskStatuses — the two statuses that count as outstanding work.
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
// CaptureTask inserts a task, or returns the existing live task when the same
// work is already outstanding. created reports which happened, so a caller can
// tell the owner "уже в списке" instead of pretending it wrote something.
// derivedSourcePrefixes — provenance that means "Maven read this somewhere",
// as opposed to "he said it". A task from one of these is a candidate and
// 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
// 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
// property the email intake depends on — it may call CaptureTask for every
// message it extracts from, as often as it likes, without growing the list.
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
// Two dedupe keys, because voice and mail have different intake semantics:
//
// - ExternalID, unique over EVERY row whatever its status. A source that
// re-reads the same immutable text forever must never resurrect work he has
// already finished. This is the property the email intake depends on: it
// 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)
if text == "" {
return 0, false, ErrTaskEmpty
return CaptureResult{}, ErrTaskEmpty
}
status := t.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 {
// Capturing straight into a resolved state is meaningless — a task is
// 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)
created2 := t.CreatedTs
@@ -101,31 +152,69 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool
if t.Due != nil {
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,
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
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 {
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 {
id, err := res.LastInsertId()
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.
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
if err != nil {
return 0, false, err
// Something already holds one of the keys. External identity first: that
// row may be resolved, in which case the answer is "already handled", not a
// new task.
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.
@@ -142,7 +231,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
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.
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
}
// ListTasks returns tasks in one status, newest first. An empty status returns
// every row; "live" returns candidate + open, which is what every read path
// that means "outstanding work" wants.
// MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a
// person keeps by hand and never approaches this; the resolved set grows for as
// 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) {
q := taskSelect
var args []any
@@ -172,7 +267,7 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
q += ` WHERE 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...)
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
// tools use: an answered question is not answered twice.
//
// Resolving frees the 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 {
// Resolving frees the NORM dedupe key, which is the point: the work can recur
// 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
switch status {
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}
}
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) + `)`
args := []any{status, resolved, id}
args := []any{status, resolved, by, id}
for _, f := range from {
args = append(args, f)
}
@@ -271,7 +372,7 @@ func scanTask(sc scanner) (Task, error) {
var t Task
var created int64
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
}
t.CreatedTs = time.UnixMilli(created).UTC()
+182 -27
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"errors"
"fmt"
"testing"
"time"
)
@@ -12,24 +13,24 @@ func TestCaptureTaskDedupesLiveWork(t *testing.T) {
st := newTestStore(t)
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 {
t.Fatal(err)
}
if !created {
if !first.Created {
t.Fatal("first capture must create a row")
}
// 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 {
t.Fatal(err)
}
if created {
if again.Created {
t.Error("second capture of the same live work must not create a row")
}
if again != id {
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
if again.ID != first.ID {
t.Errorf("dedupe returned id %d, want the existing %d", again.ID, first.ID)
}
live, err := st.ListTasks(ctx, "live")
@@ -46,20 +47,22 @@ func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
st := newTestStore(t)
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 {
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)
}
// 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 {
t.Fatal(err)
}
if !created || id2 == id {
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
id2 := second.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")
if err != nil {
@@ -76,7 +79,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour)
id, _, err := st.CaptureTask(ctx, Task{
res, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку",
Source: "email:kami",
Evidence: "Re: страховой полис истекает",
@@ -88,7 +91,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
if err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, id)
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
@@ -114,22 +117,23 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
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, CreatedTs: now})
res, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
if err != nil {
t.Fatal(err)
}
cand := res.ID
// 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)
}
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)
}
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)
}
// 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)
}
got, err := st.LookupTask(ctx, cand)
@@ -147,14 +151,15 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
ctx := context.Background()
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 {
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)
}
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)
}
}
@@ -162,7 +167,7 @@ func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
ctx := context.Background()
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)
}
}
@@ -172,10 +177,10 @@ func TestListTasksFiltersByStatus(t *testing.T) {
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
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)})
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 {
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)})
done, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
if err := st.SetTaskStatus(ctx, done.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
@@ -190,7 +195,7 @@ func TestListTasksFiltersByStatus(t *testing.T) {
if err != nil {
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)
}
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
scoreDueTomorrow = 40
scoreDueWeek = 20
scoreDueLater = 5
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
scorePerWeekOld = 1 // so nothing rots at the bottom forever
scoreAgeCap = 10
// Above scoreAgeCap on purpose: a dated task must outrank an undated one
// however long the undated one has sat, or the class ordering this block
// claims is inverted by age alone.
scoreDueLater = 12
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
scorePerWeekOld = 1 // so nothing rots at the bottom forever
scoreAgeCap = 10
// MaxWeight — the highest importance hint capture accepts. Three rungs is
// as many as anyone can rank by hand honestly.
MaxWeight = 3
@@ -141,7 +144,13 @@ func score(it Item, now time.Time) (float64, string) {
if w > 0 {
total += float64(w * scorePerWeight)
if reason == "" {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
reason = "важно"
if w >= MaxWeight {
reason = "срочно"
}
}
}
@@ -161,15 +170,23 @@ func score(it Item, now time.Time) (float64, string) {
return total, reason
}
// dayDelta — calendar days from now to due, in due's own location. Whole days,
// not hours: a task due today is due today whether it is 09:00 or 23:00, and an
// dayDelta — calendar days from now to due, in NOW's location. Whole days, not
// hours: a task due today is due today whether it is 09:00 or 23:00, and an
// hours-based comparison would call this evening's task "overdue" all afternoon.
//
// The location has to come from now. A due date read back from the store is a
// UTC instant (store.scanTask ends in time.UnixMilli(...).UTC()), so taking the
// location from it compared calendar days in UTC while the page rendered the
// same date in local time. East of Greenwich that is off by one all morning: a
// task due tomorrow read "сегодня", and on its due date it read "просрочено на
// день" and scored 105 instead of 60, one table cell away from a due column
// that said otherwise.
func dayDelta(due, now time.Time) int {
loc := due.Location()
d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc)
n := now.In(loc)
n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
return int(d.Sub(n).Hours() / 24)
loc := now.Location()
d := due.In(loc)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
nn := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
return int(dd.Sub(nn).Hours() / 24)
}
// SpokenLimit — how many tasks the spoken list names before it summarises the
@@ -232,7 +249,22 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
}
s := strings.Join(parts, "; ")
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
}
// 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)
}
}
// 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"
"sort"
"strings"
"sync"
"time"
)
@@ -40,6 +41,14 @@ type Client struct {
BaseURL string
Token string
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
@@ -124,14 +133,77 @@ type Summary struct {
// since then, which for a "this month" window is everything filed this month.
// 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
// records cannot inflate this month's total. The reverse case (a transaction
// dated this month, filed and last changed before `from`) cannot exist.
// records cannot inflate this month's total.
//
// 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) {
resp, err := c.diff(ctx, from.Unix())
if err != nil {
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) {
@@ -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
// his own money between his own accounts is not spending), and anything dated
// outside the window.
func summarize(resp diffResponse, from, to time.Time) Summary {
cur := map[int64]string{}
for _, in := range resp.Instrument {
cur[in.ID] = in.ShortTitle
}
func summarize(resp diffResponse, cur map[int64]string, from, to time.Time) Summary {
spent := map[string]float64{}
earned := map[string]float64{}
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
// the amount rather than dropping it: a sum without a currency label is still
// his money, and silently discarding it would understate the total.
// its bucket in the Summary rather than being folded into a named one: a sum is
// only checkable against his bank if every amount in it is in one currency.
func currency(names map[int64]string, id int64) string {
if s := names[id]; s != "" {
return s
}
return "?"
return UnknownCurrency
}
// 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() {
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")
}
}
@@ -138,7 +138,7 @@ func TestMonthAndDayWindows(t *testing.T) {
func TestFactValueRoundTripAndFormat(t *testing.T) {
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 {
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.
const Source = "poll:zenmoney"
// FactValue — the JSON stored in a money fact. A wire shape of its own rather
// 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.
// FactValue — the JSON stored in a money fact.
//
// 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 {
Spent []Money `json:"spent"`
Earned []Money `json:"earned"`
Count int `json:"count"`
Spent []Money `json:"spent"`
Earned []Money `json:"earned"`
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
// summary: no transactions read means no fact written, so that a failed or
// empty poll can never be recited back to him as a zero.
func (s Summary) Value() (string, bool) {
// Value encodes the summary for the facts table, stamped with the instant it
// was read. Returns ok=false for an empty summary: no transactions read means
// no fact written, so that a failed or empty poll can never be recited back to
// him as a zero.
func (s Summary) Value(asOf time.Time) (string, bool) {
if s.Empty() {
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 {
return "", false
}
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.
func ParseFactValue(raw string) (FactValue, error) {
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
// spending is exactly the nagging Maven is not for.
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 {
return ""
}
var parts []string
spent, earned := "", ""
if len(v.Spent) > 0 {
parts = append(parts, "потратил "+joinMoney(v.Spent))
if s := joinMoney(v.Spent); s != "" {
spent = "потратил " + s
}
}
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 {
return ""
@@ -75,9 +133,17 @@ func (v FactValue) FormatRU(window string) string {
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 {
parts := make([]string, 0, len(ms))
for _, m := range ms {
if m.Currency == UnknownCurrency || m.Currency == "" {
continue
}
parts = append(parts, fmt.Sprintf("%s %s", formatAmount(m.Amount), m.Currency))
}
return strings.Join(parts, " и ")