Merge branch 'fix/g03' into fix/integrated
This commit is contained in:
@@ -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
|
||||
@@ -203,6 +214,9 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
if q.Weekend {
|
||||
return profile.FormatWeekendRU(), true
|
||||
}
|
||||
return profile.FormatOverallRU(), 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,40 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
|
||||
// matchers: a habit question carrying "планы" used to be answered by the day
|
||||
// plan with today's calendar, because day-plan sits above habits.
|
||||
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня обычно планы по вторникам?",
|
||||
})
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ import (
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// memoryEvalTimeout — the per-request deadline on one evaluation.
|
||||
//
|
||||
// It used to be five minutes, on the grounds that nobody waits for the answer.
|
||||
// Nobody waits for the evaluation, but there is ONE resident model behind one
|
||||
// llama-server, so a voice turn that arrives mid-evaluation waits behind it:
|
||||
// five minutes of evaluation is five minutes of a mute assistant. Sixty seconds
|
||||
// is long enough for a Thinking model on this prompt and short enough that the
|
||||
// worst collision is one turn answered late rather than a turn abandoned. An
|
||||
// evaluation cut off here costs nothing: it is retried at the next interval.
|
||||
const memoryEvalTimeout = 60 * time.Second
|
||||
|
||||
// memoryEvalWorker — ticker + evaluator.
|
||||
type memoryEvalWorker struct {
|
||||
eval *memeval.Evaluator
|
||||
@@ -47,9 +58,7 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
|
||||
if interval <= 0 {
|
||||
interval = config.DefaultMemoryEvalInterval
|
||||
}
|
||||
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||
// and nobody is waiting on the answer.
|
||||
client := llmClientFor(lp, 5*time.Minute)
|
||||
client := llmClientFor(lp, memoryEvalTimeout)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+43
-28
@@ -85,7 +85,13 @@ type Completer interface {
|
||||
// no config facts.
|
||||
type Reader interface {
|
||||
RecentFacts(ctx context.Context, n int) ([]store.Fact, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]store.Note, error)
|
||||
// RecentNotesExcludingSource feeds the snapshot, RecentNotesBySource feeds
|
||||
// the dedupe. Both are source-scoped in SQL rather than filtered here: a
|
||||
// plain RecentNotes read makes each window a budget over ALL note writers,
|
||||
// so her own hourly observations shrink the snapshot and ordinary notes
|
||||
// push old observations out of the dedupe set.
|
||||
RecentNotesExcludingSource(ctx context.Context, source string, n int) ([]store.Note, error)
|
||||
RecentNotesBySource(ctx context.Context, source string, n int) ([]store.Note, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]store.Nudge, error)
|
||||
}
|
||||
|
||||
@@ -226,29 +232,44 @@ func formatNote(o Observation) string {
|
||||
return fmt.Sprintf("%s [%s]", o.Text, o.Action)
|
||||
}
|
||||
|
||||
// DedupeWindow — how many of her OWN past observations the dedupe looks back
|
||||
// over. Wider than MaxItems because the point is to remember saying it, not to
|
||||
// summarize it. Counted in eval notes only: when this was a plain recent-notes
|
||||
// read the window was really a budget over every note writer, so a few hundred
|
||||
// ordinary notes pushed an observation out of sight and the next evaluation was
|
||||
// free to write the same sentence again.
|
||||
const DedupeWindow = 200
|
||||
|
||||
// recordedTexts — the normalized text of every observation earlier evaluations
|
||||
// wrote, for dedupe. Reads a wider window than MaxItems because the point is to
|
||||
// remember saying it, not to summarize it.
|
||||
// wrote, for dedupe.
|
||||
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
|
||||
notes, err := e.read.RecentNotes(ctx, 200)
|
||||
notes, err := e.read.RecentNotesBySource(ctx, EvalNoteSource, DedupeWindow)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.Source != EvalNoteSource {
|
||||
continue
|
||||
}
|
||||
text := n.Text
|
||||
// Strip the "[action]" suffix formatNote appended.
|
||||
if i := strings.LastIndex(text, " ["); i > 0 && strings.HasSuffix(text, "]") {
|
||||
text = text[:i]
|
||||
}
|
||||
seen[normalizeObservation(text)] = true
|
||||
seen[normalizeObservation(stripAction(n.Text))] = true
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
// actions — the suggested_action enum, as the grammar constrains it.
|
||||
var actions = []string{"note", "propose", "notify"}
|
||||
|
||||
// stripAction removes the "[action]" suffix formatNote appended, and only that.
|
||||
// Matching any bracketed tail would eat the end of an observation that happens
|
||||
// to finish on a bracketed clause, which changes its dedupe key and lets the
|
||||
// same sentence through twice.
|
||||
func stripAction(text string) string {
|
||||
for _, a := range actions {
|
||||
if s, ok := strings.CutSuffix(text, " ["+a+"]"); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// normalizeObservation — dedupe key. Case- and whitespace-insensitive, which
|
||||
// catches the realistic repeat (the model re-emitting the same sentence with a
|
||||
// different comma) without pretending to do semantic dedupe.
|
||||
@@ -259,16 +280,19 @@ func normalizeObservation(s string) string {
|
||||
// snapshot renders recent memory as the user turn. Returns "" when there is
|
||||
// nothing in any store — the caller treats that as "do not ask the model".
|
||||
//
|
||||
// Notes written by earlier evaluations are excluded. Feeding her own
|
||||
// Notes written by earlier evaluations are excluded, in SQL. Feeding her own
|
||||
// observations back in is how "я заметила, что ты не записывал еду" becomes
|
||||
// evidence for noticing it again, three evaluations deep.
|
||||
// evidence for noticing it again, three evaluations deep. Excluding them after
|
||||
// the read was worse than not excluding them: hourly evaluation makes her own
|
||||
// notes the majority of the newest rows within weeks, so asking for MaxItems
|
||||
// and dropping hers left the model a handful of real notes to look at.
|
||||
func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
|
||||
n := e.cfg.MaxItems
|
||||
facts, err := e.read.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory eval: recent facts: %w", err)
|
||||
}
|
||||
notes, err := e.read.RecentNotes(ctx, n)
|
||||
notes, err := e.read.RecentNotesExcludingSource(ctx, EvalNoteSource, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory eval: recent notes: %w", err)
|
||||
}
|
||||
@@ -286,19 +310,10 @@ func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
own := 0
|
||||
var noteLines []string
|
||||
for _, nt := range notes {
|
||||
if nt.Source == EvalNoteSource {
|
||||
own++
|
||||
continue
|
||||
}
|
||||
noteLines = append(noteLines, fmt.Sprintf("- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160)))
|
||||
}
|
||||
if len(noteLines) > 0 {
|
||||
if len(notes) > 0 {
|
||||
b.WriteString("\nЗаметки:\n")
|
||||
for _, l := range noteLines {
|
||||
b.WriteString(l)
|
||||
for _, nt := range notes {
|
||||
fmt.Fprintf(&b, "- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160))
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -269,3 +270,95 @@ func TestParseObservationsTolerantAndBounded(t *testing.T) {
|
||||
t.Fatalf("parsed %d observations, want the %d cap", len(obs), MaxObservations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeSurvivesOrdinaryNotes — the dedupe window is a count of HER notes,
|
||||
// not of all notes. With a plain recent-notes read, DedupeWindow ordinary notes
|
||||
// written after an observation pushed it out of sight and the same sentence was
|
||||
// written again on the next evaluation.
|
||||
func TestDedupeSurvivesOrdinaryNotes(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedMemory(t, st, ctx, now)
|
||||
|
||||
same := `[{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"note"}]`
|
||||
f := &fakeLLM{replies: []string{same, same}}
|
||||
ev := NewEvaluator(st, st, f, Config{})
|
||||
|
||||
if _, err := ev.Evaluate(ctx, now); err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
// A few months of ordinary use between the two evaluations.
|
||||
for i := 0; i < DedupeWindow*2; i++ {
|
||||
if _, err := st.WriteNote(ctx, now.Add(time.Duration(i+1)*time.Minute),
|
||||
fmt.Sprintf("обычная заметка %d", i), nil, "tap:voice"); err != nil {
|
||||
t.Fatalf("write note: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := ev.Evaluate(ctx, now.Add(24*time.Hour)); err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
|
||||
own, err := st.RecentNotesBySource(ctx, EvalNoteSource, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotesBySource: %v", err)
|
||||
}
|
||||
if len(own) != 1 {
|
||||
t.Fatalf("eval notes = %d, want 1 — the repeat was not deduped", len(own))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotBudgetIsNotEatenByOwnNotes — MaxItems notes must be MaxItems of
|
||||
// HIS notes. Reading MaxItems rows and then discarding hers left the model a
|
||||
// handful of real notes once hourly evaluation had run for a few weeks.
|
||||
func TestSnapshotBudgetIsNotEatenByOwnNotes(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
// His notes first, then a long run of hers on top of them.
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := st.WriteNote(ctx, now.Add(-time.Duration(100-i)*time.Hour),
|
||||
fmt.Sprintf("его заметка %d", i), nil, "tap:voice"); err != nil {
|
||||
t.Fatalf("write note: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
if _, err := st.WriteNote(ctx, now.Add(-time.Duration(50-i)*time.Hour),
|
||||
fmt.Sprintf("я заметила кое-что %d [note]", i), nil, EvalNoteSource); err != nil {
|
||||
t.Fatalf("write note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
f := &fakeLLM{replies: []string{"[]"}}
|
||||
ev := NewEvaluator(st, st, f, Config{MaxItems: 5})
|
||||
if _, err := ev.Evaluate(ctx, now); err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if len(f.calls) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(f.calls))
|
||||
}
|
||||
prompt := f.calls[0].User
|
||||
if strings.Contains(prompt, "я заметила") {
|
||||
t.Errorf("her own observations reached the prompt:\n%s", prompt)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if !strings.Contains(prompt, fmt.Sprintf("его заметка %d", i)) {
|
||||
t.Errorf("his note %d missing from the prompt:\n%s", i, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripActionKeepsBracketedTail — the dedupe key strips the recorded
|
||||
// action and nothing else. Cutting at the last " [" ate the end of an
|
||||
// observation that itself ends on a bracketed clause, so the same sentence
|
||||
// hashed two ways.
|
||||
func TestStripActionKeepsBracketedTail(t *testing.T) {
|
||||
text := "ты не пил воду [со вторника]"
|
||||
if got := stripAction(formatNote(Observation{Text: text, Action: "note"})); got != text {
|
||||
t.Errorf("stripAction = %q, want %q", got, text)
|
||||
}
|
||||
if got := stripAction(text); got != text {
|
||||
t.Errorf("stripAction = %q, want it untouched", got)
|
||||
}
|
||||
}
|
||||
|
||||
+166
-15
@@ -45,12 +45,19 @@ type Observation struct {
|
||||
//
|
||||
// TypicalAt is the median time of day it happens at, rounded to the minute — a
|
||||
// median and not a mean, so one 03:00 outlier does not move "он обычно пьёт
|
||||
// воду утром" into the night.
|
||||
// воду утром" into the night. It is a CIRCULAR median: the clock wraps, and a
|
||||
// plain median of minutes-since-midnight reports 12:00 for a man who goes to
|
||||
// bed at 23:50.
|
||||
//
|
||||
// HasTypical is false when the times are spread too widely for any of them to
|
||||
// be typical (see maxTypicalSpread). She then names the habit without a time
|
||||
// instead of naming a time she cannot support.
|
||||
type Activity struct {
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
HasTypical bool
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model.
|
||||
@@ -82,13 +89,20 @@ const MinHabitDays = 2
|
||||
// nonBehaviouralKeyPrefixes — keys that are machinery or one-shot records, not
|
||||
// behaviour. Calendar events carry the day in the key so they can never repeat;
|
||||
// cooldown and quiet rows are maven's own tuning state, not his habits.
|
||||
// quiet is listed with its separators rather than bare: as a five-letter
|
||||
// prefix it would also swallow any future self-fact key that merely starts
|
||||
// with those letters.
|
||||
var nonBehaviouralKeyPrefixes = []string{
|
||||
"calendar_event_",
|
||||
"cooldown:",
|
||||
"quiet",
|
||||
"quiet_",
|
||||
"quiet:",
|
||||
"behavior_profile",
|
||||
}
|
||||
|
||||
// nonBehaviouralKeys — exact keys, for the ones with no separator to anchor on.
|
||||
var nonBehaviouralKeys = map[string]bool{"quiet": true}
|
||||
|
||||
// BuildProfile counts habits out of observations. now bounds the window's upper
|
||||
// end and supplies the location every day boundary is taken in — a habit is
|
||||
// "on Tuesdays" in the owner's timezone or it is nothing.
|
||||
@@ -113,7 +127,7 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
if o.Kind != "self" {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(o.Key)
|
||||
key := canonicalize(o.Key)
|
||||
if key == "" || nonBehavioural(key) {
|
||||
continue
|
||||
}
|
||||
@@ -151,11 +165,13 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
if len(b.days) < MinHabitDays {
|
||||
continue
|
||||
}
|
||||
mid, known := circularMedianMinutes(b.mins)
|
||||
out = append(out, Activity{
|
||||
Key: key,
|
||||
Days: len(b.days),
|
||||
Count: b.count,
|
||||
TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute,
|
||||
Key: key,
|
||||
Days: len(b.days),
|
||||
Count: b.count,
|
||||
TypicalAt: time.Duration(mid) * time.Minute,
|
||||
HasTypical: known,
|
||||
})
|
||||
}
|
||||
// Most-established first, then earliest in the day, then by key so the
|
||||
@@ -208,7 +224,24 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
return p
|
||||
}
|
||||
|
||||
// canonicalize maps a fact key onto the key the profile counts it under.
|
||||
// Lowercased, trimmed, and separators folded to "_" before the alias lookup,
|
||||
// so "Выпил воды", "выпил-воды" and "выпил_воды" are one habit and not three.
|
||||
// An unlisted key counts as itself.
|
||||
func canonicalize(key string) string {
|
||||
k := strings.ToLower(strings.TrimSpace(key))
|
||||
k = strings.NewReplacer(" ", "_", "-", "_").Replace(k)
|
||||
k = strings.Trim(k, "_")
|
||||
if c, ok := canonicalKey[k]; ok {
|
||||
return c
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func nonBehavioural(key string) bool {
|
||||
if nonBehaviouralKeys[key] {
|
||||
return true
|
||||
}
|
||||
for _, p := range nonBehaviouralKeyPrefixes {
|
||||
if strings.HasPrefix(key, p) {
|
||||
return true
|
||||
@@ -217,6 +250,63 @@ func nonBehavioural(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// minutesPerDay — the modulus every clock time is taken in.
|
||||
const minutesPerDay = 24 * 60
|
||||
|
||||
// maxTypicalSpread — how far apart the observations of one activity may sit,
|
||||
// once rotated onto the shortest arc, before "usually at X" stops being a
|
||||
// claim about anything. Half a day: wider than that and the values cover the
|
||||
// clock, so no point on it is typical.
|
||||
const maxTypicalSpread = minutesPerDay / 2
|
||||
|
||||
// circularMedianMinutes — the median time of day, on a clock rather than on a
|
||||
// number line. Reports (0, false) when the values are too spread out to have a
|
||||
// middle.
|
||||
//
|
||||
// A plain median of minutes-since-midnight is wrong for anything that straddles
|
||||
// midnight, which is exactly the activity most likely to: bedtimes of 23:40,
|
||||
// 23:50, 00:10 and 00:20 average out to 720 minutes, and she says "обычно ты
|
||||
// спишь около 12:00". The fix is to find the rotation of the sorted values with
|
||||
// the shortest span — the arc the observations actually occupy — take the
|
||||
// ordinary median inside it, and wrap the answer back into the day.
|
||||
func circularMedianMinutes(xs []int) (int, bool) {
|
||||
if len(xs) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
s := make([]int, len(xs))
|
||||
for i, x := range xs {
|
||||
s[i] = ((x % minutesPerDay) + minutesPerDay) % minutesPerDay
|
||||
}
|
||||
sort.Ints(s)
|
||||
|
||||
// Each rotation cuts the day at one observation and unwraps the values
|
||||
// before the cut onto the following day. The cut with the smallest span is
|
||||
// the one where no observation is on the far side of midnight from the rest.
|
||||
best, bestSpan := 0, minutesPerDay+1
|
||||
for i := range s {
|
||||
span := s[(i+len(s)-1)%len(s)] - s[i]
|
||||
if i > 0 {
|
||||
span += minutesPerDay
|
||||
}
|
||||
if span < bestSpan {
|
||||
best, bestSpan = i, span
|
||||
}
|
||||
}
|
||||
if bestSpan > maxTypicalSpread {
|
||||
return 0, false
|
||||
}
|
||||
rot := make([]int, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
v := s[(best+i)%len(s)]
|
||||
if best+i >= len(s) {
|
||||
v += minutesPerDay
|
||||
}
|
||||
rot = append(rot, v)
|
||||
}
|
||||
m := medianInt(rot) % minutesPerDay
|
||||
return m, true
|
||||
}
|
||||
|
||||
// medianInt — the middle value, averaging the two middles on an even count.
|
||||
func medianInt(xs []int) int {
|
||||
if len(xs) == 0 {
|
||||
@@ -253,15 +343,68 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
}
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
||||
//
|
||||
// The two days are answered separately rather than pooled: "по выходным" is a
|
||||
// question about both, and a habit he has on Saturdays only is the interesting
|
||||
// half of the answer, not noise to average away.
|
||||
func (p Profile) FormatWeekendRU() string {
|
||||
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||
switch {
|
||||
case len(sat) > 0 && len(sun) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
||||
joinActivities(sat), joinActivities(sun))
|
||||
case len(sat) > 0:
|
||||
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
||||
joinActivities(sat))
|
||||
case len(sun) > 0:
|
||||
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
||||
joinActivities(sun))
|
||||
case len(p.Everyday) > 0:
|
||||
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
joinActivities(p.Everyday))
|
||||
}
|
||||
return "по выходным я пока не вижу у тебя ничего постоянного."
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
||||
// says over what stretch of records it is claiming them.
|
||||
//
|
||||
// The period is spoken because "обычно" without one is an unfalsifiable claim
|
||||
// about his life: the same sentence comes out of three days of taps and out of
|
||||
// a year of them, and only one of those is worth believing.
|
||||
func (p Profile) FormatOverallRU() string {
|
||||
if len(p.All) == 0 {
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
|
||||
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
|
||||
}
|
||||
|
||||
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
|
||||
// is too short to name in days.
|
||||
func (p Profile) spanRU() string {
|
||||
if p.Since.IsZero() || !p.Until.After(p.Since) {
|
||||
return "по записям за сегодня"
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
||||
}
|
||||
|
||||
// pluralDaysRU — the Russian count form of "день" for n.
|
||||
func pluralDaysRU(n int) string {
|
||||
switch {
|
||||
case n%100 >= 11 && n%100 <= 14:
|
||||
return "дней"
|
||||
case n%10 == 1:
|
||||
return "день"
|
||||
case n%10 >= 2 && n%10 <= 4:
|
||||
return "дня"
|
||||
default:
|
||||
return "дней"
|
||||
}
|
||||
}
|
||||
|
||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||
@@ -276,7 +419,15 @@ func joinActivities(acts []Activity) string {
|
||||
for i, a := range acts {
|
||||
gloss, ok := activityRU[a.Key]
|
||||
if !ok {
|
||||
gloss = a.Key
|
||||
// No gloss: quote the key instead of reading it as a verb. The keys
|
||||
// come from the model, so an unglossed one is as likely to be
|
||||
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
|
||||
// not a sentence.
|
||||
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||
}
|
||||
if !a.HasTypical {
|
||||
parts[i] = gloss
|
||||
continue
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
|
||||
@@ -22,15 +22,18 @@ import (
|
||||
var behaviorRUJSON []byte
|
||||
|
||||
type behaviorRU struct {
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
KeyAliases map[string][]string `json:"key_aliases"`
|
||||
}
|
||||
|
||||
var (
|
||||
// weekdayRU — dative plural, indexed by time.Weekday.
|
||||
weekdayRU []string
|
||||
// activityRU — fact key to second-person-singular verb phrase.
|
||||
// activityRU — canonical fact key to second-person-singular verb phrase.
|
||||
activityRU map[string]string
|
||||
// canonicalKey — observed fact key to the canonical key it counts as.
|
||||
canonicalKey map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,4 +45,11 @@ func init() {
|
||||
panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays)))
|
||||
}
|
||||
weekdayRU, activityRU = v.Weekdays, v.Activities
|
||||
canonicalKey = make(map[string]string)
|
||||
for canonical, variants := range v.KeyAliases {
|
||||
canonicalKey[canonical] = canonical
|
||||
for _, variant := range variants {
|
||||
canonicalKey[variant] = canonical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@
|
||||
"plural, because both 'по вторникам' and 'по средам' need that form.",
|
||||
"",
|
||||
"activities glosses a fact key as a second-person-singular verb phrase. An",
|
||||
"unknown key is read back verbatim rather than guessed at: inventing a",
|
||||
"Russian phrase for a key maven does not recognise puts words in his mouth."
|
||||
"unknown key is not glossed and is quoted rather than guessed at: inventing",
|
||||
"a Russian phrase for a key maven does not recognise puts words in his",
|
||||
"mouth, and reading the raw key inline produces 'обычно ты выпил_воды'.",
|
||||
"",
|
||||
"key_aliases maps the fact keys the router actually emits onto the canonical",
|
||||
"key the profile counts. The key of a self fact comes straight out of the",
|
||||
"LLM with no allowlist behind it, so 'я выпил воду' and 'попил воды' arrive",
|
||||
"as different keys, split one habit into two, and drop both below the",
|
||||
"day threshold. Additive and lowercase; an unlisted key counts as itself."
|
||||
],
|
||||
"weekdays": [
|
||||
"воскресеньям",
|
||||
@@ -30,5 +37,15 @@
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься"
|
||||
},
|
||||
"key_aliases": {
|
||||
"water": ["вода", "воду", "воды", "водичка", "попил", "попил_воды", "выпил_воды", "выпил_воду", "drink_water", "drank_water"],
|
||||
"meal": ["еда", "еду", "поел", "поесть", "завтрак", "обед", "ужин", "food", "ate", "breakfast", "lunch", "dinner"],
|
||||
"sleep": ["сон", "спать", "лег", "лёг", "уснул", "заснул", "bedtime", "slept"],
|
||||
"break": ["перерыв", "отдых", "пауза", "rest", "pause"],
|
||||
"shower": ["душ", "принял_душ", "помылся", "мытьё", "мылся"],
|
||||
"walk": ["прогулка", "гулял", "погулял", "выгулял", "walked", "walking"],
|
||||
"pills": ["витамины", "таблетки", "таблетка", "лекарство", "vitamins", "meds", "medicine"],
|
||||
"workout": ["тренировка", "тренировался", "потренировался", "зал", "спорт", "exercise", "gym", "training"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,3 +207,163 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypicalTimeIsCircular — the median is over a clock, not a number line.
|
||||
// Bedtimes either side of midnight used to average to midday, which is the
|
||||
// exact error the median was chosen to avoid, on the one activity most likely
|
||||
// to cross the boundary.
|
||||
func TestTypicalTimeIsCircular(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, mins := range []int{23*60 + 40, 23*60 + 50, 10, 20} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 0, mins, 0, 0, now.Location()),
|
||||
Key: "sleep",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
got := p.All[0].TypicalAt
|
||||
if !p.All[0].HasTypical {
|
||||
t.Fatal("a four-observation cluster has a typical time")
|
||||
}
|
||||
if got < 23*time.Hour+55*time.Minute && got > 5*time.Minute {
|
||||
t.Errorf("typical bedtime = %v, want just either side of midnight", got)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около 12:00") {
|
||||
t.Errorf("read back as %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Times spread across the whole clock have no typical value, and she must not
|
||||
// name one.
|
||||
func TestNoTypicalTimeWhenSpreadWide(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, hh := range []int{2, 9, 16, 21} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Key: "water",
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity", p.All)
|
||||
}
|
||||
if p.All[0].HasTypical {
|
||||
t.Errorf("times spanning %v were given a typical value", p.All[0].TypicalAt)
|
||||
}
|
||||
if s := p.FormatOverallRU(); strings.Contains(s, "около") {
|
||||
t.Errorf("read back with a time she cannot support: %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeysAreCanonicalisedBeforeCounting — the fact key comes out of the LLM
|
||||
// with no allowlist behind it, so the same habit arrives spelled several ways.
|
||||
// Counted separately, each spelling sits below MinHabitDays and the habit
|
||||
// vanishes.
|
||||
func TestKeysAreCanonicalisedBeforeCounting(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for i, key := range []string{"water", "Воду", "выпил воды", "попил_воды"} {
|
||||
day := now.AddDate(0, 0, -(i + 1))
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, now.Location()),
|
||||
Key: key,
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want one activity — the spellings are one habit", p.All)
|
||||
}
|
||||
if p.All[0].Key != "water" || p.All[0].Days != 4 {
|
||||
t.Errorf("got %+v, want water on 4 days", p.All[0])
|
||||
}
|
||||
if s := p.FormatOverallRU(); !strings.Contains(s, "пьёшь воду") {
|
||||
t.Errorf("read back as %q, want the glossed canonical key", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unglossed key is quoted, not read as a verb. "обычно ты выпил_воды около
|
||||
// 09:00" is what reciting the raw key produced.
|
||||
func TestUnglossedKeyIsQuoted(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("починил_кран", time.Tuesday, 12, 0, 2, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "отмечаешь «починил кран»") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// She says over what stretch of records "обычно" is claimed. Without it the
|
||||
// same sentence comes out of three days and out of a year.
|
||||
func TestOverallNamesThePeriod(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("water", time.Tuesday, 9, 0, 3, now), now)
|
||||
got := p.FormatOverallRU()
|
||||
if !strings.Contains(got, "по записям за последние 21 день") {
|
||||
t.Errorf("got %q, want the period spoken", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||
p := BuildProfile(nil, behaviorNow())
|
||||
got := p.FormatWeekdayRU(time.Wednesday)
|
||||
if strings.Contains(got, "у меня") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "у тебя") {
|
||||
t.Errorf("got %q, want an answer about him", got)
|
||||
}
|
||||
}
|
||||
|
||||
// "quiet" is machinery, but only as a whole key. As a bare five-letter prefix
|
||||
// it silently swallowed any future self-fact key starting with those letters.
|
||||
func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("quietude", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v, want the key counted", p.All)
|
||||
}
|
||||
p = BuildProfile(habitHistory("quiet_hours", time.Tuesday, 8, 0, 3, now), now)
|
||||
if len(p.All) != 0 {
|
||||
t.Fatalf("got %+v, want maven's own tuning state dropped", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralDaysRU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := pluralDaysRU(c.n); got != c.want {
|
||||
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "по выходным" is a question about two days, answered as two days.
|
||||
func TestFormatWeekendRU(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := append(
|
||||
habitHistory("workout", time.Saturday, 11, 0, 3, now),
|
||||
habitHistory("walk", time.Sunday, 15, 0, 3, now)...,
|
||||
)
|
||||
got := BuildProfile(obs, now).FormatWeekendRU()
|
||||
if !strings.Contains(got, "по субботам") || !strings.Contains(got, "по воскресеньям") {
|
||||
t.Errorf("got %q, want both weekend days named", got)
|
||||
}
|
||||
empty := BuildProfile(nil, now).FormatWeekendRU()
|
||||
if strings.Contains(empty, "у меня") {
|
||||
t.Errorf("got %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ var otherDayWords = []string{
|
||||
// сегодня?" 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 {
|
||||
// A habit question is never a day plan, whatever words it shares with one.
|
||||
// "какие у меня обычно планы по вторникам?" carries "планы", so the plan
|
||||
// source claimed it and answered today's calendar stamped with today's
|
||||
// date, and the habit source never ran. Deciding it here rather than by
|
||||
// reordering the source table keeps one matcher from depending on the
|
||||
// other's position in a slice.
|
||||
if _, ok := ParseHabitQuery(text); ok {
|
||||
return false
|
||||
}
|
||||
toks := planTokens(text)
|
||||
for _, t := range toks {
|
||||
for _, w := range otherDayWords {
|
||||
|
||||
@@ -11,9 +11,11 @@ import "time"
|
||||
|
||||
// HabitQuery — a parsed "what do I usually do" question. Weekday is set only
|
||||
// when the utterance names one; otherwise the answer covers the whole week.
|
||||
// Weekend is set for "по выходным", which names two days rather than one.
|
||||
type HabitQuery struct {
|
||||
Weekday time.Weekday
|
||||
HasWeekday bool
|
||||
Weekend bool
|
||||
}
|
||||
|
||||
// habitMarkers — the words that make a question about habit rather than about
|
||||
@@ -35,6 +37,8 @@ var weekdayWords = map[string]time.Weekday{
|
||||
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
|
||||
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
|
||||
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
|
||||
"воскресенья": time.Sunday, "воскресенью": time.Sunday,
|
||||
"воскресеньем": time.Sunday, "воскресеньях": time.Sunday,
|
||||
"monday": time.Monday, "mondays": time.Monday,
|
||||
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
|
||||
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
|
||||
@@ -44,6 +48,14 @@ var weekdayWords = map[string]time.Weekday{
|
||||
"sunday": time.Sunday, "sundays": time.Sunday,
|
||||
}
|
||||
|
||||
// weekendWords — the weekend as one unit. "что я обычно делаю по выходным?"
|
||||
// has a habit marker and names days, but no weekday name is in it, so it used
|
||||
// to fall through to the whole-week profile and answer about Tuesdays too.
|
||||
var weekendWords = map[string]bool{
|
||||
"выходным": true, "выходные": true, "выходных": true, "выходной": true,
|
||||
"weekend": true, "weekends": true,
|
||||
}
|
||||
|
||||
// ParseHabitQuery reports whether an utterance asks what the owner usually
|
||||
// does, and on which weekday if it names one.
|
||||
//
|
||||
@@ -68,6 +80,9 @@ func ParseHabitQuery(text string) (HabitQuery, bool) {
|
||||
if wd, ok := weekdayWords[t]; ok {
|
||||
return HabitQuery{Weekday: wd, HasWeekday: true}, true
|
||||
}
|
||||
if weekendWords[t] {
|
||||
return HabitQuery{Weekend: true}, true
|
||||
}
|
||||
}
|
||||
return HabitQuery{}, true
|
||||
}
|
||||
|
||||
@@ -43,3 +43,48 @@ func TestParseHabitQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryBeatsDayPlan — "какие у меня обычно планы по вторникам?" is a
|
||||
// habit question that happens to carry a plan word. The day plan claimed it
|
||||
// first and answered today's calendar stamped with today's date, and the habit
|
||||
// source never ran.
|
||||
func TestHabitQueryBeatsDayPlan(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"какие у меня обычно планы по вторникам?",
|
||||
"что обычно по плану в среду?",
|
||||
"какие планы обычно по выходным?",
|
||||
} {
|
||||
if IsDayPlanQuery(q) {
|
||||
t.Errorf("%q was claimed as a day plan", q)
|
||||
}
|
||||
if _, ok := ParseHabitQuery(q); !ok {
|
||||
t.Errorf("%q is not parsed as a habit question", q)
|
||||
}
|
||||
}
|
||||
// A plan question without a habit marker still belongs to the day plan.
|
||||
for _, q := range []string{"какие планы на сегодня?", "что у меня по плану?"} {
|
||||
if !IsDayPlanQuery(q) {
|
||||
t.Errorf("%q must still be a day plan", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryWeekendAndSundayForms — "по выходным" names days but no
|
||||
// weekday, so it used to be answered with the whole-week profile. Sunday had
|
||||
// only its dative plural listed.
|
||||
func TestHabitQueryWeekendAndSundayForms(t *testing.T) {
|
||||
q, ok := ParseHabitQuery("что я обычно делаю по выходным?")
|
||||
if !ok || !q.Weekend || q.HasWeekday {
|
||||
t.Errorf("weekend query parsed as %+v (ok=%v)", q, ok)
|
||||
}
|
||||
for _, s := range []string{
|
||||
"что я обычно делаю в воскресенье?",
|
||||
"чем я обычно занят по воскресеньям?",
|
||||
"что обычно бывает в воскресенья?",
|
||||
} {
|
||||
q, ok := ParseHabitQuery(s)
|
||||
if !ok || !q.HasWeekday || q.Weekday != time.Sunday {
|
||||
t.Errorf("%q parsed as %+v (ok=%v)", s, q, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>.
|
||||
//
|
||||
|
||||
+22
-1
@@ -80,8 +80,29 @@ func (s *Store) QueryNotes(ctx context.Context, embedding []float32, k int) ([]N
|
||||
// embedding math; Score stays 0). This is the read surface for /dash: notes
|
||||
// captured by voice are otherwise only reachable through semantic query.
|
||||
func (s *Store) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
return s.recentNotesWhere(ctx, "", n)
|
||||
}
|
||||
|
||||
// RecentNotesBySource returns the newest n notes written by one source, newest
|
||||
// first. The filter is in SQL, not in the caller, because a caller that reads n
|
||||
// rows and then keeps the ones it wants has a window measured in OTHER writers'
|
||||
// traffic: once n newer notes from anywhere have landed, the rows it was
|
||||
// looking for are gone. Anything that needs "the last n of mine" wants this.
|
||||
func (s *Store) RecentNotesBySource(ctx context.Context, source string, n int) ([]Note, error) {
|
||||
return s.recentNotesWhere(ctx, "WHERE source = ?", n, source)
|
||||
}
|
||||
|
||||
// RecentNotesExcludingSource returns the newest n notes NOT written by source.
|
||||
// Same reasoning inverted: a reader that wants n notes he wrote must not have
|
||||
// its budget eaten by rows it is about to discard.
|
||||
func (s *Store) RecentNotesExcludingSource(ctx context.Context, source string, n int) ([]Note, error) {
|
||||
return s.recentNotesWhere(ctx, "WHERE source <> ?", n, source)
|
||||
}
|
||||
|
||||
func (s *Store) recentNotesWhere(ctx context.Context, where string, n int, args ...any) ([]Note, error) {
|
||||
args = append(args, n)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, ts, text, source FROM notes ORDER BY ts DESC LIMIT ?`, n)
|
||||
`SELECT id, ts, text, source FROM notes `+where+` ORDER BY ts DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent notes: %w", err)
|
||||
}
|
||||
|
||||
@@ -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