package main import ( "context" "fmt" "log" "strings" "time" "github.com/kami/maven/internal/morph" ) // 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. // A history question needs three things in one utterance: the interrogative, // whose turn is being asked about, and a verb of saying or recording. Any two of // them are a different question. "что я говорил про сервер" names a topic and // the notes pass answers it better; "записал молоко" is a capture. // // The verbs are matched by lemma through internal/morph, not by a truncated // prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал", // which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun, // so "что я рассказал ей" and "что я читал рассказ" were the same string test. // Aspect pairs are separate lemmas in the dictionary, so both members are listed. var ( // historySpokenVerbs — what HE did. "что я тебе говорил". historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"} // historyRecordedVerbs — what SHE did with it. "что ты записала сегодня". historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"} // firstPersonSubjects and secondPersonSubjects — whose turn the question is // about. Only the subject forms: "что я тебе говорил" is his turn, and the // dative "тебе" in it is not the subject. firstPersonSubjects = []string{"я"} secondPersonSubjects = []string{"ты"} ) // historyMarkersEn — the English pairs, kept as substrings because the // dictionary is Russian. Each half alone is a different question, the same way // the Russian test needs all three parts. var historyMarkersEn = [][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 "} // 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 historyAskedHim, false } for _, r := range historyRecall { if strings.Contains(s, r) { return historyAskedHim, false } } for _, pair := range historyMarkersEn { if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) { if strings.Contains(pair[0], "you") { return historyAskedHer, true } return historyAskedHim, true } } toks := historyTokens(s) if !hasAny(toks, "что", "чего") { 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 historyAskedHim, true } if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) { return historyAskedHer, true } return historyAskedHim, false } // historyTokens splits an utterance into bare words. The punctuation goes // because "говорил?" is the same word as "говорил". func historyTokens(s string) []string { toks := strings.Fields(s) out := make([]string, 0, len(toks)) for _, t := range toks { if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" { out = append(out, t) } } return out } func hasAny(toks []string, want ...string) bool { for _, t := range toks { for _, w := range want { if t == w { return true } } } return false } // hasVerbForm reports whether any token is a form of any of the lemmas. Both // sides go through the dictionary, so a caller may name the infinitive and he // may say the past tense. func hasVerbForm(toks []string, lemmas []string) bool { for _, t := range toks { for _, l := range lemmas { if morph.SameWord(t, l) { 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) { side, ok := historyAsks(t.dec.Utterance) if !ok { 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. if side == historyAskedHer { return "за последние сутки я ничего с твоих слов не записывала.", true } return "за последние сутки ты мне ничего такого не говорил.", true } if side == historyAskedHer { return "я записала: " + strings.Join(said, "; "), 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")) }