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))
}