diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f507d21..7a6c38d 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -91,6 +91,12 @@ var querySources = []querySource{ // answer it from whatever he once said about spending. Its matcher needs a // money noun plus an actual ask, so "я потратил весь день" is untouched. {name: "money", answer: (*reactiveHandler).queryMoney}, + // Also above the recall sources: "что я тебе говорил?" is a question about + // the facts he tapped in, and the notes pass would answer it with whatever + // note is nearest (Vikunja #456). Its matcher needs both halves of a + // history phrase and bails out when he names a topic, so "что я говорил + // про сервер" is still recall. + {name: "history", answer: (*reactiveHandler).queryHistory}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so diff --git a/cmd/mavend/historyq.go b/cmd/mavend/historyq.go new file mode 100644 index 0000000..ea85394 --- /dev/null +++ b/cmd/mavend/historyq.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" +) + +// Command history — "что я тебе говорил?", "что ты записала сегодня?" +// (Vikunja #456). +// +// Read-only over the facts that already exist. No new mechanism and no new +// storage: everything he tapped in is already a row with a source and a +// timestamp, and this only reads them back. + +// historyMarkers — the ways he asks what he told her. Each entry is a pair of +// substrings that must BOTH appear, because either half alone is a different +// question: "что я говорил про сервер" is a recall question the notes pass +// answers better, and "что ты записала" with no "что" is not a question at all. +var historyMarkers = [][2]string{ + {"что я", "говорил"}, + {"что я", "сказал"}, + {"что я", "рассказ"}, + {"что ты", "записал"}, + {"что ты", "запомнил"}, + {"что я", "отмечал"}, + {"что я", "отметил"}, + {"what did i", "tell"}, + {"what did you", "record"}, +} + +// historyRecall — the word that turns a history question into a recall +// question. "что я говорил про сервер" names a topic, and the notes pass +// answers a topic far better than a list of the last five facts does. +var historyRecall = []string{" про ", " об ", " о ", " about "} + +// isHistoryQuery reports whether he is asking what he told her. +func isHistoryQuery(u string) bool { + s := " " + strings.ToLower(strings.TrimSpace(u)) + " " + if s == " " { + return false + } + for _, r := range historyRecall { + if strings.Contains(s, r) { + return false + } + } + for _, pair := range historyMarkers { + if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) { + return true + } + } + return false +} + +// historyScan — how many recent facts are read before filtering. Deliberately +// larger than historyReadOut: a poller writing every few minutes would +// otherwise push everything he said out of the window, the same way his own +// notes used to crowd out the feed headlines. +const historyScan = 100 + +// historyReadOut — how many she says out loud. Five is what fits in one spoken +// breath; the rest are on /history, which is the surface for reading a list. +const historyReadOut = 5 + +// historyWindow — how far back "recently" reaches. A day, because the question +// is about this conversation and not about the archive. +const historyWindow = 24 * time.Hour + +// queryHistory answers what he told her, from the facts he tapped in. +// +// Only "tap:" sources. A fact written by a poller, an inference or the ambient +// relay is a thing she learned, not a thing he said, and reading those back +// under "что я тебе говорил?" would put words in his mouth. +// +// Placed with the other sources that read his own rows and above the recall +// pass: the notes pass would otherwise answer this from whatever note happens +// to be nearest, which reads as an answer and is not one. +func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) { + if !isHistoryQuery(t.dec.Utterance) { + return "", false + } + facts, err := h.api.RecentFacts(ctx, historyScan) + if err != nil { + log.Printf("voice: history: recent facts: %v", err) + return "не получилось посмотреть, что ты говорил.", true + } + cutoff := h.now().Add(-historyWindow) + var said []string + for _, f := range facts { + if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) { + continue + } + said = append(said, historyLine(f.Key, f.Value, f.Ts)) + if len(said) == historyReadOut { + break + } + } + if len(said) == 0 { + // Claim the turn rather than fall through. "ничего не говорил" is the + // true answer, and recall would answer it with an old note instead. + return "за последние сутки ты мне ничего такого не говорил.", true + } + return "ты говорил: " + strings.Join(said, "; "), true +} + +// historyLine — one fact as she says it. The hour and minute, because the day +// is already bounded by historyWindow and a date would be noise. +func historyLine(key, value string, ts time.Time) string { + what := key + if value != "" { + what = key + " — " + value + } + return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04")) +} diff --git a/cmd/mavend/historyq_test.go b/cmd/mavend/historyq_test.go new file mode 100644 index 0000000..b19342a --- /dev/null +++ b/cmd/mavend/historyq_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// historyAPI serves a fixed set of recent facts. +type historyAPI struct { + ipc.UnimplementedCoreAPI + facts []ipc.Fact + calls int +} + +func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) { + a.calls++ + return a.facts, nil +} + +func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) { + api := &historyAPI{facts: facts} + return &reactiveHandler{api: api, now: func() time.Time { return now }}, api +} + +func askHistory(h *reactiveHandler, u string) (string, bool) { + return h.queryHistory(context.Background(), &queryTurn{ + dec: router.Decision{Intent: router.IntentQuery, Utterance: u}, + }) +} + +func TestIsHistoryQuery(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + {"что я тебе говорил?", true}, + {"что ты записала сегодня?", true}, + {"что я отмечал?", true}, + // A named topic is a recall question, and the notes pass answers it + // better than a list of the last five facts does. + {"что я говорил про сервер?", false}, + {"что у меня сегодня?", false}, + {"", false}, + } { + if got := isHistoryQuery(tc.text); got != tc.want { + t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + h, api := historyHandler(now, + ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)}, + // Learned, not said: a poller writing this back under "что я тебе + // говорил?" would put words in his mouth. + ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)}, + // Older than the window. + ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)}, + ) + reply, ok := askHistory(h, "что я тебе говорил?") + if !ok { + t.Fatal("the history question must be claimed before the recall sources") + } + if !strings.Contains(reply, "water") { + t.Errorf("reply = %q, want the fact he tapped in", reply) + } + if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") { + t.Errorf("reply = %q, want only what he said inside the window", reply) + } + if api.calls != 1 { + t.Errorf("RecentFacts called %d times, want 1", api.calls) + } +} + +// Nothing said is an answer of its own. Falling through would hand the question +// to recall, which answers it with an old note. +func TestHistorySaysWhenThereIsNothing(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + h, _ := historyHandler(now) + reply, ok := askHistory(h, "что я тебе говорил?") + if !ok || !strings.Contains(reply, "ничего") { + t.Fatalf("reply = %q, ok = %v", reply, ok) + } +} + +// Five is what fits in one spoken breath; the rest are on /history. +func TestHistoryStopsAtFive(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + var facts []ipc.Fact + for i := 0; i < 12; i++ { + facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)}) + } + h, _ := historyHandler(now, facts...) + reply, _ := askHistory(h, "что ты записала?") + if got := strings.Count(reply, ";"); got != historyReadOut-1 { + t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1) + } +}