5187f3bd14
"я выпил воды" 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>
189 lines
7.2 KiB
Go
189 lines
7.2 KiB
Go
package phraser
|
|
|
|
// The capture acknowledgements — what she says after storing something he said,
|
|
// and what she says when storing it failed.
|
|
//
|
|
// They were string literals in eight files under cmd/mavend plus the stub
|
|
// replier in internal/voice. He hears them many times a day, which is exactly
|
|
// why they need variants and exactly why rewording one must not be a rebuild.
|
|
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
|
|
//
|
|
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
|
|
// and the value are interpolated here, so nothing he said lives in the copy.
|
|
|
|
import (
|
|
_ "embed"
|
|
"log"
|
|
"math/rand"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/kami/maven/internal/say"
|
|
)
|
|
|
|
//go:embed ack_ru_v1.json
|
|
var ackJSON []byte
|
|
|
|
// AckSchemaVersion — this family's own version. A file that changes on a
|
|
// different day than the fallbacks cannot share their number (Vikunja #397).
|
|
const AckSchemaVersion = 1
|
|
|
|
// The entry keys. Namespaced by family, because the floor map behind a nil deck
|
|
// is process-wide.
|
|
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"
|
|
AckTask = "ack_task"
|
|
AckTaskUrgent = "ack_task_urgent"
|
|
AckTaskDuplicate = "ack_task_duplicate"
|
|
AckNudge = "ack_nudge"
|
|
AckSnooze = "ack_snooze"
|
|
AckGeneric = "ack_generic"
|
|
AckQuietOn = "quiet_on"
|
|
AckQuietOff = "quiet_off"
|
|
|
|
FailFact = "fail_fact"
|
|
FailFactUnparsed = "fail_fact_unparsed"
|
|
FailNote = "fail_note"
|
|
FailReminder = "fail_reminder"
|
|
FailReminderTime = "fail_reminder_time"
|
|
FailTask = "fail_task"
|
|
FailAck = "fail_ack"
|
|
FailSnooze = "fail_snooze"
|
|
FailQuiet = "fail_quiet"
|
|
)
|
|
|
|
// ackKeys — every key the code requires the file to define.
|
|
var ackKeys = []string{
|
|
AckFact, AckFactKey, AckFactValue, AckFactEcho, AckNote, AckReminder, AckAct,
|
|
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
|
|
AckQuietOn, AckQuietOff,
|
|
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
|
|
FailTask, FailAck, FailSnooze, FailQuiet,
|
|
}
|
|
|
|
// ackFloor — the literal each key falls back to when the file is unusable.
|
|
// These are the exact strings that lived in Go before this file existed.
|
|
var ackFloor = map[string]string{
|
|
AckFact: "записала факт.",
|
|
AckFactKey: "отметила: {key}",
|
|
AckFactValue: "отметила: {key} = {value}",
|
|
AckFactEcho: "записала: {text}",
|
|
AckNote: "сохранила заметку.",
|
|
AckReminder: "напомню.",
|
|
AckAct: "ок, записала действие: {fn}",
|
|
AckTask: "записала: {text}",
|
|
AckTaskUrgent: "поняла, беру в работу: {text}",
|
|
AckTaskDuplicate: "это уже в списке.",
|
|
AckNudge: "отлично, отметила.",
|
|
AckSnooze: "хорошо, вернусь к этому позже.",
|
|
AckGeneric: "приняла.",
|
|
AckQuietOn: "тихий режим включён. буду реже напоминать.",
|
|
AckQuietOff: "тихий режим выключен.",
|
|
|
|
FailFact: "не получилось сохранить факт.",
|
|
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
|
|
FailNote: "не получилось сохранить заметку.",
|
|
FailReminder: "не получилось поставить напоминание.",
|
|
FailReminderTime: "не получилось разобрать время напоминания.",
|
|
FailTask: "не получилось записать задачу.",
|
|
FailAck: "не получилось отметить.",
|
|
FailSnooze: "не получилось отложить.",
|
|
FailQuiet: "не получилось переключить тихий режим.",
|
|
}
|
|
|
|
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
|
type Acks struct{ d *say.Deck }
|
|
|
|
// LoadAcks reads the embedded file. Pass a source to make the picking
|
|
// reproducible in tests; nil seeds from the clock.
|
|
func LoadAcks(src rand.Source) (*Acks, error) {
|
|
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The three entries that exist to read his own words back. A variant
|
|
// without the placeholder would confirm the capture and drop what was
|
|
// 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 {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &Acks{d: d}, nil
|
|
}
|
|
|
|
// deck reads through a nil *Acks, which is the unloadable-file case.
|
|
func (a *Acks) deck() *say.Deck {
|
|
if a == nil {
|
|
return say.FloorDeck(ackFloor)
|
|
}
|
|
return a.d
|
|
}
|
|
|
|
// Say returns one line for key, with his data filled into the frame. Pass nil
|
|
// when the entry takes none.
|
|
func (a *Acks) Say(key string, vars map[string]string) string {
|
|
return a.deck().Text(key, vars)
|
|
}
|
|
|
|
// Variants returns every line the file can produce, for the persona scorer.
|
|
func (a *Acks) Variants() []string { return a.deck().Variants() }
|
|
|
|
var (
|
|
ackOnce sync.Once
|
|
acks *Acks
|
|
)
|
|
|
|
// DefaultAcks returns the shared instance, loading it on first use. A broken
|
|
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
|
|
func DefaultAcks() *Acks {
|
|
ackOnce.Do(func() {
|
|
a, err := LoadAcks(nil)
|
|
if err != nil {
|
|
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
|
|
return
|
|
}
|
|
acks = a
|
|
})
|
|
return 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 {
|
|
return DefaultAcks().deck().Matches(key, vars, text)
|
|
}
|