Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f361d2034 | |||
| 19242ef73b |
@@ -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
|
||||||
|
|||||||
+9
-16
@@ -33,19 +33,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|||||||
router.IntentAct: {dialogue.SlotFn},
|
router.IntentAct: {dialogue.SlotFn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyQuestions — one short question per missing slot.
|
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||||
//
|
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
// used to hold; a re-ask says it differently, because a question he already
|
||||||
// would wander, and a question whose wording changes every time is harder to
|
// failed to answer is the worst one to repeat unchanged.
|
||||||
// answer than a blunt one that always reads the same. They are infinitive
|
|
||||||
// questions, so there is no gender agreement to get wrong; the feminine
|
|
||||||
// self-reference lives in the reply she gives when she drops the request.
|
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
|
||||||
dialogue.SlotTime: "Когда?",
|
|
||||||
dialogue.SlotText: "О чём напомнить?",
|
|
||||||
dialogue.SlotKey: "Что записать?",
|
|
||||||
dialogue.SlotFn: "Что сделать?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||||
// says so out loud: dropping the request in silence would leave him thinking it
|
// says so out loud: dropping the request in silence would leave him thinking it
|
||||||
@@ -147,7 +138,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
|||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
q, ok := clarifyQuestions[missing[0]]
|
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
@@ -266,7 +257,9 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
question, ok := clarifyQuestions[remaining[0]]
|
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||||
|
// with the re-ask path, so the second gap is worded like a second try.
|
||||||
|
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -290,7 +283,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||||
question := ""
|
question := ""
|
||||||
if len(q.Missing) > 0 {
|
if len(q.Missing) > 0 {
|
||||||
question = clarifyQuestions[q.Missing[0]]
|
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||||
}
|
}
|
||||||
if question == "" || !q.CanAsk() {
|
if question == "" || !q.CanAsk() {
|
||||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
|||||||
@@ -156,8 +156,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
// question he already failed to answer is the worst way to ask it.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||||
|
}
|
||||||
|
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||||
|
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||||
t.Fatalf("attempt %d must leave the question armed", i)
|
t.Fatalf("attempt %d must leave the question armed", i)
|
||||||
@@ -349,8 +355,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("the answer must be consumed as an answer")
|
t.Fatal("the answer must be consumed as an answer")
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// Second gap, second attempt, so it is the second wording of the time
|
||||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
// question — the attempt budget is shared between the two paths.
|
||||||
|
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
@@ -411,8 +420,9 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
eval.CheckCringe: true,
|
eval.CheckCringe: true,
|
||||||
}
|
}
|
||||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||||
for _, q := range clarifyQuestions {
|
lines = append(lines, clarifyMissedVariants...)
|
||||||
lines = append(lines, q)
|
for _, variants := range clarifyQuestionVariants {
|
||||||
|
lines = append(lines, variants...)
|
||||||
}
|
}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The clarify copy deck (Vikunja #457).
|
||||||
|
//
|
||||||
|
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||||
|
// that sentence word for word. A question he already failed to answer is the
|
||||||
|
// worst one to ask again unchanged: the second wording is the one that tells
|
||||||
|
// him which part she missed.
|
||||||
|
//
|
||||||
|
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||||
|
// given: the resident model would wander, and a question whose wording changes
|
||||||
|
// at random is harder to answer than a blunt one. What changes here is that the
|
||||||
|
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||||
|
// short, the second names the gap, the third spells it out.
|
||||||
|
//
|
||||||
|
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||||
|
// constants compiled into the daemon, so there is no file that can drift out of
|
||||||
|
// step with the code that reads it.
|
||||||
|
//
|
||||||
|
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||||
|
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||||
|
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||||
|
dialogue.SlotTime: {
|
||||||
|
"Когда?",
|
||||||
|
"Во сколько напомнить?",
|
||||||
|
"Скажи время — например, «в семь вечера» или «через час».",
|
||||||
|
},
|
||||||
|
dialogue.SlotText: {
|
||||||
|
"О чём напомнить?",
|
||||||
|
"Что сказать тебе в это время?",
|
||||||
|
"Скажи одной фразой, о чём напомнить.",
|
||||||
|
},
|
||||||
|
dialogue.SlotKey: {
|
||||||
|
"Что записать?",
|
||||||
|
"Что именно отметить?",
|
||||||
|
"Назови, что записать — например, «выпил воды».",
|
||||||
|
},
|
||||||
|
dialogue.SlotFn: {
|
||||||
|
"Что сделать?",
|
||||||
|
"Какое действие выполнить?",
|
||||||
|
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||||
|
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||||
|
// most explicit phrasing rather than wrapping round to the short one, because
|
||||||
|
// wrapping would ask the same short question he has already not answered.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||||
|
// same statement however it is worded, and a re-ask is not.
|
||||||
|
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||||
|
variants, ok := clarifyQuestionVariants[slot]
|
||||||
|
if !ok || len(variants) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
i := attempt - 1
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
if i >= len(variants) {
|
||||||
|
i = len(variants) - 1
|
||||||
|
}
|
||||||
|
return variants[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||||
|
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||||
|
// query, chat, system are not in wantedSlots).
|
||||||
|
//
|
||||||
|
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||||
|
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||||
|
// the same sentence every time is what makes a house assistant sound like a
|
||||||
|
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||||
|
// should say it again — because the wording may vary and the meaning may not.
|
||||||
|
var clarifyMissedVariants = []string{
|
||||||
|
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||||
|
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||||
|
"Не уловила. Скажи это по-другому?",
|
||||||
|
"Прости, не поняла — попробуй сказать иначе.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||||
|
// asked twice get the same answer and two different misses sound different.
|
||||||
|
//
|
||||||
|
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||||
|
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||||
|
func clarifyMissedFor(utterance string) string {
|
||||||
|
var sum int
|
||||||
|
for _, r := range utterance {
|
||||||
|
sum += int(r)
|
||||||
|
}
|
||||||
|
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||||
|
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||||
|
// caller keeps its own reply.
|
||||||
|
func clarifyMissedLine(dec router.Decision) string {
|
||||||
|
if !dec.Clarify {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clarifyMissedFor(dec.Utterance)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||||
|
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||||
|
// the defect this deck exists to fix (Vikunja #457).
|
||||||
|
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||||
|
for slot, variants := range clarifyQuestionVariants {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range variants {
|
||||||
|
if v == "" {
|
||||||
|
t.Errorf("%s: empty wording in the deck", slot)
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("%s: repeated wording %q", slot, v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||||
|
got, ok := clarifyQuestionFor(slot, attempt)
|
||||||
|
if !ok || got != variants[attempt-1] {
|
||||||
|
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||||
|
// the short question he has already not answered twice.
|
||||||
|
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||||
|
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||||
|
for _, attempt := range []int{0, 4, 9} {
|
||||||
|
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||||
|
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||||
|
t.Error("an unknown slot must have no question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The missed line is stable for one utterance and absent for a decision that is
|
||||||
|
// not a clarify.
|
||||||
|
func TestClarifyMissedLine(t *testing.T) {
|
||||||
|
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||||
|
first := clarifyMissedLine(d)
|
||||||
|
if first == "" || first != clarifyMissedLine(d) {
|
||||||
|
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||||
|
}
|
||||||
|
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||||
|
t.Errorf("a decision that is not a clarify got %q", got)
|
||||||
|
}
|
||||||
|
// The empty utterance still gets a line: she has to say something.
|
||||||
|
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||||
|
t.Error("an empty utterance must still be answered out loud")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,12 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
|||||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||||
func (r *llmReplier) Reply(d router.Decision) string {
|
func (r *llmReplier) Reply(d router.Decision) string {
|
||||||
if d.Clarify {
|
if d.Clarify {
|
||||||
return r.stub.Reply(d)
|
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||||
|
// into a question is the line he hears most often when she misses him,
|
||||||
|
// and it used to be the same words every time (Vikunja #457). Still no
|
||||||
|
// model call — this text has to be right every time, and it is not worth
|
||||||
|
// a generation to say something this small.
|
||||||
|
return clarifyMissedLine(d)
|
||||||
}
|
}
|
||||||
out, err := r.p.PhraseReply(context.Background(), d)
|
out, err := r.p.PhraseReply(context.Background(), d)
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
|
|||||||
@@ -37,9 +37,21 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
|||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||||
|
// the clarify deck rather than the stub's single sentence.
|
||||||
|
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||||
|
if got == "я всё поняла" {
|
||||||
|
t.Fatal("a clarify must not be phrased by the model")
|
||||||
|
}
|
||||||
|
if want := clarifyMissedFor("мгм"); got != want {
|
||||||
|
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Two different misses do not sound identical.
|
||||||
|
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||||
|
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user