190 lines
7.4 KiB
Go
190 lines
7.4 KiB
Go
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, candidate router.ActionCandidate) 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
|
||
}
|