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 block that Thinking-variant models emit. func stripThink(s string) string { if i := strings.LastIndex(s, ""); 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 } }