Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed9bdd5e09 | |||
| 49f089d8a6 |
@@ -5,10 +5,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/weather"
|
||||
)
|
||||
@@ -45,6 +47,10 @@ 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},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
@@ -89,6 +95,46 @@ 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")
|
||||
}
|
||||
|
||||
// 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 +148,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) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,7 @@ func run(args []string) error {
|
||||
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)
|
||||
@@ -449,6 +450,7 @@ func run(args []string) error {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+33
-1
@@ -334,6 +334,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 +385,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)
|
||||
})
|
||||
@@ -947,12 +959,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 {
|
||||
|
||||
+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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,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 +367,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 {
|
||||
|
||||
@@ -71,6 +71,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodListProposedRoutines: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodDayPlan: true,
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
@@ -458,6 +459,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"`
|
||||
|
||||
@@ -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 {
|
||||
@@ -729,6 +733,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)
|
||||
}),
|
||||
|
||||
@@ -113,6 +113,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,6 +45,7 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -6,7 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
)
|
||||
|
||||
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
|
||||
@@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CalendarEvents returns caldav facts whose key date falls within [from, to).
|
||||
// CalendarEvents returns calendar facts whose key date falls within [from, to).
|
||||
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
||||
//
|
||||
// Every calendar source is included, not just the personal CalDAV poll: the work
|
||||
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
|
||||
// the same answer. The source stays on each Fact, along with its confidence, so
|
||||
// the caller can hedge a reading it did not get from a calendar server —
|
||||
// filtering by source here would have thrown that judgement away.
|
||||
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
|
||||
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
|
||||
prefixFrom := calendar.KeyPrefixForDay(from)
|
||||
prefixTo := calendar.KeyPrefixForDay(to)
|
||||
sources := calendar.Sources()
|
||||
args := make([]any, 0, len(sources)+2)
|
||||
for _, src := range sources {
|
||||
args = append(args, src)
|
||||
}
|
||||
args = append(args, prefixFrom, prefixTo)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
||||
FROM facts
|
||||
WHERE source = 'poll:caldav'
|
||||
WHERE source IN (`+placeholders(len(sources))+`)
|
||||
AND key >= ? AND key < ?
|
||||
ORDER BY key`, prefixFrom, prefixTo)
|
||||
ORDER BY key`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calendar events: %w", err)
|
||||
}
|
||||
@@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) {
|
||||
f.VoidsID = voids
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// placeholders renders n comma-separated SQL bind markers.
|
||||
func placeholders(n int) string {
|
||||
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
@@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) {
|
||||
t.Fatalf("expected 0 events on July 8, got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
// The work calendar arrives as relayed phone notifications, not a CalDAV read
|
||||
// (Vikunja #126). Those events belong in the same day's answer, and their
|
||||
// provenance has to survive the query so the caller can hedge them.
|
||||
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
|
||||
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
|
||||
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
|
||||
// A fact that merely looks like one must still be excluded by source.
|
||||
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
|
||||
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
|
||||
|
||||
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("CalendarEvents: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
|
||||
}
|
||||
bySource := map[string]Fact{}
|
||||
for _, e := range events {
|
||||
bySource[e.Source] = e
|
||||
}
|
||||
if _, ok := bySource[calendar.SourcePersonal]; !ok {
|
||||
t.Error("the personal CalDAV event is missing")
|
||||
}
|
||||
amb, ok := bySource[calendar.SourceAmbient]
|
||||
if !ok {
|
||||
t.Fatal("the ambient work event is missing")
|
||||
}
|
||||
if amb.Confidence >= 1.0 {
|
||||
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
|
||||
}
|
||||
if _, ok := bySource["tap:voice"]; ok {
|
||||
t.Error("a non-calendar source must not be read as a calendar event")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user