diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index 237f528..cde52e2 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -31,6 +31,14 @@ func (r *llmReplier) Reply(d router.Decision) string { // a generation to say something this small. return clarifyMissedLine(d) } + if d.Intent == router.IntentFact { + // A captured fact is confirmed by echoing him, and the model is not + // asked (V-592). It has nothing to phrase FROM: replyContext hands it + // "записала факт: water \"drank\"", so every Russian word in the reply + // was the model's own invention, and on 2026-08-06 that was "Проверила, + // что ты выпел стакан воды" for "я выпил воды". + return phraser.FactAck(d.Utterance) + } out, err := r.p.PhraseReply(context.Background(), d) if err != nil || out == "" { return r.stub.Reply(d) diff --git a/cmd/mavend/testdata/scenarios/evening_degraded.json b/cmd/mavend/testdata/scenarios/evening_degraded.json index 5d55974..66f4ade 100644 --- a/cmd/mavend/testdata/scenarios/evening_degraded.json +++ b/cmd/mavend/testdata/scenarios/evening_degraded.json @@ -24,8 +24,8 @@ "at": "21:00", "note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.", "audio": "ru_fact", - "expect_reply_contains": ["записала"], - "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"], + "expect_reply_contains": ["записала", "выпил воды"], + "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"], "expect_events": ["water"] }, { diff --git a/cmd/mavend/testdata/scenarios/morning_missed.json b/cmd/mavend/testdata/scenarios/morning_missed.json index 3302812..10a4a6c 100644 --- a/cmd/mavend/testdata/scenarios/morning_missed.json +++ b/cmd/mavend/testdata/scenarios/morning_missed.json @@ -81,10 +81,10 @@ }, { "at": "08:55", - "note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".", + "note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".", "say": "я выпил воды", - "expect_reply_contains": ["записала"], - "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"], + "expect_reply_contains": ["записала", "я выпил воды"], + "expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"], "expect_events": ["water"] }, { diff --git a/internal/phraser/ack_ru_v1.json b/internal/phraser/ack_ru_v1.json index 01f5254..56350ce 100644 --- a/internal/phraser/ack_ru_v1.json +++ b/internal/phraser/ack_ru_v1.json @@ -5,7 +5,8 @@ "What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", "He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.", - "Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.", + "Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title or, in ack_fact_echo, his own sentence. His data is interpolated Go-side — the file holds the frame, never his words.", + "ack_fact_echo is the one entry with a single variant, deliberately: what varies in it is his own sentence, which is different every time, and the frame around it is what the simulator scenarios read back.", "An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored." ], "entries": { @@ -18,6 +19,10 @@ "ack_fact_kv": { "variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"] }, + "ack_fact_echo": { + "fixed": true, + "variants": ["записала: {text}"] + }, "ack_note": { "variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."] }, diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index c860663..194114b 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -15,6 +15,7 @@ import ( _ "embed" "log" "math/rand" + "strings" "sync" "github.com/kami/maven/internal/say" @@ -33,6 +34,7 @@ const ( AckFact = "ack_fact" AckFactKey = "ack_fact_key" AckFactValue = "ack_fact_kv" + AckFactEcho = "ack_fact_echo" AckNote = "ack_note" AckReminder = "ack_reminder" AckAct = "ack_act" @@ -58,7 +60,7 @@ const ( // ackKeys — every key the code requires the file to define. var ackKeys = []string{ - AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct, + AckFact, AckFactKey, AckFactValue, AckFactEcho, AckNote, AckReminder, AckAct, AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric, AckQuietOn, AckQuietOff, FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime, @@ -71,6 +73,7 @@ var ackFloor = map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", + AckFactEcho: "записала: {text}", AckNote: "сохранила заметку.", AckReminder: "напомню.", AckAct: "ок, записала действие: {fn}", @@ -109,6 +112,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { // captured, which reads as a successful save of nothing. for _, req := range []struct{ key, ph string }{ {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, + {AckFactEcho, "{text}"}, {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, } { if err := d.RequirePlaceholder(req.key, req.ph); err != nil { @@ -157,6 +161,26 @@ func DefaultAcks() *Acks { // Ack — one acknowledgement line, the way every caller says it. func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) } +// FactAck — the confirmation for a captured fact, in the words he used (V-592). +// +// It is a deck line with his sentence dropped into it, and there is no +// generation anywhere on this path. Asking a 1.7B to say his sentence back +// produced "Проверила, что ты выпел стакан воды" for "я выпил воды": a non-word +// for the verb, a glass he never mentioned — lifted straight out of the example +// in ReplySystemPrompt — and a claim to have checked something. The fact store +// held key=water value="drank" throughout, so nothing was mis-captured and +// everything after the capture was invented. +// +// An empty utterance falls back to the contentless line rather than confirming +// a capture of nothing. +func FactAck(utterance string) string { + utterance = strings.TrimSpace(utterance) + if utterance == "" { + return Ack(AckFact, nil) + } + return Ack(AckFactEcho, map[string]string{"text": utterance}) +} + // IsAck reports whether text is a line key could have produced. For the daemon // tests, which can no longer compare against one literal. func IsAck(key string, vars map[string]string, text string) bool { diff --git a/internal/phraser/replier.go b/internal/phraser/replier.go index b61246a..a8ae7f7 100644 --- a/internal/phraser/replier.go +++ b/internal/phraser/replier.go @@ -30,8 +30,13 @@ const replyTimeout = 60 * time.Second // ReplySystemPrompt — the reactive confirmation contract: one short Russian // sentence, feminine self-reference, informal address, no question. +// +// The example is deliberately contentless. It used to be "Записала, что ты +// выпил стакан воды.", and the model copied the glass into a real reply about +// water he never described that way (V-592). An example carrying a plausible +// completion of the input is an invitation to reuse it. const ReplySystemPrompt = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). -Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"} +Пример: {"response": "Хорошо, напомню.", "mood": "neutral"} Никогда не пиши "..." в поле response.` // Replier phrases reactive confirmations with the resident model. It has no diff --git a/internal/voice/replier.go b/internal/voice/replier.go index b1c5cca..ef05de0 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -67,13 +67,10 @@ func (s *StubReplier) Reply(d router.Decision) string { case router.IntentReminder: return phraser.Ack(phraser.AckReminder, nil) case router.IntentFact: - if d.Slots.HasKey { - if d.Slots.Value != "" { - return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value}) - } - return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key}) - } - return phraser.Ack(phraser.AckFact, nil) + // His words, not the key the parser filed them under (V-592). The key + // is machine vocabulary — "water", "meal" — and reading it back was + // never a confirmation he could check. + return phraser.FactAck(d.Utterance) case router.IntentNote: return phraser.Ack(phraser.AckNote, nil) case router.IntentQuery: