a8710c859b
"что ты записала сегодня?" was recognised as a history question and then answered with "ты говорил: …". The rows are right — a tapped fact is one act seen from two sides — but the sentence hands the question back instead of answering it. historyAsks returns which side was asked and queryHistory phrases from it, including the nothing-found reply. His side is tested first, because "отмечать" is on both verb lists and "что я отметил" is not a question about her. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
214 lines
7.9 KiB
Go
214 lines
7.9 KiB
Go
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"))
|
||
}
|