feat: {response,mood} output contract + router removal, TTS piper plan
Daemon side of Decision B: parse {"response","mood"} across the 4 consumers
(replier, nudges, reminders, chat), fall back to legacy formats. Drop the
LLM router — the classifier handles routing; replier/phraser share one
llm.Client (timeout 20s->60s). llm.Client reads reasoning_content when
content is empty (thinking models).
Docs: TTS piper-student plan (OmniVoice teacher -> piper student, from
scratch, phoneme-first). CLAUDE.md training guide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,30 +28,45 @@ func newLLMReplier(c completer) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier()}
|
||||
}
|
||||
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Без кавычек и пояснений.`
|
||||
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(), 8*time.Second)
|
||||
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: 48,
|
||||
RepeatPenalty: 1.3, // curb the sub-1B token loop ("тоже тоже тоже")
|
||||
Stop: []string{"\n"},
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.3,
|
||||
})
|
||||
if out = firstSentence(out); err != nil || out == "" {
|
||||
if err != nil {
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
return out
|
||||
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 {
|
||||
@@ -63,6 +79,25 @@ func firstSentence(s string) string {
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,14 @@ type mockCompleter struct{ out string; err error }
|
||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||
|
||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`})
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"})
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
|
||||
+7
-7
@@ -192,15 +192,12 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
log.Printf("voice: weather provider: stub (not configured)")
|
||||
}
|
||||
|
||||
// ----- LLM router (when the phraser is backed by a real model) -----
|
||||
// Both the router and the replier share the same *llm.Client; we build
|
||||
// it here from the phraser's base URL.
|
||||
var llmRouter *router.LLMRouter
|
||||
// The replier uses the same llama-server as the phraser.
|
||||
var llmClient *llm.Client
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
llmClient = llm.New(lp.BaseURL(), 20*time.Second)
|
||||
llmRouter = router.NewLLMRouter(llmClient)
|
||||
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
|
||||
}
|
||||
// LLM router disabled — the classifier handles routing reliably.
|
||||
|
||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||
@@ -209,7 +206,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
if threshold <= 0 {
|
||||
threshold = config.DefaultRouterThreshold
|
||||
}
|
||||
rtr := buildRouter(emb, matcher, threshold, llmRouter)
|
||||
rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled
|
||||
|
||||
// ----- sessions registry (shared with voicesink) -----
|
||||
sessions := voice.NewSessions()
|
||||
@@ -443,6 +440,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
// (and eventually by telegram). Splits out the audio bookends from
|
||||
// HandlePushToTalk so text channels share the same routing logic.
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
log.Printf("voice: handleText: %q", text)
|
||||
// 1b. confirm turn — if a destructive act is parked, this utterance is its
|
||||
// y/n answer. Same check as HandlePushToTalk.
|
||||
if reply, handled := h.resolveConfirm(ctx, text); handled {
|
||||
@@ -458,6 +456,7 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
log.Printf("voice: handleText router error: %v", err)
|
||||
return "не получилось разобрать команду."
|
||||
}
|
||||
log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots)
|
||||
|
||||
// 2b. dialogue — same as HandlePushToTalk.
|
||||
if h.dialogueSessions != nil {
|
||||
@@ -494,6 +493,7 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
|
||||
// 3. action — execute the decision's intent.
|
||||
replyText := h.applyAction(ctx, dec)
|
||||
log.Printf("voice: applyAction returned: %q", replyText)
|
||||
|
||||
// 4. replier — phrase the reply when applyAction returned "".
|
||||
if replyText == "" {
|
||||
|
||||
Reference in New Issue
Block a user