diff --git a/cmd/mavend/historyq.go b/cmd/mavend/historyq.go index 1375dbd..867b3a3 100644 --- a/cmd/mavend/historyq.go +++ b/cmd/mavend/historyq.go @@ -54,30 +54,56 @@ var historyMarkersEn = [][2]string{ // answers a topic far better than a list of the last five facts does. var historyRecall = []string{" про ", " об ", " о ", " about "} +// historySide — whose turn the question asks about. The rows read are the same +// either way, because a tapped fact is one act seen from two sides, but the +// sentence is not: answering "что ты записала сегодня?" with "ты говорил…" +// hands the question back instead of answering it (Vikunja #456). +type historySide int + +const ( + historyAskedHim historySide = iota // "что я тебе говорил" + historyAskedHer // "что ты записала сегодня" +) + // isHistoryQuery reports whether he is asking what he told her. func isHistoryQuery(u string) bool { + _, ok := historyAsks(u) + return ok +} + +// historyAsks reports whether this is a history question, and whose turn it is +// about. +func historyAsks(u string) (historySide, bool) { s := " " + strings.ToLower(strings.TrimSpace(u)) + " " if s == " " { - return false + return historyAskedHim, false } for _, r := range historyRecall { if strings.Contains(s, r) { - return false + return historyAskedHim, false } } for _, pair := range historyMarkersEn { if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) { - return true + if strings.Contains(pair[0], "you") { + return historyAskedHer, true + } + return historyAskedHim, true } } toks := historyTokens(s) if !hasAny(toks, "что", "чего") { - return false + return historyAskedHim, false } + // His side is tested first: "отмечать" is on both verb lists, so "что я + // отметил" must not read as a question about her. if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) { - return true + return historyAskedHim, true } - return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) + if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) { + return historyAskedHer, true + } + return historyAskedHim, false } // historyTokens splits an utterance into bare words. The punctuation goes @@ -142,7 +168,8 @@ const historyWindow = 24 * time.Hour // 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) { + side, ok := historyAsks(t.dec.Utterance) + if !ok { return "", false } facts, err := h.api.RecentFacts(ctx, historyScan) @@ -164,8 +191,14 @@ func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (strin 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. + if side == historyAskedHer { + return "за последние сутки я ничего с твоих слов не записывала.", true + } return "за последние сутки ты мне ничего такого не говорил.", true } + if side == historyAskedHer { + return "я записала: " + strings.Join(said, "; "), true + } return "ты говорил: " + strings.Join(said, "; "), true } diff --git a/cmd/mavend/historyq_test.go b/cmd/mavend/historyq_test.go index 94ebf06..52e8dfa 100644 --- a/cmd/mavend/historyq_test.go +++ b/cmd/mavend/historyq_test.go @@ -89,6 +89,33 @@ func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) { } } +// The rows are the same either way, because a tapped fact is one act seen from +// two sides. The sentence is not: "что ты записала" answered with "ты говорил" +// hands the question back (Vikunja #456). +func TestHistoryAnswersTheSideItWasAsked(t *testing.T) { + now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC) + h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)}) + + his, ok := askHistory(h, "что я тебе говорил?") + if !ok || !strings.HasPrefix(his, "ты говорил") { + t.Errorf("reply = %q, ok = %v, want his side", his, ok) + } + hers, ok := askHistory(h, "что ты записала сегодня?") + if !ok || !strings.HasPrefix(hers, "я записала") { + t.Errorf("reply = %q, ok = %v, want her side", hers, ok) + } + // "отмечать" is on both verb lists, so his subject has to win. + if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim { + t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok) + } + + empty, _ := historyHandler(now) + none, ok := askHistory(empty, "что ты записала сегодня?") + if !ok || !strings.Contains(none, "не записывала") { + t.Errorf("empty reply = %q, ok = %v, want her side", none, ok) + } +} + // 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) {