0f361d2034
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>
118 lines
4.3 KiB
Go
118 lines
4.3 KiB
Go
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"))
|
||
}
|