Version, authenticate and fully trace ecosystem calls #84
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
@@ -177,9 +178,19 @@ func isRestOfDayQuery(text string) bool {
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
//
|
||||
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
|
||||
// was a plain recent-facts read the window was a row budget over every writer,
|
||||
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
|
||||
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
|
||||
// 2000 rows was under three days of history. A weekday habit needs
|
||||
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
|
||||
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
|
||||
// store with a year of taps in it. Self facts come from voice taps, and he does
|
||||
// not tap seven hundred times a day.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
@@ -190,7 +201,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
@@ -142,17 +143,21 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||
// habitAPI answers only the kind-filtered fact read — the whole input the
|
||||
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
|
||||
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
|
||||
// profile must not read the mixed window, and a caller that does fails here.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
kind string
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
a.kind = kind
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
@@ -225,3 +230,23 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
|
||||
// so it must be spent on the rows the profile can use. Reading the mixed table
|
||||
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
|
||||
// every tap out of the window, and she then reported no habits on a store that
|
||||
// held them.
|
||||
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
if _, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
}); !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if api.kind != string(store.KindSelf) {
|
||||
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +439,10 @@ type outcomesReq struct {
|
||||
type nReq struct {
|
||||
N int `json:"n"`
|
||||
}
|
||||
type kindNReq struct {
|
||||
Kind string `json:"kind"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
type calendarEventsReq struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
@@ -586,6 +590,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)
|
||||
RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error)
|
||||
CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
|
||||
@@ -62,6 +62,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodListReminders: true,
|
||||
MethodRecentOutcomes: true,
|
||||
MethodRecentFacts: true,
|
||||
MethodRecentActiveFacts: true,
|
||||
MethodCalendarEvents: true,
|
||||
MethodRecentNudges: true,
|
||||
MethodQueryNotes: true,
|
||||
@@ -334,6 +335,14 @@ func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
var out []Fact
|
||||
if err := c.call(ctx, MethodRecentActiveFacts, kindNReq{Kind: kind, N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -120,6 +120,18 @@ func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentActiveFactsByKind(ctx, store.FactKind(kind), n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
fs, err := a.s.CalendarEvents(ctx, from, to)
|
||||
if err != nil {
|
||||
@@ -764,6 +776,16 @@ var methodTable = map[Method]handlerFunc{
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentActiveFacts: withParams(func(ctx context.Context, api CoreAPI, p kindNReq) ([]Fact, error) {
|
||||
out, err := api.RecentActiveFactsByKind(ctx, p.Kind, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
|
||||
out, err := api.CalendarEvents(ctx, p.From, p.To)
|
||||
if err != nil {
|
||||
|
||||
@@ -62,6 +62,9 @@ func (UnimplementedCoreAPI) RecentOutcomes(ctx context.Context, rule string, n i
|
||||
func (UnimplementedCoreAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
MethodResolveNudge Method = "resolve_nudge"
|
||||
MethodRecentOutcomes Method = "recent_outcomes"
|
||||
MethodRecentFacts Method = "recent_facts"
|
||||
MethodRecentActiveFacts Method = "recent_active_facts_by_kind"
|
||||
MethodCalendarEvents Method = "calendar_events"
|
||||
MethodRecentNudges Method = "recent_nudges"
|
||||
MethodWriteNote Method = "write_note"
|
||||
|
||||
@@ -82,6 +82,45 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecentActiveFactsByKind — the newest n facts of one kind, newest first, with
|
||||
// the retracted ones left out. "Active" means two exclusions: a row some later
|
||||
// row voids, and the void marker VoidLatestFact writes to retract it. A
|
||||
// correction still counts, because a correction is a value he stands behind.
|
||||
//
|
||||
// It exists because the facts table is shared and the noisy writers are not the
|
||||
// interesting ones. A caller that reads n recent rows and then keeps the self
|
||||
// ones has a window whose real length is set by how often the pollers write:
|
||||
// one WireGuard peer alone rehandshakes every couple of minutes, which is
|
||||
// enough env rows to reduce a 2000-row window to under three days. Filtering in
|
||||
// SQL makes the bound mean what the caller thinks it means.
|
||||
//
|
||||
// Voided rows are excluded here and included by RecentFacts on purpose. The
|
||||
// dash shows the audit trail, because you want to SEE a correction. A reader
|
||||
// that asks what he usually does must not count something he explicitly took
|
||||
// back.
|
||||
func (s *Store) RecentActiveFactsByKind(ctx context.Context, kind FactKind, n int) ([]Fact, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
||||
FROM facts
|
||||
WHERE kind = ?
|
||||
AND NOT (voids_id IS NOT NULL AND value = '"voided"')
|
||||
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
||||
ORDER BY ts DESC, id DESC LIMIT ?`, string(kind), n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent facts by kind: %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()
|
||||
}
|
||||
|
||||
// CalendarEvents returns calendar facts whose key date falls within [from, to).
|
||||
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
||||
//
|
||||
|
||||
@@ -424,3 +424,49 @@ func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
|
||||
t.Error("a non-calendar source must not be read as a calendar event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentActiveFactsByKind — the behaviour profile's window. Env rows must
|
||||
// not spend it, and a retracted row must not be counted as something he did.
|
||||
func TestRecentActiveFactsByKind(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
// A noisy env writer, the way mavpoll writes handshakes.
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, err := s.WriteFact(ctx, now.Add(-time.Duration(i)*time.Minute), KindEnv,
|
||||
"wg_handshake", "1", "poll:wireguard", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("write env fact: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := s.WriteFact(ctx, now.Add(-2*time.Hour), KindSelf, "workout", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("write self fact: %v", err)
|
||||
}
|
||||
if _, err := s.WriteFact(ctx, now.Add(-3*time.Hour), KindSelf, "walk", "done", "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("write self fact: %v", err)
|
||||
}
|
||||
if _, _, err := s.VoidLatestFact(ctx, "walk", "tap:voice", now.Add(-time.Hour)); err != nil {
|
||||
t.Fatalf("VoidLatestFact: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.RecentActiveFactsByKind(ctx, KindSelf, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentActiveFactsByKind: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d facts, want 1 (workout): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Key != "workout" {
|
||||
t.Errorf("key = %q, want workout", got[0].Key)
|
||||
}
|
||||
|
||||
// The window is spent on self rows only: a limit smaller than the env
|
||||
// traffic still returns the taps.
|
||||
got, err = s.RecentActiveFactsByKind(ctx, KindSelf, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentActiveFactsByKind: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Key != "workout" {
|
||||
t.Fatalf("got %+v, want the newest self fact", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user