From dae123adac9a3f90573c9ae9f91d83f00c72d7f6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:26:52 +0400 Subject: [PATCH] phraser: put the capture acknowledgements in a versioned json (V-502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What she says after storing something he said, and what she says when storing it failed. They were literals in eight files under cmd/mavend and the stub replier. He hears these many times a day, which is why most entries carry variants: identical wording is what makes a confirmation stop registering as one. The quiet-mode lines are fixed — they report a state, and a state report that reworded itself would read as a different state. His data stays Go-side. The file holds "отметила: {key} = {value}"; nothing he said lives in the copy. --- internal/phraser/ack_ru_v1.json | 84 +++++++++++++++++ internal/phraser/acks.go | 162 ++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 internal/phraser/ack_ru_v1.json create mode 100644 internal/phraser/acks.go diff --git a/internal/phraser/ack_ru_v1.json b/internal/phraser/ack_ru_v1.json new file mode 100644 index 0000000..01f5254 --- /dev/null +++ b/internal/phraser/ack_ru_v1.json @@ -0,0 +1,84 @@ +{ + "schema_version": 1, + "name": "russian capture acknowledgements v1", + "notes": [ + "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.", + "An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored." + ], + "entries": { + "ack_fact": { + "variants": ["записала факт.", "записала.", "запомнила."] + }, + "ack_fact_key": { + "variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"] + }, + "ack_fact_kv": { + "variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"] + }, + "ack_note": { + "variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."] + }, + "ack_reminder": { + "variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."] + }, + "ack_act": { + "variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"] + }, + "ack_task": { + "variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"] + }, + "ack_task_urgent": { + "variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"] + }, + "ack_task_duplicate": { + "variants": ["это уже в списке.", "такое уже есть в задачах."] + }, + "ack_nudge": { + "variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."] + }, + "ack_snooze": { + "variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."] + }, + "ack_generic": { + "variants": ["приняла.", "поняла."] + }, + "quiet_on": { + "fixed": true, + "variants": ["тихий режим включён. буду реже напоминать."] + }, + "quiet_off": { + "fixed": true, + "variants": ["тихий режим выключен."] + }, + "fail_fact": { + "variants": ["не получилось сохранить факт.", "факт не сохранился."] + }, + "fail_note": { + "variants": ["не получилось сохранить заметку.", "заметка не сохранилась."] + }, + "fail_reminder": { + "variants": ["не получилось поставить напоминание.", "напоминание не поставилось."] + }, + "fail_reminder_time": { + "variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."] + }, + "fail_task": { + "variants": ["не получилось записать задачу.", "задача не записалась."] + }, + "fail_ack": { + "variants": ["не получилось отметить.", "не смогла отметить."] + }, + "fail_snooze": { + "variants": ["не получилось отложить.", "не смогла отложить."] + }, + "fail_quiet": { + "variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."] + }, + "fail_fact_unparsed": { + "variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"] + } + } +} diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go new file mode 100644 index 0000000..20c0be4 --- /dev/null +++ b/internal/phraser/acks.go @@ -0,0 +1,162 @@ +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" + "sync" +) + +//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" + 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, 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 = registerFloor(map[string]string{ + AckFact: "записала факт.", + AckFactKey: "отметила: {key}", + AckFactValue: "отметила: {key} = {value}", + 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 *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 := loadDeck(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}"}, + {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() *deck { + if a == nil { + return nil + } + 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) } + +// 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) +}