9d80a39a30
The clarify store was keyed per reach in V-466. The dialogue session was not: five call sites read and wrote the constant voiceDialogueID, so anaphora, history and the ordinal candidate list were one slot for the whole daemon. The candidate list is the half that cost something. She recites tasks at the mic, he types "первую сделал" on /chat, and it closes the second task he heard out loud on a surface that never showed him a list. Now every one of those sites reads dialogueIDOf(ctx), which handleText and the voice path already set. resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a typed one. It takes the turn's source now. A row that lies about where it came from is worse than no row. Anaphora across surfaces was the other reading — one continuous conversation with her, any surface. Rejected: a phone open while he talks is the case this box hits, and two clients sharing one slot trample each other.
101 lines
3.8 KiB
Go
101 lines
3.8 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
|
||
"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)
|
||
return tasks.FormatRU(ranked), true
|
||
}
|
||
|
||
// 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
|
||
}
|