Strings family 2: capture acknowledgements into ack_ru_v1.json #110
+3
-2
@@ -16,6 +16,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return "не получилось отметить.", true
|
||||
return phraser.Ack(phraser.FailAck, nil), true
|
||||
}
|
||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "отлично, отметила.", true
|
||||
return phraser.Ack(phraser.AckNudge, nil), true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// it for recall, and let pattern detection propose a routine.
|
||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
factID, err := h.api.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return "не получилось сохранить факт."
|
||||
return phraser.Ack(phraser.FailFact, nil)
|
||||
}
|
||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||
// write). Facts aren't in the notes table, so this is the only recall path
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
if err != nil {
|
||||
log.Printf("voice: write note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return "не получилось разобрать время напоминания."
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
}
|
||||
}
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return "не получилось поставить напоминание."
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
return phraser.Ack(phraser.FailTask, nil), true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||
}
|
||||
return "записала: " + cap.Text, true
|
||||
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||
}
|
||||
|
||||
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||
// expected to match. What must hold is that the reply is a line that entry can
|
||||
// produce, which is the same claim without pinning one wording.
|
||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||
t.Helper()
|
||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||
t.Helper()
|
||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return "не получилось отложить.", true
|
||||
return phraser.Ack(phraser.FailSnooze, nil), true
|
||||
}
|
||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "хорошо, вернусь к этому позже.", true
|
||||
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||
}
|
||||
|
||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
|
kami
commented
Review
```
"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_task": { "variants": ["записала: {text}", "добавила в задачи: {text}"] },
"ack_task_urgent":{ "variants": ["срочное, записала: {text}", "поняла, это срочно: {text}"] },
"ack_task_duplicate": { "variants": ["это уже в списке.", "такое уже есть в задачах."] },
"ack_nudge": { "variants": ["отметила."] },
"ack_snooze": { "variants": ["отложила.", "напомню попозже."] },
"ack_generic": { "variants": ["приняла.", "поняла."] }
```
|
||||
"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": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package phraser
|
||||
|
||||
// deck — the mechanics every family of hand-written Russian lines shares.
|
||||
//
|
||||
// A family is one embedded JSON file: schema-versioned, several variants per
|
||||
// entry, never the same variant twice running, and a hard floor of Go literals
|
||||
// under it so a broken file cannot take her words away. fallbacks.go was the
|
||||
// first family (Vikunja #501) and acks.go the second, at which point copying
|
||||
// eighty lines of loader per family stopped being defensible.
|
||||
//
|
||||
// What stays per family: the file, the keys, the floor literals, the accessor
|
||||
// names, and any validation only that family can state.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deckEntry — one line she can say, in as many wordings as the file gives.
|
||||
type deckEntry struct {
|
||||
// Fixed — one variant, never picked between. For wording that must not
|
||||
// drift from turn to turn, like a phrase naming one specific gap.
|
||||
Fixed bool `json:"fixed"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type deckFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Entries map[string]deckEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A nil *deck answers from the
|
||||
// floor, which is what an unloadable file leaves behind.
|
||||
type deck struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
last map[string]string
|
||||
file deckFile
|
||||
keys []string
|
||||
floor map[string]string
|
||||
}
|
||||
|
||||
// loadDeck parses raw, checks the version and every required key, and seeds the
|
||||
// picker. Pass a source to make the picking reproducible in tests; nil seeds
|
||||
// from the clock.
|
||||
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
|
||||
var f deckFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != version {
|
||||
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
|
||||
}
|
||||
for _, k := range keys {
|
||||
e, ok := f.Entries[k]
|
||||
if !ok || len(e.Variants) == 0 {
|
||||
return nil, fmt.Errorf("entry %q is missing or empty", k)
|
||||
}
|
||||
if e.Fixed && len(e.Variants) != 1 {
|
||||
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
src = rand.NewSource(time.Now().UnixNano())
|
||||
}
|
||||
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
|
||||
}
|
||||
|
||||
// requirePlaceholder fails the load when a variant of key does not use ph. For
|
||||
// an entry whose whole job is to read something back, a variant without the
|
||||
// placeholder silently drops it.
|
||||
func (d *deck) requirePlaceholder(key, ph string) error {
|
||||
for _, v := range d.file.Entries[key].Variants {
|
||||
if !strings.Contains(v, ph) {
|
||||
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// text returns one variant for key with the placeholders filled in. A nil
|
||||
// receiver answers from the floor, so no caller checks whether the file loaded.
|
||||
func (d *deck) text(key string, vars map[string]string) string {
|
||||
tmpl := ""
|
||||
if d != nil {
|
||||
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
|
||||
tmpl = d.pick(key, e)
|
||||
}
|
||||
}
|
||||
if tmpl == "" {
|
||||
tmpl = floorOf(d, key)
|
||||
}
|
||||
return fill(tmpl, vars)
|
||||
}
|
||||
|
||||
// matches reports whether text is a line key could have produced. A caller that
|
||||
// has to recognise one of these lines cannot compare against a literal any more.
|
||||
func (d *deck) matches(key string, vars map[string]string, text string) bool {
|
||||
if fill(floorOf(d, key), vars) == text {
|
||||
return true
|
||||
}
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
for _, v := range d.file.Entries[key].Variants {
|
||||
if fill(v, vars) == text {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// variants returns every line the file can produce, in key order, for the
|
||||
// persona scorer. Stable order so a failure names the same variant twice.
|
||||
func (d *deck) variants() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, k := range d.keys {
|
||||
out = append(out, d.file.Entries[k].Variants...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (d *deck) pick(key string, e deckEntry) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
if v != d.last[key] {
|
||||
fresh = append(fresh, v)
|
||||
}
|
||||
}
|
||||
if len(fresh) > 0 {
|
||||
choices = fresh
|
||||
}
|
||||
}
|
||||
got := choices[d.rnd.Intn(len(choices))]
|
||||
d.last[key] = got
|
||||
return got
|
||||
}
|
||||
|
||||
// floorOf reads the Go literal behind key, and works on a nil deck because that
|
||||
// is exactly the case it exists for. The per-family map is the source of truth.
|
||||
func floorOf(d *deck, key string) string {
|
||||
if d != nil && d.floor != nil {
|
||||
return d.floor[key]
|
||||
}
|
||||
return deckFloors[key]
|
||||
}
|
||||
|
||||
// deckFloors — every family's floor literals in one map, so a nil deck still
|
||||
// finds them. Families register at init; the keys are namespaced by family.
|
||||
var deckFloors = map[string]string{}
|
||||
|
||||
func registerFloor(floor map[string]string) map[string]string {
|
||||
for k, v := range floor {
|
||||
deckFloors[k] = v
|
||||
}
|
||||
return floor
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
// alone rather than blanked, so a missing value is visible instead of silent.
|
||||
func fill(tmpl string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json on the persona
|
||||
// checks the nudges are already held to. These lines are heard out loud, and
|
||||
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
|
||||
// would otherwise reach him with nothing between it and the speaker.
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json and
|
||||
// ack_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// heard out loud and they live in a JSON file now, so a reworded variant that
|
||||
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||
//
|
||||
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
||||
// not nudges: they are what she says when there is no answer.
|
||||
// not nudges.
|
||||
func TestFallbackPersona(t *testing.T) {
|
||||
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
@@ -24,13 +24,20 @@ func TestFallbackPersona(t *testing.T) {
|
||||
CheckLang: true, CheckFeminine: true, CheckHisGender: true,
|
||||
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
||||
}
|
||||
variants := fb.Variants()
|
||||
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
variants := append(fb.Variants(), ack.Variants()...)
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("no variants — the file loaded empty")
|
||||
}
|
||||
for _, v := range variants {
|
||||
// {sources} stands for his own notes and never carries persona of its own.
|
||||
body := strings.ReplaceAll(v, "{sources}", "два литра")
|
||||
// The placeholders stand for his own words and carry no persona.
|
||||
body := v
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} {
|
||||
body = strings.ReplaceAll(body, ph, "вода")
|
||||
}
|
||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||
if persona[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
|
||||
|
||||
@@ -13,16 +13,7 @@ import (
|
||||
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
|
||||
func isFallback(t *testing.T, key, sources, got string) bool {
|
||||
t.Helper()
|
||||
e, ok := DefaultFallbacks().file.Entries[key]
|
||||
if !ok {
|
||||
t.Fatalf("no fallback entry %q", key)
|
||||
}
|
||||
for _, v := range e.Variants {
|
||||
if strings.ReplaceAll(v, "{sources}", sources) == got {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
|
||||
}
|
||||
|
||||
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
||||
|
||||
+23
-123
@@ -5,8 +5,6 @@ package phraser
|
||||
// They were four string literals spread across phraser.go, llmphraser.go and
|
||||
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
|
||||
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
|
||||
// This is the same shape nudges_ru_v1.json already uses for nudges: embedded,
|
||||
// schema-versioned, several variants, and never the same variant twice running.
|
||||
//
|
||||
// The floor under the floor is deliberate. These strings exist because something
|
||||
// already failed, so a broken template file must not be able to take the last
|
||||
@@ -14,13 +12,9 @@ package phraser
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed fallbacks_ru_v1.json
|
||||
@@ -45,132 +39,56 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
||||
|
||||
// hardFloor — 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 hardFloor = map[string]string{
|
||||
var hardFloor = registerFloor(map[string]string{
|
||||
fbChat: "даже не знаю, что сказать.",
|
||||
fbQueryUnknown: "не знаю.",
|
||||
fbQuerySources: "вот что я нашла: {sources}",
|
||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||
}
|
||||
})
|
||||
|
||||
type fallbackEntry struct {
|
||||
// Fixed — one variant, never picked between. For wording that must not drift
|
||||
// from turn to turn, like the gap phrase that names an unavailable model.
|
||||
Fixed bool `json:"fixed"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type fallbackFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Entries map[string]fallbackEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line.
|
||||
//
|
||||
// Safe for concurrent use. Never the same variant twice in a row for the same
|
||||
// entry: hearing the identical words every time a request fails is how a failure
|
||||
// stops registering as one.
|
||||
type Fallbacks struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
last map[string]string
|
||||
file fallbackFile
|
||||
}
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *deck }
|
||||
|
||||
// LoadFallbacks reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||
var f fallbackFile
|
||||
if err := json.Unmarshal(fallbackJSON, &f); err != nil {
|
||||
return nil, fmt.Errorf("fallbacks: parse: %w", err)
|
||||
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.SchemaVersion != FallbackSchemaVersion {
|
||||
return nil, fmt.Errorf("fallbacks: schema_version %d, want %d",
|
||||
f.SchemaVersion, FallbackSchemaVersion)
|
||||
// query_sources is the one entry whose whole job is to read something back.
|
||||
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, k := range fbKeys {
|
||||
e, ok := f.Entries[k]
|
||||
if !ok || len(e.Variants) == 0 {
|
||||
return nil, fmt.Errorf("fallbacks: entry %q is missing or empty", k)
|
||||
}
|
||||
if e.Fixed && len(e.Variants) != 1 {
|
||||
return nil, fmt.Errorf("fallbacks: entry %q is fixed but has %d variants", k, len(e.Variants))
|
||||
}
|
||||
}
|
||||
// query_sources is the one entry whose whole job is to read something back,
|
||||
// so a variant without the placeholder would silently drop the sources.
|
||||
for _, v := range f.Entries[fbQuerySources].Variants {
|
||||
if !strings.Contains(v, "{sources}") {
|
||||
return nil, fmt.Errorf("fallbacks: %q variant %q does not use {sources}", fbQuerySources, v)
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
src = rand.NewSource(time.Now().UnixNano())
|
||||
}
|
||||
return &Fallbacks{rnd: rand.New(src), last: map[string]string{}, file: f}, nil
|
||||
return &Fallbacks{d: d}, nil
|
||||
}
|
||||
|
||||
// text returns one variant for key, with {sources} filled in. A nil receiver is
|
||||
// the unloadable-file case and answers from hardFloor, so the caller never has
|
||||
// to check whether the templates loaded.
|
||||
func (f *Fallbacks) text(key, sources string) string {
|
||||
tmpl := hardFloor[key]
|
||||
if f != nil {
|
||||
if e, ok := f.file.Entries[key]; ok && len(e.Variants) > 0 {
|
||||
tmpl = f.pick(key, e)
|
||||
}
|
||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||
func (f *Fallbacks) deck() *deck {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return strings.ReplaceAll(tmpl, "{sources}", sources)
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (f *Fallbacks) pick(key string, e fallbackEntry) string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
if v != f.last[key] {
|
||||
fresh = append(fresh, v)
|
||||
}
|
||||
}
|
||||
if len(fresh) > 0 {
|
||||
choices = fresh
|
||||
}
|
||||
}
|
||||
got := choices[f.rnd.Intn(len(choices))]
|
||||
f.last[key] = got
|
||||
return got
|
||||
return f.d
|
||||
}
|
||||
|
||||
// Chat — nothing usable came back on the chat path.
|
||||
func (f *Fallbacks) Chat() string { return f.text(fbChat, "") }
|
||||
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
|
||||
|
||||
// Unknown — a question she cannot answer and will not guess at.
|
||||
func (f *Fallbacks) Unknown() string { return f.text(fbQueryUnknown, "") }
|
||||
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
|
||||
|
||||
// FromSources — read back what she was handed, because phrasing it failed.
|
||||
func (f *Fallbacks) FromSources(sources string) string {
|
||||
return f.text(fbQuerySources, sources)
|
||||
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
|
||||
}
|
||||
|
||||
// WorldGap — the world model is the one configured to answer and it is not
|
||||
// answering. Fixed wording: it names a specific gap, and a variant set here
|
||||
// would let "the big model is asleep" drift into "I don't know".
|
||||
func (f *Fallbacks) WorldGap() string { return f.text(fbWorldGap, "") }
|
||||
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
// Order is stable so a failure names the same variant twice running.
|
||||
func (f *Fallbacks) Variants() []string {
|
||||
var out []string
|
||||
for _, k := range fbKeys {
|
||||
out = append(out, f.file.Entries[k].Variants...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
|
||||
|
||||
// The process-wide instance. Package-level because these lines are needed on
|
||||
// paths that have no phraser to hand — cmd/mavend names the world gap without
|
||||
@@ -208,31 +126,13 @@ func SourcesFallback(sources string) string { return DefaultFallbacks().FromSour
|
||||
// WorldGap — what he hears when the world model is configured and unreachable.
|
||||
func WorldGap() string { return DefaultFallbacks().WorldGap() }
|
||||
|
||||
// matches reports whether text is a line the given entry could have produced.
|
||||
// A caller that has to recognise a fallback cannot compare against one literal
|
||||
// any more, because the entry picks between variants.
|
||||
func (f *Fallbacks) matches(key, sources, text string) bool {
|
||||
if strings.ReplaceAll(hardFloor[key], "{sources}", sources) == text {
|
||||
return true
|
||||
}
|
||||
if f == nil {
|
||||
return false
|
||||
}
|
||||
for _, v := range f.file.Entries[key].Variants {
|
||||
if strings.ReplaceAll(v, "{sources}", sources) == text {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
|
||||
// The daemon tests read it to tell an answer from a shrug.
|
||||
func IsUnknownFallback(text string) bool {
|
||||
return DefaultFallbacks().matches(fbQueryUnknown, "", text)
|
||||
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
|
||||
}
|
||||
|
||||
// IsSourcesFallback reports whether text is sources read back verbatim.
|
||||
func IsSourcesFallback(text, sources string) bool {
|
||||
return DefaultFallbacks().matches(fbQuerySources, sources, text)
|
||||
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
|
||||
}
|
||||
|
||||
@@ -55,3 +55,20 @@ func TestFallbacksDoNotRepeat(t *testing.T) {
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
// The acknowledgements load, fill his words into the frame, and answer from the
|
||||
// floor when the file is gone.
|
||||
func TestAcksLoad(t *testing.T) {
|
||||
a, err := LoadAcks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
|
||||
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
|
||||
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
|
||||
}
|
||||
var nilAcks *Acks
|
||||
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
|
||||
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -25,7 +25,10 @@
|
||||
// the daemon seam (config wiring, no CoreAPI or voice-package change).
|
||||
package voice
|
||||
|
||||
import "github.com/kami/maven/internal/router"
|
||||
import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
|
||||
// calls Reply with the router's Decision; the impl produces a terse reply
|
||||
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
|
||||
if !d.Slots.HasFn {
|
||||
return "не могу это сделать — не разобрала действие."
|
||||
}
|
||||
return "ок, записала действие: " + d.Slots.Fn
|
||||
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
|
||||
case router.IntentReminder:
|
||||
if d.Slots.HasTime {
|
||||
return "напомню."
|
||||
}
|
||||
return "напомню."
|
||||
return phraser.Ack(phraser.AckReminder, nil)
|
||||
case router.IntentFact:
|
||||
if d.Slots.HasKey {
|
||||
if d.Slots.Value != "" {
|
||||
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
|
||||
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
|
||||
}
|
||||
return "отметила: " + d.Slots.Key
|
||||
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
|
||||
}
|
||||
return "записала факт."
|
||||
return phraser.Ack(phraser.AckFact, nil)
|
||||
case router.IntentNote:
|
||||
return "сохранила заметку."
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
case router.IntentQuery:
|
||||
return "поискала в заметках — ничего не нашла."
|
||||
case router.IntentChat:
|
||||
return "поговорили." // stub — LLMReplier replaces this
|
||||
default:
|
||||
return "приняла."
|
||||
return phraser.Ack(phraser.AckGeneric, nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user