Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb8d26b62 | |||
| bf99fd4192 |
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
func TestPickLLMRouterOff(t *testing.T) {
|
||||
if r := pickLLMRouter(false, llm.New("http://127.0.0.1:1", time.Second)); r != nil {
|
||||
t.Error("flag off should give no LLM router")
|
||||
}
|
||||
}
|
||||
|
||||
// The operator can turn the flag on without an LLM phraser configured. That must
|
||||
// leave the classifier running, not panic.
|
||||
func TestPickLLMRouterOnWithoutClient(t *testing.T) {
|
||||
if r := pickLLMRouter(true, nil); r != nil {
|
||||
t.Error("no llama-server should give no LLM router")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLLMRouterOn(t *testing.T) {
|
||||
if r := pickLLMRouter(true, llm.New("http://127.0.0.1:1", time.Second)); r == nil {
|
||||
t.Error("flag on with a client should give an LLM router")
|
||||
}
|
||||
}
|
||||
@@ -166,9 +166,6 @@ func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRou
|
||||
return nil, 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) {
|
||||
return ipc.Tool{}, errLocked
|
||||
}
|
||||
|
||||
+20
-3
@@ -199,8 +199,6 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
|
||||
}
|
||||
// LLM router disabled — the classifier handles routing reliably.
|
||||
|
||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||
// router only matches acts the executor can run (one source of truth).
|
||||
@@ -208,7 +206,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
if threshold <= 0 {
|
||||
threshold = config.DefaultRouterThreshold
|
||||
}
|
||||
rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled
|
||||
// Both routing paths are weak on held-out utterances — the classifier gets
|
||||
// 36.8% of intents right, the resident model 50.0% and much slower. Off by
|
||||
// default (see config.VoiceConfig.LLMRouter); the classifier always stays
|
||||
// wired as the fallback, so a model error never breaks a turn.
|
||||
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.LLMRouter, llmClient))
|
||||
|
||||
// ----- sessions registry (shared with voicesink) -----
|
||||
sessions := voice.NewSessions()
|
||||
@@ -1048,6 +1050,21 @@ func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (v
|
||||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil
|
||||
}
|
||||
|
||||
// pickLLMRouter returns the LLM router when the operator asked for it and there
|
||||
// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then
|
||||
// routes with the classifier, so an unusable setting costs accuracy, not turns.
|
||||
func pickLLMRouter(enabled bool, c *llm.Client) *router.LLMRouter {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if c == nil {
|
||||
log.Printf("voice: voice.llm_router is on but there is no llama-server to route with (the phraser is not an LLM phraser) — using the classifier instead")
|
||||
return nil
|
||||
}
|
||||
log.Printf("voice: LLM router enabled")
|
||||
return router.NewLLMRouter(c)
|
||||
}
|
||||
|
||||
// buildRouter constructs the reactive-path router with the given embedder
|
||||
// and confidence threshold.
|
||||
// - stage-0 grammars from DefaultActMatcher whose fn allowlist is exactly
|
||||
|
||||
@@ -918,126 +918,3 @@ func TestHandleTools_ListToolsError_502(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-101
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
@@ -396,6 +395,9 @@ func main() {
|
||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
handleMorning(w, r, core)
|
||||
})
|
||||
@@ -448,11 +450,6 @@ func main() {
|
||||
mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
||||
@@ -683,24 +680,22 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
</section>
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||
// routinesHTML — proposed routine review surface. Lists detected patterns
|
||||
// awaiting human confirmation, with accept (→ reminder) and dismiss buttons.
|
||||
const routinesHTML = `{{template "shellTop" "routines"}}
|
||||
<h1>Routines</h1>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>noticed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<div class=scroll><table><tr><th>maven noticed</th><th>when</th><th></th><th></th></tr>
|
||||
<h2 class=card-title>proposed <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>
|
||||
{{range .Proposed}}<tr>
|
||||
<td>{{.Phrase}}</td><td class=muted>{{.Noticed}}</td>
|
||||
<td><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>
|
||||
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
|
||||
<td>
|
||||
<form method=post action=/routines class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=dismiss>
|
||||
<button class="btn btn-muted">dismiss</button></form></td>
|
||||
<button class="btn btn-muted">dismiss</button></form>
|
||||
</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
|
||||
@@ -790,23 +785,7 @@ func handleReminders(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) {
|
||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -822,17 +801,6 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
return
|
||||
}
|
||||
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":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
@@ -854,66 +822,12 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := routinesTmpl.Execute(w, struct {
|
||||
Msg string
|
||||
Proposed []routineRow
|
||||
}{msg, routineRows(proposed)}); err != nil {
|
||||
Proposed []ipc.ProposedRoutine
|
||||
}{msg, proposed}); err != nil {
|
||||
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) {
|
||||
if core == nil {
|
||||
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"tokenizer_path": "/opt/maven/models/embedder/tokenizer.json",
|
||||
"lib_path": "/opt/maven/lib/libonnxruntime.so"
|
||||
},
|
||||
"llm_router": false,
|
||||
"tool_timeout": "30s",
|
||||
"tools": [
|
||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
|
||||
|
||||
@@ -446,9 +446,6 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
||||
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,6 +257,20 @@ type VoiceConfig struct {
|
||||
// Default 0.35 if unset.
|
||||
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
||||
|
||||
// LLMRouter — route with the resident model instead of the embedding
|
||||
// classifier. Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md)
|
||||
// the model gets 50.0% of intents right against the classifier's 36.8%, but
|
||||
// it costs about 800ms per turn instead of 30ms.
|
||||
//
|
||||
// TODO: the default stays false until two things land.
|
||||
// 1. The LLM router cannot refuse. LLMRouter.Route hardcodes
|
||||
// Confidence: 1.0, so the stage-3 clarify gate never fires and an
|
||||
// unclear utterance becomes a confident wrong action (Vikunja #359).
|
||||
// 2. Extractor.Extract never runs on an LLM decision, so acts arrive with
|
||||
// no Fn and reminders with no Time.
|
||||
// Turning this on today makes routing more accurate and less safe.
|
||||
LLMRouter bool `json:"llm_router,omitempty"`
|
||||
|
||||
// QueryMinScore — the note-recall confidence gate. Top cosine below this
|
||||
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
|
||||
// the HashEmbedder floor scores lexically and may never clear it. 0.55
|
||||
|
||||
@@ -171,6 +171,28 @@ func TestWeatherConfigNilOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMRouterDefaultsOff(t *testing.T) {
|
||||
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Voice.LLMRouter {
|
||||
t.Error("voice.llm_router absent should mean false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMRouterRead(t *testing.T) {
|
||||
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !c.Voice.LLMRouter {
|
||||
t.Error("voice.llm_router true was not read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationRoundTrip(t *testing.T) {
|
||||
d := Duration(15 * time.Minute)
|
||||
b, err := d.MarshalJSON()
|
||||
|
||||
@@ -237,11 +237,6 @@ type dismissProposedRoutineReq struct {
|
||||
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:
|
||||
// - 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,
|
||||
@@ -291,9 +286,6 @@ type CoreAPI interface {
|
||||
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
|
||||
// DismissProposedRoutine flips a proposed routine to 'dismissed'.
|
||||
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
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
|
||||
@@ -430,10 +430,6 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
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) {
|
||||
var r chatResp
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||
|
||||
@@ -478,9 +478,6 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
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 {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
|
||||
@@ -253,10 +253,6 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
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 {
|
||||
return Tool{
|
||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||
@@ -778,13 +774,6 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
}
|
||||
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:
|
||||
var p struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -41,7 +41,6 @@ const (
|
||||
MethodDeleteTool Method = "delete_tool"
|
||||
MethodListProposedRoutines Method = "list_proposed_routines"
|
||||
MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
|
||||
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
|
||||
@@ -8,14 +8,6 @@ import (
|
||||
"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
|
||||
// recurring reminder. Status 'proposed' means awaiting human confirmation;
|
||||
// 'accepted' means the human confirmed and a reminder was created (reminder_id
|
||||
@@ -38,15 +30,6 @@ var (
|
||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||
// ErrProposedRoutineExists if one already exists for this action+object (any
|
||||
// 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) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||
@@ -87,29 +70,14 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListProposedRoutines returns the routines still waiting for an answer,
|
||||
// newest first. This is what the /routines page shows.
|
||||
// ListProposedRoutines returns all proposed routines with status='proposed',
|
||||
// newest first.
|
||||
func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return s.ListProposedRoutinesByStatus(ctx, RoutineProposed)
|
||||
}
|
||||
|
||||
// ListProposedRoutinesByStatus returns routines in one status, newest first.
|
||||
// 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...)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, action, object, interval_days, status, created_ts, reminder_id
|
||||
FROM proposed_routines
|
||||
WHERE status = 'proposed'
|
||||
ORDER BY created_ts DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list proposed routines: %w", err)
|
||||
}
|
||||
@@ -125,19 +93,8 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
|
||||
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.
|
||||
// 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 {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`,
|
||||
|
||||
@@ -131,96 +131,6 @@ 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) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
+14
-4
@@ -7,12 +7,22 @@
|
||||
set -euo pipefail
|
||||
|
||||
# The llama-server the phraser spawns has NO "maven" in its command line (its
|
||||
# args are `-m /path/to/LFM2.5-...gguf --port ...`), so a `llama-server.*maven`
|
||||
# args are `-m /path/to/<model>.gguf --port ...`), so a `llama-server.*maven`
|
||||
# pattern matches nothing and leaks it — the exact bug that let orphans pile up
|
||||
# and OOM the box. Match the model instead. Override MODEL if you change it.
|
||||
MODEL="${MODEL:-LFM2}"
|
||||
# and OOM the box.
|
||||
#
|
||||
# We used to match the model name, defaulting to LFM2. The deploy now runs
|
||||
# Qwen3.5-0.8B, so that default matched nothing and the server survived every
|
||||
# kill. Match any llama-server serving a .gguf instead, so swapping the model in
|
||||
# deploy/mavend.json cannot break this script again. Set MODEL to narrow it if
|
||||
# some other llama-server on this box must be left alone.
|
||||
MODEL="${MODEL:-}"
|
||||
PAT='mavend|mavsttd|mavttsd|mavweb|mavpoll|mavenclient'
|
||||
LLM="llama-server.*${MODEL}"
|
||||
if [ -n "$MODEL" ]; then
|
||||
LLM="llama-server.*${MODEL}"
|
||||
else
|
||||
LLM='llama-server.*\.gguf'
|
||||
fi
|
||||
|
||||
echo "--- Sending graceful SIGTERM to Maven services ---"
|
||||
pkill -TERM -f "$PAT" || true
|
||||
|
||||
Reference in New Issue
Block a user