Merge branch 'fix/g05' into fix/integrated
This commit is contained in:
+31
-16
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user