b9a24334ea
Wording fixes from the review of the clarify + phrasing PRs. - "На когда напомнить?" → "Когда?". After she has just been asked something, the long form is the phrasing of a form field, not of a person. - A reminder now wants a subject as well as a time. "напомни в 11" had a time and nothing to say at 11, and she asked nothing at all — she now asks "О чём напомнить?". Subject first, since a reminder with no subject is not worth setting. - The expiry notice is five phrasings picked at random instead of one fixed sentence. It is the line he hears every time he walks off mid-request, so it is the line that repeats most. - The nudge prompt's ban on "обращения" is now "ласковые обращения". It was meant to forbid "милый"/"дорогой", not his name — "Ками, ноутбук на трёх процентах" is how she talks, and the eval's cringe check already only flags pet names. - The nudge example no longer claims she plugged the laptop in. She has no hands and no smart plug; an example where she acts teaches the model to invent actions Maven never took. - replySystem: "тепло" → "спокойно и без официальных формулировок". A one-word mood instruction a 1.7B can't act on, replaced with the behaviour meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
122 lines
4.3 KiB
Go
122 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/llm"
|
|
"github.com/kami/maven/internal/persona"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
// completer is the LLM seam for the replier (subset of router.Completer).
|
|
// *llm.Client satisfies it.
|
|
type completer interface {
|
|
Complete(ctx context.Context, r llm.Req) (string, error)
|
|
}
|
|
|
|
// llmReplier phrases reactive confirmations with the resident model
|
|
// (Qwen3-1.7B). Stub is the
|
|
// floor on any error (offline-safe). Maven speaks as "she", feminine RU.
|
|
type llmReplier struct {
|
|
c completer
|
|
stub *voice.StubReplier
|
|
|
|
// block renders the shared context block per turn (who he is, the time).
|
|
// nil ⇒ the prompt stands alone.
|
|
block func() string
|
|
}
|
|
|
|
func newLLMReplier(c completer, block func() string) *llmReplier {
|
|
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
|
}
|
|
|
|
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
|
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
|
Никогда не пиши "..." в поле response.`
|
|
|
|
func (r *llmReplier) Reply(d router.Decision) string {
|
|
if d.Clarify {
|
|
return r.stub.Reply(d)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
out, err := r.c.Complete(ctx, llm.Req{
|
|
System: persona.Prepend(r.block, replySystem),
|
|
User: replyContext(d),
|
|
MaxTokens: 512,
|
|
RepeatPenalty: 1.3,
|
|
})
|
|
if err != nil {
|
|
return r.stub.Reply(d)
|
|
}
|
|
out = stripThink(out)
|
|
if response, _ := parseResponseMood(out); response != "" {
|
|
return response
|
|
}
|
|
// fallback: try plain-text parsing
|
|
if out = firstSentence(out); out != "" {
|
|
return out
|
|
}
|
|
return r.stub.Reply(d)
|
|
}
|
|
|
|
// firstSentence trims the model's output to a single clean confirmation: first
|
|
// line, first sentence, whitespace-normalized — the last-line defense against a
|
|
// small model that rambles past the first period despite the prompt + stop.
|
|
// stripThink removes the <think> block that Thinking-variant models emit.
|
|
func stripThink(s string) string {
|
|
if i := strings.LastIndex(s, "</think>"); i >= 0 {
|
|
s = strings.TrimSpace(s[i+8:])
|
|
}
|
|
return s
|
|
}
|
|
|
|
func firstSentence(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
// keep up to and including the first sentence-ending punctuation.
|
|
if i := strings.IndexAny(s, ".!?"); i >= 0 {
|
|
s = s[:i+1]
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
|
// of thinking tokens and extra text before/after the JSON block.
|
|
func parseResponseMood(raw string) (response, mood string) {
|
|
cleaned := strings.TrimSpace(raw)
|
|
start := strings.Index(cleaned, "{")
|
|
end := strings.LastIndex(cleaned, "}")
|
|
if start < 0 || end < 0 || end <= start {
|
|
return "", ""
|
|
}
|
|
var parsed struct {
|
|
Response string `json:"response"`
|
|
Mood string `json:"mood"`
|
|
}
|
|
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
|
return "", ""
|
|
}
|
|
return parsed.Response, parsed.Mood
|
|
}
|
|
|
|
// replyContext renders the decision into a compact RU description for the model.
|
|
func replyContext(d router.Decision) string {
|
|
switch d.Intent {
|
|
case router.IntentFact:
|
|
return "записала факт: " + d.Slots.Key + " " + d.Slots.Value
|
|
case router.IntentNote:
|
|
return "сохранила заметку: " + d.Slots.Text
|
|
case router.IntentReminder:
|
|
return "поставила напоминание: " + d.Slots.Text
|
|
default:
|
|
return string(d.Intent) + ": " + d.Slots.Text
|
|
}
|
|
}
|