tasks: key derived captures by external id and record who resolved

A task extracted from mail deduped on the live-norm index only, so once he
finished it the row left the live set and the next poll of the same immutable
message re-extracted it as a fresh candidate. mavmaild is a read-only reader
and marks nothing read, so that repeats forever. Derived rows now carry an
ext_id built from the message uid and the extracted span, unique across every
status, while voice keeps live-only norm dedupe because saying an errand again
is the recurrence signal. A derived source can no longer capture straight to
open, and saying a task out loud that Maven had only proposed promotes the
candidate instead of answering that it is already in the list.

SetTaskStatus was classified AuthRead. Resolving a task is not additive, it
erases work off his list, so it is a write, and the row now records the caller
that moved it. ListTasks was unbounded. The list-query matcher claimed any
utterance with "что мне делать", including "с чем мне помочь", and the urgency
stripper matched inside words.

Found in review of #60.
This commit is contained in:
kami
2026-08-01 14:16:39 +04:00
parent 7f42cc73be
commit 708a69375f
14 changed files with 571 additions and 132 deletions
+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))
}