Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8444813e2 |
@@ -51,6 +51,11 @@ var querySources = []querySource{
|
||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||
// listing would otherwise swallow it.
|
||||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{"habits", (*reactiveHandler).queryHabits},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
@@ -135,6 +140,35 @@ func isRestOfDayQuery(text string) bool {
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent 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.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
// answer out of the fact log rather than asking the model to summarise a life:
|
||||
// see internal/memory/behavior.go for why nothing here is generated.
|
||||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||
}
|
||||
profile := memory.BuildProfile(obs, h.now())
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -141,3 +141,87 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||
d := now
|
||||
for d.Weekday() != time.Tuesday {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
var out []ipc.Fact
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, ipc.Fact{
|
||||
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Kind: "self",
|
||||
Key: key,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||
now := planDay() // a Monday
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||
now := planDay()
|
||||
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||
api := &habitAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
}); ok {
|
||||
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both specific sources must precede the calendar listing, which matches any
|
||||
// utterance naming a day.
|
||||
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
habits, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "habits":
|
||||
habits = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if habits < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||
}
|
||||
if habits > cal {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,36 @@
|
||||
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
||||
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
||||
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
||||
|
||||
---
|
||||
|
||||
## Status (2026-08-01) — partially shipped, deliberately narrowed
|
||||
|
||||
Shipped on `overnight/behavior-profile`:
|
||||
|
||||
- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of
|
||||
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
|
||||
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
|
||||
- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker
|
||||
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
|
||||
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
|
||||
по вторникам?" is answered.
|
||||
|
||||
**Not shipped, and not to be shipped as written:**
|
||||
|
||||
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
|
||||
generated. A 1.7B asked to summarise a year of habits produces fluent claims
|
||||
about the owner's life that no row supports, and a wrong claim about him is the
|
||||
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
|
||||
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
|
||||
the profile is recomputed on the question, so a new fact is already in the next
|
||||
answer. A cached profile that can disagree with its own rows is two truths.
|
||||
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
|
||||
and a nudge at 08:00 every day proposing the day is the definition of one. The
|
||||
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
|
||||
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
|
||||
`/routines`. It goes through him.
|
||||
|
||||
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
|
||||
The counted profile needs no new IPC method to be *asked* about — the query source
|
||||
reads `RecentFacts` over the existing surface — so this is a display concern only.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||
//
|
||||
// The profile is COUNTED, not generated. docs/plans/09-behavioral-memory.md
|
||||
// asks for an LLM to write a behaviour profile daily and store it as a fact;
|
||||
// this does not do that, on purpose. A 1.7B asked to summarise a year of habits
|
||||
// will produce fluent claims about the owner's life that no row in the store
|
||||
// supports, and a wrong claim about him is the most expensive kind of wrong
|
||||
// maven can be. Counting distinct days per weekday is verifiable, cheap enough
|
||||
// to run on the question, and cannot invent a habit he does not have.
|
||||
//
|
||||
// Recomputed on read rather than cached as a fact for the same reason the store
|
||||
// is append-only: a cached profile can disagree with the rows it came from, and
|
||||
// then there are two truths. The plan's step 5 ("profile updates on fact write")
|
||||
// exists to keep a cache fresh; there is no cache, so a new fact is already in
|
||||
// the next answer.
|
||||
//
|
||||
// It is also read-only and unprompted-free. The plan's step 4 — a morning
|
||||
// dispatcher nudge proposing the day — is deliberately NOT here: maven is not a
|
||||
// nag, and proposing plans at 08:00 every day is the definition of one. Pattern
|
||||
// inference that leads to a routine the owner accepts already exists in
|
||||
// internal/pattern with the proposal queue on /routines; that is the sanctioned
|
||||
// path from "she noticed" to "she acts", and it goes through him.
|
||||
|
||||
// Observation — one thing the owner was recorded doing, reduced to what a habit
|
||||
// needs: when, and what. Facts arrive as store/ipc rows; the caller maps them
|
||||
// so this package stays free of both.
|
||||
type Observation struct {
|
||||
At time.Time
|
||||
Key string
|
||||
Kind string // "self" | "env" | "config"
|
||||
}
|
||||
|
||||
// Activity — one recurring thing, as counted. Days is the number of DISTINCT
|
||||
// days it was observed on, which is the number that decides whether something
|
||||
// is a habit; Count can be inflated by one busy day.
|
||||
//
|
||||
// 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.
|
||||
type Activity struct {
|
||||
Key string
|
||||
Days int
|
||||
Count int
|
||||
TypicalAt time.Duration
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model. Weekly holds the activities that
|
||||
// recur on a given weekday, Overall the ones that recur at all.
|
||||
type Profile struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Weekly map[time.Weekday][]Activity
|
||||
All []Activity
|
||||
}
|
||||
|
||||
// MinHabitDays — how many distinct days an activity must appear on before maven
|
||||
// will call it usual. Two is the smallest number that can distinguish a habit
|
||||
// from a one-off; below that she says she does not know yet, which is true.
|
||||
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.
|
||||
var nonBehaviouralKeyPrefixes = []string{
|
||||
"calendar_event_",
|
||||
"cooldown:",
|
||||
"quiet",
|
||||
"behavior_profile",
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Only self-facts count. An env row is the world (weather, a relayed meeting),
|
||||
// and a config row is maven's own state; neither says anything about what he
|
||||
// usually does.
|
||||
func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
loc := now.Location()
|
||||
p := Profile{Until: now, Weekly: map[time.Weekday][]Activity{}}
|
||||
|
||||
type bucket struct {
|
||||
days map[string]struct{}
|
||||
count int
|
||||
mins []int
|
||||
}
|
||||
// key → bucket, and (weekday, key) → bucket.
|
||||
all := map[string]*bucket{}
|
||||
weekly := map[time.Weekday]map[string]*bucket{}
|
||||
|
||||
for _, o := range obs {
|
||||
if o.Kind != "self" {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(o.Key)
|
||||
if key == "" || nonBehavioural(key) {
|
||||
continue
|
||||
}
|
||||
at := o.At.In(loc)
|
||||
if at.IsZero() || at.After(now) {
|
||||
continue
|
||||
}
|
||||
if p.Since.IsZero() || at.Before(p.Since) {
|
||||
p.Since = at
|
||||
}
|
||||
day := at.Format("2006-01-02")
|
||||
minute := at.Hour()*60 + at.Minute()
|
||||
|
||||
bump := func(m map[string]*bucket) {
|
||||
b := m[key]
|
||||
if b == nil {
|
||||
b = &bucket{days: map[string]struct{}{}}
|
||||
m[key] = b
|
||||
}
|
||||
b.days[day] = struct{}{}
|
||||
b.count++
|
||||
b.mins = append(b.mins, minute)
|
||||
}
|
||||
bump(all)
|
||||
wd := at.Weekday()
|
||||
if weekly[wd] == nil {
|
||||
weekly[wd] = map[string]*bucket{}
|
||||
}
|
||||
bump(weekly[wd])
|
||||
}
|
||||
|
||||
harvest := func(m map[string]*bucket) []Activity {
|
||||
var out []Activity
|
||||
for key, b := range m {
|
||||
if len(b.days) < MinHabitDays {
|
||||
continue
|
||||
}
|
||||
out = append(out, Activity{
|
||||
Key: key,
|
||||
Days: len(b.days),
|
||||
Count: b.count,
|
||||
TypicalAt: time.Duration(medianInt(b.mins)) * time.Minute,
|
||||
})
|
||||
}
|
||||
// Most-established first, then earliest in the day, then by key so the
|
||||
// same history always reads back the same way.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Days != out[j].Days {
|
||||
return out[i].Days > out[j].Days
|
||||
}
|
||||
if out[i].TypicalAt != out[j].TypicalAt {
|
||||
return out[i].TypicalAt < out[j].TypicalAt
|
||||
}
|
||||
return out[i].Key < out[j].Key
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
p.All = harvest(all)
|
||||
for wd, m := range weekly {
|
||||
if acts := harvest(m); len(acts) > 0 {
|
||||
p.Weekly[wd] = acts
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nonBehavioural(key string) bool {
|
||||
for _, p := range nonBehaviouralKeyPrefixes {
|
||||
if strings.HasPrefix(key, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// medianInt — the middle value, averaging the two middles on an even count.
|
||||
func medianInt(xs []int) int {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := make([]int, len(xs))
|
||||
copy(s, xs)
|
||||
sort.Ints(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read
|
||||
// back. Index is time.Weekday.
|
||||
var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"}
|
||||
|
||||
// activityRU glosses the loop's known fact keys. An unknown key is read back
|
||||
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
|
||||
// maven does not recognise would be putting words in his mouth.
|
||||
var activityRU = map[string]string{
|
||||
"water": "пьёшь воду",
|
||||
"meal": "ешь",
|
||||
"sleep": "спишь",
|
||||
"break": "делаешь перерыв",
|
||||
"shower": "принимаешь душ",
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься",
|
||||
}
|
||||
|
||||
// FormatWeekdayRU reads back what he usually does on a given weekday.
|
||||
// Second person singular and informal, as she speaks TO him.
|
||||
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
acts := p.Weekly[wd]
|
||||
day := weekdayRU[int(wd)%7]
|
||||
if len(acts) == 0 {
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
}
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
func (p Profile) FormatOverallRU() string {
|
||||
if len(p.All) == 0 {
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
}
|
||||
return fmt.Sprintf("обычно ты %s.", joinActivities(p.All))
|
||||
}
|
||||
|
||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||
// not an answer; the most established few are.
|
||||
const maxRecited = 5
|
||||
|
||||
func joinActivities(acts []Activity) string {
|
||||
if len(acts) > maxRecited {
|
||||
acts = acts[:maxRecited]
|
||||
}
|
||||
parts := make([]string, len(acts))
|
||||
for i, a := range acts {
|
||||
gloss, ok := activityRU[a.Key]
|
||||
if !ok {
|
||||
gloss = a.Key
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-1], ", ") + " и " + parts[len(parts)-1]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||
func habitHistory(key string, wd time.Weekday, hh, mm, weeks int, from time.Time) []Observation {
|
||||
var out []Observation
|
||||
d := from
|
||||
for d.Weekday() != wd {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), hh, mm, 0, 0, from.Location()),
|
||||
Key: key,
|
||||
Kind: "self",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func behaviorNow() time.Time {
|
||||
// A Monday, so "по вторникам" is a past weekday and not today.
|
||||
return time.Date(2026, 8, 3, 20, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func TestBuildProfileCountsWeekdayHabits(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := append(
|
||||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||
habitHistory("water", time.Tuesday, 9, 0, 3, now)...,
|
||||
)
|
||||
p := BuildProfile(obs, now)
|
||||
|
||||
tue := p.Weekly[time.Tuesday]
|
||||
if len(tue) != 2 {
|
||||
t.Fatalf("got %d tuesday activities, want 2: %+v", len(tue), tue)
|
||||
}
|
||||
// Most-established first.
|
||||
if tue[0].Key != "workout" || tue[0].Days != 4 {
|
||||
t.Errorf("first = %+v, want workout on 4 days", tue[0])
|
||||
}
|
||||
if tue[0].TypicalAt != 19*time.Hour {
|
||||
t.Errorf("typical at %v, want 19:00", tue[0].TypicalAt)
|
||||
}
|
||||
if len(p.Weekly[time.Wednesday]) != 0 {
|
||||
t.Errorf("wednesday must be empty: %+v", p.Weekly[time.Wednesday])
|
||||
}
|
||||
if len(p.All) != 2 {
|
||||
t.Errorf("the week-wide list should hold both: %+v", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
// A one-off is not a habit. Saying "ты обычно X" off a single row is a
|
||||
// confidently wrong claim about his life.
|
||||
func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("workout", time.Tuesday, 19, 0, 1, now)
|
||||
// Three rows, same day — a busy Tuesday, not a habit.
|
||||
obs = append(obs, Observation{At: obs[0].At.Add(time.Hour), Key: "workout", Kind: "self"})
|
||||
obs = append(obs, Observation{At: obs[0].At.Add(2 * time.Hour), Key: "workout", Kind: "self"})
|
||||
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 0 || len(p.Weekly) != 0 {
|
||||
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
|
||||
}
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||
t.Errorf("empty profile reads %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only self-facts describe him. Env rows are the world and config rows are
|
||||
// maven's own tuning state; counting either as a habit would be a category
|
||||
// error the owner would then be told about.
|
||||
func TestBuildProfileIgnoresNonSelfAndMachineryKeys(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
var obs []Observation
|
||||
for _, o := range habitHistory("water", time.Tuesday, 9, 0, 3, now) {
|
||||
o.Kind = "env"
|
||||
obs = append(obs, o)
|
||||
}
|
||||
for _, o := range habitHistory("cooldown:water", time.Tuesday, 9, 0, 3, now) {
|
||||
obs = append(obs, o) // kind=self, but a machinery key
|
||||
}
|
||||
for _, o := range habitHistory("calendar_event_20260804_standup", time.Tuesday, 10, 0, 3, now) {
|
||||
obs = append(obs, o)
|
||||
}
|
||||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||
t.Fatalf("nothing here is a habit of his: %+v", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
// The median, not the mean: one 03:00 outlier must not move a morning habit
|
||||
// into the night.
|
||||
func TestBuildProfileTypicalTimeIsMedian(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("water", time.Tuesday, 9, 0, 4, now)
|
||||
obs = append(obs, Observation{At: obs[0].At.AddDate(0, 0, -28).Add(-6 * time.Hour), Key: "water", Kind: "self"})
|
||||
p := BuildProfile(obs, now)
|
||||
if len(p.All) != 1 {
|
||||
t.Fatalf("got %+v", p.All)
|
||||
}
|
||||
if p.All[0].TypicalAt != 9*time.Hour {
|
||||
t.Errorf("typical at %v, want 09:00 despite the outlier", p.All[0].TypicalAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFormatRUPersona(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := append(
|
||||
habitHistory("workout", time.Tuesday, 19, 0, 4, now),
|
||||
habitHistory("water", time.Tuesday, 9, 5, 3, now)...,
|
||||
)
|
||||
p := BuildProfile(obs, now)
|
||||
|
||||
got := p.FormatWeekdayRU(time.Tuesday)
|
||||
want := "по вторникам ты обычно тренируешься около 19:00 и пьёшь воду около 09:05."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||
t.Errorf("an unknown weekday reads %q", empty)
|
||||
}
|
||||
// Persona: she addresses him informally, never in the masculine about
|
||||
// herself, and never with a pet name.
|
||||
for _, s := range []string{got, p.FormatOverallRU(), p.FormatWeekdayRU(time.Thursday)} {
|
||||
// Whole words: "ничего" contains "его", and a substring test would
|
||||
// call a correct sentence a persona violation.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r)
|
||||
}) {
|
||||
switch tok {
|
||||
case "рад", "понял", "вы", "ваш", "ваши", "милый", "дорогой", "он", "его":
|
||||
t.Errorf("%q uses %q", s, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unrecognised key is read back verbatim rather than glossed into something
|
||||
// maven made up.
|
||||
func TestProfileUnknownKeyReadBackVerbatim(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
p := BuildProfile(habitHistory("починил кран", time.Tuesday, 12, 0, 2, now), now)
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "починил кран") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A future-dated row is a clock problem, not a habit.
|
||||
func TestBuildProfileIgnoresFutureRows(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
obs := habitHistory("water", time.Tuesday, 9, 0, 3, now.AddDate(0, 2, 0))
|
||||
if p := BuildProfile(obs, now); len(p.All) != 0 {
|
||||
t.Fatalf("future rows counted: %+v", p.All)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package router
|
||||
|
||||
import "time"
|
||||
|
||||
// Habit queries — "что я обычно делаю по вторникам?" (Vikunja #254).
|
||||
//
|
||||
// Deterministic matching, like the calendar and plan matchers: the LLM router
|
||||
// classifies the intent, but WHICH weekday was asked about is a lookup, not a
|
||||
// generation. A model that answers "по вторникам" for a question about Thursday
|
||||
// gives a confidently wrong account of the owner's own life.
|
||||
|
||||
// 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.
|
||||
type HabitQuery struct {
|
||||
Weekday time.Weekday
|
||||
HasWeekday bool
|
||||
}
|
||||
|
||||
// habitMarkers — the words that make a question about habit rather than about
|
||||
// today. Without one of these, "что я делаю" is a question about right now, and
|
||||
// the recall path owns it.
|
||||
var habitMarkers = []string{
|
||||
"обычно", "обычное", "чаще", "постоянно", "привычки", "привычка", "привычках",
|
||||
"регулярно", "каждый", "каждую", "каждое", "usually", "habits", "habit",
|
||||
"typically", "normally",
|
||||
}
|
||||
|
||||
// weekdayWords — every form of a weekday name maven needs to recognise,
|
||||
// including the "по …ам" plural the question is usually phrased in.
|
||||
var weekdayWords = map[string]time.Weekday{
|
||||
"понедельник": time.Monday, "понедельникам": time.Monday,
|
||||
"вторник": time.Tuesday, "вторникам": time.Tuesday,
|
||||
"среда": time.Wednesday, "среду": time.Wednesday, "средам": time.Wednesday,
|
||||
"четверг": time.Thursday, "четвергам": time.Thursday,
|
||||
"пятница": time.Friday, "пятницу": time.Friday, "пятницам": time.Friday,
|
||||
"суббота": time.Saturday, "субботу": time.Saturday, "субботам": time.Saturday,
|
||||
"воскресенье": time.Sunday, "воскресеньям": time.Sunday,
|
||||
"monday": time.Monday, "mondays": time.Monday,
|
||||
"tuesday": time.Tuesday, "tuesdays": time.Tuesday,
|
||||
"wednesday": time.Wednesday, "wednesdays": time.Wednesday,
|
||||
"thursday": time.Thursday, "thursdays": time.Thursday,
|
||||
"friday": time.Friday, "fridays": time.Friday,
|
||||
"saturday": time.Saturday, "saturdays": time.Saturday,
|
||||
"sunday": time.Sunday, "sundays": time.Sunday,
|
||||
}
|
||||
|
||||
// ParseHabitQuery reports whether an utterance asks what the owner usually
|
||||
// does, and on which weekday if it names one.
|
||||
//
|
||||
// A habit marker is required. "что я делаю в среду?" without one is a question
|
||||
// about this coming Wednesday — the calendar's job — and answering it with a
|
||||
// statistical average would be answering a different question.
|
||||
func ParseHabitQuery(text string) (HabitQuery, bool) {
|
||||
toks := planTokens(text)
|
||||
marked := false
|
||||
for _, t := range toks {
|
||||
for _, m := range habitMarkers {
|
||||
if t == m {
|
||||
marked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !marked {
|
||||
return HabitQuery{}, false
|
||||
}
|
||||
for _, t := range toks {
|
||||
if wd, ok := weekdayWords[t]; ok {
|
||||
return HabitQuery{Weekday: wd, HasWeekday: true}, true
|
||||
}
|
||||
}
|
||||
return HabitQuery{}, true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseHabitQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
ok bool
|
||||
wd time.Weekday
|
||||
hasWD bool
|
||||
}{
|
||||
{"что я обычно делаю по вторникам?", true, time.Tuesday, true},
|
||||
{"что я обычно делаю?", true, 0, false},
|
||||
{"какие у меня привычки", true, 0, false},
|
||||
{"что я каждую пятницу делаю", true, time.Friday, true},
|
||||
{"what do i usually do on mondays?", true, time.Monday, true},
|
||||
// No habit marker: this is a question about the coming Wednesday, and
|
||||
// the calendar owns it. Answering with an average answers the wrong
|
||||
// question.
|
||||
{"что я делаю в среду?", false, 0, false},
|
||||
{"что у меня сегодня?", false, 0, false},
|
||||
{"какие планы на сегодня?", false, 0, false},
|
||||
{"", false, 0, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
q, ok := ParseHabitQuery(tt.in)
|
||||
if ok != tt.ok {
|
||||
t.Errorf("ParseHabitQuery(%q) ok = %v, want %v", tt.in, ok, tt.ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if q.HasWeekday != tt.hasWD {
|
||||
t.Errorf("ParseHabitQuery(%q) hasWeekday = %v, want %v", tt.in, q.HasWeekday, tt.hasWD)
|
||||
continue
|
||||
}
|
||||
if q.HasWeekday && q.Weekday != tt.wd {
|
||||
t.Errorf("ParseHabitQuery(%q) weekday = %v, want %v", tt.in, q.Weekday, tt.wd)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user