be62660be9
Step 5 of the board build. internal/tasks/stall.go counts three shapes — overdue, sitting longer than StallDays, waiting for confirmation — and states nothing about what any of them means. That is the line internal/memory/behavior.go already drew for habits, and the reason is the same: a 1.7B asked to judge will agree fluently and launder a guess into a decision. A test asserts the wording carries no assessment. Sitting is measured from created_ts, the only clock a live row carries: the store stamps resolved_ts and nothing else. So "no state change in eleven days" is exactly "captured eleven days ago and still live", which is narrower than the plan's wording and is the claim the data supports. A candidate is never counted as overdue, because its due date is Maven's reading of a mail rather than a deadline he set. Not a nag. No tick rule reads the counts; they go on /tasks and into the list reply when he asks, and tickLoop.dayPlan still does not read tasks at all. The empty case renders as nothing: "ничего не залежалось" appended to every list read is a nag with a friendly face. Three say entries, so the page and the spoken list cannot word it differently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
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) 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
|
||
}
|