Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a33ad82178 | |||
| 3884db33e9 |
@@ -166,6 +166,9 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou
|
|||||||
return nil, errLocked
|
return nil, errLocked
|
||||||
}
|
}
|
||||||
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
||||||
|
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
||||||
|
return errLocked
|
||||||
|
}
|
||||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
||||||
return ipc.Tool{}, errLocked
|
return ipc.Tool{}, errLocked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -918,3 +918,126 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- handleRoutines ---
|
||||||
|
|
||||||
|
// routineCore is a fakeCore that also answers the proposed-routine calls.
|
||||||
|
type routineCore struct {
|
||||||
|
fakeCore
|
||||||
|
|
||||||
|
routines []ipc.ProposedRoutine
|
||||||
|
dismissed int64
|
||||||
|
acceptedID int64
|
||||||
|
acceptedRe int64
|
||||||
|
remCron string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
||||||
|
return c.routines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error {
|
||||||
|
c.dismissed = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error {
|
||||||
|
c.acceptedID, c.acceptedRe = id, remID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) {
|
||||||
|
c.remCron = cron
|
||||||
|
return 77, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyRoutineCore() *routineCore {
|
||||||
|
return &routineCore{routines: []ipc.ProposedRoutine{{
|
||||||
|
ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7,
|
||||||
|
Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
||||||
|
}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postRoutine(action, id string) *http.Request {
|
||||||
|
return postForm(action, url.Values{"id": {id}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "заправляешь") {
|
||||||
|
t.Fatalf("want maven's phrasing in the page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "class=scroll") {
|
||||||
|
t.Fatal("table must be wrapped in <div class=scroll> so it pans on a phone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting hands the loop a new reason to speak, so it needs step-up.
|
||||||
|
func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
|
||||||
|
core := weeklyRoutineCore()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if core.acceptedID != 0 {
|
||||||
|
t.Fatal("accepted without step-up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
|
||||||
|
core := weeklyRoutineCore()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.acceptedID != 3 || core.acceptedRe != 77 {
|
||||||
|
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
|
||||||
|
}
|
||||||
|
if core.remCron == "" {
|
||||||
|
t.Fatal("a weekly pattern should get a cron expression")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss only ever removes a reason to speak, so it is not step-up gated.
|
||||||
|
func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) {
|
||||||
|
core := weeklyRoutineCore()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.dismissed != 3 {
|
||||||
|
t.Fatalf("dismissed = %d, want 3", core.dismissed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutines_UnknownAction_400(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutines_BadID_400(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutines_NilCore_503(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false)
|
||||||
|
if rr.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+101
-15
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -395,9 +396,6 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
handleRoutines(w, r, core)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -450,6 +448,11 @@ func main() {
|
|||||||
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
handleTools(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
|
// /routines — the authed accept surface. Registered here, next to /tools,
|
||||||
|
// because accepting shares the same step-up gate.
|
||||||
|
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||||
|
})
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// /api/revert voids the latest fact for a key — a store mutation, so it
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
||||||
@@ -680,22 +683,24 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
|||||||
</section>
|
</section>
|
||||||
{{template "shellBottom"}}`
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// routinesHTML — proposed routine review surface. Lists detected patterns
|
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||||
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
const routinesHTML = `{{template "shellTop" "routines"}}
|
||||||
<h1>Routines</h1>
|
<h1>Routines</h1>
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
<section class=card>
|
<section class=card>
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
||||||
{{if .Proposed}}<div class=scroll><table><tr><th>action</th><th>object</th><th>every</th><th></th></tr>
|
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
||||||
<td>
|
<td><form method=post action=/routines class=inline-form>
|
||||||
<form method=post action=/routines class=inline-form>
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=accept>
|
||||||
|
<button class=btn>accept</button></form></td>
|
||||||
|
<td><form method=post action=/routines class=inline-form>
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
<input type=hidden name=action value=dismiss>
|
<input type=hidden name=action value=dismiss>
|
||||||
<button class="btn btn-muted">dismiss</button></form>
|
<button class="btn btn-muted">dismiss</button></form></td>
|
||||||
</td>
|
|
||||||
</tr>{{end}}</table></div>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<div class=empty>
|
{{else}}<div class=empty>
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||||
@@ -785,7 +790,23 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||||
|
// how long ago she noticed it.
|
||||||
|
type routineRow struct {
|
||||||
|
ID int64
|
||||||
|
Phrase string
|
||||||
|
Noticed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRoutines serves the routine review surface (GET) and answers a
|
||||||
|
// proposal (POST id + action=accept|dismiss).
|
||||||
|
//
|
||||||
|
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
|
||||||
|
// hands the trigger loop a new standing reason to speak to the human, so it
|
||||||
|
// moves the boundary and only an authed surface may do it. Dismiss is not
|
||||||
|
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
||||||
|
// can do is make maven quieter.
|
||||||
|
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
@@ -801,6 +822,17 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
|
case "accept":
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||||
|
log.Printf("routines: accept %d: %v", rid, err)
|
||||||
|
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "accepted routine — maven will remind you"
|
||||||
case "dismiss":
|
case "dismiss":
|
||||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||||
@@ -822,12 +854,66 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []ipc.ProposedRoutine
|
Proposed []routineRow
|
||||||
}{msg, proposed}); err != nil {
|
}{msg, routineRows(proposed)}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routineRows turns the wire rows into display rows. The phrase comes from
|
||||||
|
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||||
|
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
||||||
|
out := make([]routineRow, 0, len(rs))
|
||||||
|
for _, r := range rs {
|
||||||
|
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||||
|
noticed := "just now"
|
||||||
|
if r.CreatedTs > 0 {
|
||||||
|
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||||
|
}
|
||||||
|
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptRoutine creates the recurring reminder for a proposal, then marks the
|
||||||
|
// proposal accepted and links the reminder to it. Weekly patterns get a cron
|
||||||
|
// expression; any other interval fires once.
|
||||||
|
//
|
||||||
|
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
|
||||||
|
// When the tick loop learns to read accepted proposals directly, both callers
|
||||||
|
// should hand off to one place in core instead of each building a reminder.
|
||||||
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||||
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var found *ipc.ProposedRoutine
|
||||||
|
for i := range proposed {
|
||||||
|
if proposed[i].ID == id {
|
||||||
|
found = &proposed[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return errors.New("no such proposed routine")
|
||||||
|
}
|
||||||
|
|
||||||
|
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
|
||||||
|
cron := ""
|
||||||
|
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
|
||||||
|
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return core.AcceptProposedRoutine(ctx, id, remID)
|
||||||
|
}
|
||||||
|
|
||||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
|||||||
@@ -446,6 +446,9 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|||||||
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
package dialogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Slot names one field of Slots. Named type, not a free string, so a missing
|
|
||||||
// slot cannot be misspelled — the question phrasing switches on these.
|
|
||||||
type Slot string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SlotTime Slot = "time" // Slots.Time / HasTime
|
|
||||||
SlotKey Slot = "key" // Slots.Key / HasKey
|
|
||||||
SlotValue Slot = "value" // Slots.Value (paired with Key)
|
|
||||||
SlotFn Slot = "fn" // Slots.Fn / HasFn
|
|
||||||
SlotText Slot = "text" // Slots.Text
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks
|
|
||||||
// one clarifying question. If the answer still leaves the slot empty she drops
|
|
||||||
// the request instead of asking again.
|
|
||||||
const MaxAttempts = 1
|
|
||||||
|
|
||||||
// PendingQuestion is what Maven holds while she waits for an answer to an open
|
|
||||||
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
|
|
||||||
// is free text that fills a missing slot rather than a verdict.
|
|
||||||
type PendingQuestion struct {
|
|
||||||
Intent Intent // what the router already guessed
|
|
||||||
Slots Slots // what it already filled
|
|
||||||
Missing []Slot // what is still empty, in the order to ask about
|
|
||||||
Utterance string // the user's original raw words
|
|
||||||
Asked time.Time
|
|
||||||
TTL time.Duration
|
|
||||||
Attempts int // questions already asked; capped by MaxAttempts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
|
||||||
return now.After(q.Asked.Add(q.TTL))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanAsk reports whether Maven may ask another question about this request.
|
|
||||||
func (q *PendingQuestion) CanAsk() bool {
|
|
||||||
return q.Attempts < MaxAttempts
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will phrase the question text from Missing (one short ru
|
|
||||||
// question per Slot, feminine self-reference) and speak it here.
|
|
||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
|
||||||
type ClarifyStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
questions map[string]*PendingQuestion
|
|
||||||
defaultTTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
|
||||||
if defaultTTL <= 0 {
|
|
||||||
// Short, like confirmTTL in voice.go: a clarifying question is a
|
|
||||||
// same-breath gesture, a stale one should not eat a later utterance.
|
|
||||||
defaultTTL = 90 * time.Second
|
|
||||||
}
|
|
||||||
return &ClarifyStore{
|
|
||||||
questions: make(map[string]*PendingQuestion),
|
|
||||||
defaultTTL: defaultTTL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will Put a question here when Decision.Clarify fires, in
|
|
||||||
// place of the flat "не разобрала" reply (cmd/mavend/voice.go).
|
|
||||||
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
|
||||||
if q.TTL <= 0 {
|
|
||||||
q.TTL = s.defaultTTL
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.questions[id] = q
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call
|
|
||||||
// Answer, and Delete — the open-question twin of resolveConfirm.
|
|
||||||
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
|
||||||
s.mu.RLock()
|
|
||||||
q, ok := s.questions[id]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if q.IsExpired(now) {
|
|
||||||
s.Delete(id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ClarifyStore) Delete(id string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.questions, id)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Answer merges the slots parsed from the user's answer into the parked ones.
|
|
||||||
// Only the slots listed in Missing are filled, and an already filled slot is
|
|
||||||
// never overwritten — the answer completes the original request, it does not
|
|
||||||
// restate it. Parsing the answer text into `answer` is the caller's job; this
|
|
||||||
// package must stay free of internal/router.
|
|
||||||
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
|
|
||||||
out := q.Slots
|
|
||||||
for _, slot := range q.Missing {
|
|
||||||
switch slot {
|
|
||||||
case SlotTime:
|
|
||||||
if !out.HasTime && answer.HasTime {
|
|
||||||
out.Time = answer.Time
|
|
||||||
out.HasTime = true
|
|
||||||
}
|
|
||||||
case SlotKey:
|
|
||||||
if !out.HasKey && answer.HasKey {
|
|
||||||
out.Key = answer.Key
|
|
||||||
out.HasKey = true
|
|
||||||
}
|
|
||||||
case SlotValue:
|
|
||||||
if out.Value == "" && answer.Value != "" {
|
|
||||||
out.Value = answer.Value
|
|
||||||
}
|
|
||||||
case SlotFn:
|
|
||||||
if !out.HasFn && answer.HasFn {
|
|
||||||
out.Fn = answer.Fn
|
|
||||||
out.HasFn = true
|
|
||||||
if len(out.Args) == 0 {
|
|
||||||
out.Args = append([]string(nil), answer.Args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case SlotText:
|
|
||||||
if out.Text == "" {
|
|
||||||
if answer.Text != "" {
|
|
||||||
out.Text = answer.Text
|
|
||||||
} else {
|
|
||||||
// No parse for a text slot — the raw answer IS the text.
|
|
||||||
out.Text = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// StillMissing lists the slots that are empty in s, out of the ones asked for.
|
|
||||||
// The caller uses it to decide between acting and dropping the request.
|
|
||||||
func StillMissing(want []Slot, s Slots) []Slot {
|
|
||||||
var out []Slot
|
|
||||||
for _, slot := range want {
|
|
||||||
empty := false
|
|
||||||
switch slot {
|
|
||||||
case SlotTime:
|
|
||||||
empty = !s.HasTime
|
|
||||||
case SlotKey:
|
|
||||||
empty = !s.HasKey
|
|
||||||
case SlotValue:
|
|
||||||
empty = s.Value == ""
|
|
||||||
case SlotFn:
|
|
||||||
empty = !s.HasFn
|
|
||||||
case SlotText:
|
|
||||||
empty = s.Text == ""
|
|
||||||
}
|
|
||||||
if empty {
|
|
||||||
out = append(out, slot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
package dialogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func TestPendingQuestionIsExpired(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
ttl time.Duration
|
|
||||||
now time.Time
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"fresh", time.Minute, base.Add(10 * time.Second), false},
|
|
||||||
{"exactly at ttl", time.Minute, base.Add(time.Minute), false},
|
|
||||||
{"past ttl", time.Minute, base.Add(2 * time.Minute), true},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
q := &PendingQuestion{Asked: base, TTL: tc.ttl}
|
|
||||||
if got := q.IsExpired(tc.now); got != tc.want {
|
|
||||||
t.Fatalf("IsExpired = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClarifyStoreGetPutDelete(t *testing.T) {
|
|
||||||
s := NewClarifyStore(time.Minute)
|
|
||||||
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("empty store returned %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base}
|
|
||||||
s.Put("voice", q)
|
|
||||||
if q.TTL != time.Minute {
|
|
||||||
t.Fatalf("Put did not apply the default TTL, got %v", q.TTL)
|
|
||||||
}
|
|
||||||
if got := s.Get("voice", base.Add(time.Second)); got != q {
|
|
||||||
t.Fatalf("Get returned %+v, want the parked question", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expired questions are dropped on read, not returned.
|
|
||||||
if got := s.Get("voice", base.Add(2*time.Minute)); got != nil {
|
|
||||||
t.Fatalf("expired Get returned %+v", got)
|
|
||||||
}
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("expired question was not deleted: %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour})
|
|
||||||
s.Delete("voice")
|
|
||||||
if got := s.Get("voice", base); got != nil {
|
|
||||||
t.Fatalf("Delete left %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
|
||||||
s := NewClarifyStore(0)
|
|
||||||
q := &PendingQuestion{Asked: base}
|
|
||||||
s.Put("voice", q)
|
|
||||||
if q.TTL != 90*time.Second {
|
|
||||||
t.Fatalf("TTL = %v, want 90s", q.TTL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|
||||||
answerTime := base.Add(3 * time.Hour)
|
|
||||||
other := base.Add(9 * time.Hour)
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
parked Slots
|
|
||||||
missing []Slot
|
|
||||||
text string
|
|
||||||
answer Slots
|
|
||||||
want Slots
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "fills the missing time",
|
|
||||||
parked: Slots{Text: "напомни позвонить"},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "в три",
|
|
||||||
answer: Slots{Time: answerTime, HasTime: true},
|
|
||||||
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not overwrite a filled time",
|
|
||||||
parked: Slots{Time: other, HasTime: true},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "в три",
|
|
||||||
answer: Slots{Time: answerTime, HasTime: true},
|
|
||||||
want: Slots{Time: other, HasTime: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ignores slots that were not missing",
|
|
||||||
parked: Slots{Key: "water", HasKey: true},
|
|
||||||
missing: []Slot{SlotValue},
|
|
||||||
text: "два литра",
|
|
||||||
answer: Slots{Key: "sleep", HasKey: true, Value: "2l"},
|
|
||||||
want: Slots{Key: "water", HasKey: true, Value: "2l"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fills key when empty",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotKey, SlotValue},
|
|
||||||
text: "воды",
|
|
||||||
answer: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
||||||
want: Slots{Key: "water", HasKey: true, Value: `"drank"`},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fills fn and its args",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotFn},
|
|
||||||
text: "перезапусти nginx",
|
|
||||||
answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "keeps existing args when fn was already known",
|
|
||||||
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
missing: []Slot{SlotFn},
|
|
||||||
text: "останови postgres",
|
|
||||||
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
|
||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "raw answer becomes the text when nothing was parsed",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotText},
|
|
||||||
text: "купить хлеб",
|
|
||||||
answer: Slots{},
|
|
||||||
want: Slots{Text: "купить хлеб"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "parsed text wins over the raw answer",
|
|
||||||
parked: Slots{},
|
|
||||||
missing: []Slot{SlotText},
|
|
||||||
text: "запиши купить хлеб",
|
|
||||||
answer: Slots{Text: "купить хлеб"},
|
|
||||||
want: Slots{Text: "купить хлеб"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty answer leaves the slot missing",
|
|
||||||
parked: Slots{Text: "напомни"},
|
|
||||||
missing: []Slot{SlotTime},
|
|
||||||
text: "не знаю",
|
|
||||||
answer: Slots{},
|
|
||||||
want: Slots{Text: "напомни"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
|
||||||
got := q.Answer(tc.text, tc.answer)
|
|
||||||
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
|
||||||
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
|
||||||
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
|
||||||
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
|
||||||
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
|
||||||
}
|
|
||||||
if len(got.Args) != len(tc.want.Args) {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
for i := range got.Args {
|
|
||||||
if got.Args[i] != tc.want.Args[i] {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
|
||||||
if MaxAttempts != 1 {
|
|
||||||
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
|
||||||
}
|
|
||||||
q := &PendingQuestion{Asked: base}
|
|
||||||
if !q.CanAsk() {
|
|
||||||
t.Fatal("a fresh question should be askable")
|
|
||||||
}
|
|
||||||
q.Attempts = MaxAttempts
|
|
||||||
if q.CanAsk() {
|
|
||||||
t.Fatal("the question should not be asked twice")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStillMissing(t *testing.T) {
|
|
||||||
want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText}
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
slots Slots
|
|
||||||
want []Slot
|
|
||||||
}{
|
|
||||||
{"all empty", Slots{}, want},
|
|
||||||
{
|
|
||||||
name: "all filled",
|
|
||||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"},
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only value left",
|
|
||||||
slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"},
|
|
||||||
want: []Slot{SlotValue},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := StillMissing(want, tc.slots)
|
|
||||||
if len(got) != len(tc.want) {
|
|
||||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != tc.want[i] {
|
|
||||||
t.Fatalf("StillMissing = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ type Slots struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
HasTime bool
|
HasTime bool
|
||||||
Key string
|
Key string
|
||||||
Value string // payload for a fact key, mirrors router.Slots.Value
|
|
||||||
HasKey bool
|
HasKey bool
|
||||||
Text string
|
Text string
|
||||||
Fn string
|
Fn string
|
||||||
@@ -105,9 +104,6 @@ func InheritSlots(prev, cur Slots) Slots {
|
|||||||
out.Key = prev.Key
|
out.Key = prev.Key
|
||||||
out.HasKey = true
|
out.HasKey = true
|
||||||
}
|
}
|
||||||
if out.Value == "" && prev.Value != "" {
|
|
||||||
out.Value = prev.Value
|
|
||||||
}
|
|
||||||
if out.Text == "" && prev.Text != "" {
|
if out.Text == "" && prev.Text != "" {
|
||||||
out.Text = prev.Text
|
out.Text = prev.Text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,14 +113,4 @@ func TestInheritSlots(t *testing.T) {
|
|||||||
if inherited6.Text != "какая погода в москве" {
|
if inherited6.Text != "какая погода в москве" {
|
||||||
t.Error("should inherit text when current is empty")
|
t.Error("should inherit text when current is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`}
|
|
||||||
inherited7 := InheritSlots(prevValue, Slots{})
|
|
||||||
if inherited7.Value != `"drank"` {
|
|
||||||
t.Error("should inherit value when current is empty")
|
|
||||||
}
|
|
||||||
kept := InheritSlots(prevValue, Slots{Value: "2l"})
|
|
||||||
if kept.Value != "2l" {
|
|
||||||
t.Error("should keep current value")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ type dismissProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type acceptProposedRoutineReq struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ReminderID int64 `json:"reminder_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||||
// for modules that live in-process for now (router, delivery) and by tests,
|
// for modules that live in-process for now (router, delivery) and by tests,
|
||||||
@@ -286,6 +291,9 @@ type CoreAPI interface {
|
|||||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||||
DismissProposedRoutine(ctx context.Context, id int64) error
|
DismissProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
// AcceptProposedRoutine flips a proposed routine to 'accepted' and links
|
||||||
|
// the reminder that will fire it. The caller creates the reminder first.
|
||||||
|
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error
|
||||||
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
// 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
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
|
|||||||
@@ -430,6 +430,10 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||||
|
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||||
var r chatResp
|
var r chatResp
|
||||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||||
|
|||||||
@@ -478,6 +478,9 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
return nil, ErrUnknownMethod
|
return nil, ErrUnknownMethod
|
||||||
}
|
}
|
||||||
|
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return ErrUnknownMethod
|
return ErrUnknownMethod
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
|||||||
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
return mapErr(a.s.DismissProposedRoutine(ctx, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||||
|
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
|
||||||
|
}
|
||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
func toTool(t store.Tool) Tool {
|
||||||
return Tool{
|
return Tool{
|
||||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||||
@@ -774,6 +778,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
}
|
}
|
||||||
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
||||||
|
|
||||||
|
case MethodAcceptProposedRoutine:
|
||||||
|
var p acceptProposedRoutineReq
|
||||||
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
|
||||||
|
|
||||||
case MethodRevertFact:
|
case MethodRevertFact:
|
||||||
var p struct {
|
var p struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const (
|
|||||||
MethodDeleteTool Method = "delete_tool"
|
MethodDeleteTool Method = "delete_tool"
|
||||||
MethodListProposedRoutines Method = "list_proposed_routines"
|
MethodListProposedRoutines Method = "list_proposed_routines"
|
||||||
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
||||||
|
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
|
||||||
MethodRevertFact Method = "revert_fact"
|
MethodRevertFact Method = "revert_fact"
|
||||||
MethodTickTrace Method = "tick_trace"
|
MethodTickTrace Method = "tick_trace"
|
||||||
MethodMorningStatus Method = "morning_status"
|
MethodMorningStatus Method = "morning_status"
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The three states a proposal can be in. A proposal starts 'proposed' and
|
||||||
|
// moves once, either way, and never moves again.
|
||||||
|
const (
|
||||||
|
RoutineProposed = "proposed"
|
||||||
|
RoutineAccepted = "accepted"
|
||||||
|
RoutineDismissed = "dismissed"
|
||||||
|
)
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to turn into a
|
// ProposedRoutine — a detected pattern the system wants to turn into a
|
||||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
||||||
@@ -30,6 +38,15 @@ var (
|
|||||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||||
// ErrProposedRoutineExists if one already exists for this action+object (any
|
// ErrProposedRoutineExists if one already exists for this action+object (any
|
||||||
// status) — the pattern detector should only propose once per pair.
|
// status) — the pattern detector should only propose once per pair.
|
||||||
|
//
|
||||||
|
// action+object is the "same routine" key. It is UNIQUE in the table, so a
|
||||||
|
// routine the human already dismissed can never come back: the detector will
|
||||||
|
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
||||||
|
// a nag.
|
||||||
|
//
|
||||||
|
// TODO(vikunja#46): the detector currently only writes here from the voice
|
||||||
|
// path. Once digestion runs the detector on its own tick, that tick should
|
||||||
|
// call this too, so a pattern gets noticed even with nobody at the mic.
|
||||||
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||||
@@ -70,14 +87,29 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string
|
|||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProposedRoutines returns all proposed routines with status='proposed',
|
// ListProposedRoutines returns the routines still waiting for an answer,
|
||||||
// newest first.
|
// newest first. This is what the /routines page shows.
|
||||||
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
|
||||||
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
}
|
||||||
FROM proposed_routines
|
|
||||||
WHERE status = 'proposed'
|
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
||||||
ORDER BY created_ts DESC, id DESC`)
|
// An empty status returns every row.
|
||||||
|
//
|
||||||
|
// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
|
||||||
|
// routine the human said yes to has a home the loop can see, instead of only
|
||||||
|
// the reminder row that accepting happened to create.
|
||||||
|
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
||||||
|
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||||
|
FROM proposed_routines`
|
||||||
|
var args []any
|
||||||
|
if status != "" {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list proposed routines: %w", err)
|
return nil, fmt.Errorf("list proposed routines: %w", err)
|
||||||
}
|
}
|
||||||
@@ -93,8 +125,19 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status changes below are an in-place UPDATE, on purpose. Facts are
|
||||||
|
// append-only (a correction writes a new row and sets voids_id) because a fact
|
||||||
|
// is a claim about the world and the old claim is still history worth keeping.
|
||||||
|
// A proposal is not a claim, it is a question with one answer, and the same
|
||||||
|
// shape already exists for tools (tools.status flips in place). The guard
|
||||||
|
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
||||||
|
// never be answered again.
|
||||||
|
//
|
||||||
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
||||||
// Returns error if not in 'proposed' status.
|
// Returns error if not in 'proposed' status.
|
||||||
|
//
|
||||||
|
// TODO(vikunja#46): the /routines page calls this through ipc to flip status
|
||||||
|
// from the authed surface.
|
||||||
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
|
||||||
res, err := s.db.ExecContext(ctx,
|
res, err := s.db.ExecContext(ctx,
|
||||||
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
||||||
|
|||||||
@@ -131,6 +131,96 @@ func TestListProposedRoutines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dismissed routine must never be proposed again. The detector will keep
|
||||||
|
// finding the same pattern; the store is what stops maven nagging about it.
|
||||||
|
func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
||||||
|
t.Fatalf("DismissProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The detector re-proposes the same pattern.
|
||||||
|
_, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour))
|
||||||
|
if !errors.Is(err, ErrProposedRoutineExists) {
|
||||||
|
t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must not reappear on the review page.
|
||||||
|
list, err := s.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProposedRoutines: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("want 0 proposed, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismissing again is a no-op, and accepting is refused.
|
||||||
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
||||||
|
t.Fatalf("second DismissProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) {
|
||||||
|
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
|
||||||
|
}
|
||||||
|
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if r.Status != RoutineDismissed {
|
||||||
|
t.Fatalf("want status=dismissed, got %s", r.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProposedRoutinesByStatus(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateReminder: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
|
||||||
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DismissProposedRoutine(ctx, drop); err != nil {
|
||||||
|
t.Fatalf("DismissProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
status string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{RoutineProposed, 0},
|
||||||
|
{RoutineAccepted, 1},
|
||||||
|
{RoutineDismissed, 1},
|
||||||
|
{"", 2}, // empty status ⇒ every row
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
list, err := s.ListProposedRoutinesByStatus(ctx, c.status)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err)
|
||||||
|
}
|
||||||
|
if len(list) != c.want {
|
||||||
|
t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupMissingProposedRoutine(t *testing.T) {
|
func TestLookupMissingProposedRoutine(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user