c0c11cd36d
- Add llmphraser: LFM-based phraser implementing Phraser interface with PhraseChat, PhraseNudge, PhraseReactive, and PhraseReminder methods. - Add shared internal/llm/client: llama-server completion client used by both the phraser (talking back) and router (routing), sharing one model. - Add LLMReplier in mavend: replaces StubReplier for chat/nudge/reactive replies, falls back to stub on model errors. - Update Phraser interface: add PhraseChat method, update stub to match. - Wire LLM phaser into mavend voice init, plumb LLM config from JSON.
79 lines
2.6 KiB
Go
79 lines
2.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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 LFM. 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 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Без кавычек и пояснений.`
|
|
|
|
func (r *llmReplier) Reply(d router.Decision) string {
|
|
if d.Clarify {
|
|
return r.stub.Reply(d)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
defer cancel()
|
|
out, err := r.c.Complete(ctx, llm.Req{
|
|
System: replySystem,
|
|
User: replyContext(d),
|
|
MaxTokens: 48,
|
|
RepeatPenalty: 1.3, // curb the sub-1B token loop ("тоже тоже тоже")
|
|
Stop: []string{"\n"},
|
|
})
|
|
if out = firstSentence(out); err != nil || out == "" {
|
|
return r.stub.Reply(d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|