a captured fact is confirmed in his words, not the model's (V-592)

"я выпил воды" came back as "Проверила, что ты выпел стакан воды". The
verb is not a Russian word, the glass was never mentioned, and nothing
had been checked.

The store was right throughout: DefaultFactParser files this as
key=water value="drank", and no row anywhere held "стакан". Every
Russian word in that sentence was generated. replyContext hands the
model "записала факт: water \"drank\"", so the model had nothing to
phrase FROM and reached for the nearest plausible sentence — the example
in ReplySystemPrompt, which was literally "Записала, что ты выпил стакан
воды."

So the fact path stops generating, the way the note payload did in
V-576. The confirmation is a fixed deck frame with his own sentence in
it, in both repliers, and the prompt example is contentless now. The
stub also read the parser's KEY back at him, which is machine
vocabulary he never said.

The clarify half of this — a fact clarified out of "запиши" answers with
"запиши" and nothing else — lands with V-593, which touches the same
lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 02:58:22 +04:00
parent 6ec4220668
commit 5187f3bd14
7 changed files with 54 additions and 15 deletions
+8
View File
@@ -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)
+2 -2
View File
@@ -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"]
},
{
+3 -3
View File
@@ -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"]
},
{
+6 -1
View File
@@ -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": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
},
+25 -1
View File
@@ -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 {
+6 -1
View File
@@ -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
+4 -7
View File
@@ -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: