7b2b96b957
A task is not a fact and not a note. A fact is a claim about the world that a correction supersedes; a note is something to recall by meaning. A task is work with a lifecycle, and the read that matters is "everything outstanding right now" — which over an append-only log would mean replaying history on every question. So: a tasks table, migration #14, statuses candidate/open/done/dropped that each move forward exactly once. Dedupe is on normalised text among LIVE rows only, via a partial unique index. That is the property the mail side needs: an extractor may call CaptureTask for every message it reads, as often as it likes, without growing the list — while a weekly errand is still capturable again once the last one is done. Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path (router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never "надо бы поспать"), the /tasks form, and the email reader from #246 when it exists. Mail-derived items set Source "email:<account>", Status "candidate" and Evidence to whatever makes the row reviewable; a candidate is inert until he confirms it on /tasks, and Maven names it as unconfirmed when she recites the list rather than putting words in his mouth. No new intent — the router enum is a contract with the relabelling prompt, so capture rides the note intent and the list rides a query source, both matched deterministically like the calendar and plan matchers already are. Nothing here speaks. No tick rule reads tasks; the list is answered when asked about, which is why /tasks POST is not step-up gated the way /tools and /routines are — a task write moves no boundary. Vikunja #130
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"strings"
|
||
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/store"
|
||
)
|
||
|
||
// Task capture on the voice/chat path (Vikunja #130).
|
||
//
|
||
// Two halves, both deliberately small:
|
||
//
|
||
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||
// store instead of the note store. Anything without an explicit marker is
|
||
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||
// not become a task.
|
||
// - queryTasks is a query source that reads the list back.
|
||
//
|
||
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||
// rule reads the table.
|
||
|
||
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||
text, ok := router.ParseTaskCapture(dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||
Text: text,
|
||
Source: "tap:voice",
|
||
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||
Ts: h.now(),
|
||
})
|
||
if err != nil {
|
||
log.Printf("voice: capture task: %v", err)
|
||
return "не получилось записать задачу.", true
|
||
}
|
||
if !resp.Created {
|
||
return "это уже в списке.", true
|
||
}
|
||
return "записала: " + text, true
|
||
}
|
||
|
||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||
//
|
||
// Reads the live set and recites it. Newest first, which is the order the store
|
||
// returns: this source has no opinion about which task matters more, and
|
||
// pretending otherwise would be a guess. Ranking is Vikunja #129.
|
||
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
tasks, err := h.api.ListTasks(ctx, "live")
|
||
if err != nil {
|
||
log.Printf("voice: list tasks: %v", err)
|
||
return "не получилось посмотреть задачи.", true
|
||
}
|
||
return formatTaskListRU(tasks), true
|
||
}
|
||
|
||
// formatTaskListRU renders the live task list the way Maven says it. Candidates
|
||
// are named as candidates — a task she pulled out of his mail is something she
|
||
// suggests, and saying it in the same breath as work he actually stated would
|
||
// put words in his mouth.
|
||
func formatTaskListRU(tasks []ipc.Task) string {
|
||
var open, cands []string
|
||
for _, t := range tasks {
|
||
switch t.Status {
|
||
case store.TaskCandidate:
|
||
cands = append(cands, t.Text)
|
||
default:
|
||
open = append(open, t.Text)
|
||
}
|
||
}
|
||
if len(open) == 0 && len(cands) == 0 {
|
||
return "задач нет."
|
||
}
|
||
var b strings.Builder
|
||
if len(open) > 0 {
|
||
b.WriteString("в списке: ")
|
||
b.WriteString(strings.Join(open, "; "))
|
||
b.WriteString(".")
|
||
}
|
||
if len(cands) > 0 {
|
||
if b.Len() > 0 {
|
||
b.WriteString(" ")
|
||
}
|
||
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||
b.WriteString(strings.Join(cands, "; "))
|
||
b.WriteString(".")
|
||
}
|
||
return b.String()
|
||
}
|