Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c860808528 | |||
| f7442c3aea | |||
| 75b067ac51 | |||
| 424d1b3446 | |||
| c47886c2bc | |||
| 54dc43516b | |||
| d30618ecb7 | |||
| 17b47ce206 |
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||||
|
|
||||||
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router eval-recall
|
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -69,7 +69,20 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(GO) version
|
||||||
|
|
||||||
test:
|
# fmt-check fails if any file needs gofmt. DESIGN.md has always said `make
|
||||||
|
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||||
|
# Run `gofmt -w` on whatever this prints.
|
||||||
|
fmt-check:
|
||||||
|
@bad=$$(gofmt -l internal cmd); \
|
||||||
|
if [ -n "$$bad" ]; then \
|
||||||
|
echo "these files need gofmt:"; echo "$$bad"; exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
vet:
|
||||||
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
|
$(GO) vet ./internal/... ./cmd/...
|
||||||
|
|
||||||
|
test: fmt-check vet
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -57,10 +57,10 @@ import (
|
|||||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||||
@@ -161,12 +161,12 @@ func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, d
|
|||||||
return errLocked
|
return errLocked
|
||||||
}
|
}
|
||||||
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
||||||
func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked }
|
func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked }
|
||||||
func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) {
|
func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) {
|
||||||
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 {
|
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return errLocked
|
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) {
|
||||||
@@ -247,15 +247,15 @@ func run(args []string) error {
|
|||||||
// ----- daemon components (only wired when unlocked) -----
|
// ----- daemon components (only wired when unlocked) -----
|
||||||
// Pre-declare so the unlock path can wire them later.
|
// Pre-declare so the unlock path can wire them later.
|
||||||
var (
|
var (
|
||||||
gatherer *loop.Gatherer
|
gatherer *loop.Gatherer
|
||||||
rules []loop.Rule
|
rules []loop.Rule
|
||||||
phr phraser.Phraser
|
phr phraser.Phraser
|
||||||
voiceW *voiceWiring
|
voiceW *voiceWiring
|
||||||
dispatcher *delivery.Dispatcher
|
dispatcher *delivery.Dispatcher
|
||||||
tl *tickLoop
|
tl *tickLoop
|
||||||
coreAPI ipc.CoreAPI
|
coreAPI ipc.CoreAPI
|
||||||
eco *ecosystemWiring
|
eco *ecosystemWiring
|
||||||
factWorker *factEnrichmentWorker
|
factWorker *factEnrichmentWorker
|
||||||
)
|
)
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockCompleter struct{ out string; err error }
|
type mockCompleter struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
|
// LLM-phrased — so a routine can't hallucinate. severity comes from config.
|
||||||
t.fireRoutines(ctx, now, state)
|
t.fireRoutines(ctx, now, state)
|
||||||
|
|
||||||
|
// accepted routines: patterns the user confirmed. read straight from the
|
||||||
|
// store each tick so the schedule survives a restart.
|
||||||
|
t.fireAcceptedRoutines(ctx, now, state)
|
||||||
|
|
||||||
// morning routines: daily checklists (medicine/water/pets/...), nagged at
|
// morning routines: daily checklists (medicine/water/pets/...), nagged at
|
||||||
// most once per day per routine, and only for items still unevidenced at
|
// most once per day per routine, and only for items still unevidenced at
|
||||||
// nudge time. See internal/morning for the "why not four timers" rationale.
|
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||||
@@ -377,6 +381,64 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fireAcceptedRoutines nudges about the routines the user accepted, once per
|
||||||
|
// interval (Vikunja #366). Accepting used to create a single reminder, so a
|
||||||
|
// non-weekly routine fired once and went quiet forever; the schedule lives in
|
||||||
|
// the proposed_routines row now and the loop re-reads it every tick.
|
||||||
|
//
|
||||||
|
// A routine is a care-class nudge and goes through the restraint gate like any
|
||||||
|
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
|
||||||
|
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
|
||||||
|
// goes out on the next tick that the gate allows — one nudge, held, not dropped
|
||||||
|
// and not repeated.
|
||||||
|
//
|
||||||
|
// The body is literal text built from the detected action and object, not
|
||||||
|
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
|
||||||
|
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
rows, err := t.store.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: list accepted routines: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted := make([]routine.Accepted, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.AcceptedTs == nil {
|
||||||
|
continue // accepted before the schedule column existed — no clock to start from.
|
||||||
|
}
|
||||||
|
accepted = append(accepted, routine.Accepted{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Action + " " + r.Object,
|
||||||
|
IntervalDays: r.IntervalDays,
|
||||||
|
Accepted: *r.AcceptedTs,
|
||||||
|
LastFired: r.LastFiredTs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range routine.DueAccepted(accepted, now) {
|
||||||
|
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := "пора: " + a.Name
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
continue // routing dropped it — leave it due.
|
||||||
|
}
|
||||||
|
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
|
||||||
|
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fireMorningRoutines checks each configured checklist against today's facts
|
// fireMorningRoutines checks each configured checklist against today's facts
|
||||||
// and dispatches a nag listing exactly what's still missing, at most once per
|
// and dispatches a nag listing exactly what's still missing, at most once per
|
||||||
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
// routine per calendar day. Fact reads happen here (not in loop.Gatherer)
|
||||||
|
|||||||
@@ -96,6 +96,115 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine
|
||||||
|
// with a 3-day interval must nudge every 3 days, not once. It also must not
|
||||||
|
// replay the occurrences it slept through: after a 30-day gap it nudges once.
|
||||||
|
func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accepted := refNow()
|
||||||
|
|
||||||
|
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
|
||||||
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
const rule = "routine:полить цветы"
|
||||||
|
|
||||||
|
// Same day as the accept: not due yet.
|
||||||
|
markPresent(t, st, ctx, accepted)
|
||||||
|
tl.tick(ctx, accepted.Add(time.Hour))
|
||||||
|
if n := countSends(sink, rule); n != 0 {
|
||||||
|
t.Fatalf("routine fired %d times before its first interval passed, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three days later: the first nudge.
|
||||||
|
first := accepted.Add(3 * 24 * time.Hour)
|
||||||
|
markPresent(t, st, ctx, first)
|
||||||
|
tl.tick(ctx, first)
|
||||||
|
if n := countSends(sink, rule); n != 1 {
|
||||||
|
t.Fatalf("first interval: sends = %d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next day: still inside the interval, silent.
|
||||||
|
sink.sends = nil
|
||||||
|
markPresent(t, st, ctx, first.Add(24*time.Hour))
|
||||||
|
tl.tick(ctx, first.Add(24*time.Hour))
|
||||||
|
if n := countSends(sink, rule); n != 0 {
|
||||||
|
t.Fatalf("mid-interval: sends = %d, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three days after the first nudge: it fires again. This is the bug —
|
||||||
|
// a one-shot reminder would never come back.
|
||||||
|
second := first.Add(3 * 24 * time.Hour)
|
||||||
|
markPresent(t, st, ctx, second)
|
||||||
|
tl.tick(ctx, second)
|
||||||
|
if n := countSends(sink, rule); n != 1 {
|
||||||
|
t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A long silence must not turn into a backlog of missed nudges.
|
||||||
|
sink.sends = nil
|
||||||
|
late := second.Add(30 * 24 * time.Hour)
|
||||||
|
markPresent(t, st, ctx, late)
|
||||||
|
tl.tick(ctx, late)
|
||||||
|
if n := countSends(sink, rule); n != 1 {
|
||||||
|
t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they
|
||||||
|
// do not inherit the reminder gate bypass. Away presence drops a care-class
|
||||||
|
// nudge, and the routine stays due so it nudges once the user is back.
|
||||||
|
func TestTickAcceptedRoutineRespectsGate(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accepted := refNow()
|
||||||
|
|
||||||
|
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
|
||||||
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newTestTickLoop(t, st, sink, nil)
|
||||||
|
const rule = "routine:полить цветы"
|
||||||
|
|
||||||
|
// No presence probes at all ⇒ away ⇒ the care gate blocks the nudge.
|
||||||
|
due := accepted.Add(3 * 24 * time.Hour)
|
||||||
|
tl.tick(ctx, due)
|
||||||
|
if n := countSends(sink, rule); n != 0 {
|
||||||
|
t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back at the desk a minute later: the nudge that was held now goes out.
|
||||||
|
back := due.Add(time.Minute)
|
||||||
|
markPresent(t, st, ctx, back)
|
||||||
|
tl.tick(ctx, back)
|
||||||
|
if n := countSends(sink, rule); n != 1 {
|
||||||
|
t.Fatalf("present again: sends = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countSends counts captured sends for one rule name.
|
||||||
|
func countSends(sink *fakeSink, rule string) int {
|
||||||
|
n := 0
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if s.RuleName == rule {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// refNow — fixed tick time so presence decay + since durations are deterministic.
|
// refNow — fixed tick time so presence decay + since durations are deterministic.
|
||||||
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
|
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
|||||||
+5
-16
@@ -1421,23 +1421,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
|
|||||||
switch classifyConfirm(text) {
|
switch classifyConfirm(text) {
|
||||||
case confirmYes:
|
case confirmYes:
|
||||||
h.pendingRoutine = nil
|
h.pendingRoutine = nil
|
||||||
// Create a recurring reminder at the detected interval.
|
// Only record the acceptance. The tick loop reads accepted
|
||||||
// Weekly patterns get a cron expression; arbitrary intervals
|
// routines and nudges on their own interval. Building a reminder
|
||||||
// fire once and the detector re-proposes on the next cycle.
|
// here made a routine fire exactly once (Vikunja #366).
|
||||||
intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour))
|
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
|
||||||
fire := h.now().Add(intervalDur)
|
|
||||||
cron := ""
|
|
||||||
if pr.interval >= 6.5 && pr.interval <= 7.5 {
|
|
||||||
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
|
|
||||||
}
|
|
||||||
payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object)
|
|
||||||
remID, err := h.api.CreateReminder(ctx, fire, payload, cron)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("voice: create routine reminder: %v", err)
|
|
||||||
return "не получилось поставить напоминание.", true
|
|
||||||
}
|
|
||||||
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil {
|
|
||||||
log.Printf("voice: accept proposed routine: %v", err)
|
log.Printf("voice: accept proposed routine: %v", err)
|
||||||
|
return "не получилось запомнить рутину.", true
|
||||||
}
|
}
|
||||||
return "буду напоминать.", true
|
return "буду напоминать.", true
|
||||||
case confirmNo:
|
case confirmNo:
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
|||||||
var d ecoData
|
var d ecoData
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(3)
|
||||||
go func() { defer wg.Done(); d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }()
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows)
|
||||||
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
|
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
|
||||||
|
|||||||
@@ -928,7 +928,6 @@ type routineCore struct {
|
|||||||
routines []ipc.ProposedRoutine
|
routines []ipc.ProposedRoutine
|
||||||
dismissed int64
|
dismissed int64
|
||||||
acceptedID int64
|
acceptedID int64
|
||||||
acceptedRe int64
|
|
||||||
remCron string
|
remCron string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,8 +940,8 @@ func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error {
|
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error {
|
||||||
c.acceptedID, c.acceptedRe = id, remID
|
c.acceptedID = id
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,18 +989,22 @@ func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) {
|
// Accepting only flips the status. It used to also create a one-shot reminder,
|
||||||
|
// which is why a non-weekly routine fired once and then went quiet forever
|
||||||
|
// (Vikunja #366). The tick loop owns the schedule now, so a reminder here would
|
||||||
|
// be a second, competing schedule.
|
||||||
|
func TestHandleRoutines_Accept_FlipsStatusAndMakesNoReminder(t *testing.T) {
|
||||||
core := weeklyRoutineCore()
|
core := weeklyRoutineCore()
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
|
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
if core.acceptedID != 3 || core.acceptedRe != 77 {
|
if core.acceptedID != 3 {
|
||||||
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe)
|
t.Fatalf("accepted id = %d, want 3", core.acceptedID)
|
||||||
}
|
}
|
||||||
if core.remCron == "" {
|
if core.remCron != "" {
|
||||||
t.Fatal("a weekly pattern should get a cron expression")
|
t.Fatalf("accepting must not create a reminder, got cron %q", core.remCron)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -898,20 +898,11 @@ func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
|||||||
return errors.New("no such proposed routine")
|
return errors.New("no such proposed routine")
|
||||||
}
|
}
|
||||||
|
|
||||||
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour)))
|
// No reminder is created here. Accepting only flips the status; the tick
|
||||||
cron := ""
|
// loop reads accepted routines and nudges on the interval (Vikunja #366).
|
||||||
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 {
|
// The old code made a one-shot reminder, so a non-weekly routine fired
|
||||||
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
|
// once and then went quiet forever.
|
||||||
}
|
return core.AcceptProposedRoutine(ctx, id)
|
||||||
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) {
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ 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 {
|
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
||||||
|
|||||||
+4
-5
@@ -238,8 +238,7 @@ type dismissProposedRoutineReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type acceptProposedRoutineReq struct {
|
type acceptProposedRoutineReq struct {
|
||||||
ID int64 `json:"id"`
|
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:
|
||||||
@@ -291,9 +290,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
|
// AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
|
||||||
// the reminder that will fire it. The caller creates the reminder first.
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error
|
AcceptProposedRoutine(ctx context.Context, id 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,8 +430,8 @@ 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 {
|
func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil)
|
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||||
|
|||||||
@@ -478,7 +478,7 @@ 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 {
|
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ 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 {
|
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID))
|
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
func toTool(t store.Tool) Tool {
|
||||||
@@ -783,7 +783,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID)
|
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID)
|
||||||
|
|
||||||
case MethodRevertFact:
|
case MethodRevertFact:
|
||||||
var p struct {
|
var p struct {
|
||||||
|
|||||||
+27
-27
@@ -13,39 +13,39 @@ import (
|
|||||||
type Method string
|
type Method string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MethodWriteFact Method = "write_fact"
|
MethodWriteFact Method = "write_fact"
|
||||||
MethodLatestFact Method = "latest_fact"
|
MethodLatestFact Method = "latest_fact"
|
||||||
MethodLatestFactBySource Method = "latest_fact_by_source"
|
MethodLatestFactBySource Method = "latest_fact_by_source"
|
||||||
MethodSince Method = "since"
|
MethodSince Method = "since"
|
||||||
MethodPresence Method = "presence"
|
MethodPresence Method = "presence"
|
||||||
MethodCreateReminder Method = "create_reminder"
|
MethodCreateReminder Method = "create_reminder"
|
||||||
MethodMarkReminder Method = "mark_reminder"
|
MethodMarkReminder Method = "mark_reminder"
|
||||||
MethodListReminders Method = "list_reminders"
|
MethodListReminders Method = "list_reminders"
|
||||||
MethodRecordNudge Method = "record_nudge"
|
MethodRecordNudge Method = "record_nudge"
|
||||||
MethodResolveNudge Method = "resolve_nudge"
|
MethodResolveNudge Method = "resolve_nudge"
|
||||||
MethodRecentOutcomes Method = "recent_outcomes"
|
MethodRecentOutcomes Method = "recent_outcomes"
|
||||||
MethodRecentFacts Method = "recent_facts"
|
MethodRecentFacts Method = "recent_facts"
|
||||||
MethodCalendarEvents Method = "calendar_events"
|
MethodCalendarEvents Method = "calendar_events"
|
||||||
MethodRecentNudges Method = "recent_nudges"
|
MethodRecentNudges Method = "recent_nudges"
|
||||||
MethodWriteNote Method = "write_note"
|
MethodWriteNote Method = "write_note"
|
||||||
MethodQueryNotes Method = "query_notes"
|
MethodQueryNotes Method = "query_notes"
|
||||||
MethodRecentNotes Method = "recent_notes"
|
MethodRecentNotes Method = "recent_notes"
|
||||||
MethodProposeTool Method = "propose_tool"
|
MethodProposeTool Method = "propose_tool"
|
||||||
MethodEnableTool Method = "enable_tool"
|
MethodEnableTool Method = "enable_tool"
|
||||||
MethodDisableTool Method = "disable_tool"
|
MethodDisableTool Method = "disable_tool"
|
||||||
MethodAssertStepUp Method = "assert_stepup"
|
MethodAssertStepUp Method = "assert_stepup"
|
||||||
MethodStoreEncryptionKey Method = "store_encryption_key"
|
MethodStoreEncryptionKey Method = "store_encryption_key"
|
||||||
MethodUnlock Method = "unlock"
|
MethodUnlock Method = "unlock"
|
||||||
MethodLookupTool Method = "lookup_tool"
|
MethodLookupTool Method = "lookup_tool"
|
||||||
MethodListTools Method = "list_tools"
|
MethodListTools Method = "list_tools"
|
||||||
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"
|
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"
|
||||||
MethodChat Method = "chat"
|
MethodChat Method = "chat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestComplete(t *testing.T) {
|
|||||||
t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path)
|
t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path)
|
||||||
}
|
}
|
||||||
var reqBody struct {
|
var reqBody struct {
|
||||||
Messages []struct {
|
Messages []struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
} `json:"messages"`
|
} `json:"messages"`
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ func TestPythonDateParser(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
text string
|
text string
|
||||||
wantOK bool
|
wantOK bool
|
||||||
checkT func(t *testing.T, got, now time.Time)
|
checkT func(t *testing.T, got, now time.Time)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ru relative — через час",
|
name: "ru relative — через час",
|
||||||
|
|||||||
@@ -23,17 +23,27 @@ func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} }
|
|||||||
|
|
||||||
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
|
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
|
||||||
// action objects (one per ask; compound utterances → multiple). Enum + key set
|
// action objects (one per ask; compound utterances → multiple). Enum + key set
|
||||||
// prevent free-form drift from a sub-1B model.
|
// prevent free-form drift from a sub-1B model. The string rule is length-bounded
|
||||||
|
// so a repetition loop cannot fill the whole token budget with one field and
|
||||||
|
// truncate the JSON.
|
||||||
const routeGrammar = `
|
const routeGrammar = `
|
||||||
root ::= "[" ws action ("," ws action)* ws "]"
|
root ::= "[" ws action ("," ws action)* ws "]"
|
||||||
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
||||||
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
|
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
|
||||||
field ::= key ws ":" ws string
|
field ::= key ws ":" ws string
|
||||||
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
||||||
string ::= "\"" ([^"\\] | "\\" .)* "\""
|
string ::= "\"" ([^"\\] | "\\" .){0,120} "\""
|
||||||
ws ::= [ \t\n]*
|
ws ::= [ \t\n]*
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits
|
||||||
|
// above the fact test and there is an explicit question test. Before that, a
|
||||||
|
// question naming a fact key ("сколько воды я выпил с утра") matched the fact
|
||||||
|
// rule first and was stored as an assertion — 15 of 76 fixture cases.
|
||||||
|
//
|
||||||
|
// The training workspace keeps its own copy of this prompt for relabelling, and
|
||||||
|
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
|
||||||
|
// repo and was not touched, so parity will fail until it gets the same edit.
|
||||||
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
||||||
|
|
||||||
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
||||||
@@ -41,22 +51,26 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
Классифицируй по цели пользователя. Порядок решения:
|
Классифицируй по цели пользователя. Порядок решения:
|
||||||
1. Хочет напоминание в будущем → reminder
|
1. Хочет напоминание в будущем → reminder
|
||||||
2. Явно просит сохранить информацию → note
|
2. Явно просит сохранить информацию → note
|
||||||
3. Сообщает или обновляет текущее состояние/событие → fact
|
3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query
|
||||||
4. Хочет получить информацию → query
|
4. Хочет получить информацию, в том числе о своих же данных → query
|
||||||
5. Просит выполнить работу → act
|
5. Утверждает: сообщает или обновляет текущее состояние/событие → fact
|
||||||
6. Про ассистента, настройки или память → system
|
6. Просит выполнить работу → act
|
||||||
7. Иначе → chat
|
7. Про ассистента, настройки или память → system
|
||||||
|
8. Иначе → chat
|
||||||
|
|
||||||
Различия:
|
Различия:
|
||||||
- note — сохранить информацию, без напоминания. text = суть.
|
- note — сохранить информацию, без напоминания. text = суть.
|
||||||
- reminder — уведомить позже. text = что напомнить.
|
- reminder — уведомить позже. text = что напомнить.
|
||||||
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
||||||
|
- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact.
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
"запиши пароль" → {"intent":"note","text":"пароль"}
|
"запиши пароль" → {"intent":"note","text":"пароль"}
|
||||||
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
||||||
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
||||||
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
||||||
|
"сколько воды я выпил с утра" → {"intent":"query","text":"сколько воды я выпил с утра"}
|
||||||
|
"сколько раз я ел вчера?" → {"intent":"query","text":"сколько раз я ел вчера"}
|
||||||
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
||||||
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
||||||
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
||||||
@@ -65,6 +79,11 @@ const routeSystem = `Классифицируй ровно одно сообще
|
|||||||
|
|
||||||
Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
|
Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
|
||||||
|
|
||||||
|
// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field
|
||||||
|
// until it runs out of tokens, which truncates the JSON. 1.15 is enough to break
|
||||||
|
// the loop without hurting short slot values.
|
||||||
|
const routeRepeatPenalty = 1.15
|
||||||
|
|
||||||
type routeAction struct {
|
type routeAction struct {
|
||||||
Intent string `json:"intent"`
|
Intent string `json:"intent"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@@ -74,7 +93,13 @@ type routeAction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) {
|
func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) {
|
||||||
raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128})
|
raw, err := lr.c.Complete(ctx, llm.Req{
|
||||||
|
System: routeSystem,
|
||||||
|
User: utterance,
|
||||||
|
Grammar: routeGrammar,
|
||||||
|
MaxTokens: 128,
|
||||||
|
RepeatPenalty: routeRepeatPenalty,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Decision{}, false, err
|
return Decision{}, false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,61 @@ package router
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockLLM struct{ out string; err error }
|
type mockLLM struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
got *llm.Req // last request, when the test wants to inspect it
|
||||||
|
}
|
||||||
|
|
||||||
func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
if m.got != nil {
|
||||||
|
*m.got = r
|
||||||
|
}
|
||||||
|
return m.out, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a repeat penalty the model loops inside the text field until MaxTokens
|
||||||
|
// and the truncated JSON fails to parse.
|
||||||
|
func TestLLMRouterSetsRepeatPenalty(t *testing.T) {
|
||||||
|
var got llm.Req
|
||||||
|
lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got})
|
||||||
|
if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil {
|
||||||
|
t.Fatalf("route: %v", err)
|
||||||
|
}
|
||||||
|
if got.RepeatPenalty <= 1 {
|
||||||
|
t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unbounded string rule lets one field eat the whole token budget.
|
||||||
|
func TestRouteGrammarBoundsStrings(t *testing.T) {
|
||||||
|
if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) {
|
||||||
|
t.Fatal("grammar string rule lost its length bound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question naming a fact key used to be stored as a fact because the fact rule
|
||||||
|
// was tested first. Keep the query rule above it.
|
||||||
|
func TestRoutePromptTestsQueryBeforeFact(t *testing.T) {
|
||||||
|
query := strings.Index(routeSystem, "→ query")
|
||||||
|
fact := strings.Index(routeSystem, "состояние/событие → fact")
|
||||||
|
if query < 0 || fact < 0 {
|
||||||
|
t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact)
|
||||||
|
}
|
||||||
|
if query > fact {
|
||||||
|
t.Fatal("query rule must come before the fact rule")
|
||||||
|
}
|
||||||
|
if !strings.Contains(routeSystem, "Задаёт вопрос") {
|
||||||
|
t.Fatal("prompt lost the explicit question test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLLMRouterFactMapping(t *testing.T) {
|
func TestLLMRouterFactMapping(t *testing.T) {
|
||||||
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
||||||
|
|||||||
@@ -55,6 +55,47 @@ func Validate(routines []Routine) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accepted — an accepted routine proposal as the tick driver sees it. This is a
|
||||||
|
// different shape from Routine: the schedule is a plain interval the pattern
|
||||||
|
// detector measured, not an operator-written cron expression. Accepted is when
|
||||||
|
// the human said yes; LastFired is nil until the first nudge.
|
||||||
|
type Accepted struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
IntervalDays float64
|
||||||
|
Accepted time.Time
|
||||||
|
LastFired *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// DueAccepted returns the accepted routines whose interval has passed. It does
|
||||||
|
// not mutate anything — the caller persists the new last-fired time, because
|
||||||
|
// that has to survive a restart (unlike Due's in-memory map).
|
||||||
|
//
|
||||||
|
// The clock starts at LastFired, or at Accepted for a routine that has never
|
||||||
|
// nudged. A routine with a non-positive interval never fires: a bad interval
|
||||||
|
// should mean silence, not a nudge every tick.
|
||||||
|
//
|
||||||
|
// One occurrence per call, no catch-up: the caller stamps the fire time as now,
|
||||||
|
// so a routine that was silent for a month nudges once and then waits a full
|
||||||
|
// interval. Never a backlog.
|
||||||
|
func DueAccepted(rs []Accepted, now time.Time) []Accepted {
|
||||||
|
var out []Accepted
|
||||||
|
for _, r := range rs {
|
||||||
|
if r.IntervalDays <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
since := r.Accepted
|
||||||
|
if r.LastFired != nil {
|
||||||
|
since = *r.LastFired
|
||||||
|
}
|
||||||
|
gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour))
|
||||||
|
if !now.Before(since.Add(gap)) {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Due returns the routines whose schedule crossed since their last fire and
|
// Due returns the routines whose schedule crossed since their last fire and
|
||||||
// records now as the new last-fire time for each one returned. The caller owns
|
// records now as the new last-fire time for each one returned. The caller owns
|
||||||
// `last` (the tick driver holds it across ticks); Due mutates it in place.
|
// `last` (the tick driver holds it across ticks); Due mutates it in place.
|
||||||
|
|||||||
@@ -5,6 +5,40 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestDueAcceptedFiresOncePerInterval(t *testing.T) {
|
||||||
|
accepted := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
fired := accepted.Add(3 * 24 * time.Hour)
|
||||||
|
rs := []Accepted{
|
||||||
|
{ID: 1, Name: "полить цветы", IntervalDays: 3, Accepted: accepted},
|
||||||
|
{ID: 2, Name: "покормить рыб", IntervalDays: 3, Accepted: accepted, LastFired: &fired},
|
||||||
|
{ID: 3, Name: "битый интервал", IntervalDays: 0, Accepted: accepted},
|
||||||
|
}
|
||||||
|
|
||||||
|
// One day in: nothing has waited a full interval.
|
||||||
|
if got := DueAccepted(rs, accepted.Add(24*time.Hour)); len(got) != 0 {
|
||||||
|
t.Fatalf("want nothing due after 1 day, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three days in: the never-fired one is due. The one that already fired at
|
||||||
|
// day 3 starts its next three days from there. A zero interval never fires.
|
||||||
|
got := DueAccepted(rs, fired)
|
||||||
|
if len(got) != 1 || got[0].ID != 1 {
|
||||||
|
t.Fatalf("want only routine 1 due at day 3, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Six days in: both real routines are due.
|
||||||
|
if got := DueAccepted(rs, accepted.Add(6*24*time.Hour)); len(got) != 2 {
|
||||||
|
t.Fatalf("want both routines due at day 6, got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A month later the zero-interval routine is still silent.
|
||||||
|
for _, r := range DueAccepted(rs, accepted.Add(30*24*time.Hour)) {
|
||||||
|
if r.ID == 3 {
|
||||||
|
t.Fatal("a routine with a zero interval must never fire")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidate(t *testing.T) {
|
func TestValidate(t *testing.T) {
|
||||||
ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}}
|
ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}}
|
||||||
if err := Validate(ok); err != nil {
|
if err := Validate(ok); err != nil {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
|
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
|
||||||
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
|
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
|
||||||
|
`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
|
||||||
|
ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ const (
|
|||||||
RoutineDismissed = "dismissed"
|
RoutineDismissed = "dismissed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to turn into a
|
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
||||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
||||||
// set); 'dismissed' means the human declined and we won't re-propose.
|
// 'dismissed' means the human declined and we won't re-propose.
|
||||||
|
//
|
||||||
|
// AcceptedTs is when the human said yes; it is the clock start for the first
|
||||||
|
// nudge. LastFiredTs is when the last nudge went out, nil until the first one.
|
||||||
|
// ReminderID is only set on rows accepted before Vikunja #366, when accepting
|
||||||
|
// created a one-shot reminder instead.
|
||||||
type ProposedRoutine struct {
|
type ProposedRoutine struct {
|
||||||
ID int64
|
ID int64
|
||||||
Action string
|
Action string
|
||||||
@@ -27,7 +32,9 @@ type ProposedRoutine struct {
|
|||||||
IntervalDays float64
|
IntervalDays float64
|
||||||
Status string // proposed | accepted | dismissed
|
Status string // proposed | accepted | dismissed
|
||||||
CreatedTs time.Time
|
CreatedTs time.Time
|
||||||
ReminderID *int64 // set when accepted
|
ReminderID *int64
|
||||||
|
AcceptedTs *time.Time
|
||||||
|
LastFiredTs *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -74,7 +81,7 @@ func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string
|
|||||||
// nil (no error) when no row exists.
|
// nil (no error) when no row exists.
|
||||||
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
|
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
||||||
FROM proposed_routines
|
FROM proposed_routines
|
||||||
WHERE action = ? AND object = ?`, action, object)
|
WHERE action = ? AND object = ?`, action, object)
|
||||||
r, err := scanProposedRoutine(row)
|
r, err := scanProposedRoutine(row)
|
||||||
@@ -95,12 +102,8 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
|
|||||||
|
|
||||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
||||||
// An empty status returns every row.
|
// 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) {
|
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
||||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
||||||
FROM proposed_routines`
|
FROM proposed_routines`
|
||||||
var args []any
|
var args []any
|
||||||
if status != "" {
|
if status != "" {
|
||||||
@@ -133,15 +136,14 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
|
|||||||
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
// `AND status = 'proposed'` makes the move one-way: an answered proposal can
|
||||||
// never be answered again.
|
// never be answered again.
|
||||||
//
|
//
|
||||||
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id.
|
// AcceptProposedRoutine flips status to 'accepted' and records when. From that
|
||||||
// Returns error if not in 'proposed' status.
|
// timestamp the tick loop owns the schedule: it re-reads accepted rows every
|
||||||
//
|
// tick and nudges when the interval has passed. Returns an error if the row is
|
||||||
// TODO(vikunja#46): the /routines page calls this through ipc to flip status
|
// not in 'proposed' status.
|
||||||
// from the authed surface.
|
func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) 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', accepted_ts = ? WHERE id = ? AND status = 'proposed'`,
|
||||||
reminderID, id)
|
ts.UnixMilli(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("accept proposed routine: %w", err)
|
return fmt.Errorf("accept proposed routine: %w", err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +154,42 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAcceptedRoutines returns every accepted routine, oldest first. The tick
|
||||||
|
// loop reads this each tick and decides which ones are due.
|
||||||
|
func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
||||||
|
FROM proposed_routines
|
||||||
|
WHERE status = 'accepted'
|
||||||
|
ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list accepted routines: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ProposedRoutine
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanProposedRoutine(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRoutineFired records that a routine just nudged. The stored time is the
|
||||||
|
// nudge time, not the time it was theoretically due, so a routine that was
|
||||||
|
// silent for a while starts its next interval from now — missed occurrences are
|
||||||
|
// dropped, never replayed as a backlog.
|
||||||
|
func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`,
|
||||||
|
ts.UnixMilli(), id); err != nil {
|
||||||
|
return fmt.Errorf("mark routine fired: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DismissProposedRoutine flips status to 'dismissed'. Idempotent.
|
// DismissProposedRoutine flips status to 'dismissed'. Idempotent.
|
||||||
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
|
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
@@ -168,12 +206,24 @@ func scanProposedRoutine(sc scanner) (ProposedRoutine, error) {
|
|||||||
var r ProposedRoutine
|
var r ProposedRoutine
|
||||||
var created int64
|
var created int64
|
||||||
var reminderID sql.NullInt64
|
var reminderID sql.NullInt64
|
||||||
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil {
|
var accepted, lastFired sql.NullInt64
|
||||||
|
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil {
|
||||||
return ProposedRoutine{}, err
|
return ProposedRoutine{}, err
|
||||||
}
|
}
|
||||||
r.CreatedTs = time.UnixMilli(created).UTC()
|
r.CreatedTs = time.UnixMilli(created).UTC()
|
||||||
if reminderID.Valid {
|
if reminderID.Valid {
|
||||||
r.ReminderID = &reminderID.Int64
|
r.ReminderID = &reminderID.Int64
|
||||||
}
|
}
|
||||||
|
r.AcceptedTs = millisToTime(accepted)
|
||||||
|
r.LastFiredTs = millisToTime(lastFired)
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// millisToTime turns a nullable unix-millis column into a *time.Time.
|
||||||
|
func millisToTime(v sql.NullInt64) *time.Time {
|
||||||
|
if !v.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.UnixMilli(v.Int64).UTC()
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,12 +43,7 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accept
|
// Accept
|
||||||
// First create a reminder to link
|
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
|
||||||
remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateReminder: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil {
|
|
||||||
t.Fatalf("AcceptProposedRoutine: %v", err)
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +55,50 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
|
|||||||
if r.Status != "accepted" {
|
if r.Status != "accepted" {
|
||||||
t.Fatalf("want status=accepted, got %s", r.Status)
|
t.Fatalf("want status=accepted, got %s", r.Status)
|
||||||
}
|
}
|
||||||
if r.ReminderID == nil || *r.ReminderID != remID {
|
if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) {
|
||||||
t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID)
|
t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs)
|
||||||
|
}
|
||||||
|
if r.LastFiredTs != nil {
|
||||||
|
t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted
|
||||||
|
// list, and the last-fired stamp it writes back after a nudge.
|
||||||
|
func TestAcceptedRoutineFiredTimestamp(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
|
||||||
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAcceptedRoutines: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != id {
|
||||||
|
t.Fatalf("want the one accepted routine, got %+v", list)
|
||||||
|
}
|
||||||
|
if list[0].IntervalDays != 3.0 {
|
||||||
|
t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
fired := now.Add(3 * 24 * time.Hour)
|
||||||
|
if err := s.MarkRoutineFired(ctx, id, fired); err != nil {
|
||||||
|
t.Fatalf("MarkRoutineFired: %v", err)
|
||||||
|
}
|
||||||
|
list, err = s.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAcceptedRoutines: %v", err)
|
||||||
|
}
|
||||||
|
if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) {
|
||||||
|
t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +202,7 @@ func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
|
|||||||
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
if err := s.DismissProposedRoutine(ctx, id); err != nil {
|
||||||
t.Fatalf("second DismissProposedRoutine: %v", err)
|
t.Fatalf("second DismissProposedRoutine: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) {
|
if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) {
|
||||||
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
|
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
|
||||||
}
|
}
|
||||||
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
|
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
|
||||||
@@ -190,11 +227,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateProposedRoutine: %v", err)
|
t.Fatalf("CreateProposedRoutine: %v", err)
|
||||||
}
|
}
|
||||||
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "")
|
if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil {
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateReminder: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
|
|
||||||
t.Fatalf("AcceptProposedRoutine: %v", err)
|
t.Fatalf("AcceptProposedRoutine: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.DismissProposedRoutine(ctx, drop); err != nil {
|
if err := s.DismissProposedRoutine(ctx, drop); err != nil {
|
||||||
|
|||||||
@@ -55,4 +55,9 @@ func spokenDate(dd, mm, yyyy string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
|
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||||
func gap(y string) string { if y == "" { return "" }; return " " + y }
|
func gap(y string) string {
|
||||||
|
if y == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " " + y
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user