package main import ( "context" "log" "strings" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" "github.com/kami/maven/internal/tasks" ) // 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) { cap, ok := router.ParseTaskCapture(dec.Utterance) if !ok { return "", false } resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{ Text: cap.Text, Source: "tap:voice", Status: store.TaskOpen, // he stated it himself — not a candidate Weight: cap.Weight, // 0 unless he said "срочно" / "важно" Ts: h.now(), }) if err != nil { log.Printf("voice: capture task: %v", err) return phraser.Ack(phraser.FailTask, nil), 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 phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true } if !resp.Created { return phraser.Ack(phraser.AckTaskDuplicate, nil), true } return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true } // queryTasks — "какие у меня задачи?", "что мне нужно сделать?". // // Reads the live set and recites it in priority order (Vikunja #129). The order // is computed by internal/tasks from what he told her — deadlines, the urgency // he stated, how long a task has been sitting — never asked of the model. The // rendering is the package's too, so the spoken list and the /tasks page can // never disagree about what comes first. func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) { if !router.IsTaskListQuery(t.dec.Utterance) { return "", false } live, err := h.api.ListTasks(ctx, "live") if err != nil { log.Printf("voice: list tasks: %v", err) return "не получилось посмотреть задачи.", true } ranked := tasks.Rank(taskItems(live), h.now()) // Bind what she is about to say, in the order she says it, so "второй" // means the second task he heard (ordinal.go). spoken := tasks.Spoken(ranked) cands := make([]dialogue.Candidate, 0, len(spoken)) for _, r := range spoken { cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) } h.offerCandidates(ctx, cands) reply := tasks.FormatRU(ranked) // The counted shapes, after the list and only when there are any (V-512). // They answer "what is going wrong with this list" without assessing any of // it, and they are said here rather than announced: no tick rule reads them. if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" { if !strings.HasSuffix(reply, ".") { reply += "." } reply += " " + stalls } return reply, true } // resolveTaskStatus moves a task he named out loud (Vikunja #512). // // The position path already worked: resolveCandidate answers "первую сделал" // against the list she just read. This is the other half — naming the task // instead of its position, which reached no code at all before the stage-0 rule // in internal/router/taskstatus.go filled the fn slot. // // Three answers besides the move, and none of them guesses. No match says so. A // match on more than one asks which, because closing the wrong task is work he // never finished being marked done. No task named asks which too, since the // router claims the turn without the referent and the list lives here. func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string { live, err := h.api.ListTasks(ctx, "live") if err != nil { log.Printf("voice: task status: list: %v", err) return "не получилось посмотреть задачи." } if dec.Slots.Text == "" { return "какую задачу?" } match := matchTaskText(live, dec.Slots.Text) switch len(match) { case 0: return "не нашла такой задачи." case 1: default: return "у тебя несколько подходящих — какую именно?" } pick := match[0] status := dec.Slots.Value // A candidate is work Maven proposed and he never confirmed, and the store // refuses candidate → done: the legal move is to open it first. Saying it is // done IS the confirmation, so both writes happen rather than the turn // naming a gap about a distinction he did not make. if pick.Status == store.TaskCandidate && status == store.TaskDone { if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil { log.Printf("voice: task status: promote %d: %v", pick.ID, err) return "не получилось изменить задачу." } } if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil { log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err) return "не получилось изменить задачу." } log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status) if status == store.TaskDropped { return "убрала: " + pick.Text } return "закрыла: " + pick.Text } // matchTaskText finds the live tasks he could have meant. // // Normalised containment, either direction, over store.NormalizeTaskText — the // same key capture dedupes on, so a task he can file twice is a task he can name // twice. Either direction because he shortens what he said ("молоко" for // "купить молоко") as often as he pads it. // // Deliberately not fuzzy. A ranked best guess would always return exactly one // answer, and the one thing this must be able to say is that it is not sure. func matchTaskText(live []ipc.Task, named string) []ipc.Task { want := store.NormalizeTaskText(named) if want == "" { return nil } var out []ipc.Task for _, t := range live { have := store.NormalizeTaskText(t.Text) if have == "" { continue } if strings.Contains(have, want) || strings.Contains(want, have) { out = append(out, t) } } return out } // taskItems maps wire rows onto the ranker's input. Written here rather than in // internal/tasks so the ranker stays a pure package with no ipc (and therefore // no store, and therefore no cgo) dependency — the same posture as // internal/morning and internal/memory. func taskItems(ts []ipc.Task) []tasks.Item { out := make([]tasks.Item, len(ts)) for i, t := range ts { out[i] = tasks.Item{ ID: t.ID, Text: t.Text, Status: t.Status, Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, } } return out }