Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a24334ea | |||
| c97aebf55a |
+57
-6
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"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
|
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
||||||
// is worse than admitting she missed it.
|
// 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{
|
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||||
router.IntentReminder: {dialogue.SlotTime},
|
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||||
router.IntentFact: {dialogue.SlotKey},
|
router.IntentFact: {dialogue.SlotKey},
|
||||||
router.IntentAct: {dialogue.SlotFn},
|
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
|
// 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.
|
// self-reference lives in the reply she gives when she drops the request.
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
var clarifyQuestions = map[dialogue.Slot]string{
|
||||||
dialogue.SlotTime: "На когда напомнить?",
|
dialogue.SlotTime: "Когда?",
|
||||||
|
dialogue.SlotText: "О чём напомнить?",
|
||||||
dialogue.SlotKey: "Что записать?",
|
dialogue.SlotKey: "Что записать?",
|
||||||
dialogue.SlotFn: "Что сделать?",
|
dialogue.SlotFn: "Что сделать?",
|
||||||
}
|
}
|
||||||
@@ -45,11 +52,55 @@ var clarifyQuestions = map[dialogue.Slot]string{
|
|||||||
// landed. Feminine self-reference ("поняла"), as everywhere.
|
// landed. Feminine self-reference ("поняла"), as everywhere.
|
||||||
const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
||||||
|
|
||||||
// clarifyExpired — his answer came after the TTL, so the parked request is
|
// clarifyExpiredVariants — his answer came after the TTL, so the parked request
|
||||||
// already gone. Same tone as clarifyGaveUp, different reason: too much time
|
// is already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||||
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
||||||
// "отпустила"); he is addressed with a plain imperative.
|
// "отпустила"); 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
|
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||||
// out, and "" when nothing was parked. Call it right after
|
// out, and "" when nothing was parked. Call it right after
|
||||||
@@ -63,7 +114,7 @@ func (h *reactiveHandler) clarifyExpiredNotice() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
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
|
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||||
|
|||||||
@@ -56,10 +56,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
asked bool
|
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},
|
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||||
{"act without a fn", clarifyDec(router.IntentAct, 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},
|
{"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},
|
{"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)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
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)
|
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ 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 != "На когда напомнить?" {
|
if reply != "Когда?" {
|
||||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||||
@@ -304,17 +307,17 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
|||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "как дела")
|
reply := h.handleText(ctx, "как дела")
|
||||||
if !strings.HasPrefix(reply, clarifyExpired) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("expired question must be announced first, got %q", 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)
|
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
// The notice is said once, not on every later utterance.
|
// 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)
|
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func newLLMReplier(c completer, block func() string) *llmReplier {
|
|||||||
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
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": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||||
Никогда не пиши "..." в поле response.`
|
Никогда не пиши "..." в поле response.`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Russian only, feminine self-reference, second person masculine (the owner is
|
||||||
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
||||||
// One short sentence — the nudge is spoken aloud.
|
// 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, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
||||||
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||||
|
|
||||||
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
||||||
|
|
||||||
Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||||
|
|
||||||
Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood".
|
Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood".
|
||||||
"response" — сам текст напоминания.
|
"response" — сам текст напоминания.
|
||||||
@@ -564,7 +573,7 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О
|
|||||||
|
|
||||||
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
||||||
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
||||||
{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"}
|
{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"}
|
||||||
|
|
||||||
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user