mavend: answer what he told her, from the facts he already tapped in (V-456)
Read-only over rows that exist. No new mechanism and no new storage: every fact he tapped in already carries a source and a timestamp, and the history source only reads them back. Only "tap:" sources, and only the last day. A fact written by a poller, an inference or the ambient relay is a thing she learned rather than a thing he said, and reading those back under "что я тебе говорил?" would put words in his mouth. Five at a time, which is what fits in one spoken breath — the rest are on /history, which is the surface for reading a list. Above the recall sources, with the others that read his own rows: the notes pass would otherwise answer this from whatever note is nearest, which reads as an answer and is not one. The matcher wants both halves of a history phrase and steps aside when he names a topic, so "что я говорил про сервер" stays a recall question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,12 @@ var querySources = []querySource{
|
|||||||
// answer it from whatever he once said about spending. Its matcher needs a
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{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
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// 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
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user