diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 512b3d3..5e78d6c 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -3,6 +3,8 @@ package main import ( "context" "log" + "math/rand" + "strings" "time" "github.com/kami/maven/internal/dialogue" @@ -21,8 +23,12 @@ const clarifyTTL = 90 * time.Second // raw utterance, chat and system have nothing to fill in. For those a clarify // decision keeps the canned "не поняла" reply — inventing a question for noise // is worse than admitting she missed it. +// A reminder wants BOTH what to remind about and when. Subject first: "напомни +// в 11" has a time and nothing to say at 11, and a reminder with no subject is +// not worth setting. Order here is the order she asks in — she still only asks +// about the first one missing. var wantedSlots = map[router.Intent][]dialogue.Slot{ - router.IntentReminder: {dialogue.SlotTime}, + router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime}, router.IntentFact: {dialogue.SlotKey}, router.IntentAct: {dialogue.SlotFn}, } @@ -35,7 +41,8 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{ // 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.SlotTime: "Когда?", + dialogue.SlotText: "О чём напомнить?", dialogue.SlotKey: "Что записать?", dialogue.SlotFn: "Что сделать?", } @@ -45,11 +52,55 @@ var clarifyQuestions = map[dialogue.Slot]string{ // landed. Feminine self-reference ("поняла"), as everywhere. const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому." -// clarifyExpired — his answer came after the TTL, so the parked request is -// already gone. Same tone as clarifyGaveUp, different reason: too much time +// clarifyExpiredVariants — his answer came after the TTL, so the parked request +// is already gone. Same tone as clarifyGaveUp, different reason: too much time // passed, not "I did not understand". Feminine self-reference ("ждала", // "отпустила"); he is addressed with a plain imperative. -const clarifyExpired = "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново." +// +// Five phrasings, not one. This is the line he hears whenever he walks off +// mid-request, so it is the line that repeats most — and the same sentence every +// time is what makes a house assistant sound like a kiosk. They all carry the +// same two facts (the old request is gone; say it again if it still matters), +// because the wording may vary and the meaning may not. +// +// Fixed templates rather than model output, for the same reason as +// clarifyQuestions: this text has to be right every time, and it is not worth a +// generation to say something this small. +var clarifyExpiredVariants = []string{ + "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.", + "Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.", + "Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.", + "Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.", + "Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.", +} + +// clarifyExpiredLine picks one of them at random. +func clarifyExpiredLine() string { + return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))] +} + +// isClarifyExpired reports whether s opens with any of the expiry lines. The +// notice is glued in front of this turn's reply (see withNotice), so a caller +// checking for it has to match a prefix, not the whole string. +func isClarifyExpired(s string) bool { + for _, v := range clarifyExpiredVariants { + if strings.HasPrefix(s, v) { + return true + } + } + return false +} + +// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual +// reply. "" ⇒ the notice was the whole thing. +func trimClarifyExpired(s string) string { + for _, v := range clarifyExpiredVariants { + if strings.HasPrefix(s, v) { + return strings.TrimSpace(strings.TrimPrefix(s, v)) + } + } + return strings.TrimSpace(s) +} // clarifyExpiredNotice returns that line when a parked question had just timed // out, and "" when nothing was parked. Call it right after @@ -63,7 +114,7 @@ func (h *reactiveHandler) clarifyExpiredNotice() string { return "" } log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh") - return clarifyExpired + return clarifyExpiredLine() } // withNotice glues the expiry notice in front of this turn's reply. One turn diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index 43880b2..e280e32 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -56,10 +56,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) { want string asked bool }{ - {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true}, + {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true}, {"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true}, {"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true}, - {"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false}, + // A time with nothing to say at that time is still half a reminder, so + // the subject is what she asks about — not silence. + {"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true}, + {"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false}, {"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false}, {"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false}, } @@ -78,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) { h, st, _ := newClarifyHandler(t) question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) - if !asked || question != "На когда напомнить?" { + if !asked || question != "Когда?" { t.Fatalf("expected the time question, got %q asked=%v", question, asked) } @@ -152,7 +155,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { if !handled { t.Fatalf("answer %d must be consumed as an answer", i) } - if reply != "На когда напомнить?" { + if reply != "Когда?" { t.Fatalf("attempt %d should ask again, got %q", i, reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { @@ -304,17 +307,17 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) { *now = now.Add(clarifyTTL + time.Second) reply := h.handleText(ctx, "как дела") - if !strings.HasPrefix(reply, clarifyExpired) { + if !isClarifyExpired(reply) { t.Fatalf("expired question must be announced first, got %q", reply) } - if strings.TrimSpace(strings.TrimPrefix(reply, clarifyExpired)) == "" { + if trimClarifyExpired(reply) == "" { t.Fatalf("the new words must still be answered, got only the notice: %q", reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { t.Fatal("the expired question must be gone") } // The notice is said once, not on every later utterance. - if reply := h.handleText(ctx, "как дела"); strings.Contains(reply, clarifyExpired) { + if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) { t.Fatalf("notice repeated on a later turn: %q", reply) } } diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index e633aa4..7b73e42 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -34,7 +34,7 @@ func newLLMReplier(c completer, block func() string) *llmReplier { return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block} } -const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). +const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"} Никогда не пиши "..." в поле response.` diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 1bf1eb9..558bec2 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -551,12 +551,21 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma // Russian only, feminine self-reference, second person masculine (the owner is // a man). She talks TO him, informally, singular — never "вы", never "он". // One short sentence — the nudge is spoken aloud. +// +// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his +// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the +// unqualified word read as forbidding that too. Hence "ласковые обращения". +// +// The examples also never claim a physical act. She has no hands and no smart +// plug — she can tell him the battery is at three percent, she cannot put the +// laptop on charge. An example that says she did teaches the model to invent +// actions Maven never took, which is worse than a missing nudge. const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл"). Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу. -Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. +Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood". "response" — сам текст напоминания. @@ -564,7 +573,7 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет: {"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"} -{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"} +{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"} Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` diff --git a/mavwaked b/mavwaked new file mode 100755 index 0000000..a74d8de Binary files /dev/null and b/mavwaked differ