76a6a007ef
The most load-bearing decision in the project was stated four incompatible ways: the docs said Qwen3-1.7B, deploy/mavend.json said Qwen3.5-2B, the repo's models/llm/ held an LFM2.5-1.2B gguf, and five code comments still said LFM. Answering "which model is deployed" meant re-deriving it from scratch every time. Two facts the review missed, found while resolving it: - /mnt/hdd1/llms is bind-mounted over /opt/maven/models/llm, which shadows the repo's models/llm/. The LFM2.5 gguf sitting there was never loaded by anything, so it was not evidence of the deployed model at all. - That library holds Qwen3.5-0.8B, -2B and -4B, and no Qwen3-1.7B. The config pointed at a file that does exist; the docs' Qwen3-1.7B was the stale claim, the reverse of the assumed direction. Qwen3-1.7B is the CPT target, and that training is still in flight (Vikunja #122), so no such gguf exists yet. phraser.model_path moves to Qwen3.5-0.8B (Q4_K_M) — the smallest checkpoint on disk, chosen for latency, and relevant to whether the LLM router is affordable on this box. Docs and comments now say the same thing in one voice: 0.8B resident now, CPT'd Qwen3-1.7B as the target, and the bind-mount shadowing written down so the next reader does not mistake models/llm/ for ground truth. Comments name the model, never a filename, so a swap stays a one-line config change. n_gpu_layers: 99 is correct and stays — compose passes /dev/dri and the render gid for Vulkan offload to the Vega iGPU. CLAUDE.md's "CPU-only" was the stale half of that contradiction and is corrected. phraser.go also dropped a wrong "sub-1b, prompted not trained" size claim: the target is trained end-to-end (RU CPT + joint persona/router SFT). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
115 lines
3.6 KiB
Go
115 lines
3.6 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 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
|
|
|
|
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
|
|
}
|
|
}
|