Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1558233665 | |||
| 5afa2dfb38 | |||
| 0f361d2034 | |||
| 19242ef73b | |||
| 26d6d71588 | |||
| df58710141 | |||
| 442d3ec08e | |||
| e481ad4930 | |||
| 3d8224fb04 |
@@ -229,11 +229,8 @@ world questions, so she needs to read external sources. What replaces it:
|
|||||||
|
|
||||||
## Web UI conventions
|
## Web UI conventions
|
||||||
|
|
||||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||||||
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
||||||
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
|
||||||
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
|
||||||
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
|
||||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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: simulate stt-fixtures test-stt-golden 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 eval-phrasing eval-models build-gpud
|
.PHONY: simulate stt-fixtures test-stt-golden 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 deps-sentinel tidy eval-router eval-recall eval-phrasing eval-models build-gpud
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ run-web: build-web
|
|||||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||||
deps-go:
|
deps-go: deps-sentinel
|
||||||
@mkdir -p deps/go
|
@mkdir -p deps/go
|
||||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||||
@@ -84,6 +84,25 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(GO) version
|
||||||
|
|
||||||
|
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
|
||||||
|
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
|
||||||
|
# Go's own compiler-error fixtures and died on files that are malformed on
|
||||||
|
# purpose ("unicode//utf8": double slash). A nested module is not part of the
|
||||||
|
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
|
||||||
|
# generated here rather than committed, and every target that populates deps/
|
||||||
|
# writes it.
|
||||||
|
deps-sentinel:
|
||||||
|
@mkdir -p deps
|
||||||
|
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||||
|
|
||||||
|
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||||
|
# go.mod, and a build target that edits the module file is a surprise.
|
||||||
|
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||||
|
# a re-vendor or the next build fails on "inconsistent vendoring".
|
||||||
|
tidy: deps-sentinel
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
|
||||||
|
|
||||||
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||||
# Run `gofmt -w` on whatever this prints.
|
# Run `gofmt -w` on whatever this prints.
|
||||||
@@ -178,7 +197,7 @@ run-tts: build-tts
|
|||||||
./mavttsd -socket /tmp/maven/tts.sock \
|
./mavttsd -socket /tmp/maven/tts.sock \
|
||||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||||
|
|
||||||
deps: deps-whisper deps-piper
|
deps: deps-sentinel deps-whisper deps-piper
|
||||||
|
|
||||||
deps-whisper:
|
deps-whisper:
|
||||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
|||||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
|
if h.phraser == nil {
|
||||||
|
return "поговорили."
|
||||||
|
}
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
q.Slots.Value = ""
|
q.Slots.Value = ""
|
||||||
return h.actionQuery(ctx, q)
|
return h.actionQuery(ctx, q)
|
||||||
}
|
}
|
||||||
|
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||||
|
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||||
|
// recall reads a self row back later as if it were still true — the same
|
||||||
|
// class of row that outranked live search in #470. The sentence describes a
|
||||||
|
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||||
|
// and anything about him are both left alone by the test.
|
||||||
|
if router.IsTransientComplaint(dec.Utterance) {
|
||||||
|
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||||
|
dec.Utterance, dec.Slots.Key)
|
||||||
|
c := dec
|
||||||
|
c.Intent = router.IntentChat
|
||||||
|
c.Slots.Key, c.Slots.HasKey = "", false
|
||||||
|
c.Slots.Value = ""
|
||||||
|
return h.actionChat(ctx, c)
|
||||||
|
}
|
||||||
now := h.now()
|
now := h.now()
|
||||||
req := ipc.WriteFactReq{
|
req := ipc.WriteFactReq{
|
||||||
Ts: now,
|
Ts: now,
|
||||||
|
|||||||
@@ -81,11 +81,22 @@ var querySources = []querySource{
|
|||||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||||
// date-bearing question still reaches the calendar.
|
// date-bearing question still reaches the calendar.
|
||||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||||
|
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||||
|
// question about the operational state Praxis holds, and it used to fall
|
||||||
|
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||||
|
// an attention marker, and it falls through when Praxis is not configured.
|
||||||
|
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||||
// the money facts the poller wrote, and the notes pass would otherwise
|
// the money facts the poller wrote, and the notes pass would otherwise
|
||||||
// answer it from whatever he once said about spending. Its matcher needs a
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||||
|
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||||
|
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||||
|
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||||
|
// history phrase and bails out when he names a topic, so "что я говорил
|
||||||
|
// про сервер" is still recall.
|
||||||
|
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// a question about the feeds she reads, and general knowledge would answer
|
||||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
@@ -140,6 +151,13 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if reply, ok := src.answer(h, ctx, t); ok {
|
if reply, ok := src.answer(h, ctx, t); ok {
|
||||||
|
// Which source claimed is the one thing about a query turn that was
|
||||||
|
// invisible from outside: /trace is the nudge-rule trace and carries
|
||||||
|
// no query-source field, so a wrong answer could not be told from a
|
||||||
|
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||||
|
// the utterance and the answer are already on the voice lines above
|
||||||
|
// and below this one.
|
||||||
|
log.Printf("voice: query claimed by source %q", src.name)
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,9 +296,17 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if !h.feedsOn {
|
if !h.feedsOn {
|
||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim only when nothing below can read the world. The reason this
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// source used to claim unconditionally was that general knowledge would
|
||||||
// news bulletin.
|
// answer "что нового?" with an invented news bulletin — true, and it
|
||||||
|
// stopped being the only alternative on 2026-08-02, when live search
|
||||||
|
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||||||
|
// в новостях про искусственный интеллект?" has a real answer below,
|
||||||
|
// and a configuration status is the wrong thing to say instead
|
||||||
|
// (Vikunja #474).
|
||||||
|
if h.search != nil || h.kiwix != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
return "я пока не читаю ленты — они не настроены.", true
|
return "я пока не читаю ленты — они не настроены.", true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||||
@@ -317,6 +343,15 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||||
// source can be tested at a fixed time like the rest.
|
// source can be tested at a fixed time like the rest.
|
||||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
// A day word is all this source matches on, so any question that merely
|
||||||
|
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||||||
|
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||||||
|
// far more often than the calendar is, and the weather source sits right
|
||||||
|
// below, so the calendar steps aside on weather wording — the same bail-out
|
||||||
|
// queryHome already does for the same reason.
|
||||||
|
if isWeatherQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
|||||||
return "не получилось разобрать время напоминания."
|
return "не получилось разобрать время напоминания."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
// The body is what she says at the hour, so the marker and the time come
|
||||||
|
// out of it: the fire time is already a column, and "напомни" is an
|
||||||
|
// instruction that has been carried out (Vikunja #469).
|
||||||
|
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||||
log.Printf("voice: create reminder: %v", err)
|
log.Printf("voice: create reminder: %v", err)
|
||||||
return "не получилось поставить напоминание."
|
return "не получилось поставить напоминание."
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a
|
||||||
|
// stem, because "внимание", "внимания" and "вниманию" are one word to him.
|
||||||
|
//
|
||||||
|
// "что нового" is deliberately absent: the feeds source claims it, and it
|
||||||
|
// still should — a question about news is a question about the feeds she
|
||||||
|
// reads. This list is about the operational state of his things.
|
||||||
|
var attentionMarkers = []string{
|
||||||
|
"внимани", "что требует", "что не так", "что важн", "что срочн",
|
||||||
|
"needs attention", "what needs looking",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAttentionQuery reports whether the utterance asks what needs looking at.
|
||||||
|
func isAttentionQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range attentionMarkers {
|
||||||
|
if strings.Contains(s, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAttention answers "что требует внимания?" from Praxis.
|
||||||
|
//
|
||||||
|
// The capability was already built and already degraded correctly, and no
|
||||||
|
// utterance could reach it (Vikunja #475). Its aliases live on the act
|
||||||
|
// dispatch, and the question routes to IntentQuery, so it fell through every
|
||||||
|
// source to the web search and came back with an encyclopedia article about
|
||||||
|
// the concept of attention — worse than silence, because it reads as an
|
||||||
|
// answer.
|
||||||
|
//
|
||||||
|
// Placed above the recall sources and well above the personal boundary: this
|
||||||
|
// is operational state about his things, and a notes pass would otherwise
|
||||||
|
// answer it from whatever he once wrote about a server. An unconfigured or
|
||||||
|
// absent Praxis falls through rather than claiming the turn, the same
|
||||||
|
// convention queryHome and queryNetwork follow. A Praxis that is configured
|
||||||
|
// and down does claim it, and says it cannot reach the service — that is the
|
||||||
|
// degradation the ecosystem contract asks for, and it comes from the same
|
||||||
|
// handler the act path uses.
|
||||||
|
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isAttentionQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||||
|
Utterance: t.dec.Utterance,
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||||
|
})
|
||||||
|
if reply == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsAttentionQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что требует внимания", true},
|
||||||
|
{"на что обратить внимание?", true},
|
||||||
|
{"что не так?", true},
|
||||||
|
{"что важного?", true},
|
||||||
|
// The feeds source owns this one, and should keep owning it.
|
||||||
|
{"что нового?", false},
|
||||||
|
{"какая погода?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isAttentionQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
|
||||||
|
// routes to IntentQuery, and every source used to pass, so a web search about
|
||||||
|
// the concept of attention answered it.
|
||||||
|
func TestAttentionQuestionReachesPraxis(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||||
|
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||||
|
}))
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the attention question must be claimed before the world sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "disk almost full") {
|
||||||
|
t.Fatalf("reply = %q, want the praxis item", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configured Praxis that is down claims the turn and says so. Falling
|
||||||
|
// through here would answer an outage with an encyclopedia article.
|
||||||
|
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
praxis.SetFault(503)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok || !strings.Contains(reply, "не могу") {
|
||||||
|
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Praxis configured means no claim: the rest of the chain still runs.
|
||||||
|
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
|
||||||
|
h, _ := newFactGateHandler(t, time.Now())
|
||||||
|
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}}); ok {
|
||||||
|
t.Fatal("an unconfigured praxis must not claim the turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-16
@@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|||||||
router.IntentAct: {dialogue.SlotFn},
|
router.IntentAct: {dialogue.SlotFn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyQuestions — one short question per missing slot.
|
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||||
//
|
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
// used to hold; a re-ask says it differently, because a question he already
|
||||||
// would wander, and a question whose wording changes every time is harder to
|
// failed to answer is the worst one to repeat unchanged.
|
||||||
// answer than a blunt one that always reads the same. They are infinitive
|
|
||||||
// questions, so there is no gender agreement to get wrong; the feminine
|
|
||||||
// self-reference lives in the reply she gives when she drops the request.
|
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
|
||||||
dialogue.SlotTime: "Когда?",
|
|
||||||
dialogue.SlotText: "О чём напомнить?",
|
|
||||||
dialogue.SlotKey: "Что записать?",
|
|
||||||
dialogue.SlotFn: "Что сделать?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||||
// says so out loud: dropping the request in silence would leave him thinking it
|
// says so out loud: dropping the request in silence would leave him thinking it
|
||||||
@@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
q, ok := clarifyQuestions[missing[0]]
|
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -266,7 +257,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
question, ok := clarifyQuestions[remaining[0]]
|
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||||
|
// with the re-ask path, so the second gap is worded like a second try.
|
||||||
|
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -290,7 +283,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||||
question := ""
|
question := ""
|
||||||
if len(q.Missing) > 0 {
|
if len(q.Missing) > 0 {
|
||||||
question = clarifyQuestions[q.Missing[0]]
|
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||||
}
|
}
|
||||||
if question == "" || !q.CanAsk() {
|
if question == "" || !q.CanAsk() {
|
||||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
|||||||
@@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
// question he already failed to answer is the worst way to ask it.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||||
|
}
|
||||||
|
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||||
|
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||||
t.Fatalf("attempt %d must leave the question armed", i)
|
t.Fatalf("attempt %d must leave the question armed", i)
|
||||||
@@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("the answer must be consumed as an answer")
|
t.Fatal("the answer must be consumed as an answer")
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// Second gap, second attempt, so it is the second wording of the time
|
||||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
// question — the attempt budget is shared between the two paths.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
@@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
eval.CheckCringe: true,
|
eval.CheckCringe: true,
|
||||||
}
|
}
|
||||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||||
for _, q := range clarifyQuestions {
|
lines = append(lines, clarifyMissedVariants...)
|
||||||
lines = append(lines, q)
|
for _, variants := range clarifyQuestionVariants {
|
||||||
|
lines = append(lines, variants...)
|
||||||
}
|
}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||||
@@ -497,8 +507,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|||||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reminders[0].Payload, "11") {
|
// The hour is the fire time, not a word in the body: the body is what she
|
||||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
// says at the hour, and the time expression is stripped out of it
|
||||||
|
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||||||
|
// reminder that fires at some other time than the one he asked for.
|
||||||
|
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||||||
|
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The clarify copy deck (Vikunja #457).
|
||||||
|
//
|
||||||
|
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||||
|
// that sentence word for word. A question he already failed to answer is the
|
||||||
|
// worst one to ask again unchanged: the second wording is the one that tells
|
||||||
|
// him which part she missed.
|
||||||
|
//
|
||||||
|
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||||
|
// given: the resident model would wander, and a question whose wording changes
|
||||||
|
// at random is harder to answer than a blunt one. What changes here is that the
|
||||||
|
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||||
|
// short, the second names the gap, the third spells it out.
|
||||||
|
//
|
||||||
|
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||||
|
// constants compiled into the daemon, so there is no file that can drift out of
|
||||||
|
// step with the code that reads it.
|
||||||
|
//
|
||||||
|
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||||
|
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||||
|
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||||
|
dialogue.SlotTime: {
|
||||||
|
"Когда?",
|
||||||
|
"Во сколько напомнить?",
|
||||||
|
"Скажи время — например, «в семь вечера» или «через час».",
|
||||||
|
},
|
||||||
|
dialogue.SlotText: {
|
||||||
|
"О чём напомнить?",
|
||||||
|
"Что сказать тебе в это время?",
|
||||||
|
"Скажи одной фразой, о чём напомнить.",
|
||||||
|
},
|
||||||
|
dialogue.SlotKey: {
|
||||||
|
"Что записать?",
|
||||||
|
"Что именно отметить?",
|
||||||
|
"Назови, что записать — например, «выпил воды».",
|
||||||
|
},
|
||||||
|
dialogue.SlotFn: {
|
||||||
|
"Что сделать?",
|
||||||
|
"Какое действие выполнить?",
|
||||||
|
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||||
|
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||||
|
// most explicit phrasing rather than wrapping round to the short one, because
|
||||||
|
// wrapping would ask the same short question he has already not answered.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||||
|
// same statement however it is worded, and a re-ask is not.
|
||||||
|
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||||
|
variants, ok := clarifyQuestionVariants[slot]
|
||||||
|
if !ok || len(variants) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
i := attempt - 1
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
if i >= len(variants) {
|
||||||
|
i = len(variants) - 1
|
||||||
|
}
|
||||||
|
return variants[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||||
|
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||||
|
// query, chat, system are not in wantedSlots).
|
||||||
|
//
|
||||||
|
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||||
|
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||||
|
// the same sentence every time is what makes a house assistant sound like a
|
||||||
|
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||||
|
// should say it again — because the wording may vary and the meaning may not.
|
||||||
|
var clarifyMissedVariants = []string{
|
||||||
|
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||||
|
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||||
|
"Не уловила. Скажи это по-другому?",
|
||||||
|
"Прости, не поняла — попробуй сказать иначе.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||||
|
// asked twice get the same answer and two different misses sound different.
|
||||||
|
//
|
||||||
|
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||||
|
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||||
|
func clarifyMissedFor(utterance string) string {
|
||||||
|
var sum int
|
||||||
|
for _, r := range utterance {
|
||||||
|
sum += int(r)
|
||||||
|
}
|
||||||
|
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||||
|
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||||
|
// caller keeps its own reply.
|
||||||
|
func clarifyMissedLine(dec router.Decision) string {
|
||||||
|
if !dec.Clarify {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clarifyMissedFor(dec.Utterance)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||||
|
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||||
|
// the defect this deck exists to fix (Vikunja #457).
|
||||||
|
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||||
|
for slot, variants := range clarifyQuestionVariants {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range variants {
|
||||||
|
if v == "" {
|
||||||
|
t.Errorf("%s: empty wording in the deck", slot)
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("%s: repeated wording %q", slot, v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||||
|
got, ok := clarifyQuestionFor(slot, attempt)
|
||||||
|
if !ok || got != variants[attempt-1] {
|
||||||
|
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||||
|
// the short question he has already not answered twice.
|
||||||
|
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||||
|
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||||
|
for _, attempt := range []int{0, 4, 9} {
|
||||||
|
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||||
|
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||||
|
t.Error("an unknown slot must have no question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The missed line is stable for one utterance and absent for a decision that is
|
||||||
|
// not a clarify.
|
||||||
|
func TestClarifyMissedLine(t *testing.T) {
|
||||||
|
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||||
|
first := clarifyMissedLine(d)
|
||||||
|
if first == "" || first != clarifyMissedLine(d) {
|
||||||
|
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||||
|
}
|
||||||
|
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||||
|
t.Errorf("a decision that is not a clarify got %q", got)
|
||||||
|
}
|
||||||
|
// The empty utterance still gets a line: she has to say something.
|
||||||
|
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||||
|
t.Error("an empty utterance must still be answered out loud")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,3 +123,48 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
|||||||
}
|
}
|
||||||
return vec
|
return vec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The write half of #481: a complaint about a thing is a state of the
|
||||||
|
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||||
|
// it comes back on recall as if the network were still down.
|
||||||
|
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
reply := h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "сеть какая-то медленная",
|
||||||
|
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||||
|
t.Fatal("a passing complaint was stored as a fact about him")
|
||||||
|
}
|
||||||
|
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory search: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 0 {
|
||||||
|
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the turn was neither stored nor answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||||
|
// over the question gate.
|
||||||
|
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "запомни что интернет не работает",
|
||||||
|
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||||
|
t.Fatalf("an explicit capture was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,3 +194,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|||||||
t.Fatal("the poller fetched a private address")
|
t.Fatal("the poller fetched a private address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
|
||||||
|
// deployed box has no feeds block and does have SearXNG, and "что происходит
|
||||||
|
// сейчас в новостях про искусственный интеллект?" got a configuration status
|
||||||
|
// instead of the live answer sitting one source below.
|
||||||
|
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, false)
|
||||||
|
h.search = &searchWiring{max: 3, runes: 1500}
|
||||||
|
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
|
||||||
|
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
|
||||||
|
}
|
||||||
|
// With nothing below that reads the world, the honest status is still said:
|
||||||
|
// general knowledge would otherwise answer with an invented bulletin.
|
||||||
|
h.search = nil
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
|
||||||
|
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||||
|
// (Vikunja #456).
|
||||||
|
//
|
||||||
|
// Read-only over the facts that already exist. No new mechanism and no new
|
||||||
|
// storage: everything he tapped in is already a row with a source and a
|
||||||
|
// timestamp, and this only reads them back.
|
||||||
|
|
||||||
|
// historyMarkers — the ways he asks what he told her. Each entry is a pair of
|
||||||
|
// substrings that must BOTH appear, because either half alone is a different
|
||||||
|
// question: "что я говорил про сервер" is a recall question the notes pass
|
||||||
|
// answers better, and "что ты записала" with no "что" is not a question at all.
|
||||||
|
var historyMarkers = [][2]string{
|
||||||
|
{"что я", "говорил"},
|
||||||
|
{"что я", "сказал"},
|
||||||
|
{"что я", "рассказ"},
|
||||||
|
{"что ты", "записал"},
|
||||||
|
{"что ты", "запомнил"},
|
||||||
|
{"что я", "отмечал"},
|
||||||
|
{"что я", "отметил"},
|
||||||
|
{"what did i", "tell"},
|
||||||
|
{"what did you", "record"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyRecall — the word that turns a history question into a recall
|
||||||
|
// question. "что я говорил про сервер" names a topic, and the notes pass
|
||||||
|
// answers a topic far better than a list of the last five facts does.
|
||||||
|
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||||
|
|
||||||
|
// isHistoryQuery reports whether he is asking what he told her.
|
||||||
|
func isHistoryQuery(u string) bool {
|
||||||
|
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||||
|
if s == " " {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range historyRecall {
|
||||||
|
if strings.Contains(s, r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pair := range historyMarkers {
|
||||||
|
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyScan — how many recent facts are read before filtering. Deliberately
|
||||||
|
// larger than historyReadOut: a poller writing every few minutes would
|
||||||
|
// otherwise push everything he said out of the window, the same way his own
|
||||||
|
// notes used to crowd out the feed headlines.
|
||||||
|
const historyScan = 100
|
||||||
|
|
||||||
|
// historyReadOut — how many she says out loud. Five is what fits in one spoken
|
||||||
|
// breath; the rest are on /history, which is the surface for reading a list.
|
||||||
|
const historyReadOut = 5
|
||||||
|
|
||||||
|
// historyWindow — how far back "recently" reaches. A day, because the question
|
||||||
|
// is about this conversation and not about the archive.
|
||||||
|
const historyWindow = 24 * time.Hour
|
||||||
|
|
||||||
|
// queryHistory answers what he told her, from the facts he tapped in.
|
||||||
|
//
|
||||||
|
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
|
||||||
|
// relay is a thing she learned, not a thing he said, and reading those back
|
||||||
|
// under "что я тебе говорил?" would put words in his mouth.
|
||||||
|
//
|
||||||
|
// Placed with the other sources that read his own rows and above the recall
|
||||||
|
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||||
|
// to be nearest, which reads as an answer and is not one.
|
||||||
|
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !isHistoryQuery(t.dec.Utterance) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: history: recent facts: %v", err)
|
||||||
|
return "не получилось посмотреть, что ты говорил.", true
|
||||||
|
}
|
||||||
|
cutoff := h.now().Add(-historyWindow)
|
||||||
|
var said []string
|
||||||
|
for _, f := range facts {
|
||||||
|
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
said = append(said, historyLine(f.Key, f.Value, f.Ts))
|
||||||
|
if len(said) == historyReadOut {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) == 0 {
|
||||||
|
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||||
|
// true answer, and recall would answer it with an old note instead.
|
||||||
|
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||||
|
}
|
||||||
|
return "ты говорил: " + strings.Join(said, "; "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyLine — one fact as she says it. The hour and minute, because the day
|
||||||
|
// is already bounded by historyWindow and a date would be noise.
|
||||||
|
func historyLine(key, value string, ts time.Time) string {
|
||||||
|
what := key
|
||||||
|
if value != "" {
|
||||||
|
what = key + " — " + value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyAPI serves a fixed set of recent facts.
|
||||||
|
type historyAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
|
||||||
|
api := &historyAPI{facts: facts}
|
||||||
|
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
|
||||||
|
}
|
||||||
|
|
||||||
|
func askHistory(h *reactiveHandler, u string) (string, bool) {
|
||||||
|
return h.queryHistory(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHistoryQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что я тебе говорил?", true},
|
||||||
|
{"что ты записала сегодня?", true},
|
||||||
|
{"что я отмечал?", true},
|
||||||
|
// A named topic is a recall question, and the notes pass answers it
|
||||||
|
// better than a list of the last five facts does.
|
||||||
|
{"что я говорил про сервер?", false},
|
||||||
|
{"что у меня сегодня?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isHistoryQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, api := historyHandler(now,
|
||||||
|
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
|
||||||
|
// Learned, not said: a poller writing this back under "что я тебе
|
||||||
|
// говорил?" would put words in his mouth.
|
||||||
|
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
|
||||||
|
// Older than the window.
|
||||||
|
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
|
||||||
|
)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the history question must be claimed before the recall sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "water") {
|
||||||
|
t.Errorf("reply = %q, want the fact he tapped in", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
|
||||||
|
t.Errorf("reply = %q, want only what he said inside the window", reply)
|
||||||
|
}
|
||||||
|
if api.calls != 1 {
|
||||||
|
t.Errorf("RecentFacts called %d times, want 1", api.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing said is an answer of its own. Falling through would hand the question
|
||||||
|
// to recall, which answers it with an old note.
|
||||||
|
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, _ := historyHandler(now)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok || !strings.Contains(reply, "ничего") {
|
||||||
|
t.Fatalf("reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five is what fits in one spoken breath; the rest are on /history.
|
||||||
|
func TestHistoryStopsAtFive(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
var facts []ipc.Fact
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
|
||||||
|
}
|
||||||
|
h, _ := historyHandler(now, facts...)
|
||||||
|
reply, _ := askHistory(h, "что ты записала?")
|
||||||
|
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
|
||||||
|
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||||
|
// the instruction, not the thing to say at the hour.
|
||||||
|
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
|
||||||
|
|
||||||
|
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||||
|
// the body because the fire time is already a column. Ordered longest-first
|
||||||
|
// where two could match the same words, so "через полтора часа" does not leave
|
||||||
|
// "полтора" behind.
|
||||||
|
//
|
||||||
|
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||||
|
// boundaries here are written out as whitespace or an end of string — the same
|
||||||
|
// trap the agenda grammars hit.
|
||||||
|
var reminderTimeWords = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderBody is what she says at the hour.
|
||||||
|
//
|
||||||
|
// The whole utterance used to be stored, so /reminders read "напомни завтра в
|
||||||
|
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
|
||||||
|
// agenda recited the marker back at him (Vikunja #469). The fire time is
|
||||||
|
// already a column, and the marker is an instruction that was carried out.
|
||||||
|
//
|
||||||
|
// Falls back to the fuller text whenever stripping would leave nothing: an
|
||||||
|
// empty body is a reminder that fires and says nothing, which is worse than a
|
||||||
|
// wordy one.
|
||||||
|
func reminderBody(utterance, text string) string {
|
||||||
|
body := strings.TrimSpace(text)
|
||||||
|
if body == "" {
|
||||||
|
body = strings.TrimSpace(utterance)
|
||||||
|
}
|
||||||
|
stripped := reminderMarker.ReplaceAllString(body, "")
|
||||||
|
for _, re := range reminderTimeWords {
|
||||||
|
stripped = re.ReplaceAllString(stripped, " ")
|
||||||
|
}
|
||||||
|
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
|
||||||
|
stripped = strings.Trim(stripped, " ,;:—-")
|
||||||
|
if stripped == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReminderBody(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ utterance, text, want string }{
|
||||||
|
// The row from the QA sitting: the whole utterance was the body.
|
||||||
|
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
|
||||||
|
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
|
||||||
|
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
|
||||||
|
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
|
||||||
|
// Nothing left after stripping ⇒ keep what there was. A reminder that
|
||||||
|
// fires and says nothing is worse than a wordy one.
|
||||||
|
{"напомни завтра", "завтра", "завтра"},
|
||||||
|
{"", "", ""},
|
||||||
|
} {
|
||||||
|
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
|
||||||
|
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,12 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
|||||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||||
func (r *llmReplier) Reply(d router.Decision) string {
|
func (r *llmReplier) Reply(d router.Decision) string {
|
||||||
if d.Clarify {
|
if d.Clarify {
|
||||||
return r.stub.Reply(d)
|
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||||
|
// into a question is the line he hears most often when she misses him,
|
||||||
|
// and it used to be the same words every time (Vikunja #457). Still no
|
||||||
|
// model call — this text has to be right every time, and it is not worth
|
||||||
|
// a generation to say something this small.
|
||||||
|
return clarifyMissedLine(d)
|
||||||
}
|
}
|
||||||
out, err := r.p.PhraseReply(context.Background(), d)
|
out, err := r.p.PhraseReply(context.Background(), d)
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
|
|||||||
@@ -37,9 +37,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
|||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||||
|
// the clarify deck rather than the stub's single sentence.
|
||||||
|
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||||
|
if got == "я всё поняла" {
|
||||||
|
t.Fatal("a clarify must not be phrased by the model")
|
||||||
|
}
|
||||||
|
if want := clarifyMissedFor("мгм"); got != want {
|
||||||
|
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Two different misses do not sound identical.
|
||||||
|
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||||
|
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
|
|||||||
@@ -10,17 +10,22 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -310,6 +315,613 @@ func (t *tickLoop) repeatPhrase(rule string) (body, summary string) {
|
|||||||
return pn.Body, pn.Summary
|
return pn.Body, pn.Summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldQueue — true when digest is enabled and the candidate's severity is
|
||||||
|
// at or below the configured ceiling.
|
||||||
|
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
||||||
|
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
||||||
|
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueNudge — phrases the candidate and appends it to the digest queue.
|
||||||
|
// Deduplicates by rule name: if the same rule is already queued, this is a
|
||||||
|
// no-op (the first fire within the window is the one that counts).
|
||||||
|
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
||||||
|
for _, q := range t.digestQ {
|
||||||
|
if q.Rule == cand.Rule.Name {
|
||||||
|
return // already queued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.digestQ = append(t.digestQ, QueuedNudge{
|
||||||
|
Rule: cand.Rule.Name,
|
||||||
|
Severity: int(cand.Severity),
|
||||||
|
Body: pn.Body,
|
||||||
|
Key: cand.Rule.Name,
|
||||||
|
QueuedAt: now,
|
||||||
|
})
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
||||||
|
// first item or the queue reached MaxItems.
|
||||||
|
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first := t.digestQ[0]
|
||||||
|
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
||||||
|
len(t.digestQ) >= t.digestCfg.MaxItems {
|
||||||
|
t.flushDigest(ctx, now, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushDigest — concatenates queued nudge bodies into a single digest
|
||||||
|
// notification and dispatches it. Clears the queue after a successful send.
|
||||||
|
// The digest uses the max severity among queued items for routing.
|
||||||
|
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.digestQ) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
maxSev := 0
|
||||||
|
for i, q := range t.digestQ {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" · ")
|
||||||
|
}
|
||||||
|
b.WriteString(q.Body)
|
||||||
|
if q.Severity > maxSev {
|
||||||
|
maxSev = q.Severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := b.String()
|
||||||
|
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
||||||
|
|
||||||
|
cand := loop.Candidate{
|
||||||
|
Rule: loop.Rule{
|
||||||
|
Name: "digest",
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
},
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: cand,
|
||||||
|
Body: body,
|
||||||
|
Summary: summary,
|
||||||
|
}
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
// keep the queue — the next tick's maybeFlush re-attempts.
|
||||||
|
log.Printf("tick: dispatch digest: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.digestQ = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPatterns runs the pattern detector proactively over every
|
||||||
|
// action+object pair that has ever produced an event, independent of
|
||||||
|
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||||
|
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||||
|
// own schedule reading accumulated history, not only as a side effect of a
|
||||||
|
// live voice turn.
|
||||||
|
//
|
||||||
|
// Idempotence and noise are handled by the store, not here — this function
|
||||||
|
// is safe to call every tick:
|
||||||
|
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||||
|
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||||
|
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||||
|
// already has a row — in ANY status — produces no second row and no log
|
||||||
|
// spam beyond the one line at genuine creation.
|
||||||
|
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||||
|
// status in place; the row is never deleted. So the same Lookup check
|
||||||
|
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||||
|
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||||
|
// calling the same shared path the voice route already used.
|
||||||
|
//
|
||||||
|
// By default this only creates a row for the /routines page to show: it does
|
||||||
|
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||||
|
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||||
|
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||||
|
// for the restraints that apply even then. A proposal only starts producing
|
||||||
|
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||||
|
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: distinct event pairs: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
announced := false
|
||||||
|
for _, p := range pairs {
|
||||||
|
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||||
|
}
|
||||||
|
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||||
|
// One announcement per tick at most, whatever the scan turned up. The
|
||||||
|
// rest are on /routines; they are not lost, they are just not shouted.
|
||||||
|
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||||
|
// them and they are never announced. See announceProposal.
|
||||||
|
if announced {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
announced = t.announceProposal(ctx, r, now, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceProposal offers a freshly inferred routine through the ordinary
|
||||||
|
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||||
|
// something was actually sent.
|
||||||
|
//
|
||||||
|
// Everything here is restraint. The feature is off unless configured; when on
|
||||||
|
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||||
|
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||||
|
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||||
|
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||||
|
// real send, but the proposal row already exists, so the next tick will not
|
||||||
|
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||||
|
// he reads it on /routines instead, which is the whole point of the page.
|
||||||
|
//
|
||||||
|
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||||
|
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||||
|
// that first proposes it. Combined with one announcement per tick, the first
|
||||||
|
// tick over a populated history announces one pattern and permanently silences
|
||||||
|
// every other pattern found in the same pass. That is the intent, not an
|
||||||
|
// oversight — an inferred routine is not worth a second attempt at his
|
||||||
|
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||||
|
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||||
|
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||||
|
// that needs a queue rather than this counter.
|
||||||
|
//
|
||||||
|
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||||
|
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||||
|
// and an unspaced announcer is not what those tests mean to exercise.
|
||||||
|
//
|
||||||
|
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||||
|
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||||
|
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||||
|
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||||
|
if !t.proposalCfg.AnnounceProposals() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||||
|
if cooldown <= 0 {
|
||||||
|
cooldown = config.DefaultProposalCooldown
|
||||||
|
}
|
||||||
|
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||||
|
if !loop.Gate(state, rule) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := pattern.PhraseRoutine(r)
|
||||||
|
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: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(sent) == 0 {
|
||||||
|
return false // routing dropped it — /routines still has it.
|
||||||
|
}
|
||||||
|
t.lastProposalAt = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||||
|
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||||
|
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||||
|
// digest entry that outlives one full day is describing a day that's already
|
||||||
|
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||||
|
// news. Bounding at one day also means a digest can never silently span a
|
||||||
|
// weekend of quiet hours into an unbounded backlog.
|
||||||
|
const digestExpiry = 24 * time.Hour
|
||||||
|
|
||||||
|
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||||
|
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||||
|
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||||
|
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||||
|
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||||
|
// count instead of being spoken in full.
|
||||||
|
const maxDigestSpokenItems = 3
|
||||||
|
|
||||||
|
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||||
|
// gate blocked for a genuine restraint reason and durably records the
|
||||||
|
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||||
|
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||||
|
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||||
|
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||||
|
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||||
|
// when a pending entry already exists avoids the LLM round-trip too).
|
||||||
|
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||||
|
if trace == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, tr := range trace.RuleTraces {
|
||||||
|
if !tr.PredicateResult || tr.GateResult {
|
||||||
|
continue // didn't want to fire, or wasn't suppressed
|
||||||
|
}
|
||||||
|
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||||
|
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||||
|
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expires := now.Add(digestExpiry)
|
||||||
|
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||||
|
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||||
|
} else if deduped {
|
||||||
|
// same suppressed nudge already pending — nothing new to say.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||||
|
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||||
|
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||||
|
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: expire stale digest entries: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||||
|
// suppression reasons have actually cleared — quiet hours over, back from
|
||||||
|
// away, out of the meeting. Draining while still suppressed would just be a
|
||||||
|
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||||
|
// it allowed right now" condition a live nudge already waits for.
|
||||||
|
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||||
|
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: pending digest entries: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spoken := entries
|
||||||
|
extra := 0
|
||||||
|
if len(spoken) > maxDigestSpokenItems {
|
||||||
|
spoken = entries[:maxDigestSpokenItems]
|
||||||
|
extra = len(entries) - maxDigestSpokenItems
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
maxSev := 0
|
||||||
|
for i, e := range spoken {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(" · ")
|
||||||
|
}
|
||||||
|
b.WriteString(e.Body)
|
||||||
|
if e.Severity > maxSev {
|
||||||
|
maxSev = e.Severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra > 0 {
|
||||||
|
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||||
|
}
|
||||||
|
body := b.String()
|
||||||
|
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||||
|
|
||||||
|
cand := loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||||
|
Severity: loop.Severity(maxSev),
|
||||||
|
State: state,
|
||||||
|
}
|
||||||
|
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||||
|
t.cachePhrase(pn)
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||||
|
return // leave entries pending; retried next tick
|
||||||
|
}
|
||||||
|
ids := make([]int64, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||||
|
log.Printf("tick: drain digest entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||||
|
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||||
|
// in config.Load, so this is a pure field copy.
|
||||||
|
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
||||||
|
if len(rc) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]routine.Routine, len(rc))
|
||||||
|
for i, r := range rc {
|
||||||
|
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
||||||
|
// last fire. Each is delivered as a nudge through the normal routing table
|
||||||
|
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
||||||
|
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
||||||
|
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
||||||
|
// already advanced the last-fire time so a transient failure drops that fire
|
||||||
|
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
||||||
|
// no repeat-til-ack).
|
||||||
|
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
||||||
|
Severity: loop.Severity(r.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: r.Body,
|
||||||
|
Summary: r.Body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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)
|
||||||
|
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
||||||
|
// concern — pulling it into the shared gather path would leak that mapping
|
||||||
|
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
||||||
|
// operator text (item labels joined), not LLM-phrased, same rationale as
|
||||||
|
// cron routines: deterministic, can't hallucinate a checklist item.
|
||||||
|
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
|
||||||
|
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
pn := delivery.PhrasedNudge{
|
||||||
|
Candidate: loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
||||||
|
Severity: loop.Severity(cand.Routine.Severity),
|
||||||
|
State: state,
|
||||||
|
},
|
||||||
|
Body: body,
|
||||||
|
Summary: body,
|
||||||
|
}
|
||||||
|
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||||
|
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningNudgeBody words the one message a routine gets per day. Required
|
||||||
|
// items are what she says was not done; optional ones follow, worded as
|
||||||
|
// something he could still do rather than something he owes (Vikunja #473).
|
||||||
|
// Operator text, not phrased by the model, for the same reason it always was:
|
||||||
|
// a checklist item must not be invented.
|
||||||
|
func morningNudgeBody(cand morning.Candidate) string {
|
||||||
|
labels := func(items []morning.Item) string {
|
||||||
|
out := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
out[i] = it.Label
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
||||||
|
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
||||||
|
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
||||||
|
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
||||||
|
// decision) and morningStatus (read-only query) so the two paths can never
|
||||||
|
// disagree about what evidence exists.
|
||||||
|
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
for _, it := range r.Items {
|
||||||
|
keys[it.FactKey] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts := make(map[string]store.Fact, len(keys))
|
||||||
|
for k := range keys {
|
||||||
|
f, err := t.store.LatestFact(ctx, k)
|
||||||
|
if err == nil {
|
||||||
|
facts[k] = f
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != store.ErrNoFact {
|
||||||
|
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return facts
|
||||||
|
}
|
||||||
|
|
||||||
|
// morningStatus is the read-only "what's missing" query the web UI (and
|
||||||
|
// eventually a voice query) calls. Pure recompute over the current facts —
|
||||||
|
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
||||||
|
// "state right now," not "should we nag."
|
||||||
|
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
||||||
|
if len(t.morningRoutines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
facts := t.gatherMorningFacts(ctx)
|
||||||
|
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
||||||
|
for _, r := range t.morningRoutines {
|
||||||
|
st := morning.Evaluate(r, facts, now)
|
||||||
|
done := make(map[string]bool, len(st.Completed))
|
||||||
|
for _, it := range st.Completed {
|
||||||
|
done[it.Key] = true
|
||||||
|
}
|
||||||
|
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
||||||
|
for i, it := range r.Items {
|
||||||
|
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
||||||
|
}
|
||||||
|
out = append(out, ipc.MorningRoutineStatus{
|
||||||
|
Name: r.Name,
|
||||||
|
Active: st.Active,
|
||||||
|
WindowStart: r.WindowStart,
|
||||||
|
WindowEnd: r.WindowEnd,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||||
|
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||||
|
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||||
|
//
|
||||||
|
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||||
|
// unprompted delivery in maven stays with the morning nudge and the
|
||||||
|
// dispatcher's policy.
|
||||||
|
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
var events []morning.PlanEntry
|
||||||
|
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: calendar events: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range facts {
|
||||||
|
events = append(events, morning.PlanEntry{
|
||||||
|
At: f.Ts,
|
||||||
|
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||||
|
// fact value carries would say it twice.
|
||||||
|
Text: calendar.FactSummary(f.Value),
|
||||||
|
Kind: morning.PlanEvent,
|
||||||
|
// Provenance below a calendar read (an ambient relay, #126) is
|
||||||
|
// hedged rather than recited as fact.
|
||||||
|
Uncertain: f.Confidence < 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminders []morning.PlanEntry
|
||||||
|
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != store.ReminderPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fire := r.NextFireTs
|
||||||
|
if fire.IsZero() {
|
||||||
|
fire = r.FireTs
|
||||||
|
}
|
||||||
|
reminders = append(reminders, morning.PlanEntry{
|
||||||
|
At: fire,
|
||||||
|
Text: r.Text(),
|
||||||
|
Kind: morning.PlanReminder,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklistFacts map[string]store.Fact
|
||||||
|
if len(t.morningRoutines) > 0 {
|
||||||
|
checklistFacts = t.gatherMorningFacts(ctx)
|
||||||
|
}
|
||||||
|
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||||
|
|
||||||
|
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||||
|
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||||
|
for i, it := range plan.Items {
|
||||||
|
out.Items[i] = ipc.DayPlanItem{
|
||||||
|
At: it.At,
|
||||||
|
Text: it.Text,
|
||||||
|
Kind: string(it.Kind),
|
||||||
|
Uncertain: it.Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -382,3 +994,101 @@ func (t *tickLoop) trace() *loop.TickTrace {
|
|||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
return t.lastTrace
|
return t.lastTrace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||||
|
// daemon's in-memory tick trace cache.
|
||||||
|
type daemonAPI struct {
|
||||||
|
ipc.CoreAPI
|
||||||
|
getTrace func() *loop.TickTrace
|
||||||
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||||
|
// look the same to a reader on purpose, because neither is a fault and the
|
||||||
|
// page renders both as an empty table.
|
||||||
|
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||||
|
if d.getEvents == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getEvents(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
|
if d.chatFn == nil {
|
||||||
|
return "", errors.New("mavend: chat not available")
|
||||||
|
}
|
||||||
|
return d.chatFn(ctx, conversation, text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
|
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||||
|
// default state and the web surface renders it as such.
|
||||||
|
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||||
|
if d.getMCPServers == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.getMCPServers(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||||
|
trace := d.getTrace()
|
||||||
|
if trace == nil {
|
||||||
|
return ipc.TickTrace{}, nil
|
||||||
|
}
|
||||||
|
return toIPCTickTrace(*trace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||||
|
if d.getMorningStatus == nil {
|
||||||
|
return nil, errors.New("mavend: morning status not available")
|
||||||
|
}
|
||||||
|
return d.getMorningStatus(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||||
|
if d.getDayPlan == nil {
|
||||||
|
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||||
|
}
|
||||||
|
return d.getDayPlan(ctx), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
|
for i, r := range t.RuleTraces {
|
||||||
|
rules[i] = toIPCRuleTrace(r)
|
||||||
|
}
|
||||||
|
return ipc.TickTrace{
|
||||||
|
Now: t.Now,
|
||||||
|
Winner: t.Winner,
|
||||||
|
Rules: rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
||||||
|
return ipc.RuleTrace{
|
||||||
|
RuleName: r.RuleName,
|
||||||
|
Severity: int(r.Severity),
|
||||||
|
PredicateResult: r.PredicateResult,
|
||||||
|
GateResult: r.GateResult,
|
||||||
|
GateBlockedBy: r.GateBlockedBy,
|
||||||
|
GateDetail: toIPCGateDetail(r.GateDetail),
|
||||||
|
WasSelected: r.WasSelected,
|
||||||
|
LostTo: r.LostTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
||||||
|
return ipc.GateDetail{
|
||||||
|
SnoozeUntil: d.SnoozeUntil,
|
||||||
|
CooldownUntil: d.CooldownUntil,
|
||||||
|
QuietHours: d.QuietHours,
|
||||||
|
CalendarBusy: d.CalendarBusy,
|
||||||
|
Presence: d.Presence,
|
||||||
|
InertKeysMissing: d.InertKeysMissing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
// mavend/tick_api.go — the daemonAPI read surface over the tick loop.
|
|
||||||
//
|
|
||||||
// Split out of tick.go, move-only (Vikunja #422). What mavweb asks the daemon
|
|
||||||
// for, and the loop-to-ipc conversions those answers need.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
)
|
|
||||||
|
|
||||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
|
||||||
// daemon's in-memory tick trace cache.
|
|
||||||
type daemonAPI struct {
|
|
||||||
ipc.CoreAPI
|
|
||||||
getTrace func() *loop.TickTrace
|
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
|
||||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
|
||||||
chatFn func(ctx context.Context, conversation, text string) string
|
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
|
||||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
|
||||||
// look the same to a reader on purpose, because neither is a fault and the
|
|
||||||
// page renders both as an empty table.
|
|
||||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
|
||||||
if d.getEvents == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return d.getEvents(n), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
|
||||||
if d.chatFn == nil {
|
|
||||||
return "", errors.New("mavend: chat not available")
|
|
||||||
}
|
|
||||||
return d.chatFn(ctx, conversation, text), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
|
||||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
|
||||||
// default state and the web surface renders it as such.
|
|
||||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
|
||||||
if d.getMCPServers == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return d.getMCPServers(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
|
||||||
trace := d.getTrace()
|
|
||||||
if trace == nil {
|
|
||||||
return ipc.TickTrace{}, nil
|
|
||||||
}
|
|
||||||
return toIPCTickTrace(*trace), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
|
||||||
if d.getMorningStatus == nil {
|
|
||||||
return nil, errors.New("mavend: morning status not available")
|
|
||||||
}
|
|
||||||
return d.getMorningStatus(ctx), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
|
||||||
if d.getDayPlan == nil {
|
|
||||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
|
||||||
}
|
|
||||||
return d.getDayPlan(ctx), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
|
||||||
for i, r := range t.RuleTraces {
|
|
||||||
rules[i] = toIPCRuleTrace(r)
|
|
||||||
}
|
|
||||||
return ipc.TickTrace{
|
|
||||||
Now: t.Now,
|
|
||||||
Winner: t.Winner,
|
|
||||||
Rules: rules,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
|
|
||||||
return ipc.RuleTrace{
|
|
||||||
RuleName: r.RuleName,
|
|
||||||
Severity: int(r.Severity),
|
|
||||||
PredicateResult: r.PredicateResult,
|
|
||||||
GateResult: r.GateResult,
|
|
||||||
GateBlockedBy: r.GateBlockedBy,
|
|
||||||
GateDetail: toIPCGateDetail(r.GateDetail),
|
|
||||||
WasSelected: r.WasSelected,
|
|
||||||
LostTo: r.LostTo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
|
|
||||||
return ipc.GateDetail{
|
|
||||||
SnoozeUntil: d.SnoozeUntil,
|
|
||||||
CooldownUntil: d.CooldownUntil,
|
|
||||||
QuietHours: d.QuietHours,
|
|
||||||
CalendarBusy: d.CalendarBusy,
|
|
||||||
Presence: d.Presence,
|
|
||||||
InertKeysMissing: d.InertKeysMissing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
// mavend/tick_digest.go — the digest queue.
|
|
||||||
//
|
|
||||||
// Split out of tick.go, move-only (Vikunja #422). A nudge below the severity
|
|
||||||
// ceiling is held here instead of spoken, flushed as one batch on the window,
|
|
||||||
// and drained by hand when he asks. Everything about batching lives here.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// shouldQueue — true when digest is enabled and the candidate's severity is
|
|
||||||
// at or below the configured ceiling.
|
|
||||||
func (t *tickLoop) shouldQueue(cand *loop.Candidate) bool {
|
|
||||||
return t.digestCfg != nil && t.digestCfg.Enabled &&
|
|
||||||
cand.Severity <= loop.Severity(t.digestCfg.SeverityCeiling)
|
|
||||||
}
|
|
||||||
|
|
||||||
// queueNudge — phrases the candidate and appends it to the digest queue.
|
|
||||||
// Deduplicates by rule name: if the same rule is already queued, this is a
|
|
||||||
// no-op (the first fire within the window is the one that counts).
|
|
||||||
func (t *tickLoop) queueNudge(ctx context.Context, cand *loop.Candidate, _ loop.State, now time.Time) {
|
|
||||||
for _, q := range t.digestQ {
|
|
||||||
if q.Rule == cand.Rule.Name {
|
|
||||||
return // already queued
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = append(t.digestQ, QueuedNudge{
|
|
||||||
Rule: cand.Rule.Name,
|
|
||||||
Severity: int(cand.Severity),
|
|
||||||
Body: pn.Body,
|
|
||||||
Key: cand.Rule.Name,
|
|
||||||
QueuedAt: now,
|
|
||||||
})
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeFlush — flushes the digest queue if the window has elapsed since the
|
|
||||||
// first item or the queue reached MaxItems.
|
|
||||||
func (t *tickLoop) maybeFlush(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if t.digestCfg == nil || !t.digestCfg.Enabled || len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
first := t.digestQ[0]
|
|
||||||
if now.Sub(first.QueuedAt) >= time.Duration(t.digestCfg.Window) ||
|
|
||||||
len(t.digestQ) >= t.digestCfg.MaxItems {
|
|
||||||
t.flushDigest(ctx, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushDigest — concatenates queued nudge bodies into a single digest
|
|
||||||
// notification and dispatches it. Clears the queue after a successful send.
|
|
||||||
// The digest uses the max severity among queued items for routing.
|
|
||||||
func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if len(t.digestQ) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, q := range t.digestQ {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(q.Body)
|
|
||||||
if q.Severity > maxSev {
|
|
||||||
maxSev = q.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
summary := fmt.Sprintf("%d pending notifications", len(t.digestQ))
|
|
||||||
|
|
||||||
cand := loop.Candidate{
|
|
||||||
Rule: loop.Rule{
|
|
||||||
Name: "digest",
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
},
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: cand,
|
|
||||||
Body: body,
|
|
||||||
Summary: summary,
|
|
||||||
}
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
// keep the queue — the next tick's maybeFlush re-attempts.
|
|
||||||
log.Printf("tick: dispatch digest: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.digestQ = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
|
||||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
|
||||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
|
||||||
// digest entry that outlives one full day is describing a day that's already
|
|
||||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
|
||||||
// news. Bounding at one day also means a digest can never silently span a
|
|
||||||
// weekend of quiet hours into an unbounded backlog.
|
|
||||||
const digestExpiry = 24 * time.Hour
|
|
||||||
|
|
||||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
|
||||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
|
||||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
|
||||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
|
||||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
|
||||||
// count instead of being spoken in full.
|
|
||||||
const maxDigestSpokenItems = 3
|
|
||||||
|
|
||||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
|
||||||
// gate blocked for a genuine restraint reason and durably records the
|
|
||||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
|
||||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
|
||||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
|
||||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
|
||||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
|
||||||
// when a pending entry already exists avoids the LLM round-trip too).
|
|
||||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
|
||||||
if trace == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, tr := range trace.RuleTraces {
|
|
||||||
if !tr.PredicateResult || tr.GateResult {
|
|
||||||
continue // didn't want to fire, or wasn't suppressed
|
|
||||||
}
|
|
||||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
|
||||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
|
||||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
expires := now.Add(digestExpiry)
|
|
||||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
|
||||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
|
||||||
} else if deduped {
|
|
||||||
// same suppressed nudge already pending — nothing new to say.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
|
||||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
|
||||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
|
||||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: expire stale digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
|
||||||
// suppression reasons have actually cleared — quiet hours over, back from
|
|
||||||
// away, out of the meeting. Draining while still suppressed would just be a
|
|
||||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
|
||||||
// it allowed right now" condition a live nudge already waits for.
|
|
||||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
|
||||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: pending digest entries: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
spoken := entries
|
|
||||||
extra := 0
|
|
||||||
if len(spoken) > maxDigestSpokenItems {
|
|
||||||
spoken = entries[:maxDigestSpokenItems]
|
|
||||||
extra = len(entries) - maxDigestSpokenItems
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
maxSev := 0
|
|
||||||
for i, e := range spoken {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString(" · ")
|
|
||||||
}
|
|
||||||
b.WriteString(e.Body)
|
|
||||||
if e.Severity > maxSev {
|
|
||||||
maxSev = e.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if extra > 0 {
|
|
||||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
|
||||||
}
|
|
||||||
body := b.String()
|
|
||||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
|
||||||
|
|
||||||
cand := loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
|
||||||
Severity: loop.Severity(maxSev),
|
|
||||||
State: state,
|
|
||||||
}
|
|
||||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
|
||||||
t.cachePhrase(pn)
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
|
||||||
return // leave entries pending; retried next tick
|
|
||||||
}
|
|
||||||
ids := make([]int64, len(entries))
|
|
||||||
for i, e := range entries {
|
|
||||||
ids[i] = e.ID
|
|
||||||
}
|
|
||||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
|
||||||
log.Printf("tick: drain digest entries: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
// mavend/tick_morning.go — the morning checklist and the day plan.
|
|
||||||
//
|
|
||||||
// Split out of tick.go, move-only (Vikunja #422). One window per configured
|
|
||||||
// routine, nudging once at the end for what is still open, plus the read
|
|
||||||
// surfaces /morning renders.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
"github.com/kami/maven/internal/morning"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fireMorningRoutines checks each configured checklist against today's facts
|
|
||||||
// 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)
|
|
||||||
// because the item↔fact-key mapping is morning-routine-specific, not a rule
|
|
||||||
// concern — pulling it into the shared gather path would leak that mapping
|
|
||||||
// into loop's "rules declare wanted keys" contract. Bodies are literal
|
|
||||||
// operator text (item labels joined), not LLM-phrased, same rationale as
|
|
||||||
// cron routines: deterministic, can't hallucinate a checklist item.
|
|
||||||
func (t *tickLoop) fireMorningRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
if len(t.morningRoutines) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
facts := t.gatherMorningFacts(ctx)
|
|
||||||
|
|
||||||
for _, cand := range morning.Due(t.morningRoutines, facts, t.morningLast, now) {
|
|
||||||
body := morningNudgeBody(cand)
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "morning:" + cand.Routine.Name, Severity: loop.Severity(cand.Routine.Severity)},
|
|
||||||
Severity: loop.Severity(cand.Routine.Severity),
|
|
||||||
State: state,
|
|
||||||
},
|
|
||||||
Body: body,
|
|
||||||
Summary: body,
|
|
||||||
}
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch morning routine %s: %v", cand.Routine.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// morningNudgeBody words the one message a routine gets per day. Required
|
|
||||||
// items are what she says was not done; optional ones follow, worded as
|
|
||||||
// something he could still do rather than something he owes (Vikunja #473).
|
|
||||||
// Operator text, not phrased by the model, for the same reason it always was:
|
|
||||||
// a checklist item must not be invented.
|
|
||||||
func morningNudgeBody(cand morning.Candidate) string {
|
|
||||||
labels := func(items []morning.Item) string {
|
|
||||||
out := make([]string, len(items))
|
|
||||||
for i, it := range items {
|
|
||||||
out[i] = it.Label
|
|
||||||
}
|
|
||||||
return strings.Join(out, ", ")
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s: не сделано — %s", cand.Routine.Name, labels(morning.Required(cand.Missing)))
|
|
||||||
if opt := morning.OptionalOnly(cand.Missing); len(opt) > 0 {
|
|
||||||
body += fmt.Sprintf(". если будет время — %s", labels(opt))
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatherMorningFacts reads the latest fact for every item's fact_key across
|
|
||||||
// all configured morning routines. Shared by fireMorningRoutines (nudge
|
|
||||||
// decision) and morningStatus (read-only query) so the two paths can never
|
|
||||||
// disagree about what evidence exists.
|
|
||||||
func (t *tickLoop) gatherMorningFacts(ctx context.Context) map[string]store.Fact {
|
|
||||||
keys := make(map[string]struct{})
|
|
||||||
for _, r := range t.morningRoutines {
|
|
||||||
for _, it := range r.Items {
|
|
||||||
keys[it.FactKey] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
facts := make(map[string]store.Fact, len(keys))
|
|
||||||
for k := range keys {
|
|
||||||
f, err := t.store.LatestFact(ctx, k)
|
|
||||||
if err == nil {
|
|
||||||
facts[k] = f
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != store.ErrNoFact {
|
|
||||||
log.Printf("tick: morning: latest fact %s: %v", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return facts
|
|
||||||
}
|
|
||||||
|
|
||||||
// morningStatus is the read-only "what's missing" query the web UI (and
|
|
||||||
// eventually a voice query) calls. Pure recompute over the current facts —
|
|
||||||
// no dedupe/nudge-time gating, unlike fireMorningRoutines: this answers
|
|
||||||
// "state right now," not "should we nag."
|
|
||||||
func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.MorningRoutineStatus {
|
|
||||||
if len(t.morningRoutines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
facts := t.gatherMorningFacts(ctx)
|
|
||||||
out := make([]ipc.MorningRoutineStatus, 0, len(t.morningRoutines))
|
|
||||||
for _, r := range t.morningRoutines {
|
|
||||||
st := morning.Evaluate(r, facts, now)
|
|
||||||
done := make(map[string]bool, len(st.Completed))
|
|
||||||
for _, it := range st.Completed {
|
|
||||||
done[it.Key] = true
|
|
||||||
}
|
|
||||||
items := make([]ipc.MorningRoutineItem, len(r.Items))
|
|
||||||
for i, it := range r.Items {
|
|
||||||
items[i] = ipc.MorningRoutineItem{Key: it.Key, Label: it.Label, Done: done[it.Key]}
|
|
||||||
}
|
|
||||||
out = append(out, ipc.MorningRoutineStatus{
|
|
||||||
Name: r.Name,
|
|
||||||
Active: st.Active,
|
|
||||||
WindowStart: r.WindowStart,
|
|
||||||
WindowEnd: r.WindowEnd,
|
|
||||||
Items: items,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
|
||||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
|
||||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
|
||||||
//
|
|
||||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
|
||||||
// unprompted delivery in maven stays with the morning nudge and the
|
|
||||||
// dispatcher's policy.
|
|
||||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
|
||||||
y, m, d := now.Date()
|
|
||||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
||||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
|
||||||
|
|
||||||
var events []morning.PlanEntry
|
|
||||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: day plan: calendar events: %v", err)
|
|
||||||
}
|
|
||||||
for _, f := range facts {
|
|
||||||
events = append(events, morning.PlanEntry{
|
|
||||||
At: f.Ts,
|
|
||||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
|
||||||
// fact value carries would say it twice.
|
|
||||||
Text: calendar.FactSummary(f.Value),
|
|
||||||
Kind: morning.PlanEvent,
|
|
||||||
// Provenance below a calendar read (an ambient relay, #126) is
|
|
||||||
// hedged rather than recited as fact.
|
|
||||||
Uncertain: f.Confidence < 1.0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var reminders []morning.PlanEntry
|
|
||||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
|
||||||
}
|
|
||||||
for _, r := range rems {
|
|
||||||
if r.Status != store.ReminderPending {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fire := r.NextFireTs
|
|
||||||
if fire.IsZero() {
|
|
||||||
fire = r.FireTs
|
|
||||||
}
|
|
||||||
reminders = append(reminders, morning.PlanEntry{
|
|
||||||
At: fire,
|
|
||||||
Text: r.Text(),
|
|
||||||
Kind: morning.PlanReminder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var checklistFacts map[string]store.Fact
|
|
||||||
if len(t.morningRoutines) > 0 {
|
|
||||||
checklistFacts = t.gatherMorningFacts(ctx)
|
|
||||||
}
|
|
||||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
|
||||||
|
|
||||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
|
||||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
|
||||||
for i, it := range plan.Items {
|
|
||||||
out.Items[i] = ipc.DayPlanItem{
|
|
||||||
At: it.At,
|
|
||||||
Text: it.Text,
|
|
||||||
Kind: string(it.Kind),
|
|
||||||
Uncertain: it.Uncertain,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
// mavend/tick_routines.go — pattern detection and the routines that fire.
|
|
||||||
//
|
|
||||||
// Split out of tick.go, move-only (Vikunja #422). Configured routines, the
|
|
||||||
// routines he accepted on /routines, and the tick-side detector that proposes
|
|
||||||
// new ones.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
"github.com/kami/maven/internal/pattern"
|
|
||||||
"github.com/kami/maven/internal/routine"
|
|
||||||
)
|
|
||||||
|
|
||||||
// detectPatterns runs the pattern detector proactively over every
|
|
||||||
// action+object pair that has ever produced an event, independent of
|
|
||||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
|
||||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
|
||||||
// own schedule reading accumulated history, not only as a side effect of a
|
|
||||||
// live voice turn.
|
|
||||||
//
|
|
||||||
// Idempotence and noise are handled by the store, not here — this function
|
|
||||||
// is safe to call every tick:
|
|
||||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
|
||||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
|
||||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
|
||||||
// already has a row — in ANY status — produces no second row and no log
|
|
||||||
// spam beyond the one line at genuine creation.
|
|
||||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
|
||||||
// status in place; the row is never deleted. So the same Lookup check
|
|
||||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
|
||||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
|
||||||
// calling the same shared path the voice route already used.
|
|
||||||
//
|
|
||||||
// By default this only creates a row for the /routines page to show: it does
|
|
||||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
|
||||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
|
||||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
|
||||||
// for the restraints that apply even then. A proposal only starts producing
|
|
||||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
|
||||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: distinct event pairs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
announced := false
|
|
||||||
for _, p := range pairs {
|
|
||||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == nil {
|
|
||||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
|
||||||
}
|
|
||||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
|
||||||
// One announcement per tick at most, whatever the scan turned up. The
|
|
||||||
// rest are on /routines; they are not lost, they are just not shouted.
|
|
||||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
|
||||||
// them and they are never announced. See announceProposal.
|
|
||||||
if announced {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
announced = t.announceProposal(ctx, r, now, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// announceProposal offers a freshly inferred routine through the ordinary
|
|
||||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
|
||||||
// something was actually sent.
|
|
||||||
//
|
|
||||||
// Everything here is restraint. The feature is off unless configured; when on
|
|
||||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
|
||||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
|
||||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
|
||||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
|
||||||
// real send, but the proposal row already exists, so the next tick will not
|
|
||||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
|
||||||
// he reads it on /routines instead, which is the whole point of the page.
|
|
||||||
//
|
|
||||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
|
||||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
|
||||||
// that first proposes it. Combined with one announcement per tick, the first
|
|
||||||
// tick over a populated history announces one pattern and permanently silences
|
|
||||||
// every other pattern found in the same pass. That is the intent, not an
|
|
||||||
// oversight — an inferred routine is not worth a second attempt at his
|
|
||||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
|
||||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
|
||||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
|
||||||
// that needs a queue rather than this counter.
|
|
||||||
//
|
|
||||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
|
||||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
|
||||||
// and an unspaced announcer is not what those tests mean to exercise.
|
|
||||||
//
|
|
||||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
|
||||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
|
||||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
|
||||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
|
||||||
if !t.proposalCfg.AnnounceProposals() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
|
||||||
if cooldown <= 0 {
|
|
||||||
cooldown = config.DefaultProposalCooldown
|
|
||||||
}
|
|
||||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
|
||||||
if !loop.Gate(state, rule) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body := pattern.PhraseRoutine(r)
|
|
||||||
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: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(sent) == 0 {
|
|
||||||
return false // routing dropped it — /routines still has it.
|
|
||||||
}
|
|
||||||
t.lastProposalAt = now
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
|
||||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
|
||||||
// in config.Load, so this is a pure field copy.
|
|
||||||
func routinesFromConfig(rc []config.RoutineConfig) []routine.Routine {
|
|
||||||
if len(rc) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]routine.Routine, len(rc))
|
|
||||||
for i, r := range rc {
|
|
||||||
out[i] = routine.Routine{Name: r.Name, Cron: r.Cron, Body: r.Body, Severity: r.Severity}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// fireRoutines dispatches the routines whose cron schedule crossed since their
|
|
||||||
// last fire. Each is delivered as a nudge through the normal routing table
|
|
||||||
// (ChannelsFor(severity, presence)) with a "routine:"-prefixed rule name so it
|
|
||||||
// can't collide with a care rule in the feedback autotuner. A dispatch failure
|
|
||||||
// logs and continues — one bad send must not skip the rest, and routine.Due has
|
|
||||||
// already advanced the last-fire time so a transient failure drops that fire
|
|
||||||
// rather than replaying it every tick (a routine is clockwork, not an alarm —
|
|
||||||
// no repeat-til-ack).
|
|
||||||
func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.State) {
|
|
||||||
for _, r := range routine.Due(t.routines, t.routineLast, now) {
|
|
||||||
pn := delivery.PhrasedNudge{
|
|
||||||
Candidate: loop.Candidate{
|
|
||||||
Rule: loop.Rule{Name: "routine:" + r.Name, Severity: loop.Severity(r.Severity)},
|
|
||||||
Severity: loop.Severity(r.Severity),
|
|
||||||
State: state,
|
|
||||||
},
|
|
||||||
Body: r.Body,
|
|
||||||
Summary: r.Body,
|
|
||||||
}
|
|
||||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
|
||||||
log.Printf("tick: dispatch routine %s: %v", r.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
markPresent(t, st, ctx, now)
|
markPresent(t, st, ctx, now)
|
||||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
||||||
t.Fatalf("seed service_down: %v", err)
|
t.Fatalf("seed service_down: %v", err)
|
||||||
}
|
}
|
||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||||
|
// routed system and answered "пока не умею" (Vikunja #474).
|
||||||
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||||
// explicit capture marker beats the model, which called it an act and
|
// explicit capture marker beats the model, which called it an act and
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
// TestExtractWeatherLocation — any place he names comes through, not just the
|
// TestExtractWeatherLocation — any place he names comes through, not just the
|
||||||
// six that used to be in a table (Vikunja #421).
|
// six that used to be in a table (Vikunja #421).
|
||||||
@@ -31,3 +36,30 @@ func TestExtractWeatherLocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCalendarStepsAsideForWeather — the defect (Vikunja #474). "какая сегодня
|
||||||
|
// погода в Москве?" answered "на 02.08.2026 ничего нет.": the calendar matches
|
||||||
|
// on a day word alone, and it sits above the weather source.
|
||||||
|
func TestCalendarStepsAsideForWeather(t *testing.T) {
|
||||||
|
h, api := contQueryHandler()
|
||||||
|
for _, u := range []string{
|
||||||
|
"какая сегодня погода в Москве?",
|
||||||
|
"будет дождь завтра?",
|
||||||
|
"сколько градусов сегодня?",
|
||||||
|
} {
|
||||||
|
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
}); ok {
|
||||||
|
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.events != 0 {
|
||||||
|
t.Errorf("CalendarEvents called %d times for weather questions, want 0", api.events)
|
||||||
|
}
|
||||||
|
// The agenda question it exists for still reaches it.
|
||||||
|
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что у меня сегодня?"},
|
||||||
|
}); !ok {
|
||||||
|
t.Fatal("the calendar stopped answering the agenda question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-71
@@ -140,10 +140,6 @@ type poller struct {
|
|||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
|
||||||
// disappears from the gauge can be marked unknown instead of staying down.
|
|
||||||
kumaSeen map[string]string
|
|
||||||
|
|
||||||
// zen is nil unless a token file was configured — money tracking is a
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
// capability, off by default like weather and telegram.
|
// capability, off by default like weather and telegram.
|
||||||
zen *zenmoney.Client
|
zen *zenmoney.Client
|
||||||
@@ -337,96 +333,50 @@ func maxSeverity(a netdataAlarms) string {
|
|||||||
return sev
|
return sev
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
||||||
|
|
||||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||||
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
||||||
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
||||||
// service is down. The aggregate this used to write could not, which is why
|
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
||||||
// the rule shipped disabled.
|
// only needs "something is down".
|
||||||
var (
|
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||||
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
|
||||||
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
states := kumaMonitors(body)
|
down, seen := kumaAnyDown(body)
|
||||||
if len(states) == 0 {
|
if !seen {
|
||||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||||
}
|
}
|
||||||
var firstErr error
|
val := "up"
|
||||||
for name, val := range states {
|
if down {
|
||||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
val = "down"
|
||||||
firstErr = err // one bad monitor must not blind the rest
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
||||||
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
|
||||||
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
|
||||||
// the next poll that still lacks the monitor says nothing new either.
|
|
||||||
for name := range p.kumaSeen {
|
|
||||||
if _, still := states[name]; !still {
|
|
||||||
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.kumaSeen = states
|
|
||||||
return firstErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
||||||
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
||||||
const kumaSource = "poll:uptimekuma"
|
// lines matched at all (wrong endpoint or auth rejected before the body).
|
||||||
|
func kumaAnyDown(body []byte) (down, seen bool) {
|
||||||
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
|
||||||
// so it stays as kuma spells it rather than being slugged into something else.
|
|
||||||
func kumaFactKey(name string) string { return "service_down:" + name }
|
|
||||||
|
|
||||||
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
|
||||||
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
|
||||||
// line matched at all (wrong endpoint, or auth rejected before the body).
|
|
||||||
// A line with no monitor_name label is skipped: a fact nobody can name is
|
|
||||||
// exactly the thing this replaced.
|
|
||||||
func kumaMonitors(body []byte) map[string]string {
|
|
||||||
out := make(map[string]string)
|
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nm := kumaName.FindStringSubmatch(m[1])
|
seen = true
|
||||||
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseFloat(m[2], 64)
|
v, err := strconv.ParseFloat(m[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
if v == 0 {
|
||||||
}
|
down = true
|
||||||
return out
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
|
||||||
// a monitor paused in kuma should silence that monitor, not page him.
|
|
||||||
func kumaState(v float64) string {
|
|
||||||
switch v {
|
|
||||||
case 0:
|
|
||||||
return "down"
|
|
||||||
case 1:
|
|
||||||
return "up"
|
|
||||||
case 2:
|
|
||||||
return "pending"
|
|
||||||
case 3:
|
|
||||||
return "maintenance"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
}
|
||||||
|
return down, seen
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|||||||
+12
-36
@@ -35,46 +35,22 @@ func TestMaxSeverity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
func TestKumaAnyDown(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
body string
|
||||||
body string
|
down, seen bool
|
||||||
want map[string]string
|
|
||||||
}{
|
}{
|
||||||
{"empty body", "", map[string]string{}},
|
{"", false, false},
|
||||||
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
{`monitor_status{monitor_name="web"} 1`, false, true},
|
||||||
{
|
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
||||||
"one up one down",
|
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
||||||
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
{`# HELP monitor_status ...`, false, false},
|
||||||
map[string]string{"web": "up", "db": "down"},
|
|
||||||
},
|
|
||||||
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
|
||||||
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
|
||||||
{
|
|
||||||
"other labels do not hide the name",
|
|
||||||
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
|
||||||
map[string]string{"ci": "down"},
|
|
||||||
},
|
|
||||||
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
down, seen := kumaAnyDown([]byte(c.body))
|
||||||
got := kumaMonitors([]byte(c.body))
|
if down != c.down || seen != c.seen {
|
||||||
if len(got) != len(c.want) {
|
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
||||||
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
}
|
||||||
}
|
|
||||||
for k, v := range c.want {
|
|
||||||
if got[k] != v {
|
|
||||||
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
|
||||||
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
|
||||||
t.Errorf("kumaFactKey = %q", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -23,6 +23,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,15 +40,27 @@ func run(args []string) error {
|
|||||||
model := flag.String("model", "", "path to piper onnx model file")
|
model := flag.String("model", "", "path to piper onnx model file")
|
||||||
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
espeakData := flag.String("espeak_data", "", "path to espeak-ng data directory")
|
||||||
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
tashkeelModel := flag.String("tashkeel_model", "", "path to libtashkeel onnx model")
|
||||||
|
lexiconPath := flag.String("lexicon", "", "path to the pronunciation dictionary (json, name to spelling)")
|
||||||
|
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Read before the handler is built: a dictionary he asked for and that
|
||||||
|
// cannot be read is a startup failure, not a warning. Saying names wrong
|
||||||
|
// in silence is the thing it exists to stop.
|
||||||
|
lex, err := tts.LoadLexicon(*lexiconPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lex.Size() > 0 {
|
||||||
|
log.Printf("mavttsd: pronunciation dictionary: %d names from %s", lex.Size(), *lexiconPath)
|
||||||
|
}
|
||||||
|
|
||||||
var s worker.Synthesizer
|
var s worker.Synthesizer
|
||||||
if *piperBin != "" && *model != "" {
|
if *piperBin != "" && *model != "" {
|
||||||
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel)
|
s = newPiperHandler(*piperBin, *model, *espeakData, *tashkeelModel, lex)
|
||||||
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
log.Printf("mavttsd: using piper tts (%s, model=%s)", *piperBin, *model)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
log.Printf("mavttsd: no piper/model specified, using stub handler")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,3 +118,18 @@ func abs(n int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dictionary that ships in deploy/ must parse and must be non-empty. It is
|
||||||
|
// data, so nothing else would catch a trailing comma before the voice did.
|
||||||
|
func TestShippedLexiconLoads(t *testing.T) {
|
||||||
|
lex, err := tts.LoadLexicon(filepath.Join("..", "..", "deploy", "tts-lexicon.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deploy/tts-lexicon.json: %v", err)
|
||||||
|
}
|
||||||
|
if lex.Size() < 10 {
|
||||||
|
t.Errorf("shipped dictionary holds %d names, want the full list", lex.Size())
|
||||||
|
}
|
||||||
|
if got := lex.Apply("задача в Vikunja"); got == "задача в Vikunja" {
|
||||||
|
t.Error("the shipped dictionary did not rewrite a name it lists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
|
"github.com/kami/maven/internal/tts"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,15 +19,20 @@ type piperHandler struct {
|
|||||||
configPath string
|
configPath string
|
||||||
espeakData string
|
espeakData string
|
||||||
tashkeelModel string
|
tashkeelModel string
|
||||||
|
// lexicon rewrites service ids and Latin names into the spelling the
|
||||||
|
// Russian voice reads correctly (Vikunja #458). Nil-safe: an unconfigured
|
||||||
|
// dictionary rewrites nothing.
|
||||||
|
lexicon *tts.Lexicon
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string) *piperHandler {
|
func newPiperHandler(piperPath, modelPath, espeakData, tashkeelModel string, lexicon *tts.Lexicon) *piperHandler {
|
||||||
return &piperHandler{
|
return &piperHandler{
|
||||||
piperPath: piperPath,
|
piperPath: piperPath,
|
||||||
modelPath: modelPath,
|
modelPath: modelPath,
|
||||||
configPath: modelPath + ".json",
|
configPath: modelPath + ".json",
|
||||||
espeakData: espeakData,
|
espeakData: espeakData,
|
||||||
tashkeelModel: tashkeelModel,
|
tashkeelModel: tashkeelModel,
|
||||||
|
lexicon: lexicon,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +72,14 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
|||||||
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
return worker.SynthesizeResp{}, fmt.Errorf("piper: start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.WriteString(stdin, req.Text); err != nil {
|
// The dictionary is applied here, at the last edge before the voice: every
|
||||||
|
// caller's text passes through this one point, and nothing upstream has to
|
||||||
|
// know how a name is spelled out loud.
|
||||||
|
text := req.Text
|
||||||
|
if h.lexicon != nil {
|
||||||
|
text = h.lexicon.Apply(text)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(stdin, text); err != nil {
|
||||||
stdin.Close()
|
stdin.Close()
|
||||||
stdout.Close()
|
stdout.Close()
|
||||||
_ = cmd.Wait()
|
_ = cmd.Wait()
|
||||||
|
|||||||
@@ -139,10 +139,7 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAmbientAuth(t *testing.T) {
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
// "завтра" so the meeting is in the future in every zone: the clock in the
|
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
// text is a local wall clock and posted_at is a Z instant, so a bare "10:00"
|
|
||||||
// is already stale on a box east of UTC and stores nothing.
|
|
||||||
body := `{"title":"Планёрка завтра 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
|
||||||
|
|
||||||
newReq := func(hdr, val string) *http.Request {
|
newReq := func(hdr, val string) *http.Request {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
@@ -248,31 +245,3 @@ func TestHandleAmbientBadInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Z-instant defect end to end: a phone posts an RFC 3339 instant in UTC and
|
|
||||||
// the clock inside the text is his wall clock. On a UTC+4 box a 14:30 standup
|
|
||||||
// used to be stored at 18:30, and the size of the error was the deploy's offset.
|
|
||||||
func TestHandleAmbientStoresTheWallClockHeRead(t *testing.T) {
|
|
||||||
// No zone juggling: the assertion is that the stored wall clock is the one
|
|
||||||
// he read, whatever zone the box is in. That is false under the old code
|
|
||||||
// on every box except a UTC one.
|
|
||||||
core := &ambientCore{}
|
|
||||||
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
|
||||||
Package: "com.slack",
|
|
||||||
Title: "Standup",
|
|
||||||
Text: "созвон завтра в 14:30",
|
|
||||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC),
|
|
||||||
})
|
|
||||||
if rr.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(resp.Key, "_Standup") {
|
|
||||||
t.Errorf("key = %q, want a key naming the meeting", resp.Key)
|
|
||||||
}
|
|
||||||
if len(core.writeLog) != 1 {
|
|
||||||
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
|
||||||
}
|
|
||||||
if got := core.writeLog[0].Value; got != "Standup @ 14:30-15:00" {
|
|
||||||
t.Errorf("value = %q, want %q", got, "Standup @ 14:30-15:00")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{{template "shellTop" "chat"}}
|
|
||||||
<h1>Chat</h1>
|
|
||||||
<section class=card>
|
|
||||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
|
||||||
<div class="scroll chat-scroll" id=chatHistory>
|
|
||||||
{{range .Messages}}
|
|
||||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
|
||||||
{{else}}
|
|
||||||
<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
|
||||||
<div>start a conversation</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
<form method=post action=/api/chat class=chat-form>
|
|
||||||
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
|
||||||
<button class=btn>send</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
<script>
|
|
||||||
var ch = document.getElementById('chatHistory');
|
|
||||||
if(ch) ch.scrollTop = ch.scrollHeight;
|
|
||||||
</script>
|
|
||||||
{{template "shellBottom"}}
|
|
||||||
+289
-68
@@ -75,23 +75,6 @@ var morningHTML string
|
|||||||
//go:embed events.html
|
//go:embed events.html
|
||||||
var eventsHTML string
|
var eventsHTML string
|
||||||
|
|
||||||
//go:embed tools.html
|
|
||||||
var toolsHTML string
|
|
||||||
|
|
||||||
//go:embed routines.html
|
|
||||||
var routinesHTML string
|
|
||||||
|
|
||||||
//go:embed chat.html
|
|
||||||
var chatPageHTML string
|
|
||||||
|
|
||||||
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
|
||||||
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
|
||||||
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
|
||||||
// markup that was still concatenated in Go.
|
|
||||||
//
|
|
||||||
//go:embed shell.html
|
|
||||||
var shellHTML string
|
|
||||||
|
|
||||||
// ── Ethos Workstation Shell ──
|
// ── Ethos Workstation Shell ──
|
||||||
//
|
//
|
||||||
// Two template pieces that wrap every page:
|
// Two template pieces that wrap every page:
|
||||||
@@ -152,40 +135,74 @@ var sidebarSections = []struct {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
func sidebarActive(url, key string, activeKey string) string {
|
||||||
// sidebar template wraps it in the <use> reference.
|
if key == activeKey {
|
||||||
|
return `class="active"`
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebarHTML renders the sidebar navigation given the active page key.
|
||||||
|
func sidebarHTML(active string) template.HTML {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, sec := range sidebarSections {
|
||||||
|
b.WriteString(`<div class=sidebar-section>`)
|
||||||
|
b.WriteString(`<div class=sidebar-label>`)
|
||||||
|
b.WriteString(sec.Label)
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
for _, p := range sec.Pages {
|
||||||
|
cls := ""
|
||||||
|
if p.Key == active {
|
||||||
|
cls = ` class="active"`
|
||||||
|
}
|
||||||
|
b.WriteString(`<a href="`)
|
||||||
|
b.WriteString(p.URL)
|
||||||
|
b.WriteString(`"`)
|
||||||
|
b.WriteString(cls)
|
||||||
|
b.WriteString(`><span class=icon>`)
|
||||||
|
b.WriteString(pageIcon(p.Key))
|
||||||
|
b.WriteString(`</span><span>`)
|
||||||
|
b.WriteString(p.Label)
|
||||||
|
b.WriteString(`</span></a>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
}
|
||||||
|
return template.HTML(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
|
||||||
func pageIcon(key string) string {
|
func pageIcon(key string) string {
|
||||||
switch key {
|
switch key {
|
||||||
case "dash":
|
case "dash":
|
||||||
return "i-grid"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "history":
|
case "history":
|
||||||
return "i-clock"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
|
||||||
case "trace":
|
case "trace":
|
||||||
return "i-wave"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "i-bell"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||||
case "tasks":
|
case "tasks":
|
||||||
return "i-grid"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return "i-calendar"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "routines":
|
case "routines":
|
||||||
return "i-repeat"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
|
||||||
case "morning":
|
case "morning":
|
||||||
return "i-calendar"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "chat":
|
case "chat":
|
||||||
return "i-message"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
|
||||||
case "voice":
|
case "voice":
|
||||||
return "i-mic"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
|
||||||
case "ecosystem":
|
case "ecosystem":
|
||||||
return "i-grid"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "tools":
|
case "tools":
|
||||||
return "i-settings"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||||
case "models":
|
case "models":
|
||||||
return "i-wave"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "passkey":
|
case "passkey":
|
||||||
return "i-lock"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||||
default:
|
default:
|
||||||
return "i-search"
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +242,63 @@ func pageTitle(key string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shellTopHTML opens the shell and renders the top bar + sidebar.
|
||||||
|
// Usage: {{template "shellTop" "<page-key>"}}
|
||||||
|
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||||
|
<meta name=theme-color content="#14110D">
|
||||||
|
<link rel=manifest href=/manifest.json>
|
||||||
|
<title>maven · {{pageTitle .}}</title>
|
||||||
|
<link rel=stylesheet href=/ui.css>
|
||||||
|
<div class=shell data-app=maven>
|
||||||
|
<header class=topbar>
|
||||||
|
<div class=breadcrumbs>
|
||||||
|
<span class=current>{{pageTitle .}}</span>
|
||||||
|
</div>
|
||||||
|
<div class=topbar-actions>
|
||||||
|
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
||||||
|
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
||||||
|
Search
|
||||||
|
<span class=kbd-hint>Ctrl+/</span>
|
||||||
|
</div>
|
||||||
|
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
||||||
|
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class=conn-status>
|
||||||
|
<span class="dot online" id=connDot></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class=shell-body>
|
||||||
|
<aside class=sidebar>
|
||||||
|
{{sidebarHTML .}}
|
||||||
|
</aside>
|
||||||
|
<main class=content>
|
||||||
|
{{end}}`
|
||||||
|
|
||||||
|
// shellBottomHTML closes the content area, inspector, and shell.
|
||||||
|
// Usage: {{template "shellBottom"}}
|
||||||
|
const shellBottomHTML = `{{define "shellBottom"}}
|
||||||
|
</main>
|
||||||
|
<aside class=inspector id=inspector>
|
||||||
|
<div class=inspector-inner>
|
||||||
|
<div class=inspector-header>
|
||||||
|
<span id=inspectorTitle>Details</span>
|
||||||
|
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
||||||
|
</div>
|
||||||
|
<div class=inspector-body id=inspectorBody></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src=/mavweb.js></script>
|
||||||
|
{{end}}`
|
||||||
|
|
||||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||||
func shellFuncs() template.FuncMap {
|
func shellFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"pageTitle": pageTitle,
|
"pageTitle": pageTitle,
|
||||||
"pageIcon": pageIcon,
|
"sidebarHTML": sidebarHTML,
|
||||||
"sidebarSections": func() any { return sidebarSections },
|
|
||||||
"ago": func(t time.Time) string {
|
"ago": func(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return "never"
|
return "never"
|
||||||
@@ -245,21 +313,21 @@ func shellFuncs() template.FuncMap {
|
|||||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||||
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellHTML + dashHTML))
|
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
|
||||||
|
|
||||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||||
// human surface is here (they ship no web UI of their own).
|
// human surface is here (they ship no web UI of their own).
|
||||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellHTML + ecosystemHTML))
|
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||||
|
|
||||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML))
|
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||||
|
|
||||||
// morningTmpl — read-only view of today's checklist state per configured
|
// morningTmpl — read-only view of today's checklist state per configured
|
||||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||||
// checklist state changes on the scale of minutes, not seconds.
|
// checklist state changes on the scale of minutes, not seconds.
|
||||||
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML))
|
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -679,23 +747,107 @@ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMa
|
|||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellHTML + toolsHTML))
|
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
|
||||||
|
|
||||||
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML))
|
const toolsHTML = `{{template "shellTop" "tools"}}
|
||||||
|
<h1>Tools</h1>
|
||||||
|
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
||||||
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||||
|
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||||
|
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||||
|
{{range .Proposed}}<tr>
|
||||||
|
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||||
|
<td><form method=post action=/tools>
|
||||||
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
|
<input type=hidden name=action value=enable>
|
||||||
|
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||||
|
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||||
|
<button class=btn>enable</button></form>
|
||||||
|
<form method=post action=/tools class=inline-form>
|
||||||
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=action value=dismiss>
|
||||||
|
<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-search"/></svg>
|
||||||
|
<div>no proposed tools</div>
|
||||||
|
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
||||||
|
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
||||||
|
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
||||||
|
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
||||||
|
<td><form method=post action=/tools class=inline-form>
|
||||||
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
|
<input type=hidden name=action value=disable>
|
||||||
|
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||||
|
<div>no tools enabled</div>
|
||||||
|
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||||
|
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||||
|
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||||
|
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||||
|
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||||
|
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||||
|
<div>no MCP servers configured</div>
|
||||||
|
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML))
|
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||||
|
|
||||||
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML))
|
|
||||||
|
|
||||||
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML))
|
|
||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML))
|
|
||||||
|
|
||||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML))
|
|
||||||
|
|
||||||
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
|
||||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML))
|
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>
|
||||||
|
{{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>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=dismiss>
|
||||||
|
<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>
|
||||||
|
<div>no proposed routines</div>
|
||||||
|
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
|
var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
m := shellFuncs()
|
m := shellFuncs()
|
||||||
@@ -707,7 +859,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMa
|
|||||||
}
|
}
|
||||||
m["join"] = strings.Join
|
m["join"] = strings.Join
|
||||||
return m
|
return m
|
||||||
}()).Parse(shellHTML + traceHTML))
|
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
|
||||||
|
|
||||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
@@ -794,6 +946,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||||
|
// timestamps already in his clock.
|
||||||
|
//
|
||||||
|
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||||
|
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||||
|
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||||
|
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||||
|
type reminderRow struct {
|
||||||
|
Created string
|
||||||
|
Fires string
|
||||||
|
Status string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderText unwraps the {"text":...} payload the router writes.
|
||||||
|
//
|
||||||
|
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||||
|
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||||
|
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||||
|
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||||
|
func reminderText(payload string) string {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||||
|
if t, ok := m["text"]; ok {
|
||||||
|
if s, isStr := t.(string); isStr && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||||
|
out := make([]reminderRow, 0, len(rs))
|
||||||
|
for _, r := range rs {
|
||||||
|
out = append(out, reminderRow{
|
||||||
|
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||||
|
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||||
|
Status: r.Status,
|
||||||
|
Text: reminderText(r.Payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
@@ -807,7 +1004,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
|
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil {
|
||||||
log.Printf("reminders template: %v", err)
|
log.Printf("reminders template: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1020,10 +1217,9 @@ func fmtTaskDate(t *time.Time) string {
|
|||||||
return t.Local().Format("02 Jan")
|
return t.Local().Format("02 Jan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// routineView is one line on the page: what maven noticed, in her words, and
|
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it. A view model, not a database row — the template
|
// how long ago she noticed it.
|
||||||
// never formats an interval or a timestamp itself.
|
type routineRow struct {
|
||||||
type routineView struct {
|
|
||||||
ID int64
|
ID int64
|
||||||
Phrase string
|
Phrase string
|
||||||
Noticed string
|
Noticed string
|
||||||
@@ -1085,23 +1281,23 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := routinesTmpl.Execute(w, struct {
|
if err := routinesTmpl.Execute(w, struct {
|
||||||
Msg string
|
Msg string
|
||||||
Proposed []routineView
|
Proposed []routineRow
|
||||||
}{msg, toRoutineViews(proposed)}); err != nil {
|
}{msg, routineRows(proposed)}); err != nil {
|
||||||
log.Printf("routines render: %v", err)
|
log.Printf("routines render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
// 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.
|
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||||
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
func routineRows(rs []ipc.ProposedRoutine) []routineRow {
|
||||||
out := make([]routineView, 0, len(rs))
|
out := make([]routineRow, 0, len(rs))
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||||
noticed := "just now"
|
noticed := "just now"
|
||||||
if r.CreatedTs > 0 {
|
if r.CreatedTs > 0 {
|
||||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||||
}
|
}
|
||||||
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -1498,7 +1694,32 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
|||||||
|
|
||||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||||
// and the handler redirects back to /chat with the response.
|
// and the handler redirects back to /chat with the response.
|
||||||
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellHTML + chatPageHTML))
|
var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
const chatPageHTML = `{{template "shellTop" "chat"}}
|
||||||
|
<h1>Chat</h1>
|
||||||
|
<section class=card>
|
||||||
|
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||||
|
<div class="scroll chat-scroll" id=chatHistory>
|
||||||
|
{{range .Messages}}
|
||||||
|
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||||
|
<div>start a conversation</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<form method=post action=/api/chat class=chat-form>
|
||||||
|
<input type=text name=text class=input-wide placeholder="type a message..." required autofocus>
|
||||||
|
<button class=btn>send</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
var ch = document.getElementById('chatHistory');
|
||||||
|
if(ch) ch.scrollTop = ch.scrollHeight;
|
||||||
|
</script>
|
||||||
|
{{template "shellBottom"}}`
|
||||||
|
|
||||||
// handleChatPage renders the chat conversation page.
|
// handleChatPage renders the chat conversation page.
|
||||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type modelController interface {
|
|||||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
|
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
|
||||||
|
|
||||||
const modelsHTML = `{{template "shellTop" "models"}}
|
const modelsHTML = `{{template "shellTop" "models"}}
|
||||||
<h1>Resident model</h1>
|
<h1>Resident model</h1>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
{{if .Reminders}}<div class=scroll><table>
|
{{if .Reminders}}<div class=scroll><table>
|
||||||
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
|
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
|
||||||
{{range .Reminders}}<tr>
|
{{range .Reminders}}<tr>
|
||||||
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
|
<td class=hint>{{.Created}}</td>
|
||||||
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
|
<td>{{.Fires}}</td>
|
||||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
<td class=text-max>{{.Payload}}</td>
|
<td class=text-max>{{.Text}}</td>
|
||||||
</tr>{{end}}</table></div>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<div class=empty>
|
{{else}}<div class=empty>
|
||||||
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
|
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The page showed the storage envelope and the UTC instant (Vikunja #469).
|
||||||
|
func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
|
||||||
|
fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC)
|
||||||
|
rows := reminderRows([]ipc.Reminder{{
|
||||||
|
CreatedTs: fire.Add(-time.Hour),
|
||||||
|
FireTs: fire,
|
||||||
|
Status: "pending",
|
||||||
|
Payload: `{"text":"выпить таблетки"}`,
|
||||||
|
}})
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Text != "выпить таблетки" {
|
||||||
|
t.Errorf("Text = %q, want the words without the envelope", rows[0].Text)
|
||||||
|
}
|
||||||
|
if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want {
|
||||||
|
t.Errorf("Fires = %q, want %q", rows[0].Fires, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(rows[0].Text, "{") {
|
||||||
|
t.Errorf("Text still carries JSON: %q", rows[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payload that is not the envelope is his own words, so it is shown as it is.
|
||||||
|
func TestReminderTextKeepsPlainPayload(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{`{"text":"позвонить маме"}`, "позвонить маме"},
|
||||||
|
{" полить цветы ", "полить цветы"},
|
||||||
|
{`{"body":"nope"}`, `{"body":"nope"}`},
|
||||||
|
{"", ""},
|
||||||
|
} {
|
||||||
|
if got := reminderText(tc.in); got != tc.want {
|
||||||
|
t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{{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>
|
|
||||||
{{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>
|
|
||||||
<input type=hidden name=id value="{{.ID}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<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>
|
|
||||||
<div>no proposed routines</div>
|
|
||||||
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{{define "shellTop"}}<!doctype html><meta charset=utf-8>
|
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
||||||
<meta name=theme-color content="#14110D">
|
|
||||||
<link rel=manifest href=/manifest.json>
|
|
||||||
<title>maven · {{pageTitle .}}</title>
|
|
||||||
<link rel=stylesheet href=/ui.css>
|
|
||||||
<div class=shell data-app=maven>
|
|
||||||
<header class=topbar>
|
|
||||||
<div class=breadcrumbs>
|
|
||||||
<span class=current>{{pageTitle .}}</span>
|
|
||||||
</div>
|
|
||||||
<div class=topbar-actions>
|
|
||||||
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
|
|
||||||
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
|
|
||||||
Search
|
|
||||||
<span class=kbd-hint>Ctrl+/</span>
|
|
||||||
</div>
|
|
||||||
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
|
|
||||||
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
|
|
||||||
</button>
|
|
||||||
<span class=conn-status>
|
|
||||||
<span class="dot online" id=connDot></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class=shell-body>
|
|
||||||
<aside class=sidebar>
|
|
||||||
{{template "sidebar" .}}
|
|
||||||
</aside>
|
|
||||||
<main class=content>
|
|
||||||
{{end}}{{/* sidebar — the section list, with the active page's link marked. The dot
|
|
||||||
argument is the page key the page passed to shellTop. */}}{{define "sidebar"}}{{$active := .}}{{range sidebarSections}}<div class=sidebar-section><div class=sidebar-label>{{.Label}}</div>
|
|
||||||
{{- range .Pages}}<a href="{{.URL}}"{{if eq .Key $active}} class="active"{{end}}><span class=icon><svg class=icon width="14" height="14"><use href="/ethos-icons.svg#{{pageIcon .Key}}"/></svg></span><span>{{.Label}}</span></a>
|
|
||||||
{{- end}}</div>
|
|
||||||
{{end}}{{end}}{{define "shellBottom"}}
|
|
||||||
</main>
|
|
||||||
<aside class=inspector id=inspector>
|
|
||||||
<div class=inspector-inner>
|
|
||||||
<div class=inspector-header>
|
|
||||||
<span id=inspectorTitle>Details</span>
|
|
||||||
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">×</button>
|
|
||||||
</div>
|
|
||||||
<div class=inspector-body id=inspectorBody></div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src=/mavweb.js></script>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{{template "shellTop" "tools"}}
|
|
||||||
<h1>Tools</h1>
|
|
||||||
<p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
|
|
||||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
|
||||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
|
||||||
{{range .Proposed}}<tr>
|
|
||||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
|
||||||
<td><form method=post action=/tools>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=enable>
|
|
||||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
|
||||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
|
||||||
<button class=btn>enable</button></form>
|
|
||||||
<form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=action value=dismiss>
|
|
||||||
<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-search"/></svg>
|
|
||||||
<div>no proposed tools</div>
|
|
||||||
<div class=hint>maven will propose tools here when she needs help running an action</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
|
||||||
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
|
||||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
|
|
||||||
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
|
||||||
<td><form method=post action=/tools class=inline-form>
|
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
|
||||||
<input type=hidden name=action value=disable>
|
|
||||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no tools enabled</div>
|
|
||||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
<section class=card>
|
|
||||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
|
||||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
|
||||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
|
||||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
|
||||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
|
||||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
|
||||||
{{else}}<div class=empty>
|
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
|
||||||
<div>no MCP servers configured</div>
|
|
||||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
|
||||||
</div>{{end}}
|
|
||||||
</section>
|
|
||||||
{{template "shellBottom"}}
|
|
||||||
+5
-5
@@ -8,12 +8,12 @@
|
|||||||
"//disabled_rules": [
|
"//disabled_rules": [
|
||||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||||
"water, meal, break, service_down, netdata_critical.",
|
"water, meal, break, service_down, netdata_critical.",
|
||||||
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
||||||
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
||||||
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
||||||
"stays down is one nudge, not one every fifteen minutes."
|
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
||||||
],
|
],
|
||||||
"disabled_rules": [],
|
"disabled_rules": ["service_down"],
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"Maven": "Мэйвен",
|
||||||
|
"Nexus": "Нексус",
|
||||||
|
"Praxis": "Праксис",
|
||||||
|
"Hexis": "Хексис",
|
||||||
|
"Vikunja": "Викунья",
|
||||||
|
"SearXNG": "сёрчиксэнджи",
|
||||||
|
"Kiwix": "Кивикс",
|
||||||
|
"Gitea": "Гитея",
|
||||||
|
"Home Assistant": "Хоум Ассистент",
|
||||||
|
"Docker": "Докер",
|
||||||
|
"Telegram": "Телеграм",
|
||||||
|
"ntfy": "энтифай",
|
||||||
|
"homesrv": "хоумсёрв",
|
||||||
|
"workpc": "воркписи",
|
||||||
|
"whisper": "виспер",
|
||||||
|
"piper": "пайпер",
|
||||||
|
"llama-server": "лама сервер",
|
||||||
|
"Qwen": "Квен",
|
||||||
|
"CalDAV": "калдав",
|
||||||
|
"IMAP": "аймап",
|
||||||
|
"API": "эй-пи-ай",
|
||||||
|
"CPU": "си-пи-ю",
|
||||||
|
"GPU": "джи-пи-ю",
|
||||||
|
"RAM": "оперативная память",
|
||||||
|
"SSD": "эс-эс-ди",
|
||||||
|
"uptime": "аптайм",
|
||||||
|
"backup": "бэкап",
|
||||||
|
"deploy": "деплой"
|
||||||
|
}
|
||||||
@@ -389,29 +389,6 @@ lives in `source`; rules trust provenance.
|
|||||||
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
||||||
trigger.
|
trigger.
|
||||||
|
|
||||||
#### A fact per monitor, not an aggregate
|
|
||||||
|
|
||||||
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
|
|
||||||
It used to fold the whole gauge into a single boolean, and the nudge could then
|
|
||||||
only say that something on homesrv was down. That is not something he can act
|
|
||||||
on, so the rule shipped disabled.
|
|
||||||
|
|
||||||
Three things follow from the split:
|
|
||||||
|
|
||||||
- The key set is no longer known at wiring time. A rule declares
|
|
||||||
`WantPrefixes` and the gatherer resolves the family per tick, which is the
|
|
||||||
only prefix read in the loop.
|
|
||||||
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
|
|
||||||
silenced nothing, because some other monitor kept the boolean at "down".
|
|
||||||
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
|
|
||||||
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
|
|
||||||
|
|
||||||
The rule is also edge-triggered: it fires on a transition it has not already
|
|
||||||
nudged about (`State.NudgedSince`). A polled fact is written only when the
|
|
||||||
value changes, but the predicate reads the current value, so without the edge
|
|
||||||
check a service that stays down qualifies on every tick and cooldown is the
|
|
||||||
only brake.
|
|
||||||
|
|
||||||
### Presence — concrete scoring
|
### Presence — concrete scoring
|
||||||
|
|
||||||
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export LD_LIBRARY_PATH="$ROOT/deps/piper"
|
|||||||
|
|
||||||
Without `-piper` it runs as a stub.
|
Without `-piper` it runs as a stub.
|
||||||
|
|
||||||
|
`-lexicon deploy/tts-lexicon.json` adds the pronunciation dictionary: a flat
|
||||||
|
JSON object of name to Russian spelling, applied to the text just before piper
|
||||||
|
reads it. It is how `Vikunja` is said as a word rather than spelled out, and how
|
||||||
|
`SearXNG` and `homesrv` are said at all. Off unless the flag is set; a path that
|
||||||
|
is set and unreadable stops mavttsd rather than letting it say names wrong in
|
||||||
|
silence. Adding a name needs a restart of mavttsd and nothing else.
|
||||||
|
|
||||||
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
|
## mavweb — PWA voice bridge (WebSocket ↔ TCP)
|
||||||
|
|
||||||
No CGo, no deps; builds with stock Go.
|
No CGo, no deps; builds with stock Go.
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ require github.com/kami/hexis v0.0.0
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/kami/praxis v0.0.0
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -25,8 +24,4 @@ require (
|
|||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
|
|
||||||
|
|
||||||
replace github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
|
|
||||||
|
|
||||||
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
replace github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||||
|
|||||||
@@ -68,28 +68,7 @@ var dayWords = map[string]int{
|
|||||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||||
// time gets DefaultReminderDuration.
|
// time gets DefaultReminderDuration.
|
||||||
//
|
|
||||||
// The clock reading is a wall clock in the daemon's local zone. See
|
|
||||||
// EventFromNotificationIn.
|
|
||||||
func EventFromNotification(n Notification) (Event, bool) {
|
func EventFromNotification(n Notification) (Event, bool) {
|
||||||
return EventFromNotificationIn(n, time.Local)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventFromNotificationIn is EventFromNotification against an explicit zone.
|
|
||||||
//
|
|
||||||
// "созвон в 14:30" carries no zone, and nobody writing a phone notification
|
|
||||||
// means 14:30Z. The wall clock used to be resolved against Posted's own zone,
|
|
||||||
// and a phone posts an RFC 3339 instant ending in Z, so every ambient meeting
|
|
||||||
// on this box landed four hours late. The size of the error is the deploy's UTC
|
|
||||||
// offset, which is why it is invisible on a UTC box and wrong everywhere else.
|
|
||||||
//
|
|
||||||
// Posted stays an instant in its own zone: it says when the phone showed the
|
|
||||||
// notification, and the past-grace check compares instants. Only the day and
|
|
||||||
// the wall clock are read in loc.
|
|
||||||
func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
|
|
||||||
if loc == nil {
|
|
||||||
loc = time.Local
|
|
||||||
}
|
|
||||||
if n.Posted.IsZero() {
|
if n.Posted.IsZero() {
|
||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
@@ -103,9 +82,8 @@ func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
|
|||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// The day is Posted's LOCAL day: a notification posted at 23:30Z saying
|
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
|
||||||
// "завтра в 09:00" is already tomorrow where he is standing.
|
loc := n.Posted.Location()
|
||||||
y, m, d := n.Posted.In(loc).AddDate(0, 0, dayOffset(line)).Date()
|
|
||||||
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||||
// Too far in the past to be the meeting this notification is about. The day
|
// Too far in the past to be the meeting this notification is about. The day
|
||||||
// was inferred, so the honest reading is that the inference was wrong.
|
// was inferred, so the honest reading is that the inference was wrong.
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ev, ok := EventFromNotificationIn(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Package: "com.google.android.gm",
|
Package: "com.google.android.gm",
|
||||||
Title: tt.title,
|
Title: tt.title,
|
||||||
Text: tt.text,
|
Text: tt.text,
|
||||||
Posted: posted,
|
Posted: posted,
|
||||||
}, posted.Location())
|
})
|
||||||
if ok != tt.wantOK {
|
if ok != tt.wantOK {
|
||||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,8 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
if !ev.End.After(ev.Start) {
|
if !ev.End.After(ev.Start) {
|
||||||
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||||
}
|
}
|
||||||
// The event lands on the day the phone showed it, in the zone it
|
// The event lands on the day the phone showed it, in the phone's
|
||||||
// was resolved against — never shifted into UTC.
|
// location — not shifted into UTC.
|
||||||
if ev.Start.Location() != posted.Location() {
|
if ev.Start.Location() != posted.Location() {
|
||||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||||
}
|
}
|
||||||
@@ -162,10 +162,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ev, ok := EventFromNotificationIn(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Package: "com.google.android.calendar",
|
Package: "com.google.android.calendar",
|
||||||
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
Title: tt.title, Text: tt.text, Posted: tt.posted,
|
||||||
}, tt.posted.Location())
|
})
|
||||||
if ok != tt.wantOK {
|
if ok != tt.wantOK {
|
||||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
}
|
}
|
||||||
@@ -189,10 +189,10 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
// summary makes "Завтра Планёрка" the name of the meeting, and FactKey folds
|
||||||
// that into the key.
|
// that into the key.
|
||||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||||
ev, ok := EventFromNotificationIn(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Завтра Планёрка 09:00",
|
Title: "Завтра Планёрка 09:00",
|
||||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
||||||
}, time.UTC)
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
}
|
}
|
||||||
@@ -209,10 +209,10 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
|||||||
|
|
||||||
// An ambient event must never be indistinguishable from a calendar read.
|
// An ambient event must never be indistinguishable from a calendar read.
|
||||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
ev, ok := EventFromNotificationIn(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Планёрка 10:00-10:30",
|
Title: "Планёрка 10:00-10:30",
|
||||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||||
}, time.UTC)
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
}
|
}
|
||||||
@@ -237,52 +237,3 @@ func TestStripClock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The defect this file's zone handling exists for: a phone posts an RFC 3339
|
|
||||||
// instant ending in Z, and "созвон в 14:30" used to be resolved against that
|
|
||||||
// Z, so on a UTC+4 box the meeting was stored at 18:30. A wall clock in a
|
|
||||||
// notification is local by construction.
|
|
||||||
func TestNotificationClockIsLocalNotUTC(t *testing.T) {
|
|
||||||
samara := time.FixedZone("+04", 4*3600)
|
|
||||||
n := Notification{
|
|
||||||
Package: "com.slack",
|
|
||||||
Title: "Standup",
|
|
||||||
Text: "созвон в 14:30",
|
|
||||||
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
|
|
||||||
}
|
|
||||||
ev, ok := EventFromNotificationIn(n, samara)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected an event")
|
|
||||||
}
|
|
||||||
if got := ev.Start.Format("15:04"); got != "14:30" {
|
|
||||||
t.Errorf("start = %s local, want 14:30", got)
|
|
||||||
}
|
|
||||||
if got := ev.Start.UTC().Format("15:04"); got != "10:30" {
|
|
||||||
t.Errorf("start = %sZ, want 10:30Z (14:30 at UTC+4)", got)
|
|
||||||
}
|
|
||||||
if ev.Start.Location() != samara {
|
|
||||||
t.Errorf("location = %v, want %v", ev.Start.Location(), samara)
|
|
||||||
}
|
|
||||||
// The stored key is the local day, so it files under the day he lived.
|
|
||||||
if want, got := "calendar_event_20260802_Standup", FactKeyIn(ev, samara); got != want {
|
|
||||||
t.Errorf("FactKey = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A notification posted late in the UTC evening is already the next day where
|
|
||||||
// he is standing. The day must come from the local clock, not from Posted's.
|
|
||||||
func TestNotificationDayIsTheLocalDay(t *testing.T) {
|
|
||||||
samara := time.FixedZone("+04", 4*3600)
|
|
||||||
n := Notification{
|
|
||||||
Title: "Планёрка 09:00",
|
|
||||||
Text: "завтра",
|
|
||||||
Posted: time.Date(2026, 8, 2, 21, 0, 0, 0, time.UTC), // 03-08 01:00 local
|
|
||||||
}
|
|
||||||
ev, ok := EventFromNotificationIn(n, samara)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected an event")
|
|
||||||
}
|
|
||||||
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 4 {
|
|
||||||
t.Errorf("date = %d-%02d-%02d, want 2026-08-04 (tomorrow, locally)", y, m, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -149,11 +149,10 @@ type Config struct {
|
|||||||
//
|
//
|
||||||
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
||||||
// this only subtracts. It exists because a rule can be right in principle
|
// this only subtracts. It exists because a rule can be right in principle
|
||||||
// and useless in practice. service_down was the case that forced it: it
|
// and useless in practice — kuma's service_down cannot name the service it
|
||||||
// could not name the service it was nudging about, so being told "a service
|
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
||||||
// on homesrv is down" every fifteen minutes was noise with no action
|
// down" every fifteen minutes is noise with no action attached. Turning it
|
||||||
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
// off beats learning to ignore her.
|
||||||
// enabled again. The escape hatch stays.
|
|
||||||
//
|
//
|
||||||
// A disabled rule is never gathered for, never evaluated, and never
|
// A disabled rule is never gathered for, never evaluated, and never
|
||||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||||
|
|||||||
@@ -678,6 +678,135 @@ type acceptProposedRoutineReq struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"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,
|
||||||
|
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||||
|
// - the client proxy (client.go) — same interface, over the wire.
|
||||||
|
//
|
||||||
|
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||||
|
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||||
|
// That swappability is the seam the auth layer will insert into without
|
||||||
|
// touching the module code.
|
||||||
|
type CoreAPI interface {
|
||||||
|
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||||
|
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||||
|
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||||
|
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||||
|
Presence(ctx context.Context) (Presence, error)
|
||||||
|
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
||||||
|
MarkReminder(ctx context.Context, id int64, status string) error
|
||||||
|
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
||||||
|
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||||
|
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||||
|
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||||
|
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||||
|
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||||
|
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||||
|
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||||
|
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
||||||
|
// every status (Vikunja #390).
|
||||||
|
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
||||||
|
|
||||||
|
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||||
|
// own table so machine-rate traces never crowd out human-rate facts.
|
||||||
|
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||||
|
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||||
|
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||||
|
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||||
|
// RecentNotesFromSource — the newest n notes whose source starts with
|
||||||
|
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
||||||
|
// from recall, so this is the only way to reach them, and it keeps the feed
|
||||||
|
// answer from being crowded out of a fixed window by his own notes.
|
||||||
|
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
||||||
|
|
||||||
|
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||||
|
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||||
|
// destructive and flips status to 'enabled'. DisableTool reverts an
|
||||||
|
// enabled tool back to proposed (it stays in the store, won't run).
|
||||||
|
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||||
|
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||||
|
// LookupTool/ListTools read them.
|
||||||
|
// scope defaults to "homelab" when empty.
|
||||||
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||||
|
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||||
|
DisableTool(ctx context.Context, name string) error
|
||||||
|
DeleteTool(ctx context.Context, name string) error
|
||||||
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||||
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||||
|
RevertFact(ctx context.Context, key string) (int64, error)
|
||||||
|
|
||||||
|
// ListProposedRoutines returns proposed routines, newest first.
|
||||||
|
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'. The tick
|
||||||
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||||
|
// intake seam for the voice path, the web form and the future email
|
||||||
|
// extractor. Idempotent per live normalised text; the response says
|
||||||
|
// whether a row was actually created.
|
||||||
|
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||||
|
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||||
|
// "live" is candidate + open (outstanding work).
|
||||||
|
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||||
|
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||||
|
// open→done|dropped. Any other move is refused.
|
||||||
|
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||||
|
|
||||||
|
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||||
|
// this after every tick; the store adapter returns an error (trace is not
|
||||||
|
// persisted — it's a daemon-level cache).
|
||||||
|
TickTrace(ctx context.Context) (TickTrace, error)
|
||||||
|
|
||||||
|
// MorningStatus returns each configured morning routine's current
|
||||||
|
// checklist state (see internal/morning): active today/now, which items
|
||||||
|
// are done, which are still missing. The store adapter returns an error
|
||||||
|
// (morning routines are daemon-config, not persisted) — same shape as
|
||||||
|
// TickTrace.
|
||||||
|
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||||
|
|
||||||
|
// MCPServers reports the configured MCP servers and their health
|
||||||
|
// (Vikunja #251). Read-only introspection for /tools — there is no
|
||||||
|
// "call this tool" method on purpose: an MCP tool runs through the same
|
||||||
|
// allowlist, confirm turn and act path as any other tool, and a second
|
||||||
|
// mutation path would be a second thing to get wrong. Empty when the
|
||||||
|
// mcp config block is absent, which is the default.
|
||||||
|
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
||||||
|
|
||||||
|
// DayPlan returns today's ordered plan — calendar events, pending
|
||||||
|
// reminders and any morning checklist still outstanding (see
|
||||||
|
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||||
|
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||||
|
// The store adapter returns an error (the plan needs the daemon's routine
|
||||||
|
// config) — same shape as TickTrace and MorningStatus.
|
||||||
|
DayPlan(ctx context.Context) (DayPlan, error)
|
||||||
|
|
||||||
|
// Chat routes a text utterance through the reactive handler's core path
|
||||||
|
// (router → dialogue → action → replier) and returns the reply text.
|
||||||
|
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||||
|
//
|
||||||
|
// conversation names the thread. A parked clarifying question is held per
|
||||||
|
// conversation, so an unanswered question on one reach cannot eat the next
|
||||||
|
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||||
|
// tap and is still one conversation of its own, separate from the mic.
|
||||||
|
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||||
|
|
||||||
|
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||||
|
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||||
|
// it came from: a relayed notification, a mail candidate, a feed item, a
|
||||||
|
// changed page, a spend, a presence probe.
|
||||||
|
//
|
||||||
|
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
||||||
|
// the store adapter returns an error, because the journal is a bounded
|
||||||
|
// in-memory ring and not a table. Its contents are a window over intake,
|
||||||
|
// never the durable record — that is still the fact, note or task the
|
||||||
|
// intake path wrote.
|
||||||
|
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
||||||
|
}
|
||||||
|
|
||||||
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
|
||||||
// event.Event field for field; the ipc package does not import internal/event
|
// event.Event field for field; the ipc package does not import internal/event
|
||||||
// so the wire shape stays independent of the in-process type.
|
// so the wire shape stays independent of the in-process type.
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
package ipc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CoreAPI and the eight domain interfaces it composes (Vikunja #408).
|
|
||||||
//
|
|
||||||
// It used to be one flat block of forty methods, and the cost of that shape
|
|
||||||
// was paid three times over: every method added an arm to the dispatcher, a
|
|
||||||
// stub to the daemon's locked API, and a stub to every test double. Two of
|
|
||||||
// those three are already gone — the dispatcher is a table (methodTable in
|
|
||||||
// server.go), and UnimplementedCoreAPI took the padding out of the doubles and
|
|
||||||
// out of lockedAPI, which no longer exists.
|
|
||||||
//
|
|
||||||
// What was left is the interface itself, and this file is that half. CoreAPI
|
|
||||||
// is unchanged as a type: the same forty methods, in the same order, so the
|
|
||||||
// wire contract, the client proxy and the store adapter are all untouched.
|
|
||||||
// What it gains is a named seam per domain, so a caller that only reads facts
|
|
||||||
// can say FactAPI and a reader can see which cluster a method belongs to
|
|
||||||
// without counting lines.
|
|
||||||
//
|
|
||||||
// Add a method to the domain it belongs to, not to CoreAPI.
|
|
||||||
|
|
||||||
// FactAPI — the fact store: write, read the current value, read history, and
|
|
||||||
// undo. Presence sits here because it is a hysteresis view over presence
|
|
||||||
// facts, not a store of its own.
|
|
||||||
type FactAPI interface {
|
|
||||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
|
||||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
|
||||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
|
||||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
|
||||||
Presence(ctx context.Context) (Presence, error)
|
|
||||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
|
||||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
|
||||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
|
||||||
RevertFact(ctx context.Context, key string) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReminderAPI — scheduled sends the owner asked for.
|
|
||||||
type ReminderAPI interface {
|
|
||||||
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
|
|
||||||
MarkReminder(ctx context.Context, id int64, status string) error
|
|
||||||
ListReminders(ctx context.Context, n int) ([]Reminder, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NudgeAPI — proactive sends Maven proposed, their outcomes, and the outbox
|
|
||||||
// they went out through.
|
|
||||||
type NudgeAPI interface {
|
|
||||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
|
||||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
|
||||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
|
||||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
|
||||||
// DeliveryAttempts reads the outbox, newest first. An empty status means
|
|
||||||
// every status (Vikunja #390).
|
|
||||||
DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NoteAPI — free text he captured, plus the embedded recall over it.
|
|
||||||
type NoteAPI interface {
|
|
||||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
|
||||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
|
||||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
|
||||||
// RecentNotesFromSource — the newest n notes whose source starts with
|
|
||||||
// prefix. Notes Maven read rather than heard (rss:, crawl:) are excluded
|
|
||||||
// from recall, so this is the only way to reach them, and it keeps the feed
|
|
||||||
// answer from being crowded out of a fixed window by his own notes.
|
|
||||||
RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToolAPI — the capability allowlist and its read side.
|
|
||||||
//
|
|
||||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
|
||||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
|
||||||
// destructive and flips status to 'enabled'. DisableTool reverts an
|
|
||||||
// enabled tool back to proposed (it stays in the store, won't run).
|
|
||||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
|
||||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
|
||||||
// LookupTool/ListTools read them.
|
|
||||||
// scope defaults to "homelab" when empty.
|
|
||||||
type ToolAPI interface {
|
|
||||||
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
|
||||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
|
||||||
DisableTool(ctx context.Context, name string) error
|
|
||||||
DeleteTool(ctx context.Context, name string) error
|
|
||||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
|
||||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
|
||||||
|
|
||||||
// MCPServers reports the configured MCP servers and their health
|
|
||||||
// (Vikunja #251). Read-only introspection for /tools — there is no
|
|
||||||
// "call this tool" method on purpose: an MCP tool runs through the same
|
|
||||||
// allowlist, confirm turn and act path as any other tool, and a second
|
|
||||||
// mutation path would be a second thing to get wrong. Empty when the
|
|
||||||
// mcp config block is absent, which is the default.
|
|
||||||
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RoutineAPI — the shapes of his day: routines Maven noticed and proposed, the
|
|
||||||
// morning checklist, and today's plan.
|
|
||||||
//
|
|
||||||
// MorningStatus and DayPlan are daemon-computed rather than stored, so the
|
|
||||||
// store adapter returns an error for both — the same shape as TickTrace.
|
|
||||||
type RoutineAPI interface {
|
|
||||||
// ListProposedRoutines returns proposed routines, newest first.
|
|
||||||
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'. The tick
|
|
||||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
|
||||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
|
||||||
|
|
||||||
// MorningStatus returns each configured morning routine's current
|
|
||||||
// checklist state (see internal/morning): active today/now, which items
|
|
||||||
// are done, which are still missing.
|
|
||||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
|
||||||
|
|
||||||
// DayPlan returns today's ordered plan — calendar events, pending
|
|
||||||
// reminders and any morning checklist still outstanding (see
|
|
||||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
|
||||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
|
||||||
DayPlan(ctx context.Context) (DayPlan, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskAPI — outstanding work, whatever surface it arrived from.
|
|
||||||
type TaskAPI interface {
|
|
||||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
|
||||||
// intake seam for the voice path, the web form and the future email
|
|
||||||
// extractor. Idempotent per live normalised text; the response says
|
|
||||||
// whether a row was actually created.
|
|
||||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
|
||||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
|
||||||
// "live" is candidate + open (outstanding work).
|
|
||||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
|
||||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
|
||||||
// open→done|dropped. Any other move is refused.
|
|
||||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
|
||||||
// runs a whole turn.
|
|
||||||
type SystemAPI interface {
|
|
||||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
|
||||||
// this after every tick; the store adapter returns an error (trace is not
|
|
||||||
// persisted — it's a daemon-level cache).
|
|
||||||
TickTrace(ctx context.Context) (TickTrace, error)
|
|
||||||
|
|
||||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
|
||||||
// own table so machine-rate traces never crowd out human-rate facts.
|
|
||||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
|
||||||
|
|
||||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
|
||||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
|
||||||
// it came from: a relayed notification, a mail candidate, a feed item, a
|
|
||||||
// changed page, a spend, a presence probe.
|
|
||||||
//
|
|
||||||
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
|
|
||||||
// the store adapter returns an error, because the journal is a bounded
|
|
||||||
// in-memory ring and not a table. Its contents are a window over intake,
|
|
||||||
// never the durable record — that is still the fact, note or task the
|
|
||||||
// intake path wrote.
|
|
||||||
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
|
|
||||||
|
|
||||||
// Chat routes a text utterance through the reactive handler's core path
|
|
||||||
// (router → dialogue → action → replier) and returns the reply text.
|
|
||||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
|
||||||
//
|
|
||||||
// conversation names the thread. A parked clarifying question is held per
|
|
||||||
// conversation, so an unanswered question on one reach cannot eat the next
|
|
||||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
|
||||||
// tap and is still one conversation of its own, separate from the mic.
|
|
||||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
|
||||||
// - the in-process store adapter (storeapi.go) — used by the daemon
|
|
||||||
// for modules that live in-process for now (router, delivery) and by tests,
|
|
||||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
|
||||||
// - the client proxy (client.go) — same interface, over the wire.
|
|
||||||
//
|
|
||||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
|
||||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
|
||||||
// That swappability is the seam the auth layer inserts into without touching
|
|
||||||
// the module code.
|
|
||||||
type CoreAPI interface {
|
|
||||||
FactAPI
|
|
||||||
ReminderAPI
|
|
||||||
NudgeAPI
|
|
||||||
NoteAPI
|
|
||||||
ToolAPI
|
|
||||||
RoutineAPI
|
|
||||||
TaskAPI
|
|
||||||
SystemAPI
|
|
||||||
}
|
|
||||||
@@ -36,13 +36,6 @@ const maxFrame = 4 << 20
|
|||||||
// (we read the length but not the body), so the caller must close it.
|
// (we read the length but not the body), so the caller must close it.
|
||||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
||||||
|
|
||||||
// The framing is hand-rolled and it stays that way (Vikunja #410): ninety
|
|
||||||
// lines, readable on the wire with socat, and every standard replacement
|
|
||||||
// brings schema machinery this boundary does not want. The condition is that
|
|
||||||
// it is defended by tests rather than inherited untested — truncated header,
|
|
||||||
// truncated body, partial reads, frame boundaries and a non-JSON body all
|
|
||||||
// live in frame_test.go.
|
|
||||||
//
|
|
||||||
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
||||||
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package ipc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The framing is hand-rolled, and it is the protocol all nine daemons depend
|
|
||||||
// on, so a bug in it is a bug everywhere (Vikunja #410). The review asked
|
|
||||||
// whether to replace it with something standard. It stays: length-prefixed
|
|
||||||
// JSON over a unix socket is ninety lines, it is readable with socat, and the
|
|
||||||
// alternatives (net/rpc, gRPC, a codec library) all buy schema machinery this
|
|
||||||
// boundary does not want. What was wrong was inheriting it untested. These
|
|
||||||
// are the paths a real socket produces that the round-trip test never does.
|
|
||||||
|
|
||||||
// A short header is not EOF. EOF means the peer closed cleanly between
|
|
||||||
// frames, which the server treats as a normal disconnect; a header that stops
|
|
||||||
// halfway is a truncated frame and must be reported as an error, or a peer
|
|
||||||
// that dies mid-write looks like one that hung up politely.
|
|
||||||
func TestReadFrameTruncatedHeader(t *testing.T) {
|
|
||||||
var v any
|
|
||||||
err := readFrame(bytes.NewReader([]byte{0, 0, 4}), &v)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a three-byte header must fail")
|
|
||||||
}
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
t.Fatalf("err = %v, want a truncation error, not EOF", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A header promising more body than follows. Same reasoning: the frame never
|
|
||||||
// arrived, so it must not decode into a zero value the caller then trusts.
|
|
||||||
func TestReadFrameTruncatedBody(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
var hdr [4]byte
|
|
||||||
binary.BigEndian.PutUint32(hdr[:], 32)
|
|
||||||
buf.Write(hdr[:])
|
|
||||||
buf.WriteString(`{"m":"pi`)
|
|
||||||
|
|
||||||
var got map[string]string
|
|
||||||
if err := readFrame(&buf, &got); err == nil {
|
|
||||||
t.Fatal("a body shorter than its prefix must fail")
|
|
||||||
}
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("decoded %v from a truncated frame", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing at all is EOF, and only this is.
|
|
||||||
func TestReadFrameEmptyIsEOF(t *testing.T) {
|
|
||||||
var v any
|
|
||||||
if err := readFrame(bytes.NewReader(nil), &v); !errors.Is(err, io.EOF) {
|
|
||||||
t.Fatalf("err = %v, want io.EOF", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// byteAtATime returns one byte per Read, which is what a socket is allowed to
|
|
||||||
// do and what a bytes.Reader never does. readFrame uses io.ReadFull for both
|
|
||||||
// the header and the body; this is the test that would fail if either turned
|
|
||||||
// into a bare Read.
|
|
||||||
type byteAtATime struct {
|
|
||||||
b []byte
|
|
||||||
i int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *byteAtATime) Read(p []byte) (int, error) {
|
|
||||||
if r.i >= len(r.b) {
|
|
||||||
return 0, io.EOF
|
|
||||||
}
|
|
||||||
if len(p) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
p[0] = r.b[r.i]
|
|
||||||
r.i++
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadFrameReassemblesPartialReads(t *testing.T) {
|
|
||||||
type payload struct {
|
|
||||||
Msg string `json:"m"`
|
|
||||||
N int `json:"n"`
|
|
||||||
}
|
|
||||||
want := payload{Msg: "привет", N: 7}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := writeFrame(&buf, want); err != nil {
|
|
||||||
t.Fatalf("writeFrame: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got payload
|
|
||||||
if err := readFrame(&byteAtATime{b: buf.Bytes()}, &got); err != nil {
|
|
||||||
t.Fatalf("readFrame: %v", err)
|
|
||||||
}
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("got %+v, want %+v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two frames written back to back must come back as two frames. A reader that
|
|
||||||
// consumed more than one frame's body would desynchronize the connection, and
|
|
||||||
// the symptom would be a reply attributed to the wrong request.
|
|
||||||
func TestReadFrameStopsAtTheFrameBoundary(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
for _, m := range []string{"first", "second"} {
|
|
||||||
if err := writeFrame(&buf, map[string]string{"m": m}); err != nil {
|
|
||||||
t.Fatalf("writeFrame: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r := &byteAtATime{b: buf.Bytes()}
|
|
||||||
for _, want := range []string{"first", "second"} {
|
|
||||||
var got map[string]string
|
|
||||||
if err := readFrame(r, &got); err != nil {
|
|
||||||
t.Fatalf("readFrame(%s): %v", want, err)
|
|
||||||
}
|
|
||||||
if got["m"] != want {
|
|
||||||
t.Fatalf("got %q, want %q", got["m"], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var extra map[string]string
|
|
||||||
if err := readFrame(r, &extra); !errors.Is(err, io.EOF) {
|
|
||||||
t.Fatalf("after two frames: err = %v, want io.EOF", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A body that is not JSON is an error, not a zero value. The peer is either
|
|
||||||
// broken or not speaking this protocol; either way the caller must not read
|
|
||||||
// on as though it decoded.
|
|
||||||
func TestReadFrameRejectsNonJSONBody(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
var hdr [4]byte
|
|
||||||
body := []byte("not json at all")
|
|
||||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
|
||||||
buf.Write(hdr[:])
|
|
||||||
buf.Write(body)
|
|
||||||
|
|
||||||
var got map[string]string
|
|
||||||
if err := readFrame(&buf, &got); err == nil {
|
|
||||||
t.Fatal("a non-JSON body must fail")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package ipc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"go/ast"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io/fs"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mapErr turns a store sentinel into its wire twin so a module can errors.Is
|
|
||||||
// without importing internal/store. The design is right and the failure mode is
|
|
||||||
// quiet: add a sentinel to store, forget the switch, and the client gets an
|
|
||||||
// untyped error that no caller can branch on. These two tests are the alarm.
|
|
||||||
|
|
||||||
// mapErrPairs — every store sentinel that has a wire twin, and the twin.
|
|
||||||
var mapErrPairs = []struct {
|
|
||||||
name string // the store identifier, for the coverage test below
|
|
||||||
from error
|
|
||||||
wants error
|
|
||||||
}{
|
|
||||||
{"ErrNoFact", store.ErrNoFact, ErrNoFact},
|
|
||||||
{"ErrConfidence", store.ErrConfidence, ErrConfidence},
|
|
||||||
{"ErrVoidsMissing", store.ErrVoidsMissing, ErrVoidsMissing},
|
|
||||||
{"ErrNudgeNotFound", store.ErrNudgeNotFound, ErrNudgeNotFound},
|
|
||||||
{"ErrNudgeOutcome", store.ErrNudgeOutcome, ErrNudgeOutcome},
|
|
||||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
|
||||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
|
||||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
|
||||||
}
|
|
||||||
|
|
||||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
|
||||||
// each with the reason it stays store-side. A new sentinel is in neither list
|
|
||||||
// and fails TestMapErrCoversEveryStoreSentinel, which is the point: whether a
|
|
||||||
// module can branch on an error is a decision, not a default.
|
|
||||||
var unmappedStoreErrors = map[string]string{
|
|
||||||
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
|
||||||
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
|
||||||
|
|
||||||
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
|
||||||
|
|
||||||
"ErrProposedRoutineNotFound": "no module branches on a routine id that vanished; accept and dismiss are owner clicks",
|
|
||||||
"ErrProposedRoutineExists": "the propose path already reports 'nothing new' through its bool return",
|
|
||||||
"ErrRoutineStatus": "an unknown status is a caller bug, not a state a module recovers from",
|
|
||||||
|
|
||||||
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
|
|
||||||
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
|
|
||||||
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mapping itself, through a wrap, because every real caller wraps.
|
|
||||||
func TestMapErrMapsEveryPair(t *testing.T) {
|
|
||||||
for _, p := range mapErrPairs {
|
|
||||||
got := mapErr(fmt.Errorf("storeapi: %w", p.from))
|
|
||||||
if got != p.wants {
|
|
||||||
t.Errorf("mapErr(store.%s) = %v, want %v", p.name, got, p.wants)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anything mapErr does not recognise passes through untouched. A module that
|
|
||||||
// cannot branch on an error must still see the original text.
|
|
||||||
func TestMapErrPassesUnknownThrough(t *testing.T) {
|
|
||||||
if mapErr(nil) != nil {
|
|
||||||
t.Error("mapErr(nil) must stay nil")
|
|
||||||
}
|
|
||||||
own := fmt.Errorf("socket closed")
|
|
||||||
if got := mapErr(own); got != own {
|
|
||||||
t.Errorf("mapErr(%v) = %v, want the same error back", own, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The parity half: every exported sentinel in internal/store is either mapped
|
|
||||||
// or listed with a reason. Read off the source, so a sentinel added in a file
|
|
||||||
// this package never touches still trips it.
|
|
||||||
func TestMapErrCoversEveryStoreSentinel(t *testing.T) {
|
|
||||||
mapped := map[string]bool{}
|
|
||||||
for _, p := range mapErrPairs {
|
|
||||||
mapped[p.name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range storeSentinelNames(t) {
|
|
||||||
if mapped[name] || unmappedStoreErrors[name] != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t.Errorf("store.%s is a new sentinel with no verdict: add it to mapErr and mapErrPairs, "+
|
|
||||||
"or to unmappedStoreErrors with the reason a module cannot branch on it", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeSentinelNames reads internal/store for exported package-level error
|
|
||||||
// values: `var ErrX = errors.New(...)`, inside a block or on its own.
|
|
||||||
func storeSentinelNames(t *testing.T) []string {
|
|
||||||
t.Helper()
|
|
||||||
fset := token.NewFileSet()
|
|
||||||
pkgs, err := parser.ParseDir(fset, "../store", func(fi fs.FileInfo) bool {
|
|
||||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
|
||||||
}, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse internal/store: %v", err)
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, pkg := range pkgs {
|
|
||||||
for _, f := range pkg.Files {
|
|
||||||
for _, d := range f.Decls {
|
|
||||||
gd, ok := d.(*ast.GenDecl)
|
|
||||||
if !ok || gd.Tok != token.VAR {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, spec := range gd.Specs {
|
|
||||||
vs, ok := spec.(*ast.ValueSpec)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for i, n := range vs.Names {
|
|
||||||
if !strings.HasPrefix(n.Name, "Err") || !n.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if i < len(vs.Values) && isErrorsNew(vs.Values[i]) {
|
|
||||||
out = append(out, n.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) < len(mapErrPairs) {
|
|
||||||
t.Fatalf("found %d sentinels in internal/store, fewer than the %d already mapped — the scan is broken, not the store", len(out), len(mapErrPairs))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func isErrorsNew(e ast.Expr) bool {
|
|
||||||
call, ok := e.(*ast.CallExpr)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
||||||
if !ok || sel.Sel.Name != "New" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
id, ok := sel.X.(*ast.Ident)
|
|
||||||
return ok && id.Name == "errors"
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,9 @@ package ipc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -11,9 +13,449 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/netaddr"
|
"github.com/kami/maven/internal/netaddr"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
||||||
|
// these inside the core process; the socket Server calls it through the
|
||||||
|
// CoreAPI interface, so over-the-wire and in-process callers behave
|
||||||
|
// identically. The translation here is the only place store sentinels cross
|
||||||
|
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
||||||
|
// view of errors stable regardless of transport.
|
||||||
|
type storeAPI struct {
|
||||||
|
s *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
||||||
|
// unlocked handle held ONLY in core's address space; this adapter never
|
||||||
|
// returns it to a caller — core mediates.
|
||||||
|
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
||||||
|
|
||||||
|
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||||
|
var voids sql.NullInt64
|
||||||
|
if req.VoidsID != nil {
|
||||||
|
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
||||||
|
}
|
||||||
|
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
|
||||||
|
return id, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||||
|
f, err := a.s.LatestFact(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return Fact{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return toFact(f), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||||
|
f, err := a.s.LatestFactBySource(ctx, key, source)
|
||||||
|
if err != nil {
|
||||||
|
return Fact{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return toFact(f), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||||
|
d, err := a.s.Since(ctx, key, now)
|
||||||
|
return d, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
||||||
|
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
||||||
|
}
|
||||||
|
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||||
|
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
|
||||||
|
return id, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||||
|
return mapErr(a.s.MarkReminder(ctx, id, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||||
|
rs, err := a.s.ListReminders(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Reminder, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = toReminder(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||||
|
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||||
|
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
||||||
|
return id, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||||
|
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||||
|
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
||||||
|
return out, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||||
|
fs, err := a.s.RecentFacts(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Fact, len(fs))
|
||||||
|
for i, f := range fs {
|
||||||
|
out[i] = toFact(f)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||||
|
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Fact, len(fs))
|
||||||
|
for i, f := range fs {
|
||||||
|
out[i] = toFact(f)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||||
|
fs, err := a.s.CalendarEvents(ctx, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Fact, len(fs))
|
||||||
|
for i, f := range fs {
|
||||||
|
out[i] = toFact(f)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
||||||
|
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]EcosystemTrace, len(trs))
|
||||||
|
for i, tr := range trs {
|
||||||
|
out[i] = EcosystemTrace{
|
||||||
|
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
||||||
|
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
||||||
|
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||||
|
ns, err := a.s.RecentNudges(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Nudge, len(ns))
|
||||||
|
for i, ng := range ns {
|
||||||
|
out[i] = toNudge(ng)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
||||||
|
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]DeliveryAttempt, len(as))
|
||||||
|
for i, at := range as {
|
||||||
|
out[i] = DeliveryAttempt{
|
||||||
|
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||||
|
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||||
|
}
|
||||||
|
if at.HasComplete {
|
||||||
|
t := at.Completed
|
||||||
|
out[i].Completed = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||||
|
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||||
|
return id, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||||
|
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Note, len(ns))
|
||||||
|
for i, n := range ns {
|
||||||
|
out[i] = toNote(n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
||||||
|
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Note, len(ns))
|
||||||
|
for i, note := range ns {
|
||||||
|
out[i] = toNote(note)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||||
|
ns, err := a.s.RecentNotes(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Note, len(ns))
|
||||||
|
for i, note := range ns {
|
||||||
|
out[i] = toNote(note)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||||
|
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
||||||
|
return ok, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||||
|
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
||||||
|
return mapErr(a.s.DisableTool(ctx, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
|
t, err := a.s.LookupTool(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return Tool{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return toTool(t), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||||
|
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
|
||||||
|
return newID, mapErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||||
|
return "", errors.New("store: chat not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||||
|
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
||||||
|
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
||||||
|
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
||||||
|
return nil, errors.New("store: intake events not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
||||||
|
return nil, nil // no manager behind a bare store: nothing configured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||||
|
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||||
|
ts, err := a.s.ListTools(ctx, status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Tool, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
out[i] = toTool(t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||||
|
return mapErr(a.s.DeleteTool(ctx, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
|
res, err := a.s.CaptureTask(ctx, store.Task{
|
||||||
|
CreatedTs: req.Ts,
|
||||||
|
Text: req.Text,
|
||||||
|
Source: req.Source,
|
||||||
|
Evidence: req.Evidence,
|
||||||
|
ExternalID: req.ExternalID,
|
||||||
|
Status: req.Status,
|
||||||
|
Due: req.Due,
|
||||||
|
Weight: req.Weight,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return CaptureTaskResp{}, mapErr(err)
|
||||||
|
}
|
||||||
|
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
|
ts, err := a.s.ListTasks(ctx, status)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]Task, len(ts))
|
||||||
|
for i, t := range ts {
|
||||||
|
out[i] = Task{
|
||||||
|
ID: t.ID,
|
||||||
|
CreatedTs: t.CreatedTs,
|
||||||
|
Text: t.Text,
|
||||||
|
Source: t.Source,
|
||||||
|
Evidence: t.Evidence,
|
||||||
|
ExternalID: t.ExternalID,
|
||||||
|
Status: t.Status,
|
||||||
|
Due: t.Due,
|
||||||
|
Weight: t.Weight,
|
||||||
|
Resolved: t.ResolvedTs,
|
||||||
|
ResolvedBy: t.ResolvedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||||
|
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||||
|
rs, err := a.s.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapErr(err)
|
||||||
|
}
|
||||||
|
out := make([]ProposedRoutine, len(rs))
|
||||||
|
for i, r := range rs {
|
||||||
|
out[i] = ProposedRoutine{
|
||||||
|
ID: r.ID,
|
||||||
|
Action: r.Action,
|
||||||
|
Object: r.Object,
|
||||||
|
IntervalDays: r.IntervalDays,
|
||||||
|
Status: r.Status,
|
||||||
|
CreatedTs: r.CreatedTs.UnixMilli(),
|
||||||
|
}
|
||||||
|
if r.ReminderID != nil {
|
||||||
|
out[i].ReminderID = r.ReminderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 int64) error {
|
||||||
|
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTool(t store.Tool) Tool {
|
||||||
|
return Tool{
|
||||||
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||||
|
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toReminder(r store.Reminder) Reminder {
|
||||||
|
return Reminder{
|
||||||
|
ID: r.ID,
|
||||||
|
CreatedTs: r.CreatedTs,
|
||||||
|
FireTs: r.FireTs,
|
||||||
|
NextFireTs: r.NextFireTs,
|
||||||
|
Payload: r.Payload,
|
||||||
|
Status: r.Status,
|
||||||
|
Cron: r.Cron,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNote(n store.Note) Note {
|
||||||
|
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNudge(n store.Nudge) Nudge {
|
||||||
|
out := Nudge{
|
||||||
|
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
||||||
|
Message: n.Message, Outcome: n.Outcome,
|
||||||
|
}
|
||||||
|
if n.OutcomeTs.Valid {
|
||||||
|
v := n.OutcomeTs.Int64
|
||||||
|
out.OutcomeTs = &v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFact(f store.Fact) Fact {
|
||||||
|
out := Fact{
|
||||||
|
ID: f.ID,
|
||||||
|
Ts: f.Ts,
|
||||||
|
Kind: string(f.Kind),
|
||||||
|
Key: f.Key,
|
||||||
|
Value: f.Value,
|
||||||
|
Source: f.Source,
|
||||||
|
Confidence: f.Confidence,
|
||||||
|
}
|
||||||
|
if f.VoidsID.Valid {
|
||||||
|
v := f.VoidsID.Int64
|
||||||
|
out.VoidsID = &v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
||||||
|
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
||||||
|
// keeping internal text off the wire except to the daemon log).
|
||||||
|
func mapErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrNoFact):
|
||||||
|
return ErrNoFact
|
||||||
|
case errors.Is(err, store.ErrConfidence):
|
||||||
|
return ErrConfidence
|
||||||
|
case errors.Is(err, store.ErrVoidsMissing):
|
||||||
|
return ErrVoidsMissing
|
||||||
|
case errors.Is(err, store.ErrNudgeNotFound):
|
||||||
|
return ErrNudgeNotFound
|
||||||
|
case errors.Is(err, store.ErrNudgeOutcome):
|
||||||
|
return ErrNudgeOutcome
|
||||||
|
case errors.Is(err, store.ErrReminderNotFound):
|
||||||
|
return ErrReminderNotFound
|
||||||
|
case errors.Is(err, store.ErrReminderState):
|
||||||
|
return ErrReminderState
|
||||||
|
case errors.Is(err, store.ErrToolNotFound):
|
||||||
|
return ErrToolNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Server — the core side of the boundary. Listens on a unix domain socket,
|
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||||
// One Server per daemon process; concurrent connections are handled in their
|
// One Server per daemon process; concurrent connections are handled in their
|
||||||
|
|||||||
@@ -1,455 +0,0 @@
|
|||||||
// ipc/storeapi.go — the sqlite-backed CoreAPI.
|
|
||||||
//
|
|
||||||
// Split out of server.go, move-only (Vikunja #423). server.go was two
|
|
||||||
// unrelated things: this adapter, and the dispatcher that calls it over the
|
|
||||||
// socket. Nothing here knows there is a wire.
|
|
||||||
package ipc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
|
||||||
// these inside the core process; the socket Server calls it through the
|
|
||||||
// CoreAPI interface, so over-the-wire and in-process callers behave
|
|
||||||
// identically. The translation here is the only place store sentinels cross
|
|
||||||
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
|
||||||
// view of errors stable regardless of transport.
|
|
||||||
type storeAPI struct {
|
|
||||||
s *store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
|
||||||
// unlocked handle held ONLY in core's address space; this adapter never
|
|
||||||
// returns it to a caller — core mediates.
|
|
||||||
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
|
||||||
|
|
||||||
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
|
||||||
var voids sql.NullInt64
|
|
||||||
if req.VoidsID != nil {
|
|
||||||
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
|
||||||
}
|
|
||||||
id, err := a.s.WriteFactAboutSubject(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Subject, req.Value, req.Source, req.Confidence, voids)
|
|
||||||
return id, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
|
||||||
f, err := a.s.LatestFact(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return Fact{}, mapErr(err)
|
|
||||||
}
|
|
||||||
return toFact(f), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
|
||||||
f, err := a.s.LatestFactBySource(ctx, key, source)
|
|
||||||
if err != nil {
|
|
||||||
return Fact{}, mapErr(err)
|
|
||||||
}
|
|
||||||
return toFact(f), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
|
||||||
d, err := a.s.Since(ctx, key, now)
|
|
||||||
return d, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
|
||||||
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
|
||||||
}
|
|
||||||
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
|
||||||
id, err := a.s.CreateReminder(ctx, fire, payload, cron)
|
|
||||||
return id, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
|
||||||
return mapErr(a.s.MarkReminder(ctx, id, status))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
|
||||||
rs, err := a.s.ListReminders(ctx, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Reminder, len(rs))
|
|
||||||
for i, r := range rs {
|
|
||||||
out[i] = toReminder(r)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
|
||||||
return mapErr(a.s.RescheduleReminder(ctx, id, now))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
|
||||||
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
|
||||||
return id, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
|
||||||
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
|
||||||
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
|
||||||
return out, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
|
||||||
fs, err := a.s.RecentFacts(ctx, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Fact, len(fs))
|
|
||||||
for i, f := range fs {
|
|
||||||
out[i] = toFact(f)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
|
||||||
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Fact, len(fs))
|
|
||||||
for i, f := range fs {
|
|
||||||
out[i] = toFact(f)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
|
||||||
fs, err := a.s.CalendarEvents(ctx, from, to)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Fact, len(fs))
|
|
||||||
for i, f := range fs {
|
|
||||||
out[i] = toFact(f)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) {
|
|
||||||
trs, err := a.s.RecentEcosystemTraces(ctx, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]EcosystemTrace, len(trs))
|
|
||||||
for i, tr := range trs {
|
|
||||||
out[i] = EcosystemTrace{
|
|
||||||
ID: tr.ID, Ts: tr.Ts, Service: tr.Service, Operation: tr.Operation,
|
|
||||||
Status: tr.Status, DurationMs: tr.DurationMs, CorrelationID: tr.CorrelationID,
|
|
||||||
CausationID: tr.CausationID, HTTPStatus: tr.HTTPStatus, Fields: tr.Fields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
|
||||||
ns, err := a.s.RecentNudges(ctx, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Nudge, len(ns))
|
|
||||||
for i, ng := range ns {
|
|
||||||
out[i] = toNudge(ng)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) {
|
|
||||||
as, err := a.s.ListDeliveryAttempts(ctx, status, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]DeliveryAttempt, len(as))
|
|
||||||
for i, at := range as {
|
|
||||||
out[i] = DeliveryAttempt{
|
|
||||||
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
|
||||||
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
|
||||||
}
|
|
||||||
if at.HasComplete {
|
|
||||||
t := at.Completed
|
|
||||||
out[i].Completed = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
|
||||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
|
||||||
return id, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
|
||||||
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Note, len(ns))
|
|
||||||
for i, n := range ns {
|
|
||||||
out[i] = toNote(n)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentNotesFromSource(ctx context.Context, prefix string, n int) ([]Note, error) {
|
|
||||||
ns, err := a.s.RecentNotesFromSource(ctx, prefix, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Note, len(ns))
|
|
||||||
for i, note := range ns {
|
|
||||||
out[i] = toNote(note)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
|
||||||
ns, err := a.s.RecentNotes(ctx, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Note, len(ns))
|
|
||||||
for i, note := range ns {
|
|
||||||
out[i] = toNote(note)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
|
||||||
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
|
||||||
return ok, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
|
||||||
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
|
||||||
return mapErr(a.s.DisableTool(ctx, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
|
||||||
t, err := a.s.LookupTool(ctx, name)
|
|
||||||
if err != nil {
|
|
||||||
return Tool{}, mapErr(err)
|
|
||||||
}
|
|
||||||
return toTool(t), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
|
||||||
_, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
|
|
||||||
return newID, mapErr(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
|
||||||
return "", errors.New("store: chat not available via direct store API")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|
||||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
|
||||||
return nil, errors.New("store: morning status not available via direct store API")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentEvents — same shape as TickTrace: the intake journal is a bounded ring
|
|
||||||
// in the daemon's memory, not a table, so a bare store cannot serve it.
|
|
||||||
func (a *storeAPI) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
|
|
||||||
return nil, errors.New("store: intake events not available via direct store API")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, error) {
|
|
||||||
return nil, nil // no manager behind a bare store: nothing configured
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
|
||||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
|
||||||
ts, err := a.s.ListTools(ctx, status)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Tool, len(ts))
|
|
||||||
for i, t := range ts {
|
|
||||||
out[i] = toTool(t)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
|
||||||
return mapErr(a.s.DeleteTool(ctx, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
|
||||||
res, err := a.s.CaptureTask(ctx, store.Task{
|
|
||||||
CreatedTs: req.Ts,
|
|
||||||
Text: req.Text,
|
|
||||||
Source: req.Source,
|
|
||||||
Evidence: req.Evidence,
|
|
||||||
ExternalID: req.ExternalID,
|
|
||||||
Status: req.Status,
|
|
||||||
Due: req.Due,
|
|
||||||
Weight: req.Weight,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return CaptureTaskResp{}, mapErr(err)
|
|
||||||
}
|
|
||||||
return CaptureTaskResp{ID: res.ID, Created: res.Created, Promoted: res.Promoted}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
|
||||||
ts, err := a.s.ListTasks(ctx, status)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]Task, len(ts))
|
|
||||||
for i, t := range ts {
|
|
||||||
out[i] = Task{
|
|
||||||
ID: t.ID,
|
|
||||||
CreatedTs: t.CreatedTs,
|
|
||||||
Text: t.Text,
|
|
||||||
Source: t.Source,
|
|
||||||
Evidence: t.Evidence,
|
|
||||||
ExternalID: t.ExternalID,
|
|
||||||
Status: t.Status,
|
|
||||||
Due: t.Due,
|
|
||||||
Weight: t.Weight,
|
|
||||||
Resolved: t.ResolvedTs,
|
|
||||||
ResolvedBy: t.ResolvedBy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
|
||||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
|
||||||
rs, err := a.s.ListProposedRoutines(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapErr(err)
|
|
||||||
}
|
|
||||||
out := make([]ProposedRoutine, len(rs))
|
|
||||||
for i, r := range rs {
|
|
||||||
out[i] = ProposedRoutine{
|
|
||||||
ID: r.ID,
|
|
||||||
Action: r.Action,
|
|
||||||
Object: r.Object,
|
|
||||||
IntervalDays: r.IntervalDays,
|
|
||||||
Status: string(r.Status),
|
|
||||||
CreatedTs: r.CreatedTs.UnixMilli(),
|
|
||||||
}
|
|
||||||
if r.ReminderID != nil {
|
|
||||||
out[i].ReminderID = r.ReminderID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 int64) error {
|
|
||||||
return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
|
||||||
return Tool{
|
|
||||||
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
|
||||||
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toReminder(r store.Reminder) Reminder {
|
|
||||||
return Reminder{
|
|
||||||
ID: r.ID,
|
|
||||||
CreatedTs: r.CreatedTs,
|
|
||||||
FireTs: r.FireTs,
|
|
||||||
NextFireTs: r.NextFireTs,
|
|
||||||
Payload: r.Payload,
|
|
||||||
Status: r.Status,
|
|
||||||
Cron: r.Cron,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toNote(n store.Note) Note {
|
|
||||||
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toNudge(n store.Nudge) Nudge {
|
|
||||||
out := Nudge{
|
|
||||||
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
|
||||||
Message: n.Message, Outcome: n.Outcome,
|
|
||||||
}
|
|
||||||
if n.OutcomeTs.Valid {
|
|
||||||
v := n.OutcomeTs.Int64
|
|
||||||
out.OutcomeTs = &v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFact(f store.Fact) Fact {
|
|
||||||
out := Fact{
|
|
||||||
ID: f.ID,
|
|
||||||
Ts: f.Ts,
|
|
||||||
Kind: string(f.Kind),
|
|
||||||
Key: f.Key,
|
|
||||||
Value: f.Value,
|
|
||||||
Source: f.Source,
|
|
||||||
Confidence: f.Confidence,
|
|
||||||
}
|
|
||||||
if f.VoidsID.Valid {
|
|
||||||
v := f.VoidsID.Int64
|
|
||||||
out.VoidsID = &v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
|
||||||
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
|
||||||
// keeping internal text off the wire except to the daemon log).
|
|
||||||
func mapErr(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, store.ErrNoFact):
|
|
||||||
return ErrNoFact
|
|
||||||
case errors.Is(err, store.ErrConfidence):
|
|
||||||
return ErrConfidence
|
|
||||||
case errors.Is(err, store.ErrVoidsMissing):
|
|
||||||
return ErrVoidsMissing
|
|
||||||
case errors.Is(err, store.ErrNudgeNotFound):
|
|
||||||
return ErrNudgeNotFound
|
|
||||||
case errors.Is(err, store.ErrNudgeOutcome):
|
|
||||||
return ErrNudgeOutcome
|
|
||||||
case errors.Is(err, store.ErrReminderNotFound):
|
|
||||||
return ErrReminderNotFound
|
|
||||||
case errors.Is(err, store.ErrReminderState):
|
|
||||||
return ErrReminderState
|
|
||||||
case errors.Is(err, store.ErrToolNotFound):
|
|
||||||
return ErrToolNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
|||||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||||
now := refTime()
|
now := refTime()
|
||||||
s := State{Now: now, Presence: store.Away,
|
s := State{Now: now, Presence: store.Away,
|
||||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||||
}
|
}
|
||||||
r := ServiceDownRule() // Sev4 ops
|
r := ServiceDownRule() // Sev4 ops
|
||||||
passed, blocked, d := ExplainGate(s, r)
|
passed, blocked, d := ExplainGate(s, r)
|
||||||
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cand, trace := ExplainTick(s, DefaultRules())
|
cand, trace := ExplainTick(s, DefaultRules())
|
||||||
|
|||||||
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||||
|
|||||||
@@ -91,20 +91,6 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
|||||||
return State{}, nil, err
|
return State{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// prefix families — the keys a rule cannot name at wiring time (one fact
|
|
||||||
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
|
|
||||||
for _, r := range g.rules {
|
|
||||||
for _, p := range r.WantPrefixes {
|
|
||||||
fam, err := g.store.LatestFactsByPrefix(ctx, p)
|
|
||||||
if err != nil {
|
|
||||||
return State{}, nil, err
|
|
||||||
}
|
|
||||||
for _, f := range fam {
|
|
||||||
facts[f.Key] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// last nudge per rule + cooldown-until derived from the active cooldown.
|
// last nudge per rule + cooldown-until derived from the active cooldown.
|
||||||
// "active" = the feedback tuner's persisted base if one exists, else the
|
// "active" = the feedback tuner's persisted base if one exists, else the
|
||||||
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package loop
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The gatherer is the only impure piece, and a rule over a prefix has no keys
|
|
||||||
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
|
|
||||||
// facts reach the snapshot, and the rule fires on the one that is down.
|
|
||||||
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = s.Close() })
|
|
||||||
|
|
||||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
||||||
for key, val := range map[string]string{
|
|
||||||
"service_down:db": "down",
|
|
||||||
"service_down:web": "up",
|
|
||||||
} {
|
|
||||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
|
||||||
t.Fatalf("SetValue %s: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := []Rule{ServiceDownRule()}
|
|
||||||
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GatherState: %v", err)
|
|
||||||
}
|
|
||||||
if _, ok := st.Facts["service_down:db"]; !ok {
|
|
||||||
t.Fatalf("prefix family not gathered: %v", st.Facts)
|
|
||||||
}
|
|
||||||
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
|
|
||||||
t.Fatalf("DownServices = %v, want [db]", got)
|
|
||||||
}
|
|
||||||
if !rules[0].Predicate(st) {
|
|
||||||
t.Fatal("the rule must fire on a gathered per-monitor fact")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
|||||||
Presence: store.Away,
|
Presence: store.Away,
|
||||||
QuietHours: true,
|
QuietHours: true,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if got := Tick(s, DefaultRules()); got != nil {
|
if got := Tick(s, DefaultRules()); got != nil {
|
||||||
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
|
|||||||
+13
-55
@@ -28,13 +28,6 @@ type Rule struct {
|
|||||||
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
||||||
// needs and the gate will skip the rule when any are missing.
|
// needs and the gate will skip the rule when any are missing.
|
||||||
InertWhenNoData []string
|
InertWhenNoData []string
|
||||||
|
|
||||||
// WantPrefixes — key prefixes whose whole family the gatherer must load.
|
|
||||||
// InertWhenNoData names keys that exist at wiring time; a rule over a key
|
|
||||||
// set that is only known at read time (one fact per kuma monitor) declares
|
|
||||||
// the prefix here instead. Prefixes never make a rule inert: an empty
|
|
||||||
// family is the predicate's own "no data" case.
|
|
||||||
WantPrefixes []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||||
@@ -105,58 +98,23 @@ func BreakRule() Rule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
|
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
|
||||||
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
|
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
|
||||||
const ServiceDownPrefix = "service_down:"
|
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
|
||||||
|
// a compromised poller writing under a different source can't forge the trigger.
|
||||||
// ServiceDownSource — kuma is the source of truth for service up/down. The
|
|
||||||
// rule is provenance-scoped: a poller writing under a different source cannot
|
|
||||||
// forge the trigger.
|
|
||||||
const ServiceDownSource = "poll:uptimekuma"
|
|
||||||
|
|
||||||
// DownServices — the monitors currently reading "down", by name, in key order.
|
|
||||||
//
|
|
||||||
// Pure, and the rule and the phraser both call it, so the message can never
|
|
||||||
// name a service the predicate did not fire on.
|
|
||||||
func DownServices(s State) []string {
|
|
||||||
var out []string
|
|
||||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
|
||||||
if f.Source == ServiceDownSource && f.Value == `"down"` {
|
|
||||||
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
|
|
||||||
//
|
|
||||||
// It used to read one aggregate `service_down` fact, which is why it was
|
|
||||||
// disabled in deploy: the nudge could say that something on homesrv was down
|
|
||||||
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
|
|
||||||
// kuma now silences that monitor rather than nothing.
|
|
||||||
//
|
|
||||||
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
|
|
||||||
// for a day qualifies on every tick and cooldown alone is the only brake.
|
|
||||||
func ServiceDownRule() Rule {
|
func ServiceDownRule() Rule {
|
||||||
return Rule{
|
return Rule{
|
||||||
Name: "service_down",
|
Name: "service_down",
|
||||||
Severity: Sev4,
|
Severity: Sev4,
|
||||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||||
WantPrefixes: []string{ServiceDownPrefix},
|
InertWhenNoData: []string{"service_down"},
|
||||||
Predicate: func(s State) bool {
|
Predicate: func(s State) bool {
|
||||||
var newest time.Time
|
f, ok := s.Fact("service_down")
|
||||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
if !ok || f.Ts.IsZero() {
|
||||||
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
return false
|
||||||
continue
|
|
||||||
}
|
|
||||||
if f.Ts.After(newest) {
|
|
||||||
newest = f.Ts
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if newest.IsZero() {
|
// value is json `"down"`; trivial check keyed off source provenance.
|
||||||
return false // nothing down, or no data at all → shut up
|
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
|
||||||
}
|
|
||||||
return !s.NudgedSince("service_down", newest)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-80
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down fires on a kuma down fact",
|
name: "service_down fires on a kuma down fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down quiet when kuma says up",
|
name: "service_down quiet when kuma says up",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down quiet on a zero-timestamp fact",
|
name: "service_down quiet on a zero-timestamp fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
|
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
// forgery attempts — right value, wrong writer.
|
// forgery attempts — right value, wrong writer.
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the netdata poller",
|
name: "service_down refuses a forgery from the netdata poller",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from ambient audio",
|
name: "service_down refuses a forgery from ambient audio",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the user's own voice",
|
name: "service_down refuses a forgery from the user's own voice",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a source that only looks like kuma",
|
name: "service_down refuses a source that only looks like kuma",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses an unquoted down value",
|
name: "service_down refuses an unquoted down value",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -312,11 +312,9 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||||
// even if its predicate happens to check.
|
// even if its predicate happens to check.
|
||||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||||
// A rule over a key set that only exists at read time declares a prefix
|
|
||||||
// instead — the gatherer still needs to be told what to load.
|
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
|
if len(r.InertWhenNoData) == 0 {
|
||||||
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
|
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,11 +377,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
|||||||
// hidden state, no clock reads.
|
// hidden state, no clock reads.
|
||||||
func TestPredicatesArePure(t *testing.T) {
|
func TestPredicatesArePure(t *testing.T) {
|
||||||
s := stateWith(map[string]store.Fact{
|
s := stateWith(map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
})
|
})
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
first := r.Predicate(s)
|
first := r.Predicate(s)
|
||||||
@@ -436,66 +434,3 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
|||||||
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------- per-monitor service_down -----------------------
|
|
||||||
|
|
||||||
// The rule must name what fired on it, and the phraser reads the same helper,
|
|
||||||
// so a service that is up can never be spoken as down.
|
|
||||||
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
|
||||||
s := State{
|
|
||||||
Now: refTime(),
|
|
||||||
Facts: map[string]store.Fact{
|
|
||||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
|
||||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
|
||||||
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
|
||||||
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
|
||||||
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
|
||||||
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
got := DownServices(s)
|
|
||||||
want := []string{"db", "vault"} // key order, so speech is stable
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("DownServices = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("DownServices = %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
|
|
||||||
// the aggregate stayed "down" and pausing achieved nothing.
|
|
||||||
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
|
||||||
base := map[string]store.Fact{
|
|
||||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
|
||||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
|
||||||
}
|
|
||||||
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
|
||||||
t.Fatal("web is still down, the rule must fire")
|
|
||||||
}
|
|
||||||
delete(base, "service_down:web")
|
|
||||||
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
|
||||||
t.Fatal("only a paused monitor is left, the rule must be quiet")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge-triggered: he is told once per transition. A service that stays down
|
|
||||||
// for a day used to qualify on every tick, with cooldown as the only brake.
|
|
||||||
func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
|
||||||
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
|
|
||||||
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
|
|
||||||
if !ServiceDownRule().Predicate(s) {
|
|
||||||
t.Fatal("first sight of the transition must fire")
|
|
||||||
}
|
|
||||||
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
|
|
||||||
if ServiceDownRule().Predicate(s) {
|
|
||||||
t.Fatal("already told about this transition, must be quiet")
|
|
||||||
}
|
|
||||||
// A second service goes down after that nudge — a new edge, so it fires.
|
|
||||||
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
|
||||||
if !ServiceDownRule().Predicate(s) {
|
|
||||||
t.Fatal("a later transition must fire again")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
package loop
|
package loop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -97,32 +95,6 @@ func (s State) Fact(key string) (store.Fact, bool) {
|
|||||||
return f, true
|
return f, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
|
|
||||||
// by key so a caller that names them speaks them in a stable order. Facts with
|
|
||||||
// a zero Ts are skipped, the same "no data" rule Fact applies.
|
|
||||||
func (s State) FactsUnder(prefix string) []store.Fact {
|
|
||||||
var out []store.Fact
|
|
||||||
for k, f := range s.Facts {
|
|
||||||
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// NudgedSince reports whether rule already sent a nudge at or after ts.
|
|
||||||
//
|
|
||||||
// It is what makes a rule edge-triggered. A polled fact is written only when
|
|
||||||
// the value changes, so its Ts is the moment the service went down — but the
|
|
||||||
// predicate reads the current value, so a service that stays down keeps
|
|
||||||
// qualifying forever and cooldown alone only slows the repetition. Asking
|
|
||||||
// whether he was already told about THIS transition stops it.
|
|
||||||
func (s State) NudgedSince(rule string, ts time.Time) bool {
|
|
||||||
n, ok := s.LastNudge[rule]
|
|
||||||
return ok && !n.Ts.Before(ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since returns the duration since the latest fact for key, or (0,false).
|
// Since returns the duration since the latest fact for key, or (0,false).
|
||||||
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||||||
func (s State) Since(key string) (time.Duration, bool) {
|
func (s State) Since(key string) (time.Duration, bool) {
|
||||||
|
|||||||
@@ -975,9 +975,6 @@ var fallbackNudges = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fallbackNudge(c loop.Candidate) string {
|
func fallbackNudge(c loop.Candidate) string {
|
||||||
if down := loop.DownServices(c.State); len(down) > 0 {
|
|
||||||
return "Не отвечает: " + strings.Join(down, ", ") + "."
|
|
||||||
}
|
|
||||||
if s, ok := fallbackNudges[c.Rule.Name]; ok {
|
if s, ok := fallbackNudges[c.Rule.Name]; ok {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -993,11 +990,6 @@ func buildNudgePrompt(c loop.Candidate) string {
|
|||||||
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
|
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
|
||||||
ctxParts = append(ctxParts, "Что именно: "+f.Key)
|
ctxParts = append(ctxParts, "Что именно: "+f.Key)
|
||||||
}
|
}
|
||||||
if down := loop.DownServices(c.State); len(down) > 0 {
|
|
||||||
// The names come from the same helper the rule fired on, so the model
|
|
||||||
// is never handed a service that is actually up.
|
|
||||||
ctxParts = append(ctxParts, "Какие сервисы лежат: "+strings.Join(down, ", "))
|
|
||||||
}
|
|
||||||
if d, ok := c.State.Since(c.Rule.Name); ok {
|
if d, ok := c.State.Since(c.Rule.Name); ok {
|
||||||
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
|
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,21 +142,16 @@ func phraseNudge(c loop.Candidate) (body, summary string) {
|
|||||||
}
|
}
|
||||||
return body, "take a break"
|
return body, "take a break"
|
||||||
case "service_down":
|
case "service_down":
|
||||||
// One fact per kuma monitor, so the nudge names the service. The rule
|
// the fact value is json `"down"`; the key carries the service name.
|
||||||
// and this share loop.DownServices, so the message cannot name a
|
body = "a service on homesrv is down — check journalctl."
|
||||||
// service the predicate did not fire on.
|
summary = "service down on homesrv"
|
||||||
down := loop.DownServices(c.State)
|
if f, ok := c.State.Fact("service_down"); ok {
|
||||||
switch len(down) {
|
if f.Key != "" && f.Key != "service_down" {
|
||||||
case 0:
|
body = fmt.Sprintf("%s on homesrv is down — check journalctl.", f.Key)
|
||||||
return "a service on homesrv is down — check journalctl.", "service down on homesrv"
|
summary = fmt.Sprintf("%s down on homesrv", f.Key)
|
||||||
case 1:
|
}
|
||||||
return fmt.Sprintf("%s on homesrv is down — check journalctl.", down[0]),
|
|
||||||
fmt.Sprintf("%s down on homesrv", down[0])
|
|
||||||
default:
|
|
||||||
list := strings.Join(down, ", ")
|
|
||||||
return fmt.Sprintf("%s on homesrv are down — check journalctl.", list),
|
|
||||||
fmt.Sprintf("%d services down on homesrv", len(down))
|
|
||||||
}
|
}
|
||||||
|
return body, summary
|
||||||
default:
|
default:
|
||||||
// generic: name the rule + severity; the LLM impl replaces this with
|
// generic: name the rule + severity; the LLM impl replaces this with
|
||||||
// a prompted phrase. the Stub never editorializes beyond the rule name.
|
// a prompted phrase. the Stub never editorializes beyond the rule name.
|
||||||
|
|||||||
@@ -78,14 +78,12 @@ func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
||||||
// one fact per kuma monitor → the phrase names the monitor that is down.
|
// a service_down fact whose Key is the specific service name → the phrase
|
||||||
|
// names the service, not just "service down".
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
st := loop.State{
|
st := loop.State{
|
||||||
Now: now,
|
Now: now,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{"service_down": {Key: "nginx", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
|
||||||
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
|
||||||
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
||||||
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
||||||
@@ -98,12 +96,12 @@ func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
||||||
// the old aggregate key, still in the store from before the per-monitor
|
// the rule key itself ("service_down") rather than a specific service →
|
||||||
// facts landed → the generic phrase, never a phantom "service_down down".
|
// the generic phrase, not a phantom "service_down down on homesrv".
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
st := loop.State{
|
st := loop.State{
|
||||||
Now: now,
|
Now: now,
|
||||||
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
|
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
|
||||||
}
|
}
|
||||||
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
||||||
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
||||||
@@ -112,26 +110,6 @@ func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two monitors down at once must both be named — he needs to know the blast
|
|
||||||
// radius, and "a service is down" was the whole defect being fixed here.
|
|
||||||
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
st := loop.State{
|
|
||||||
Now: now,
|
|
||||||
Facts: map[string]store.Fact{
|
|
||||||
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
|
||||||
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
|
||||||
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
|
||||||
for _, want := range []string{"nginx", "db"} {
|
|
||||||
if !strings.Contains(pn.Body, want) {
|
|
||||||
t.Fatalf("body should name %q, got %q", want, pn.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
|
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
|
||||||
// a rule without a dedicated template — generic fallback names the rule +
|
// a rule without a dedicated template — generic fallback names the rule +
|
||||||
// severity gist. never empty.
|
// severity gist. never empty.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// transientStems — the states a thing is in for an afternoon. Compared as
|
||||||
|
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
|
||||||
|
// "медленный" and "медленно" without listing them.
|
||||||
|
var transientStems = []string{
|
||||||
|
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
|
||||||
|
"отвал", "падает", "упал", "сдох", "греется", "перегре",
|
||||||
|
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
|
||||||
|
}
|
||||||
|
|
||||||
|
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
|
||||||
|
// "не работает", "не грузит", "не открывается". Prefixes again.
|
||||||
|
var brokenVerbs = []string{
|
||||||
|
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
|
||||||
|
"work", "load", "connect", "respond",
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfMarkers — the words that make a sentence about him rather than about a
|
||||||
|
// thing. Their presence turns the test off, because losing a fact he meant to
|
||||||
|
// store is worse than keeping a complaint: "я сломал руку" is durable, and
|
||||||
|
// "интернет не работает" is not.
|
||||||
|
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
|
||||||
|
|
||||||
|
// IsTransientComplaint reports whether text observes a passing state of some
|
||||||
|
// thing rather than recording a fact.
|
||||||
|
//
|
||||||
|
// It exists because "сеть какая-то медленная" and "интернет не работает" were
|
||||||
|
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
|
||||||
|
// where recall reads them back later as if they were still true. A complaint
|
||||||
|
// describes a moment; the fact store describes him.
|
||||||
|
//
|
||||||
|
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
|
||||||
|
// explicit capture verb wins over everything, because "запомни что интернет
|
||||||
|
// не работает" is an instruction and not a passing remark. A first-person
|
||||||
|
// marker also turns it off — the test is meant to catch a sentence about a
|
||||||
|
// thing, and it errs toward storing.
|
||||||
|
func IsTransientComplaint(text string) bool {
|
||||||
|
t := strings.TrimSpace(text)
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
toks := planTokens(strings.ToLower(t))
|
||||||
|
for _, v := range captureVerbs {
|
||||||
|
if hasTok(toks, v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range selfMarkers {
|
||||||
|
if hasTok(toks, m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tok := range toks {
|
||||||
|
for _, stem := range transientStems {
|
||||||
|
if strings.HasPrefix(tok, stem) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "не" plus a verb of working, in either order of the two tokens that
|
||||||
|
// follow it — "не работает" and "не очень работает" both deny the same
|
||||||
|
// thing.
|
||||||
|
for i, tok := range toks {
|
||||||
|
if tok != "не" && tok != "not" && tok != "isn" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(toks) && j <= i+2; j++ {
|
||||||
|
for _, v := range brokenVerbs {
|
||||||
|
if strings.HasPrefix(toks[j], v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsTransientComplaint(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// The two rows from the QA run that named this bug.
|
||||||
|
{"сеть какая-то медленная", true},
|
||||||
|
{"интернет не работает", true},
|
||||||
|
{"вайфай тормозит", true},
|
||||||
|
{"сервер завис", true},
|
||||||
|
{"the wifi is slow", true},
|
||||||
|
|
||||||
|
// An instruction wins: he asked for it to be written down.
|
||||||
|
{"запомни что интернет не работает", false},
|
||||||
|
{"запиши что сеть медленная", false},
|
||||||
|
|
||||||
|
// About him, so it stays a fact even when it sounds like a complaint.
|
||||||
|
{"я сломал руку", false},
|
||||||
|
{"мне медленно думается", false},
|
||||||
|
|
||||||
|
// Ordinary captures must not be touched.
|
||||||
|
{"поужинал", false},
|
||||||
|
{"выпил воды", false},
|
||||||
|
{"машина на парковке", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := IsTransientComplaint(tc.text); got != tc.want {
|
||||||
|
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
|
|||||||
// or dateparser is unavailable, falls back to the stub parser. Returns
|
// or dateparser is unavailable, falls back to the stub parser. Returns
|
||||||
// (time, true, nil) on success; (zero, false, nil) when no date is found.
|
// (time, true, nil) on success; (zero, false, nil) when no date is found.
|
||||||
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
|
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||||
|
// Speech says the hour in words, and neither this parser nor the stub
|
||||||
|
// reads "в семь вечера" (Vikunja #469). Both see the digits instead.
|
||||||
|
text = SpellOutDigits(text)
|
||||||
t, ok, err := p.parseWithPython(ctx, text, now)
|
t, ok, err := p.parseWithPython(ctx, text, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// python3 missing, dateparser not installed, or process failure —
|
// python3 missing, dateparser not installed, or process failure —
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
|||||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||||
// anything while its grammar set is the daemon's grammar set.
|
// anything while its grammar set is the daemon's grammar set.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func feedRouter(t *testing.T) *Router {
|
||||||
|
t.Helper()
|
||||||
|
r := newTestRouter(t, 0.0)
|
||||||
|
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||||
|
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||||
|
r.grammars = append(r.grammars, FeedQueryGrammar())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// The documented utterance of task 258 step 1 routed system and answered
|
||||||
|
// "пока не умею отвечать на этот вопрос.", while the same question worded with
|
||||||
|
// "новостях" worked (Vikunja #474).
|
||||||
|
func TestFeedQuestionsRouteToQuery(t *testing.T) {
|
||||||
|
r := feedRouter(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"что нового в лентах?",
|
||||||
|
"что в лентах?",
|
||||||
|
"расскажи что в новостных лентах",
|
||||||
|
"покажи ленту",
|
||||||
|
} {
|
||||||
|
d, err := r.Route(context.Background(), u, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("route(%q): %v", u, err)
|
||||||
|
}
|
||||||
|
if d.Intent != IntentQuery {
|
||||||
|
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The greeting and the statement keep their intents. "что нового?" is the most
|
||||||
|
// common opener in the language, and a rule that claimed it would answer hello
|
||||||
|
// with a configuration status.
|
||||||
|
func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) {
|
||||||
|
r := feedRouter(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"что нового?",
|
||||||
|
"у меня новая лента в инстаграме",
|
||||||
|
} {
|
||||||
|
d, err := r.Route(context.Background(), u, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("route(%q): %v", u, err)
|
||||||
|
}
|
||||||
|
if d.Stage == 0 {
|
||||||
|
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
|
||||||
|
// that follow them. Every case ending he might say is listed rather than
|
||||||
|
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
|
||||||
|
// prefix rule short enough to cover them also matches "семья".
|
||||||
|
//
|
||||||
|
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
|
||||||
|
// восемь", "в половине шестого"). Anything larger is said in digits.
|
||||||
|
var ruNumerals = map[string]string{
|
||||||
|
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
|
||||||
|
"два": "2", "две": "2", "двух": "2",
|
||||||
|
"три": "3", "трёх": "3", "трех": "3",
|
||||||
|
"четыре": "4", "четырёх": "4", "четырех": "4",
|
||||||
|
"пять": "5", "пяти": "5",
|
||||||
|
"шесть": "6", "шести": "6",
|
||||||
|
"семь": "7", "семи": "7",
|
||||||
|
"восемь": "8", "восьми": "8",
|
||||||
|
"девять": "9", "девяти": "9",
|
||||||
|
"десять": "10", "десяти": "10",
|
||||||
|
"одиннадцать": "11", "одиннадцати": "11",
|
||||||
|
"двенадцать": "12", "двенадцати": "12",
|
||||||
|
"тринадцать": "13", "тринадцати": "13",
|
||||||
|
"четырнадцать": "14", "четырнадцати": "14",
|
||||||
|
"пятнадцать": "15", "пятнадцати": "15",
|
||||||
|
"шестнадцать": "16", "шестнадцати": "16",
|
||||||
|
"семнадцать": "17", "семнадцати": "17",
|
||||||
|
"восемнадцать": "18", "восемнадцати": "18",
|
||||||
|
"девятнадцать": "19", "девятнадцати": "19",
|
||||||
|
"двадцать": "20", "двадцати": "20",
|
||||||
|
"тридцать": "30", "тридцати": "30",
|
||||||
|
"сорок": "40", "сорока": "40",
|
||||||
|
"пятьдесят": "50", "пятидесяти": "50",
|
||||||
|
}
|
||||||
|
|
||||||
|
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||||
|
// rewritten when one of these sits next to it, so "три яблока" in a note is
|
||||||
|
// left alone and "в три часа" is not.
|
||||||
|
var numeralContext = map[string]bool{
|
||||||
|
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||||
|
"часа": true, "часов": true, "час": true, "часу": true,
|
||||||
|
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||||
|
"минут": true, "минуты": true, "минуту": true,
|
||||||
|
"at": true, "by": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
|
||||||
|
// shape they know.
|
||||||
|
//
|
||||||
|
// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00"
|
||||||
|
// parsed fine (Vikunja #469). Speech is where reminders come from, and speech
|
||||||
|
// says the hour in words, so this is not a long-tail case — it is the ordinary
|
||||||
|
// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python
|
||||||
|
// script already does; it does not read "в семь вечера".
|
||||||
|
//
|
||||||
|
// Conservative by construction: a numeral is only rewritten when a time word
|
||||||
|
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
|
||||||
|
// and a note or a fact carrying a spoken number is untouched.
|
||||||
|
func SpellOutDigits(text string) string {
|
||||||
|
toks := strings.Fields(text)
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
out := make([]string, len(toks))
|
||||||
|
copy(out, toks)
|
||||||
|
for i, tok := range toks {
|
||||||
|
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
||||||
|
digit, ok := ruNumerals[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// "час" and "часу" are the hour noun as often as they are the number
|
||||||
|
// one, and rewriting "в час дня" to "в 1 дня" is right either way. What
|
||||||
|
// must not happen is rewriting the noun that gives another numeral its
|
||||||
|
// context: "в семь часов" must keep "часов".
|
||||||
|
if !hasTimeNeighbour(toks, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[i] = digit
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasTimeNeighbour reports whether the token before or after i is a time word.
|
||||||
|
func hasTimeNeighbour(toks []string, i int) bool {
|
||||||
|
for _, j := range []int{i - 1, i + 1} {
|
||||||
|
if j < 0 || j >= len(toks) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpellOutDigits(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
|
||||||
|
{"в три часа дня", "в 3 часа дня"},
|
||||||
|
{"напомни в половине шестого", "напомни в половине шестого"},
|
||||||
|
{"через двадцать минут", "через 20 минут"},
|
||||||
|
// Untouched: no time word stands beside the number.
|
||||||
|
{"купить три яблока", "купить три яблока"},
|
||||||
|
{"семь раз отмерь", "семь раз отмерь"},
|
||||||
|
{"напомни в 19:00", "напомни в 19:00"},
|
||||||
|
{"", ""},
|
||||||
|
} {
|
||||||
|
if got := SpellOutDigits(tc.in); got != tc.want {
|
||||||
|
t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The utterance from the QA sitting that named this bug: the numeric form
|
||||||
|
// parsed and the spoken form did not.
|
||||||
|
func TestStubParsesASpokenHour(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local)
|
||||||
|
got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("Parse ok=%v err=%v, want a time", ok, err)
|
||||||
|
}
|
||||||
|
if got.Hour() != 19 {
|
||||||
|
t.Fatalf("hour = %d, want 19", got.Hour())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ func afterWord(s, w string) string {
|
|||||||
type StubDateTimeParser struct{}
|
type StubDateTimeParser struct{}
|
||||||
|
|
||||||
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||||
s := strings.ToLower(strings.TrimSpace(text))
|
s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
|
||||||
toks := strings.Fields(s)
|
toks := strings.Fields(s)
|
||||||
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
||||||
// expression from surrounding text; the stub does the same naively.
|
// expression from surrounding text; the stub does the same naively.
|
||||||
@@ -204,14 +204,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
|
|||||||
|
|
||||||
// --- Russian time expressions (stub floor; dateparser replaces) ---
|
// --- Russian time expressions (stub floor; dateparser replaces) ---
|
||||||
|
|
||||||
// "в <clock>" anywhere — mirror of the English "at" scan.
|
// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
|
||||||
|
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||||
|
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||||
|
// as too (Vikunja #469).
|
||||||
for i := 0; i+1 < len(toks); i++ {
|
for i := 0; i+1 < len(toks); i++ {
|
||||||
if toks[i] != "в" {
|
if toks[i] != "в" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t, ok := parseClock(toks[i+1], now); ok {
|
t, ok := parseClock(toks[i+1], now)
|
||||||
return t, true, nil
|
if !ok {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if i+2 < len(toks) {
|
||||||
|
t = applyRuQualifier(t, toks[i+2], now)
|
||||||
|
}
|
||||||
|
return t, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
||||||
@@ -476,3 +484,30 @@ func midnight(now time.Time, days int) time.Time {
|
|||||||
y, m, d := now.AddDate(0, 0, days).Date()
|
y, m, d := now.AddDate(0, 0, days).Date()
|
||||||
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
|
||||||
|
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
|
||||||
|
// qualifiers need no arithmetic, they only confirm the hour as spoken.
|
||||||
|
//
|
||||||
|
// The date is recomputed rather than shifted, so an hour that parseClock
|
||||||
|
// already pushed to tomorrow does not land two days out.
|
||||||
|
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
|
||||||
|
h := t.Hour()
|
||||||
|
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
||||||
|
case "вечера", "дня":
|
||||||
|
if h < 12 {
|
||||||
|
h += 12
|
||||||
|
}
|
||||||
|
case "утра", "ночи":
|
||||||
|
if h == 12 {
|
||||||
|
h = 0
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
|
||||||
|
if !out.After(now) {
|
||||||
|
out = out.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,13 +208,39 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
|
||||||
|
// IntentQuery so it reaches queryFeeds.
|
||||||
|
//
|
||||||
|
// Same shape of defect as the agenda grammars: the model calls it system, and
|
||||||
|
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
|
||||||
|
// answered "пока не умею отвечать на этот вопрос." while the same question
|
||||||
|
// worded with "новостях" worked (Vikunja #474).
|
||||||
|
//
|
||||||
|
// An ask word at the front and a feed noun after it are both required, which
|
||||||
|
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
|
||||||
|
// — the most common opener in the language — and vagueNouns in feeds.go exists
|
||||||
|
// to keep it out of the feed reader; routing it to query here would put it
|
||||||
|
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
|
||||||
|
// stays the statement it is.
|
||||||
|
func FeedQueryGrammar() Grammar {
|
||||||
|
return Grammar{
|
||||||
|
Name: "feed-query",
|
||||||
|
// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
|
||||||
|
// to a Cyrillic letter.
|
||||||
|
Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`),
|
||||||
|
Build: agendaQueryBuild,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||||
// in the accusative and prepositional forms the questions actually use ("в
|
// in the accusative and prepositional forms the questions actually use ("в
|
||||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||||
// than a fixed ending.
|
// than a fixed ending.
|
||||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||||||
|
|
||||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
|
||||||
|
// which all do the same single thing: keep the utterance out of IntentSystem
|
||||||
|
// and let the query chain decide who answers it. Confidence 1.0 on
|
||||||
// the intent only: the utterance travels intact and the query chain's own
|
// the intent only: the utterance travels intact and the query chain's own
|
||||||
// matchers decide the rest.
|
// matchers decide the rest.
|
||||||
func agendaQueryBuild(m []string) (Decision, bool) {
|
func agendaQueryBuild(m []string) (Decision, bool) {
|
||||||
|
|||||||
@@ -254,41 +254,6 @@ func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fac
|
|||||||
return scanFact(row)
|
return scanFact(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestFactsByPrefix — the latest non-voided fact for every key that starts
|
|
||||||
// with prefix, newest-per-key, ordered by key.
|
|
||||||
//
|
|
||||||
// The loop's gatherer loads the keys its rules declare, which works while the
|
|
||||||
// key set is static. Kuma's monitors are not: one fact per monitor means the
|
|
||||||
// keys are only known once the gauge is read, so the rule declares the prefix
|
|
||||||
// and this read resolves it per tick. `_` and `%` are escaped — a monitor name
|
|
||||||
// is user text and must not act as a LIKE wildcard.
|
|
||||||
func (s *Store) LatestFactsByPrefix(ctx context.Context, prefix string) ([]Fact, error) {
|
|
||||||
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)
|
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
|
||||||
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
|
||||||
FROM facts f
|
|
||||||
WHERE key LIKE ? ESCAPE '\'
|
|
||||||
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
||||||
AND id = (SELECT id FROM facts g
|
|
||||||
WHERE g.key = f.key
|
|
||||||
AND g.id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
|
||||||
ORDER BY g.ts DESC, g.id DESC LIMIT 1)
|
|
||||||
ORDER BY key`, esc+"%")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("facts by prefix %q: %w", prefix, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []Fact
|
|
||||||
for rows.Next() {
|
|
||||||
f, err := scanFact(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since returns how long ago the latest non-voided fact for key landed, or
|
// Since returns how long ago the latest non-voided fact for key landed, or
|
||||||
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
|
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
|
||||||
// the spec — silence on no-data is "shuts up when uncertain".
|
// the spec — silence on no-data is "shuts up when uncertain".
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// One fact per kuma monitor means the loop cannot name its keys at wiring time,
|
|
||||||
// so it asks for the family by prefix. The read must return the newest row per
|
|
||||||
// key and stop at the prefix boundary.
|
|
||||||
func TestLatestFactsByPrefix(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
||||||
write := func(key, val string, at time.Time) int64 {
|
|
||||||
id, err := s.SetValue(ctx, KindEnv, key, "poll:uptimekuma", val, at)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetValue %s: %v", key, err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
write("service_down:db", "up", now.Add(-2*time.Hour))
|
|
||||||
write("service_down:db", "down", now.Add(-time.Hour)) // newer wins
|
|
||||||
write("service_down:web", "up", now.Add(-time.Hour))
|
|
||||||
write("service_downtime", "irrelevant", now) // no colon, not in the family
|
|
||||||
write("water", "250ml", now)
|
|
||||||
|
|
||||||
got, err := s.LatestFactsByPrefix(ctx, "service_down:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LatestFactsByPrefix: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("got %d facts, want 2: %+v", len(got), got)
|
|
||||||
}
|
|
||||||
if got[0].Key != "service_down:db" || got[0].Value != `"down"` {
|
|
||||||
t.Errorf("first = %s=%s, want the newest db row", got[0].Key, got[0].Value)
|
|
||||||
}
|
|
||||||
if got[1].Key != "service_down:web" {
|
|
||||||
t.Errorf("second = %s, want service_down:web", got[1].Key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A monitor name is user text. An underscore in it must match itself, not act
|
|
||||||
// as a LIKE wildcard and drag in every other monitor.
|
|
||||||
func TestLatestFactsByPrefixEscapesWildcards(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
||||||
for _, k := range []string{"a_b:one", "axb:two"} {
|
|
||||||
if _, err := s.SetValue(ctx, KindEnv, k, "poll:uptimekuma", "down", now); err != nil {
|
|
||||||
t.Fatalf("SetValue %s: %v", k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got, err := s.LatestFactsByPrefix(ctx, "a_b:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LatestFactsByPrefix: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].Key != "a_b:one" {
|
|
||||||
t.Fatalf("got %+v, want only a_b:one", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,37 +8,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RoutineStatus — the state a proposal is in. A defined type, not a bare
|
// The three states a proposal can be in. A proposal starts 'proposed' and
|
||||||
// string, because the legal set used to live in a comment: nothing caught a
|
// moves once, either way, and never moves again.
|
||||||
// typo at compile time, nothing enumerated the set for a test, and a bad value
|
|
||||||
// surfaced as a /routines row that neither accepts nor dismisses (Vikunja #46,
|
|
||||||
// #410).
|
|
||||||
type RoutineStatus string
|
|
||||||
|
|
||||||
// The three states a proposal can be in. A proposal starts proposed and moves
|
|
||||||
// once, either way, and never moves again.
|
|
||||||
const (
|
const (
|
||||||
RoutineProposed RoutineStatus = "proposed"
|
RoutineProposed = "proposed"
|
||||||
RoutineAccepted RoutineStatus = "accepted"
|
RoutineAccepted = "accepted"
|
||||||
RoutineDismissed RoutineStatus = "dismissed"
|
RoutineDismissed = "dismissed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RoutineStatuses — the legal set, and the single source of truth a test can
|
|
||||||
// range over. Adding a state means adding it here.
|
|
||||||
var RoutineStatuses = []RoutineStatus{RoutineProposed, RoutineAccepted, RoutineDismissed}
|
|
||||||
|
|
||||||
// Valid reports whether s is one of RoutineStatuses.
|
|
||||||
func (s RoutineStatus) Valid() bool {
|
|
||||||
for _, v := range RoutineStatuses {
|
|
||||||
if s == v {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s RoutineStatus) String() string { return string(s) }
|
|
||||||
|
|
||||||
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
// ProposedRoutine — a detected pattern the system wants to nudge about on a
|
||||||
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
// repeating interval. Status 'proposed' means awaiting human confirmation;
|
||||||
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
// 'accepted' means the human confirmed and the tick loop now owns the schedule;
|
||||||
@@ -53,7 +30,7 @@ type ProposedRoutine struct {
|
|||||||
Action string
|
Action string
|
||||||
Object string
|
Object string
|
||||||
IntervalDays float64
|
IntervalDays float64
|
||||||
Status RoutineStatus
|
Status string // proposed | accepted | dismissed
|
||||||
CreatedTs time.Time
|
CreatedTs time.Time
|
||||||
ReminderID *int64
|
ReminderID *int64
|
||||||
AcceptedTs *time.Time
|
AcceptedTs *time.Time
|
||||||
@@ -63,7 +40,6 @@ type ProposedRoutine struct {
|
|||||||
var (
|
var (
|
||||||
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
ErrProposedRoutineNotFound = errors.New("store: proposed routine not found")
|
||||||
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
ErrProposedRoutineExists = errors.New("store: proposed routine already exists for this action+object")
|
||||||
ErrRoutineStatus = errors.New("store: unknown routine status")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateProposedRoutine inserts a new proposed routine. Returns
|
// CreateProposedRoutine inserts a new proposed routine. Returns
|
||||||
@@ -75,16 +51,6 @@ var (
|
|||||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
||||||
// a nag.
|
// a nag.
|
||||||
//
|
//
|
||||||
// The object stays a local string. It is not resolved against Nexus and it
|
|
||||||
// carries no canonical entity ref (asked on the PR 4 review, decided here,
|
|
||||||
// Vikunja #410). Nexus owns identity for things the ecosystem acts on, and
|
|
||||||
// nothing acts on a routine object: it is the word he used, replayed back to
|
|
||||||
// him in a nudge, and compared only against itself for the UNIQUE key. Two
|
|
||||||
// spellings of the same watering can are two routines, and that is the right
|
|
||||||
// answer when the point is to say the sentence he would say. Canonical refs
|
|
||||||
// arrive here only if a routine ever drives a Hexis call, which is Vikunja
|
|
||||||
// #272, not this.
|
|
||||||
//
|
|
||||||
// Vikunja #43: this is called both from the voice fact-write path (for the
|
// Vikunja #43: this is called both from the voice fact-write path (for the
|
||||||
// immediate spoken confirmation) and from the digestion tick's proactive
|
// immediate spoken confirmation) and from the digestion tick's proactive
|
||||||
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
||||||
@@ -136,13 +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 unknown one is refused rather than
|
// An empty status returns every row.
|
||||||
// silently answering with nothing, since a typo and a genuinely empty state
|
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
|
||||||
// read the same otherwise.
|
|
||||||
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status RoutineStatus) ([]ProposedRoutine, error) {
|
|
||||||
if status != "" && !status.Valid() {
|
|
||||||
return nil, fmt.Errorf("%w: %q", ErrRoutineStatus, status)
|
|
||||||
}
|
|
||||||
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
|
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
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
status RoutineStatus
|
status string
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{RoutineProposed, 0},
|
{RoutineProposed, 0},
|
||||||
@@ -266,59 +266,3 @@ func TestLookupMissingProposedRoutine(t *testing.T) {
|
|||||||
t.Fatal("want nil for missing routine")
|
t.Fatal("want nil for missing routine")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every legal status must survive the database. The status column is written
|
|
||||||
// by three different UPDATE statements with the value spelled inline, so a
|
|
||||||
// constant that drifts from its SQL is exactly the failure this catches: the
|
|
||||||
// row would come back in a state no Go code compares equal to.
|
|
||||||
func TestRoutineStatusRoundTrip(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
s := newTestStore(t)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
write := map[RoutineStatus]func(id int64) error{
|
|
||||||
RoutineProposed: func(int64) error { return nil },
|
|
||||||
RoutineAccepted: func(id int64) error { return s.AcceptProposedRoutine(ctx, id, now) },
|
|
||||||
RoutineDismissed: func(id int64) error { return s.DismissProposedRoutine(ctx, id) },
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, want := range RoutineStatuses {
|
|
||||||
if !want.Valid() {
|
|
||||||
t.Fatalf("%q is in RoutineStatuses but not Valid()", want)
|
|
||||||
}
|
|
||||||
object := "object-" + want.String()
|
|
||||||
id, err := s.CreateProposedRoutine(ctx, "полить", object, 3, now)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProposedRoutine(%s): %v", want, err)
|
|
||||||
}
|
|
||||||
if err := write[want](id); err != nil {
|
|
||||||
t.Fatalf("move to %s: %v", want, err)
|
|
||||||
}
|
|
||||||
got, err := s.LookupProposedRoutine(ctx, "полить", object)
|
|
||||||
if err != nil || got == nil {
|
|
||||||
t.Fatalf("LookupProposedRoutine(%s): %v", want, err)
|
|
||||||
}
|
|
||||||
if got.Status != want {
|
|
||||||
t.Errorf("status = %q, want %q", got.Status, want)
|
|
||||||
}
|
|
||||||
list, err := s.ListProposedRoutinesByStatus(ctx, want)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProposedRoutinesByStatus(%s): %v", want, err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Errorf("status %s: listed %d rows, want 1", want, len(list))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typo used to read as "nothing is in that state", which is the same answer
|
|
||||||
// a correct query gives on an empty table.
|
|
||||||
func TestListByStatusRefusesAnUnknownStatus(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
if _, err := s.ListProposedRoutinesByStatus(context.Background(), "accpeted"); !errors.Is(err, ErrRoutineStatus) {
|
|
||||||
t.Fatalf("err = %v, want ErrRoutineStatus", err)
|
|
||||||
}
|
|
||||||
if RoutineStatus("accpeted").Valid() {
|
|
||||||
t.Error("a typo must not be Valid()")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pronunciation dictionary (Vikunja #458).
|
||||||
|
//
|
||||||
|
// piper reads a Russian sentence with a Russian voice, and a Latin service id
|
||||||
|
// inside that sentence comes out as letters or as noise: "Vikunja" is spelled
|
||||||
|
// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The
|
||||||
|
// fix is not a code change per name — it is a file of replacements applied to
|
||||||
|
// the text before piper sees it.
|
||||||
|
//
|
||||||
|
// Spelling, not phonemes. piper has no lexicon input of its own here, so the
|
||||||
|
// only lever is the text, and the entry for a name is how it should be spelled
|
||||||
|
// in Russian for the voice to say it right. That also means a wrong entry is
|
||||||
|
// visible: it is a word, and it is read out loud.
|
||||||
|
//
|
||||||
|
// The dictionary is data, so it ships as a file rather than a table in Go. A
|
||||||
|
// name added to it needs no rebuild and no deploy of the daemon that owns the
|
||||||
|
// text — only a restart of mavttsd, which is the process that reads it.
|
||||||
|
|
||||||
|
// Lexicon rewrites names into the spelling the voice reads correctly.
|
||||||
|
//
|
||||||
|
// The zero value is usable and rewrites nothing, so a daemon with no dictionary
|
||||||
|
// configured behaves exactly as it did before this existed.
|
||||||
|
type Lexicon struct {
|
||||||
|
// Rules are held in one alternation rather than as a map, so a text is
|
||||||
|
// scanned once however many entries there are, and the longest name wins
|
||||||
|
// where two overlap ("Home Assistant" before "Home").
|
||||||
|
re *regexp.Regexp
|
||||||
|
// by lower-cased name, because the match is case-insensitive and the
|
||||||
|
// replacement is not derived from what was matched.
|
||||||
|
by map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling.
|
||||||
|
//
|
||||||
|
// {"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"}
|
||||||
|
//
|
||||||
|
// An empty path returns an empty Lexicon and no error — the dictionary is off
|
||||||
|
// unless configured, like every other optional capability. A path that is set
|
||||||
|
// and unreadable IS an error: he asked for it, and silently saying names wrong
|
||||||
|
// is the failure this exists to remove.
|
||||||
|
func LoadLexicon(path string) (*Lexicon, error) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return &Lexicon{}, nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var entries map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||||
|
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return NewLexicon(entries), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLexicon builds a lexicon from entries already in memory.
|
||||||
|
func NewLexicon(entries map[string]string) *Lexicon {
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
by := make(map[string]string, len(entries))
|
||||||
|
for name, say := range entries {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || strings.TrimSpace(say) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
by[strings.ToLower(name)] = say
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return &Lexicon{}
|
||||||
|
}
|
||||||
|
// Longest first: "Home Assistant" must match before "Home" does, and Go's
|
||||||
|
// regexp alternation is leftmost-first, not longest-match.
|
||||||
|
sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
|
||||||
|
quoted := make([]string, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
quoted[i] = regexp.QuoteMeta(n)
|
||||||
|
}
|
||||||
|
// The boundaries are written out rather than left to \b, which is ASCII-only
|
||||||
|
// and never fires next to a Cyrillic letter — so "в Vikunja," would not
|
||||||
|
// match with \b on the left in a Russian sentence.
|
||||||
|
pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])`
|
||||||
|
return &Lexicon{re: regexp.MustCompile(pattern), by: by}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rewrites every name in the text. Text with no name in it comes back
|
||||||
|
// unchanged and untouched.
|
||||||
|
func (l *Lexicon) Apply(text string) string {
|
||||||
|
if l == nil || l.re == nil || text == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
// Twice, because two names separated by a single space share the character
|
||||||
|
// between them and one pass consumes it: "Nexus Praxis" would leave the
|
||||||
|
// second name alone otherwise.
|
||||||
|
out := l.replaceOnce(text)
|
||||||
|
return l.replaceOnce(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexicon) replaceOnce(text string) string {
|
||||||
|
return l.re.ReplaceAllStringFunc(text, func(m string) string {
|
||||||
|
groups := l.re.FindStringSubmatch(m)
|
||||||
|
if groups == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
say, ok := l.by[strings.ToLower(groups[2])]
|
||||||
|
if !ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return groups[1] + say + groups[3]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size reports how many names are loaded, for the startup log line.
|
||||||
|
func (l *Lexicon) Size() int {
|
||||||
|
if l == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(l.by)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLexiconRewritesNames(t *testing.T) {
|
||||||
|
lex := NewLexicon(map[string]string{
|
||||||
|
"Vikunja": "Викунья",
|
||||||
|
"Home Assistant": "Хоум Ассистент",
|
||||||
|
"Home": "Хоум",
|
||||||
|
"GPU": "джи-пи-ю",
|
||||||
|
})
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"задача в Vikunja готова", "задача в Викунья готова"},
|
||||||
|
// Case-insensitive: the router and the model both change the case of a
|
||||||
|
// name on the way through.
|
||||||
|
{"открой vikunja.", "открой Викунья."},
|
||||||
|
// Longest first, or "Home Assistant" is read as "Хоум Assistant".
|
||||||
|
{"Home Assistant не отвечает", "Хоум Ассистент не отвечает"},
|
||||||
|
// Two names in a row share the space between them, which one pass
|
||||||
|
// would consume.
|
||||||
|
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
|
||||||
|
// Not a word boundary: a name inside a longer token is left alone.
|
||||||
|
{"vikunjaless", "vikunjaless"},
|
||||||
|
{"ничего не совпало", "ничего не совпало"},
|
||||||
|
{"", ""},
|
||||||
|
} {
|
||||||
|
if got := lex.Apply(tc.in); got != tc.want {
|
||||||
|
t.Errorf("Apply(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The zero value and an unconfigured path rewrite nothing, so a daemon with no
|
||||||
|
// dictionary behaves as it did before this existed.
|
||||||
|
func TestLexiconOffByDefault(t *testing.T) {
|
||||||
|
var zero *Lexicon
|
||||||
|
if got := zero.Apply("Vikunja"); got != "Vikunja" {
|
||||||
|
t.Errorf("nil lexicon rewrote %q", got)
|
||||||
|
}
|
||||||
|
lex, err := LoadLexicon("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLexicon(\"\"): %v", err)
|
||||||
|
}
|
||||||
|
if lex.Size() != 0 || lex.Apply("Vikunja") != "Vikunja" {
|
||||||
|
t.Errorf("empty path produced a live lexicon of %d names", lex.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path he set and that cannot be read is a startup failure. Saying names
|
||||||
|
// wrong in silence is what the dictionary exists to stop.
|
||||||
|
func TestLexiconLoadErrors(t *testing.T) {
|
||||||
|
if _, err := LoadLexicon(filepath.Join(t.TempDir(), "nope.json")); err == nil {
|
||||||
|
t.Error("a missing dictionary must be an error")
|
||||||
|
}
|
||||||
|
bad := filepath.Join(t.TempDir(), "bad.json")
|
||||||
|
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadLexicon(bad); err == nil {
|
||||||
|
t.Error("an unparseable dictionary must be an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLexiconRoundTripsAFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "lex.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"Praxis":"Праксис"," ":"skipped","Nexus":""}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lex, err := LoadLexicon(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLexicon: %v", err)
|
||||||
|
}
|
||||||
|
// Blank names and blank spellings are dropped: an entry that says nothing
|
||||||
|
// would delete the word it matched.
|
||||||
|
if lex.Size() != 1 {
|
||||||
|
t.Fatalf("Size = %d, want 1", lex.Size())
|
||||||
|
}
|
||||||
|
if got := lex.Apply("Praxis молчит"); got != "Праксис молчит" {
|
||||||
|
t.Errorf("Apply = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
-3
@@ -15,8 +15,6 @@ github.com/google/uuid
|
|||||||
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
# github.com/kami/hexis v0.0.0 => /home/kami/apps/hexis
|
||||||
## explicit; go 1.25.5
|
## explicit; go 1.25.5
|
||||||
github.com/kami/hexis/pkg/client
|
github.com/kami/hexis/pkg/client
|
||||||
# github.com/kami/praxis v0.0.0 => /home/kami/apps/praxis
|
|
||||||
## explicit; go 1.23
|
|
||||||
# github.com/mattn/go-isatty v0.0.20
|
# github.com/mattn/go-isatty v0.0.20
|
||||||
## explicit; go 1.15
|
## explicit; go 1.15
|
||||||
github.com/mattn/go-isatty
|
github.com/mattn/go-isatty
|
||||||
@@ -79,4 +77,3 @@ modernc.org/memory
|
|||||||
modernc.org/sqlite
|
modernc.org/sqlite
|
||||||
modernc.org/sqlite/lib
|
modernc.org/sqlite/lib
|
||||||
modernc.org/sqlite/vtab
|
modernc.org/sqlite/vtab
|
||||||
# github.com/kami/nexus v0.0.0 => /home/kami/apps/nexus
|
|
||||||
|
|||||||
Reference in New Issue
Block a user