maven: calendar event querying (task 3)

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 <opencode@anthropic.com>
This commit is contained in:
kami
2026-07-06 04:09:47 +04:00
parent 3b8fb691cb
commit cf066bde97
11 changed files with 212 additions and 1 deletions
+15
View File
@@ -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)
+5
View File
@@ -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)
+8
View File
@@ -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 {
+26
View File
@@ -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 {
+1
View File
@@ -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"
+19
View File
@@ -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, "; "))
}
+26
View File
@@ -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)
}
}
+14
View File
@@ -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
}
+29 -1
View File
@@ -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)
}
}
}
+26
View File
@@ -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_<summary>.
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.
+43
View File
@@ -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))
}
}