Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6ccf9aea | |||
| 7b2b96b957 |
@@ -11,6 +11,12 @@ import (
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
|
||||
@@ -56,6 +56,12 @@ var querySources = []querySource{
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{"habits", (*reactiveHandler).queryHabits},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{"tasks", (*reactiveHandler).queryTasks},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"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 "не получилось записать задачу.", true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return "записала: " + 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
|
||||
}
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), 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
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// taskAPI answers only the three task methods; every other call is
|
||||
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||
// particular no embedder, so a filed task costs no model call.
|
||||
type taskAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
capErr error
|
||||
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
a.captured = append(a.captured, req)
|
||||
if a.capErr != nil {
|
||||
return ipc.CaptureTaskResp{}, a.capErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
|
||||
}
|
||||
|
||||
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
a.listArg = status
|
||||
return a.tasks, a.listErr
|
||||
}
|
||||
|
||||
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: taskNow}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if len(api.captured) != 1 {
|
||||
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "купить молоко" {
|
||||
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||
}
|
||||
if got.Source != "tap:voice" {
|
||||
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||
}
|
||||
if !got.Ts.Equal(taskNow()) {
|
||||
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A note is still a note: capture only fires on an explicit marker, so
|
||||
// ordinary recall is untouched.
|
||||
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||
t.Errorf("%q was captured as a task", u)
|
||||
}
|
||||
}
|
||||
if len(api.captured) != 0 {
|
||||
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{created: false})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||
if !ok {
|
||||
t.Fatal("expected the capture path to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "уже") {
|
||||
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the task source must claim a task-list question")
|
||||
}
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want both tasks", reply)
|
||||
}
|
||||
// The candidate must be named as unconfirmed, not recited as his work.
|
||||
openIdx := strings.Index(reply, "купить молоко")
|
||||
candIdx := strings.Index(reply, "продлить страховку")
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||
}); !ok {
|
||||
t.Fatal("expected a capture")
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "оплатить интернет" {
|
||||
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||
}
|
||||
if got.Weight == 0 {
|
||||
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||
// comes before undated work.
|
||||
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||
due := taskNow()
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the dated task first", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "сегодня") {
|
||||
t.Errorf("reply = %q, want the reason named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksEmptyList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{})
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the task source to claim it")
|
||||
}
|
||||
if reply != "задач нет." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the task source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.listArg != "" {
|
||||
t.Error("a non-task question must not read the task list")
|
||||
}
|
||||
}
|
||||
|
||||
// The chain must reach the task source before the recall sources, or "что мне
|
||||
// нужно сделать?" gets answered by whatever note is nearest.
|
||||
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||
var tasksAt, notesAt = -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "tasks":
|
||||
tasksAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if tasksAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||
}
|
||||
if tasksAt > notesAt {
|
||||
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
@@ -58,6 +59,9 @@ var notificationsHTML string
|
||||
//go:embed reminders.html
|
||||
var remindersHTML string
|
||||
|
||||
//go:embed tasks.html
|
||||
var tasksHTML string
|
||||
|
||||
//go:embed voice.html
|
||||
var voiceHTML string
|
||||
|
||||
@@ -97,6 +101,7 @@ var sidebarSections = []struct {
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
@@ -170,6 +175,8 @@ func pageIcon(key string) string {
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
case "notifications":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||
case "tasks":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "reminders":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
case "routines":
|
||||
@@ -202,6 +209,8 @@ func pageTitle(key string) string {
|
||||
return "Rule Trace"
|
||||
case "notifications":
|
||||
return "Notifications"
|
||||
case "tasks":
|
||||
return "Tasks"
|
||||
case "reminders":
|
||||
return "Reminders"
|
||||
case "routines":
|
||||
@@ -408,6 +417,11 @@ func main() {
|
||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleReminders(w, r, core)
|
||||
})
|
||||
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
||||
// why a task write is not in the same class as /tools or /routines.
|
||||
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleTasks(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleMorning(w, r, core)
|
||||
})
|
||||
@@ -744,6 +758,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
|
||||
|
||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||
|
||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||
|
||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||
|
||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||
@@ -814,6 +830,187 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||
// template holds no date logic.
|
||||
type taskRow struct {
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the four writes it
|
||||
// offers (POST): add, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg, errMsg string
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
// and the voice reply can never disagree about what comes first. Resolved
|
||||
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||
var live []tasks.Item
|
||||
var resolved []taskRow
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "candidate", "open":
|
||||
live = append(live, tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
})
|
||||
default:
|
||||
resolved = append(resolved, taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
})
|
||||
}
|
||||
}
|
||||
byID := make(map[int64]ipc.Task, len(all))
|
||||
for _, t := range all {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
var cands, open []taskRow
|
||||
for _, r := range tasks.Rank(live, time.Now()) {
|
||||
t := byID[r.ID]
|
||||
row := taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
Why: r.Reason,
|
||||
}
|
||||
if t.Status == "candidate" {
|
||||
// A candidate's due date is Maven's reading of a mail, so its
|
||||
// ranking reason is not shown as if he had set a priority.
|
||||
row.Why = ""
|
||||
cands = append(cands, row)
|
||||
} else {
|
||||
open = append(open, row)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tasksTmpl.Execute(w, struct {
|
||||
Msg, Err string
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||
log.Printf("tasks render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyTaskPost performs one write and returns the message to show. A bad
|
||||
// request returns an error, which the page renders inline rather than as a
|
||||
// bare 400 — this is a form surface, not an API.
|
||||
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
action := r.FormValue("action")
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
var wgt int
|
||||
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
req.Weight = wgt
|
||||
}
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
req.Due = &due
|
||||
}
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Created {
|
||||
return "already on the list", nil
|
||||
}
|
||||
return "added task", nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
status, msg = "open", "confirmed task"
|
||||
case "done":
|
||||
status, msg = "done", "task done"
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
|
||||
func fmtTaskDate(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan")
|
||||
}
|
||||
|
||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it.
|
||||
type routineRow struct {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{{template "shellTop" "tasks"}}
|
||||
<h1>Tasks</h1>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>add</h2>
|
||||
<form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=action value=add>
|
||||
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||
<input type=date name=due title="due date (optional)">
|
||||
<select name=weight title="importance (optional)">
|
||||
<option value=0>normal</option>
|
||||
<option value=2>важно</option>
|
||||
<option value=3>срочно</option>
|
||||
</select>
|
||||
<button class=btn>add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Candidates}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Candidates}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=confirm>
|
||||
<button class=btn>confirm</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=drop>
|
||||
<button class="btn btn-muted">drop</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Why}}</td>
|
||||
<td class=hint>{{.Source}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=done>
|
||||
<button class=btn>done</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=drop>
|
||||
<button class="btn btn-muted">drop</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||
<div>no open tasks</div>
|
||||
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Resolved}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||
{{range .Resolved}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=muted>{{.Resolved}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
|
||||
// writes the page made.
|
||||
type fakeTaskCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
tasks []ipc.Task
|
||||
listErr error
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
captureErr error
|
||||
|
||||
statusID int64
|
||||
statusVal string
|
||||
statusErr error
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.tasks, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
f.captured = append(f.captured, req)
|
||||
if f.captureErr != nil {
|
||||
return ipc.CaptureTaskResp{}, f.captureErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||
f.statusID, f.statusVal = id, status
|
||||
return f.statusErr
|
||||
}
|
||||
|
||||
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
resolved := now.Add(time.Hour)
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
|
||||
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
|
||||
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"купить молоко", "продлить страховку", "полить цветы",
|
||||
"полис истекает", // the evidence trail is visible for review
|
||||
"found, not confirmed", // candidates get their own section
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
// The candidate must offer confirm, and the open task must not.
|
||||
if !strings.Contains(body, "value=confirm") {
|
||||
t.Error("candidate row has no confirm action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if len(core.captured) != 1 {
|
||||
t.Fatalf("captured %d requests, want 1", len(core.captured))
|
||||
}
|
||||
got := core.captured[0]
|
||||
if got.Text != "позвонить в банк" {
|
||||
t.Errorf("text = %q, want trimmed", got.Text)
|
||||
}
|
||||
if got.Source != "tap:web" {
|
||||
t.Errorf("source = %q, want tap:web", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v", got.Due)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "added task") {
|
||||
t.Error("no confirmation message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
|
||||
core := &fakeTaskCore{created: false}
|
||||
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if !strings.Contains(rec.Body.String(), "already on the list") {
|
||||
t.Error("a deduped capture must not claim it saved something new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksStatusActions(t *testing.T) {
|
||||
for _, tc := range []struct{ action, want string }{
|
||||
{"confirm", "open"},
|
||||
{"done", "done"},
|
||||
{"drop", "dropped"},
|
||||
} {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {tc.action}, "id": {"42"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.statusID != 42 || core.statusVal != tc.want {
|
||||
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {"explode"}, "id": {"1"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
// The page still renders, with the error inline — and nothing was written.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if core.statusVal != "" || len(core.captured) != 0 {
|
||||
t.Error("an unknown action must write nothing")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "unknown action") {
|
||||
t.Error("error not surfaced on the page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksNoCore(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The open list is ordered by the ranker, and the reason is shown so the page
|
||||
// says why a task is first instead of asking him to trust the order.
|
||||
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
|
||||
now := time.Now()
|
||||
due := now
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
|
||||
t.Error("want the dated task rendered first")
|
||||
}
|
||||
if !strings.Contains(body, "сегодня") {
|
||||
t.Error("want the ranker's reason shown in the why column")
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate is ranked into place but never carries a priority reason: its due
|
||||
// date is Maven's reading of a mail, not something he stated.
|
||||
func TestHandleTasksHidesCandidateReason(t *testing.T) {
|
||||
now := time.Now()
|
||||
due := now
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
if strings.Contains(rec.Body.String(), "сегодня") {
|
||||
t.Error("a candidate must not be shown with a priority reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
|
||||
t.Fatalf("captured = %+v, want weight 3", core.captured)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.captured[0].Weight != tasks.MaxWeight {
|
||||
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -65,7 +65,16 @@ func Requirement(m ipc.Method) Authority {
|
||||
ipc.MethodCreateReminder,
|
||||
ipc.MethodMarkReminder,
|
||||
ipc.MethodRecordNudge,
|
||||
ipc.MethodResolveNudge:
|
||||
ipc.MethodResolveNudge,
|
||||
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||
// the default so the intent is on the record: capturing a task is a
|
||||
// module write, not an allowlist mutation and not a new standing reason
|
||||
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||
// existing analogue.
|
||||
ipc.MethodCaptureTask,
|
||||
ipc.MethodListTasks,
|
||||
ipc.MethodSetTaskStatus:
|
||||
return AuthRead
|
||||
}
|
||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||
|
||||
@@ -93,6 +93,63 @@ type WriteFactReq struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// Task — one captured piece of work (Vikunja #130). Status is
|
||||
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
|
||||
// "done" or "dropped". Source is provenance in the facts vocabulary:
|
||||
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
|
||||
// task came from, empty for anything he stated himself.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedTs time.Time `json:"created_ts"`
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
// through this one shape: the voice path, the web form, and (Vikunja #246) the
|
||||
// email reader, which has not been built yet.
|
||||
//
|
||||
// An extractor that reads mail sets Source "email:<account>", Status
|
||||
// "candidate", and Evidence to whatever makes the task reviewable (the subject
|
||||
// line). It must NOT set Status "open" — work Maven inferred from something she
|
||||
// read is a suggestion until the owner confirms it on the /tasks page. Capture
|
||||
// is idempotent on normalised text among live tasks, so re-reading the same
|
||||
// mailbox is free.
|
||||
type CaptureTaskReq struct {
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "" ⇒ open
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
// in which case ID is the existing row. A caller tells the owner "уже в
|
||||
// списке" rather than claiming it saved something new.
|
||||
type CaptureTaskResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
type listTasksResp struct {
|
||||
Tasks []Task `json:"tasks"`
|
||||
}
|
||||
type setTaskStatusReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -294,6 +351,18 @@ type CoreAPI interface {
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
|
||||
@@ -69,6 +69,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodLookupTool: true,
|
||||
MethodListTools: true,
|
||||
MethodListProposedRoutines: true,
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodDayPlan: true,
|
||||
@@ -427,6 +428,26 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
||||
return r.Routines, nil
|
||||
}
|
||||
|
||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
var r CaptureTaskResp
|
||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||
return CaptureTaskResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
var r listTasksResp
|
||||
if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
@@ -231,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DeleteTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
id, created, err := a.s.CaptureTask(ctx, store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
}
|
||||
return CaptureTaskResp{ID: id, Created: created}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
Source: t.Source,
|
||||
Evidence: t.Evidence,
|
||||
Status: t.Status,
|
||||
Due: t.Due,
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -700,6 +742,22 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||
return api.DeleteTool(ctx, p.Name)
|
||||
}),
|
||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return api.CaptureTask(ctx, p)
|
||||
}),
|
||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||
out, err := api.ListTasks(ctx, p.Status)
|
||||
if err != nil {
|
||||
return listTasksResp{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Task{}
|
||||
}
|
||||
return listTasksResp{Tasks: out}, nil
|
||||
}),
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
|
||||
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return CaptureTaskResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// Task capture and task listing, matched deterministically (Vikunja #130).
|
||||
//
|
||||
// No new intent. The router's intent enum is a contract shared with the
|
||||
// relabelling prompt in the training workspace (`llm/check_prompt_parity.py`
|
||||
// enforces it), so adding an eighth intent would mean retraining before a task
|
||||
// could be captured at all. A task phrased out loud is a note-shaped or
|
||||
// query-shaped utterance with an explicit marker in it, and the marker is a
|
||||
// lookup — the same reasoning the calendar, plan and habit matchers already
|
||||
// follow. What the model classifies is unchanged; what these functions decide
|
||||
// is which store the turn lands in.
|
||||
|
||||
// taskCapturePrefixes — the leading phrases that mean "put this on the list".
|
||||
// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
|
||||
// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
|
||||
// position tells them apart.
|
||||
//
|
||||
// Everything here is an explicit instruction. There is deliberately no entry
|
||||
// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
|
||||
// files, and a capture path that guesses would fill the list with his moods.
|
||||
var taskCapturePrefixes = []string{
|
||||
"добавь в задачи",
|
||||
"добавь в список задач",
|
||||
"добавь в список дел",
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"в задачи",
|
||||
"add a task",
|
||||
"add task",
|
||||
"add to my tasks",
|
||||
"add to tasks",
|
||||
"new task",
|
||||
}
|
||||
|
||||
// TaskCapture — a parsed capture: the task itself, plus the importance he
|
||||
// stated out loud if he stated one (Vikunja #129). Weight 0 means he said
|
||||
// nothing about importance, which the ranker treats as exactly that — no
|
||||
// urgency is inferred from the wording.
|
||||
type TaskCapture struct {
|
||||
Text string
|
||||
Weight int
|
||||
}
|
||||
|
||||
// urgencyMarkers — the words that set a weight, strongest first. Only these
|
||||
// two rungs: "срочно" is a deadline he has not named, "важно" is a preference,
|
||||
// and a third shade of urgent would be a distinction he never makes out loud.
|
||||
var urgencyMarkers = []struct {
|
||||
word string
|
||||
weight int
|
||||
}{
|
||||
{"срочно", 3},
|
||||
{"urgent", 3},
|
||||
{"важно", 2},
|
||||
{"important", 2},
|
||||
}
|
||||
|
||||
// ParseTaskCapture reports whether an utterance explicitly files a task, and
|
||||
// returns the task text with the marker stripped. A marker with nothing after it
|
||||
// is not a capture (there is no task in "добавь в задачи") — the caller falls
|
||||
// through to whatever it would otherwise have done with the turn.
|
||||
func ParseTaskCapture(text string) (TaskCapture, bool) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
lower := strings.ToLower(trimmed)
|
||||
best := ""
|
||||
for _, p := range taskCapturePrefixes {
|
||||
if strings.HasPrefix(lower, p) && len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return TaskCapture{}, false
|
||||
}
|
||||
// Cut on the rune length of the matched prefix. ToLower does not change the
|
||||
// byte length of Russian or English letters, so the index carries over.
|
||||
rest := strings.TrimSpace(trimmed[len(best):])
|
||||
rest = strings.TrimLeft(rest, ":—- ")
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.TrimRight(rest, ".!")
|
||||
rest, weight := stripUrgency(rest)
|
||||
if rest == "" {
|
||||
return TaskCapture{}, false
|
||||
}
|
||||
return TaskCapture{Text: rest, Weight: weight}, true
|
||||
}
|
||||
|
||||
// stripUrgency pulls a leading or trailing urgency word out of the task text
|
||||
// and returns the weight it implies. Only at the edges: "срочно оплатить
|
||||
// интернет" and "оплатить интернет срочно" are the same instruction, while
|
||||
// "позвонить в срочную помощь" is a task whose text happens to contain the
|
||||
// stem, and cutting a word out of the middle of it would mangle the task.
|
||||
//
|
||||
// The word is removed from the text, because the list should read "оплатить
|
||||
// интернет (важно)" and not "важно оплатить интернет (важно)".
|
||||
func stripUrgency(text string) (string, int) {
|
||||
for _, m := range urgencyMarkers {
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, m.word+" "):
|
||||
return strings.TrimSpace(text[len(m.word):]), m.weight
|
||||
case strings.HasSuffix(lower, " "+m.word):
|
||||
return strings.TrimSpace(text[:len(text)-len(m.word)]), m.weight
|
||||
case lower == m.word:
|
||||
// Nothing but the marker — no task in it.
|
||||
return "", 0
|
||||
}
|
||||
}
|
||||
return text, 0
|
||||
}
|
||||
|
||||
// taskListWords — the nouns that make a question be about the task list.
|
||||
var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
|
||||
|
||||
// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
|
||||
// has no task noun in it at all, so it is matched as a phrase below.
|
||||
var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
|
||||
|
||||
// IsTaskListQuery reports whether an utterance asks for the outstanding task
|
||||
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
|
||||
//
|
||||
// Narrow on purpose. "как дела?" is a greeting, not a query about work, and it
|
||||
// contains a task noun; it is excluded explicitly. Anything that mentions a
|
||||
// task noun without asking for the list falls through to ordinary recall.
|
||||
func IsTaskListQuery(text string) bool {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return false
|
||||
}
|
||||
// "как дела" — the greeting. Excluded before anything else matches.
|
||||
if hasTok(toks, "как") && (hasTok(toks, "дела") || hasTok(toks, "делишки")) {
|
||||
return false
|
||||
}
|
||||
// "что мне нужно сделать" / "что мне делать" — no task noun at all.
|
||||
if (hasTok(toks, "что") || hasTok(toks, "чем")) &&
|
||||
(hasTok(toks, "сделать") || hasTok(toks, "заняться")) {
|
||||
return true
|
||||
}
|
||||
if hasTok(toks, "what") && hasTok(toks, "do") {
|
||||
return true
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
for _, w := range taskListWords {
|
||||
if t == w {
|
||||
hasNoun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return false
|
||||
}
|
||||
// A task noun plus any of: a question word, "список", or a bare
|
||||
// one/two-word ask ("задачи", "мои задачи").
|
||||
if hasTok(toks, "какие") || hasTok(toks, "какая") || hasTok(toks, "что") ||
|
||||
hasTok(toks, "сколько") || hasTok(toks, "список") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "напомни") || hasTok(toks, "my") || hasTok(toks, "list") ||
|
||||
hasTok(toks, "show") {
|
||||
return true
|
||||
}
|
||||
if len(toks) <= 2 {
|
||||
for _, t := range toks {
|
||||
for _, w := range taskListWordsShortcut {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTaskCapture(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
text string
|
||||
weight int
|
||||
ok bool
|
||||
}{
|
||||
{"добавь в задачи купить молоко", "купить молоко", 0, true},
|
||||
{"Добавь в список дел: позвонить в банк", "позвонить в банк", 0, true},
|
||||
{"запиши задачу починить кран.", "починить кран", 0, true},
|
||||
{"новая задача — оплатить интернет", "оплатить интернет", 0, true},
|
||||
{"add a task buy milk", "buy milk", 0, true},
|
||||
// Urgency he stated out loud, leading or trailing, stripped from the text.
|
||||
{"добавь в задачи срочно оплатить интернет", "оплатить интернет", 3, true},
|
||||
{"добавь в задачи оплатить интернет срочно", "оплатить интернет", 3, true},
|
||||
{"новая задача важно позвонить маме", "позвонить маме", 2, true},
|
||||
// The stem inside the task text is part of the task, not a marker.
|
||||
{"добавь в задачи позвонить в срочную помощь", "позвонить в срочную помощь", 0, true},
|
||||
// A marker with nothing after it files nothing.
|
||||
{"добавь в задачи", "", 0, false},
|
||||
{"новая задача", "", 0, false},
|
||||
{"добавь в задачи срочно", "", 0, false},
|
||||
// Not a capture: he is talking, not filing.
|
||||
{"надо бы поспать", "", 0, false},
|
||||
{"я не добавил молоко в список", "", 0, false},
|
||||
{"какие у меня задачи?", "", 0, false},
|
||||
{"", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseTaskCapture(c.in)
|
||||
if ok != c.ok || got.Text != c.text || got.Weight != c.weight {
|
||||
t.Errorf("ParseTaskCapture(%q) = (%+v, %v), want (%q, w=%d, %v)", c.in, got, ok, c.text, c.weight, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTaskListQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие у меня задачи?",
|
||||
"что мне нужно сделать?",
|
||||
"покажи список дел",
|
||||
"сколько у меня задач?",
|
||||
"задачи",
|
||||
"мои задачи",
|
||||
"what should I do",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !IsTaskListQuery(s) {
|
||||
t.Errorf("IsTaskListQuery(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
no := []string{
|
||||
"как дела?",
|
||||
"какая погода?",
|
||||
"напомни мне позвонить маме в шесть",
|
||||
"я сделал зарядку",
|
||||
"",
|
||||
}
|
||||
for _, s := range no {
|
||||
if IsTaskListQuery(s) {
|
||||
t.Errorf("IsTaskListQuery(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
expires_ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
||||
|
||||
// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
|
||||
// a fact is a claim about the world that gets superseded, a task is a
|
||||
// piece of work with a lifecycle (captured → open → done), and the
|
||||
// prioritiser needs to read the live set cheaply.
|
||||
//
|
||||
// status: 'candidate' is a task Maven derived from something she read
|
||||
// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
|
||||
// he actually stated (or confirmed). Nothing schedules or announces off
|
||||
// this table — capture is not a nag.
|
||||
//
|
||||
// norm is the normalised dedupe key. The unique index is PARTIAL, over
|
||||
// live rows only: re-capturing "купить молоко" after last week's one is
|
||||
// done must work, while the same mail arriving twice must not produce two
|
||||
// rows.
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
norm TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
evidence TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
|
||||
due_ts INTEGER,
|
||||
weight INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_ts INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Tasks — the capture store (Vikunja #130). One row per piece of work, with a
|
||||
// lifecycle instead of a valid-time: captured, then either done or dropped.
|
||||
//
|
||||
// Why not facts: a fact is a claim about the world and a correction supersedes
|
||||
// it (append-only, voids_id). A task is not a claim — it is work, it has a
|
||||
// state that moves forward once, and the read the prioritiser needs is "every
|
||||
// live task right now", which over an append-only log would mean replaying
|
||||
// history on every question.
|
||||
//
|
||||
// Nothing in this file schedules, fires or announces anything. Capture is a
|
||||
// store, not a trigger: a task exists to be answered when asked about, and the
|
||||
// owner's reminders remain the only thing that speaks unprompted.
|
||||
const (
|
||||
// TaskCandidate — Maven derived this task from something she read (mail,
|
||||
// once the email reader exists) and the owner has not confirmed it. A
|
||||
// candidate is inert: it is listed as a candidate and never counted as work
|
||||
// he agreed to.
|
||||
TaskCandidate = "candidate"
|
||||
// TaskOpen — work the owner stated himself, or a candidate he confirmed.
|
||||
TaskOpen = "open"
|
||||
// TaskDone — finished.
|
||||
TaskDone = "done"
|
||||
// TaskDropped — declined, or a candidate rejected. Kept for provenance, so
|
||||
// the same mail cannot resurrect it silently; nothing re-proposes a
|
||||
// dropped task.
|
||||
TaskDropped = "dropped"
|
||||
)
|
||||
|
||||
// Task — one captured piece of work.
|
||||
//
|
||||
// Source is provenance in the same vocabulary facts use: "tap:voice" for
|
||||
// something he said, "tap:web" for the review page, "email:<account>" for a
|
||||
// mail-derived candidate. Evidence is the free-text trail a derived task came
|
||||
// from (a subject line), empty for anything he stated himself — it is what
|
||||
// makes a candidate reviewable instead of mysterious.
|
||||
//
|
||||
// Due is optional. Weight is an explicit importance hint (0 = none), which the
|
||||
// prioritiser reads; capture never invents one.
|
||||
type Task struct {
|
||||
ID int64
|
||||
CreatedTs time.Time
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
ResolvedTs *time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTaskNotFound = errors.New("store: task not found")
|
||||
ErrTaskEmpty = errors.New("store: task text is empty")
|
||||
ErrTaskStatus = errors.New("store: invalid task status")
|
||||
)
|
||||
|
||||
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
|
||||
|
||||
// CaptureTask inserts a task, or returns the existing live task when the same
|
||||
// work is already outstanding. created reports which happened, so a caller can
|
||||
// tell the owner "уже в списке" instead of pretending it wrote something.
|
||||
//
|
||||
// Dedupe is on the normalised text among LIVE rows only (see the partial unique
|
||||
// index in migration #14): a weekly errand can be captured again once the last
|
||||
// one is done, but a mail that gets re-read produces no second row. This is the
|
||||
// property the email intake depends on — it may call CaptureTask for every
|
||||
// message it extracts from, as often as it likes, without growing the list.
|
||||
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
|
||||
text := strings.TrimSpace(t.Text)
|
||||
if text == "" {
|
||||
return 0, false, ErrTaskEmpty
|
||||
}
|
||||
status := t.Status
|
||||
if status == "" {
|
||||
status = TaskOpen
|
||||
}
|
||||
if status != TaskCandidate && status != TaskOpen {
|
||||
// Capturing straight into a resolved state is meaningless — a task is
|
||||
// captured live and moved later.
|
||||
return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
||||
}
|
||||
norm := NormalizeTaskText(text)
|
||||
created2 := t.CreatedTs
|
||||
if created2.IsZero() {
|
||||
created2 = time.Now()
|
||||
}
|
||||
var due sql.NullInt64
|
||||
if t.Due != nil {
|
||||
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
|
||||
} else if n > 0 {
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Already live — hand back the row that won.
|
||||
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return existing.ID, false, nil
|
||||
}
|
||||
|
||||
// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
|
||||
func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, error) {
|
||||
row := s.db.QueryRowContext(ctx, taskSelect+`
|
||||
WHERE norm = ? AND status IN ('candidate','open')`, norm)
|
||||
t, err := scanTask(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Task{}, ErrTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("lookup live task: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks`
|
||||
|
||||
// LookupTask returns one task by id.
|
||||
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE id = ?`, id)
|
||||
t, err := scanTask(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Task{}, fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("lookup task: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListTasks returns tasks in one status, newest first. An empty status returns
|
||||
// every row; "live" returns candidate + open, which is what every read path
|
||||
// that means "outstanding work" wants.
|
||||
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
q := taskSelect
|
||||
var args []any
|
||||
switch status {
|
||||
case "":
|
||||
case "live":
|
||||
q += ` WHERE status IN (?,?)`
|
||||
args = append(args, liveTaskStatuses[0], liveTaskStatuses[1])
|
||||
default:
|
||||
q += ` WHERE status = ?`
|
||||
args = append(args, status)
|
||||
}
|
||||
q += ` ORDER BY created_ts DESC, id DESC`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Task
|
||||
for rows.Next() {
|
||||
t, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks: %w", err)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetTaskStatus moves a task once, forward. The legal moves are:
|
||||
//
|
||||
// candidate → open (the owner confirms a derived task)
|
||||
// candidate → dropped (he rejects it)
|
||||
// open → done (finished)
|
||||
// open → dropped (abandoned)
|
||||
//
|
||||
// Anything else — including re-resolving a resolved task — is refused with
|
||||
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
|
||||
// tools use: an answered question is not answered twice.
|
||||
//
|
||||
// Resolving frees the dedupe key, which is the point: the work can recur.
|
||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
var from []string
|
||||
switch status {
|
||||
case TaskOpen:
|
||||
from = []string{TaskCandidate}
|
||||
case TaskDone:
|
||||
from = []string{TaskOpen}
|
||||
case TaskDropped:
|
||||
from = []string{TaskCandidate, TaskOpen}
|
||||
default:
|
||||
return fmt.Errorf("%w: %q", ErrTaskStatus, status)
|
||||
}
|
||||
|
||||
// resolved_ts is only meaningful for a terminal state; confirming a
|
||||
// candidate leaves it null (the task is still live).
|
||||
var resolved sql.NullInt64
|
||||
if status == TaskDone || status == TaskDropped {
|
||||
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
|
||||
}
|
||||
|
||||
q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` +
|
||||
strings.Repeat(",?", len(from)-1) + `)`
|
||||
args := []any{status, resolved, id}
|
||||
for _, f := range from {
|
||||
args = append(args, f)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set task status: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set task status: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
||||
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
||||
// to reason about what will and will not be treated as the same task.
|
||||
//
|
||||
// Deliberately shallow — no stemming, no synonyms. Russian morphology would
|
||||
// need a real lemmatiser to do better, and a normaliser that guesses would
|
||||
// silently swallow two different tasks. This only catches the case that
|
||||
// actually happens: the same sentence arriving twice with different casing or
|
||||
// punctuation.
|
||||
func NormalizeTaskText(s string) string {
|
||||
var b strings.Builder
|
||||
space := true // leading space collapses to nothing
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
space = false
|
||||
case !space:
|
||||
b.WriteRune(' ')
|
||||
space = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func scanTask(sc scanner) (Task, error) {
|
||||
var t Task
|
||||
var created int64
|
||||
var due, resolved sql.NullInt64
|
||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||
t.Due = millisToTime(due)
|
||||
t.ResolvedTs = millisToTime(resolved)
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("first capture must create a row")
|
||||
}
|
||||
|
||||
// Same work, different casing and punctuation — one task, not two.
|
||||
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Error("second capture of the same live work must not create a row")
|
||||
}
|
||||
if again != id {
|
||||
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
|
||||
}
|
||||
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("live tasks = %d, want 1", len(live))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The dedupe key is free again: a recurring errand must be capturable.
|
||||
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created || id2 == id {
|
||||
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
|
||||
}
|
||||
live, err := st.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(live) != 1 || live[0].ID != id2 {
|
||||
t.Fatalf("live = %+v, want only the new task %d", live, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
due := now.Add(48 * time.Hour)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{
|
||||
Text: "продлить страховку",
|
||||
Source: "email:kami",
|
||||
Evidence: "Re: страховой полис истекает",
|
||||
Status: TaskCandidate,
|
||||
Due: &due,
|
||||
Weight: 2,
|
||||
CreatedTs: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != TaskCandidate {
|
||||
t.Errorf("status = %q, want candidate", got.Status)
|
||||
}
|
||||
if got.Evidence != "Re: страховой полис истекает" {
|
||||
t.Errorf("evidence = %q", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || !got.Due.Equal(due.UTC()) {
|
||||
t.Errorf("due = %v, want %v", got.Due, due.UTC())
|
||||
}
|
||||
if got.Weight != 2 {
|
||||
t.Errorf("weight = %d, want 2", got.Weight)
|
||||
}
|
||||
if got.ResolvedTs != nil {
|
||||
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
cand, _, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// candidate → done is not a legal move: he has to confirm it first.
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Already resolved — a second resolve must not move it again.
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour)); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, cand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != TaskDone {
|
||||
t.Errorf("status = %q, want done", got.Status)
|
||||
}
|
||||
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
|
||||
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
if _, _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
|
||||
t.Errorf("err = %v, want ErrTaskEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasksFiltersByStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
open1, _, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
|
||||
_, _, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
|
||||
done, _, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
|
||||
if err := st.SetTaskStatus(ctx, done, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cands, err := st.ListTasks(ctx, TaskCandidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cands) != 1 || cands[0].Text != "вторая" {
|
||||
t.Fatalf("candidates = %+v", cands)
|
||||
}
|
||||
opens, err := st.ListTasks(ctx, TaskOpen)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 1 || opens[0].ID != open1 {
|
||||
t.Fatalf("open = %+v", opens)
|
||||
}
|
||||
all, err := st.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("all = %d, want 3", len(all))
|
||||
}
|
||||
// Newest first.
|
||||
if all[0].Text != "третья" {
|
||||
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTaskText(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Купить молоко!", "купить молоко"},
|
||||
{" купить МОЛОКО ", "купить молоко"},
|
||||
{"позвонить в банк (важно)", "позвонить в банк важно"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := NormalizeTaskText(c.in); got != c.want {
|
||||
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Package tasks ranks captured work (Vikunja #129).
|
||||
//
|
||||
// The ordering is COMPUTED, not generated. Asking a 1.7B model which of his
|
||||
// tasks matters most would produce a fluent opinion about his life with no
|
||||
// basis in anything, and a confidently wrong priority is worse than no
|
||||
// priority at all — the same reasoning as the behaviour profile in
|
||||
// internal/memory, which counts instead of summarising.
|
||||
//
|
||||
// So: four signals, all of them things he told her, and a reason string naming
|
||||
// the one that decided each row. Nothing here invents urgency. A task with no
|
||||
// due date and no weight scores nothing and sits where its age puts it, which
|
||||
// is the honest answer to "which of these matters?" when he never said.
|
||||
//
|
||||
// Ranking is a READ. It sorts and renders; it never writes, schedules or
|
||||
// announces. Maven is not a nag: a task rising to the top of this list is not a
|
||||
// reason to speak, only the order she recites in when asked.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
|
||||
// without importing the store.
|
||||
const (
|
||||
StatusCandidate = "candidate"
|
||||
StatusOpen = "open"
|
||||
)
|
||||
|
||||
// Item — one task to rank. The subset of a task that ranking depends on;
|
||||
// callers map their own row type onto it.
|
||||
type Item struct {
|
||||
ID int64
|
||||
Text string
|
||||
Status string
|
||||
Created time.Time
|
||||
Due *time.Time
|
||||
Weight int
|
||||
}
|
||||
|
||||
// Ranked — one task with its score and the reason that decided it.
|
||||
type Ranked struct {
|
||||
Item
|
||||
Score float64
|
||||
// Reason — the dominant signal, in Russian, for the page and the spoken
|
||||
// list. Empty when nothing distinguished this task: no due date, no
|
||||
// weight, not old. Saying "потому что" about a task he never prioritised
|
||||
// would be making something up.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Scoring weights. Deliberately coarse round numbers: this is a knob, not
|
||||
// math, and the only property that has to hold is the ordering between classes
|
||||
// (overdue beats today beats this week beats undated).
|
||||
const (
|
||||
scoreOverdue = 100 // he already missed it
|
||||
scoreOverduePer = 5 // per further day late, capped
|
||||
scoreOverdueCap = 40
|
||||
scoreDueToday = 60
|
||||
scoreDueTomorrow = 40
|
||||
scoreDueWeek = 20
|
||||
scoreDueLater = 5
|
||||
scorePerWeight = 15 // "срочно" / "важно" / the web form's select
|
||||
scorePerWeekOld = 1 // so nothing rots at the bottom forever
|
||||
scoreAgeCap = 10
|
||||
// MaxWeight — the highest importance hint capture accepts. Three rungs is
|
||||
// as many as anyone can rank by hand honestly.
|
||||
MaxWeight = 3
|
||||
)
|
||||
|
||||
// Rank scores every item and returns them ordered: confirmed work first, then
|
||||
// candidates, each by score descending, oldest first on a tie.
|
||||
//
|
||||
// Candidates never outrank open work, whatever their due date. A task Maven
|
||||
// derived from something she read is a suggestion until he confirms it, and
|
||||
// putting her guess above his own stated work would be reading his priorities
|
||||
// back to him wrong.
|
||||
func Rank(items []Item, now time.Time) []Ranked {
|
||||
out := make([]Ranked, 0, len(items))
|
||||
for _, it := range items {
|
||||
score, reason := score(it, now)
|
||||
out = append(out, Ranked{Item: it, Score: score, Reason: reason})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ci, cj := out[i].Status == StatusCandidate, out[j].Status == StatusCandidate
|
||||
if ci != cj {
|
||||
return !ci // open before candidate
|
||||
}
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
return out[i].Created.Before(out[j].Created) // oldest first, FIFO
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// score — the per-item scoring function. Returns the score and the dominant
|
||||
// reason. Deadline beats weight when both are present: a date is a fact about
|
||||
// the world, a weight is how he felt when he filed it.
|
||||
func score(it Item, now time.Time) (float64, string) {
|
||||
var total float64
|
||||
reason := ""
|
||||
|
||||
if it.Due != nil {
|
||||
days := dayDelta(*it.Due, now)
|
||||
switch {
|
||||
case days < 0:
|
||||
late := -days
|
||||
bonus := float64(late * scoreOverduePer)
|
||||
if bonus > scoreOverdueCap {
|
||||
bonus = scoreOverdueCap
|
||||
}
|
||||
total += scoreOverdue + bonus
|
||||
reason = "просрочено"
|
||||
if late == 1 {
|
||||
reason = "просрочено на день"
|
||||
} else if late > 1 {
|
||||
reason = fmt.Sprintf("просрочено на %d дн.", late)
|
||||
}
|
||||
case days == 0:
|
||||
total += scoreDueToday
|
||||
reason = "сегодня"
|
||||
case days == 1:
|
||||
total += scoreDueTomorrow
|
||||
reason = "завтра"
|
||||
case days <= 7:
|
||||
total += scoreDueWeek
|
||||
reason = fmt.Sprintf("через %d дн.", days)
|
||||
default:
|
||||
total += scoreDueLater
|
||||
}
|
||||
}
|
||||
|
||||
w := it.Weight
|
||||
if w > MaxWeight {
|
||||
w = MaxWeight
|
||||
}
|
||||
if w > 0 {
|
||||
total += float64(w * scorePerWeight)
|
||||
if reason == "" {
|
||||
reason = "важно"
|
||||
}
|
||||
}
|
||||
|
||||
if !it.Created.IsZero() {
|
||||
weeks := int(now.Sub(it.Created).Hours() / (24 * 7))
|
||||
if weeks > 0 {
|
||||
age := float64(weeks * scorePerWeekOld)
|
||||
if age > scoreAgeCap {
|
||||
age = scoreAgeCap
|
||||
}
|
||||
total += age
|
||||
if reason == "" && weeks >= 2 {
|
||||
reason = "давно в списке"
|
||||
}
|
||||
}
|
||||
}
|
||||
return total, reason
|
||||
}
|
||||
|
||||
// dayDelta — calendar days from now to due, in due's own location. Whole days,
|
||||
// not hours: a task due today is due today whether it is 09:00 or 23:00, and an
|
||||
// hours-based comparison would call this evening's task "overdue" all afternoon.
|
||||
func dayDelta(due, now time.Time) int {
|
||||
loc := due.Location()
|
||||
d := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, loc)
|
||||
n := now.In(loc)
|
||||
n = time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
|
||||
return int(d.Sub(n).Hours() / 24)
|
||||
}
|
||||
|
||||
// SpokenLimit — how many tasks the spoken list names before it summarises the
|
||||
// rest. A recital of twenty items is noise; five is a list he can hold.
|
||||
const SpokenLimit = 5
|
||||
|
||||
// FormatRU renders a ranked list the way Maven says it. Confirmed work first,
|
||||
// with the reason attached where there is one; candidates named as
|
||||
// unconfirmed, never recited as his work.
|
||||
//
|
||||
// One renderer for the voice reply and the web page, for the same reason
|
||||
// DayPlan.Spoken is built core-side: two formatters drift, and then she says
|
||||
// one order and shows another.
|
||||
func FormatRU(ranked []Ranked) string {
|
||||
var open, cands []Ranked
|
||||
for _, r := range ranked {
|
||||
if r.Status == StatusCandidate {
|
||||
cands = append(cands, r)
|
||||
} else {
|
||||
open = append(open, r)
|
||||
}
|
||||
}
|
||||
if len(open) == 0 && len(cands) == 0 {
|
||||
return "задач нет."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if len(open) > 0 {
|
||||
b.WriteString("сначала: ")
|
||||
b.WriteString(joinRU(open, SpokenLimit, true))
|
||||
b.WriteString(".")
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||||
b.WriteString(joinRU(cands, SpokenLimit, false))
|
||||
b.WriteString(".")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// joinRU lists up to limit tasks, then says how many are left. withReasons
|
||||
// attaches the parenthesised reason — candidates are listed bare, since their
|
||||
// due dates are Maven's reading of a mail and not something he stated.
|
||||
func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
shown := rs
|
||||
rest := 0
|
||||
if len(rs) > limit {
|
||||
shown, rest = rs[:limit], len(rs)-limit
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, r := range shown {
|
||||
if withReasons && r.Reason != "" {
|
||||
parts = append(parts, r.Text+" ("+r.Reason+")")
|
||||
} else {
|
||||
parts = append(parts, r.Text)
|
||||
}
|
||||
}
|
||||
s := strings.Join(parts, "; ")
|
||||
if rest > 0 {
|
||||
s += fmt.Sprintf("; и ещё %d", rest)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func at(y int, m time.Month, d int) *time.Time {
|
||||
t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
return &t
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Date(2026, 8, 1, 14, 0, 0, 0, time.UTC) }
|
||||
|
||||
func texts(rs []Ranked) []string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.Text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRankOrdersByDeadline(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: 1, Text: "через неделю", Status: StatusOpen, Due: at(2026, 8, 7), Created: now()},
|
||||
{ID: 2, Text: "просрочено", Status: StatusOpen, Due: at(2026, 7, 28), Created: now()},
|
||||
{ID: 3, Text: "без срока", Status: StatusOpen, Created: now()},
|
||||
{ID: 4, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
|
||||
{ID: 5, Text: "завтра", Status: StatusOpen, Due: at(2026, 8, 2), Created: now()},
|
||||
}
|
||||
got := texts(Rank(items, now()))
|
||||
want := []string{"просрочено", "сегодня", "завтра", "через неделю", "без срока"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidatesNeverOutrankOpenWork(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: 1, Text: "его задача", Status: StatusOpen, Created: now()},
|
||||
// Everything about this one screams urgent — and it is still a guess.
|
||||
{ID: 2, Text: "из письма", Status: StatusCandidate, Due: at(2026, 7, 1), Weight: 3, Created: now()},
|
||||
}
|
||||
got := Rank(items, now())
|
||||
if got[0].Text != "его задача" {
|
||||
t.Errorf("order = %v, want his own work first", texts(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankWeightLiftsUndatedWork(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: 1, Text: "обычная", Status: StatusOpen, Created: now()},
|
||||
{ID: 2, Text: "важная", Status: StatusOpen, Weight: 2, Created: now()},
|
||||
}
|
||||
got := Rank(items, now())
|
||||
if got[0].Text != "важная" {
|
||||
t.Errorf("order = %v, want the weighted task first", texts(got))
|
||||
}
|
||||
if got[0].Reason != "важно" {
|
||||
t.Errorf("reason = %q, want важно", got[0].Reason)
|
||||
}
|
||||
// A deadline still beats a weight: a date is a fact, a weight is a feeling.
|
||||
items = append(items, Item{ID: 3, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()})
|
||||
got = Rank(items, now())
|
||||
if got[0].Text != "сегодня" {
|
||||
t.Errorf("order = %v, want the dated task first", texts(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankOldestFirstOnATie(t *testing.T) {
|
||||
old := now().AddDate(0, 0, -3)
|
||||
items := []Item{
|
||||
{ID: 1, Text: "новая", Status: StatusOpen, Created: now()},
|
||||
{ID: 2, Text: "старая", Status: StatusOpen, Created: old},
|
||||
}
|
||||
got := Rank(items, now())
|
||||
if got[0].Text != "старая" {
|
||||
t.Errorf("order = %v, want FIFO on equal urgency", texts(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankNoInventedReason(t *testing.T) {
|
||||
got := Rank([]Item{{ID: 1, Text: "что-то", Status: StatusOpen, Created: now()}}, now())
|
||||
if got[0].Reason != "" {
|
||||
t.Errorf("reason = %q — nothing distinguished this task, so there is nothing to say", got[0].Reason)
|
||||
}
|
||||
if got[0].Score != 0 {
|
||||
t.Errorf("score = %v, want 0", got[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankAgeIsCappedAndNamed(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: 1, Text: "прошлогодняя", Status: StatusOpen, Created: now().AddDate(-1, 0, 0)},
|
||||
{ID: 2, Text: "трёхнедельная", Status: StatusOpen, Created: now().AddDate(0, 0, -21)},
|
||||
}
|
||||
got := Rank(items, now())
|
||||
if got[0].Score != scoreAgeCap {
|
||||
t.Errorf("oldest score = %v, want the cap %v", got[0].Score, float64(scoreAgeCap))
|
||||
}
|
||||
if got[0].Reason != "давно в списке" {
|
||||
t.Errorf("reason = %q", got[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// A task due at 23:00 today is due today, not overdue since this morning.
|
||||
func TestRankDueTodayIsNotOverdue(t *testing.T) {
|
||||
due := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC)
|
||||
got := Rank([]Item{{ID: 1, Text: "вечером", Status: StatusOpen, Due: &due, Created: now()}}, now())
|
||||
if got[0].Reason != "сегодня" {
|
||||
t.Errorf("reason = %q, want сегодня", got[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankOverdueDaysAreCounted(t *testing.T) {
|
||||
got := Rank([]Item{
|
||||
{ID: 1, Text: "вчера", Status: StatusOpen, Due: at(2026, 7, 31), Created: now()},
|
||||
{ID: 2, Text: "давно", Status: StatusOpen, Due: at(2026, 7, 20), Created: now()},
|
||||
}, now())
|
||||
if got[0].Text != "давно" {
|
||||
t.Errorf("order = %v, want the later-overdue task first", texts(got))
|
||||
}
|
||||
if got[0].Reason != "просрочено на 12 дн." {
|
||||
t.Errorf("reason = %q", got[0].Reason)
|
||||
}
|
||||
if got[1].Reason != "просрочено на день" {
|
||||
t.Errorf("reason = %q", got[1].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
|
||||
ranked := Rank([]Item{
|
||||
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()},
|
||||
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now()},
|
||||
{ID: 3, Text: "продлить страховку", Status: StatusCandidate, Due: at(2026, 7, 1), Created: now()},
|
||||
}, now())
|
||||
got := FormatRU(ranked)
|
||||
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
|
||||
t.Errorf("reply = %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "не подтвердил: продлить страховку") {
|
||||
t.Errorf("candidate not named as unconfirmed: %q", got)
|
||||
}
|
||||
// A candidate's due date is Maven's reading of a mail, not his statement.
|
||||
if strings.Contains(got, "продлить страховку (") {
|
||||
t.Errorf("a candidate must be listed without a reason: %q", got)
|
||||
}
|
||||
// Persona: nothing masculine, no pet names, informal address only.
|
||||
for _, bad := range []string{"рад ", "понял ", "милый", "дорогой", "вам", "ваши"} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("reply %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRUCapsTheSpokenList(t *testing.T) {
|
||||
var items []Item
|
||||
for i := 0; i < SpokenLimit+3; i++ {
|
||||
items = append(items, Item{ID: int64(i), Text: "задача", Status: StatusOpen, Created: now()})
|
||||
}
|
||||
got := FormatRU(Rank(items, now()))
|
||||
if !strings.Contains(got, "и ещё 3") {
|
||||
t.Errorf("reply = %q, want the tail summarised", got)
|
||||
}
|
||||
if strings.Count(got, "задача") != SpokenLimit {
|
||||
t.Errorf("reply = %q, want exactly %d named", got, SpokenLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRUEmpty(t *testing.T) {
|
||||
if got := FormatRU(nil); got != "задач нет." {
|
||||
t.Errorf("reply = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user