Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad074cea31 | |||
| 2c1b0eede0 | |||
| cb3641e7bb | |||
| ee7bec11e3 | |||
| f42d1594ef | |||
| b4646155b4 | |||
| da647e87d0 | |||
| bf6ccf9aea | |||
| 7b2b96b957 | |||
| c8444813e2 | |||
| ed9bdd5e09 | |||
| 49f089d8a6 | |||
| 3af290152c |
@@ -7,6 +7,7 @@
|
||||
/mavpoll
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
@@ -34,6 +35,10 @@ deps
|
||||
deploy/db_key.env
|
||||
# Deploy secret (telegram bot token + chat id) — never commit
|
||||
deploy/telegram.env
|
||||
# zenmoney API token, read by mavpoll (never in argv, never committed)
|
||||
deploy/zenmoney.token
|
||||
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||
deploy/imap.password
|
||||
|
||||
# Temp files
|
||||
/tmp/
|
||||
|
||||
@@ -34,7 +34,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
|
||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||
|
||||
```sh
|
||||
make build # all 8 binaries
|
||||
make build # all 9 binaries
|
||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
```
|
||||
@@ -62,6 +62,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
|
||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ RUN go build -o /out/mavend ./cmd/mavend && \
|
||||
go build -o /out/mavttsd ./cmd/mavttsd && \
|
||||
go build -o /out/mavweb ./cmd/mavweb && \
|
||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav && \
|
||||
go build -o /out/mavmaild ./cmd/mavmaild
|
||||
|
||||
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
||||
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
||||
|
||||
@@ -20,7 +20,7 @@ PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
all: build
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -50,6 +50,9 @@ build-poll:
|
||||
build-caldav:
|
||||
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
||||
|
||||
build-mail:
|
||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||
|
||||
run-web: build-web
|
||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||
|
||||
@@ -185,4 +188,4 @@ download-embedder:
|
||||
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
||||
|
||||
clean:
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild
|
||||
|
||||
+75
-144
@@ -1,17 +1,24 @@
|
||||
// mavcaldav — the CalDAV poller module.
|
||||
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
|
||||
// maven's own reminders back out to a calendar she owns.
|
||||
//
|
||||
// Polls a Radicale (or any CalDAV) server for today's events and writes
|
||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
||||
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||
// touch the store key, worst case a stale calendar_busy fact until the next
|
||||
// poll. Two facts:
|
||||
//
|
||||
// Two facts written:
|
||||
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||
// nudges during meetings
|
||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||
//
|
||||
// Append-only discipline: a fact is written only when its value CHANGED
|
||||
// vs the latest for that key+source.
|
||||
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||
// latest for that key+source.
|
||||
//
|
||||
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
||||
// pending reminder as a single-event iCal resource in a collection maven owns.
|
||||
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
||||
// must differ from the read URL, checked at startup, so the render target can
|
||||
// never be a calendar maven is only supposed to read.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +34,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
@@ -43,6 +51,10 @@ func run(args []string) error {
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
||||
}
|
||||
defer core.Close()
|
||||
|
||||
hc := &http.Client{Timeout: *timeout}
|
||||
p := &poller{
|
||||
core: core,
|
||||
http: &http.Client{Timeout: *timeout},
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: *pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, *renderPass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
}
|
||||
|
||||
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
||||
p.pollOnce(ctx) // fire immediately
|
||||
tick := func() {
|
||||
p.pollOnce(ctx)
|
||||
if rend != nil {
|
||||
rend.renderOnce(ctx)
|
||||
}
|
||||
}
|
||||
tick() // fire immediately
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
||||
log.Printf("mavcaldav: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
p.pollOnce(ctx)
|
||||
tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||
// structural half of #127's "cannot write to your work calendar": the write
|
||||
// credential and the write URL are separate flags, and the one calendar maven
|
||||
// is known to only read is rejected as a target at startup rather than trusted
|
||||
// at runtime.
|
||||
func checkRenderTarget(readURL, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
}
|
||||
if sameCollection(readURL, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameCollection(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||
}
|
||||
|
||||
type poller struct {
|
||||
core ipc.CoreAPI
|
||||
http *http.Client
|
||||
@@ -95,12 +149,6 @@ type poller struct {
|
||||
pass string
|
||||
}
|
||||
|
||||
type icalEvent struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
summary string
|
||||
}
|
||||
|
||||
func (p *poller) pollOnce(ctx context.Context) {
|
||||
now := time.Now()
|
||||
events, err := p.fetchEvents(ctx, now)
|
||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
busy := false
|
||||
for _, e := range events {
|
||||
if !now.Before(e.start) && now.Before(e.end) {
|
||||
busy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
busyVal := "false"
|
||||
if busy {
|
||||
if calendar.Busy(events, now) {
|
||||
busyVal = "true"
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write per-event facts (one per event, keyed by event summary + start).
|
||||
// Write per-event facts (one per event, keyed by day + event summary).
|
||||
// This lets the note RAG path answer "what's on my calendar" without
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
|
||||
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||
}
|
||||
|
||||
return parseICal(body, now), nil
|
||||
}
|
||||
|
||||
// parseICal scans iCal text for VEVENT components. Returns events that overlap
|
||||
// with today (UTC day boundaries) to keep the response manageable.
|
||||
func parseICal(body []byte, now time.Time) []icalEvent {
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
todayEnd := todayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []icalEvent
|
||||
text := string(body)
|
||||
for {
|
||||
veventStart := strings.Index(text, "BEGIN:VEVENT")
|
||||
if veventStart < 0 {
|
||||
break
|
||||
}
|
||||
text = text[veventStart+len("BEGIN:VEVENT"):]
|
||||
veventEnd := strings.Index(text, "END:VEVENT")
|
||||
if veventEnd < 0 {
|
||||
break
|
||||
}
|
||||
block := text[:veventEnd]
|
||||
text = text[veventEnd+len("END:VEVENT"):]
|
||||
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
// Only keep events overlapping today.
|
||||
if e.end.After(todayStart) && e.start.Before(todayEnd) {
|
||||
events = append(events, *e)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// parseVEVENT extracts start, end, summary from a VEVENT block.
|
||||
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
|
||||
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
|
||||
// parse failures.
|
||||
func parseVEVENT(block string) *icalEvent {
|
||||
var e icalEvent
|
||||
lines := strings.Split(block, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.start = t
|
||||
}
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.end = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
if idx := strings.Index(line, ":"); idx >= 0 {
|
||||
e.summary = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.start.IsZero() || e.end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &e
|
||||
}
|
||||
|
||||
// parseDT parses a DTSTART/DTEND value. Supports:
|
||||
// - UTC: DTEND:20260703T100000Z
|
||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
|
||||
func parseDT(line string) (time.Time, bool) {
|
||||
if strings.Contains(line, "VALUE=DATE:") {
|
||||
return time.Time{}, false // all-day, skip
|
||||
}
|
||||
idx := strings.LastIndex(line, ":")
|
||||
if idx < 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
val := line[idx+1:]
|
||||
val = strings.TrimSuffix(val, "Z")
|
||||
|
||||
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
|
||||
if strings.HasSuffix(line, "Z") {
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// Local time — treat as UTC for simplicity (CalDAV server and poller
|
||||
// run in the same timezone; the gate only needs busy/not-busy accuracy).
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
return calendar.ParseICalDay(body, now), nil
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == val:
|
||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
Confidence: confidence,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
|
||||
+5
-164
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
||||
return int64(len(f.writeLog)), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseICal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
body := []byte(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T090000Z
|
||||
DTEND:20260703T100000Z
|
||||
SUMMARY:Morning standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T140000Z
|
||||
DTEND:20260703T150000Z
|
||||
SUMMARY:Team sync
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260702T140000Z
|
||||
DTEND:20260702T150000Z
|
||||
SUMMARY:Yesterday retro
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260704T090000Z
|
||||
DTEND:20260704T100000Z
|
||||
SUMMARY:Tomorrow standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20260704
|
||||
DTEND;VALUE=DATE:20260705
|
||||
SUMMARY:All-day event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`)
|
||||
|
||||
events := parseICal(body, now)
|
||||
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
||||
}
|
||||
|
||||
// Morning standup — overlaps today.
|
||||
if events[0].summary != "Morning standup" {
|
||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
||||
}
|
||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
||||
if !events[0].start.Equal(wantStart0) {
|
||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
||||
}
|
||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
||||
if !events[0].end.Equal(wantEnd0) {
|
||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
||||
}
|
||||
|
||||
// Team sync — overlaps today.
|
||||
if events[1].summary != "Team sync" {
|
||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
||||
}
|
||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !events[1].start.Equal(wantStart1) {
|
||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
||||
}
|
||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
||||
if !events[1].end.Equal(wantEnd1) {
|
||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
t.Fatal("expected non-nil icalEvent")
|
||||
}
|
||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
if !e.start.Equal(wantStart) {
|
||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
||||
}
|
||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !e.end.Equal(wantEnd) {
|
||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
||||
}
|
||||
if e.summary != "Stand up meeting" {
|
||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
||||
}
|
||||
|
||||
// All-day event (VALUE=DATE) → nil.
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
||||
t.Error("expected nil for all-day event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "UTC",
|
||||
line: "DTEND:20260703T100000Z",
|
||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "local time",
|
||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "all-day",
|
||||
line: "DTSTART;VALUE=DATE:20260703",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
line: "DTSTART:garbage",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseDT(tt.line)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !got.Equal(tt.want) {
|
||||
t.Errorf("got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Stand up meeting", "Stand-up-meeting"},
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := safeKey(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||
//
|
||||
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
|
||||
// renderer reads the pending reminders out of core and publishes each one as a
|
||||
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
|
||||
// read back from that collection, and losing it costs nothing — the next tick
|
||||
// rebuilds it.
|
||||
//
|
||||
// It structurally cannot write to a calendar maven only reads. The URL comes
|
||||
// from its own flag, checked at startup against every read URL (see
|
||||
// run in main.go), and the only paths it ever addresses carry
|
||||
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
|
||||
// only touch resources it created.
|
||||
type renderer struct {
|
||||
core ipc.CoreAPI
|
||||
http *http.Client
|
||||
url string
|
||||
user string
|
||||
pass string
|
||||
dur time.Duration
|
||||
|
||||
// published maps reminder id → the body last successfully PUT, so an
|
||||
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||
// re-publishes every reminder once, which is idempotent.
|
||||
published map[int64]string
|
||||
}
|
||||
|
||||
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||
return &renderer{
|
||||
core: core,
|
||||
http: hc,
|
||||
url: strings.TrimRight(url, "/"),
|
||||
user: user,
|
||||
pass: pass,
|
||||
dur: dur,
|
||||
published: make(map[int64]string),
|
||||
}
|
||||
}
|
||||
|
||||
// renderOnce publishes every pending reminder and withdraws the ones that are
|
||||
// no longer pending. Errors are logged and skipped: a calendar maven cannot
|
||||
// reach must never break the reminder itself, which lives in sqlite.
|
||||
func (r *renderer) renderOnce(ctx context.Context) {
|
||||
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
|
||||
if err != nil {
|
||||
log.Printf("mavcaldav: list reminders: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
live := make(map[int64]bool, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
if rem.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
live[rem.ID] = true
|
||||
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
|
||||
body := calendar.RenderICal([]calendar.Event{e})
|
||||
if r.published[rem.ID] == body {
|
||||
continue
|
||||
}
|
||||
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
|
||||
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
|
||||
continue
|
||||
}
|
||||
r.published[rem.ID] = body
|
||||
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||
}
|
||||
|
||||
for id := range r.published {
|
||||
if live[id] {
|
||||
continue
|
||||
}
|
||||
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
delete(r.published, id)
|
||||
log.Printf("mavcaldav: withdrew reminder %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||
// anything a calendar view is useful for.
|
||||
const renderMaxReminders = 200
|
||||
|
||||
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
|
||||
// worth showing; FireTs is the original statement.
|
||||
func fireTime(rem ipc.Reminder) time.Time {
|
||||
if !rem.NextFireTs.IsZero() {
|
||||
return rem.NextFireTs
|
||||
}
|
||||
return rem.FireTs
|
||||
}
|
||||
|
||||
func (r *renderer) put(ctx context.Context, name, body string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
return r.do(req, name)
|
||||
}
|
||||
|
||||
func (r *renderer) delete(ctx context.Context, name string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
return r.do(req, name)
|
||||
}
|
||||
|
||||
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
|
||||
// a resource that is already gone is the state the caller wanted.
|
||||
func (r *renderer) do(req *http.Request, name string) error {
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return nil
|
||||
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// reminderCore is a fakeCore that also answers ListReminders.
|
||||
type reminderCore struct {
|
||||
fakeCore
|
||||
reminders []ipc.Reminder
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
|
||||
if c.listErr != nil {
|
||||
return nil, c.listErr
|
||||
}
|
||||
return c.reminders, nil
|
||||
}
|
||||
|
||||
// calSrv records what a CalDAV collection received.
|
||||
type calSrv struct {
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
status int
|
||||
*httptest.Server
|
||||
}
|
||||
|
||||
func newCalSrv() *calSrv {
|
||||
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||
case http.MethodDelete:
|
||||
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||
}
|
||||
w.WriteHeader(s.status)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *calSrv) putCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.puts)
|
||||
}
|
||||
|
||||
func TestRenderOncePublishesPendingReminders(t *testing.T) {
|
||||
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
|
||||
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
|
||||
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
body, ok := srv.puts["maven-reminder-7.ics"]
|
||||
n := len(srv.puts)
|
||||
srv.mu.Unlock()
|
||||
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("pending reminder 7 was not published")
|
||||
}
|
||||
if !strings.Contains(body, "SUMMARY:позвонить маме") {
|
||||
t.Errorf("payload missing from rendered body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "UID:maven-reminder-7") {
|
||||
t.Errorf("UID missing from rendered body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceSkipsUnchanged(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.putCount(); got != 1 {
|
||||
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
core.reminders[0].Status = "fired"
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
dels := append([]string(nil), srv.dels...)
|
||||
srv.mu.Unlock()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
|
||||
}
|
||||
if len(r.published) != 0 {
|
||||
t.Errorf("published map still holds %v", r.published)
|
||||
}
|
||||
}
|
||||
|
||||
// A calendar maven cannot reach must never break anything: sqlite is canonical.
|
||||
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
srv.status = http.StatusInternalServerError
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if len(r.published) != 0 {
|
||||
t.Error("a failed PUT must not be recorded as published, or it never retries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
|
||||
core := &reminderCore{reminders: []ipc.Reminder{{
|
||||
ID: 3,
|
||||
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||
NextFireTs: next,
|
||||
Payload: "зарядка",
|
||||
Status: "pending",
|
||||
Cron: "0 7 * * *",
|
||||
}}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
body := srv.puts["maven-reminder-3.ics"]
|
||||
srv.mu.Unlock()
|
||||
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
|
||||
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||
read := "http://localhost:5232/kami/personal"
|
||||
if err := checkRenderTarget(read, ""); err != nil {
|
||||
t.Fatalf("rendering off must be fine: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, read); err == nil {
|
||||
t.Error("rendering into the read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||
t.Error("a trailing slash must not defeat the check")
|
||||
}
|
||||
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||
t.Error("case must not defeat the check")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// Money questions (Vikunja #125).
|
||||
//
|
||||
// This is the whole read side: mavpoll holds the zenmoney token and writes
|
||||
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
|
||||
// Core never sees the token, never calls zenmoney, and has no rule on these
|
||||
// keys — a total is never a reason for Maven to speak first. Maven is not a
|
||||
// nag, least of all about his money.
|
||||
//
|
||||
// Nothing here can reach the external search capability: the figures are read
|
||||
// from the store and rendered locally, and his financial data is never search
|
||||
// input.
|
||||
|
||||
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
|
||||
//
|
||||
// Answers only from the latest fact the poller wrote. Three honest outcomes and
|
||||
// no fourth: the figure, "the fact is old and here is its date", or "money
|
||||
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||
// its own — an invented number about his money is the worst thing this could do.
|
||||
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||
if window == router.MoneyToday {
|
||||
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||
}
|
||||
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||
if err != nil {
|
||||
// No fact at all is the normal state when the capability is off. Claim
|
||||
// the turn anyway: falling through to recall would answer a question
|
||||
// about money with whatever note happens to be nearest.
|
||||
if !isNoFactErr(err) {
|
||||
log.Printf("voice: money fact: %v", err)
|
||||
}
|
||||
return "я не отслеживаю траты — не подключено.", true
|
||||
}
|
||||
val, err := zenmoney.ParseFactValue(fact.Value)
|
||||
if err != nil {
|
||||
log.Printf("voice: money fact: decode: %v", err)
|
||||
return "не получилось прочитать траты.", true
|
||||
}
|
||||
reply := val.FormatRU(phrase)
|
||||
if reply == "" {
|
||||
return "по тратам пока нечего сказать.", true
|
||||
}
|
||||
// A stale fact is reported as stale rather than spoken as today's number.
|
||||
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||
func isNoFactErr(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
||||
// which is the assertion that answering a money question costs no model call
|
||||
// and reaches no network.
|
||||
type moneyAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
fact ipc.Fact
|
||||
err error
|
||||
gotKey string
|
||||
gotSrc string
|
||||
callCnt int
|
||||
}
|
||||
|
||||
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
a.gotKey, a.gotSrc = key, source
|
||||
a.callCnt++
|
||||
return a.fact, a.err
|
||||
}
|
||||
|
||||
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
||||
|
||||
func moneyFact(ts time.Time, val string) ipc.Fact {
|
||||
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
||||
}
|
||||
|
||||
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the money source must claim a money question")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
||||
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
||||
}
|
||||
if !strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q, want the exact figure", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "в этом месяце") {
|
||||
t.Errorf("reply = %q, want the window named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
}); !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentToday {
|
||||
t.Errorf("key = %q, want today's", api.gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The capability is off unless configured, and then there is no fact. She says
|
||||
// so instead of letting the recall pass answer a money question from a note.
|
||||
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
||||
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "не подключено") {
|
||||
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
||||
}
|
||||
// No number of any kind in that answer.
|
||||
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
||||
if strings.Contains(reply, d) {
|
||||
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fact older than the staleness bound is dated rather than spoken as if it
|
||||
// were current: the poller can be down, and last week's total presented as
|
||||
// today's is a lie by omission.
|
||||
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
||||
old := moneyNow().Add(-72 * time.Hour)
|
||||
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q, want the stale fact dated", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the money source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a non-money question must not read the money facts")
|
||||
}
|
||||
}
|
||||
|
||||
// Money must be answered before the recall sources, or a question about
|
||||
// spending gets answered by the nearest note.
|
||||
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||
moneyAt, notesAt := -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "money":
|
||||
moneyAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if moneyAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
||||
}
|
||||
if moneyAt > notesAt {
|
||||
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
|
||||
+219
-3
@@ -5,11 +5,15 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
@@ -45,11 +49,43 @@ type querySource struct {
|
||||
// line here plus its method; where you put the line is the whole decision.
|
||||
var querySources = []querySource{
|
||||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||
// listing would otherwise swallow it.
|
||||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{"habits", (*reactiveHandler).queryHabits},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{"tasks", (*reactiveHandler).queryTasks},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// 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
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{"money", (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// 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
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{"feeds", (*reactiveHandler).queryFeeds},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
{"memory", (*reactiveHandler).queryMemory},
|
||||
{"notes", (*reactiveHandler).queryNotes},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): local sources first. The model, his own notes and
|
||||
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||||
// ZIMs all get their turn before anything touches the network. This source
|
||||
// only claims a turn where he named a URL out loud, so it never competes
|
||||
// with a local answer.
|
||||
{"web", (*reactiveHandler).queryWeb},
|
||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||
}
|
||||
|
||||
@@ -89,6 +125,132 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
return "", false
|
||||
}
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and any morning checklist still outstanding.
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||
// that means "from now on" rather than "the whole day".
|
||||
func isRestOfDayQuery(text string) bool {
|
||||
s := strings.ToLower(text)
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
// answer out of the fact log rather than asking the model to summarise a life:
|
||||
// see internal/memory/behavior.go for why nothing here is generated.
|
||||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||
}
|
||||
profile := memory.BuildProfile(obs, h.now())
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// feedNoteWindow — how many recent notes are scanned for feed items, and
|
||||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||
// top of the pile, she does not recite a river.
|
||||
const (
|
||||
feedNoteWindow = 200
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||||
continue
|
||||
}
|
||||
if !router.CategoryMatches(n.Text, q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary and link; she reads the title.
|
||||
title := n.Text
|
||||
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||||
title = title[:i]
|
||||
}
|
||||
picked = append(picked, strings.TrimSpace(title))
|
||||
if len(picked) == feedReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
// 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.
|
||||
@@ -102,12 +264,15 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return "не получилось проверить календарь.", true
|
||||
}
|
||||
values := make([]string, len(events))
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
// and gets hedged; a CalDAV read is recited plainly.
|
||||
entries := make([]router.CalendarEntry, len(events))
|
||||
for i, e := range events {
|
||||
values[i] = e.Value
|
||||
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||||
}
|
||||
var f router.CalendarEventFormatter
|
||||
return f.Format(values, date), true
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
@@ -214,6 +379,57 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||||
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||||
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||||
// travels with it.
|
||||
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
link, ok := router.FirstURL(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// Claim rather than fall through: he asked about a specific page, and
|
||||
// letting the model answer from the URL's spelling alone is how a small
|
||||
// model invents a page's contents.
|
||||
return "я не читаю страницы — это не настроено.", true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return "страница открылась, но читать там нечего.", true
|
||||
}
|
||||
// The page is handed to the phraser the same way a note is: as context for
|
||||
// the question he actually asked. She answers the question, she does not
|
||||
// recite the page.
|
||||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||
if perr != nil {
|
||||
log.Printf("voice: web: phrase: %v", perr)
|
||||
}
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// queryGeneral — general knowledge from the phraser, the last source before
|
||||
// giving up. It always claims: either the model answers or Maven says she
|
||||
// doesn't know.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
// Task capture on the voice/chat path (Vikunja #130).
|
||||
//
|
||||
// Two halves, both deliberately small:
|
||||
//
|
||||
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||||
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||||
// store instead of the note store. Anything without an explicit marker is
|
||||
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||||
// not become a task.
|
||||
// - queryTasks is a query source that reads the list back.
|
||||
//
|
||||
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||||
// rule reads the table.
|
||||
|
||||
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||||
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||||
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
cap, ok := router.ParseTaskCapture(dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||
Text: cap.Text,
|
||||
Source: "tap:voice",
|
||||
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
|
||||
Ts: h.now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return "записала: " + cap.Text, true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
//
|
||||
// Reads the live set and recites it in priority order (Vikunja #129). The order
|
||||
// is computed by internal/tasks from what he told her — deadlines, the urgency
|
||||
// he stated, how long a task has been sitting — never asked of the model. The
|
||||
// rendering is the package's too, so the spoken list and the /tasks page can
|
||||
// never disagree about what comes first.
|
||||
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", true
|
||||
}
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
|
||||
// no store, and therefore no cgo) dependency — the same posture as
|
||||
// internal/morning and internal/memory.
|
||||
func taskItems(ts []ipc.Task) []tasks.Item {
|
||||
out := make([]tasks.Item, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// taskAPI answers only the three task methods; every other call is
|
||||
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||
// particular no embedder, so a filed task costs no model call.
|
||||
type taskAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
capErr error
|
||||
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
a.captured = append(a.captured, req)
|
||||
if a.capErr != nil {
|
||||
return ipc.CaptureTaskResp{}, a.capErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
|
||||
}
|
||||
|
||||
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
a.listArg = status
|
||||
return a.tasks, a.listErr
|
||||
}
|
||||
|
||||
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: taskNow}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if len(api.captured) != 1 {
|
||||
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "купить молоко" {
|
||||
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||
}
|
||||
if got.Source != "tap:voice" {
|
||||
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||
}
|
||||
if !got.Ts.Equal(taskNow()) {
|
||||
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A note is still a note: capture only fires on an explicit marker, so
|
||||
// ordinary recall is untouched.
|
||||
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||
t.Errorf("%q was captured as a task", u)
|
||||
}
|
||||
}
|
||||
if len(api.captured) != 0 {
|
||||
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{created: false})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||
if !ok {
|
||||
t.Fatal("expected the capture path to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "уже") {
|
||||
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the task source must claim a task-list question")
|
||||
}
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want both tasks", reply)
|
||||
}
|
||||
// The candidate must be named as unconfirmed, not recited as his work.
|
||||
openIdx := strings.Index(reply, "купить молоко")
|
||||
candIdx := strings.Index(reply, "продлить страховку")
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||
}); !ok {
|
||||
t.Fatal("expected a capture")
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "оплатить интернет" {
|
||||
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||
}
|
||||
if got.Weight == 0 {
|
||||
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||
// comes before undated work.
|
||||
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||
due := taskNow()
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the dated task first", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "сегодня") {
|
||||
t.Errorf("reply = %q, want the reason named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksEmptyList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{})
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the task source to claim it")
|
||||
}
|
||||
if reply != "задач нет." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the task source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.listArg != "" {
|
||||
t.Error("a non-task question must not read the task list")
|
||||
}
|
||||
}
|
||||
|
||||
// The chain must reach the task source before the recall sources, or "что мне
|
||||
// нужно сделать?" gets answered by whatever note is nearest.
|
||||
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||
var tasksAt, notesAt = -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "tasks":
|
||||
tasksAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if tasksAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||
}
|
||||
if tasksAt > notesAt {
|
||||
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
|
||||
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
|
||||
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
|
||||
// scheduled watches, and the fact-backed dedup hashes.
|
||||
//
|
||||
// Two paths, one config block, both off unless configured:
|
||||
//
|
||||
// - ON DEMAND — he names a URL out loud and she reads it. That is the
|
||||
// `queryWeb` source in actions_query.go, LAST in the chain: after his
|
||||
// memory, after the notes, and (once Kiwix is wired into the chain) after
|
||||
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
|
||||
// a URL in someone's log, so it goes last.
|
||||
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
|
||||
// text changed is written as a note. It does NOT announce itself. Same rule
|
||||
// as the feed poller: notes, never nudges.
|
||||
//
|
||||
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
|
||||
// the history, and internal/crawl has no access to the store at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// newCrawler builds the crawler from the `crawl` block, or returns nil when
|
||||
// there is none. Every caller checks for nil, and nil means no page is ever
|
||||
// fetched.
|
||||
func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
|
||||
hosts := append([]string(nil), cc.AllowHosts...)
|
||||
// A watched page's own host is always reachable; otherwise an allowlist and
|
||||
// a watch list would have to be kept in sync by hand.
|
||||
for _, w := range cc.Watches {
|
||||
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
// An allowlist plus on-demand is a contradiction worth logging rather than
|
||||
// silently resolving: he asked for arbitrary pages AND for a fixed list.
|
||||
// The allowlist wins, because it is the narrower instruction.
|
||||
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
|
||||
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
|
||||
}
|
||||
ua := cc.UserAgent
|
||||
if ua == "" {
|
||||
ua = webfetch.DefaultUserAgent
|
||||
}
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
DenyHosts: cc.DenyHosts,
|
||||
Timeout: time.Duration(cc.Timeout),
|
||||
MaxBytes: cc.MaxBytes,
|
||||
UserAgent: ua,
|
||||
})
|
||||
// The user-agent handed to the crawler is the one the fetcher sends: obeying
|
||||
// robots rules written for a different name would be a lie.
|
||||
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
|
||||
UserAgent: ua,
|
||||
MaxRunes: cc.MaxRunes,
|
||||
})
|
||||
}
|
||||
|
||||
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
|
||||
// reading is off. The scheduled watches can be on while this is off: reading a
|
||||
// fixed list of pages on a timer and reading whatever URL is in an utterance are
|
||||
// different permissions, and the config keeps them separate.
|
||||
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||
return nil
|
||||
}
|
||||
return newCrawler(cfg)
|
||||
}
|
||||
|
||||
// crawlWorker — ticker + watcher for the scheduled half.
|
||||
type crawlWorker struct {
|
||||
watcher *crawl.Watcher
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
|
||||
// is the watcher's business.
|
||||
const crawlTickInterval = 15 * time.Minute
|
||||
|
||||
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
|
||||
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
|
||||
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
|
||||
return nil
|
||||
}
|
||||
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
|
||||
for _, w := range cfg.Crawl.Watches {
|
||||
watches = append(watches, crawl.WatchConfig{
|
||||
Name: w.Name,
|
||||
URL: w.URL,
|
||||
Interval: time.Duration(w.Interval),
|
||||
})
|
||||
}
|
||||
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
|
||||
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
|
||||
if watcher == nil {
|
||||
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
|
||||
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
|
||||
}
|
||||
|
||||
// run checks what is due until ctx is canceled. The first round runs
|
||||
// immediately; it writes notes only, so an early round startles nobody.
|
||||
func (w *crawlWorker) run(ctx context.Context) {
|
||||
w.watcher.CheckDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.watcher.CheckDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
|
||||
// net/http out of the crawler package.
|
||||
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factHashes stores each watch's last content hash as a config fact, so a
|
||||
// restart does not re-note an unchanged page. Same mechanism the feed reader
|
||||
// uses for its marks, and inspectable on /dash.
|
||||
type factHashes struct{ api ipc.CoreAPI }
|
||||
|
||||
func hashKey(name string) string { return "crawl:hash:" + name }
|
||||
|
||||
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
|
||||
f, err := h.api.LatestFact(ctx, hashKey(name))
|
||||
if err != nil {
|
||||
// No hash yet is not an error: the watcher treats "" as "never read".
|
||||
return "", nil
|
||||
}
|
||||
return f.Value, nil
|
||||
}
|
||||
|
||||
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
|
||||
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: hashKey(name),
|
||||
Value: hash,
|
||||
Source: "poll:crawl",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
|
||||
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
|
||||
// asymmetric).
|
||||
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// The default config reads nothing. This is the whole "off unless configured"
|
||||
// contract for the crawler, asserted at the wiring level rather than trusted.
|
||||
func TestCrawlOffByDefault(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
if c := newCrawler(cfg); c != nil {
|
||||
t.Error("newCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if c := onDemandCrawler(cfg); c != nil {
|
||||
t.Error("onDemandCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
|
||||
t.Error("newCrawlWorker with no crawl block returned a worker")
|
||||
}
|
||||
// Watches configured but on_demand off ⇒ the answer path still reads
|
||||
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
|
||||
withWatch := &config.Config{Crawl: &config.CrawlConfig{
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
|
||||
}}
|
||||
if c := onDemandCrawler(withWatch); c != nil {
|
||||
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
|
||||
}
|
||||
if c := newCrawler(withWatch); c == nil {
|
||||
t.Error("newCrawler returned nil for a configured watch")
|
||||
}
|
||||
}
|
||||
|
||||
// The wired fetcher must refuse a private address, because the crawler on this
|
||||
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
|
||||
// this asserts the daemon actually wires it.
|
||||
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<html><body>secret</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
|
||||
if c == nil {
|
||||
t.Fatal("newCrawler returned nil for an on-demand config")
|
||||
}
|
||||
if _, err := c.Page(context.Background(), srv.URL); err == nil {
|
||||
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactHashesRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
h := &factHashes{api: ipc.NewStoreAPI(st)}
|
||||
|
||||
got, err := h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash on a fresh store: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("LastHash = %q, want empty for a never-read page", got)
|
||||
}
|
||||
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
|
||||
t.Fatalf("SetHash: %v", err)
|
||||
}
|
||||
got, err = h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash: %v", err)
|
||||
}
|
||||
if got != "deadbeef" {
|
||||
t.Errorf("LastHash = %q, want deadbeef", got)
|
||||
}
|
||||
if key := hashKey("page"); key != "crawl:hash:page" {
|
||||
t.Errorf("hashKey = %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
|
||||
// exercised without a network or an allowlist.
|
||||
type stubCrawlFetcher struct{ body, ctype string }
|
||||
|
||||
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
ct := s.ctype
|
||||
if ct == "" {
|
||||
ct = "text/html"
|
||||
}
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
|
||||
}
|
||||
|
||||
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
|
||||
return &reactiveHandler{
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
crawler: c,
|
||||
}
|
||||
}
|
||||
|
||||
func askWeb(h *reactiveHandler, q string) (string, bool) {
|
||||
return h.queryWeb(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
|
||||
if reply, ok := askWeb(h, "почему небо синее?"); ok {
|
||||
t.Errorf("the web source claimed a question with no URL: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Not configured is said out loud rather than falling through, so a small model
|
||||
// never invents a page's contents from its URL.
|
||||
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не настроено") {
|
||||
t.Errorf("reply = %q, want the not-configured answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebReadsThePage(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "текст страницы") {
|
||||
t.Errorf("reply = %q, want the page text read back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "\x00\x01binary", ctype: "application/octet-stream",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||
// reporting a generic failure.
|
||||
func TestQueryWebObeysRobots(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
type robotsDenyFetcher struct{}
|
||||
|
||||
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain",
|
||||
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
// exactly the assertion that the plan source needs nothing else.
|
||||
type planAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
plan ipc.DayPlan
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
||||
a.calls++
|
||||
if a.err != nil {
|
||||
return ipc.DayPlan{}, a.err
|
||||
}
|
||||
return a.plan, nil
|
||||
}
|
||||
|
||||
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func samplePlan() ipc.DayPlan {
|
||||
day := planDay()
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||
return ipc.DayPlan{
|
||||
Date: mid,
|
||||
Items: []ipc.DayPlanItem{
|
||||
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
|
||||
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
|
||||
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
|
||||
},
|
||||
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
|
||||
}
|
||||
}
|
||||
|
||||
func planHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: planDay}
|
||||
}
|
||||
|
||||
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
|
||||
api := &planAPI{plan: samplePlan()}
|
||||
h := planHandler(api)
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the plan source must claim a plan question")
|
||||
}
|
||||
if reply != api.plan.Spoken {
|
||||
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" is the rest of the day, not the whole day: what has already
|
||||
// happened is not a plan.
|
||||
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
h := planHandler(&planAPI{plan: samplePlan()})
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, "Standup") {
|
||||
t.Errorf("a passed item must not be read back: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Errorf("the rest of the day is missing: %q", reply)
|
||||
}
|
||||
// Provenance survives the trim.
|
||||
if !strings.Contains(reply, "похоже,") {
|
||||
t.Errorf("a relayed event must stay hedged: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
} {
|
||||
api := &planAPI{plan: samplePlan()}
|
||||
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
if ok {
|
||||
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q hit the core for a plan it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
h := planHandler(&planAPI{err: errors.New("socket closed")})
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The day plan must sit before the calendar listing: both match "…на сегодня",
|
||||
// and the more specific matcher has to get first refusal (see #373 for what
|
||||
// happens when the order is wrong).
|
||||
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
plan, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "day-plan":
|
||||
plan = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if plan < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
|
||||
}
|
||||
if plan > cal {
|
||||
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||
d := now
|
||||
for d.Weekday() != time.Tuesday {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
var out []ipc.Fact
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, ipc.Fact{
|
||||
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Kind: "self",
|
||||
Key: key,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||
now := planDay() // a Monday
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||
now := planDay()
|
||||
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||
api := &habitAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
}); ok {
|
||||
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both specific sources must precede the calendar listing, which matches any
|
||||
// utterance naming a day.
|
||||
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
habits, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "habits":
|
||||
habits = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if habits < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||
}
|
||||
if habits > cal {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
|
||||
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
|
||||
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
|
||||
// two adapters that let a pure package talk to the store.
|
||||
//
|
||||
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
|
||||
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
|
||||
// are separate processes is that core must never see it. A feed URL is public,
|
||||
// there is no secret to isolate, and a whole extra binary and compose service
|
||||
// would buy nothing. The other half of the mavpoll precedent — off unless
|
||||
// configured — is kept: no `feeds` block, no poller, no outbound request.
|
||||
//
|
||||
// It is its own goroutine, not a step on the tick: the tick has a delivery
|
||||
// deadline behind it, and a feed read is a network round-trip that nobody is
|
||||
// waiting on.
|
||||
//
|
||||
// Nothing here dispatches. A feed that announced itself would be a nag, so the
|
||||
// only output is notes with source "rss:<feed>", which the answer path reads
|
||||
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// feedWorker — ticker + poller.
|
||||
type feedWorker struct {
|
||||
poller *rss.Poller
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
|
||||
// cadence is the poller's business; this is just the granularity.
|
||||
const feedTickInterval = 5 * time.Minute
|
||||
|
||||
// newFeedWorker wires feed reading, or returns nil when it must not run:
|
||||
// no `feeds` block (the normal case), or nothing valid in it. Every caller
|
||||
// checks for nil.
|
||||
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
|
||||
if cfg.Feeds == nil {
|
||||
return nil
|
||||
}
|
||||
fc := cfg.Feeds
|
||||
|
||||
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
|
||||
hosts := append([]string(nil), fc.AllowHosts...)
|
||||
for _, s := range fc.Sources {
|
||||
feeds = append(feeds, rss.FeedConfig{
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
Category: s.Category,
|
||||
Interval: time.Duration(s.Interval),
|
||||
Include: s.Include,
|
||||
Exclude: s.Exclude,
|
||||
})
|
||||
// Each configured feed's own host is allowed. The allowlist is then
|
||||
// exactly "the feeds he asked for", so a redirect off to somewhere else
|
||||
// is refused by the fetcher rather than followed.
|
||||
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
Timeout: time.Duration(fc.Timeout),
|
||||
MaxBytes: fc.MaxBytes,
|
||||
})
|
||||
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
|
||||
embedderFor(emb), nil, rss.Config{
|
||||
DefaultInterval: time.Duration(fc.PollInterval),
|
||||
MaxItems: fc.MaxItems,
|
||||
MaxAge: time.Duration(fc.MaxAge),
|
||||
})
|
||||
if poller == nil {
|
||||
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
|
||||
return &feedWorker{poller: poller, interval: feedTickInterval}
|
||||
}
|
||||
|
||||
// run polls what is due until ctx is canceled. The first round runs immediately
|
||||
// so a restart does not blind her for the first interval; it writes notes only,
|
||||
// so an early round cannot startle anyone.
|
||||
func (w *feedWorker) run(ctx context.Context) {
|
||||
w.poller.PollDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.poller.PollDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
|
||||
// Feed notes are embedded with the SAME model the rest of the store uses, or not
|
||||
// at all; a second embedder would write vectors nothing can search.
|
||||
func embedderOf(w *voiceWiring) router.Embedder {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rss.Body{Bytes: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factMarks stores "how far this feed was read" as a config fact, the same
|
||||
// mechanism the plan named and the same one the pattern tick uses for its own
|
||||
// bookkeeping. Durable, inspectable on /dash, and cheap.
|
||||
type factMarks struct{ api ipc.CoreAPI }
|
||||
|
||||
func markKey(feed string) string { return "rss:latest:" + feed }
|
||||
|
||||
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
|
||||
f, err := m.api.LatestFact(ctx, markKey(feed))
|
||||
if err != nil {
|
||||
// No mark yet is not an error worth propagating: the poller treats a
|
||||
// zero time as a cold start.
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, f.Value)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
|
||||
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: markKey(feed),
|
||||
Value: at.UTC().Format(time.RFC3339),
|
||||
Source: "poll:rss",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
|
||||
// there is none — a note without a vector is still a note the recent-notes path
|
||||
// can read.
|
||||
//
|
||||
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Getting this backwards makes the item unfindable by
|
||||
// the question that should have matched it.
|
||||
func embedderFor(emb router.Embedder) rss.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
|
||||
type passageEmbedder struct{ e router.Embedder }
|
||||
|
||||
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, p.e, text)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// buildFeedHandler — a handler with the given feed notes already stored. No
|
||||
// embedder: the feed source answers from recent notes by source, which is what
|
||||
// makes it work for notes written before an embedder existed.
|
||||
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
for i, n := range notes {
|
||||
ts := now.Add(time.Duration(i) * time.Minute)
|
||||
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
||||
t.Helper()
|
||||
return h.queryFeeds(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
||||
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok {
|
||||
t.Fatal("the feed source did not claim the question")
|
||||
}
|
||||
if !strings.Contains(reply, "уязвимость") {
|
||||
t.Errorf("reply = %q, want the headline", reply)
|
||||
}
|
||||
if strings.Contains(reply, "он сам сказал") {
|
||||
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
||||
}
|
||||
// She reads the headline, not the summary and not the URL.
|
||||
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
||||
t.Errorf("reply = %q, want the title line only", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsByCategory(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
||||
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
||||
if !ok {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового?")
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
h := buildFeedHandler(t, true)
|
||||
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||
// fact round-trip is worth a test of its own.
|
||||
func TestFactMarksRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
||||
ctx := context.Background()
|
||||
|
||||
at, err := m.LastMark(ctx, "habr")
|
||||
if err != nil || !at.IsZero() {
|
||||
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
||||
}
|
||||
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
||||
if err := m.SetMark(ctx, "habr", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.LastMark(ctx, "habr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("mark = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
||||
// worker ⇒ no outbound request is possible.
|
||||
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
||||
t.Fatal("a config with no feeds block wired a feed worker")
|
||||
}
|
||||
// An empty sources list is normalised to "off" by config.Load; the worker
|
||||
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
||||
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
||||
t.Fatal("an empty sources list wired a feed worker")
|
||||
}
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(api, nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("a configured feed did not wire a worker")
|
||||
}
|
||||
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
||||
t.Fatalf("feeds = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
||||
// nothing else — the crawler's SSRF guards are only worth as much as the
|
||||
// allowlist handed to them.
|
||||
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("no worker")
|
||||
}
|
||||
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
||||
// itself must fail rather than be read.
|
||||
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
||||
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
||||
}, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// mavend/mail.go — core's half of the email reader (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md).
|
||||
//
|
||||
// The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox
|
||||
// and converts messages to plaintext; it hands each message to core over
|
||||
// ipc.MethodIngestMail. Core runs the extraction on the resident model —
|
||||
// llama-server lives in this process, spawned by the phraser — and writes what
|
||||
// comes back through the one task intake seam.
|
||||
//
|
||||
// What this file may produce is exactly one thing: rows in `tasks` with status
|
||||
// "candidate". No fact, no reminder, no note, no nudge, no calendar event. A
|
||||
// 1.7B misreading a mail can therefore put a wrong line on a review page and
|
||||
// nothing else; it can never make Maven speak, and it can never make her
|
||||
// recite something out of an advert as true.
|
||||
//
|
||||
// Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC
|
||||
// method does not exist; no llama-server phraser ⇒ same. A reader pointed at a
|
||||
// core that is not set up for mail gets ErrUnknownMethod rather than silence.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// evidenceMaxChars — how much of the subject line is kept as a candidate's
|
||||
// evidence. Enough to recognise the mail on /tasks, not enough to turn the task
|
||||
// list into a copy of his mailbox.
|
||||
const evidenceMaxChars = 160
|
||||
|
||||
// mailIntake — extraction + capture for one message at a time.
|
||||
type mailIntake struct {
|
||||
st *store.Store
|
||||
ex *email.Extractor
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||
// the default. Both preconditions are real:
|
||||
//
|
||||
// - no cfg.Email ⇒ not configured, and a capability is off unless configured;
|
||||
// - no llama-server phraser ⇒ nothing to extract with. There is deliberately
|
||||
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||
// page faster than he could clear it.
|
||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *mailIntake {
|
||||
if cfg.Email == nil {
|
||||
return nil
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
|
||||
return nil
|
||||
}
|
||||
timeout := time.Duration(cfg.Email.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultEmailTimeout
|
||||
}
|
||||
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now}
|
||||
}
|
||||
|
||||
// ingest handles one ipc.MethodIngestMail call.
|
||||
//
|
||||
// Junk and empty messages are answered Skipped without touching the model — the
|
||||
// reader's header filter is what keeps the resident model off newsletters.
|
||||
//
|
||||
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
|
||||
// and the subject as Evidence. CaptureTask dedupes on normalised text among
|
||||
// live rows, so a mailbox re-read after a restart produces Created=0 rather
|
||||
// than a second copy of every task.
|
||||
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
msg := email.Message{
|
||||
UID: req.UID,
|
||||
From: req.From,
|
||||
Subject: req.Subject,
|
||||
Date: req.Date,
|
||||
Body: req.Body,
|
||||
Junk: req.Junk,
|
||||
}
|
||||
if msg.Junk || (msg.Subject == "" && msg.Body == "") {
|
||||
return ipc.IngestMailResp{Skipped: true}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
cands, err := m.ex.Extract(ctx, msg)
|
||||
if err != nil {
|
||||
// The error from internal/email never carries mail text; keep it that way
|
||||
// by not adding the subject here.
|
||||
return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return ipc.IngestMailResp{}, nil
|
||||
}
|
||||
|
||||
source := email.SourcePrefix + req.Mailbox
|
||||
evidence := truncateRunes(req.Subject, evidenceMaxChars)
|
||||
now := m.now()
|
||||
var resp ipc.IngestMailResp
|
||||
for _, c := range cands {
|
||||
t := store.Task{
|
||||
CreatedTs: now,
|
||||
Text: c.Text,
|
||||
Source: source,
|
||||
Evidence: evidence,
|
||||
// The one status this path may ever write. Anything Maven derived from
|
||||
// something she read is a suggestion until he confirms it on /tasks.
|
||||
Status: store.TaskCandidate,
|
||||
}
|
||||
if due, ok := email.ParseDue(c.Due); ok {
|
||||
t.Due = &due
|
||||
}
|
||||
id, created, err := m.st.CaptureTask(ctx, t)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||
}
|
||||
resp.TaskIDs = append(resp.TaskIDs, id)
|
||||
if created {
|
||||
resp.Created++
|
||||
}
|
||||
}
|
||||
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||
// the sender or the task text. Reviewing a candidate is what /tasks is for.
|
||||
log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||
// unlock) so mail behaves the same either way.
|
||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config) {
|
||||
mi := newMailIntake(st, phr, cfg)
|
||||
if mi == nil {
|
||||
return
|
||||
}
|
||||
srv.IngestMailFn = mi.ingest
|
||||
}
|
||||
|
||||
// truncateRunes cuts a string to n runes, marking the cut.
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// mailLLM — a canned extraction reply.
|
||||
type mailLLM struct {
|
||||
reply string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
m.calls++
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
fake := &mailLLM{reply: reply}
|
||||
return &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(fake, 0, nil),
|
||||
timeout: 5 * time.Second,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}, st, fake
|
||||
}
|
||||
|
||||
func ingestReq() ipc.IngestMailReq {
|
||||
return ipc.IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 42,
|
||||
From: "billing@isp.example",
|
||||
Subject: "Счёт за интернет",
|
||||
Body: "Оплатите счёт до 5 августа.",
|
||||
}
|
||||
}
|
||||
|
||||
// The one property that matters: a mail-derived task is a candidate, attributed
|
||||
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
||||
// written.
|
||||
func TestIngestCapturesCandidates(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
||||
t.Fatalf("resp = %+v, want one created task", resp)
|
||||
}
|
||||
tasks, err := st.ListTasks(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
got := tasks[0]
|
||||
if got.Status != store.TaskCandidate {
|
||||
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
||||
}
|
||||
if got.Source != "email:INBOX" {
|
||||
t.Errorf("source = %q, want email:INBOX", got.Source)
|
||||
}
|
||||
if got.Evidence != "Счёт за интернет" {
|
||||
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
||||
}
|
||||
// Nothing else may have been written: no reminder, no fact.
|
||||
rem, err := st.ListReminders(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
||||
// rows, and the intake relies on exactly that.
|
||||
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
||||
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
||||
t.Fatalf("first ingest: %v", err)
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
||||
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Junk = true
|
||||
resp, err := mi.ingest(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if !resp.Skipped || resp.Created != 0 {
|
||||
t.Errorf("resp = %+v, want skipped", resp)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
||||
mi, _, fake := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
||||
if err != nil || !resp.Skipped {
|
||||
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
||||
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("got %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTruncatesEvidence(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Subject = strings.Repeat("щ", 400)
|
||||
if _, err := mi.ingest(context.Background(), req); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
||||
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
||||
// exist at all.
|
||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if mi := newMailIntake(st, nil, &config.Config{}); mi != nil {
|
||||
t.Error("no email block must mean no mail intake")
|
||||
}
|
||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||
// extraction, by design.
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}); mi != nil {
|
||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,8 @@ func run(args []string) error {
|
||||
eco *ecosystemWiring
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||
)
|
||||
|
||||
if !locked {
|
||||
@@ -265,11 +267,14 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
@@ -319,6 +324,14 @@ func run(args []string) error {
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
|
||||
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||
// configured and there is a llama-server to extract with, in which case
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
@@ -443,18 +456,23 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
@@ -486,6 +504,20 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the watched-page crawls (nil unless configured).
|
||||
if crawlWkr != nil {
|
||||
go func() {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
@@ -531,6 +563,20 @@ func run(args []string) error {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/memeval"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -50,7 +49,7 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
|
||||
}
|
||||
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||
// and nobody is waiting on the answer.
|
||||
client := llm.New(lp.BaseURL(), 5*time.Minute)
|
||||
client := llmClientFor(lp, 5*time.Minute)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// Swapping the resident model while the daemon runs (Vikunja #250).
|
||||
//
|
||||
// Off unless configured: with no phraser.swap_models allowlist the two IPC
|
||||
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
|
||||
// swap method is AuthStepUp (internal/auth), which means an authed human surface
|
||||
// only — there is no act, no intent and no timer that reaches it. The daemon
|
||||
// never decides to change its own brain.
|
||||
//
|
||||
// The allowlist is exact-match against paths a human wrote in mavend.json. The
|
||||
// request carries a path and llama-server is started with it as `-m`, so
|
||||
// anything looser would turn "swap the model" into "load any file on my disk".
|
||||
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
|
||||
return
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, m := range cfg.Phraser.SwapModels {
|
||||
allowed[filepath.Clean(m)] = true
|
||||
}
|
||||
// The configured model is always swappable back to, listed or not: the way
|
||||
// out of a bad swap must not depend on remembering to allowlist the model
|
||||
// you are already running.
|
||||
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
|
||||
|
||||
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
path := filepath.Clean(req.ModelPath)
|
||||
if !allowed[path] {
|
||||
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
|
||||
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
|
||||
}
|
||||
res, err := lp.Swap(ctx, phraser.SwapSpec{
|
||||
ModelPath: path,
|
||||
NGpuLayers: req.NGpuLayers,
|
||||
NCtx: req.NCtx,
|
||||
})
|
||||
resp := ipc.SwapModelResp{
|
||||
Model: res.Model,
|
||||
ModelPath: res.ModelPath,
|
||||
BaseURL: res.BaseURL,
|
||||
RolledBack: res.RolledBack,
|
||||
TookMs: res.Took.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
// A rolled-back swap is a failure that left a working daemon behind.
|
||||
// Both halves matter to the caller, so the response is filled in even
|
||||
// though the error is returned.
|
||||
log.Printf("model swap: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
path, ngl, nctx := lp.LiveModel()
|
||||
base := lp.BaseURL()
|
||||
resp := ipc.ModelStatusResp{
|
||||
ModelPath: path,
|
||||
BaseURL: base,
|
||||
NGpuLayers: ngl,
|
||||
NCtx: nctx,
|
||||
Swappable: cfg.Phraser.SwapModels,
|
||||
}
|
||||
if base == "" {
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
id, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// Report the honest "I could not confirm it" rather than echoing the
|
||||
// configured filename as if the server had said it.
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
resp.Model = id
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
|
||||
}
|
||||
|
||||
// llmClientFor builds a completion client on the phraser's llama-server and
|
||||
// keeps it pointed at the right one across a model swap.
|
||||
//
|
||||
// Without the OnSwap registration every holder of a base URL — the LLM router,
|
||||
// the replier, the mail extractor, the memory evaluator — would keep talking to
|
||||
// the port of a server that no longer exists, and the daemon would degrade to
|
||||
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
@@ -789,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
|
||||
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,
|
||||
Text: 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.ListReminders(ctx, dayPlanMaxReminders)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: list reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
if fire.IsZero() {
|
||||
fire = r.FireTs
|
||||
}
|
||||
reminders = append(reminders, morning.PlanEntry{
|
||||
At: fire,
|
||||
Text: strings.TrimSpace(r.Payload),
|
||||
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
|
||||
}
|
||||
|
||||
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||
const dayPlanMaxReminders = 500
|
||||
|
||||
// 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
|
||||
// rule:
|
||||
@@ -868,6 +939,7 @@ 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, text string) string
|
||||
}
|
||||
|
||||
@@ -893,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
||||
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 {
|
||||
|
||||
@@ -52,6 +52,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
@@ -82,6 +83,15 @@ type reactiveHandler struct {
|
||||
replier voice.Replier
|
||||
now func() time.Time
|
||||
|
||||
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
|
||||
// page reading is off, which is the default: no `crawl` block, no fetch.
|
||||
crawler *crawl.Crawler
|
||||
|
||||
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
||||
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
||||
// instead of "ничего нового", which are different truths.
|
||||
feedsOn bool
|
||||
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
|
||||
+17
-11
@@ -148,7 +148,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// The replier uses the same llama-server as the phraser.
|
||||
var llmClient *llm.Client
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
|
||||
// llmClientFor, not llm.New: this client must follow the phraser onto
|
||||
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||
llmClient = llmClientFor(lp, 60*time.Second)
|
||||
}
|
||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||
@@ -201,16 +203,20 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
|
||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||
h := &reactiveHandler{
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
memStore: memStore,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// mavmaild — the mail reader module (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md).
|
||||
//
|
||||
// Every so often it opens one IMAP mailbox read-only, fetches the messages it
|
||||
// has not read yet, and hands each one to core over ipc.MethodIngestMail. Core
|
||||
// runs the extraction on the resident model and writes what comes back as task
|
||||
// CANDIDATES he reviews on /tasks. Nothing here writes to the store, nothing
|
||||
// here can create a reminder, and nothing here speaks.
|
||||
//
|
||||
// Why a separate daemon rather than a loop inside mavend, when extraction has
|
||||
// to happen in mavend anyway: the credential. mavpoll set the precedent with the
|
||||
// zenmoney token (#125) — the module that talks to a third party holds the
|
||||
// secret, reads it from a FILE so it never appears in `ps`, in
|
||||
// docker-compose.yml or in shell history, and core never sees it. Core learns
|
||||
// that mail exists only as message text on one IPC method; it cannot connect to
|
||||
// the mailbox even if it wanted to, and a compromised core yields no mail
|
||||
// password.
|
||||
//
|
||||
// Off unless configured: without -password-file there is nothing to run, and
|
||||
// the daemon says so and exits. If core has no `email` block the very first
|
||||
// ingest comes back ErrUnknownMethod and this daemon stops polling instead of
|
||||
// hammering a socket that will keep refusing.
|
||||
//
|
||||
// Mail is personal, so the log is counts and UIDs: how many messages were
|
||||
// fetched, how many were bulk, how many candidates came back. No subject, no
|
||||
// sender, no body, ever — reviewing a candidate is what /tasks is for.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mavmaild:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("mavmaild", flag.ContinueOnError)
|
||||
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||
server := fs.String("imap", "", "IMAP server, host or host:993 (required)")
|
||||
user := fs.String("user", "", "IMAP username (required)")
|
||||
passFile := fs.String("password-file", "", "file holding the IMAP password (required — never passed as a flag value)")
|
||||
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
|
||||
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
|
||||
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
|
||||
max := fs.Int("max", 25, "most messages to fetch in one poll")
|
||||
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
|
||||
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *server == "" || *user == "" || *passFile == "" {
|
||||
return fmt.Errorf("mail reading is off unless configured: set -imap, -user and -password-file")
|
||||
}
|
||||
|
||||
// The password is read from a file, never taken as a flag value: an argv
|
||||
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||
// file and the shell history. Read once at start — a rotated password means a
|
||||
// restart, which is cheaper than re-reading his credential every quarter hour.
|
||||
raw, err := os.ReadFile(*passFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
password := strings.TrimSpace(string(raw))
|
||||
if password == "" {
|
||||
return fmt.Errorf("password file %s is empty", *passFile)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
core, err := ipc.DialWait(*socket, 60*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer core.Close()
|
||||
|
||||
r := &reader{
|
||||
core: core,
|
||||
addr: *server,
|
||||
user: *user,
|
||||
mailbox: *mailbox,
|
||||
lookback: *lookback,
|
||||
max: *max,
|
||||
timeout: *timeout,
|
||||
state: newSeenState(*statePath),
|
||||
}
|
||||
if err := r.state.load(); err != nil {
|
||||
// A missing or corrupt state file must not stop mail from being read: the
|
||||
// worst case is re-reading the window, and capture dedupes on text.
|
||||
log.Printf("mavmaild: state: %v (starting from an empty seen-set)", err)
|
||||
}
|
||||
|
||||
// The password is never logged, not even its length.
|
||||
log.Printf("mavmaild: reading %s on %s every %s (lookback %s, max %d/poll)",
|
||||
*mailbox, *server, *interval, *lookback, *max)
|
||||
|
||||
r.pollOnce(ctx, password) // don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("mavmaild: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
if r.disabled {
|
||||
// Core told us mail ingestion is not configured. Nothing will change
|
||||
// without a core restart, and a restart restarts us too.
|
||||
log.Printf("mavmaild: core does not accept mail — idling")
|
||||
return nil
|
||||
}
|
||||
r.pollOnce(ctx, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mailIngester — the slice of core this daemon uses. One method: hand over a
|
||||
// message. It cannot write a fact, create a reminder or read the store, and the
|
||||
// interface says so.
|
||||
type mailIngester interface {
|
||||
IngestMail(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error)
|
||||
}
|
||||
|
||||
type reader struct {
|
||||
core mailIngester
|
||||
addr string
|
||||
user string
|
||||
mailbox string
|
||||
lookback time.Duration
|
||||
max int
|
||||
timeout time.Duration
|
||||
state *seenState
|
||||
|
||||
// dial — connection seam for the tests; nil ⇒ implicit TLS.
|
||||
dial func(addr string, timeout time.Duration) (*email.Conn, error)
|
||||
|
||||
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
|
||||
disabled bool
|
||||
}
|
||||
|
||||
// pollOnce — one read of the mailbox, then one ingest per message.
|
||||
//
|
||||
// A fetch error aborts this poll and nothing else; the next tick tries again.
|
||||
// An ingest error for one message does not skip the rest — one mail the model
|
||||
// choked on should not hide the four behind it.
|
||||
func (r *reader) pollOnce(ctx context.Context, password string) {
|
||||
msgs, err := r.fetch(password)
|
||||
if err != nil {
|
||||
// The error may name a UID; it never names a subject or a sender.
|
||||
log.Printf("mavmaild: fetch: %v", err)
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
var junk, candidates, created int
|
||||
for _, m := range msgs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if m.Junk {
|
||||
junk++
|
||||
// Marked seen without a model call: the header filter already decided,
|
||||
// and re-classifying it every quarter hour would be pure waste.
|
||||
r.state.mark(m.UID)
|
||||
continue
|
||||
}
|
||||
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
|
||||
Mailbox: r.mailbox,
|
||||
UID: m.UID,
|
||||
From: m.From,
|
||||
Subject: m.Subject,
|
||||
Date: m.Date,
|
||||
Body: m.Body,
|
||||
})
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
|
||||
r.disabled = true
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Not marked seen: an ingest that failed should be retried next poll.
|
||||
log.Printf("mavmaild: ingest uid %d: %v", m.UID, err)
|
||||
continue
|
||||
}
|
||||
r.state.mark(m.UID)
|
||||
candidates += len(resp.TaskIDs)
|
||||
created += resp.Created
|
||||
}
|
||||
if err := r.state.save(); err != nil {
|
||||
log.Printf("mavmaild: state: %v", err)
|
||||
}
|
||||
log.Printf("mavmaild: %s: %d read, %d bulk, %d candidate(s), %d new", r.mailbox, len(msgs), junk, candidates, created)
|
||||
}
|
||||
|
||||
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
|
||||
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
|
||||
func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||
f := email.FetchSince{
|
||||
Addr: r.addr,
|
||||
User: r.user,
|
||||
Mailbox: r.mailbox,
|
||||
Timeout: r.timeout,
|
||||
Since: time.Now().Add(-r.lookback),
|
||||
Max: r.max,
|
||||
Skip: r.state.seen,
|
||||
}
|
||||
return f.RunWith(password, r.dial)
|
||||
}
|
||||
|
||||
// ---- seen state ------------------------------------------------------------
|
||||
|
||||
// seenState — the UIDs already handed to core, persisted so a restart does not
|
||||
// re-read (and re-extract, at multi-second LLM cost) the whole lookback window.
|
||||
//
|
||||
// Correctness does not depend on it: ipc.CaptureTask dedupes on normalised text
|
||||
// among live tasks, so a re-read produces no duplicate rows. This exists to save
|
||||
// the model's time, which is why a broken state file is a log line rather than a
|
||||
// failure.
|
||||
//
|
||||
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
|
||||
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
|
||||
// reset and the window is simply re-read once — dedupe absorbs it.
|
||||
type seenState struct {
|
||||
path string
|
||||
high uint32
|
||||
set map[uint32]bool
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func newSeenState(path string) *seenState {
|
||||
return &seenState{path: path, set: map[uint32]bool{}}
|
||||
}
|
||||
|
||||
type seenFile struct {
|
||||
High uint32 `json:"high"`
|
||||
UIDs []uint32 `json:"uids,omitempty"`
|
||||
}
|
||||
|
||||
func (s *seenState) seen(uid uint32) bool {
|
||||
return uid <= s.high || s.set[uid]
|
||||
}
|
||||
|
||||
func (s *seenState) mark(uid uint32) {
|
||||
if s.seen(uid) {
|
||||
return
|
||||
}
|
||||
s.set[uid] = true
|
||||
s.dirty = true
|
||||
// Advance the high-water mark through any contiguous run, so the explicit set
|
||||
// stays small on a mailbox read in order.
|
||||
for {
|
||||
next := s.high + 1
|
||||
if !s.set[next] {
|
||||
break
|
||||
}
|
||||
delete(s.set, next)
|
||||
s.high = next
|
||||
}
|
||||
}
|
||||
|
||||
func (s *seenState) load() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil // first run
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var f seenFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
s.high = f.High
|
||||
for _, u := range f.UIDs {
|
||||
s.set[u] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes the state atomically (temp file + rename), 0600: it is a list of
|
||||
// message ids from his mailbox, which is metadata about his mail.
|
||||
func (s *seenState) save() error {
|
||||
if s.path == "" || !s.dirty {
|
||||
return nil
|
||||
}
|
||||
uids := make([]uint32, 0, len(s.set))
|
||||
for u := range s.set {
|
||||
uids = append(uids, u)
|
||||
}
|
||||
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
|
||||
b, err := json.Marshal(seenFile{High: s.high, UIDs: uids})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dirty = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
|
||||
|
||||
type fakeIMAP struct {
|
||||
msgs map[uint32]string
|
||||
uids []uint32
|
||||
cmds []string
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) serve(c net.Conn) {
|
||||
defer c.Close()
|
||||
fmt.Fprint(c, "* OK fake ready\r\n")
|
||||
r := bufio.NewReader(c)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
tag, cmd := parts[0], parts[1]
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||
var ids []string
|
||||
for _, u := range f.uids {
|
||||
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||
}
|
||||
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
|
||||
case strings.HasPrefix(upper, "UID FETCH"):
|
||||
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||
if raw, ok := f.msgs[uint32(uid)]; ok {
|
||||
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
|
||||
}
|
||||
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||
case strings.HasPrefix(upper, "LOGOUT"):
|
||||
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(c, "%s BAD\r\n", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(srv)
|
||||
return email.NewConn(cli, timeout)
|
||||
}
|
||||
|
||||
// ---- a fake core -----------------------------------------------------------
|
||||
|
||||
type fakeCore struct {
|
||||
got []ipc.IngestMailReq
|
||||
resp ipc.IngestMailResp
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
c.got = append(c.got, req)
|
||||
if c.err != nil {
|
||||
return ipc.IngestMailResp{}, c.err
|
||||
}
|
||||
return c.resp, nil
|
||||
}
|
||||
|
||||
func mail(subject, body string, extraHeaders ...string) string {
|
||||
h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
||||
for _, e := range extraHeaders {
|
||||
h += e + "\r\n"
|
||||
}
|
||||
return h + "\r\n" + body + "\r\n"
|
||||
}
|
||||
|
||||
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
|
||||
t.Helper()
|
||||
return &reader{
|
||||
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
|
||||
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
|
||||
state: newSeenState(statePath),
|
||||
dial: f.dial,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollHandsMessagesToCore(t *testing.T) {
|
||||
f := &fakeIMAP{
|
||||
uids: []uint32{1, 2},
|
||||
msgs: map[uint32]string{
|
||||
1: mail("Счёт", "Оплатить до 5 августа."),
|
||||
2: mail("Скидки", "Sale!", "List-Unsubscribe: <mailto:u@x>"),
|
||||
},
|
||||
}
|
||||
core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
|
||||
// The newsletter is filtered before core is asked: only the real mail crosses.
|
||||
if len(core.got) != 1 {
|
||||
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
|
||||
}
|
||||
got := core.got[0]
|
||||
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
|
||||
t.Errorf("ingest req = %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Body, "Оплатить") {
|
||||
t.Errorf("body = %q", got.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// A second poll must not re-send what core already saw — extraction is a
|
||||
// multi-second LLM call per message.
|
||||
func TestPollSkipsSeenUIDs(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if len(core.got) != 1 {
|
||||
t.Errorf("core saw %d messages over two polls, want 1", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
// An ingest that failed is NOT marked seen: the next poll retries it.
|
||||
func TestPollRetriesFailedIngest(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{err: fmt.Errorf("llama-server is warming up")}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
core.err = nil
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if len(core.got) != 2 {
|
||||
t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
// Core without an email block ⇒ stop, don't hammer the socket.
|
||||
func TestPollStopsWhenCoreRefusesMail(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}}
|
||||
core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if !r.disabled {
|
||||
t.Error("ErrUnknownMethod must disable the reader")
|
||||
}
|
||||
if len(core.got) != 1 {
|
||||
t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStatePersists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state", "seen.json")
|
||||
f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, path)
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("state file: %v", err)
|
||||
}
|
||||
// A list of message ids from his mailbox is metadata about his mail.
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("state file mode = %v, want 0600", perm)
|
||||
}
|
||||
|
||||
// A fresh reader with the same state file must not re-read the message.
|
||||
core2 := &fakeCore{}
|
||||
r2 := newTestReader(t, f, core2, path)
|
||||
if err := r2.state.load(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
r2.pollOnce(context.Background(), "secret")
|
||||
if len(core2.got) != 0 {
|
||||
t.Errorf("after a restart core saw %d messages, want 0", len(core2.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStateHighWaterMark(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1)
|
||||
s.mark(3)
|
||||
s.mark(2)
|
||||
if s.high != 3 {
|
||||
t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high)
|
||||
}
|
||||
if len(s.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", s.set)
|
||||
}
|
||||
if !s.seen(2) || s.seen(4) {
|
||||
t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStateCorruptFileIsNotFatal(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "seen.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newSeenState(path)
|
||||
if err := s.load(); err == nil {
|
||||
t.Error("a corrupt state file should report an error the caller logs")
|
||||
}
|
||||
if s.seen(1) {
|
||||
t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one")
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, and the credential is never a flag value.
|
||||
func TestRunRequiresConfig(t *testing.T) {
|
||||
if err := run([]string{}); err == nil {
|
||||
t.Error("no -socket must be an error")
|
||||
}
|
||||
if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil {
|
||||
t.Error("no mailbox configuration must be an error, not a default mailbox")
|
||||
}
|
||||
// There is no -password flag at all: only -password-file.
|
||||
if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "flag provided but not defined") {
|
||||
t.Errorf("a -password flag must not exist; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsEmptyPasswordFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pass")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
|
||||
}
|
||||
}
|
||||
+124
-3
@@ -9,6 +9,12 @@
|
||||
// Two sources, each its own provenance (the loop's rules trust source):
|
||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||
//
|
||||
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||
// already owns every other third-party credential, it holds no store key, and
|
||||
// core never needs to know an account exists to answer a question about a fact
|
||||
// the poller wrote. It is off unless -zenmoney-token-file is given.
|
||||
//
|
||||
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
||||
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
||||
@@ -37,6 +43,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -52,6 +59,9 @@ func run(args []string) error {
|
||||
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
||||
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
||||
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
||||
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
|
||||
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
|
||||
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
|
||||
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
||||
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
||||
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
||||
@@ -62,8 +72,24 @@ func run(args []string) error {
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
|
||||
// The token is read from a file, never taken as a flag value: an argv token
|
||||
// is visible in `ps` to every user on the box and lands in the compose file
|
||||
// and the shell history. Read once at start — a rotated token means a
|
||||
// restart, which is cheaper than re-reading his credential every hour.
|
||||
var zen *zenmoney.Client
|
||||
if *zenTokenFile != "" {
|
||||
raw, err := os.ReadFile(*zenTokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -83,9 +109,13 @@ func run(args []string) error {
|
||||
kumaKey: *kumaKey,
|
||||
wgIface: *wgIface,
|
||||
wgCmd: *wgCmd,
|
||||
zen: zen,
|
||||
zenEvery: *zenInterval,
|
||||
}
|
||||
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
|
||||
// The token is never logged, not even its length.
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
@@ -108,6 +138,12 @@ type poller struct {
|
||||
kumaKey string
|
||||
wgIface string
|
||||
wgCmd string
|
||||
|
||||
// zen is nil unless a token file was configured — money tracking is a
|
||||
// capability, off by default like weather and telegram.
|
||||
zen *zenmoney.Client
|
||||
zenEvery time.Duration
|
||||
zenLast time.Time
|
||||
}
|
||||
|
||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
log.Printf("mavpoll: wg: %v", err)
|
||||
}
|
||||
}
|
||||
// Money on its own, much slower cadence: a bank feed that updates hourly
|
||||
// polled every minute is 60 pointless reads of his financial history.
|
||||
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
|
||||
p.zenLast = now
|
||||
if err := p.pollZenmoney(ctx, now); err != nil {
|
||||
log.Printf("mavpoll: zenmoney: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zenmoney: spending/income totals → money facts ------------------------
|
||||
|
||||
// pollZenmoney reads today's and this month's totals and writes them as
|
||||
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
|
||||
//
|
||||
// Two properties this function exists to hold:
|
||||
//
|
||||
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
|
||||
// to encode a summary built from zero transactions, so a poller that cannot
|
||||
// reach the API leaves the last good fact in place rather than overwriting
|
||||
// it with a zero Maven would then recite as fact.
|
||||
// - Nothing about the money leaves the box except the diff request itself, to
|
||||
// the service that already holds his bank sessions. The totals are written
|
||||
// to the store and read back only when he asks; they are never search input
|
||||
// and no tick rule fires on them.
|
||||
//
|
||||
// Both windows are read from one diff call each. Two calls an hour against an
|
||||
// API whose whole job is this is not worth caching.
|
||||
// moneyWindow — one fact key and the period it covers.
|
||||
type moneyWindow struct {
|
||||
key string
|
||||
from, to time.Time
|
||||
}
|
||||
|
||||
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
dFrom, dTo := zenmoney.DayWindow(now)
|
||||
mFrom, mTo := zenmoney.MonthWindow(now)
|
||||
windows := []moneyWindow{
|
||||
{zenmoney.KeySpentToday, dFrom, dTo},
|
||||
{zenmoney.KeySpentMonth, mFrom, mTo},
|
||||
}
|
||||
var firstErr error
|
||||
for _, w := range windows {
|
||||
sum, err := p.zen.Since(ctx, w.from, w.to)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
val, ok := sum.Value()
|
||||
if !ok {
|
||||
// Nothing read. Silence, not a zero.
|
||||
continue
|
||||
}
|
||||
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
|
||||
// money facts store an object, not a string). Kept separate rather than
|
||||
// generalising writeIfChanged, because the string-valued env facts encoding
|
||||
// their own value is the convention the rules rely on.
|
||||
//
|
||||
// The log line names the key and the source, never the figures: mavpoll's log
|
||||
// is not the place his spending ends up.
|
||||
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == jsonVal:
|
||||
return nil
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
}
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||
func isNoFact(err error) bool {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
func TestMaxSeverity(t *testing.T) {
|
||||
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
||||
|
||||
// factCore records the facts the poller wrote and answers "no fact yet".
|
||||
type factCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
written []ipc.WriteFactReq
|
||||
prev map[string]string
|
||||
}
|
||||
|
||||
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
if v, ok := c.prev[key+"|"+source]; ok {
|
||||
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
||||
}
|
||||
return ipc.Fact{}, ipc.ErrNoFact
|
||||
}
|
||||
|
||||
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
c.written = append(c.written, req)
|
||||
return int64(len(c.written)), nil
|
||||
}
|
||||
|
||||
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
||||
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := zenFixtureServer(t, body, http.StatusOK)
|
||||
defer srv.Close()
|
||||
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
||||
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 2 {
|
||||
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
||||
}
|
||||
for _, f := range core.written {
|
||||
if f.Kind != "env" || f.Source != zenmoney.Source {
|
||||
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
||||
}
|
||||
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
||||
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
||||
// zero: an invented 0 would be recited back to him as fact.
|
||||
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
||||
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
||||
defer srv.Close()
|
||||
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v, want no fact at all", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// An API failure must not overwrite the last good total either.
|
||||
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
||||
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
||||
defer srv.Close()
|
||||
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
||||
t.Error("want the 401 reported")
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v on a failed read", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged totals do not churn the facts table.
|
||||
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
||||
core := &factCore{prev: map[string]string{
|
||||
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
||||
}}
|
||||
p := &poller{core: core}
|
||||
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v for an unchanged value", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// Money tracking is off unless configured: no token file, no zenmoney client,
|
||||
// and the poller still refuses to start with nothing at all to poll.
|
||||
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// POST /api/ambient — the work calendar read (Vikunja #126).
|
||||
//
|
||||
// Maven does not hold a work credential. A corp mail or calendar session on the
|
||||
// homelab ties the box's blast radius to the employer's data, so the work
|
||||
// calendar is read as a SIGNAL instead: an Android notification-listener on the
|
||||
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
|
||||
// clearly describe a meeting become calendar events at source=ambient:notif,
|
||||
// confidence below 1.0. Mail as a notification signal, not a mailbox.
|
||||
//
|
||||
// Off unless configured: no -ambient-token, no route. The token is a shared
|
||||
// secret because the poster is a phone service, not a browser — WebAuthn has no
|
||||
// answer for a background Android service. The endpoint is write-only and
|
||||
// accepts exactly one shape of write; it cannot read anything back out.
|
||||
//
|
||||
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
|
||||
type ambientResp struct {
|
||||
Stored bool `json:"stored"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleAmbient ingests one relayed notification. token is the configured
|
||||
// shared secret; an empty token means the capability is off and the handler is
|
||||
// never registered, so it is treated as a hard failure here too.
|
||||
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !ambientAuthorized(r, token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if core == nil {
|
||||
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var n calendar.Notification
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &n); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if n.Posted.IsZero() {
|
||||
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ev, ok := calendar.EventFromNotification(n)
|
||||
if !ok {
|
||||
// Not an event. 202: the relay did its job, there is just nothing here
|
||||
// worth remembering, and it must not retry.
|
||||
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
|
||||
return
|
||||
}
|
||||
|
||||
key := calendar.FactKey(ev)
|
||||
val := calendar.FactValue(ev)
|
||||
|
||||
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
|
||||
// notification many times, and each repost is the same event.
|
||||
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
|
||||
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||
log.Printf("ambient: read %s: %v", key, err)
|
||||
http.Error(w, "read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// kind=env: an observation about the world, never a self-fact — a passive
|
||||
// signal does not write truth about the owner. Confidence below 1.0 is the
|
||||
// honest part: this is a notification about a meeting, not a reading of a
|
||||
// calendar, and the query path hedges when it recites one.
|
||||
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||
Ts: ev.Start,
|
||||
Kind: "env",
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: calendar.SourceAmbient,
|
||||
Confidence: calendar.AmbientConfidence,
|
||||
}); err != nil {
|
||||
log.Printf("ambient: write %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
|
||||
}
|
||||
|
||||
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
||||
// header, compared in constant time.
|
||||
func ambientAuthorized(r *http.Request, token string) bool {
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
const ambientTestToken = "s3cret"
|
||||
|
||||
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||
// needs.
|
||||
type ambientCore struct {
|
||||
fakeCore
|
||||
latest map[string]ipc.Fact // "key|source" → fact
|
||||
readErr error
|
||||
}
|
||||
|
||||
func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
if c.readErr != nil {
|
||||
return ipc.Fact{}, c.readErr
|
||||
}
|
||||
f, ok := c.latest[key+"|"+source]
|
||||
if !ok {
|
||||
return ipc.Fact{}, ipc.ErrNoFact
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+ambientTestToken)
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, req, core, token)
|
||||
var resp ambientResp
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
return rr, resp
|
||||
}
|
||||
|
||||
func meetingNotification() calendar.Notification {
|
||||
return calendar.Notification{
|
||||
Package: "com.google.android.gm",
|
||||
Title: "Планёрка",
|
||||
Text: "10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAmbientStoresMeeting(t *testing.T) {
|
||||
core := &ambientCore{}
|
||||
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||
}
|
||||
if !resp.Stored {
|
||||
t.Errorf("resp = %+v, want stored", resp)
|
||||
}
|
||||
if len(core.writeLog) != 1 {
|
||||
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||
}
|
||||
got := core.writeLog[0]
|
||||
if got.Source != calendar.SourceAmbient {
|
||||
t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient)
|
||||
}
|
||||
if got.Confidence >= 1.0 {
|
||||
t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence)
|
||||
}
|
||||
if got.Confidence != calendar.AmbientConfidence {
|
||||
t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence)
|
||||
}
|
||||
if got.Kind != "env" {
|
||||
t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind)
|
||||
}
|
||||
if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) {
|
||||
t.Errorf("key = %q, want prefix %q", got.Key, want)
|
||||
}
|
||||
if got.Value != "Планёрка @ 10:00-10:30" {
|
||||
t.Errorf("value = %q", got.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone reposts the same notification many times. Each repost is the same
|
||||
// event, and the append-only log must not fill with duplicates.
|
||||
func TestHandleAmbientDedupesReposts(t *testing.T) {
|
||||
core := &ambientCore{}
|
||||
postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||
if len(core.writeLog) != 1 {
|
||||
t.Fatalf("first post did not write")
|
||||
}
|
||||
w := core.writeLog[0]
|
||||
core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}}
|
||||
|
||||
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code)
|
||||
}
|
||||
if resp.Stored {
|
||||
t.Error("a repost must not be stored again")
|
||||
}
|
||||
if len(core.writeLog) != 1 {
|
||||
t.Errorf("wrote %d facts, want 1", len(core.writeLog))
|
||||
}
|
||||
}
|
||||
|
||||
// The conservative half: noise stores nothing at all.
|
||||
func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||
core := &ambientCore{}
|
||||
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||
Package: "com.google.android.gm",
|
||||
Title: "3 новых письма",
|
||||
Posted: time.Now(),
|
||||
})
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code)
|
||||
}
|
||||
if resp.Stored {
|
||||
t.Error("a notification with no meeting time must store nothing")
|
||||
}
|
||||
if len(core.writeLog) != 0 {
|
||||
t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAmbientAuth(t *testing.T) {
|
||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||
|
||||
newReq := func(hdr, val string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||
if hdr != "" {
|
||||
r.Header.Set(hdr, val)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
t.Run("no token rejected", func(t *testing.T) {
|
||||
core := &ambientCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("", ""), core, ambientTestToken)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
if len(core.writeLog) != 0 {
|
||||
t.Error("an unauthorized post must not write")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong token rejected", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("X-Maven-Token accepted", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("capability off", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "")
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503 when no token is configured", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET rejected", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
|
||||
handleAmbient(rr, r, &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleAmbientBadInput(t *testing.T) {
|
||||
t.Run("bad json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope"))
|
||||
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing posted_at", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`))
|
||||
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read error surfaces", func(t *testing.T) {
|
||||
core := &ambientCore{readErr: fmt.Errorf("socket closed")}
|
||||
rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||
if rr.Code != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want 502", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
+241
-1
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
@@ -58,6 +59,9 @@ var notificationsHTML string
|
||||
//go:embed reminders.html
|
||||
var remindersHTML string
|
||||
|
||||
//go:embed tasks.html
|
||||
var tasksHTML string
|
||||
|
||||
//go:embed voice.html
|
||||
var voiceHTML string
|
||||
|
||||
@@ -97,6 +101,7 @@ var sidebarSections = []struct {
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
@@ -119,6 +124,7 @@ var sidebarSections = []struct {
|
||||
Label: "Settings",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||
{Label: "Model", URL: "/models", Key: "models"},
|
||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||
},
|
||||
},
|
||||
@@ -170,6 +176,8 @@ func pageIcon(key string) string {
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
case "notifications":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||
case "tasks":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "reminders":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||
case "routines":
|
||||
@@ -184,6 +192,8 @@ func pageIcon(key string) string {
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||
case "tools":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
|
||||
case "models":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||
case "passkey":
|
||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
|
||||
default:
|
||||
@@ -202,6 +212,8 @@ func pageTitle(key string) string {
|
||||
return "Rule Trace"
|
||||
case "notifications":
|
||||
return "Notifications"
|
||||
case "tasks":
|
||||
return "Tasks"
|
||||
case "reminders":
|
||||
return "Reminders"
|
||||
case "routines":
|
||||
@@ -216,6 +228,8 @@ func pageTitle(key string) string {
|
||||
return "Ecosystem"
|
||||
case "tools":
|
||||
return "Tools"
|
||||
case "models":
|
||||
return "Resident Model"
|
||||
case "passkey":
|
||||
return "Passkey"
|
||||
default:
|
||||
@@ -334,6 +348,10 @@ func main() {
|
||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||
// reads the work calendar as a signal instead of holding a work credential
|
||||
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||
flag.Parse()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
@@ -381,6 +399,14 @@ func main() {
|
||||
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleSignal(w, r, core)
|
||||
})
|
||||
// Off unless configured: no token, no route — an unconfigured ingest is not
|
||||
// a 503 waiting to be probed, it does not exist.
|
||||
if *ambientToken != "" {
|
||||
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAmbient(w, r, core, *ambientToken)
|
||||
})
|
||||
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
||||
}
|
||||
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDash(w, r, core)
|
||||
})
|
||||
@@ -396,6 +422,11 @@ func main() {
|
||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleReminders(w, r, core)
|
||||
})
|
||||
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
||||
// why a task write is not in the same class as /tools or /routines.
|
||||
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleTasks(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleMorning(w, r, core)
|
||||
})
|
||||
@@ -453,6 +484,12 @@ func main() {
|
||||
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
// /models — the resident-model surface (Vikunja #250). Same step-up gate as
|
||||
// /tools, and for a comparable reason: which model is loaded decides how every
|
||||
// utterance is routed and how every reply is worded. GET is read-only.
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||
//
|
||||
@@ -732,6 +769,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
|
||||
|
||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||
|
||||
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||
|
||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||
|
||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||
@@ -802,6 +841,187 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||
// template holds no date logic.
|
||||
type taskRow struct {
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the four writes it
|
||||
// offers (POST): add, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg, errMsg string
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
// and the voice reply can never disagree about what comes first. Resolved
|
||||
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||
var live []tasks.Item
|
||||
var resolved []taskRow
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "candidate", "open":
|
||||
live = append(live, tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
})
|
||||
default:
|
||||
resolved = append(resolved, taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
})
|
||||
}
|
||||
}
|
||||
byID := make(map[int64]ipc.Task, len(all))
|
||||
for _, t := range all {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
var cands, open []taskRow
|
||||
for _, r := range tasks.Rank(live, time.Now()) {
|
||||
t := byID[r.ID]
|
||||
row := taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
Why: r.Reason,
|
||||
}
|
||||
if t.Status == "candidate" {
|
||||
// A candidate's due date is Maven's reading of a mail, so its
|
||||
// ranking reason is not shown as if he had set a priority.
|
||||
row.Why = ""
|
||||
cands = append(cands, row)
|
||||
} else {
|
||||
open = append(open, row)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tasksTmpl.Execute(w, struct {
|
||||
Msg, Err string
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||
log.Printf("tasks render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyTaskPost performs one write and returns the message to show. A bad
|
||||
// request returns an error, which the page renders inline rather than as a
|
||||
// bare 400 — this is a form surface, not an API.
|
||||
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
action := r.FormValue("action")
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
var wgt int
|
||||
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
req.Weight = wgt
|
||||
}
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
req.Due = &due
|
||||
}
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Created {
|
||||
return "already on the list", nil
|
||||
}
|
||||
return "added task", nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
status, msg = "open", "confirmed task"
|
||||
case "done":
|
||||
status, msg = "done", "task done"
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
|
||||
func fmtTaskDate(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan")
|
||||
}
|
||||
|
||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it.
|
||||
type routineRow struct {
|
||||
@@ -947,12 +1167,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
view := morningView{Routines: status}
|
||||
// The day plan (#128) shows on this page because it is the same question at
|
||||
// a different scale. A plan read that fails must not take the checklist
|
||||
// down with it — the page degrades to what it had before.
|
||||
plan, err := core.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: day plan: %v", err)
|
||||
view.PlanErr = err.Error()
|
||||
} else {
|
||||
view.Plan = &plan
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := morningTmpl.Execute(w, status); err != nil {
|
||||
if err := morningTmpl.Execute(w, view); err != nil {
|
||||
log.Printf("morning render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// morningView — what /morning renders: today's plan on top, the checklist
|
||||
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||
// a plan, so the page says so rather than showing an empty day.
|
||||
type morningView struct {
|
||||
Plan *ipc.DayPlan
|
||||
PlanErr string
|
||||
Routines []ipc.MorningRoutineStatus
|
||||
}
|
||||
|
||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The resident-model surface (Vikunja #250).
|
||||
//
|
||||
// GET shows which model llama-server actually has loaded and which files the
|
||||
// daemon is configured to allow. POST swaps to one of them, behind the same
|
||||
// step-up gate as POST /tools: the loaded model decides how every utterance is
|
||||
// routed and how every reply is worded, so it is an owner action.
|
||||
//
|
||||
// There is nothing on this page Maven can press. The swap is an IPC method rated
|
||||
// AuthStepUp in internal/auth, unreachable from an act, an intent or a timer.
|
||||
|
||||
// modelController — the two non-CoreAPI methods this page needs. *ipc.Client
|
||||
// satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which
|
||||
// the page renders as "not configured" rather than an error.
|
||||
type modelController interface {
|
||||
ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error)
|
||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||
}
|
||||
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
|
||||
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
type modelsPage struct {
|
||||
Msg string
|
||||
Err string
|
||||
Off bool
|
||||
Status ipc.ModelStatusResp
|
||||
}
|
||||
|
||||
// handleModels renders the model surface (GET) and applies a swap (POST).
|
||||
//
|
||||
// A failed swap is reported as a failure with the model that is still serving
|
||||
// named, because that is the state the operator needs: the daemon rolled back
|
||||
// and is answering turns, it just is not answering them with what he asked for.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if core == nil {
|
||||
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
mc, ok := core.(modelController)
|
||||
if !ok {
|
||||
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
page := modelsPage{}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(r.FormValue("model_path"))
|
||||
if path == "" {
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req := ipc.SwapModelReq{ModelPath: path}
|
||||
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
|
||||
req.NCtx = v
|
||||
}
|
||||
res, err := mc.SwapModel(ctx, req)
|
||||
switch {
|
||||
case err == nil:
|
||||
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
|
||||
case errors.Is(err, ipc.ErrForbidden):
|
||||
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
|
||||
return
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||
return
|
||||
case res.RolledBack:
|
||||
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
|
||||
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
|
||||
default:
|
||||
page.Err = "swap failed: " + err.Error()
|
||||
log.Printf("models: swap to %s failed: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := mc.ModelStatus(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
page.Off = true
|
||||
} else {
|
||||
log.Printf("models: status: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
page.Status = st
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := modelsTmpl.Execute(w, page); err != nil {
|
||||
log.Printf("models render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// fakeModelCore is a core that supports the two model methods. It records what
|
||||
// the page asked for, so the tests can assert the gate rather than the HTML.
|
||||
type fakeModelCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
status ipc.ModelStatusResp
|
||||
statusErr error
|
||||
|
||||
swapResp ipc.SwapModelResp
|
||||
swapErr error
|
||||
swapped []ipc.SwapModelReq
|
||||
}
|
||||
|
||||
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
return f.status, f.statusErr
|
||||
}
|
||||
|
||||
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
f.swapped = append(f.swapped, req)
|
||||
return f.swapResp, f.swapErr
|
||||
}
|
||||
|
||||
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
|
||||
return w
|
||||
}
|
||||
|
||||
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, r, core, session, requireStepUp)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
|
||||
core := &fakeModelCore{status: ipc.ModelStatusResp{
|
||||
Model: "Qwen3-1.7B-UD-Q4_K_XL",
|
||||
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
|
||||
BaseURL: "http://127.0.0.1:18099",
|
||||
NCtx: 4096,
|
||||
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
|
||||
}}
|
||||
w := modelsGET(t, core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /models = %d; want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
}
|
||||
if len(core.swapped) != 0 {
|
||||
t.Errorf("a GET swapped the model: %v", core.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
|
||||
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
|
||||
// like POST /tools. Nothing reaches core.
|
||||
core := &fakeModelCore{}
|
||||
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
|
||||
}
|
||||
if len(core.swapped) != 0 {
|
||||
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models = %d; want 200", w.Code)
|
||||
}
|
||||
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
|
||||
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
|
||||
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
|
||||
swapErr: errBrokenModel{},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "rolled back to qwen3") {
|
||||
t.Errorf("page does not say it rolled back:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_RefusedPathIs403(t *testing.T) {
|
||||
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
|
||||
w := modelsPOST(t, core, nil, false, "/etc/passwd")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
|
||||
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
|
||||
w := modelsGET(t, core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "swap not configured") {
|
||||
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
|
||||
// An in-process CoreAPI (no swap methods) must not 500 the page.
|
||||
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
+20
-2
@@ -1,9 +1,27 @@
|
||||
{{template "shellTop" "morning"}}
|
||||
<h1>Today</h1>
|
||||
{{with .Plan}}
|
||||
<div class=hint>{{.Date.Format "02.01.2006"}}</div>
|
||||
{{if not .Items}}
|
||||
<div class=hint>nothing planned</div>
|
||||
{{else}}
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>at<th>kind<th>what</tr>
|
||||
{{range .Items}}<tr>
|
||||
<td>{{.At.Format "15:04"}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||
</tr>{{end}}
|
||||
</table></div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .PlanErr}}<div class=hint>plan unavailable: {{.PlanErr}}</div>{{end}}
|
||||
|
||||
<h1>Morning Routines</h1>
|
||||
{{if not .}}
|
||||
{{if not .Routines}}
|
||||
<div class=hint>no morning routines configured</div>
|
||||
{{else}}
|
||||
{{range .}}
|
||||
{{range .Routines}}
|
||||
<div class="mb-4">
|
||||
<div><strong>{{.Name}}</strong>
|
||||
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{{template "shellTop" "tasks"}}
|
||||
<h1>Tasks</h1>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>add</h2>
|
||||
<form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=action value=add>
|
||||
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||
<input type=date name=due title="due date (optional)">
|
||||
<select name=weight title="importance (optional)">
|
||||
<option value=0>normal</option>
|
||||
<option value=2>важно</option>
|
||||
<option value=3>срочно</option>
|
||||
</select>
|
||||
<button class=btn>add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Candidates}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Candidates}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=confirm>
|
||||
<button class=btn>confirm</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=drop>
|
||||
<button class="btn btn-muted">drop</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Why}}</td>
|
||||
<td class=hint>{{.Source}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=done>
|
||||
<button class=btn>done</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=drop>
|
||||
<button class="btn btn-muted">drop</button></form></td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||
<div>no open tasks</div>
|
||||
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Resolved}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||
{{range .Resolved}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=muted>{{.Resolved}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
|
||||
// writes the page made.
|
||||
type fakeTaskCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
tasks []ipc.Task
|
||||
listErr error
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
captureErr error
|
||||
|
||||
statusID int64
|
||||
statusVal string
|
||||
statusErr error
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.tasks, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
f.captured = append(f.captured, req)
|
||||
if f.captureErr != nil {
|
||||
return ipc.CaptureTaskResp{}, f.captureErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||
f.statusID, f.statusVal = id, status
|
||||
return f.statusErr
|
||||
}
|
||||
|
||||
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
resolved := now.Add(time.Hour)
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
|
||||
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
|
||||
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"купить молоко", "продлить страховку", "полить цветы",
|
||||
"полис истекает", // the evidence trail is visible for review
|
||||
"found, not confirmed", // candidates get their own section
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
// The candidate must offer confirm, and the open task must not.
|
||||
if !strings.Contains(body, "value=confirm") {
|
||||
t.Error("candidate row has no confirm action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if len(core.captured) != 1 {
|
||||
t.Fatalf("captured %d requests, want 1", len(core.captured))
|
||||
}
|
||||
got := core.captured[0]
|
||||
if got.Text != "позвонить в банк" {
|
||||
t.Errorf("text = %q, want trimmed", got.Text)
|
||||
}
|
||||
if got.Source != "tap:web" {
|
||||
t.Errorf("source = %q, want tap:web", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v", got.Due)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "added task") {
|
||||
t.Error("no confirmation message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
|
||||
core := &fakeTaskCore{created: false}
|
||||
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if !strings.Contains(rec.Body.String(), "already on the list") {
|
||||
t.Error("a deduped capture must not claim it saved something new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksStatusActions(t *testing.T) {
|
||||
for _, tc := range []struct{ action, want string }{
|
||||
{"confirm", "open"},
|
||||
{"done", "done"},
|
||||
{"drop", "dropped"},
|
||||
} {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {tc.action}, "id": {"42"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.statusID != 42 || core.statusVal != tc.want {
|
||||
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {"explode"}, "id": {"1"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
// The page still renders, with the error inline — and nothing was written.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if core.statusVal != "" || len(core.captured) != 0 {
|
||||
t.Error("an unknown action must write nothing")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "unknown action") {
|
||||
t.Error("error not surfaced on the page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksNoCore(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The open list is ordered by the ranker, and the reason is shown so the page
|
||||
// says why a task is first instead of asking him to trust the order.
|
||||
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
|
||||
now := time.Now()
|
||||
due := now
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
|
||||
t.Error("want the dated task rendered first")
|
||||
}
|
||||
if !strings.Contains(body, "сегодня") {
|
||||
t.Error("want the ranker's reason shown in the why column")
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate is ranked into place but never carries a priority reason: its due
|
||||
// date is Maven's reading of a mail, not something he stated.
|
||||
func TestHandleTasksHidesCandidateReason(t *testing.T) {
|
||||
now := time.Now()
|
||||
due := now
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
if strings.Contains(rec.Body.String(), "сегодня") {
|
||||
t.Error("a candidate must not be shown with a priority reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
|
||||
t.Fatalf("captured = %+v, want weight 3", core.captured)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.captured[0].Weight != tasks.MaxWeight {
|
||||
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,67 @@ present (see `.dockerignore`).
|
||||
| `/var/lib/maven` (volume) | encrypted db at rest |
|
||||
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
|
||||
|
||||
## Reading the outside world (off by default)
|
||||
|
||||
`mavend.json` ships without a `feeds` block, which means no RSS/Atom feed is
|
||||
fetched and no outbound request is made. Switching it on is adding the block:
|
||||
|
||||
```json
|
||||
"feeds": {
|
||||
"poll_interval": "30m",
|
||||
"max_items": 5,
|
||||
"max_age": "24h",
|
||||
"sources": [
|
||||
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
|
||||
"category": "технологии", "exclude": ["реклама"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
What it does and does not do:
|
||||
|
||||
- items are written as notes with source `rss:<name>`, visible on `/dash`;
|
||||
- **nothing is announced.** She reads them back when asked — "что нового в
|
||||
лентах?", "что нового по технологиям?" — and never on arrival. There is no
|
||||
severity or channel knob here on purpose;
|
||||
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
|
||||
`allow_hosts`. It refuses non-http(s) schemes and every private address
|
||||
(loopback, the LAN, the `10.42.0.0/24` wg range, cloud metadata). It caps the
|
||||
response at 2 MiB and redirects at 3, and makes at most one request per host
|
||||
per second. See `internal/webfetch`;
|
||||
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
|
||||
a restart does not re-note yesterday's headlines.
|
||||
|
||||
### Reading a page (`crawl`, also off by default)
|
||||
|
||||
There is no `crawl` block either, so no page is fetched. Two halves, separately
|
||||
switched:
|
||||
|
||||
```json
|
||||
"crawl": {
|
||||
"on_demand": true,
|
||||
"interval": "6h",
|
||||
"max_runes": 4000,
|
||||
"watches": [
|
||||
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `on_demand` lets her read a page he names in the utterance: "посмотри
|
||||
https://example.org/x — что там?". The page becomes context for his question,
|
||||
and only the URL leaves the box. Without a URL nothing is fetched, so this is
|
||||
a fallback and not a habit;
|
||||
- `watches` re-reads a fixed list on its interval and writes a note when the
|
||||
text changed. Like the feeds, it announces nothing;
|
||||
- the answer path sits **last** in the query chain, behind his memory, his notes
|
||||
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
|
||||
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
|
||||
refusal she says out loud. `Crawl-delay` is honoured;
|
||||
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
|
||||
size cap, redirect cap, timeout, one request per host per second;
|
||||
- dedup state is the config fact `crawl:hash:<name>`.
|
||||
|
||||
## Not yet verified / host-dependent
|
||||
|
||||
This stack is correct-by-construction but has **not been build-tested here**
|
||||
|
||||
@@ -101,9 +101,42 @@ services:
|
||||
"-netdata", "http://127.0.0.1:19999",
|
||||
"-kuma", "http://127.0.0.1:3001/metrics",
|
||||
"-kuma-key", "uk5_mavpoll-key"]
|
||||
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
|
||||
# which mavpoll reads from a FILE so it never appears in `ps`, in
|
||||
# this file, or in shell history. To enable, mount the token and
|
||||
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
|
||||
# (optionally "-zenmoney-interval", "1h"). Core never sees the
|
||||
# token — the poller writes facts(kind=env, source=poll:zenmoney)
|
||||
# and mavend only reads those back when he asks.
|
||||
depends_on: [mavend]
|
||||
volumes:
|
||||
- sockets:/run/maven
|
||||
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
|
||||
|
||||
# The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP
|
||||
# account, and there is none on this box. mavmaild reads the password from a
|
||||
# FILE so it never appears in `ps`, in this file, or in shell history — the
|
||||
# same rule mavpoll follows for the zenmoney token. Core never sees the
|
||||
# password: the reader hands core message text on one IPC method, and core
|
||||
# writes what the model extracts as task CANDIDATES he reviews on /tasks.
|
||||
# Nothing here can create a reminder, so a misread mail cannot fire.
|
||||
#
|
||||
# To enable: write the password to deploy/imap.password (0600, gitignored),
|
||||
# add an "email": {} block to deploy/mavend.json, and uncomment this service.
|
||||
# mavmaild:
|
||||
# <<: *image
|
||||
# command: ["mavmaild", "-socket", "/run/maven/mavend.sock",
|
||||
# "-imap", "imap.example.org:993",
|
||||
# "-user", "kami@example.org",
|
||||
# "-password-file", "/run/secrets/imap.password",
|
||||
# "-mailbox", "INBOX",
|
||||
# "-interval", "15m",
|
||||
# "-state", "/var/lib/maven/mail-seen.json"]
|
||||
# depends_on: [mavend]
|
||||
# volumes:
|
||||
# - sockets:/run/maven
|
||||
# - dbdata:/var/lib/maven
|
||||
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
|
||||
@@ -27,3 +27,36 @@
|
||||
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
||||
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
||||
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
||||
|
||||
---
|
||||
|
||||
## Status (2026-08-01) — partially shipped, deliberately narrowed
|
||||
|
||||
Shipped on `overnight/behavior-profile`:
|
||||
|
||||
- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of
|
||||
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
|
||||
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
|
||||
- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker
|
||||
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
|
||||
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
|
||||
по вторникам?" is answered.
|
||||
|
||||
**Not shipped, and not to be shipped as written:**
|
||||
|
||||
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
|
||||
generated. A 1.7B asked to summarise a year of habits produces fluent claims
|
||||
about the owner's life that no row supports, and a wrong claim about him is the
|
||||
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
|
||||
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
|
||||
the profile is recomputed on the question, so a new fact is already in the next
|
||||
answer. A cached profile that can disagree with its own rows is two truths.
|
||||
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
|
||||
and a nudge at 08:00 every day proposing the day is the definition of one. The
|
||||
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
|
||||
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
|
||||
`/routines`. It goes through him.
|
||||
|
||||
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
|
||||
The counted profile needs no new IPC method to be *asked* about — the query source
|
||||
reads `RecentFacts` over the existing surface — so this is a display concern only.
|
||||
|
||||
@@ -27,3 +27,28 @@
|
||||
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
|
||||
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
|
||||
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
|
||||
|
||||
---
|
||||
|
||||
## Shipped 2026-08-01 (#258)
|
||||
|
||||
`internal/webfetch` (the guarded HTTP door: scheme, allow/deny hosts, private-address
|
||||
refusal in the dialer, size cap, redirect cap, per-host rate limit), `internal/rss`
|
||||
(RSS 2.0 + Atom parser, poller with durable marks and a keyword filter),
|
||||
`cmd/mavend/feeds.go` (ticker, fetcher adapter, `rss:latest:<feed>` fact marks),
|
||||
config block `feeds`, and the `feeds` query source with `router.ParseFeedQuery`.
|
||||
|
||||
Deviations from the plan above, both deliberate:
|
||||
|
||||
- **Step 5 (breaking-news nudges) was not built.** A feed that dispatches is a nag,
|
||||
and the one thing Maven is not is a nag. Items are read when asked and nowhere else.
|
||||
If breaking news is ever wanted, it belongs behind the existing delivery policy
|
||||
(severity, quiet hours, digest), not in the poller.
|
||||
- **Step 3 (embedder relevance) is a seam, not an implementation.** `rss.Ranker`
|
||||
exists and is wired nil. Scoring items against an "interest profile" needs a
|
||||
profile, and there is none yet; a threshold with nothing to compare against is a
|
||||
random filter with a confident name. The filter that runs is the per-feed
|
||||
include/exclude keyword list, which he can read and predict.
|
||||
|
||||
No new dependency: stdlib `encoding/xml`, no gofeed. Stock deploy config has no
|
||||
`feeds` block, so the capability is off.
|
||||
|
||||
@@ -28,3 +28,41 @@
|
||||
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
|
||||
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
|
||||
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
|
||||
|
||||
## Shipped 2026-08-01 (#259)
|
||||
|
||||
Built as `internal/crawl` (pure: robots, extraction, watcher) plus
|
||||
`cmd/mavend/crawls.go` (fetcher, ticker, dedup facts), on top of the guarded
|
||||
`internal/webfetch` door added with the feed reader (#258). Off unless
|
||||
configured, in two separately-switched halves: `crawl.on_demand` for a URL he
|
||||
names, `crawl.watches` for a scheduled re-read.
|
||||
|
||||
**Limits are code, not documentation** (`internal/webfetch`, tested one test per
|
||||
limit): host allowlist/denylist, no private addresses (loopback, RFC1918 —
|
||||
hence the LAN and the `10.42.0.0/24` wg range —, link-local incl. cloud
|
||||
metadata, CGNAT, v6 ULA) enforced in the dialer's `Control` hook so DNS
|
||||
rebinding and every redirect hop are covered, response size cap, redirect cap,
|
||||
timeout, one request per host per second. `robots.txt` is fetched first, cached
|
||||
per host, and a `Disallow` is refused with no override.
|
||||
|
||||
Deliberate deviations from the plan above:
|
||||
|
||||
- **No CSS selectors and no LLM structured extraction** (steps 2). The output is
|
||||
plaintext handed to the phraser as context for the question he asked. A 1.7B
|
||||
extracting a JSON price table from 4000 runes is a worse bet than reading, and
|
||||
`goquery` is not vendored.
|
||||
- **No `crawl` act verb and no new IPC methods** (steps 5, 7). Reading a page is
|
||||
a query source (`queryWeb` in `actions_query.go`, last in the chain, behind
|
||||
Kiwix once that is wired), not an action he commands. Nothing needs a new wire
|
||||
method to work.
|
||||
- **Notes, not facts.** A page's text is not a fact about him. Only the dedup
|
||||
hash is a fact (`crawl:hash:<name>`, kind `config`, source `poll:crawl`).
|
||||
- **Nothing is dispatched.** A changed page writes a note; it does not nudge.
|
||||
Not a nag.
|
||||
- **No `/tools` crawl history page.** The notes and the hash facts are already
|
||||
visible on `/dash`.
|
||||
|
||||
**No new dependency.** The vendored tree has no `x/net/html`, no `goquery` and
|
||||
no `temoto/robotstxt`, so robots parsing and HTML-to-text are stdlib
|
||||
(`regexp`, `html`) — RE2 has no backreferences, hence the `pairsRE` builder in
|
||||
`extract.go`.
|
||||
|
||||
@@ -393,3 +393,26 @@ func mustWriteFactParams(source string) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestRequirement_SwapModel — loading a different resident model is an owner
|
||||
// action at the same rung as mutating the tool allowlist: it decides how every
|
||||
// utterance is routed and how every reply is worded. The read side is not.
|
||||
func TestRequirement_SwapModel(t *testing.T) {
|
||||
if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
|
||||
t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
|
||||
}
|
||||
if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
|
||||
t.Errorf("ModelStatus authority = %v; want AuthRead", got)
|
||||
}
|
||||
// A surface that cannot carry a passkey gesture cannot swap the model, no
|
||||
// matter what it is enrolled as — this is the "never through voice" property.
|
||||
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
|
||||
if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
|
||||
t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
|
||||
}
|
||||
// And with no step-up session asserted, the gate refuses even a capable surface.
|
||||
noSession := &Gate{Enrollment: NewFloorEnrollment()}
|
||||
if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -53,6 +53,13 @@ func Requirement(m ipc.Method) Authority {
|
||||
// asserted — never a module or the voice/chat path. maven can propose
|
||||
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
|
||||
return AuthStepUp
|
||||
case ipc.MethodSwapModel:
|
||||
// Swapping the resident model changes what routes every utterance and
|
||||
// what words every reply. It is the owner's call, from a surface that can
|
||||
// carry a passkey gesture — the same rung as mutating the tool allowlist,
|
||||
// and for the same reason: nothing Maven says or does may reach it.
|
||||
// MethodModelStatus is only the read side, so it stays at AuthRead.
|
||||
return AuthStepUp
|
||||
case ipc.MethodWriteFact:
|
||||
return AuthWrite
|
||||
case ipc.MethodAssertStepUp:
|
||||
@@ -65,7 +72,24 @@ func Requirement(m ipc.Method) Authority {
|
||||
ipc.MethodCreateReminder,
|
||||
ipc.MethodMarkReminder,
|
||||
ipc.MethodRecordNudge,
|
||||
ipc.MethodResolveNudge:
|
||||
ipc.MethodResolveNudge,
|
||||
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||
// the default so the intent is on the record: capturing a task is a
|
||||
// module write, not an allowlist mutation and not a new standing reason
|
||||
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||
// existing analogue.
|
||||
ipc.MethodCaptureTask,
|
||||
ipc.MethodListTasks,
|
||||
ipc.MethodSetTaskStatus,
|
||||
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
|
||||
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
||||
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
||||
// at worst put junk on a review page he clears in one click.
|
||||
ipc.MethodIngestMail,
|
||||
// The read side of the model swap: which model is resident, which ones are
|
||||
// allowlisted. It loads nothing and changes nothing.
|
||||
ipc.MethodModelStatus:
|
||||
return AuthRead
|
||||
}
|
||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Ambient events — the work calendar read (Vikunja #126).
|
||||
//
|
||||
// The work calendar is not read by holding a work credential. A corp mail or
|
||||
// calendar session living on the homelab ties the box's blast radius to the
|
||||
// employer's data, which is the thing the task exists to refuse. What maven
|
||||
// reads instead is the SIGNAL: an Android notification-listener on the owner's
|
||||
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
|
||||
// clearly describe a meeting into calendar events.
|
||||
//
|
||||
// That makes the provenance honest. A notification is evidence about an event,
|
||||
// not a reading of the calendar, so it is stored under SourceAmbient at
|
||||
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
|
||||
// query path hedges when it recites one.
|
||||
//
|
||||
// The parse is deliberately conservative. A notification with no recognisable
|
||||
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
|
||||
// mailbox full of noise turned into invented events is worse than a gap. Mail
|
||||
// as a notification signal, not a mailbox.
|
||||
|
||||
// Notification — one relayed Android notification. Package is the posting app
|
||||
// (for the log and for the owner to see where a wrong event came from), Title
|
||||
// and Text are the notification's two text lines, Posted is when the phone
|
||||
// showed it. Nothing else off the notification is kept.
|
||||
type Notification struct {
|
||||
Package string `json:"package"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Posted time.Time `json:"posted_at"`
|
||||
}
|
||||
|
||||
// EventFromNotification turns a notification into the event it describes, or
|
||||
// reports false when it does not clearly describe one.
|
||||
//
|
||||
// It needs two things: a clock reading, and a summary that is not just that
|
||||
// clock reading. Everything else is defaulted — the date is Posted's day (a
|
||||
// meeting notification is about today or it would not be firing now), and a
|
||||
// bare start time gets DefaultReminderDuration.
|
||||
func EventFromNotification(n Notification) (Event, bool) {
|
||||
if n.Posted.IsZero() {
|
||||
return Event{}, false
|
||||
}
|
||||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||
start, end, ok := parseTimeRange(line)
|
||||
if !ok {
|
||||
return Event{}, false
|
||||
}
|
||||
summary := notificationSummary(n)
|
||||
if summary == "" {
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
y, m, d := n.Posted.Date()
|
||||
loc := n.Posted.Location()
|
||||
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||
var e time.Time
|
||||
if end != nil {
|
||||
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
|
||||
// A range that ends before it starts crossed midnight.
|
||||
if !e.After(s) {
|
||||
e = e.AddDate(0, 0, 1)
|
||||
}
|
||||
} else {
|
||||
e = s.Add(DefaultReminderDuration)
|
||||
}
|
||||
return Event{Summary: summary, Start: s, End: e}, true
|
||||
}
|
||||
|
||||
// notificationSummary picks the text that names the meeting: the title when it
|
||||
// carries words, otherwise the body. The clock reading is stripped out — it
|
||||
// already lives in the times, and FactValue renders it again.
|
||||
func notificationSummary(n Notification) string {
|
||||
for _, cand := range []string{n.Title, n.Text} {
|
||||
s := strings.TrimSpace(stripClock(cand))
|
||||
s = strings.Trim(s, " \t-–—,;:@|·")
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if hasLetters(s) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type clock struct{ hour, min int }
|
||||
|
||||
// parseTimeRange finds the first clock reading in s, and a second one if the
|
||||
// text spells a range. Accepted separators between hours and minutes are ":"
|
||||
// and "."; between the two ends of a range, "-", "–", "—" or "до".
|
||||
//
|
||||
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
|
||||
// more often a count, a date or an unread badge than a meeting time, and an
|
||||
// invented event is worse than no event.
|
||||
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
|
||||
first, _, firstEnd, ok := nextClock(s, 0)
|
||||
if !ok {
|
||||
return clock{}, nil, false
|
||||
}
|
||||
sep := strings.TrimLeft(s[firstEnd:], " \t")
|
||||
for _, p := range []string{"-", "–", "—", "до "} {
|
||||
if !strings.HasPrefix(sep, p) {
|
||||
continue
|
||||
}
|
||||
if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
|
||||
return first, &second, true
|
||||
}
|
||||
break
|
||||
}
|
||||
return first, nil, true
|
||||
}
|
||||
|
||||
// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
|
||||
// returns it with the byte range it occupied. Digits and separators are ASCII,
|
||||
// so byte offsets are safe over Cyrillic text.
|
||||
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
|
||||
for i := from; i < len(s); i++ {
|
||||
if !isDigit(s[i]) {
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(s) && isDigit(s[j]) {
|
||||
j++
|
||||
}
|
||||
// A run longer than two digits is a year, an id or an unread count.
|
||||
if j-i > 2 {
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if j >= len(s) || (s[j] != ':' && s[j] != '.') {
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
k := j + 1
|
||||
for k < len(s) && isDigit(s[k]) {
|
||||
k++
|
||||
}
|
||||
if k-(j+1) != 2 {
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
// Reject a group that is a link in a longer dotted or colon chain:
|
||||
// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
|
||||
// date invented as a meeting time is exactly the wrong kind of guess.
|
||||
// A trailing ":ss" is fine — that is a time with seconds.
|
||||
if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
|
||||
i = k
|
||||
continue
|
||||
}
|
||||
if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
|
||||
i = k
|
||||
continue
|
||||
}
|
||||
hour, min := atoi(s[i:j]), atoi(s[j+1:k])
|
||||
if hour > 23 || min > 59 {
|
||||
i = k
|
||||
continue
|
||||
}
|
||||
return clock{hour, min}, i, k, true
|
||||
}
|
||||
return clock{}, 0, 0, false
|
||||
}
|
||||
|
||||
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
n = n*10 + int(s[i]-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// stripClock removes every clock reading from a summary candidate, along with
|
||||
// the preposition or separator that introduced it.
|
||||
func stripClock(s string) string {
|
||||
for {
|
||||
_, start, end, ok := nextClock(s, 0)
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
|
||||
s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
|
||||
}
|
||||
}
|
||||
|
||||
// trimTrailingPreposition drops the word that introduced a clock reading, so
|
||||
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
|
||||
// keep a dangling "с". It repeats, because a range has two of them.
|
||||
func trimTrailingPreposition(s string) string {
|
||||
preps := []string{"в", "с", "до", "от", "at", "from", "to"}
|
||||
for again := true; again; {
|
||||
again = false
|
||||
s = strings.TrimRight(s, " \t")
|
||||
for _, p := range preps {
|
||||
if s == p {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(s, " "+p) {
|
||||
s = s[:len(s)-len(p)-1]
|
||||
again = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hasLetters(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventFromNotification(t *testing.T) {
|
||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title, text string
|
||||
wantOK bool
|
||||
wantSummary string
|
||||
wantStart string // "15:04"
|
||||
wantEnd string
|
||||
}{
|
||||
{
|
||||
name: "range in the body",
|
||||
title: "Планёрка",
|
||||
text: "10:00-10:30",
|
||||
wantOK: true,
|
||||
wantSummary: "Планёрка",
|
||||
wantStart: "10:00", wantEnd: "10:30",
|
||||
},
|
||||
{
|
||||
name: "russian preposition and single time",
|
||||
title: "Встреча с подрядчиком в 14:00",
|
||||
wantOK: true,
|
||||
wantSummary: "Встреча с подрядчиком",
|
||||
wantStart: "14:00", wantEnd: "14:30",
|
||||
},
|
||||
{
|
||||
name: "en dash range",
|
||||
title: "Sprint review",
|
||||
text: "Today 16:00 – 17:00, Meet",
|
||||
wantOK: true,
|
||||
wantSummary: "Sprint review",
|
||||
wantStart: "16:00", wantEnd: "17:00",
|
||||
},
|
||||
{
|
||||
name: "до as a range separator",
|
||||
title: "Созвон",
|
||||
text: "с 11:30 до 12:15",
|
||||
wantOK: true,
|
||||
wantSummary: "Созвон",
|
||||
wantStart: "11:30", wantEnd: "12:15",
|
||||
},
|
||||
{
|
||||
name: "dotted clock",
|
||||
title: "Обед 13.00",
|
||||
wantOK: true,
|
||||
wantSummary: "Обед",
|
||||
wantStart: "13:00", wantEnd: "13:30",
|
||||
},
|
||||
{
|
||||
name: "range crossing midnight",
|
||||
title: "Ночной релиз",
|
||||
text: "23:30-00:30",
|
||||
wantOK: true,
|
||||
wantSummary: "Ночной релиз",
|
||||
wantStart: "23:30", wantEnd: "00:30",
|
||||
},
|
||||
// The conservative half: no clock reading, no event.
|
||||
{name: "no time at all", title: "3 новых письма", wantOK: false},
|
||||
{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
|
||||
{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
|
||||
{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
|
||||
{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
|
||||
{name: "impossible clock", title: "Смена 99:99", wantOK: false},
|
||||
{name: "empty", wantOK: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Package: "com.google.android.gm",
|
||||
Title: tt.title,
|
||||
Text: tt.text,
|
||||
Posted: posted,
|
||||
})
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.Summary != tt.wantSummary {
|
||||
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
|
||||
}
|
||||
if got := ev.Start.Format("15:04"); got != tt.wantStart {
|
||||
t.Errorf("start = %s, want %s", got, tt.wantStart)
|
||||
}
|
||||
if got := ev.End.Format("15:04"); got != tt.wantEnd {
|
||||
t.Errorf("end = %s, want %s", got, tt.wantEnd)
|
||||
}
|
||||
if !ev.End.After(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 phone's
|
||||
// location — not shifted into UTC.
|
||||
if ev.Start.Location() != posted.Location() {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||
}
|
||||
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||
t.Error("a notification with no posted_at has no date to sit on")
|
||||
}
|
||||
}
|
||||
|
||||
// An ambient event must never be indistinguishable from a calendar read.
|
||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
Title: "Планёрка 10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected an event")
|
||||
}
|
||||
if FactKey(ev) == "" || FactValue(ev) == "" {
|
||||
t.Fatal("ambient events must use the shared fact encoding")
|
||||
}
|
||||
if AmbientConfidence >= 1.0 {
|
||||
t.Fatal("ambient confidence must be below a calendar read's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripClock(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"Встреча в 14:00", "Встреча"},
|
||||
{"Планёрка 10:00-10:30", "Планёрка"},
|
||||
{"с 11:30 до 12:15 Созвон", "Созвон"},
|
||||
{"Ничего", "Ничего"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := stripClock(tt.in); got != tt.want {
|
||||
t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package calendar is the one calendar data model the rest of maven shares:
|
||||
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
||||
// encoding that puts it in the store.
|
||||
//
|
||||
// It exists because three separate features read or write the same events and
|
||||
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
||||
// #126/#127), the write-only render target that publishes maven's own
|
||||
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
||||
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
||||
// format was a Sprintf in two places.
|
||||
//
|
||||
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
||||
// impurity, the way internal/morning and internal/loop do.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fact sources. A calendar event reaches the store as a
|
||||
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
||||
// the source is the whole provenance story:
|
||||
//
|
||||
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
||||
// state stays in sqlite; the calendar is a render target (#127).
|
||||
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
||||
// maven ever writes to it: no code path pairs this source with a PUT.
|
||||
// - SourceAmbient — inferred from an Android notification-listener relay
|
||||
// rather than read from a server (#126). Confidence is below 1.0 because a
|
||||
// notification is a signal about an event, not the event.
|
||||
const (
|
||||
SourcePersonal = "poll:caldav"
|
||||
SourceWork = "poll:caldav:work"
|
||||
SourceAmbient = "ambient:notif"
|
||||
)
|
||||
|
||||
// AmbientConfidence — the confidence a notification-derived event is stored
|
||||
// with. A parsed notification line is evidence, not a reading of the calendar,
|
||||
// so it must never be indistinguishable from one (#126).
|
||||
const AmbientConfidence = 0.6
|
||||
|
||||
// Sources lists every source a calendar event may legitimately carry, for the
|
||||
// store query that reads the calendar back out. Ordered from most to least
|
||||
// trusted.
|
||||
func Sources() []string {
|
||||
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
||||
}
|
||||
|
||||
// ReadOnlySource reports whether events from this source may never be written
|
||||
// back. The work calendar is read-only by definition — see #126: maven holding
|
||||
// a credential that can write to an employer's calendar is the thing the task
|
||||
// exists to avoid.
|
||||
func ReadOnlySource(source string) bool {
|
||||
return source == SourceWork || source == SourceAmbient
|
||||
}
|
||||
|
||||
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
||||
// from a server and the identity maven renders under when it publishes one;
|
||||
// Start/End are instants. All-day events are not modelled: the busy gate and
|
||||
// the day plan both need a time of day, and an all-day marker answers neither.
|
||||
type Event struct {
|
||||
UID string
|
||||
Summary string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
// FactKey is the store key for an event: one key per day per summary, stable
|
||||
// across polls so re-reading an unchanged calendar rewrites nothing.
|
||||
//
|
||||
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
||||
// by key prefix, not by a timestamp column.
|
||||
func FactKey(e Event) string {
|
||||
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
||||
}
|
||||
|
||||
// FactValue is the human-readable rendering stored as the fact value, and the
|
||||
// string the day plan and the query path read back.
|
||||
func FactValue(e Event) string {
|
||||
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
||||
}
|
||||
|
||||
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||
// range-scans between two of these.
|
||||
func KeyPrefixForDay(day time.Time) string {
|
||||
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
||||
}
|
||||
|
||||
// Busy reports whether any event covers the instant now — the read the loop
|
||||
// gate uses to suppress nudges during a meeting.
|
||||
func Busy(events []Event, now time.Time) bool {
|
||||
for _, e := range events {
|
||||
if !now.Before(e.Start) && now.Before(e.End) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Overlapping returns the events intersecting [from, to), sorted by start.
|
||||
func Overlapping(events []Event, from, to time.Time) []Event {
|
||||
var out []Event
|
||||
for _, e := range events {
|
||||
if e.End.After(from) && e.Start.Before(to) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||
return out
|
||||
}
|
||||
|
||||
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||
// the day prefix carries the identity and this only disambiguates within a day.
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '_':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
body := []byte(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:a@example
|
||||
DTSTART:20260703T090000Z
|
||||
DTEND:20260703T100000Z
|
||||
SUMMARY:Morning standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T140000Z
|
||||
DTEND:20260703T150000Z
|
||||
SUMMARY:Team sync
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260702T140000Z
|
||||
DTEND:20260702T150000Z
|
||||
SUMMARY:Yesterday retro
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260704T090000Z
|
||||
DTEND:20260704T100000Z
|
||||
SUMMARY:Tomorrow standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20260704
|
||||
DTEND;VALUE=DATE:20260705
|
||||
SUMMARY:All-day event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`)
|
||||
|
||||
events := ParseICalDay(body, now)
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
|
||||
}
|
||||
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
|
||||
t.Errorf("events[0] = %+v", events[0])
|
||||
}
|
||||
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("events[0].Start = %v", events[0].Start)
|
||||
}
|
||||
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("events[0].End = %v", events[0].End)
|
||||
}
|
||||
if events[1].Summary != "Team sync" {
|
||||
t.Errorf("events[1].Summary = %q", events[1].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: "today" is the owner's day, in the owner's location. Taking the
|
||||
// day number off a local clock but building the boundaries in UTC made the
|
||||
// evening fall outside the window on any box east of Greenwich.
|
||||
func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
||||
plus4 := time.FixedZone("+04", 4*60*60)
|
||||
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
|
||||
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
|
||||
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
||||
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
|
||||
"END:VEVENT\nEND:VCALENDAR")
|
||||
|
||||
events := ParseICalDay(body, now)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want the in-progress one", len(events))
|
||||
}
|
||||
if !Busy(events, now.UTC()) {
|
||||
t.Error("an event in progress right now must read as busy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e, ok := parseVEVENT(block)
|
||||
if !ok {
|
||||
t.Fatal("expected a parsed event")
|
||||
}
|
||||
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("start = %v", e.Start)
|
||||
}
|
||||
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("end = %v", e.End)
|
||||
}
|
||||
if e.Summary != "Stand up meeting" {
|
||||
t.Errorf("summary = %q", e.Summary)
|
||||
}
|
||||
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if _, ok := parseVEVENT(allDay); ok {
|
||||
t.Error("all-day event should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
||||
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
||||
{"garbage", "DTSTART:garbage", time.Time{}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseDT(tt.line)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !got.Equal(tt.want) {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeKey(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"Stand up meeting", "Stand-up-meeting"},
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := safeKey(tt.in); got != tt.want {
|
||||
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactKeyAndValue(t *testing.T) {
|
||||
e := Event{
|
||||
Summary: "Team sync",
|
||||
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
||||
}
|
||||
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
||||
t.Errorf("FactKey = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
||||
t.Errorf("FactValue = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
||||
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
||||
t.Error("FactKey must start with the day prefix the store range-scans on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyAndOverlapping(t *testing.T) {
|
||||
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
|
||||
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
|
||||
}
|
||||
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
|
||||
t.Error("should be busy inside the early event")
|
||||
}
|
||||
if Busy(events, base.Add(12*time.Hour)) {
|
||||
t.Error("should be free at noon")
|
||||
}
|
||||
// Half-open: the end instant is free.
|
||||
if Busy(events, base.Add(10*time.Hour)) {
|
||||
t.Error("the end instant should not count as busy")
|
||||
}
|
||||
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
|
||||
if len(got) != 1 || got[0].Summary != "early" {
|
||||
t.Fatalf("Overlapping = %+v", got)
|
||||
}
|
||||
all := Overlapping(events, base, base.AddDate(0, 0, 1))
|
||||
if len(all) != 2 || all[0].Summary != "early" {
|
||||
t.Fatalf("Overlapping must sort by start: %+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceTrust(t *testing.T) {
|
||||
if ReadOnlySource(SourcePersonal) {
|
||||
t.Error("the personal calendar is the one maven may render to")
|
||||
}
|
||||
if !ReadOnlySource(SourceWork) {
|
||||
t.Error("the work calendar must be read-only")
|
||||
}
|
||||
if !ReadOnlySource(SourceAmbient) {
|
||||
t.Error("an ambient notification is not a writable calendar")
|
||||
}
|
||||
if AmbientConfidence >= 1.0 {
|
||||
t.Error("ambient events must be less trusted than a calendar read")
|
||||
}
|
||||
if len(Sources()) != 3 {
|
||||
t.Errorf("Sources() = %v", Sources())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseICal scans iCal text for VEVENT components and returns the events
|
||||
// overlapping [from, to). All-day events are skipped: parseDT reports no time
|
||||
// for a VALUE=DATE value, and an event with no clock reading answers neither
|
||||
// the busy gate nor the day plan.
|
||||
func ParseICal(body []byte, from, to time.Time) []Event {
|
||||
var events []Event
|
||||
text := string(body)
|
||||
for {
|
||||
i := strings.Index(text, "BEGIN:VEVENT")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
text = text[i+len("BEGIN:VEVENT"):]
|
||||
j := strings.Index(text, "END:VEVENT")
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
block := text[:j]
|
||||
text = text[j+len("END:VEVENT"):]
|
||||
|
||||
e, ok := parseVEVENT(block)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e.End.After(from) && e.Start.Before(to) {
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
|
||||
// location — the window cmd/mavcaldav polls.
|
||||
//
|
||||
// The location matters. The old inline version took the day number off a local
|
||||
// clock reading but built the boundaries in UTC, so east of Greenwich the
|
||||
// window was shifted by the offset and part of the evening fell outside
|
||||
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
|
||||
// owner's day is the day the day plan and the busy gate mean.
|
||||
func ParseICalDay(body []byte, now time.Time) []Event {
|
||||
y, m, d := now.Date()
|
||||
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
return ParseICal(body, start, start.AddDate(0, 0, 1))
|
||||
}
|
||||
|
||||
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
|
||||
// Reports false for all-day events and parse failures.
|
||||
func parseVEVENT(block string) (Event, bool) {
|
||||
var e Event
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.Start = t
|
||||
}
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.End = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
e.Summary = afterColon(line)
|
||||
case strings.HasPrefix(line, "UID"):
|
||||
e.UID = afterColon(line)
|
||||
}
|
||||
}
|
||||
if e.Start.IsZero() || e.End.IsZero() {
|
||||
return Event{}, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func afterColon(line string) string {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
return strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseDT parses a DTSTART/DTEND value:
|
||||
//
|
||||
// - UTC: DTEND:20260703T100000Z
|
||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
|
||||
//
|
||||
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
|
||||
// CalDAV server and the poller run in the same timezone, and the busy gate only
|
||||
// needs busy/not-busy to be right.
|
||||
func parseDT(line string) (time.Time, bool) {
|
||||
if strings.Contains(line, "VALUE=DATE:") {
|
||||
return time.Time{}, false
|
||||
}
|
||||
i := strings.LastIndex(line, ":")
|
||||
if i < 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
|
||||
// collection. One event per file is the CalDAV convention, so callers normally
|
||||
// pass a single event.
|
||||
//
|
||||
// This is the write half of #127 and it only ever renders: the canonical state
|
||||
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
|
||||
func RenderICal(events []Event) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
|
||||
for _, e := range events {
|
||||
b.WriteString("BEGIN:VEVENT\r\n")
|
||||
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
|
||||
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
|
||||
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
|
||||
b.WriteString("END:VEVENT\r\n")
|
||||
}
|
||||
b.WriteString("END:VCALENDAR\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
|
||||
// would otherwise let a reminder payload inject iCal properties.
|
||||
func escapeText(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, ";", "\\;")
|
||||
s = strings.ReplaceAll(s, ",", "\\,")
|
||||
s = strings.ReplaceAll(s, "\r\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\n")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenderICalRoundTrips(t *testing.T) {
|
||||
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
||||
if e.UID != "maven-reminder-7" {
|
||||
t.Errorf("UID = %q", e.UID)
|
||||
}
|
||||
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
||||
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
||||
}
|
||||
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
||||
t.Errorf("ReminderPath = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
body := RenderICal([]Event{e})
|
||||
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
||||
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
||||
}
|
||||
|
||||
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
||||
if len(back) != 1 {
|
||||
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
||||
}
|
||||
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
||||
t.Errorf("round trip lost identity: %+v", back[0])
|
||||
}
|
||||
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
||||
t.Errorf("round trip lost times: %+v", back[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderICalIsDeterministic(t *testing.T) {
|
||||
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
||||
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
||||
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder payload is owner-supplied text. It must not be able to close the
|
||||
// VEVENT and inject properties of its own.
|
||||
func TestRenderICalEscapesInjection(t *testing.T) {
|
||||
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
||||
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
||||
body := RenderICal([]Event{e})
|
||||
// Count line-initial occurrences: the escaped text still contains the
|
||||
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
||||
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
||||
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
||||
}
|
||||
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
||||
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
||||
}
|
||||
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
||||
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||
if e.Summary != "напоминание" {
|
||||
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a
|
||||
// fixed prefix and not a random UUID: the render is idempotent (the same
|
||||
// reminder always lands on the same UID, so re-rendering overwrites instead of
|
||||
// duplicating), and everything maven owns in the target collection is
|
||||
// identifiable at a glance — she never touches a file she did not create.
|
||||
const ReminderUIDPrefix = "maven-reminder-"
|
||||
|
||||
// DefaultReminderDuration — how long a rendered reminder occupies. A reminder
|
||||
// is an instant, a calendar entry is a span, so one has to be invented; 30
|
||||
// minutes reads as a block in a calendar app without swallowing the afternoon.
|
||||
const DefaultReminderDuration = 30 * time.Minute
|
||||
|
||||
// ReminderEvent maps a reminder to the event that represents it. id and fire
|
||||
// come from the store; payload is the RU text as the owner said it, rendered
|
||||
// verbatim as the summary — the calendar is a view of sqlite, not a place to
|
||||
// rephrase.
|
||||
func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event {
|
||||
if dur <= 0 {
|
||||
dur = DefaultReminderDuration
|
||||
}
|
||||
summary := strings.TrimSpace(payload)
|
||||
if summary == "" {
|
||||
summary = "напоминание"
|
||||
}
|
||||
return Event{
|
||||
UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id),
|
||||
Summary: summary,
|
||||
Start: fire,
|
||||
End: fire.Add(dur),
|
||||
}
|
||||
}
|
||||
|
||||
// ReminderPath is the collection-relative filename for a rendered reminder.
|
||||
// One event per resource, per the CalDAV convention.
|
||||
func ReminderPath(id int64) string {
|
||||
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
|
||||
}
|
||||
@@ -150,6 +150,21 @@ type Config struct {
|
||||
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
||||
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
||||
|
||||
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
|
||||
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
|
||||
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
|
||||
// live in the reader (cmd/mavmaild), never here.
|
||||
Email *EmailConfig `json:"email,omitempty"`
|
||||
|
||||
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
||||
// ever fetched: reading the outside world is off unless configured, like
|
||||
// the weather and telegram. See FeedsConfig.
|
||||
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
||||
|
||||
// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
|
||||
// fetches a page: not on request, not on a schedule. See CrawlConfig.
|
||||
Crawl *CrawlConfig `json:"crawl,omitempty"`
|
||||
|
||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||
@@ -396,6 +411,109 @@ func (p *PatternProposalConfig) AnnounceProposals() bool {
|
||||
return p != nil && p.Notify
|
||||
}
|
||||
|
||||
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
|
||||
//
|
||||
// Absent ⇒ off, and off means no outbound request at all. Present with an empty
|
||||
// `sources` list is also off — a poller with nothing to poll is not wired.
|
||||
//
|
||||
// What a feed may NOT do here: speak. Items are written as notes with source
|
||||
// "rss:<name>" and read back when he asks; nothing is dispatched, nudged or
|
||||
// announced on arrival. That is the "not a nag" constraint, and it is why there
|
||||
// is no severity or channel field in this block to reach for.
|
||||
type FeedsConfig struct {
|
||||
// Sources — the feeds to read. Empty ⇒ the reader stays down.
|
||||
Sources []FeedSourceConfig `json:"sources,omitempty"`
|
||||
|
||||
// PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m).
|
||||
PollInterval Duration `json:"poll_interval,omitempty"`
|
||||
|
||||
// MaxItems — most items kept from one feed in one poll. 0 ⇒
|
||||
// rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MaxAge — on a first poll (no saved mark), how far back to take items.
|
||||
// 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not
|
||||
// the archive.
|
||||
MaxAge Duration `json:"max_age,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the reader may only connect to these hosts (and
|
||||
// their subdomains). The feed URLs' own hosts are added automatically, so
|
||||
// this is only needed to be stricter than that.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// FeedSourceConfig — one feed.
|
||||
type FeedSourceConfig struct {
|
||||
Name string `json:"name"` // note source is "rss:<name>"
|
||||
URL string `json:"url"` // http(s) only
|
||||
Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval
|
||||
Include []string `json:"include,omitempty"` // keep only items containing one of these
|
||||
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
||||
}
|
||||
|
||||
// CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md).
|
||||
//
|
||||
// Absent ⇒ off, and off means no page is ever fetched. Present with neither
|
||||
// `on_demand` nor a `watches` entry is also off: there would be nothing to do.
|
||||
//
|
||||
// The crawler is the LAST place an answer is looked for, behind the model, his
|
||||
// own memory and the local Kiwix ZIMs. That ordering lives in the query-source
|
||||
// chain (cmd/mavend/actions_query.go), not here, but it is the reason this block
|
||||
// is small: it is a fallback, not a search engine.
|
||||
//
|
||||
// Only the URL leaves the box. His notes, facts, persona block and history are
|
||||
// never part of a request — the crawler package cannot even read the store.
|
||||
type CrawlConfig struct {
|
||||
// OnDemand — may he ask her to read a page he names out loud
|
||||
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
|
||||
// source stays off and only the watches below run.
|
||||
OnDemand bool `json:"on_demand,omitempty"`
|
||||
|
||||
// Watches — pages re-read on a schedule. A page whose text changed is
|
||||
// written as a note (source "crawl:<name>"); nothing is announced.
|
||||
Watches []CrawlWatchConfig `json:"watches,omitempty"`
|
||||
|
||||
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
|
||||
// included). Watched pages' own hosts are added automatically. Setting this
|
||||
// is how "she may read the arch wiki and nothing else" is expressed.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// DenyHosts — never reachable, checked first. Private addresses do not need
|
||||
// to be listed: they are refused unconditionally (see internal/webfetch).
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// UserAgent — sent on every request AND matched against robots.txt groups.
|
||||
// Empty ⇒ webfetch.DefaultUserAgent.
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
|
||||
// (4000), which is what fits a 4096-token context alongside a prompt.
|
||||
MaxRunes int `json:"max_runes,omitempty"`
|
||||
}
|
||||
|
||||
// CrawlWatchConfig — one page kept an eye on.
|
||||
type CrawlWatchConfig struct {
|
||||
Name string `json:"name"` // note source is "crawl:<name>"
|
||||
URL string `json:"url"`
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
|
||||
}
|
||||
|
||||
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||
@@ -418,6 +536,26 @@ type MemoryEvalConfig struct {
|
||||
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||
// message may produce, and how long the extraction call may take.
|
||||
//
|
||||
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||
// block only says "extraction is allowed, with these bounds".
|
||||
type EmailConfig struct {
|
||||
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||
MaxTasks int `json:"max_tasks,omitempty"`
|
||||
|
||||
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||
// hung llama-server must not pin the reader's connection forever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultEmailTimeout — extraction budget per message.
|
||||
const DefaultEmailTimeout = 2 * time.Minute
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
@@ -441,6 +579,21 @@ type PhraserConfig struct {
|
||||
// persona and invented units). Chat, query and reminder phrasing always go
|
||||
// through the model regardless. See phraser.Config.LLMNudges.
|
||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||
|
||||
// SwapModels — the gguf files the running daemon is allowed to swap to
|
||||
// without a restart (Vikunja #250). Empty (the default) means the swap
|
||||
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
|
||||
// exactly like an unconfigured weather or telegram block.
|
||||
//
|
||||
// It is an allowlist and not a directory on purpose. The request carries a
|
||||
// path, and llama-server is started with it as `-m`; anything short of an
|
||||
// exact match against a list a human wrote in this file would make "swap the
|
||||
// model" mean "load a file of your choosing off my disk". ModelPath is
|
||||
// always swappable back to whether or not it is listed.
|
||||
//
|
||||
// Paths must be absolute — the daemon's working directory is not the
|
||||
// operator's, and a relative path here would resolve somewhere surprising.
|
||||
SwapModels []string `json:"swap_models,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||
@@ -607,6 +760,24 @@ func (c *Config) applyDefaults() {
|
||||
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||
}
|
||||
|
||||
// Same rule again: absent stays nil (⇒ mail ingestion refused), present gets
|
||||
// the timeout default so `{}` is a valid "on with the defaults".
|
||||
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||
}
|
||||
|
||||
// A feeds block with no sources is the same as no block: nothing to poll,
|
||||
// nothing wired. Normalising it to nil keeps that "off" in one place.
|
||||
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
|
||||
c.Feeds = nil
|
||||
}
|
||||
|
||||
// Same rule for the crawler: a block that neither answers on demand nor
|
||||
// watches anything has nothing to do, so it is normalised to "off".
|
||||
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
|
||||
c.Crawl = nil
|
||||
}
|
||||
|
||||
if c.Voice != nil {
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
@@ -664,6 +835,14 @@ func (c *Config) validate() error {
|
||||
if c.Phraser.ModelPath == "" {
|
||||
return errors.New("phraser.model_path is required")
|
||||
}
|
||||
// A relative entry in the swap allowlist would resolve against the
|
||||
// daemon's working directory, so the path a human reads in this file
|
||||
// would not be the path llama-server is handed. Fail at startup.
|
||||
for _, m := range c.Phraser.SwapModels {
|
||||
if !filepath.IsAbs(m) {
|
||||
return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Voice != nil && c.Voice.Enabled {
|
||||
if c.Voice.Bind == "" {
|
||||
|
||||
@@ -293,3 +293,35 @@ func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
||||
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the
|
||||
// operator lists the models he allows (Vikunja #250).
|
||||
func TestSwapModelsAbsentMeansOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(c.Phraser.SwapModels) != 0 {
|
||||
t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"phraser": {
|
||||
"model_path": "/m/qwen.gguf",
|
||||
"swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"]
|
||||
}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(c.Phraser.SwapModels) != 2 {
|
||||
t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels)
|
||||
}
|
||||
// A relative entry would resolve against the daemon's cwd, not the operator's.
|
||||
if _, err := Load(writeConfig(t, `{"phraser": {
|
||||
"model_path": "/m/qwen.gguf",
|
||||
"swap_models": ["models/llm/qwen.gguf"]
|
||||
}}`)); err == nil {
|
||||
t.Error("Load accepted a relative swap_models entry; want a startup failure")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Package crawl reads a web page: fetch, robots check, HTML to text.
|
||||
//
|
||||
// It is the LAST place Maven looks for an answer, and that ordering is the whole
|
||||
// design. "Never phones home" is deprecated, but what replaced it puts local
|
||||
// sources first: the resident model, then his own memory, then the Kiwix ZIMs on
|
||||
// the box (internal/kiwix), and only then the network. A local read costs
|
||||
// nothing and leaks nothing; a fetch costs a round-trip and puts a URL in
|
||||
// someone's access log. So this package exists to be the fallback, not the
|
||||
// front door — see the querySources chain in cmd/mavend/actions_query.go for
|
||||
// where it actually sits.
|
||||
//
|
||||
// What never leaves the box: his notes, his facts, the persona block, the
|
||||
// conversation history. Only the URL is requested and, for the on-demand path,
|
||||
// only because he said it out loud. Nothing here reads the store.
|
||||
//
|
||||
// The limits are not in this package — they are in internal/webfetch, which is
|
||||
// the only way anything here touches a socket: http(s) only, host allow/deny,
|
||||
// private-address refusal, size cap, redirect cap, per-host rate limit. What
|
||||
// this package adds is politeness (robots.txt) and dedup.
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
ErrRobots = errors.New("crawl: robots.txt disallows this path")
|
||||
ErrNotHTML = errors.New("crawl: response is not html or text")
|
||||
)
|
||||
|
||||
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
|
||||
// interface so this package constructs no http.Client of its own and can be
|
||||
// tested without a network.
|
||||
type Fetcher interface {
|
||||
Get(ctx context.Context, url string) (*Response, error)
|
||||
}
|
||||
|
||||
// Response is the minimum a crawl needs from a fetch.
|
||||
type Response struct {
|
||||
URL string
|
||||
ContentType string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// Config — crawler knobs.
|
||||
type Config struct {
|
||||
// UserAgent is the name matched against robots.txt groups. It must be the
|
||||
// same string the fetcher sends, or Maven would be claiming one identity
|
||||
// and obeying the rules for another.
|
||||
UserAgent string
|
||||
// MaxRunes caps extracted text. 0 ⇒ DefaultMaxRunes.
|
||||
MaxRunes int
|
||||
// RobotsTTL — how long a parsed robots.txt is trusted. 0 ⇒ 1h.
|
||||
RobotsTTL time.Duration
|
||||
// Now is injectable for tests. nil ⇒ time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Crawler fetches and extracts pages. Safe for concurrent use.
|
||||
type Crawler struct {
|
||||
fetch Fetcher
|
||||
cfg Config
|
||||
robots *robotsCache
|
||||
}
|
||||
|
||||
// New builds a crawler. Returns nil when there is no fetcher, which is how the
|
||||
// daemon expresses "crawling is off unless configured".
|
||||
func New(fetch Fetcher, cfg Config) *Crawler {
|
||||
if fetch == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.UserAgent == "" {
|
||||
cfg.UserAgent = "Maven"
|
||||
}
|
||||
if cfg.MaxRunes <= 0 {
|
||||
cfg.MaxRunes = DefaultMaxRunes
|
||||
}
|
||||
if cfg.RobotsTTL <= 0 {
|
||||
cfg.RobotsTTL = time.Hour
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL)}
|
||||
}
|
||||
|
||||
// Page fetches rawURL and returns its text. It checks robots.txt first and
|
||||
// refuses a disallowed path with ErrRobots — there is no override.
|
||||
func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return Page{}, fmt.Errorf("crawl: bad url %q: %w", rawURL, err)
|
||||
}
|
||||
ok, err := c.allowed(ctx, u)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Page{}, fmt.Errorf("%w: %s", ErrRobots, u.Path)
|
||||
}
|
||||
resp, err := c.fetch.Get(ctx, u.String())
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
// A PDF or an image is bytes Maven cannot read; saying so beats storing
|
||||
// binary garbage as a "note".
|
||||
ct := strings.ToLower(resp.ContentType)
|
||||
if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "text/") &&
|
||||
!strings.Contains(ct, "xml") && !strings.Contains(ct, "json") {
|
||||
return Page{}, fmt.Errorf("%w: %s", ErrNotHTML, resp.ContentType)
|
||||
}
|
||||
return Extract(resp.URL, resp.Body, c.cfg.MaxRunes), nil
|
||||
}
|
||||
|
||||
// allowed consults robots.txt for u's host, reading it at most once per TTL.
|
||||
//
|
||||
// A robots.txt that cannot be fetched (404, a timeout, a blocked host) means
|
||||
// allow, per the standard. The one thing that is NOT fail-open is an explicit
|
||||
// Disallow.
|
||||
func (c *Crawler) allowed(ctx context.Context, u *url.URL) (bool, error) {
|
||||
host := u.Host
|
||||
now := c.cfg.Now()
|
||||
rules, ok := c.robots.get(host, now)
|
||||
if !ok {
|
||||
robotsURL := u.Scheme + "://" + host + "/robots.txt"
|
||||
resp, err := c.fetch.Get(ctx, robotsURL)
|
||||
switch {
|
||||
case err != nil:
|
||||
// Note what is NOT swallowed: a refusal from the guarded fetcher.
|
||||
// If webfetch says this host is denied or private, the page fetch
|
||||
// would fail the same way, and reporting the real reason beats
|
||||
// reporting a robots verdict we never got.
|
||||
if isFatalFetchError(err) {
|
||||
return false, err
|
||||
}
|
||||
rules = Rules{}
|
||||
default:
|
||||
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
|
||||
}
|
||||
c.robots.put(host, rules, now)
|
||||
}
|
||||
path := u.EscapedPath()
|
||||
if u.RawQuery != "" {
|
||||
path += "?" + u.RawQuery
|
||||
}
|
||||
return rules.Allowed(path), nil
|
||||
}
|
||||
|
||||
// isFatalFetchError — a fetch failure that means "this host is off limits"
|
||||
// rather than "there is no robots.txt here". The sentinel set is webfetch's, but
|
||||
// this package must not import it (the interface exists precisely so it does
|
||||
// not), so the check is on the message. Ugly and honest: the alternative is a
|
||||
// dependency inversion for two strings.
|
||||
func isFatalFetchError(err error) bool {
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "not allowed") || strings.Contains(s, "private address") ||
|
||||
strings.Contains(s, "only http and https")
|
||||
}
|
||||
|
||||
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
|
||||
// hex, first 16 chars. Text and not raw HTML, because a page whose only change
|
||||
// is a rotating ad slot or a CSRF token has not changed.
|
||||
func Hash(text string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(text)))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeFetcher serves canned pages by URL and counts requests, so a test can
|
||||
// assert that robots.txt was read once and that a refusal never reached the page.
|
||||
type fakeFetcher struct {
|
||||
pages map[string]Response
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) Get(_ context.Context, u string) (*Response, error) {
|
||||
f.calls = append(f.calls, u)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
r, ok := f.pages[u]
|
||||
if !ok {
|
||||
return nil, errors.New("http 404")
|
||||
}
|
||||
if r.URL == "" {
|
||||
r.URL = u
|
||||
}
|
||||
if r.ContentType == "" {
|
||||
r.ContentType = "text/html; charset=utf-8"
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
const htmlPage = `<html><head><title>Почему небо синее</title>
|
||||
<style>body{color:red}</style><script>track()</script></head>
|
||||
<body><nav>меню</nav><h1>Небо</h1>
|
||||
<p>Свет рассеивается на молекулах воздуха.</p>
|
||||
<p>Короткие волны рассеиваются сильнее.</p>
|
||||
<footer>© 2026</footer></body></html>`
|
||||
|
||||
func newTestCrawler(f *fakeFetcher) *Crawler {
|
||||
return New(f, Config{UserAgent: "Maven/1.0", Now: func() time.Time { return time.Unix(0, 0) }})
|
||||
}
|
||||
|
||||
func TestPageExtractsText(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{
|
||||
"https://example.org/sky": {Body: []byte(htmlPage)},
|
||||
}}
|
||||
page, err := newTestCrawler(f).Page(context.Background(), "https://example.org/sky")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Title != "Почему небо синее" {
|
||||
t.Errorf("title = %q", page.Title)
|
||||
}
|
||||
if !strings.Contains(page.Text, "Свет рассеивается") {
|
||||
t.Errorf("body text missing: %q", page.Text)
|
||||
}
|
||||
for _, junk := range []string{"track()", "color:red", "меню", "© 2026"} {
|
||||
if strings.Contains(page.Text, junk) {
|
||||
t.Errorf("%q survived extraction: %q", junk, page.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotsIsCheckedAndObeyed(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{
|
||||
"https://example.org/robots.txt": {Body: []byte("User-agent: *\nDisallow: /secret\n"), ContentType: "text/plain"},
|
||||
"https://example.org/secret/x": {Body: []byte(htmlPage)},
|
||||
"https://example.org/open": {Body: []byte(htmlPage)},
|
||||
}}
|
||||
c := newTestCrawler(f)
|
||||
if _, err := c.Page(context.Background(), "https://example.org/secret/x"); !errors.Is(err, ErrRobots) {
|
||||
t.Fatalf("error = %v, want ErrRobots", err)
|
||||
}
|
||||
for _, u := range f.calls {
|
||||
if strings.Contains(u, "/secret") {
|
||||
t.Fatal("the disallowed page was fetched anyway")
|
||||
}
|
||||
}
|
||||
if _, err := c.Page(context.Background(), "https://example.org/open"); err != nil {
|
||||
t.Fatalf("allowed page: %v", err)
|
||||
}
|
||||
// robots.txt was read once for the host, not once per page.
|
||||
robotsReads := 0
|
||||
for _, u := range f.calls {
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
robotsReads++
|
||||
}
|
||||
}
|
||||
if robotsReads != 1 {
|
||||
t.Fatalf("robots.txt read %d times, want 1", robotsReads)
|
||||
}
|
||||
}
|
||||
|
||||
// No robots.txt means allow — that is the standard, and the alternative makes
|
||||
// most of the web unreadable.
|
||||
func TestMissingRobotsAllows(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{
|
||||
"https://example.org/page": {Body: []byte(htmlPage)},
|
||||
}}
|
||||
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/page"); err != nil {
|
||||
t.Fatalf("err = %v, want the page", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal from the guarded fetcher must surface as itself, not be laundered
|
||||
// into "no robots.txt, go ahead".
|
||||
func TestFetcherRefusalIsNotSwallowed(t *testing.T) {
|
||||
f := &fakeFetcher{err: errors.New("webfetch: refusing to connect to a private address: 127.0.0.1")}
|
||||
_, err := newTestCrawler(f).Page(context.Background(), "http://127.0.0.1:9100/mcp")
|
||||
if err == nil || !strings.Contains(err.Error(), "private address") {
|
||||
t.Fatalf("error = %v, want the fetcher's refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonTextIsRefused(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{
|
||||
"https://example.org/f.pdf": {Body: []byte("%PDF-1.7"), ContentType: "application/pdf"},
|
||||
}}
|
||||
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/f.pdf"); !errors.Is(err, ErrNotHTML) {
|
||||
t.Fatalf("error = %v, want ErrNotHTML", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxRunesCapsText(t *testing.T) {
|
||||
long := "<html><body><p>" + strings.Repeat("привет ", 2000) + "</p></body></html>"
|
||||
f := &fakeFetcher{pages: map[string]Response{"https://example.org/l": {Body: []byte(long)}}}
|
||||
c := New(f, Config{MaxRunes: 50})
|
||||
page, err := c.Page(context.Background(), "https://example.org/l")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len([]rune(page.Text)); n > 51 {
|
||||
t.Fatalf("text = %d runes, want the 50-rune cap", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithoutFetcherIsNil(t *testing.T) {
|
||||
if New(nil, Config{}) != nil {
|
||||
t.Fatal("a crawler with no fetcher must be nil — crawling is off unless configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIgnoresNothingButText(t *testing.T) {
|
||||
if Hash("a") == Hash("b") {
|
||||
t.Fatal("different text hashed the same")
|
||||
}
|
||||
if Hash(" same \n") != Hash("same") {
|
||||
t.Fatal("surrounding whitespace changed the hash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML → text, with a regexp and no tokenizer.
|
||||
//
|
||||
// golang.org/x/net/html is not vendored and the network is not assumed, so this
|
||||
// is stdlib. That is less of a compromise than it sounds: the unit of context
|
||||
// here is a few hundred words for a 4096-token model to read, exactly like the
|
||||
// Kiwix snippet, so what matters is dropping script/style/nav noise and keeping
|
||||
// paragraph boundaries. A DOM would buy correctness on malformed markup that is
|
||||
// then thrown away by truncation anyway.
|
||||
//
|
||||
// What this deliberately does NOT do: run JavaScript, follow links, or extract
|
||||
// structured fields with CSS selectors or an LLM prompt. The plan's step 2 asked
|
||||
// for the last of those; see docs/plans/14-web-crawler.md for why it was left
|
||||
// out for now.
|
||||
|
||||
var (
|
||||
// RE2 has no backreferences, so each tag pair is spelled out rather than
|
||||
// captured and matched against itself.
|
||||
dropRE = regexp.MustCompile(pairsRE("script", "style", "noscript", "svg", "head", "nav", "footer", "form"))
|
||||
titleRE = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
|
||||
h1RE = regexp.MustCompile(`(?is)<h1\b[^>]*>(.*?)</h1>`)
|
||||
// Block-level tags become newlines so paragraphs survive as paragraphs.
|
||||
blockRE = regexp.MustCompile(`(?is)</?(p|div|br|li|tr|h[1-6]|section|article|blockquote|pre)\b[^>]*>`)
|
||||
tagRE = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
commentRE = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
spaceRE = regexp.MustCompile(`[ \t\f\v]+`)
|
||||
blankRE = regexp.MustCompile(`\n{2,}`)
|
||||
)
|
||||
|
||||
// pairsRE builds `(?is)<tag …>…</tag>|…` for the given tags.
|
||||
func pairsRE(tags ...string) string {
|
||||
parts := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
parts = append(parts, `<`+t+`\b[^>]*>.*?</`+t+`>`)
|
||||
}
|
||||
return `(?is)` + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// Page is an extracted page.
|
||||
type Page struct {
|
||||
URL string
|
||||
Title string
|
||||
Text string // plain text, paragraphs separated by single newlines
|
||||
}
|
||||
|
||||
// Extract turns a fetched HTML document into a Page. maxRunes caps the text (0 ⇒
|
||||
// DefaultMaxRunes); the cap is on runes, not bytes, because a Russian page cut
|
||||
// at a byte boundary ends in half a letter.
|
||||
func Extract(url string, body []byte, maxRunes int) Page {
|
||||
if maxRunes <= 0 {
|
||||
maxRunes = DefaultMaxRunes
|
||||
}
|
||||
s := string(body)
|
||||
s = commentRE.ReplaceAllString(s, " ")
|
||||
|
||||
title := firstGroup(titleRE, s)
|
||||
if title == "" {
|
||||
title = firstGroup(h1RE, s)
|
||||
}
|
||||
|
||||
s = dropRE.ReplaceAllString(s, "\n")
|
||||
s = blockRE.ReplaceAllString(s, "\n")
|
||||
s = tagRE.ReplaceAllString(s, " ")
|
||||
s = html.UnescapeString(s)
|
||||
s = spaceRE.ReplaceAllString(s, " ")
|
||||
|
||||
var lines []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
text := blankRE.ReplaceAllString(strings.Join(lines, "\n"), "\n")
|
||||
|
||||
return Page{URL: url, Title: title, Text: TrimRunes(text, maxRunes)}
|
||||
}
|
||||
|
||||
// DefaultMaxRunes — how much of a page is kept. ~4000 runes is a long answer's
|
||||
// worth of context and still leaves room in a 4096-token window for the prompt
|
||||
// and the reply.
|
||||
const DefaultMaxRunes = 4000
|
||||
|
||||
func firstGroup(re *regexp.Regexp, s string) string {
|
||||
m := re.FindStringSubmatch(s)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
t := tagRE.ReplaceAllString(m[1], " ")
|
||||
return strings.TrimSpace(strings.Join(strings.Fields(html.UnescapeString(t)), " "))
|
||||
}
|
||||
|
||||
// TrimRunes cuts s to at most max runes, on a rune boundary.
|
||||
func TrimRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:max])) + "…"
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// robots.txt, parsed the small way: no wildcards beyond the two the standard
|
||||
// actually defines (`*` inside a path and `$` at the end), no sitemaps, no
|
||||
// crawl-delay-per-agent gymnastics. A personal assistant reading a handful of
|
||||
// pages does not need a spec-complete implementation; it needs to not be rude,
|
||||
// and to be auditable in one sitting.
|
||||
//
|
||||
// Two rules worth stating because they are choices, not accidents:
|
||||
//
|
||||
// - a missing or unreadable robots.txt means ALLOW. That is what the standard
|
||||
// says (404 ⇒ unrestricted), and the alternative would make a site that
|
||||
// simply has no robots.txt unreadable;
|
||||
// - an explicit Disallow means REFUSE, and Maven does not offer an override.
|
||||
// There is no "but he asked me to" flag: the page is not read.
|
||||
|
||||
// Rules is a parsed robots.txt for one user-agent.
|
||||
type Rules struct {
|
||||
allow []string
|
||||
disallow []string
|
||||
// Delay is Crawl-delay in seconds when the group named one, 0 otherwise.
|
||||
// The fetcher's own per-host rate limit is the floor; this can only make
|
||||
// Maven slower, never faster.
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
// ParseRobots reads robots.txt and returns the rules that apply to agent.
|
||||
//
|
||||
// Group selection follows the standard: the most specific matching group wins,
|
||||
// which here means an exact user-agent match beats `*`. Lines that are neither
|
||||
// are ignored rather than guessed at.
|
||||
func ParseRobots(body string, agent string) Rules {
|
||||
agent = strings.ToLower(agent)
|
||||
|
||||
type group struct {
|
||||
agents []string
|
||||
allow []string
|
||||
disallow []string
|
||||
delay time.Duration
|
||||
}
|
||||
var groups []group
|
||||
var cur *group
|
||||
// startNew tracks whether the next User-agent line opens a new group or
|
||||
// joins the current one: consecutive User-agent lines share their rules.
|
||||
startNew := true
|
||||
|
||||
for _, raw := range strings.Split(body, "\n") {
|
||||
line := raw
|
||||
if i := strings.IndexByte(line, '#'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
val = strings.TrimSpace(val)
|
||||
|
||||
switch key {
|
||||
case "user-agent":
|
||||
if startNew || cur == nil {
|
||||
groups = append(groups, group{})
|
||||
cur = &groups[len(groups)-1]
|
||||
startNew = false
|
||||
}
|
||||
cur.agents = append(cur.agents, strings.ToLower(val))
|
||||
case "disallow":
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
startNew = true
|
||||
// "Disallow:" with an empty value allows everything, and is not a
|
||||
// path rule at all.
|
||||
if val != "" {
|
||||
cur.disallow = append(cur.disallow, val)
|
||||
}
|
||||
case "allow":
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
startNew = true
|
||||
if val != "" {
|
||||
cur.allow = append(cur.allow, val)
|
||||
}
|
||||
case "crawl-delay":
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
startNew = true
|
||||
if d, err := time.ParseDuration(val + "s"); err == nil && d > 0 {
|
||||
cur.delay = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var star, exact *group
|
||||
for i := range groups {
|
||||
for _, a := range groups[i].agents {
|
||||
if a == "*" && star == nil {
|
||||
star = &groups[i]
|
||||
}
|
||||
// A robots.txt names "maven", we send "Maven/1.0 (…)": match on
|
||||
// prefix, which is how every crawler reads this field.
|
||||
if a != "*" && a != "" && strings.HasPrefix(agent, a) {
|
||||
exact = &groups[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
g := exact
|
||||
if g == nil {
|
||||
g = star
|
||||
}
|
||||
if g == nil {
|
||||
return Rules{}
|
||||
}
|
||||
return Rules{allow: g.allow, disallow: g.disallow, Delay: g.delay}
|
||||
}
|
||||
|
||||
// Allowed reports whether path may be fetched. Longest matching rule wins, and
|
||||
// Allow beats Disallow at equal length — the standard's tie-break, and the one
|
||||
// that makes "Disallow: /" plus "Allow: /public" mean what it looks like.
|
||||
func (r Rules) Allowed(path string) bool {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
best, allowed := -1, true
|
||||
for _, p := range r.disallow {
|
||||
if n, ok := matchPath(p, path); ok && n > best {
|
||||
best, allowed = n, false
|
||||
}
|
||||
}
|
||||
for _, p := range r.allow {
|
||||
if n, ok := matchPath(p, path); ok && n >= best {
|
||||
best, allowed = n, true
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// matchPath applies a robots path pattern and returns the pattern's length as
|
||||
// the specificity score. `*` matches any run of characters, `$` anchors the end.
|
||||
// A pattern is a PREFIX match otherwise, which is what "Disallow: /admin" means.
|
||||
func matchPath(pattern, path string) (int, bool) {
|
||||
score := len(pattern)
|
||||
re, err := robotsRegexp(pattern)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return score, re.MatchString(path)
|
||||
}
|
||||
|
||||
// robotsRegexp turns a robots path pattern into an anchored-at-the-start
|
||||
// regexp. Everything but `*` and a trailing `$` is a literal, so the pattern is
|
||||
// quoted first and the two metacharacters are put back afterwards.
|
||||
func robotsRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
end := ""
|
||||
if strings.HasSuffix(pattern, "$") {
|
||||
pattern = strings.TrimSuffix(pattern, "$")
|
||||
end = "$"
|
||||
}
|
||||
parts := strings.Split(pattern, "*")
|
||||
for i, p := range parts {
|
||||
parts[i] = regexp.QuoteMeta(p)
|
||||
}
|
||||
return regexp.Compile("^" + strings.Join(parts, ".*") + end)
|
||||
}
|
||||
|
||||
// robotsCache holds parsed rules per host so a crawl of ten pages on one site
|
||||
// reads robots.txt once. TTL because a site may change its mind, and a daemon
|
||||
// that runs for weeks would otherwise never notice.
|
||||
type robotsCache struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
m map[string]robotsEntry
|
||||
}
|
||||
|
||||
type robotsEntry struct {
|
||||
rules Rules
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newRobotsCache(ttl time.Duration) *robotsCache {
|
||||
return &robotsCache{ttl: ttl, m: map[string]robotsEntry{}}
|
||||
}
|
||||
|
||||
func (c *robotsCache) get(host string, now time.Time) (Rules, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.m[host]
|
||||
if !ok || now.Sub(e.at) > c.ttl {
|
||||
return Rules{}, false
|
||||
}
|
||||
return e.rules, true
|
||||
}
|
||||
|
||||
func (c *robotsCache) put(host string, r Rules, now time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.m[host] = robotsEntry{rules: r, at: now}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const robotsBody = `# a comment
|
||||
User-agent: *
|
||||
Disallow: /private
|
||||
Disallow: /tmp/
|
||||
Crawl-delay: 5
|
||||
|
||||
User-agent: Maven
|
||||
Disallow: /
|
||||
Allow: /public
|
||||
`
|
||||
|
||||
func TestParseRobotsPicksTheMostSpecificGroup(t *testing.T) {
|
||||
// The Maven group applies to us even though we send a longer UA string.
|
||||
r := ParseRobots(robotsBody, "Maven/1.0 (self-hosted personal assistant)")
|
||||
if r.Allowed("/anything") {
|
||||
t.Error("Disallow: / in our own group was ignored")
|
||||
}
|
||||
if !r.Allowed("/public/page") {
|
||||
t.Error("Allow: /public must beat the shorter Disallow: /")
|
||||
}
|
||||
|
||||
// A different agent falls into the * group.
|
||||
star := ParseRobots(robotsBody, "SomeoneElse/2")
|
||||
if !star.Allowed("/anything") {
|
||||
t.Error("the * group disallows nothing but /private and /tmp/")
|
||||
}
|
||||
if star.Allowed("/private/x") || star.Allowed("/tmp/") {
|
||||
t.Error("the * group's disallows were not applied")
|
||||
}
|
||||
if star.Delay != 5*time.Second {
|
||||
t.Errorf("crawl-delay = %v, want 5s", star.Delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRobotsEmptyMeansAllowAll(t *testing.T) {
|
||||
for _, body := range []string{"", "# nothing here\n", "User-agent: *\nDisallow:\n"} {
|
||||
if !ParseRobots(body, "Maven").Allowed("/whatever") {
|
||||
t.Errorf("body %q must allow everything", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotsWildcards(t *testing.T) {
|
||||
r := ParseRobots("User-agent: *\nDisallow: /*.pdf$\nDisallow: /a/*/secret\n", "Maven")
|
||||
if r.Allowed("/docs/manual.pdf") {
|
||||
t.Error("*.pdf$ did not match")
|
||||
}
|
||||
if !r.Allowed("/docs/manual.pdf.html") {
|
||||
t.Error("$ must anchor at the end")
|
||||
}
|
||||
if r.Allowed("/a/b/secret") {
|
||||
t.Error("/a/*/secret did not match")
|
||||
}
|
||||
if !r.Allowed("/a/b/public") {
|
||||
t.Error("unrelated path was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// Consecutive User-agent lines share one group, which is common in the wild.
|
||||
func TestRobotsSharedGroup(t *testing.T) {
|
||||
r := ParseRobots("User-agent: Googlebot\nUser-agent: Maven\nDisallow: /x\n", "Maven/1.0")
|
||||
if r.Allowed("/x/y") {
|
||||
t.Fatal("a shared group's rules were not applied to the second agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotsCacheTTL(t *testing.T) {
|
||||
c := newRobotsCache(time.Minute)
|
||||
now := time.Now()
|
||||
c.put("example.com", ParseRobots("User-agent: *\nDisallow: /\n", "Maven"), now)
|
||||
if _, ok := c.get("example.com", now.Add(30*time.Second)); !ok {
|
||||
t.Error("a fresh entry must be served from cache")
|
||||
}
|
||||
if _, ok := c.get("example.com", now.Add(2*time.Minute)); ok {
|
||||
t.Error("an expired entry must be re-read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scheduled crawls: a page is re-read on an interval, and when its TEXT changed
|
||||
// the new text is written as a note. Nothing is dispatched — same rule as the
|
||||
// feed poller (Vikunja #258). A page that announced its own change would be a
|
||||
// nag, and "the docs page changed" is not worth interrupting anyone for.
|
||||
//
|
||||
// Dedup is by content hash, so a page that re-renders identically writes nothing
|
||||
// and a rotating ad slot does not count as news.
|
||||
|
||||
// WatchConfig — one page to keep an eye on.
|
||||
type WatchConfig struct {
|
||||
Name string // note source is "crawl:<Name>"
|
||||
URL string // http(s), guarded by the fetcher
|
||||
Interval time.Duration // 0 ⇒ Watcher's default
|
||||
}
|
||||
|
||||
// Notes is core's note-writing half (same shape as ipc.CoreAPI's method).
|
||||
type Notes interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
}
|
||||
|
||||
// Hashes remembers the last text hash per watch, durably, so a restart does not
|
||||
// re-note an unchanged page. The daemon backs this with config facts
|
||||
// ("crawl:hash:<name>").
|
||||
type Hashes interface {
|
||||
LastHash(ctx context.Context, name string) (string, error)
|
||||
SetHash(ctx context.Context, name, hash string) error
|
||||
}
|
||||
|
||||
// Embedder embeds a note on its way into the store. nil ⇒ no vector.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
}
|
||||
|
||||
// DefaultWatchInterval — pages change slowly, and every check is a request in
|
||||
// someone's log.
|
||||
const DefaultWatchInterval = 6 * time.Hour
|
||||
|
||||
// Watcher re-reads watched pages on their interval.
|
||||
type Watcher struct {
|
||||
c *Crawler
|
||||
watches []WatchConfig
|
||||
notes Notes
|
||||
hashes Hashes
|
||||
embed Embedder
|
||||
interval time.Duration
|
||||
nextDue map[string]time.Time
|
||||
}
|
||||
|
||||
// NewWatcher wires the scheduled half, or returns nil when there is nothing to
|
||||
// watch. Callers check for nil: no watches, no goroutine, no request.
|
||||
func NewWatcher(c *Crawler, watches []WatchConfig, notes Notes, hashes Hashes, embed Embedder, defaultInterval time.Duration) *Watcher {
|
||||
if c == nil || notes == nil {
|
||||
return nil
|
||||
}
|
||||
var valid []WatchConfig
|
||||
for _, w := range watches {
|
||||
if strings.TrimSpace(w.Name) == "" || strings.TrimSpace(w.URL) == "" {
|
||||
log.Printf("crawl: skipping a watch with no name or no url")
|
||||
continue
|
||||
}
|
||||
valid = append(valid, w)
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return nil
|
||||
}
|
||||
if defaultInterval <= 0 {
|
||||
defaultInterval = DefaultWatchInterval
|
||||
}
|
||||
return &Watcher{
|
||||
c: c, watches: valid, notes: notes, hashes: hashes, embed: embed,
|
||||
interval: defaultInterval, nextDue: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Watches returns the configured watches.
|
||||
func (w *Watcher) Watches() []WatchConfig { return w.watches }
|
||||
|
||||
// CheckDue re-reads every watch whose interval elapsed and returns how many
|
||||
// notes were written. Errors are logged per watch, never returned: one dead page
|
||||
// must not stop the others.
|
||||
func (w *Watcher) CheckDue(ctx context.Context, now time.Time) int {
|
||||
written := 0
|
||||
for _, watch := range w.watches {
|
||||
if due, ok := w.nextDue[watch.Name]; ok && now.Before(due) {
|
||||
continue
|
||||
}
|
||||
interval := watch.Interval
|
||||
if interval <= 0 {
|
||||
interval = w.interval
|
||||
}
|
||||
w.nextDue[watch.Name] = now.Add(interval)
|
||||
changed, err := w.Check(ctx, watch, now)
|
||||
if err != nil {
|
||||
log.Printf("crawl: watch %s: %v", watch.Name, err)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
log.Printf("crawl: watch %s: page changed, noted", watch.Name)
|
||||
written++
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// Check re-reads one watch now and reports whether it wrote a note.
|
||||
func (w *Watcher) Check(ctx context.Context, watch WatchConfig, now time.Time) (bool, error) {
|
||||
page, err := w.c.Page(ctx, watch.URL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Title included: a page whose headline changed has changed.
|
||||
h := Hash(page.Title + "\n" + page.Text)
|
||||
if w.hashes != nil {
|
||||
prev, err := w.hashes.LastHash(ctx, watch.Name)
|
||||
if err != nil {
|
||||
log.Printf("crawl: watch %s: read hash: %v", watch.Name, err)
|
||||
}
|
||||
if prev == h {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
text := NoteText(watch, page)
|
||||
var vec []float32
|
||||
if w.embed != nil {
|
||||
v, err := w.embed.Embed(ctx, text)
|
||||
if err != nil {
|
||||
log.Printf("crawl: watch %s: embed: %v", watch.Name, err)
|
||||
} else {
|
||||
vec = v
|
||||
}
|
||||
}
|
||||
if _, err := w.notes.WriteNote(ctx, now, text, vec, SourceFor(watch.Name)); err != nil {
|
||||
return false, fmt.Errorf("write note: %w", err)
|
||||
}
|
||||
if w.hashes != nil {
|
||||
if err := w.hashes.SetHash(ctx, watch.Name, h); err != nil {
|
||||
log.Printf("crawl: watch %s: save hash: %v", watch.Name, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SourceFor is the note source for a watch, and SourcePrefix is what the answer
|
||||
// path matches to recognise one.
|
||||
func SourceFor(name string) string { return SourcePrefix + name }
|
||||
|
||||
// SourcePrefix — provenance for anything read off the network on a schedule.
|
||||
const SourcePrefix = "crawl:"
|
||||
|
||||
// noteRunes — how much of a watched page goes into a note. Shorter than what the
|
||||
// on-demand path reads: a note is a record of a change, not an archive.
|
||||
const noteRunes = 800
|
||||
|
||||
// NoteText renders a watched page as a note body.
|
||||
func NoteText(watch WatchConfig, page Page) string {
|
||||
var b strings.Builder
|
||||
if page.Title != "" {
|
||||
b.WriteString(page.Title)
|
||||
} else {
|
||||
b.WriteString(watch.Name)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(TrimRunes(page.Text, noteRunes))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(watch.URL)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type note struct {
|
||||
text string
|
||||
source string
|
||||
}
|
||||
|
||||
type fakeNotes struct{ notes []note }
|
||||
|
||||
func (n *fakeNotes) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, source string) (int64, error) {
|
||||
n.notes = append(n.notes, note{text, source})
|
||||
return int64(len(n.notes)), nil
|
||||
}
|
||||
|
||||
type fakeHashes struct{ m map[string]string }
|
||||
|
||||
func newHashes() *fakeHashes { return &fakeHashes{m: map[string]string{}} }
|
||||
func (f *fakeHashes) LastHash(_ context.Context, name string) (string, error) {
|
||||
return f.m[name], nil
|
||||
}
|
||||
func (f *fakeHashes) SetHash(_ context.Context, name, h string) error { f.m[name] = h; return nil }
|
||||
|
||||
var t0 = time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestWatchNotesAChangedPage(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{
|
||||
"https://example.org/docs": {Body: []byte(htmlPage)},
|
||||
}}
|
||||
notes := &fakeNotes{}
|
||||
hashes := newHashes()
|
||||
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "docs", URL: "https://example.org/docs"}},
|
||||
notes, hashes, nil, time.Hour)
|
||||
if w == nil {
|
||||
t.Fatal("NewWatcher returned nil for a configured watch")
|
||||
}
|
||||
if n := w.CheckDue(context.Background(), t0); n != 1 {
|
||||
t.Fatalf("first check wrote %d notes, want 1", n)
|
||||
}
|
||||
if notes.notes[0].source != "crawl:docs" {
|
||||
t.Errorf("source = %q, want crawl:docs", notes.notes[0].source)
|
||||
}
|
||||
if !strings.Contains(notes.notes[0].text, "https://example.org/docs") {
|
||||
t.Errorf("note does not carry the url: %q", notes.notes[0].text)
|
||||
}
|
||||
|
||||
// Unchanged page, interval elapsed: nothing written.
|
||||
if n := w.CheckDue(context.Background(), t0.Add(2*time.Hour)); n != 0 {
|
||||
t.Fatalf("an unchanged page wrote %d notes", n)
|
||||
}
|
||||
|
||||
// Changed page: one note.
|
||||
f.pages["https://example.org/docs"] = Response{Body: []byte(strings.Replace(htmlPage, "синее", "серое", 1))}
|
||||
if n := w.CheckDue(context.Background(), t0.Add(4*time.Hour)); n != 1 {
|
||||
t.Fatalf("a changed page wrote %d notes, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchIntervalIsRespected(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
|
||||
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "d", URL: "https://example.org/d", Interval: time.Hour}},
|
||||
&fakeNotes{}, newHashes(), nil, 0)
|
||||
w.CheckDue(context.Background(), t0)
|
||||
before := len(f.calls)
|
||||
w.CheckDue(context.Background(), t0.Add(time.Minute))
|
||||
if len(f.calls) != before {
|
||||
t.Fatal("the page was re-read inside its interval")
|
||||
}
|
||||
}
|
||||
|
||||
// The hash is durable so a restart does not re-note an unchanged page.
|
||||
func TestWatchHashSurvivesRestart(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
|
||||
hashes := newHashes()
|
||||
watches := []WatchConfig{{Name: "d", URL: "https://example.org/d"}}
|
||||
NewWatcher(newTestCrawler(f), watches, &fakeNotes{}, hashes, nil, time.Hour).CheckDue(context.Background(), t0)
|
||||
|
||||
notes2 := &fakeNotes{}
|
||||
NewWatcher(newTestCrawler(f), watches, notes2, hashes, nil, time.Hour).CheckDue(context.Background(), t0.Add(time.Hour))
|
||||
if len(notes2.notes) != 0 {
|
||||
t.Fatalf("a fresh watcher re-noted an unchanged page: %q", notes2.notes[0].text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchDeadPageDoesNotStopTheOthers(t *testing.T) {
|
||||
f := &fakeFetcher{pages: map[string]Response{"https://example.org/live": {Body: []byte(htmlPage)}}}
|
||||
notes := &fakeNotes{}
|
||||
w := NewWatcher(newTestCrawler(f), []WatchConfig{
|
||||
{Name: "dead", URL: "https://example.org/gone"},
|
||||
{Name: "live", URL: "https://example.org/live"},
|
||||
}, notes, newHashes(), nil, time.Hour)
|
||||
if n := w.CheckDue(context.Background(), t0); n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1 (the live page)", n)
|
||||
}
|
||||
if notes.notes[0].source != "crawl:live" {
|
||||
t.Fatalf("source = %q", notes.notes[0].source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWatchesMeansNoWatcher(t *testing.T) {
|
||||
c := newTestCrawler(&fakeFetcher{})
|
||||
if NewWatcher(c, nil, &fakeNotes{}, nil, nil, 0) != nil {
|
||||
t.Fatal("no watches must mean no watcher")
|
||||
}
|
||||
if NewWatcher(nil, []WatchConfig{{Name: "a", URL: "u"}}, &fakeNotes{}, nil, nil, 0) != nil {
|
||||
t.Fatal("no crawler must mean no watcher")
|
||||
}
|
||||
if NewWatcher(c, []WatchConfig{{Name: "", URL: ""}}, &fakeNotes{}, nil, nil, 0) != nil {
|
||||
t.Fatal("a watch with no name or url is not a configuration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
)
|
||||
|
||||
// Extraction — turning one mail into task CANDIDATES, and nothing else.
|
||||
//
|
||||
// The output of this file can only ever become rows in `tasks` with status
|
||||
// "candidate" (store.TaskCandidate), written through the one intake seam
|
||||
// (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design:
|
||||
//
|
||||
// - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that
|
||||
// misreads "встреча была в четверг" as a future appointment would then wake
|
||||
// him up about it. A candidate that is wrong is a line on a review page he
|
||||
// dismisses in one click, which is the correct cost of a model being wrong
|
||||
// about someone's mail.
|
||||
// - No fact. A fact is a claim Maven will later recite as true. Nothing read
|
||||
// out of a marketing mail deserves that standing.
|
||||
// - No calendar event, no note, no action. Extraction writes candidates or
|
||||
// writes nothing.
|
||||
//
|
||||
// The due date the model may return is stored on the candidate (tasks.due_ts),
|
||||
// which no scheduler reads — it is there so the review page can sort by it.
|
||||
//
|
||||
// Privacy: the mail text goes to the resident model on this box and nowhere
|
||||
// else. It is never search input (CLAUDE.md: "his notes and facts are never
|
||||
// search input" — mail is the same class), and Evidence keeps only the subject
|
||||
// line, so the review page shows him where a candidate came from without the
|
||||
// store growing a copy of his mailbox.
|
||||
|
||||
// MaxCandidates — at most this many candidates per message, enforced by the
|
||||
// grammar. A mail with four tasks in it is a mail he has to read himself; a
|
||||
// model allowed ten will produce ten.
|
||||
const MaxCandidates = 3
|
||||
|
||||
// SourcePrefix — provenance for everything this package captures. The mailbox
|
||||
// name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata.
|
||||
const SourcePrefix = "email:"
|
||||
|
||||
// Candidate — one piece of work the model thinks the mail is asking for.
|
||||
type Candidate struct {
|
||||
Text string `json:"text"`
|
||||
// Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a
|
||||
// date it computed: relative wording ("до пятницы") is left in Text, because
|
||||
// a small model resolving "пятница" against today's date gets it wrong often
|
||||
// enough that a stored wrong date is worse than no date.
|
||||
Due string `json:"due"`
|
||||
}
|
||||
|
||||
// Completer — the llama-server seam, same shape memeval and the router use, so
|
||||
// the one resident model serves this caller too.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// Extractor reads a message and returns candidates. It holds no store and no
|
||||
// writer on purpose: this type cannot persist anything, so "extraction never
|
||||
// acts" is a property of the code, not of a review.
|
||||
type Extractor struct {
|
||||
llm Completer
|
||||
// MaxCandidates — 0 ⇒ MaxCandidates.
|
||||
max int
|
||||
// ContextBlock — the shared persona block, optional. Extraction output is
|
||||
// not spoken, so the persona matters less here than in the phraser; it is
|
||||
// wired anyway so a candidate reads in her voice on the review page.
|
||||
contextBlock func() string
|
||||
}
|
||||
|
||||
func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor {
|
||||
if max <= 0 || max > MaxCandidates {
|
||||
max = MaxCandidates
|
||||
}
|
||||
return &Extractor{llm: c, max: max, contextBlock: contextBlock}
|
||||
}
|
||||
|
||||
// extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape
|
||||
// candidates. Same reasoning as memeval's evalGrammar and the router's
|
||||
// routeGrammar: the shape and the length bound are what keep a small model from
|
||||
// drifting into prose or spending the token budget repeating one field.
|
||||
//
|
||||
// The empty array is reachable, deliberately: most mail contains no task, and a
|
||||
// model with no way to say "nothing" invents something.
|
||||
const extractGrammar = `
|
||||
root ::= "[" ws (item ("," ws item){0,2})? ws "]"
|
||||
item ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}"
|
||||
text ::= "\"" ([^"\\] | "\\" .){1,120} "\""
|
||||
due ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// extractSystem — the extraction prompt.
|
||||
//
|
||||
// Written around the two failure modes a small model has on this task: it
|
||||
// summarises when asked to extract (turning a mail into "письмо от Антона"),
|
||||
// and it invents an obligation from any polite closing sentence. Hence the
|
||||
// insistence on a verb phrase, and the explicit permission to return [].
|
||||
const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует.
|
||||
|
||||
Правила:
|
||||
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}.
|
||||
- text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его.
|
||||
- Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат.
|
||||
- Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего.
|
||||
- Ничего не придумывай. Если срока в письме нет — "".
|
||||
- due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй.
|
||||
- Максимум три дела. Лучше одно точное, чем три общих.`
|
||||
|
||||
// Extract returns the candidates in one message.
|
||||
//
|
||||
// Junk is refused without an LLM call — cheapest possible defence, and the
|
||||
// reason the header filter exists. An empty message (no subject, no body) is
|
||||
// likewise not worth a round trip.
|
||||
//
|
||||
// A parse failure is an error the caller logs and moves past. It is never
|
||||
// silently turned into zero candidates, because "the model went off the rails"
|
||||
// and "the mail contains no task" want different reactions from a human reading
|
||||
// the log.
|
||||
func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) {
|
||||
if msg.Junk {
|
||||
return nil, nil
|
||||
}
|
||||
user := renderForModel(msg)
|
||||
if user == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := e.llm.Complete(ctx, llm.Req{
|
||||
System: persona.Prepend(e.contextBlock, extractSystem),
|
||||
User: user,
|
||||
Grammar: extractGrammar,
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: extract: %w", err)
|
||||
}
|
||||
items, err := parseCandidates(raw)
|
||||
if err != nil {
|
||||
// The raw reply is NOT in the error: it is a transformation of his mail,
|
||||
// and this error reaches the daemon log.
|
||||
return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw))
|
||||
}
|
||||
out := make([]Candidate, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
it.Text = strings.TrimSpace(it.Text)
|
||||
if it.Text == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.Join(strings.Fields(it.Text), " "))
|
||||
if seen[key] {
|
||||
continue // the model repeating itself is not two tasks
|
||||
}
|
||||
seen[key] = true
|
||||
if _, ok := ParseDue(it.Due); !ok {
|
||||
it.Due = "" // a date the grammar allowed but the calendar does not
|
||||
}
|
||||
out = append(out, it)
|
||||
if len(out) >= e.max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// renderForModel is the user turn: subject, sender and body, labelled. Only
|
||||
// these three fields — no headers, no recipient list, no message-id, nothing
|
||||
// that would let the model start reasoning about routing metadata.
|
||||
func renderForModel(msg Message) string {
|
||||
var b strings.Builder
|
||||
if msg.From != "" {
|
||||
fmt.Fprintf(&b, "От: %s\n", msg.From)
|
||||
}
|
||||
if msg.Subject != "" {
|
||||
fmt.Fprintf(&b, "Тема: %s\n", msg.Subject)
|
||||
}
|
||||
if msg.Body != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", msg.Body)
|
||||
}
|
||||
if msg.Subject == "" && msg.Body == "" {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseCandidates decodes the grammar-constrained reply, tolerating the
|
||||
// wrappers a Thinking model sometimes leaves around it (a fenced block, or
|
||||
// leading reasoning before the array).
|
||||
func parseCandidates(raw string) ([]Candidate, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if i := strings.Index(s, "["); i > 0 {
|
||||
s = s[i:]
|
||||
}
|
||||
if j := strings.LastIndex(s, "]"); j >= 0 {
|
||||
s = s[:j+1]
|
||||
}
|
||||
var out []Candidate
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because
|
||||
// the daemon-side intake stores it on the candidate.
|
||||
//
|
||||
// The zero-value/empty case returns ok=false rather than an error: no date is
|
||||
// the common answer, not a failure.
|
||||
func ParseDue(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// fakeLLM returns a canned reply and records the request, so a test can assert
|
||||
// on the grammar and on what of the mail was sent.
|
||||
type fakeLLM struct {
|
||||
reply string
|
||||
err error
|
||||
got llm.Req
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
f.calls++
|
||||
f.got = r
|
||||
return f.reply, f.err
|
||||
}
|
||||
|
||||
func msgFor(subject, body string) Message {
|
||||
return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body}
|
||||
}
|
||||
|
||||
func TestExtractCandidates(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`}
|
||||
e := NewExtractor(f, 0, nil)
|
||||
got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа."))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" {
|
||||
t.Errorf("candidates = %+v", got)
|
||||
}
|
||||
if f.got.Grammar == "" {
|
||||
t.Error("extraction must be grammar-constrained")
|
||||
}
|
||||
// The subject and body go to the model; nothing else about the message does.
|
||||
if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") {
|
||||
t.Errorf("user turn = %q", f.got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmptyArrayIsNotAnError(t *testing.T) {
|
||||
f := &fakeLLM{reply: "[]"}
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению."))
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Junk must never reach the model: the header filter exists so the resident
|
||||
// model is not spent on newsletters.
|
||||
func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`}
|
||||
msg := msgFor("Скидки", "Sale!")
|
||||
msg.Junk = true
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
if f.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmptyMessageIsNotSent(t *testing.T) {
|
||||
f := &fakeLLM{reply: "[]"}
|
||||
if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if f.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCaps(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`}
|
||||
got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d, want the configured cap of 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDropsRepeatsAndBadDates(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`}
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Due != "" {
|
||||
t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due)
|
||||
}
|
||||
}
|
||||
|
||||
// A Thinking model sometimes wraps the array; and when it emits something
|
||||
// unparsable the caller must hear about it rather than see "no tasks".
|
||||
func TestParseCandidatesTolerance(t *testing.T) {
|
||||
got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё")
|
||||
if err != nil || len(got) != 1 || got[0].Text != "x" {
|
||||
t.Fatalf("got (%+v, %v)", got, err)
|
||||
}
|
||||
if _, err := parseCandidates("нет никакого JSON"); err == nil {
|
||||
t.Error("unparsable output must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractParseErrorHidesMailText(t *testing.T) {
|
||||
f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"}
|
||||
_, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст"))
|
||||
if err == nil {
|
||||
t.Fatal("want an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") {
|
||||
t.Errorf("error text leaks mail content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDue(t *testing.T) {
|
||||
if _, ok := ParseDue(""); ok {
|
||||
t.Error("empty due must be (zero, false)")
|
||||
}
|
||||
if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 {
|
||||
t.Errorf("ParseDue = (%v, %v)", got, ok)
|
||||
}
|
||||
if _, ok := ParseDue("05.08.2026"); ok {
|
||||
t.Error("a non-ISO date must not parse")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchSince is the whole read path in one call: connect, log in, examine the
|
||||
// mailbox read-only, list what arrived since a date, fetch and parse the ones
|
||||
// the caller has not seen, log out.
|
||||
//
|
||||
// It is a function rather than a long-lived object because a mail poller should
|
||||
// not hold an authenticated session (and therefore his credential in a live TLS
|
||||
// state) between polls. Connect, read, drop.
|
||||
//
|
||||
// skip decides which UIDs are already known — the poller's seen-set. max bounds
|
||||
// one poll: a mailbox that received 400 messages overnight must not turn into
|
||||
// 400 LLM calls, and the newest max are the ones a task could still be hiding
|
||||
// in. Junk messages are returned too, flagged, so the caller can mark them seen
|
||||
// without a second protocol round.
|
||||
type FetchSince struct {
|
||||
Addr string // host or host:993
|
||||
User string
|
||||
Mailbox string // e.g. "INBOX"
|
||||
Timeout time.Duration
|
||||
Since time.Time
|
||||
Max int
|
||||
Skip func(uid uint32) bool
|
||||
}
|
||||
|
||||
// Run performs one read. password is passed here, not stored in the struct, so
|
||||
// the configuration of a mailbox and the secret for it are never the same value
|
||||
// sitting in the same place.
|
||||
func (f FetchSince) Run(password string) ([]Message, error) {
|
||||
return f.RunWith(password, nil)
|
||||
}
|
||||
|
||||
// RunWith is Run with an explicit connection function, which is how the reader
|
||||
// daemon and the tests substitute an in-process server. nil ⇒ Dial, i.e.
|
||||
// implicit TLS with certificate verification; there is no configuration path
|
||||
// that reaches this, so no deployment can end up talking cleartext IMAP.
|
||||
func (f FetchSince) RunWith(password string, dial func(addr string, timeout time.Duration) (*Conn, error)) ([]Message, error) {
|
||||
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
|
||||
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
|
||||
}
|
||||
if dial == nil {
|
||||
dial = Dial
|
||||
}
|
||||
c, err := dial(f.Addr, f.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Login(f.User, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Select(f.Mailbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uids, err := c.SearchSince(f.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
|
||||
// list the recent mail is what matters.
|
||||
wanted := make([]uint32, 0, len(uids))
|
||||
for i := len(uids) - 1; i >= 0; i-- {
|
||||
if f.Skip != nil && f.Skip(uids[i]) {
|
||||
continue
|
||||
}
|
||||
wanted = append(wanted, uids[i])
|
||||
if f.Max > 0 && len(wanted) >= f.Max {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Message, 0, len(wanted))
|
||||
for _, uid := range wanted {
|
||||
raw, err := c.Fetch(uid)
|
||||
if err != nil {
|
||||
// One unreadable message does not abandon the poll; the rest of the
|
||||
// mailbox is still worth reading. The error names the UID, not the
|
||||
// message.
|
||||
return out, fmt.Errorf("email: fetch uid %d: %w", uid, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue // vanished between SEARCH and FETCH
|
||||
}
|
||||
msg, err := ParseMessage(uid, raw)
|
||||
if err != nil {
|
||||
continue // unparsable headers — nothing to review, skip silently
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFetchSinceRun(t *testing.T) {
|
||||
mk := func(subject string) string {
|
||||
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
|
||||
}
|
||||
f := &fakeIMAP{
|
||||
uids: []uint32{1, 2, 3},
|
||||
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
|
||||
}
|
||||
fs := FetchSince{
|
||||
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
|
||||
Timeout: 5 * time.Second,
|
||||
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
|
||||
Max: 2,
|
||||
Skip: func(uid uint32) bool { return uid == 3 },
|
||||
}
|
||||
msgs, err := fs.RunWith("secret", func(addr string, timeout time.Duration) (*Conn, error) {
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(t, srv)
|
||||
return NewConn(cli, timeout)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
// Newest first, the already-seen UID skipped, Max respected.
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
|
||||
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
|
||||
}
|
||||
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
|
||||
t.Error("a skipped UID must not be fetched again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSinceRequiresConfig(t *testing.T) {
|
||||
if _, err := (FetchSince{}).Run("secret"); err == nil {
|
||||
t.Fatal("an unconfigured mailbox must not be read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
|
||||
// BODY.PEEK, LOGOUT, and nothing else.
|
||||
//
|
||||
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
|
||||
// commands, and this is the one code path that holds his mailbox credential and
|
||||
// reads his private mail. A ~200-line client with no dependencies is auditable
|
||||
// in one sitting; a general-purpose IMAP library is a much larger amount of
|
||||
// code doing much more than we asked, in the most sensitive place in the tree.
|
||||
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
|
||||
// trade should be re-made deliberately.
|
||||
//
|
||||
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
|
||||
// leave no trace of having done so. Reading a message here does not mark it
|
||||
// \Seen, so the unread state in his own mail client stays his.
|
||||
|
||||
// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
|
||||
// path in this client: an option to send his password over a plain socket is an
|
||||
// option to get it wrong once.
|
||||
const DefaultIMAPPort = "993"
|
||||
|
||||
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
|
||||
// poller drives one connection at a time.
|
||||
type Conn struct {
|
||||
rwc io.ReadWriteCloser
|
||||
r *bufio.Reader
|
||||
tag int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Dial opens an implicit-TLS connection and reads the server greeting.
|
||||
func Dial(addr string, timeout time.Duration) (*Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
|
||||
}
|
||||
d := &net.Dialer{Timeout: timeout}
|
||||
// ServerName is set from the host we asked for: certificate verification is
|
||||
// the only thing standing between his password and a MITM on the way out.
|
||||
c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: dial %s: %w", addr, err)
|
||||
}
|
||||
return NewConn(c, timeout)
|
||||
}
|
||||
|
||||
// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
|
||||
// consumes the greeting.
|
||||
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
|
||||
c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: greeting: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
|
||||
c.rwc.Close()
|
||||
return nil, fmt.Errorf("email: server refused connection: %s", line)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error { return c.rwc.Close() }
|
||||
|
||||
// Login authenticates with LOGIN. The password is passed as an argument and
|
||||
// never stored on the Conn: nothing in this package keeps a credential alive
|
||||
// past the command that uses it, so no struct dump or panic trace can carry it.
|
||||
func (c *Conn) Login(user, pass string) error {
|
||||
// The command line itself is never logged (see exec) — a LOGIN line IS the
|
||||
// credential.
|
||||
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
|
||||
return fmt.Errorf("email: login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
|
||||
// protocol level means no command in this session can change a flag, expunge a
|
||||
// message, or move anything, even by mistake.
|
||||
func (c *Conn) Select(mailbox string) error {
|
||||
if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
|
||||
return fmt.Errorf("email: examine %s: %w", mailbox, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchSince returns the UIDs of messages received on or after since. An
|
||||
// unlimited search is not offered: the first poll against a years-old mailbox
|
||||
// would otherwise fetch everything and hand a decade of mail to the model.
|
||||
//
|
||||
// The IMAP SINCE key has date granularity (and compares the server's internal
|
||||
// date), so the result can include messages slightly older than since. The
|
||||
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
|
||||
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
|
||||
cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
|
||||
lines, err := c.exec(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: search: %w", err)
|
||||
}
|
||||
var uids []uint32
|
||||
for _, l := range lines {
|
||||
rest, ok := untagged(l, "SEARCH")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(rest) {
|
||||
n, err := strconv.ParseUint(f, 10, 32)
|
||||
if err == nil {
|
||||
uids = append(uids, uint32(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
|
||||
|
||||
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
|
||||
//
|
||||
// Returns (nil, nil) when the UID no longer exists — a message he deleted
|
||||
// between SEARCH and FETCH is normal, not an error.
|
||||
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
|
||||
tag := c.nextTag()
|
||||
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []byte
|
||||
for {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
}
|
||||
if done, err := c.tagged(tag, line); done {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(c.r, buf); err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
|
||||
}
|
||||
if raw == nil {
|
||||
raw = buf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout ends the session politely. A failure is not worth reporting — the
|
||||
// connection is being closed either way.
|
||||
func (c *Conn) Logout() {
|
||||
_, _ = c.exec("LOGOUT")
|
||||
}
|
||||
|
||||
// ---- protocol plumbing -----------------------------------------------------
|
||||
|
||||
func (c *Conn) nextTag() string {
|
||||
c.tag++
|
||||
return fmt.Sprintf("a%03d", c.tag)
|
||||
}
|
||||
|
||||
// exec sends one command and returns the untagged response lines.
|
||||
//
|
||||
// Neither the command nor the response is ever logged here. LOGIN goes through
|
||||
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
|
||||
// ends up in a log file forever.
|
||||
func (c *Conn) exec(cmd string) ([]string, error) {
|
||||
tag := c.nextTag()
|
||||
if err := c.send(tag + " " + cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lines []string
|
||||
for {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if done, err := c.tagged(tag, line); done {
|
||||
return lines, err
|
||||
}
|
||||
lines = append(lines, line)
|
||||
// A response line may carry a literal (e.g. a header FETCH). Nothing we
|
||||
// send asks for one outside Fetch, but skip it if it appears so the
|
||||
// stream stays aligned.
|
||||
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tagged reports whether line completes the command with this tag, and turns a
|
||||
// NO/BAD completion into an error. The error text is the server's, which never
|
||||
// echoes a password.
|
||||
func (c *Conn) tagged(tag, line string) (bool, error) {
|
||||
if !strings.HasPrefix(line, tag+" ") {
|
||||
return false, nil
|
||||
}
|
||||
rest := strings.TrimSpace(line[len(tag):])
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "OK"):
|
||||
return true, nil
|
||||
case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
|
||||
return true, fmt.Errorf("server said: %s", rest)
|
||||
default:
|
||||
return true, fmt.Errorf("unexpected completion: %s", rest)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) send(line string) error {
|
||||
c.setDeadline()
|
||||
if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
|
||||
return fmt.Errorf("email: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) readLine() (string, error) {
|
||||
c.setDeadline()
|
||||
line, err := c.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
// setDeadline applies the per-connection timeout when the transport supports
|
||||
// one. A hung IMAP server must not park the poller forever.
|
||||
func (c *Conn) setDeadline() {
|
||||
if c.timeout <= 0 {
|
||||
return
|
||||
}
|
||||
if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
|
||||
_ = d.SetDeadline(time.Now().Add(c.timeout))
|
||||
}
|
||||
}
|
||||
|
||||
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
|
||||
func untagged(line, key string) (string, bool) {
|
||||
if !strings.HasPrefix(line, "* ") {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[2:])
|
||||
if !strings.HasPrefix(rest, key) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(rest[len(key):]), true
|
||||
}
|
||||
|
||||
// quote renders an IMAP quoted string. Passwords routinely contain characters
|
||||
// that would otherwise end the argument early, and CR/LF are stripped rather
|
||||
// than escaped because there is no legal way to send them — a credential file
|
||||
// with a stray newline must not become a second command.
|
||||
func quote(s string) string {
|
||||
s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeIMAP is a scripted server: enough of IMAP to exercise the client, and
|
||||
// nothing more. It records the commands it received so a test can assert on the
|
||||
// protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT).
|
||||
type fakeIMAP struct {
|
||||
msgs map[uint32]string
|
||||
uids []uint32
|
||||
cmds []string
|
||||
failOn string // substring of a command to answer NO
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) serve(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
defer c.Close()
|
||||
fmt.Fprint(c, "* OK fake IMAP ready\r\n")
|
||||
r := bufio.NewReader(c)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
tag, cmd := parts[0], parts[1]
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
if f.failOn != "" && strings.Contains(cmd, f.failOn) {
|
||||
fmt.Fprintf(c, "%s NO computer says no\r\n", tag)
|
||||
continue
|
||||
}
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||
fmt.Fprintf(c, "%s OK done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||
var ids []string
|
||||
for _, u := range f.uids {
|
||||
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||
}
|
||||
fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " "))
|
||||
fmt.Fprintf(c, "%s OK search done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID FETCH"):
|
||||
uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||
raw, ok := f.msgs[uint32(uid64)]
|
||||
if ok {
|
||||
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw))
|
||||
fmt.Fprint(c, raw)
|
||||
fmt.Fprint(c, ")\r\n")
|
||||
}
|
||||
fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "LOGOUT"):
|
||||
fmt.Fprint(c, "* BYE\r\n")
|
||||
fmt.Fprintf(c, "%s OK bye\r\n", tag)
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(c, "%s BAD unknown\r\n", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialFake wires a client Conn to an in-process server over net.Pipe.
|
||||
func dialFake(t *testing.T, f *fakeIMAP) *Conn {
|
||||
t.Helper()
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(t, srv)
|
||||
c, err := NewConn(cli, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("greeting: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
func TestIMAPRoundTrip(t *testing.T) {
|
||||
body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n"
|
||||
f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}}
|
||||
c := dialFake(t, f)
|
||||
|
||||
if err := c.Login("kami", `pa"ss\word`); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if err := c.Select("INBOX"); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 {
|
||||
t.Fatalf("uids = %v, want [4 9]", uids)
|
||||
}
|
||||
raw, err := c.Fetch(9)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if string(raw) != body {
|
||||
t.Errorf("fetched %q, want the literal verbatim", raw)
|
||||
}
|
||||
c.Logout()
|
||||
|
||||
joined := strings.Join(f.cmds, "\n")
|
||||
// Read-only at the protocol level, and peeking — Maven must leave no trace
|
||||
// of having read his mail.
|
||||
if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") {
|
||||
t.Errorf("want EXAMINE (read-only), got:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "BODY.PEEK[]") {
|
||||
t.Errorf("want BODY.PEEK, got:\n%s", joined)
|
||||
}
|
||||
// The password must have been quoted and escaped, not truncated at the quote.
|
||||
if !strings.Contains(joined, `"pa\"ss\\word"`) {
|
||||
t.Errorf("password not quoted correctly:\n%s", joined)
|
||||
}
|
||||
// SINCE must carry the IMAP date form.
|
||||
if !strings.Contains(joined, "SINCE 1-Aug-2026") {
|
||||
t.Errorf("want a SINCE date, got:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPServerNoIsAnError(t *testing.T) {
|
||||
f := &fakeIMAP{failOn: "LOGIN"}
|
||||
c := dialFake(t, f)
|
||||
err := c.Login("kami", "wrong")
|
||||
if err == nil {
|
||||
t.Fatal("a NO completion must be an error")
|
||||
}
|
||||
// The error is the server's text; it must not echo the credential.
|
||||
if strings.Contains(err.Error(), "wrong") {
|
||||
t.Errorf("error leaks the password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPFetchMissingUID(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}}
|
||||
c := dialFake(t, f)
|
||||
raw, err := c.Fetch(1)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if raw != nil {
|
||||
t.Errorf("a vanished UID should give nil, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteStripsNewlines(t *testing.T) {
|
||||
if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") {
|
||||
t.Errorf("quote kept a line break: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The junk filter — the cheapest and most important half of reading mail.
|
||||
//
|
||||
// A mailbox is mostly machine-generated: newsletters, receipts nobody acts on,
|
||||
// social notifications, marketing. Sending all of it to a 1.7B and asking "is
|
||||
// there a task here" produces confident nonsense at a rate proportional to the
|
||||
// volume, so junk is decided by HEADERS, before any model sees the message.
|
||||
//
|
||||
// The rules are all bulk-mail markers that senders set on themselves, never
|
||||
// guesses about content:
|
||||
//
|
||||
// - List-Unsubscribe / List-Id — by definition a mailing list. If he can
|
||||
// unsubscribe from it, it is not asking him to do anything.
|
||||
// - Precedence: bulk|junk|list — the sender declaring itself bulk.
|
||||
// - Auto-Submitted other than "no" (RFC 3834) — generated by a machine.
|
||||
// - X-Spam-Flag: YES, X-Spam-Status: Yes — the spam filter upstream already
|
||||
// decided; we do not second-guess it in the other direction.
|
||||
// - X-GM-LABELS / X-Gmail-Labels containing a Gmail category — Gmail's own
|
||||
// Promotions/Social/Forums/Spam classification, when the server sends it.
|
||||
//
|
||||
// Deliberately NOT here: sender allow/deny lists and subject keyword matching.
|
||||
// Both are configuration that ages badly and both would be a place for his
|
||||
// contacts to end up in a config file. If a real correspondent's mail is being
|
||||
// dropped, the fix is a rule about a header, not a list of names.
|
||||
//
|
||||
// A junk verdict never deletes anything and never touches a flag on the server.
|
||||
// It means "do not spend the model on this", nothing more.
|
||||
|
||||
// junkHeaders — headers whose mere presence marks bulk mail.
|
||||
var junkPresence = []string{"List-Unsubscribe", "List-Id", "List-Post"}
|
||||
|
||||
// gmailCategories — Gmail's category labels, lowercased as they appear in
|
||||
// X-GM-LABELS. "important" and "inbox" are labels too, and are NOT categories.
|
||||
// Matching is by these exact tokens (substring is fine — they are namespaced
|
||||
// and cannot appear in a hand-made label by accident), so a user label named
|
||||
// "Social Club" is not mistaken for Gmail's Social category.
|
||||
var gmailCategories = []string{
|
||||
"category_promotions", "category_social", "category_forums", "category_updates",
|
||||
`\spam`, `\junk`,
|
||||
}
|
||||
|
||||
// classifyJunk returns whether the message is bulk/automated and why. The
|
||||
// reason is a short header name, safe to log — it names the marker, never the
|
||||
// sender or the subject.
|
||||
func classifyJunk(h mail.Header) (bool, string) {
|
||||
for _, name := range junkPresence {
|
||||
if strings.TrimSpace(h.Get(name)) != "" {
|
||||
return true, strings.ToLower(name)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
||||
case "bulk", "junk", "list":
|
||||
return true, "precedence"
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
|
||||
return true, "auto-submitted"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(h.Get("X-Spam-Flag")), "yes") {
|
||||
return true, "x-spam-flag"
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(h.Get("X-Spam-Status"))); strings.HasPrefix(v, "yes") {
|
||||
return true, "x-spam-status"
|
||||
}
|
||||
labels := strings.ToLower(h.Get("X-GM-LABELS") + " " + h.Get("X-Gmail-Labels"))
|
||||
for _, c := range gmailCategories {
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(labels, c) {
|
||||
return true, "gmail-category"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func headers(t *testing.T, raw string) mail.Header {
|
||||
t.Helper()
|
||||
m, err := mail.ReadMessage(strings.NewReader(strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n\r\nbody\r\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("read headers: %v", err)
|
||||
}
|
||||
return m.Header
|
||||
}
|
||||
|
||||
func TestClassifyJunk(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
junk bool
|
||||
reason string
|
||||
}{
|
||||
{"personal", "From: a@b.c\nSubject: привет", false, ""},
|
||||
{"list-unsubscribe", "From: a@b.c\nList-Unsubscribe: <mailto:u@b.c>", true, "list-unsubscribe"},
|
||||
{"list-id", "From: a@b.c\nList-Id: <golang-nuts.example>", true, "list-id"},
|
||||
{"precedence bulk", "From: a@b.c\nPrecedence: bulk", true, "precedence"},
|
||||
{"auto-submitted", "From: a@b.c\nAuto-Submitted: auto-generated", true, "auto-submitted"},
|
||||
{"auto-submitted no", "From: a@b.c\nAuto-Submitted: no", false, ""},
|
||||
{"spam flag", "From: a@b.c\nX-Spam-Flag: YES", true, "x-spam-flag"},
|
||||
{"spam status", "From: a@b.c\nX-Spam-Status: Yes, score=9.1", true, "x-spam-status"},
|
||||
{"spam status no", "From: a@b.c\nX-Spam-Status: No, score=0.1", false, ""},
|
||||
{"gmail promo", "From: a@b.c\nX-Gmail-Labels: Inbox,CATEGORY_PROMOTIONS", true, "gmail-category"},
|
||||
{"user label", "From: a@b.c\nX-Gmail-Labels: Social Club,Important", false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
junk, reason := classifyJunk(headers(t, c.raw))
|
||||
if junk != c.junk || reason != c.reason {
|
||||
t.Errorf("classifyJunk = (%v, %q), want (%v, %q)", junk, reason, c.junk, c.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterFixtureIsJunk(t *testing.T) {
|
||||
msg, err := ParseMessage(9, fixture(t, "newsletter.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !msg.Junk {
|
||||
t.Fatal("a newsletter with List-Unsubscribe + Precedence: bulk must be junk")
|
||||
}
|
||||
// The reason is what gets logged, so it must never carry mail content.
|
||||
if strings.Contains(msg.JunkReason, "@") || strings.Contains(msg.JunkReason, "Скидки") {
|
||||
t.Errorf("junk reason leaks content: %q", msg.JunkReason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package email is the reading half of the email reader (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md): a small IMAP client, a MIME-to-plaintext
|
||||
// converter, and the junk filter that decides a message is not worth reading at
|
||||
// all. Extraction lives in extract.go and writes nothing itself.
|
||||
//
|
||||
// Two constraints shape everything here, both from CLAUDE.md:
|
||||
//
|
||||
// - Mail is personal. Nothing in this package logs a body, a subject, or an
|
||||
// address; callers get the text and decide. Mail text is never search input
|
||||
// — no function here reaches the network except the IMAP connection itself.
|
||||
// - Off unless configured. There is no default host, no default account, and
|
||||
// no fallback that would make a mailbox get read because a field was empty.
|
||||
//
|
||||
// The IMAP subset is deliberately tiny (LOGIN, SELECT, UID SEARCH, UID FETCH
|
||||
// with BODY.PEEK, LOGOUT). No IDLE: a poll every few minutes is what a task
|
||||
// candidate needs, and IDLE would mean holding a connection and a credential
|
||||
// open forever for latency nobody is waiting on.
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxBodyBytes — how much of one message body is kept. A task hides in the
|
||||
// first screenful; the rest is signature, quoted history and legal boilerplate,
|
||||
// and it would only spend the resident model's 4096-token context.
|
||||
const MaxBodyBytes = 4000
|
||||
|
||||
// Message — one mail, reduced to the fields extraction and review need.
|
||||
//
|
||||
// Raw is deliberately absent: once a message is parsed the original bytes are
|
||||
// dropped, so no caller can accidentally log or forward the whole mail.
|
||||
type Message struct {
|
||||
UID uint32
|
||||
From string
|
||||
Subject string
|
||||
Date string // as sent, unparsed — display only
|
||||
Body string // plaintext, decoded, HTML-stripped, truncated
|
||||
// Junk is set by the junk filter (see junk.go). A junk message is carried
|
||||
// rather than dropped so the poller can count it and still mark it seen.
|
||||
Junk bool
|
||||
JunkReason string
|
||||
}
|
||||
|
||||
// ParseMessage turns one RFC 5322 message into a Message.
|
||||
//
|
||||
// It never fails on a body it cannot understand: an unparsable or
|
||||
// unsupported-charset body yields an empty Body and the headers still come
|
||||
// through, because a subject line alone is often the whole task ("Счёт за
|
||||
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||
func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
||||
}
|
||||
msg := Message{
|
||||
UID: uid,
|
||||
From: decodeHeader(m.Header.Get("From")),
|
||||
Subject: decodeHeader(m.Header.Get("Subject")),
|
||||
Date: m.Header.Get("Date"),
|
||||
}
|
||||
msg.Junk, msg.JunkReason = classifyJunk(m.Header)
|
||||
body, err := plaintextBody(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body)
|
||||
if err == nil {
|
||||
msg.Body = truncate(collapse(body), MaxBodyBytes)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// plaintextBody walks the MIME tree and returns the best plaintext it can.
|
||||
//
|
||||
// Preference order inside a multipart: text/plain first, text/html stripped
|
||||
// only when there is no plain part. multipart/mixed attachments are skipped
|
||||
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||
// mean parsing arbitrary formats from the network.
|
||||
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if contentType == "" || err != nil {
|
||||
// No Content-Type at all is legal and means text/plain; a broken one is
|
||||
// treated the same rather than dropping the message.
|
||||
mediaType, params = "text/plain", nil
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return "", fmt.Errorf("email: multipart without boundary")
|
||||
}
|
||||
return multipartText(multipart.NewReader(body, boundary))
|
||||
case mediaType == "text/html":
|
||||
raw, err := decodeBody(body, encoding, params["charset"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stripHTML(raw), nil
|
||||
case mediaType == "text/plain":
|
||||
return decodeBody(body, encoding, params["charset"])
|
||||
default:
|
||||
// A single-part non-text message (a bare PDF, say). No body, headers only.
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// multipartText reads one multipart level, recursing into nested multiparts.
|
||||
// Returns the plain part if any part yielded one, else the stripped HTML.
|
||||
func multipartText(mr *multipart.Reader) (string, error) {
|
||||
var plain, html string
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// A truncated multipart still gives up whatever came before it.
|
||||
break
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
part.Close()
|
||||
continue // attachment
|
||||
}
|
||||
ct := part.Header.Get("Content-Type")
|
||||
mediaType, _, _ := mime.ParseMediaType(ct)
|
||||
text, err := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
part.Close()
|
||||
if err != nil || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
if mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/") {
|
||||
if html == "" {
|
||||
html = text
|
||||
}
|
||||
continue
|
||||
}
|
||||
if plain == "" {
|
||||
plain = text
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(plain) != "" {
|
||||
return plain, nil
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// decodeBody applies the transfer encoding, then the charset.
|
||||
//
|
||||
// Charset support is UTF-8 (and ASCII, its subset) only, on purpose: x/text's
|
||||
// encoding tables are not vendored here, and guessing at windows-1251 bytes
|
||||
// would feed the model mojibake it would happily extract a task from. An
|
||||
// unsupported charset returns an error, which ParseMessage turns into an empty
|
||||
// body — subject-only, which is honest.
|
||||
func decodeBody(r io.Reader, encoding, charset string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
r = newBase64Reader(r)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
||||
if err != nil && len(b) == 0 {
|
||||
return "", fmt.Errorf("email: read body: %w", err)
|
||||
}
|
||||
switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
|
||||
case "", "utf-8", "utf8", "us-ascii", "ascii":
|
||||
return string(b), nil
|
||||
default:
|
||||
return "", fmt.Errorf("email: unsupported charset %q", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeHeader decodes RFC 2047 encoded words ("=?utf-8?B?...?="), which is how
|
||||
// every Russian subject line arrives. Undecodable headers come back as-is
|
||||
// rather than empty: a mangled subject is still a hint, and it is only ever
|
||||
// shown to him as evidence.
|
||||
func decodeHeader(v string) string {
|
||||
dec := new(mime.WordDecoder)
|
||||
out, err := dec.DecodeHeader(v)
|
||||
if err != nil {
|
||||
return collapse(v)
|
||||
}
|
||||
return collapse(out)
|
||||
}
|
||||
|
||||
var (
|
||||
scriptStyle = regexp.MustCompile(`(?is)<(script|style)\b[^>]*>.*?</\s*(script|style)\s*>`)
|
||||
htmlBreak = regexp.MustCompile(`(?i)<\s*(br\s*/?|/p|/div|/tr|/li|/h[1-6])\s*>`)
|
||||
htmlTag = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
)
|
||||
|
||||
// stripHTML reduces an HTML part to text. A regex stripper, not a parser:
|
||||
// x/net/html is not vendored, and the consumer is a model reading prose — a
|
||||
// stray angle bracket costs nothing, whereas a new dependency for the privacy-
|
||||
// sensitive path costs review.
|
||||
func stripHTML(s string) string {
|
||||
s = scriptStyle.ReplaceAllString(s, " ")
|
||||
s = htmlComment.ReplaceAllString(s, " ")
|
||||
s = htmlBreak.ReplaceAllString(s, "\n")
|
||||
s = htmlTag.ReplaceAllString(s, " ")
|
||||
return unescapeEntities(s)
|
||||
}
|
||||
|
||||
var entities = strings.NewReplacer(
|
||||
" ", " ", "&", "&", "<", "<", ">", ">",
|
||||
""", `"`, "'", "'", "'", "'", "—", "—", "–", "–",
|
||||
)
|
||||
|
||||
func unescapeEntities(s string) string { return entities.Replace(s) }
|
||||
|
||||
// collapse squeezes runs of whitespace, keeping single newlines. Mail bodies
|
||||
// arrive with hard-wrapped lines and blocks of blank space; the model does not
|
||||
// need them and they are pure context budget.
|
||||
func collapse(s string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
blank := 0
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(strings.Join(strings.Fields(l), " "))
|
||||
if l == "" {
|
||||
blank++
|
||||
if blank > 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "")
|
||||
continue
|
||||
}
|
||||
blank = 0
|
||||
out = append(out, l)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// truncate cuts to n bytes on a rune boundary.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
cut := s[:n]
|
||||
for len(cut) > 0 && !isRuneStart(cut[len(cut)-1]) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return strings.TrimSpace(cut) + "…"
|
||||
}
|
||||
|
||||
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }
|
||||
|
||||
// newBase64Reader — base64.NewDecoder already skips the CRLFs mail bodies wrap
|
||||
// with, so this is only a named seam for decodeBody to read cleanly.
|
||||
func newBase64Reader(r io.Reader) io.Reader {
|
||||
return base64.NewDecoder(base64.StdEncoding, r)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestParsePlainRussian(t *testing.T) {
|
||||
msg, err := ParseMessage(7, fixture(t, "plain_ru.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if msg.UID != 7 {
|
||||
t.Errorf("uid = %d, want 7", msg.UID)
|
||||
}
|
||||
if want := "Нужно закрыть задачу"; msg.Subject != want {
|
||||
t.Errorf("subject = %q, want %q", msg.Subject, want)
|
||||
}
|
||||
if !strings.Contains(msg.From, "Антон") {
|
||||
t.Errorf("from = %q, want the decoded display name", msg.From)
|
||||
}
|
||||
if !strings.Contains(msg.Body, "Надо отправить акт до пятницы.") {
|
||||
t.Errorf("body = %q, want the quoted-printable text decoded", msg.Body)
|
||||
}
|
||||
if msg.Junk {
|
||||
t.Errorf("a personal mail must not be junk (%s)", msg.JunkReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLOnlyIsStripped(t *testing.T) {
|
||||
msg, err := ParseMessage(1, fixture(t, "html_only.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if strings.Contains(msg.Body, "<") || strings.Contains(msg.Body, "color:red") || strings.Contains(msg.Body, "x()") {
|
||||
t.Errorf("body still has markup/script/style: %q", msg.Body)
|
||||
}
|
||||
for _, want := range []string{"Счёт за интернет: 700", "Оплатить до 5 августа."} {
|
||||
if !strings.Contains(msg.Body, want) {
|
||||
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
|
||||
}
|
||||
}
|
||||
// must have become a real space, not vanished into the number.
|
||||
if strings.Contains(msg.Body, " ") {
|
||||
t.Errorf("entity left unescaped: %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrefersPlainAndSkipsAttachments(t *testing.T) {
|
||||
msg, err := ParseMessage(2, fixture(t, "mixed_attachment.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(msg.Body); got != "Sign the contract before Monday." {
|
||||
t.Errorf("body = %q, want the text/plain alternative only", got)
|
||||
}
|
||||
if strings.Contains(msg.Body, "PDF") {
|
||||
t.Errorf("attachment bytes leaked into the body: %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// An unsupported charset must degrade to headers-only rather than to mojibake
|
||||
// the model would then extract a task from.
|
||||
func TestParseUnsupportedCharsetKeepsHeaders(t *testing.T) {
|
||||
msg, err := ParseMessage(3, fixture(t, "cp1251.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if msg.Subject != "Legacy" {
|
||||
t.Errorf("subject = %q, want Legacy", msg.Subject)
|
||||
}
|
||||
if msg.Body != "" {
|
||||
t.Errorf("body = %q, want empty for an undecodable charset", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTruncatesLongBody(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("Subject: long\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
for i := 0; i < 2000; i++ {
|
||||
b.WriteString("длинная строка ")
|
||||
}
|
||||
msg, err := ParseMessage(4, []byte(b.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(msg.Body) > MaxBodyBytes+8 {
|
||||
t.Errorf("body kept %d bytes, want ≤ %d", len(msg.Body), MaxBodyBytes)
|
||||
}
|
||||
if !strings.HasSuffix(msg.Body, "…") {
|
||||
t.Errorf("truncated body should be marked: %q", msg.Body[len(msg.Body)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||
if got != "a b\n\nc" {
|
||||
t.Errorf("collapse = %q, want %q", got, "a b\n\nc")
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
From: legacy@example.org
|
||||
To: kami@example.org
|
||||
Subject: Legacy
|
||||
Date: Fri, 01 Aug 2026 05:00:00 +0400
|
||||
Content-Type: text/plain; charset="windows-1251"
|
||||
|
||||
Ï
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
From: billing@isp.example
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0KHRh9GR0YIg0LfQsCDQuNC90YLQtdGA0L3QtdGC?=
|
||||
Date: Fri, 01 Aug 2026 08:00:00 +0400
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="B1"
|
||||
|
||||
--B1
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PGh0bWw+PGhlYWQ+PHN0eWxlPnB7Y29sb3I6cmVkfTwvc3R5bGU+PC9oZWFkPjxib2R5PjxwPtCh0YfRkdGCINC30LAg0LjQvdGC0LXRgNC90LXRgjogNzAwJm5ic3A74oK9PC9wPjxwPtCe0L/Qu9Cw0YLQuNGC0Ywg0LTQviA1INCw0LLQs9GD0YHRgtCwLjwvcD48c2NyaXB0PngoKTwvc2NyaXB0PjwvYm9keT48L2h0bWw+
|
||||
--B1--
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
From: hr@work.example
|
||||
To: kami@example.org
|
||||
Subject: Contract
|
||||
Date: Fri, 01 Aug 2026 07:00:00 +0400
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="M1"
|
||||
|
||||
--M1
|
||||
Content-Type: multipart/alternative; boundary="A1"
|
||||
|
||||
--A1
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Sign the contract before Monday.
|
||||
--A1
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
|
||||
<p>Sign the contract before Monday.</p>
|
||||
--A1--
|
||||
--M1
|
||||
Content-Type: application/pdf; name="contract.pdf"
|
||||
Content-Disposition: attachment; filename="contract.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQgbm90IHJlYWxseSBhIHBkZg==
|
||||
--M1--
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
From: news@shop.example
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0KHQutC40LTQutC4INGC0L7Qu9GM0LrQviDRgdC10LPQvtC00L3Rjw==?=
|
||||
Date: Fri, 01 Aug 2026 06:00:00 +0400
|
||||
List-Unsubscribe: <mailto:unsub@shop.example>
|
||||
Precedence: bulk
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Sale!
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
From: =?utf-8?B?0JDQvdGC0L7QvQ==?= <anton@example.org>
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0J3Rg9C20L3QviDQt9Cw0LrRgNGL0YLRjCDQt9Cw0LTQsNGH0YM=?=
|
||||
Date: Fri, 01 Aug 2026 09:12:00 +0400
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Message-ID: <plain-ru@example.org>
|
||||
|
||||
=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82! =D0=9D=D0=B0=D0=B4=D0=BE =D0=BE=D1=82=
|
||||
=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C =D0=B0=D0=BA=D1=82 =D0=B4=D0=BE =
|
||||
=D0=BF=D1=8F=D1=82=D0=BD=D0=B8=D1=86=D1=8B.
|
||||
|
||||
--
|
||||
Anton
|
||||
@@ -93,6 +93,145 @@ type WriteFactReq struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// Task — one captured piece of work (Vikunja #130). Status is
|
||||
// "candidate" (Maven derived it and it is unconfirmed), "open" (his work),
|
||||
// "done" or "dropped". Source is provenance in the facts vocabulary:
|
||||
// "tap:voice", "tap:web", "email:<account>". Evidence is the trail a derived
|
||||
// task came from, empty for anything he stated himself.
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedTs time.Time `json:"created_ts"`
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
// through this one shape: the voice path, the web form, and (Vikunja #246) the
|
||||
// email reader, which has not been built yet.
|
||||
//
|
||||
// An extractor that reads mail sets Source "email:<account>", Status
|
||||
// "candidate", and Evidence to whatever makes the task reviewable (the subject
|
||||
// line). It must NOT set Status "open" — work Maven inferred from something she
|
||||
// read is a suggestion until the owner confirms it on the /tasks page. Capture
|
||||
// is idempotent on normalised text among live tasks, so re-reading the same
|
||||
// mailbox is free.
|
||||
type CaptureTaskReq struct {
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Status string `json:"status,omitempty"` // "" ⇒ open
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
// in which case ID is the existing row. A caller tells the owner "уже в
|
||||
// списке" rather than claiming it saved something new.
|
||||
type CaptureTaskResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||
// extraction (Vikunja #246).
|
||||
//
|
||||
// The mail reader (cmd/mavmaild) holds the IMAP credential and core never sees
|
||||
// it, the same split mavpoll uses for the zenmoney token. What crosses this
|
||||
// boundary is only the message text, because extraction runs on the resident
|
||||
// model and llama-server lives inside core's process.
|
||||
//
|
||||
// Body is already plaintext and truncated by internal/email; core does not
|
||||
// re-parse MIME and never stores the body. Junk means the reader's header
|
||||
// filter already classified the message as bulk — core is told rather than
|
||||
// asked, so a junk message can be counted without a model call.
|
||||
//
|
||||
// This method is available only when core has an email block configured AND a
|
||||
// llama-server phraser; otherwise it answers ErrUnknownMethod, which is what
|
||||
// "off unless configured" looks like at the wire.
|
||||
type IngestMailReq struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
UID uint32 `json:"uid"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Junk bool `json:"junk,omitempty"`
|
||||
}
|
||||
|
||||
// IngestMailResp — what core did with the message. TaskIDs are the rows
|
||||
// CaptureTask returned; Created counts the ones that were new (a re-read
|
||||
// mailbox dedupes to Created=0). Skipped is set when nothing was asked of the
|
||||
// model at all — junk, or an empty message.
|
||||
//
|
||||
// Nothing here echoes the mail back. The reader logs counts.
|
||||
type IngestMailResp struct {
|
||||
TaskIDs []int64 `json:"task_ids,omitempty"`
|
||||
Created int `json:"created"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// SwapModelReq — load another resident model without restarting the daemon
|
||||
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
|
||||
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
|
||||
// method ErrUnknownMethod.
|
||||
//
|
||||
// NGpuLayers and NCtx are zero for "keep what is loaded now", which is the
|
||||
// normal case — the same laptop iGPU, a different gguf.
|
||||
//
|
||||
// This is an owner action. It is AuthStepUp in the authority table, it is not on
|
||||
// CoreAPI, and no act, intent or timer can reach it: swapping the model is not
|
||||
// something Maven does to herself.
|
||||
type SwapModelReq struct {
|
||||
ModelPath string `json:"model_path"`
|
||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||
NCtx int `json:"n_ctx,omitempty"`
|
||||
}
|
||||
|
||||
// SwapModelResp — what the daemon ended up serving. Model is the identity the
|
||||
// new llama-server reported for itself, not an echo of the request: if the file
|
||||
// was not the model the operator thought it was, this is where it shows.
|
||||
//
|
||||
// RolledBack is true when the requested model failed to load or would not answer
|
||||
// and the previous one was put back. In that case the call also returns an error
|
||||
// — the swap did not happen — and Model names the model still serving.
|
||||
type SwapModelResp struct {
|
||||
Model string `json:"model"`
|
||||
ModelPath string `json:"model_path"`
|
||||
BaseURL string `json:"base_url"`
|
||||
RolledBack bool `json:"rolled_back,omitempty"`
|
||||
TookMs int64 `json:"took_ms"`
|
||||
}
|
||||
|
||||
// ModelStatusResp — which model is resident and which ones may be swapped in.
|
||||
// Read-only; the authed page renders it. Swappable is the configured allowlist,
|
||||
// so an empty list means the capability is off.
|
||||
type ModelStatusResp struct {
|
||||
Model string `json:"model"`
|
||||
ModelPath string `json:"model_path"`
|
||||
BaseURL string `json:"base_url"`
|
||||
NGpuLayers int `json:"n_gpu_layers"`
|
||||
NCtx int `json:"n_ctx"`
|
||||
Swappable []string `json:"swappable,omitempty"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
type listTasksResp struct {
|
||||
Tasks []Task `json:"tasks"`
|
||||
}
|
||||
type setTaskStatusReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -294,6 +433,18 @@ type CoreAPI interface {
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// CaptureTask records a task. See CaptureTaskReq — this is the single
|
||||
// intake seam for the voice path, the web form and the future email
|
||||
// extractor. Idempotent per live normalised text; the response says
|
||||
// whether a row was actually created.
|
||||
CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error)
|
||||
// ListTasks returns tasks in one status, newest first. "" is every row,
|
||||
// "live" is candidate + open (outstanding work).
|
||||
ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error
|
||||
|
||||
// TickTrace returns the most recent tick's rule trace. The daemon caches
|
||||
// this after every tick; the store adapter returns an error (trace is not
|
||||
// persisted — it's a daemon-level cache).
|
||||
@@ -306,6 +457,14 @@ type CoreAPI interface {
|
||||
// TickTrace.
|
||||
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.
|
||||
// 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).
|
||||
@@ -359,6 +518,26 @@ type MorningRoutineStatus struct {
|
||||
Items []MorningRoutineItem `json:"items"`
|
||||
}
|
||||
|
||||
// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
|
||||
// "checklist"; Uncertain marks an item whose provenance is below a full
|
||||
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
|
||||
// the same way the spoken form does.
|
||||
type DayPlanItem struct {
|
||||
At time.Time `json:"at"`
|
||||
Text string `json:"text"`
|
||||
Kind string `json:"kind"`
|
||||
Uncertain bool `json:"uncertain,omitempty"`
|
||||
}
|
||||
|
||||
// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
|
||||
// says when asked, rendered core-side so the voice reply and the web view can
|
||||
// never drift apart.
|
||||
type DayPlan struct {
|
||||
Date time.Time `json:"date"`
|
||||
Items []DayPlanItem `json:"items"`
|
||||
Spoken string `json:"spoken"`
|
||||
}
|
||||
|
||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||
type storeEncryptionKeyReq struct {
|
||||
|
||||
@@ -69,8 +69,10 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodLookupTool: true,
|
||||
MethodListTools: true,
|
||||
MethodListProposedRoutines: true,
|
||||
MethodListTasks: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodDayPlan: true,
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
@@ -426,6 +428,59 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
||||
return r.Routines, nil
|
||||
}
|
||||
|
||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
var r CaptureTaskResp
|
||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||
return CaptureTaskResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
var r listTasksResp
|
||||
if err := c.call(ctx, MethodListTasks, listTasksReq{Status: status}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||
// means core has no email block configured — the caller should stop asking, not
|
||||
// retry.
|
||||
func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailResp, error) {
|
||||
var r IngestMailResp
|
||||
if err := c.call(ctx, MethodIngestMail, req, &r); err != nil {
|
||||
return IngestMailResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// SwapModel asks core to load another resident model (Vikunja #250).
|
||||
// ErrUnknownMethod means core has no phraser.swap_models allowlist configured;
|
||||
// ErrForbidden means the path is not on it, or step-up was not asserted. A
|
||||
// non-nil error with RolledBack set means nothing changed — the old model is
|
||||
// still serving.
|
||||
func (c *Client) SwapModel(ctx context.Context, req SwapModelReq) (SwapModelResp, error) {
|
||||
var r SwapModelResp
|
||||
if err := c.call(ctx, MethodSwapModel, req, &r); err != nil {
|
||||
return SwapModelResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// ModelStatus reports the resident model and the swap allowlist. Read-only.
|
||||
func (c *Client) ModelStatus(ctx context.Context) (ModelStatusResp, error) {
|
||||
var r ModelStatusResp
|
||||
if err := c.call(ctx, MethodModelStatus, nil, &r); err != nil {
|
||||
return ModelStatusResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
@@ -458,6 +513,14 @@ func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, err
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
var p DayPlan
|
||||
if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil {
|
||||
return DayPlan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
var result struct {
|
||||
NewID int64 `json:"new_id"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
@@ -565,3 +566,77 @@ func mustJSON(v any) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestIngestMail_OffUnlessConfigured — with no IngestMailFn set (the default,
|
||||
// and what an unconfigured core looks like) the method does not exist. A mail
|
||||
// reader gets a refusal it can act on rather than a silent success.
|
||||
func TestIngestMail_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
if _, err := cli.IngestMail(context.Background(), IngestMailReq{Mailbox: "INBOX", UID: 1}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("IngestMail error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestMail_Hook — when the daemon wires the hook, the message crosses the
|
||||
// boundary intact and the response comes back.
|
||||
func TestIngestMail_Hook(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var got IngestMailReq
|
||||
srv.IngestMailFn = func(_ context.Context, req IngestMailReq) (IngestMailResp, error) {
|
||||
got = req
|
||||
return IngestMailResp{TaskIDs: []int64{7}, Created: 1}, nil
|
||||
}
|
||||
resp, err := cli.IngestMail(context.Background(), IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 12, Subject: "Счёт", Body: "Оплатить.", Junk: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IngestMail: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 || resp.TaskIDs[0] != 7 {
|
||||
t.Errorf("resp = %+v", resp)
|
||||
}
|
||||
if got.UID != 12 || got.Subject != "Счёт" || got.Body != "Оплатить." {
|
||||
t.Errorf("req across the wire = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModel_OffUnlessConfigured — no allowlist in the config means the
|
||||
// daemon never sets the hook, and the method does not exist. That is what "off
|
||||
// unless configured" looks like at the wire for the model swap (Vikunja #250).
|
||||
func TestSwapModel_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
if _, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/m/x.gguf"}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("SwapModel error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
if _, err := cli.ModelStatus(context.Background()); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("ModelStatus error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapModel_Hook — the request crosses the boundary intact and the reported
|
||||
// identity comes back. A refusal from the daemon's allowlist arrives as
|
||||
// ErrForbidden, which is what a caller keys its error message off.
|
||||
func TestSwapModel_Hook(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var got SwapModelReq
|
||||
srv.SwapModelFn = func(_ context.Context, req SwapModelReq) (SwapModelResp, error) {
|
||||
got = req
|
||||
if req.ModelPath != "/m/allowed.gguf" {
|
||||
return SwapModelResp{}, fmt.Errorf("%w: not allowlisted", ErrForbidden)
|
||||
}
|
||||
return SwapModelResp{Model: "allowed", ModelPath: req.ModelPath, BaseURL: "http://127.0.0.1:9", TookMs: 12}, nil
|
||||
}
|
||||
resp, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/m/allowed.gguf", NCtx: 4096})
|
||||
if err != nil {
|
||||
t.Fatalf("SwapModel: %v", err)
|
||||
}
|
||||
if resp.Model != "allowed" || resp.TookMs != 12 {
|
||||
t.Errorf("resp = %+v", resp)
|
||||
}
|
||||
if got.NCtx != 4096 {
|
||||
t.Errorf("req across the wire = %+v", got)
|
||||
}
|
||||
if _, err := cli.SwapModel(context.Background(), SwapModelReq{ModelPath: "/etc/shadow"}); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("swap to a non-allowlisted path = %v; want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
+142
-4
@@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
|
||||
return nil, errors.New("store: morning status not available via direct store API")
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -227,6 +231,48 @@ func (a *storeAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return mapErr(a.s.DeleteTool(ctx, name))
|
||||
}
|
||||
|
||||
func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
id, created, err := a.s.CaptureTask(ctx, store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
}
|
||||
return CaptureTaskResp{ID: id, Created: created}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
ts, err := a.s.ListTasks(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Task, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = Task{
|
||||
ID: t.ID,
|
||||
CreatedTs: t.CreatedTs,
|
||||
Text: t.Text,
|
||||
Source: t.Source,
|
||||
Evidence: t.Evidence,
|
||||
Status: t.Status,
|
||||
Due: t.Due,
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -375,6 +421,30 @@ type Server struct {
|
||||
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
||||
WrapKeyFn WrapKeyFunc
|
||||
|
||||
// IngestMailFn — extracts task candidates from one fetched message. Set by
|
||||
// the daemon only when an email block is configured AND there is a
|
||||
// llama-server to extract with; nil ⇒ MethodIngestMail returns
|
||||
// ErrUnknownMethod, so a mail reader pointed at a core that is not
|
||||
// configured for mail is refused rather than silently ignored.
|
||||
//
|
||||
// Like StepUp/WrapKeyFn/UnlockFn this bypasses CoreAPI: it is not a store
|
||||
// operation, it needs the resident model, and it must not become a method
|
||||
// every CoreAPI implementation has to carry.
|
||||
IngestMailFn IngestMailFunc
|
||||
|
||||
// SwapModelFn / ModelStatusFn — the on-the-fly resident model swap (Vikunja
|
||||
// #250) and its read side. Set by the daemon only when phraser.swap_models
|
||||
// lists at least one model AND the phraser owns a llama-server; nil ⇒ both
|
||||
// methods answer ErrUnknownMethod, which is what "off unless configured"
|
||||
// looks like at the wire.
|
||||
//
|
||||
// They bypass CoreAPI for the same reason IngestMailFn does: this is not a
|
||||
// store operation, it needs the daemon's llama-server, and no other CoreAPI
|
||||
// implementation should have to carry it. MethodSwapModel is AuthStepUp in
|
||||
// internal/auth — owner-triggered, never an act and never a timer.
|
||||
SwapModelFn SwapModelFunc
|
||||
ModelStatusFn ModelStatusFunc
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey credential public key, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -393,6 +463,15 @@ type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
||||
// public key and completes daemon initialization.
|
||||
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
||||
|
||||
// SwapModelFunc — loads another resident model in place of the live one.
|
||||
type SwapModelFunc func(ctx context.Context, req SwapModelReq) (SwapModelResp, error)
|
||||
|
||||
// ModelStatusFunc — reports the resident model and the swap allowlist.
|
||||
type ModelStatusFunc func(ctx context.Context) (ModelStatusResp, error)
|
||||
|
||||
// IngestMailFunc — core-side mail extraction. Returns what was captured.
|
||||
type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp, error)
|
||||
|
||||
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||
@@ -560,9 +639,11 @@ func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error))
|
||||
// existed) as an argument — so SetAPI's runtime swap (the unlock transition)
|
||||
// is still honored on the very next request with no extra plumbing here.
|
||||
//
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey and MethodUnlock are NOT in
|
||||
// this table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn /
|
||||
// s.UnlockFn), so dispatch special-cases them before consulting the table.
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock,
|
||||
// MethodIngestMail, MethodSwapModel and MethodModelStatus are NOT in this
|
||||
// table: they bypass CoreAPI entirely
|
||||
// (s.StepUp / s.WrapKeyFn / s.UnlockFn / s.IngestMailFn), so dispatch
|
||||
// special-cases them before consulting the table.
|
||||
var methodTable = map[Method]handlerFunc{
|
||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||
id, err := api.WriteFact(ctx, p)
|
||||
@@ -696,6 +777,22 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||
return api.DeleteTool(ctx, p.Name)
|
||||
}),
|
||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return api.CaptureTask(ctx, p)
|
||||
}),
|
||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||
out, err := api.ListTasks(ctx, p.Status)
|
||||
if err != nil {
|
||||
return listTasksResp{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Task{}
|
||||
}
|
||||
return listTasksResp{Tasks: out}, nil
|
||||
}),
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -729,6 +826,9 @@ var methodTable = map[Method]handlerFunc{
|
||||
// MorningStatus intentionally has no nil→[]T{} normalization here — the
|
||||
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
|
||||
// serializes as JSON null), and this preserves that exact wire shape.
|
||||
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
|
||||
return api.DayPlan(ctx)
|
||||
}),
|
||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||
return api.MorningStatus(ctx)
|
||||
}),
|
||||
@@ -751,7 +851,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
}
|
||||
}
|
||||
|
||||
// These three bypass CoreAPI entirely — they drive Server fields set
|
||||
// These bypass CoreAPI entirely — they drive Server fields set
|
||||
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
||||
// state, so they can never be table entries keyed on a CoreAPI method.
|
||||
switch req.Method {
|
||||
@@ -780,6 +880,44 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodIngestMail:
|
||||
if s.IngestMailFn != nil {
|
||||
var p IngestMailReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.IngestMailFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodSwapModel:
|
||||
if s.SwapModelFn != nil {
|
||||
var p SwapModelReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.SwapModelFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodModelStatus:
|
||||
if s.ModelStatusFn != nil {
|
||||
resp, err := s.ModelStatusFn(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
|
||||
h, ok := methodTable[req.Method]
|
||||
|
||||
@@ -89,6 +89,15 @@ func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error
|
||||
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return CaptureTaskResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
@@ -113,6 +122,9 @@ func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -45,7 +45,14 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
+27
-1
@@ -10,10 +10,17 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
// mu guards base only. The base URL changes when the daemon swaps the
|
||||
// resident model (Vikunja #250): llama-server is relaunched on a fresh
|
||||
// port, and every holder of this client — the LLM router, the replier, the
|
||||
// mail extractor — must follow without being rebuilt. One mutexed field is
|
||||
// the whole mechanism; a swap re-points the client, it does not replace it.
|
||||
mu sync.RWMutex
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
@@ -22,6 +29,25 @@ func New(baseURL string, timeout time.Duration) *Client {
|
||||
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
||||
}
|
||||
|
||||
// SetBaseURL re-points the client at another llama-server. Safe to call while
|
||||
// requests are in flight: a request that already read the old base finishes
|
||||
// against the old base (or fails, and every caller of Complete has a fallback),
|
||||
// and the next one uses the new base. It is deliberately NOT a queue-and-retry —
|
||||
// the phraser quiesces around a swap, so the window is small and a lost turn
|
||||
// degrades to the classifier rather than hanging.
|
||||
func (c *Client) SetBaseURL(base string) {
|
||||
c.mu.Lock()
|
||||
c.base = base
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// BaseURL is the server this client currently talks to.
|
||||
func (c *Client) BaseURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.base
|
||||
}
|
||||
|
||||
type Req struct {
|
||||
System string
|
||||
User string
|
||||
@@ -63,7 +89,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
RepeatPenalty: r.RepeatPenalty,
|
||||
Stop: r.Stop,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/chat/completions", bytes.NewReader(b))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL()+"/v1/chat/completions", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -57,3 +57,37 @@ func TestComplete(t *testing.T) {
|
||||
t.Errorf("got %q, want %q", got, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetBaseURL — a model swap re-points every holder of the client rather than
|
||||
// rebuilding the router, the replier and the extractors (Vikunja #250).
|
||||
func TestSetBaseURL(t *testing.T) {
|
||||
var hit string
|
||||
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = "A"
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"a"}}]}`))
|
||||
}))
|
||||
defer srvA.Close()
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hit = "B"
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"b"}}]}`))
|
||||
}))
|
||||
defer srvB.Close()
|
||||
|
||||
c := New(srvA.URL, 5*time.Second)
|
||||
if _, err := c.Complete(context.Background(), Req{User: "x"}); err != nil {
|
||||
t.Fatalf("Complete against A: %v", err)
|
||||
}
|
||||
if hit != "A" {
|
||||
t.Fatalf("first request went to %q; want A", hit)
|
||||
}
|
||||
c.SetBaseURL(srvB.URL)
|
||||
if got := c.BaseURL(); got != srvB.URL {
|
||||
t.Errorf("BaseURL = %q; want %q", got, srvB.URL)
|
||||
}
|
||||
if _, err := c.Complete(context.Background(), Req{User: "x"}); err != nil {
|
||||
t.Fatalf("Complete against B: %v", err)
|
||||
}
|
||||
if hit != "B" {
|
||||
t.Errorf("request after the swap went to %q; want B", hit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||
//
|
||||
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
|
||||
// asks for an LLM to write a behaviour profile daily and store it as a fact;
|
||||
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
|
||||
// will produce fluent claims about the owner's life that no row in the store
|
||||
// supports, and a wrong claim about him is the most expensive kind of wrong
|
||||
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
|
||||
// to run on the question, and cannot invent a habit he does not have.
|
||||
//
|
||||
// Recomputed on read rather than cached as a fact for the same reason the store
|
||||
// is append-only: a cached profile can disagree with the rows it came from, and
|
||||
// then there are two truths. The plan's step 5 ("profile updates on fact write")
|
||||
// exists to keep a cache fresh; there is no cache, so a new fact is already in
|
||||
// the next answer.
|
||||
//
|
||||
// It is also read-only and unprompted-free. The plan's step 4 — a morning
|
||||
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
|
||||
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
|
||||
// inference that leads to a routine the owner accepts already exists in
|
||||
// internal/pattern with the proposal queue on /routines; that is the sanctioned
|
||||
// path from "she noticed" to "she acts", and it goes through him.
|
||||
|
||||
// Observation — one thing the owner was recorded doing, reduced to what a habit
|
||||
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
|
||||
// so this package stays free of both.
|
||||
type Observation struct {
|
||||
At time.Time
|
||||
Key string
|
||||
Kind string // "self" | "env" | "config"
|
||||
}
|
||||
|
||||
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
|
||||
// days it was observed on, which is the number that decides whether something
|
||||
// is a habit; Count can be inflated by one busy day.
|
||||
//
|
||||
// TypicalAt is the median time of day it happens at, rounded to the minute — a
|
||||
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
|
||||
// воду утром" into the night.
|
||||
type Activity struct {
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model. Weekly holds the activities that
|
||||
// recur on a given weekday, Overall the ones that recur at all.
|
||||
type Profile struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Weekly map[time.Weekday][]Activity
|
||||
All []Activity
|
||||
}
|
||||
|
||||
// MinHabitDays — how many distinct days an activity must appear on before maven
|
||||
// will call it usual. Two is the smallest number that can distinguish a habit
|
||||
// from a one-off; below that she says she does not know yet, which is true.
|
||||
const MinHabitDays = 2
|
||||
|
||||
// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
|
||||
// behaviour. Calendar events carry the day in the key so they can never repeat;
|
||||
// cooldown and quiet rows are maven's own tuning state, not his habits.
|
||||
var nonBehaviouralKeyPrefixes = []string{
|
||||
"calendar_event_",
|
||||
"cooldown:",
|
||||
"quiet",
|
||||
"behavior_profile",
|
||||
}
|
||||
|
||||
// BuildProfile counts habits out of observations. now bounds the window's upper
|
||||
// end and supplies the location every day boundary is taken in — a habit is
|
||||
// "on Tuesdays" in the owner's timezone or it is nothing.
|
||||
//
|
||||
// Only self-facts count. An env row is the world (weather, a relayed meeting),
|
||||
// and a config row is maven's own state; neither says anything about what he
|
||||
// usually does.
|
||||
func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
loc := now.Location()
|
||||
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
|
||||
|
||||
type bucket struct {
|
||||
days map[string]struct{}
|
||||
count int
|
||||
mins []int
|
||||
}
|
||||
// key → bucket, and (weekday, key) → bucket.
|
||||
all := map[string]*bucket{}
|
||||
weekly := map[time.Weekday]map[string]*bucket{}
|
||||
|
||||
for _, o := range obs {
|
||||
if o.Kind != "self" {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(o.Key)
|
||||
if key == "" || nonBehavioural(key) {
|
||||
continue
|
||||
}
|
||||
at := o.At.In(loc)
|
||||
if at.IsZero() || at.After(now) {
|
||||
continue
|
||||
}
|
||||
if p.Since.IsZero() || at.Before(p.Since) {
|
||||
p.Since = at
|
||||
}
|
||||
day := at.Format("2006-01-02")
|
||||
minute := at.Hour()*60 + at.Minute()
|
||||
|
||||
bump := func(m map[string]*bucket) {
|
||||
b := m[key]
|
||||
if b == nil {
|
||||
b = &bucket{days: map[string]struct{}{}}
|
||||
m[key] = b
|
||||
}
|
||||
b.days[day] = struct{}{}
|
||||
b.count++
|
||||
b.mins = append(b.mins, minute)
|
||||
}
|
||||
bump(all)
|
||||
wd := at.Weekday()
|
||||
if weekly[wd] == nil {
|
||||
weekly[wd] = map[string]*bucket{}
|
||||
}
|
||||
bump(weekly[wd])
|
||||
}
|
||||
|
||||
harvest := func(m map[string]*bucket) []Activity {
|
||||
var out []Activity
|
||||
for key, b := range m {
|
||||
if len(b.days) < MinHabitDays {
|
||||
continue
|
||||
}
|
||||
out = append(out, Activity{
|
||||
Key: key,
|
||||
Days: len(b.days),
|
||||
Count: b.count,
|
||||
TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute,
|
||||
})
|
||||
}
|
||||
// Most-established first, then earliest in the day, then by key so the
|
||||
// same history always reads back the same way.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Days != out[j].Days {
|
||||
return out[i].Days > out[j].Days
|
||||
}
|
||||
if out[i].TypicalAt != out[j].TypicalAt {
|
||||
return out[i].TypicalAt < out[j].TypicalAt
|
||||
}
|
||||
return out[i].Key < out[j].Key
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
p.All = harvest(all)
|
||||
for wd, m := range weekly {
|
||||
if acts := harvest(m); len(acts) > 0 {
|
||||
p.Weekly[wd] = acts
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nonBehavioural(key string) bool {
|
||||
for _, p := range nonBehaviouralKeyPrefixes {
|
||||
if strings.HasPrefix(key, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// medianInt — the middle value, averaging the two middles on an even count.
|
||||
func medianInt(xs []int) int {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := make([]int, len(xs))
|
||||
copy(s, xs)
|
||||
sort.Ints(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read
|
||||
// back. Index is time.Weekday.
|
||||
var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"}
|
||||
|
||||
// activityRU glosses the loop's known fact keys. An unknown key is read back
|
||||
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
|
||||
// maven does not recognise would be putting words in his mouth.
|
||||
var activityRU = map[string]string{
|
||||
"water": "пьёшь воду",
|
||||
"meal": "ешь",
|
||||
"sleep": "спишь",
|
||||
"break": "делаешь перерыв",
|
||||
"shower": "принимаешь душ",
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься",
|
||||
}
|
||||
|
||||
// FormatWeekdayRU reads back what he usually does on a given weekday.
|
||||
// Second person singular and informal, as she speaks TO him.
|
||||
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
acts := p.Weekly[wd]
|
||||
day := weekdayRU[int(wd)%7]
|
||||
if len(acts) == 0 {
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
}
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
func (p Profile) FormatOverallRU() string {
|
||||
if len(p.All) == 0 {
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
|
||||
}
|
||||
|
||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||
// not an answer; the most established few are.
|
||||
const maxRecited = 5
|
||||
|
||||
func joinActivities(acts []Activity) string {
|
||||
if len(acts) > maxRecited {
|
||||
acts = acts[:maxRecited]
|
||||
}
|
||||
parts := make([]string, len(acts))
|
||||
for i, a := range acts {
|
||||
gloss, ok := activityRU[a.Key]
|
||||
if !ok {
|
||||
gloss = a.Key
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
|
||||
var out []Observation
|
||||
d := from
|
||||
for d.Weekday() != wd {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
|
||||
Key: key,
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func behaviorNow() time.Time {
|
||||
// A Monday, so "по вторникам" is a past weekday and not today.
|
||||
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := append(
|
||||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
|
||||
)
|
||||
p := BuildProfile(obs, now)
|
||||
|
||||
tue := p.Weekly[time.Tuesday]
|
||||
if len(tue) != 2 {
|
||||
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
|
||||
}
|
||||
// Most-established first.
|
||||
if tue[0].Key != "workout" || tue[0].Days != 4 {
|
||||
t.Errorf("first = %+v, want workout on 4 days", tue[0])
|
||||
}
|
||||
if tue[0].TypicalAt != 19*time.Hour {
|
||||
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
|
||||
}
|
||||
if len(p.Weekly[time.Wednesday]) != 0 {
|
||||
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
|
||||
}
|
||||
if len(p.All) != 2 {
|
||||
t.Errorf("the week-wide list should hold both: %+v", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
|
||||
// confidently wrong claim about his life.
|
||||
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
|
||||
// Three rows, same day — a busy Tuesday, not a habit.
|
||||
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
|
||||
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
|
||||
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||
}
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||
t.Errorf("empty profile reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only self-facts describe him. Env rows are the world and config rows are
|
||||
// maven's own tuning state; counting either as a habit would be a category
|
||||
// error the owner would then be told about.
|
||||
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
|
||||
o.Kind = "env"
|
||||
obs = append(obs, o)
|
||||
}
|
||||
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
|
||||
obs = append(obs, o) // kind=self, but a machinery key
|
||||
}
|
||||
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
|
||||
obs = append(obs, o)
|
||||
}
|
||||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||
t.Fatalf("nothing here is a habit of his: %+v", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
// The median, not the mean: one 03:00 outlier must not move a morning habit
|
||||
// into the night.
|
||||
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
|
||||
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v", p.All)
|
||||
}
|
||||
if p.All[0].TypicalAt != 9*time.Hour {
|
||||
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFormatRUPersona(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := append(
|
||||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
|
||||
)
|
||||
p := BuildProfile(obs, now)
|
||||
|
||||
got := p.FormatWeekdayRU(time.Tuesday)
|
||||
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||
t.Errorf("an unknown weekday reads %q", empty)
|
||||
}
|
||||
// Persona: she addresses him informally, never in the masculine about
|
||||
// herself, and never with a pet name.
|
||||
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
|
||||
// Whole words: "ничего" contains "его", and a substring test would
|
||||
// call a correct sentence a persona violation.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r)
|
||||
}) {
|
||||
switch tok {
|
||||
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
|
||||
t.Errorf("%q uses %q", s, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unrecognised key is read back verbatim rather than glossed into something
|
||||
// maven made up.
|
||||
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A future-dated row is a clock problem, not a habit.
|
||||
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
|
||||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||
t.Fatalf("future rows counted: %+v", p.All)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The day plan (Vikunja #128).
|
||||
//
|
||||
// It lives here, with the morning routine engine, because it is the same
|
||||
// question asked at a different scale: the routine knows what is still missing
|
||||
// from a window, the plan knows what the whole day holds. A parallel system
|
||||
// would have to re-read the same facts and re-decide what "today" means.
|
||||
//
|
||||
// It is pure, like the rest of this package: the daemon reads the calendar,
|
||||
// the reminders and the checklist facts, and BuildPlan puts them in order.
|
||||
//
|
||||
// It is also NOT a nag. A plan she can recite when asked is the whole feature;
|
||||
// nothing here fires, schedules or announces. Unprompted delivery stays with
|
||||
// the existing morning nudge and the dispatcher's policy.
|
||||
|
||||
// PlanKind — where a plan line came from. It survives into the reply and the
|
||||
// web view because the three read differently: an event is something happening
|
||||
// to the owner, a reminder is something he asked for, a checklist item is
|
||||
// something he has not done yet.
|
||||
type PlanKind string
|
||||
|
||||
const (
|
||||
PlanEvent PlanKind = "event"
|
||||
PlanReminder PlanKind = "reminder"
|
||||
PlanChecklist PlanKind = "checklist"
|
||||
)
|
||||
|
||||
// PlanEntry — one timed thing on the day, as the daemon read it out of the
|
||||
// store. Text is rendered verbatim; the plan does not rephrase.
|
||||
//
|
||||
// Uncertain marks provenance below a full-confidence read — a work meeting
|
||||
// relayed off a phone notification (#126). It travels through to the reply so
|
||||
// she hedges instead of reciting a guess as fact.
|
||||
type PlanEntry struct {
|
||||
At time.Time
|
||||
Text string
|
||||
Kind PlanKind
|
||||
Uncertain bool
|
||||
}
|
||||
|
||||
// Plan — the ordered day. Date is the calendar day it describes.
|
||||
type Plan struct {
|
||||
Date time.Time
|
||||
Items []PlanEntry
|
||||
}
|
||||
|
||||
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||
// events, pending reminders, and one line per morning routine that still has
|
||||
// unfinished items.
|
||||
//
|
||||
// Entries outside that calendar day are dropped — a plan for today that
|
||||
// includes tomorrow's meeting is wrong in a way that is worse than terse.
|
||||
// Ordering is by time, then by kind, then by text, so the same day always reads
|
||||
// the same way.
|
||||
func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminders []PlanEntry, now time.Time) Plan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
p := Plan{Date: dayStart}
|
||||
for _, group := range [][]PlanEntry{events, reminders} {
|
||||
for _, e := range group {
|
||||
at := e.At.In(now.Location())
|
||||
if at.Before(dayStart) || !at.Before(dayEnd) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(e.Text) == "" {
|
||||
continue
|
||||
}
|
||||
e.At = at
|
||||
p.Items = append(p.Items, e)
|
||||
}
|
||||
}
|
||||
p.Items = append(p.Items, checklistEntries(routines, facts, now)...)
|
||||
|
||||
sort.SliceStable(p.Items, func(i, j int) bool {
|
||||
a, b := p.Items[i], p.Items[j]
|
||||
if !a.At.Equal(b.At) {
|
||||
return a.At.Before(b.At)
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.Text < b.Text
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// checklistEntries renders one line per routine with work left in it, placed at
|
||||
// the routine's nudge time — where the checklist actually matters in the day.
|
||||
// A routine that does not apply today, is not in its window, or is already
|
||||
// complete contributes nothing: the plan says what is left, not what was done.
|
||||
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||
var out []PlanEntry
|
||||
for _, r := range routines {
|
||||
st := Evaluate(r, facts, now)
|
||||
if !st.Active || len(st.Missing) == 0 {
|
||||
continue
|
||||
}
|
||||
labels := make([]string, 0, len(st.Missing))
|
||||
for _, it := range st.Missing {
|
||||
label := it.Label
|
||||
if label == "" {
|
||||
label = it.Key
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
at := r.NudgeAt
|
||||
if at == "" {
|
||||
at = r.WindowEnd
|
||||
}
|
||||
when, ok := todayAt(at, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, PlanEntry{
|
||||
At: when,
|
||||
Text: fmt.Sprintf("%s — осталось: %s", r.Name, strings.Join(labels, ", ")),
|
||||
Kind: PlanChecklist,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// After returns the part of the plan that has not happened yet — the answer to
|
||||
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||
// empty result still knows which day it is empty for.
|
||||
func (p Plan) After(now time.Time) Plan {
|
||||
out := Plan{Date: p.Date}
|
||||
for _, it := range p.Items {
|
||||
if it.At.Before(now) {
|
||||
continue
|
||||
}
|
||||
out.Items = append(out.Items, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
||||
// informal address, no pet names — and no exhortation: she reads the day back,
|
||||
// she does not tell him to get on with it.
|
||||
func (p Plan) FormatRU() string {
|
||||
if len(p.Items) == 0 {
|
||||
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
for i, it := range p.Items {
|
||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||
if it.Uncertain {
|
||||
line = "похоже, " + line
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing
|
||||
// at() in morning_test.go is pinned to a different date.
|
||||
func planAt(now time.Time, hh, mm int) time.Time {
|
||||
y, m, d := now.Date()
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
func planFixture(t *testing.T) (Plan, time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{{
|
||||
Name: "утро",
|
||||
WindowStart: "07:00",
|
||||
WindowEnd: "11:00",
|
||||
NudgeAt: "10:30",
|
||||
Items: []Item{
|
||||
{Key: "water", FactKey: "drank_water", Label: "выпить воды"},
|
||||
{Key: "pills", FactKey: "took_pills", Label: "витамины"},
|
||||
},
|
||||
}}
|
||||
facts := map[string]store.Fact{
|
||||
"drank_water": {Ts: planAt(now, 8, 0)},
|
||||
}
|
||||
events := []PlanEntry{
|
||||
{At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true},
|
||||
{At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent},
|
||||
}
|
||||
reminders := []PlanEntry{
|
||||
{At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder},
|
||||
}
|
||||
return BuildPlan(routines, facts, events, reminders, now), now
|
||||
}
|
||||
|
||||
func TestBuildPlanOrdersTheDay(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
|
||||
if !p.Date.Equal(planAt(now, 0, 0)) {
|
||||
t.Errorf("Date = %v, want midnight of now's day", p.Date)
|
||||
}
|
||||
want := []struct {
|
||||
hhmm string
|
||||
kind PlanKind
|
||||
}{
|
||||
{"10:00", PlanEvent},
|
||||
{"10:30", PlanChecklist},
|
||||
{"14:00", PlanEvent},
|
||||
{"18:30", PlanReminder},
|
||||
}
|
||||
if len(p.Items) != len(want) {
|
||||
t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items)
|
||||
}
|
||||
for i, w := range want {
|
||||
if got := p.Items[i].At.Format("15:04"); got != w.hhmm {
|
||||
t.Errorf("item %d at %s, want %s", i, got, w.hhmm)
|
||||
}
|
||||
if p.Items[i].Kind != w.kind {
|
||||
t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The checklist line says what is LEFT. An item already evidenced today must
|
||||
// not be read back as outstanding.
|
||||
func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) {
|
||||
p, _ := planFixture(t)
|
||||
var line string
|
||||
for _, it := range p.Items {
|
||||
if it.Kind == PlanChecklist {
|
||||
line = it.Text
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatal("no checklist line in the plan")
|
||||
}
|
||||
if !strings.Contains(line, "витамины") {
|
||||
t.Errorf("missing item not listed: %q", line)
|
||||
}
|
||||
if strings.Contains(line, "выпить воды") {
|
||||
t.Errorf("a completed item must not be read back as outstanding: %q", line)
|
||||
}
|
||||
if !strings.HasPrefix(line, "утро — осталось:") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{
|
||||
{
|
||||
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00",
|
||||
Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}},
|
||||
},
|
||||
{
|
||||
// Not in its window at 09:00.
|
||||
Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00",
|
||||
Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}},
|
||||
},
|
||||
}
|
||||
facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}}
|
||||
p := BuildPlan(routines, facts, nil, nil, now)
|
||||
if len(p.Items) != 0 {
|
||||
t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items)
|
||||
}
|
||||
if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan for today that includes tomorrow's meeting is worse than terse.
|
||||
func TestBuildPlanDropsOtherDays(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
events := []PlanEntry{
|
||||
{At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent},
|
||||
{At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent},
|
||||
{At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent},
|
||||
{At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent},
|
||||
}
|
||||
p := BuildPlan(nil, nil, events, nil, now)
|
||||
if len(p.Items) != 1 || p.Items[0].Text != "today" {
|
||||
t.Fatalf("got %+v", p.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFormatRU(t *testing.T) {
|
||||
p, _ := planFixture(t)
|
||||
got := p.FormatRU()
|
||||
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"10:30 — утро — осталось: витамины; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||
"18:30 — позвонить маме."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
// Persona: she recites, she does not exhort, and she never speaks of
|
||||
// herself in the masculine or addresses him formally.
|
||||
for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Errorf("plan text contains %q: %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAfter(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
rest := p.After(planAt(now, 11, 0))
|
||||
if len(rest.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items)
|
||||
}
|
||||
if !rest.Date.Equal(p.Date) {
|
||||
t.Error("After must keep the date, so an empty rest-of-day still knows which day")
|
||||
}
|
||||
empty := p.After(planAt(now, 23, 0))
|
||||
if len(empty.Items) != 0 {
|
||||
t.Errorf("got %+v", empty.Items)
|
||||
}
|
||||
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
|
||||
t.Errorf("empty plan reads %q", empty.FormatRU())
|
||||
}
|
||||
}
|
||||
+150
-34
@@ -27,14 +27,46 @@ var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||
type LLMPhraser struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
port string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||||
// Config.LLMNudges. nil only if the template file failed to load.
|
||||
tmpl *NudgeTemplates
|
||||
|
||||
// spawnCtx — the parent of every llama-server this phraser starts, i.e. the
|
||||
// daemon's own context. Deliberately NOT the per-request context of the call
|
||||
// that asked for a model swap: that one is cancelled the moment the request
|
||||
// returns, which would kill the model it had just loaded.
|
||||
spawnCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// launch / probe — the two side effects of a swap, injectable so the swap
|
||||
// logic is testable without a real llama-server and a real model file.
|
||||
// launch is nil when this phraser does not own its server (NewLLMPhraserAt),
|
||||
// which is also what makes Swap refuse there.
|
||||
launch func(ctx context.Context, cfg Config) (backend, error)
|
||||
probe func(ctx context.Context, base string) (string, error)
|
||||
|
||||
// swapMu — single-flight around Swap. Held for the whole swap, including the
|
||||
// model load, so two concurrent swap requests can never both be loading.
|
||||
swapMu sync.Mutex
|
||||
|
||||
// mu guards everything below: the live backend, the swap gate and the
|
||||
// in-flight request count. See acquire/quiesce in swap.go.
|
||||
mu sync.Mutex
|
||||
be backend
|
||||
live liveModel
|
||||
swapping bool
|
||||
inflight int
|
||||
observers []func(baseURL string)
|
||||
}
|
||||
|
||||
// liveModel — what is actually loaded right now. Distinct from Config, which
|
||||
// stays immutable after construction: a swap changes these three fields and
|
||||
// nothing else, so no reader of cfg (prompts, grammar, timeouts) races a swap.
|
||||
type liveModel struct {
|
||||
ModelPath string
|
||||
NGpuLayers int
|
||||
NCtx int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -85,15 +117,21 @@ func DefaultConfig(modelPath string) Config {
|
||||
func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p := &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
cancel: cancel,
|
||||
tmpl: loadNudgeTemplates(),
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
spawnCtx: ctx,
|
||||
cancel: cancel,
|
||||
launch: spawnLlamaServer,
|
||||
probe: defaultProbe,
|
||||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
if err := p.start(ctx); err != nil {
|
||||
be, err := p.launch(ctx, cfg)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
p.be = be
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -106,11 +144,17 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
// still uses NewLLMPhraser and still owns its own child process.
|
||||
func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
||||
return &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
port: strings.TrimSuffix(baseURL, "/"),
|
||||
cancel: func() {},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
spawnCtx: context.Background(),
|
||||
cancel: func() {},
|
||||
probe: defaultProbe,
|
||||
// launch stays nil: we did not start this server, so we must not stop it.
|
||||
// Swap therefore refuses here (ErrSwapNotOwned) instead of killing a
|
||||
// server another process depends on.
|
||||
be: borrowedBackend(strings.TrimSuffix(baseURL, "/")),
|
||||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,16 +170,66 @@ func loadNudgeTemplates() *NudgeTemplates {
|
||||
return nt
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
// backend — one llama-server this phraser talks to. Two implementations: a
|
||||
// llamaProc we spawned and must reap, and a borrowedBackend someone else owns.
|
||||
type backend interface {
|
||||
BaseURL() string
|
||||
Close() error
|
||||
}
|
||||
|
||||
// borrowedBackend — a server started and owned by someone else (the phrasing
|
||||
// scorer's shared llama-server). Closing it is a no-op by construction.
|
||||
type borrowedBackend string
|
||||
|
||||
func (b borrowedBackend) BaseURL() string { return string(b) }
|
||||
func (b borrowedBackend) Close() error { return nil }
|
||||
|
||||
// llamaProc — a llama-server child process plus the goroutine reading its
|
||||
// stderr. Close kills and reaps it; see the Pdeathsig note in spawnLlamaServer.
|
||||
type llamaProc struct {
|
||||
base string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func (l *llamaProc) BaseURL() string { return l.base }
|
||||
|
||||
func (l *llamaProc) Close() error {
|
||||
l.cancel()
|
||||
if l.cmd != nil && l.cmd.Process != nil {
|
||||
_ = l.cmd.Process.Kill()
|
||||
_ = l.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||||
}
|
||||
l.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// spawnLlamaServer starts one llama-server for cfg and waits until it says which
|
||||
// address it is listening on. ctx owns the process lifetime, so it must be the
|
||||
// daemon's context, not a request's.
|
||||
func spawnLlamaServer(ctx context.Context, cfg Config) (backend, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
p, err := startLlamaProc(ctx, cfg)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
p.cancel = cancel
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||||
p := &llamaProc{}
|
||||
args := []string{
|
||||
"-m", p.cfg.ModelPath,
|
||||
"-m", cfg.ModelPath,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", extractPort(p.cfg.Listen),
|
||||
"-c", fmt.Sprintf("%d", p.cfg.NCtx),
|
||||
"-ngl", fmt.Sprintf("%d", p.cfg.NGpuLayers),
|
||||
"--port", extractPort(cfg.Listen),
|
||||
"-c", fmt.Sprintf("%d", cfg.NCtx),
|
||||
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
|
||||
"--no-webui",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.cfg.BinPath, args...)
|
||||
cmd := exec.CommandContext(ctx, cfg.BinPath, args...)
|
||||
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
||||
// ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without
|
||||
// it a hard-killed mavend orphans its llama-server (reparented to init, keeps
|
||||
@@ -148,12 +242,12 @@ func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("llm: stderr pipe: %w", err)
|
||||
return nil, fmt.Errorf("llm: stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
stderr.Close()
|
||||
return fmt.Errorf("llm: start: %w", err)
|
||||
return nil, fmt.Errorf("llm: start: %w", err)
|
||||
}
|
||||
|
||||
portCh := make(chan string, 1)
|
||||
@@ -186,32 +280,44 @@ func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
|
||||
select {
|
||||
case addr := <-portCh:
|
||||
p.port = addr
|
||||
return nil
|
||||
p.base = addr
|
||||
return p, nil
|
||||
case err := <-errCh:
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return fmt.Errorf("llm: server output: %w", err)
|
||||
return nil, fmt.Errorf("llm: server output: %w", err)
|
||||
case <-ctx.Done():
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return ctx.Err()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(60 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return fmt.Errorf("llm: server did not start within 60s")
|
||||
return nil, fmt.Errorf("llm: server did not start within 60s")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) BaseURL() string { return p.port }
|
||||
// BaseURL is the llama-server this phraser talks to right now. It changes when
|
||||
// the model is swapped, so callers that cache it must register an observer
|
||||
// (OnSwap) rather than keeping the string forever.
|
||||
func (p *LLMPhraser) BaseURL() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.be == nil {
|
||||
return ""
|
||||
}
|
||||
return p.be.BaseURL()
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) Close() error {
|
||||
p.cancel()
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
_ = p.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||||
p.mu.Lock()
|
||||
be := p.be
|
||||
p.be = nil
|
||||
p.mu.Unlock()
|
||||
if be != nil {
|
||||
return be.Close()
|
||||
}
|
||||
p.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -350,6 +456,11 @@ func chatSystemPrompt(block func() string) string {
|
||||
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
|
||||
// slice — the caller owns the system prompt placement.
|
||||
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: msgs,
|
||||
Temperature: 0.7,
|
||||
@@ -360,7 +471,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: request: %w", err)
|
||||
}
|
||||
@@ -493,6 +604,11 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: []chatMsg{
|
||||
{Role: "system", Content: system},
|
||||
@@ -507,7 +623,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("llm: request: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Swapping the resident model without restarting the daemon (Vikunja #250).
|
||||
//
|
||||
// Three properties this file exists to hold, in order of importance:
|
||||
//
|
||||
// 1. NEVER two models resident at once. The deploy target is a laptop iGPU
|
||||
// with the whole 1.7B offloaded to it (`n_gpu_layers: 99`); loading a second
|
||||
// model beside the first is how you OOM the box, and a blue/green swap that
|
||||
// "keeps the old one warm until the new one answers" does exactly that. So
|
||||
// the old server is killed FIRST and the new one loaded after. The cost of
|
||||
// that ordering is a window with no model at all, which is why:
|
||||
//
|
||||
// 2. A swap is atomic from a turn's point of view. An in-flight turn finishes
|
||||
// on the old model — Swap waits for the last one to return before killing
|
||||
// anything. A turn that arrives during the swap is REFUSED immediately with
|
||||
// ErrSwapping rather than blocked: every phrasing path already has a
|
||||
// fallback (templates, "вот что я нашла", the classifier for routing), so a
|
||||
// fast refusal degrades one turn instead of hanging it for the length of a
|
||||
// model load. No turn ever gets half of one model and half of another.
|
||||
//
|
||||
// 3. A failed load rolls back to the model that was working. The new server is
|
||||
// probed (it must say which model it loaded) before it is published; if the
|
||||
// launch or the probe fails, the previous config is relaunched and the
|
||||
// phraser goes back to serving. Only if the rollback ALSO fails is the
|
||||
// phraser left without a backend, and then it says so loudly and every turn
|
||||
// degrades rather than breaks.
|
||||
//
|
||||
// Not here, deliberately: nothing calls Swap on a timer, and no act or intent can
|
||||
// reach it. It is an IPC method behind the step-up gate, i.e. owner-triggered.
|
||||
|
||||
var (
|
||||
// ErrSwapping — a turn arrived while the model was being swapped. Callers
|
||||
// treat it like any other LLM error and use their fallback.
|
||||
ErrSwapping = errors.New("phraser: model swap in progress")
|
||||
|
||||
// ErrSwapNotOwned — this phraser did not start its llama-server, so it must
|
||||
// not stop one (NewLLMPhraserAt: the eval harness shares a server).
|
||||
ErrSwapNotOwned = errors.New("phraser: llama-server is not ours to swap")
|
||||
|
||||
// ErrNoBackend — no model is loaded at all. Only reachable after a failed
|
||||
// swap whose rollback also failed.
|
||||
ErrNoBackend = errors.New("phraser: no llama-server loaded")
|
||||
|
||||
// ErrSwapBusy — a turn was still running when the drain deadline expired, so
|
||||
// the swap was abandoned. Nothing was killed; ask again.
|
||||
ErrSwapBusy = errors.New("phraser: turns still in flight, swap abandoned")
|
||||
)
|
||||
|
||||
// SwapSpec — what to load. Zero NGpuLayers/NCtx keep whatever is live, so the
|
||||
// common case ("same settings, different gguf") is one field.
|
||||
type SwapSpec struct {
|
||||
ModelPath string
|
||||
NGpuLayers int
|
||||
NCtx int
|
||||
}
|
||||
|
||||
// SwapResult — what happened. Model is the identity the NEW server reported, so
|
||||
// it is evidence rather than an echo of the request: if the file at ModelPath is
|
||||
// not what the operator thought it was, this is where that shows up.
|
||||
type SwapResult struct {
|
||||
Model string
|
||||
BaseURL string
|
||||
ModelPath string
|
||||
RolledBack bool
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// drainTimeout — how long Swap waits for in-flight turns before giving up. A
|
||||
// turn is at most Config.Timeout (30s in deploy) plus the model's own latency;
|
||||
// 90s covers a slow Thinking generation without wedging the caller forever.
|
||||
const drainTimeout = 90 * time.Second
|
||||
|
||||
// probeTimeout — how long the new server gets to answer "which model do you
|
||||
// have". The load itself is bounded by spawnLlamaServer's own 60s wait.
|
||||
const probeTimeout = 30 * time.Second
|
||||
|
||||
// defaultProbe asks the server which model it has loaded. This is the health
|
||||
// check: a server that answers /v1/models has finished loading weights and is
|
||||
// serving, and its answer is the identity we report back.
|
||||
func defaultProbe(ctx context.Context, base string) (string, error) {
|
||||
return llm.ModelID(ctx, base)
|
||||
}
|
||||
|
||||
// OnSwap registers a callback fired with the new base URL every time the live
|
||||
// backend changes, including after a rollback. Holders of an *llm.Client (the
|
||||
// LLM router, the replier, the mail extractor) register SetBaseURL here so a
|
||||
// swap re-points them without rebuilding the router or the handler.
|
||||
//
|
||||
// Callbacks run with no lock held, in registration order.
|
||||
func (p *LLMPhraser) OnSwap(fn func(baseURL string)) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.observers = append(p.observers, fn)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// LiveModel is the model file currently loaded (and its load settings). Empty
|
||||
// ModelPath means no model is loaded.
|
||||
func (p *LLMPhraser) LiveModel() (path string, nGpuLayers, nCtx int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.live.ModelPath, p.live.NGpuLayers, p.live.NCtx
|
||||
}
|
||||
|
||||
// acquire reserves a slot for one request and returns the base URL to use.
|
||||
// Every request path must call it and must call the returned release exactly
|
||||
// once — that count is what Swap drains.
|
||||
func (p *LLMPhraser) acquire() (string, func(), error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.swapping {
|
||||
return "", nil, ErrSwapping
|
||||
}
|
||||
if p.be == nil {
|
||||
return "", nil, ErrNoBackend
|
||||
}
|
||||
p.inflight++
|
||||
base := p.be.BaseURL()
|
||||
var once bool
|
||||
return base, func() {
|
||||
p.mu.Lock()
|
||||
if !once {
|
||||
once = true
|
||||
p.inflight--
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Swap loads another model in place of the live one. See the file comment for
|
||||
// the properties it guarantees. Returns the new model's reported identity, or
|
||||
// an error plus RolledBack=true when the old model was put back.
|
||||
//
|
||||
// ctx bounds the drain and the probe. It does NOT own the new server's lifetime
|
||||
// — that is the daemon's context, captured at construction — so a swap survives
|
||||
// the request that asked for it.
|
||||
func (p *LLMPhraser) Swap(ctx context.Context, spec SwapSpec) (SwapResult, error) {
|
||||
if spec.ModelPath == "" {
|
||||
return SwapResult{}, fmt.Errorf("phraser: swap needs a model path")
|
||||
}
|
||||
p.swapMu.Lock()
|
||||
defer p.swapMu.Unlock()
|
||||
|
||||
if p.launch == nil {
|
||||
return SwapResult{}, ErrSwapNotOwned
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
oldLive := p.liveSnapshot()
|
||||
newLive := liveModel{
|
||||
ModelPath: spec.ModelPath,
|
||||
NGpuLayers: pickInt(spec.NGpuLayers, oldLive.NGpuLayers),
|
||||
NCtx: pickInt(spec.NCtx, oldLive.NCtx),
|
||||
}
|
||||
if newLive == oldLive && p.BaseURL() != "" {
|
||||
// Already serving exactly this. Report the live identity rather than
|
||||
// pointlessly unloading and reloading the same weights.
|
||||
base := p.BaseURL()
|
||||
id, err := p.probeWith(ctx, base)
|
||||
if err != nil {
|
||||
return SwapResult{}, err
|
||||
}
|
||||
return SwapResult{Model: id, BaseURL: base, ModelPath: oldLive.ModelPath, Took: time.Since(started)}, nil
|
||||
}
|
||||
|
||||
if err := p.quiesce(ctx); err != nil {
|
||||
return SwapResult{}, err
|
||||
}
|
||||
defer p.resume()
|
||||
|
||||
// Property 1: the old model leaves the GPU before the new one arrives.
|
||||
p.mu.Lock()
|
||||
old := p.be
|
||||
p.be = nil
|
||||
p.mu.Unlock()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
|
||||
be, err := p.loadAndProbe(ctx, newLive)
|
||||
if err != nil {
|
||||
log.Printf("phraser: swap to %s FAILED (%v) — rolling back to %s", newLive.ModelPath, err, oldLive.ModelPath)
|
||||
rb, rbErr := p.loadAndProbe(ctx, oldLive)
|
||||
if rbErr != nil {
|
||||
log.Printf("phraser: ROLLBACK to %s ALSO FAILED (%v) — no model is loaded, every phrasing path is on its fallback and routing is on the classifier until the daemon is restarted", oldLive.ModelPath, rbErr)
|
||||
return SwapResult{RolledBack: true, Took: time.Since(started)},
|
||||
fmt.Errorf("phraser: swap failed (%w) and rollback failed too: %v", err, rbErr)
|
||||
}
|
||||
p.publish(rb, oldLive)
|
||||
return SwapResult{
|
||||
Model: rb.id, BaseURL: rb.be.BaseURL(), ModelPath: oldLive.ModelPath,
|
||||
RolledBack: true, Took: time.Since(started),
|
||||
},
|
||||
fmt.Errorf("phraser: swap to %s failed, rolled back to %s: %w", newLive.ModelPath, oldLive.ModelPath, err)
|
||||
}
|
||||
p.publish(be, newLive)
|
||||
log.Printf("phraser: model swapped to %s (%s) at %s in %s", newLive.ModelPath, be.id, be.be.BaseURL(), time.Since(started).Round(time.Millisecond))
|
||||
return SwapResult{
|
||||
Model: be.id, BaseURL: be.be.BaseURL(), ModelPath: newLive.ModelPath,
|
||||
Took: time.Since(started),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loaded — a started server plus the identity it reported.
|
||||
type loaded struct {
|
||||
be backend
|
||||
id string
|
||||
}
|
||||
|
||||
// loadAndProbe starts a server for lm and verifies it answers. A server that
|
||||
// starts but will not say what it loaded is treated as a failed load and is
|
||||
// killed here — publishing it would hand every turn to a backend we could not
|
||||
// confirm.
|
||||
func (p *LLMPhraser) loadAndProbe(ctx context.Context, lm liveModel) (loaded, error) {
|
||||
cfg := p.cfg
|
||||
cfg.ModelPath = lm.ModelPath
|
||||
cfg.NGpuLayers = lm.NGpuLayers
|
||||
cfg.NCtx = lm.NCtx
|
||||
// p.spawnCtx, not ctx: the process must outlive the request asking for it.
|
||||
be, err := p.launch(p.spawnCtx, cfg)
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
}
|
||||
id, err := p.probeWith(ctx, be.BaseURL())
|
||||
if err != nil {
|
||||
_ = be.Close()
|
||||
return loaded{}, fmt.Errorf("phraser: %s started but would not answer: %w", lm.ModelPath, err)
|
||||
}
|
||||
return loaded{be: be, id: id}, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) probeWith(ctx context.Context, base string) (string, error) {
|
||||
probe := p.probe
|
||||
if probe == nil {
|
||||
probe = defaultProbe
|
||||
}
|
||||
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
return probe(pctx, base)
|
||||
}
|
||||
|
||||
// quiesce closes the door on new turns and waits for the ones already running.
|
||||
// Polling rather than a sync.Cond: the wait happens once per swap, a 25ms poll
|
||||
// is invisible next to a model load, and a poll cannot deadlock on a release
|
||||
// path that panicked.
|
||||
func (p *LLMPhraser) quiesce(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
if p.swapping {
|
||||
p.mu.Unlock()
|
||||
return ErrSwapping
|
||||
}
|
||||
p.swapping = true
|
||||
inflight := p.inflight
|
||||
p.mu.Unlock()
|
||||
if inflight == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(drainTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.resume()
|
||||
return ctx.Err()
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
p.mu.Lock()
|
||||
inflight = p.inflight
|
||||
p.mu.Unlock()
|
||||
if inflight == 0 {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// Nothing has been killed yet, so abandoning is free: reopen the door
|
||||
// and let the operator try again rather than cutting a live turn off
|
||||
// mid-generation.
|
||||
p.resume()
|
||||
return fmt.Errorf("%w (%d still running after %s)", ErrSwapBusy, inflight, drainTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) resume() {
|
||||
p.mu.Lock()
|
||||
p.swapping = false
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// publish makes l the live backend and tells everyone holding a base URL.
|
||||
func (p *LLMPhraser) publish(l loaded, lm liveModel) {
|
||||
p.mu.Lock()
|
||||
p.be = l.be
|
||||
p.live = lm
|
||||
obs := make([]func(string), len(p.observers))
|
||||
copy(obs, p.observers)
|
||||
p.mu.Unlock()
|
||||
base := l.be.BaseURL()
|
||||
for _, fn := range obs {
|
||||
fn(base)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) liveSnapshot() liveModel {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.live
|
||||
}
|
||||
|
||||
// pickInt returns v when the caller set it, and fallback otherwise. 0 is the
|
||||
// "unset" value: -1 already means "offload every layer" and deploy uses 99, so
|
||||
// nothing legitimate asks for exactly zero GPU layers through this path.
|
||||
func pickInt(v, fallback int) int {
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeModel — a stand-in llama-server. It answers /v1/models with its own name
|
||||
// and /v1/chat/completions with a phrasing-contract reply that names itself, so
|
||||
// a test can tell WHICH model answered a turn — the property the swap is about.
|
||||
type fakeModel struct {
|
||||
srv *httptest.Server
|
||||
name string
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func newFakeModel(t *testing.T, name string) *fakeModel {
|
||||
t.Helper()
|
||||
f := &fakeModel{name: name}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"data":[{"id":"/models/` + name + `.gguf"}]}`))
|
||||
})
|
||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\":\"` + name + `\",\"mood\":\"neutral\"}"}}]}`))
|
||||
})
|
||||
f.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeModel) BaseURL() string { return f.srv.URL }
|
||||
func (f *fakeModel) Close() error { f.closed.Store(true); return nil }
|
||||
|
||||
// fakeFleet is the injected launcher: it hands out a prepared fakeModel per
|
||||
// model path, and refuses paths the test did not prepare (that is what a bad
|
||||
// gguf looks like from here). It also asserts the invariant that matters on a
|
||||
// laptop iGPU: never two servers alive at the same time.
|
||||
type fakeFleet struct {
|
||||
mu sync.Mutex
|
||||
models map[string]string // model path → fake name
|
||||
live int
|
||||
maxLive int
|
||||
launch int
|
||||
}
|
||||
|
||||
func (fl *fakeFleet) launcher(t *testing.T) func(context.Context, Config) (backend, error) {
|
||||
return func(ctx context.Context, cfg Config) (backend, error) {
|
||||
fl.mu.Lock()
|
||||
name, ok := fl.models[cfg.ModelPath]
|
||||
fl.launch++
|
||||
if !ok {
|
||||
fl.mu.Unlock()
|
||||
return nil, errors.New("no such model file: " + cfg.ModelPath)
|
||||
}
|
||||
fl.live++
|
||||
if fl.live > fl.maxLive {
|
||||
fl.maxLive = fl.live
|
||||
}
|
||||
fl.mu.Unlock()
|
||||
f := newFakeModel(t, name)
|
||||
return &fleetBackend{fleet: fl, model: f}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type fleetBackend struct {
|
||||
fleet *fakeFleet
|
||||
model *fakeModel
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *fleetBackend) BaseURL() string { return b.model.BaseURL() }
|
||||
func (b *fleetBackend) Close() error {
|
||||
b.once.Do(func() {
|
||||
b.fleet.mu.Lock()
|
||||
b.fleet.live--
|
||||
b.fleet.mu.Unlock()
|
||||
})
|
||||
return b.model.Close()
|
||||
}
|
||||
|
||||
// newSwapPhraser builds an LLMPhraser with an injected launcher, so the swap
|
||||
// path is exercised without a gguf or a GPU.
|
||||
func newSwapPhraser(t *testing.T, fl *fakeFleet, modelPath string) *LLMPhraser {
|
||||
t.Helper()
|
||||
cfg := DefaultConfig(modelPath)
|
||||
cfg.Timeout = 5 * time.Second
|
||||
p := &LLMPhraser{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
spawnCtx: context.Background(),
|
||||
cancel: func() {},
|
||||
launch: fl.launcher(t),
|
||||
probe: defaultProbe,
|
||||
live: liveModel{ModelPath: modelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||||
}
|
||||
be, err := p.launch(p.spawnCtx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("initial launch: %v", err)
|
||||
}
|
||||
p.be = be
|
||||
t.Cleanup(func() { p.Close() })
|
||||
return p
|
||||
}
|
||||
|
||||
func TestSwap_LoadsNewModelAndRepointsHolders(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
// A holder of the base URL (the LLM router's client, in the daemon).
|
||||
var seen []string
|
||||
p.OnSwap(func(base string) { seen = append(seen, base) })
|
||||
|
||||
before, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || before != "old" {
|
||||
t.Fatalf("before swap: %q, %v; want the old model to answer", before, err)
|
||||
}
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"})
|
||||
if err != nil {
|
||||
t.Fatalf("Swap: %v", err)
|
||||
}
|
||||
if res.Model != "new" {
|
||||
t.Errorf("res.Model = %q; want the identity the NEW server reported (%q)", res.Model, "new")
|
||||
}
|
||||
if res.RolledBack {
|
||||
t.Errorf("res.RolledBack = true on a successful swap")
|
||||
}
|
||||
after, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || after != "new" {
|
||||
t.Fatalf("after swap: %q, %v; want the new model to answer", after, err)
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/new.gguf" {
|
||||
t.Errorf("LiveModel = %q; want /m/new.gguf", path)
|
||||
}
|
||||
if len(seen) != 1 || seen[0] != p.BaseURL() {
|
||||
t.Errorf("observers saw %v; want exactly one call with the new base %q", seen, p.BaseURL())
|
||||
}
|
||||
if fl.maxLive > 1 {
|
||||
t.Errorf("%d servers were alive at once; the iGPU only fits one model", fl.maxLive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_FailedLoadRollsBackToTheWorkingModel(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap to a model that will not load returned nil error")
|
||||
}
|
||||
if !res.RolledBack {
|
||||
t.Errorf("res.RolledBack = false; a failed swap must say it rolled back")
|
||||
}
|
||||
if res.Model != "old" {
|
||||
t.Errorf("res.Model = %q; want the old model back", res.Model)
|
||||
}
|
||||
// The point of the rollback: turns keep working.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || got != "old" {
|
||||
t.Fatalf("after rollback: %q, %v; want the old model serving again", got, err)
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/old.gguf" {
|
||||
t.Errorf("LiveModel = %q; want the old model", path)
|
||||
}
|
||||
if fl.maxLive > 1 {
|
||||
t.Errorf("%d servers alive at once during a rollback", fl.maxLive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_ProbeFailureIsTreatedAsAFailedLoad(t *testing.T) {
|
||||
// A server that starts but will not say what it loaded must never be
|
||||
// published — we would be serving turns from a backend we cannot confirm.
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/mute.gguf": "mute"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
// Fail the probe once — for the newly launched server — and let the
|
||||
// rollback's probe through.
|
||||
calls := 0
|
||||
p.probe = func(ctx context.Context, base string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "", errors.New("no answer from the new server")
|
||||
}
|
||||
return defaultProbe(ctx, base)
|
||||
}
|
||||
|
||||
_, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/mute.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap published a server that failed its probe")
|
||||
}
|
||||
if path, _, _ := p.LiveModel(); path != "/m/old.gguf" {
|
||||
t.Errorf("LiveModel = %q; want the old model after a failed probe", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
// Make the rollback fail too: the old file "disappears" mid-swap.
|
||||
fl.mu.Lock()
|
||||
delete(fl.models, "/m/old.gguf")
|
||||
fl.mu.Unlock()
|
||||
|
||||
_, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/broken.gguf"})
|
||||
if err == nil {
|
||||
t.Fatal("Swap returned nil when both the load and the rollback failed")
|
||||
}
|
||||
// Nothing is loaded, and the request path says so rather than panicking.
|
||||
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
||||
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
|
||||
}
|
||||
// Phrasing degrades to its fallback instead of failing the turn.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("PhraseChat returned empty; the fallback must still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_WaitsForInFlightTurnAndRefusesNewOnes(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old", "/m/new.gguf": "new"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
|
||||
// Hold one turn open by taking a slot directly — the same slot every
|
||||
// request path takes.
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire: %v", err)
|
||||
}
|
||||
if base == "" {
|
||||
t.Fatal("acquire returned an empty base URL")
|
||||
}
|
||||
|
||||
swapped := make(chan error, 1)
|
||||
go func() { _, e := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); swapped <- e }()
|
||||
|
||||
// While the swap waits to drain, a NEW turn is refused immediately rather
|
||||
// than blocked for the length of a model load.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
_, rel, aerr := p.acquire()
|
||||
if rel != nil {
|
||||
rel()
|
||||
}
|
||||
if errors.Is(aerr, ErrSwapping) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("new turns were never refused during a swap (last error: %v)", aerr)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// The swap cannot have completed while our turn was still in flight.
|
||||
select {
|
||||
case e := <-swapped:
|
||||
t.Fatalf("Swap finished before the in-flight turn released: %v", e)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
if e := <-swapped; e != nil {
|
||||
t.Fatalf("Swap after drain: %v", e)
|
||||
}
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if err != nil || got != "new" {
|
||||
t.Fatalf("after swap: %q, %v; want the new model", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_RefusedWhenWeDoNotOwnTheServer(t *testing.T) {
|
||||
// NewLLMPhraserAt points at a shared server the eval harness owns. Swapping
|
||||
// there would kill a server another process depends on.
|
||||
p := NewLLMPhraserAt("http://127.0.0.1:1/", DefaultConfig("/m/old.gguf"))
|
||||
if _, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/new.gguf"}); !errors.Is(err, ErrSwapNotOwned) {
|
||||
t.Fatalf("Swap on a borrowed server = %v; want ErrSwapNotOwned", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_SameModelIsANoOp(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
launchesBefore := fl.launch
|
||||
|
||||
res, err := p.Swap(context.Background(), SwapSpec{ModelPath: "/m/old.gguf"})
|
||||
if err != nil {
|
||||
t.Fatalf("Swap to the live model: %v", err)
|
||||
}
|
||||
if res.Model != "old" {
|
||||
t.Errorf("res.Model = %q; want old", res.Model)
|
||||
}
|
||||
if fl.launch != launchesBefore {
|
||||
t.Errorf("%d extra launches; swapping to the live model must not reload weights", fl.launch-launchesBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwap_EmptyModelPathRefused(t *testing.T) {
|
||||
fl := &fakeFleet{models: map[string]string{"/m/old.gguf": "old"}}
|
||||
p := newSwapPhraser(t, fl, "/m/old.gguf")
|
||||
if _, err := p.Swap(context.Background(), SwapSpec{}); err == nil {
|
||||
t.Fatal("Swap with no model path returned nil error")
|
||||
}
|
||||
}
|
||||
+100
-4
@@ -4,16 +4,112 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// CalendarEventFormatter formats calendar events into a Russian reply string.
|
||||
type CalendarEventFormatter struct{}
|
||||
|
||||
// Format returns a Russian reply for the given calendar events on the given date.
|
||||
func (CalendarEventFormatter) Format(events []string, date time.Time) string {
|
||||
// CalendarEntry — one event to recite. Uncertain marks an event maven did not
|
||||
// read off a calendar server: the work calendar arrives as relayed phone
|
||||
// notifications (Vikunja #126), stored below full confidence, and she says so
|
||||
// rather than reciting a guess as fact.
|
||||
type CalendarEntry struct {
|
||||
Text string
|
||||
Uncertain bool
|
||||
}
|
||||
|
||||
// dayPlanWords — the tokens that ask for the day as a whole rather than for a
|
||||
// calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a
|
||||
// notification about one must not be mistaken for a request for the plan.
|
||||
var dayPlanWords = []string{
|
||||
"план", "плана", "плану", "плане", "планом",
|
||||
"планы", "планов", "планам", "планах",
|
||||
"расписание", "расписании", "распорядок", "распорядке",
|
||||
"plan", "plans", "schedule", "agenda",
|
||||
}
|
||||
|
||||
// otherDayWords — a day that is not today. The plan is built for the clock's
|
||||
// own day only, so an utterance naming another one belongs to the calendar
|
||||
// listing instead. Claiming it here would answer the wrong day, which is worse
|
||||
// than answering more tersely.
|
||||
var otherDayWords = []string{
|
||||
"завтра", "послезавтра", "вчера", "позавчера",
|
||||
"tomorrow", "yesterday",
|
||||
}
|
||||
|
||||
// IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja
|
||||
// #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?".
|
||||
//
|
||||
// Deliberately narrow. The calendar listing already answers "что у меня
|
||||
// сегодня?" and a plan that hijacks every date-bearing question would bury the
|
||||
// events under checklist lines. Only a plan-shaped ask, and only about today.
|
||||
func IsDayPlanQuery(text string) bool {
|
||||
toks := planTokens(text)
|
||||
for _, t := range toks {
|
||||
for _, w := range otherDayWords {
|
||||
if t == w {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range toks {
|
||||
for _, w := range dayPlanWords {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// "что дальше?" / "what's next?" — the rest of the day, with no plan word
|
||||
// in it. Both tokens rather than adjacency, because "what's" splits into
|
||||
// "what" and "s" and because "и что потом дальше" is the same question.
|
||||
return (hasTok(toks, "что") && hasTok(toks, "дальше")) ||
|
||||
(hasTok(toks, "what") && hasTok(toks, "next"))
|
||||
}
|
||||
|
||||
func hasTok(toks []string, w string) bool {
|
||||
for _, t := range toks {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTokens lowercases and splits on everything that is not a letter or a
|
||||
// digit, so "планы?" and "что-дальше" tokenize like the plain words do.
|
||||
func planTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Format returns a Russian reply for the given calendar events on the given
|
||||
// date. Every event is treated as certain — use FormatEntries when provenance
|
||||
// differs between them.
|
||||
func (f CalendarEventFormatter) Format(events []string, date time.Time) string {
|
||||
entries := make([]CalendarEntry, len(events))
|
||||
for i, e := range events {
|
||||
entries[i] = CalendarEntry{Text: e}
|
||||
}
|
||||
return f.FormatEntries(entries, date)
|
||||
}
|
||||
|
||||
// FormatEntries returns a Russian reply, hedging the entries maven is not sure
|
||||
// about. "похоже" and not "возможно": the notification did arrive, what is
|
||||
// uncertain is whether it describes the meeting correctly.
|
||||
func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string {
|
||||
dateStr := date.Format("02.01.2006")
|
||||
if len(events) == 0 {
|
||||
if len(entries) == 0 {
|
||||
return fmt.Sprintf("на %s ничего нет.", dateStr)
|
||||
}
|
||||
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; "))
|
||||
parts := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
if e.Uncertain {
|
||||
parts[i] = "похоже, " + e.Text
|
||||
continue
|
||||
}
|
||||
parts[i] = e.Text
|
||||
}
|
||||
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
@@ -24,3 +24,60 @@ func TestCalendarEventFormatter(t *testing.T) {
|
||||
t.Errorf("multiple: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) {
|
||||
f := CalendarEventFormatter{}
|
||||
date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// An event relayed off a phone notification is not a calendar read, and she
|
||||
// says so instead of reciting a guess as fact.
|
||||
got := f.FormatEntries([]CalendarEntry{
|
||||
{Text: "Standup @ 10:00-10:30"},
|
||||
{Text: "Планёрка @ 14:00-14:30", Uncertain: true},
|
||||
}, date)
|
||||
want := "на 06.07.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
|
||||
// Format is FormatEntries with everything certain.
|
||||
if got := f.FormatEntries(nil, date); got != "на 06.07.2026 ничего нет." {
|
||||
t.Errorf("empty: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDayPlanQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие планы на сегодня?",
|
||||
"что у меня по плану",
|
||||
"расскажи план",
|
||||
"мой распорядок на сегодня",
|
||||
"расписание?",
|
||||
"что дальше?",
|
||||
"what's next",
|
||||
"what is my plan today",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !IsDayPlanQuery(s) {
|
||||
t.Errorf("IsDayPlanQuery(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
|
||||
no := []string{
|
||||
// The calendar listing owns these.
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
"план на послезавтра",
|
||||
"что было вчера",
|
||||
// "планёрка" is a meeting, not a request for the plan.
|
||||
"когда планёрка?",
|
||||
"запиши планёрку на 14:00",
|
||||
"какая погода?",
|
||||
"",
|
||||
}
|
||||
for _, s := range no {
|
||||
if IsDayPlanQuery(s) {
|
||||
t.Errorf("IsDayPlanQuery(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// Feed queries — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// Deterministic matching, like the calendar, plan and habit matchers above it:
|
||||
// the LLM router says this is a query, and this decides whether it is a question
|
||||
// about the feeds. A model deciding that would occasionally answer "что нового?"
|
||||
// out of world knowledge, which is the one thing a feed reader exists to avoid.
|
||||
|
||||
// FeedQuery — a parsed "what's new" question. Category is the topic he named
|
||||
// ("технологии"), empty when he asked about the feeds in general.
|
||||
type FeedQuery struct {
|
||||
Category string
|
||||
}
|
||||
|
||||
// feedNouns — the words that make a question about the feeds themselves.
|
||||
var feedNouns = []string{
|
||||
"лента", "ленте", "ленты", "лентах", "лентам",
|
||||
"новости", "новостей", "новостях", "новостям",
|
||||
"новое", "нового", "новенького",
|
||||
"feed", "feeds", "news", "headlines",
|
||||
}
|
||||
|
||||
// newnessMarkers — the "что нового" half. "нового" alone is in feedNouns
|
||||
// because it carries the question on its own ("что нового?"); a bare "лента"
|
||||
// needs the ask, which is what askMarkers below is for.
|
||||
var askMarkers = []string{
|
||||
"что", "какие", "какое", "расскажи", "почитай", "прочитай", "покажи",
|
||||
"what", "any", "tell", "show", "read",
|
||||
}
|
||||
|
||||
// ParseFeedQuery reports whether an utterance asks what is new in the feeds, and
|
||||
// which topic if it names one after "по"/"о"/"про"/"about".
|
||||
//
|
||||
// Both a feed noun and an ask are required. "у меня новая лента в инстаграме" is
|
||||
// a statement and must not be read as a request to recite headlines.
|
||||
func ParseFeedQuery(text string) (FeedQuery, bool) {
|
||||
toks := planTokens(text)
|
||||
noun, ask := false, false
|
||||
for _, t := range toks {
|
||||
for _, n := range feedNouns {
|
||||
if t == n {
|
||||
noun = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, a := range askMarkers {
|
||||
if t == a {
|
||||
ask = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !noun || !ask {
|
||||
return FeedQuery{}, false
|
||||
}
|
||||
return FeedQuery{Category: feedCategory(toks)}, true
|
||||
}
|
||||
|
||||
// categoryPreps — the prepositions a topic follows. Russian marks the topic with
|
||||
// a preposition ("по технологиям", "про политику"), so the word after one is the
|
||||
// category; there is no stemming here, and the match against the configured
|
||||
// category is a prefix comparison for exactly that reason.
|
||||
var categoryPreps = map[string]bool{"по": true, "о": true, "об": true, "про": true, "about": true, "on": true}
|
||||
|
||||
func feedCategory(toks []string) string {
|
||||
for i, t := range toks {
|
||||
if categoryPreps[t] && i+1 < len(toks) {
|
||||
next := toks[i+1]
|
||||
// "по новостям" names no topic, it repeats the noun.
|
||||
for _, n := range feedNouns {
|
||||
if next == n {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CategoryMatches reports whether a note's text plausibly belongs to the
|
||||
// category he named. Russian inflects the topic ("технологиям" vs the configured
|
||||
// "технологии"), and there is no stemmer in this repo, so the comparison is on a
|
||||
// common prefix — long enough that "полит" and "погод" stay apart, short enough
|
||||
// to survive a case ending.
|
||||
func CategoryMatches(text, category string) bool {
|
||||
if category == "" {
|
||||
return true
|
||||
}
|
||||
stem := categoryStem(category)
|
||||
if stem == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(text), stem)
|
||||
}
|
||||
|
||||
// categoryStem cuts a word down to the part inflection leaves alone. 5 runes is
|
||||
// the compromise: shorter words are used whole.
|
||||
func categoryStem(word string) string {
|
||||
r := []rune(strings.ToLower(strings.TrimSpace(word)))
|
||||
if len(r) > 5 {
|
||||
r = r[:5]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFeedQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
ok bool
|
||||
category string
|
||||
}{
|
||||
{"что нового в лентах?", true, ""},
|
||||
{"что нового?", true, ""},
|
||||
{"какие новости?", true, ""},
|
||||
{"что нового по технологиям?", true, "технологиям"},
|
||||
{"расскажи новости про политику", true, "политику"},
|
||||
{"что нового по новостям", true, ""},
|
||||
{"what's new in the feeds?", true, ""},
|
||||
{"any news about kubernetes", true, "kubernetes"},
|
||||
// Statements, not requests.
|
||||
{"у меня новая лента в инстаграме", false, ""},
|
||||
{"новости меня утомили", false, ""},
|
||||
{"напомни полить цветы", false, ""},
|
||||
{"", false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
q, ok := ParseFeedQuery(c.text)
|
||||
if ok != c.ok {
|
||||
t.Errorf("ParseFeedQuery(%q) ok = %v, want %v", c.text, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if ok && q.Category != c.category {
|
||||
t.Errorf("ParseFeedQuery(%q) category = %q, want %q", c.text, q.Category, c.category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryMatches(t *testing.T) {
|
||||
// The inflected form he says must match the form the config spells.
|
||||
if !CategoryMatches("Новый релиз [технологии]", "технологиям") {
|
||||
t.Error("inflected category did not match")
|
||||
}
|
||||
if CategoryMatches("Новый релиз [технологии]", "политику") {
|
||||
t.Error("unrelated category matched")
|
||||
}
|
||||
if !CategoryMatches("anything", "") {
|
||||
t.Error("an empty category must match everything")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package router
|
||||
|
||||
import "time"
|
||||
|
||||
// Habit queries — "что я обычно делаю по вторникам?" (Vikunja #254).
|
||||
//
|
||||
// Deterministic matching, like the calendar and plan matchers: the LLM router
|
||||
// classifies the intent, but WHICH weekday was asked about is a lookup, not a
|
||||
// generation. A model that answers "по вторникам" for a question about Thursday
|
||||
// gives a confidently wrong account of the owner's own life.
|
||||
|
||||
// HabitQuery — a parsed "what do I usually do" question. Weekday is set only
|
||||
// when the utterance names one; otherwise the answer covers the whole week.
|
||||
type HabitQuery struct {
|
||||
Weekday time.Weekday
|
||||
HasWeekday bool
|
||||
}
|
||||
|
||||
// habitMarkers — the words that make a question about habit rather than about
|
||||
// today. Without one of these, "что я делаю" is a question about right now, and
|
||||
// the recall path owns it.
|
||||
var habitMarkers = []string{
|
||||
"обычно", "обычное", "чаще", "постоянно", "привычки", "привычка", "привычках",
|
||||
"регулярно", "каждый", "каждую", "каждое", "usually", "habits", "habit",
|
||||
"typically", "normally",
|
||||
}
|
||||
|
||||
// weekdayWords — every form of a weekday name maven needs to recognise,
|
||||
// including the "по …ам" plural the question is usually phrased in.
|
||||
var weekdayWords = map[string]time.Weekday{
|
||||
"понедельник": time.Monday, "понедельникам": time.Monday,
|
||||
"вторник": time.Tuesday, "вторникам": time.Tuesday,
|
||||
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
|
||||
"четверг": time.Thursday, "четвергам": time.Thursday,
|
||||
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
|
||||
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
|
||||
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
|
||||
"monday": time.Monday, "mondays": time.Monday,
|
||||
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
|
||||
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
|
||||
"thursday": time.Thursday, "thursdays": time.Thursday,
|
||||
"friday": time.Friday, "fridays": time.Friday,
|
||||
"saturday": time.Saturday, "saturdays": time.Saturday,
|
||||
"sunday": time.Sunday, "sundays": time.Sunday,
|
||||
}
|
||||
|
||||
// ParseHabitQuery reports whether an utterance asks what the owner usually
|
||||
// does, and on which weekday if it names one.
|
||||
//
|
||||
// A habit marker is required. "что я делаю в среду?" without one is a question
|
||||
// about this coming Wednesday — the calendar's job — and answering it with a
|
||||
// statistical average would be answering a different question.
|
||||
func ParseHabitQuery(text string) (HabitQuery, bool) {
|
||||
toks := planTokens(text)
|
||||
marked := false
|
||||
for _, t := range toks {
|
||||
for _, m := range habitMarkers {
|
||||
if t == m {
|
||||
marked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !marked {
|
||||
return HabitQuery{}, false
|
||||
}
|
||||
for _, t := range toks {
|
||||
if wd, ok := weekdayWords[t]; ok {
|
||||
return HabitQuery{Weekday: wd, HasWeekday: true}, true
|
||||
}
|
||||
}
|
||||
return HabitQuery{}, true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseHabitQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
ok bool
|
||||
wd time.Weekday
|
||||
hasWD bool
|
||||
}{
|
||||
{"что я обычно делаю по вторникам?", true, time.Tuesday, true},
|
||||
{"что я обычно делаю?", true, 0, false},
|
||||
{"какие у меня привычки", true, 0, false},
|
||||
{"что я каждую пятницу делаю", true, time.Friday, true},
|
||||
{"what do i usually do on mondays?", true, time.Monday, true},
|
||||
// No habit marker: this is a question about the coming Wednesday, and
|
||||
// the calendar owns it. Answering with an average answers the wrong
|
||||
// question.
|
||||
{"что я делаю в среду?", false, 0, false},
|
||||
{"что у меня сегодня?", false, 0, false},
|
||||
{"какие планы на сегодня?", false, 0, false},
|
||||
{"", false, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
q, ok := ParseHabitQuery(tt.in)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("ParseHabitQuery(%q) ok = %v, want %v", tt.in, ok, tt.ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if q.HasWeekday != tt.hasWD {
|
||||
t.Errorf("ParseHabitQuery(%q) hasWeekday = %v, want %v", tt.in, q.HasWeekday, tt.hasWD)
|
||||
continue
|
||||
}
|
||||
if q.HasWeekday && q.Weekday != tt.wd {
|
||||
t.Errorf("ParseHabitQuery(%q) weekday = %v, want %v", tt.in, q.Weekday, tt.wd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// Money questions, matched deterministically (Vikunja #125).
|
||||
//
|
||||
// No new intent, for the same reason as tasks: the intent enum is a contract
|
||||
// with the relabelling prompt. "сколько я потратил?" is a query; which figure
|
||||
// it asks for is a lookup, not something to ask a 1.7B — and a model asked to
|
||||
// invent a spending total will happily do it.
|
||||
|
||||
// MoneyWindow — which period a money question asks about.
|
||||
type MoneyWindow int
|
||||
|
||||
const (
|
||||
MoneyNone MoneyWindow = iota
|
||||
MoneyToday
|
||||
MoneyMonth
|
||||
)
|
||||
|
||||
// moneyNouns — the words that make a question be about his money.
|
||||
var moneyNouns = []string{
|
||||
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||
"заработал", "потрачено", "денег", "spend", "spent", "expenses",
|
||||
}
|
||||
|
||||
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||
// and over which window. Defaults to the month: "сколько я потратил?" without a
|
||||
// period is the month-to-date question, which is the one worth answering.
|
||||
//
|
||||
// Narrow on purpose. A money noun alone is not enough — "я потратил весь день
|
||||
// на это" is him talking about his day, so an amount word or an explicit
|
||||
// question word has to be there too.
|
||||
func ParseMoneyQuery(text string) (MoneyWindow, bool) {
|
||||
toks := planTokens(text)
|
||||
if len(toks) == 0 {
|
||||
return MoneyNone, false
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
for _, n := range moneyNouns {
|
||||
if t == n {
|
||||
hasNoun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
return MoneyNone, false
|
||||
}
|
||||
// "весь день", "время", "силы" — spending that is not money.
|
||||
for _, t := range toks {
|
||||
switch t {
|
||||
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||
return MoneyNone, false
|
||||
}
|
||||
}
|
||||
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasTok(toks, "мои") || hasTok(toks, "траты") || hasTok(toks, "расходы")
|
||||
if !asking {
|
||||
return MoneyNone, false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||
return MoneyToday, true
|
||||
case hasTok(toks, "месяц") || hasTok(toks, "месяце") || strings.Contains(lower, "month"):
|
||||
return MoneyMonth, true
|
||||
}
|
||||
return MoneyMonth, true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMoneyQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
window MoneyWindow
|
||||
ok bool
|
||||
}{
|
||||
{"сколько я потратил сегодня?", MoneyToday, true},
|
||||
{"сколько я потратил в этом месяце?", MoneyMonth, true},
|
||||
{"сколько я потратил?", MoneyMonth, true}, // month-to-date by default
|
||||
{"покажи мои траты", MoneyMonth, true},
|
||||
{"какие у меня расходы за месяц", MoneyMonth, true},
|
||||
{"how much did I spend today", MoneyToday, true},
|
||||
{"сколько я заработал в этом месяце", MoneyMonth, true},
|
||||
// Not about money.
|
||||
{"я потратил весь день на это", MoneyNone, false},
|
||||
{"потратил много сил", MoneyNone, false},
|
||||
{"какая погода?", MoneyNone, false},
|
||||
{"я купил молоко", MoneyNone, false},
|
||||
{"", MoneyNone, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w, ok := ParseMoneyQuery(c.in)
|
||||
if ok != c.ok || w != c.window {
|
||||
t.Errorf("ParseMoneyQuery(%q) = (%v, %v), want (%v, %v)", c.in, w, ok, c.window, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user