eef5d4da4f
The prompts stated the feminine self-reference rule but never said whom she is
speaking to, so the model produced formal plural ("Жду вас") and talked about
him in third person ("Он не ел 11 дней"). Adds the address rule right next to
the feminine one, in the nudge prompt and the confirmation prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
117 lines
4.0 KiB
Go
117 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/llm"
|
|
"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
|
|
}
|
|
|
|
func newLLMReplier(c completer) *llmReplier {
|
|
return &llmReplier{c: c, stub: voice.NewStubReplier()}
|
|
}
|
|
|
|
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: 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
|
|
}
|
|
}
|