From cf066bde97946104dea6bc0ebdb8b3911df1af96 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 04:09:47 +0400 Subject: [PATCH] maven: calendar event querying (task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store: CalendarEvents(ctx, from, to) — filters caldav facts by key date prefix. IPC: full wiring through interface, server dispatch, and client. Router: ParseCalendarDate (сегодня/завтра), CalendarEventFormatter (RU reply). Voice: calendar detection before notes RAG in IntentQuery handler. Tests: store integration test, date parser tests, formatter tests. Co-Authored-By: opencode --- cmd/mavend/voice.go | 15 +++++++++++ internal/ipc/api.go | 5 ++++ internal/ipc/client.go | 8 ++++++ internal/ipc/server.go | 26 +++++++++++++++++++ internal/ipc/wire.go | 1 + internal/router/calendar.go | 19 ++++++++++++++ internal/router/calendar_test.go | 26 +++++++++++++++++++ internal/router/slots.go | 14 +++++++++++ internal/router/slots_ru_test.go | 30 +++++++++++++++++++++- internal/store/facts.go | 26 +++++++++++++++++++ internal/store/store_test.go | 43 ++++++++++++++++++++++++++++++++ 11 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 internal/router/calendar.go create mode 100644 internal/router/calendar_test.go diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 1a148f9..6a23416 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -411,6 +411,21 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return "" // replier phrases the "saved" reply case router.IntentQuery: + // Calendar questions: "что у меня сегодня?", "планы на завтра?" + if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { + events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) + if err != nil { + log.Printf("voice: calendar events: %v", err) + return "не получилось проверить календарь." + } + values := make([]string, len(events)) + for i, e := range events { + values[i] = e.Value + } + var f router.CalendarEventFormatter + return f.Format(values, date) + } + vec, err := h.embedder.Embed(ctx, dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) diff --git a/internal/ipc/api.go b/internal/ipc/api.go index b6c0fa0..4de18c1 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -123,6 +123,10 @@ type outcomesReq struct { type nReq struct { N int `json:"n"` } +type calendarEventsReq struct { + From time.Time `json:"from"` + To time.Time `json:"to"` +} type revertReq struct { Key string `json:"key"` } @@ -221,6 +225,7 @@ type CoreAPI interface { ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) RecentFacts(ctx context.Context, n int) ([]Fact, error) + CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) RecentNudges(ctx context.Context, n int) ([]Nudge, error) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) diff --git a/internal/ipc/client.go b/internal/ipc/client.go index ddc4276..26b5c73 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -277,6 +277,14 @@ func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) { return out, nil } +func (c *Client) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { + var out []Fact + if err := c.call(ctx, MethodCalendarEvents, calendarEventsReq{From: from, To: to}, &out); err != nil { + return nil, err + } + return out, nil +} + func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { var out []Nudge if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 06f6761..353372f 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -107,6 +107,18 @@ func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) { return out, nil } +func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { + fs, err := a.s.CalendarEvents(ctx, from, to) + if err != nil { + return nil, mapErr(err) + } + out := make([]Fact, len(fs)) + for i, f := range fs { + out[i] = toFact(f) + } + return out, nil +} + func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { ns, err := a.s.RecentNudges(ctx, n) if err != nil { @@ -520,6 +532,20 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(out), nil + case MethodCalendarEvents: + var p calendarEventsReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + out, err := s.api.CalendarEvents(ctx, p.From, p.To) + if err != nil { + return nil, err + } + if out == nil { + out = []Fact{} + } + return marshalResult(out), nil + case MethodRecentNudges: var p nReq if err := unmarshalParams(req.Params, &p); err != nil { diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index f644e3b..62f87bd 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -24,6 +24,7 @@ const ( MethodResolveNudge Method = "resolve_nudge" MethodRecentOutcomes Method = "recent_outcomes" MethodRecentFacts Method = "recent_facts" + MethodCalendarEvents Method = "calendar_events" MethodRecentNudges Method = "recent_nudges" MethodWriteNote Method = "write_note" MethodQueryNotes Method = "query_notes" diff --git a/internal/router/calendar.go b/internal/router/calendar.go new file mode 100644 index 0000000..72890ea --- /dev/null +++ b/internal/router/calendar.go @@ -0,0 +1,19 @@ +package router + +import ( + "fmt" + "strings" + "time" +) + +// 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 { + dateStr := date.Format("02.01.2006") + if len(events) == 0 { + return fmt.Sprintf("на %s ничего нет.", dateStr) + } + return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; ")) +} diff --git a/internal/router/calendar_test.go b/internal/router/calendar_test.go new file mode 100644 index 0000000..ba5867f --- /dev/null +++ b/internal/router/calendar_test.go @@ -0,0 +1,26 @@ +package router + +import ( + "testing" + "time" +) + +func TestCalendarEventFormatter(t *testing.T) { + f := CalendarEventFormatter{} + date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC) + + // Empty + if got := f.Format(nil, date); got != "на 06.07.2026 ничего нет." { + t.Errorf("empty: got %q", got) + } + + // One event + if got := f.Format([]string{"Standup @ 10:00-10:30"}, date); got != "на 06.07.2026: Standup @ 10:00-10:30" { + t.Errorf("one: got %q", got) + } + + // Multiple events + if got := f.Format([]string{"Standup @ 10:00-10:30", "Lunch @ 12:00-13:00"}, date); got != "на 06.07.2026: Standup @ 10:00-10:30; Lunch @ 12:00-13:00" { + t.Errorf("multiple: got %q", got) + } +} diff --git a/internal/router/slots.go b/internal/router/slots.go index 4537bfe..9e4b45b 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -327,3 +327,17 @@ func parseDurationValue(s string) (string, bool) { } return strconv.Itoa(n) + unit, true } + +// ParseCalendarDate detects RU calendar date words in text and returns the +// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). +// Returns zero time + false if no match. +func ParseCalendarDate(text string, now time.Time) (time.Time, bool) { + lower := strings.ToLower(text) + if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") { + return now.Truncate(24 * time.Hour), true + } + if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") { + return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true + } + return time.Time{}, false +} diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go index dd624ff..4c52d37 100644 --- a/internal/router/slots_ru_test.go +++ b/internal/router/slots_ru_test.go @@ -1,6 +1,9 @@ package router -import "testing" +import ( + "testing" + "time" +) // Russian capture must produce the same keys the loop's care rules read, or a // ru voice test silently writes nothing and the nudges look broken. @@ -28,3 +31,28 @@ func TestDefaultFactParserRU(t *testing.T) { } } } + +func TestParseCalendarDate(t *testing.T) { + now := time.Date(2026, 7, 6, 14, 30, 0, 0, time.UTC) + cases := []struct { + text string + want time.Time + ok bool + }{ + {"что у меня сегодня по плану", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, + {"что завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"планы на сегодня", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, + {"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, + {"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"какая погода", time.Time{}, false}, + {"сколько времени", time.Time{}, false}, + {"", time.Time{}, false}, + } + for _, c := range cases { + got, ok := ParseCalendarDate(c.text, now) + if ok != c.ok || !got.Equal(c.want) { + t.Errorf("ParseCalendarDate(%q) = %v, %v; want %v, %v", c.text, got, ok, c.want, c.ok) + } + } +} diff --git a/internal/store/facts.go b/internal/store/facts.go index 646d398..bf09432 100644 --- a/internal/store/facts.go +++ b/internal/store/facts.go @@ -79,6 +79,32 @@ 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). +// Calendar event keys have the format calendar_event_YYYYMMDD_. +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")) + rows, err := s.db.QueryContext(ctx, ` + SELECT id, ts, kind, key, value, source, confidence, voids_id + FROM facts + WHERE source = 'poll:caldav' + AND key >= ? AND key < ? + ORDER BY key`, prefixFrom, prefixTo) + if err != nil { + return nil, fmt.Errorf("calendar events: %w", err) + } + defer rows.Close() + var out []Fact + for rows.Next() { + f, err := scanFact(rows) + if err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + // LatestFactBySource — provenance-scoped. A rule on `service_down` trusts only // source=poll:healthcheck; a compromised poller can't forge a trigger. Use this // from rules, not LatestFact, whenever the rule's source contract matters. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b1321bd..9e49052 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -334,4 +334,47 @@ func TestPresenceStateSingletonRoundtrip(t *testing.T) { if b, score, _, err := s.LoadPresenceState(ctx); err != nil || b != Present || score != 0.83 { t.Fatalf("after save: want Present/0.83, got %s/%f (%v)", b, score, err) } +} + +func TestCalendarEvents(t *testing.T) { + store := newTestStore(t) + defer store.Close() + + ctx := context.Background() + now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + + // Write facts for different dates + store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Morning-standup", `"Morning standup @ 10:00-10:30"`, "poll:caldav", 1.0, sql.NullInt64{}) + store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260706_Lunch", `"Lunch @ 12:00-13:00"`, "poll:caldav", 1.0, sql.NullInt64{}) + store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260707_Doctor", `"Doctor @ 14:00-15:00"`, "poll:caldav", 1.0, sql.NullInt64{}) + store.WriteFact(ctx, time.Date(2026, 7, 6, 8, 0, 0, 0, time.UTC), KindSelf, "calendar_event_20260705_Yesterday", `"Yesterday thing"`, "poll:caldav", 1.0, sql.NullInt64{}) + + // Query July 6 + events, err := store.CalendarEvents(ctx, now.Truncate(24*time.Hour), now.Truncate(24*time.Hour).Add(24*time.Hour)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("expected 2 events on July 6, got %d", len(events)) + } + + // Query July 7 + t7 := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC) + events, err = store.CalendarEvents(ctx, t7, t7.Add(24*time.Hour)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event on July 7, got %d", len(events)) + } + + // Query date with no events + t8 := time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC) + events, err = store.CalendarEvents(ctx, t8, t8.Add(24*time.Hour)) + if err != nil { + t.Fatalf("CalendarEvents: %v", err) + } + if len(events) != 0 { + t.Fatalf("expected 0 events on July 8, got %d", len(events)) + } } \ No newline at end of file