Merge branch 'fix/g05' into fix/integrated
This commit is contained in:
+11
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
],
|
||||
"capture_prefixes": [
|
||||
"добавь в задачи",
|
||||
"добавь в тудушки",
|
||||
"добавь в список задач",
|
||||
"добавь в список дел",
|
||||
"добавь в список",
|
||||
@@ -28,6 +29,7 @@
|
||||
"запиши в задачи",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"поставь задачу",
|
||||
"в задачи",
|
||||
"add a task",
|
||||
"add task",
|
||||
|
||||
@@ -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?",
|
||||
"какая погода?",
|
||||
"напомни мне позвонить маме в шесть",
|
||||
"я сделал зарядку",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 "задач"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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, " и ")
|
||||
|
||||
Reference in New Issue
Block a user