Version, authenticate and fully trace ecosystem calls #84

Merged
claude merged 135 commits from overnight/eco-versioned-traces into master 2026-08-01 14:50:26 +02:00
7 changed files with 130 additions and 0 deletions
Showing only changes of commit c21d8fdcee - Show all commits
+3
View File
@@ -214,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
}
+17
View File
@@ -250,3 +250,20 @@ func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
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)
}
}
+24
View File
@@ -346,6 +346,30 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
}
// 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.
//
+17
View File
@@ -350,3 +350,20 @@ func TestPluralDaysRU(t *testing.T) {
}
}
}
// "по выходным" 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)
}
}
+9
View File
@@ -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 {
+15
View File
@@ -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
}
+45
View File
@@ -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)
}
}
}