6a5121657a
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>
506 lines
15 KiB
Go
506 lines
15 KiB
Go
package phraser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/loop"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
|
|
|
type LLMPhraser struct {
|
|
cfg Config
|
|
client *http.Client
|
|
port string
|
|
cmd *exec.Cmd
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
type Config struct {
|
|
ModelPath string
|
|
BinPath string
|
|
Listen string
|
|
NGpuLayers int
|
|
NCtx int
|
|
Timeout time.Duration
|
|
Persona string // optional prompt prefix tuning maven's character
|
|
}
|
|
|
|
func DefaultConfig(modelPath string) Config {
|
|
return Config{
|
|
ModelPath: modelPath,
|
|
BinPath: "llama-server",
|
|
Listen: "127.0.0.1:0",
|
|
NGpuLayers: -1,
|
|
NCtx: 2048,
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
p := &LLMPhraser{
|
|
cfg: cfg,
|
|
client: &http.Client{Timeout: cfg.Timeout},
|
|
cancel: cancel,
|
|
}
|
|
if err := p.start(ctx); err != nil {
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (p *LLMPhraser) start(ctx context.Context) error {
|
|
args := []string{
|
|
"-m", p.cfg.ModelPath,
|
|
"--host", "127.0.0.1",
|
|
"--port", extractPort(p.cfg.Listen),
|
|
"-c", fmt.Sprintf("%d", p.cfg.NCtx),
|
|
"-ngl", fmt.Sprintf("%d", p.cfg.NGpuLayers),
|
|
"--no-webui",
|
|
}
|
|
cmd := exec.CommandContext(ctx, p.cfg.BinPath, args...)
|
|
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
|
// ANY means, including SIGKILL/OOM/panic where our Close() never runs. Without
|
|
// it a hard-killed mavend orphans its llama-server (reparented to init, keeps
|
|
// eating GPU/RAM); repeated dev restarts pile up orphans until the box OOMs.
|
|
// Setpgid isolates it in its own process group so a stray Ctrl-C on the
|
|
// terminal group doesn't half-kill it out from under us. (Linux-only, like
|
|
// the rest of the daemon.)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
|
|
p.cmd = cmd
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("llm: stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
stderr.Close()
|
|
return fmt.Errorf("llm: start: %w", err)
|
|
}
|
|
|
|
portCh := make(chan string, 1)
|
|
errCh := make(chan error, 1)
|
|
p.wg.Add(1)
|
|
go func() {
|
|
defer p.wg.Done()
|
|
buf := make([]byte, 4096)
|
|
var leftover []byte
|
|
for {
|
|
n, err := stderr.Read(buf)
|
|
if n > 0 {
|
|
data := append(leftover, buf[:n]...)
|
|
lines := bytes.Split(data, []byte("\n"))
|
|
for _, line := range lines[:len(lines)-1] {
|
|
if m := listenRE.FindSubmatch(line); len(m) > 1 {
|
|
addr := string(m[1])
|
|
portCh <- addr
|
|
close(portCh)
|
|
}
|
|
}
|
|
leftover = lines[len(lines)-1]
|
|
}
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case addr := <-portCh:
|
|
p.port = addr
|
|
return nil
|
|
case err := <-errCh:
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return fmt.Errorf("llm: server output: %w", err)
|
|
case <-ctx.Done():
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return ctx.Err()
|
|
case <-time.After(60 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
return fmt.Errorf("llm: server did not start within 60s")
|
|
}
|
|
}
|
|
|
|
func (p *LLMPhraser) BaseURL() string { return p.port }
|
|
|
|
func (p *LLMPhraser) Close() error {
|
|
p.cancel()
|
|
if p.cmd != nil && p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
_ = p.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
|
}
|
|
p.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
|
prompt := buildNudgePrompt(c)
|
|
resp, err := p.chat(ctx, prompt)
|
|
if err != nil {
|
|
return delivery.PhrasedNudge{}, err
|
|
}
|
|
body, mood := parseResponseMood(resp)
|
|
if body == "" {
|
|
// fallback: try old body/summary format
|
|
body, _ = parsePhrase(resp)
|
|
}
|
|
if body == "" {
|
|
body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity))
|
|
}
|
|
if mood == "" {
|
|
mood = "neutral"
|
|
}
|
|
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil
|
|
}
|
|
|
|
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
|
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
|
// LLM error — better to give the raw data than silence.
|
|
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
|
if len(notes) == 0 {
|
|
// General knowledge — no notes to ground the answer. The system
|
|
// prompt is the single tested source in router.KnowledgePrompt.
|
|
sys := router.KnowledgePrompt()
|
|
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
|
if err != nil || resp == "" {
|
|
return "не знаю.", nil
|
|
}
|
|
if text, _ := parseResponseMood(resp); text != "" {
|
|
return text, nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
if len(notes) == 1 {
|
|
notes[0] = strings.TrimSpace(notes[0])
|
|
}
|
|
sys := p.querySystemPrompt()
|
|
prompt := fmt.Sprintf(
|
|
`The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`,
|
|
utterance, strings.Join(notes, `"; "`),
|
|
)
|
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
|
if err != nil {
|
|
if len(notes) == 1 {
|
|
return "вот что я нашла: " + notes[0], nil
|
|
}
|
|
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
|
}
|
|
if text, _ := parseResponseMood(resp); text != "" {
|
|
return text, nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
|
// message array from dialogue history + the current user utterance. Falls back
|
|
// to a simple greeting on any LLM error — better to say something than nothing.
|
|
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
|
sys := chatSystemPrompt(p.cfg.Persona)
|
|
msgs := []chatMsg{
|
|
{Role: "system", Content: sys},
|
|
}
|
|
// Combine history and current utterance into one user message.
|
|
// Some model chat templates (Ministral, etc.) reject consecutive user turns.
|
|
var combined string
|
|
for _, t := range history {
|
|
combined += t.Text + "\n"
|
|
}
|
|
combined += utterance
|
|
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
|
|
|
resp, err := p.chatWithMessages(ctx, msgs, 512)
|
|
if err != nil {
|
|
log.Printf("phraser: PhraseChat: %v", err)
|
|
return "поговорили.", nil
|
|
}
|
|
if text, _ := parseResponseMood(resp); text != "" {
|
|
return text, nil
|
|
}
|
|
// fallback: plain text without JSON
|
|
if i := strings.IndexByte(resp, '\n'); i >= 0 {
|
|
resp = resp[:i]
|
|
}
|
|
return strings.TrimSpace(resp), nil
|
|
}
|
|
|
|
// chatSystemPrompt returns the system prompt for conversational chat.
|
|
// Prepends the configured persona when set.
|
|
func chatSystemPrompt(persona string) string {
|
|
base := `You are maven, a self-hosted personal assistant. You're talking with your owner.
|
|
Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm.
|
|
Respond in the user's language (Russian or English, matching their last message).
|
|
Never roleplay emotions you don't have, but stay friendly.
|
|
Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}. "response" is your reply text; "mood" reflects your tone (neutral/happy/thinking/tired/confused).`
|
|
if persona != "" {
|
|
base = persona + "\n\n" + base
|
|
}
|
|
return base
|
|
}
|
|
|
|
// chatWithMessages sends a full message array (system + history + current) to
|
|
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
|
|
// slice — the caller owns the system prompt placement.
|
|
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
|
|
req := chatReq{
|
|
Messages: msgs,
|
|
Temperature: 0.7,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: marshal: %w", err)
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: post: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: read: %w", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
var cr chatResp
|
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
|
return "", fmt.Errorf("llm: parse: %w", err)
|
|
}
|
|
if len(cr.Choices) == 0 {
|
|
return "", fmt.Errorf("llm: no choices in response")
|
|
}
|
|
content := cr.Choices[0].Message.Content
|
|
if content == "" {
|
|
content = cr.Choices[0].Message.ReasoningContent
|
|
}
|
|
log.Printf("llm raw content: %q", content)
|
|
return stripThink(content), nil
|
|
}
|
|
|
|
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
|
text := extractReminderText(d.Reminder.Payload)
|
|
if text == "" {
|
|
text = "reminder"
|
|
}
|
|
|
|
prompt := fmt.Sprintf(
|
|
`The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"response": "...", "mood": "..."}`,
|
|
text,
|
|
)
|
|
resp, err := p.chat(ctx, prompt)
|
|
if err != nil {
|
|
return delivery.PhrasedReminder{}, err
|
|
}
|
|
body, mood := parseResponseMood(resp)
|
|
if body == "" {
|
|
// fallback: try old body/summary format
|
|
body, _ = parsePhrase(resp)
|
|
}
|
|
if body == "" {
|
|
body = text
|
|
}
|
|
if mood == "" {
|
|
mood = "neutral"
|
|
}
|
|
summary := body
|
|
if len(summary) > 60 {
|
|
summary = summary[:57] + "..."
|
|
}
|
|
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary, Mood: mood}, nil
|
|
}
|
|
|
|
type chatMsg struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type chatReq struct {
|
|
Model string `json:"model"`
|
|
Messages []chatMsg `json:"messages"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
}
|
|
|
|
type chatResp struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
|
return p.chatWithSystem(ctx, p.systemPrompt(), userPrompt, 512)
|
|
}
|
|
|
|
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
|
req := chatReq{
|
|
Messages: []chatMsg{
|
|
{Role: "system", Content: system},
|
|
{Role: "user", Content: user},
|
|
},
|
|
Temperature: 0.7,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: marshal: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: post: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("llm: read: %w", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
|
|
var cr chatResp
|
|
if err := json.Unmarshal(raw, &cr); err != nil {
|
|
return "", fmt.Errorf("llm: parse: %w", err)
|
|
}
|
|
if len(cr.Choices) == 0 {
|
|
return "", fmt.Errorf("llm: no choices in response")
|
|
}
|
|
content := cr.Choices[0].Message.Content
|
|
if content == "" {
|
|
content = cr.Choices[0].Message.ReasoningContent
|
|
}
|
|
return stripThink(content), nil
|
|
}
|
|
|
|
func (p *LLMPhraser) systemPrompt() string {
|
|
base := `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"response": "full voice message", "mood": "neutral"}. "response" is what the user hears; "mood" reflects maven's tone (neutral/happy/thinking/tired/confused).`
|
|
if p.cfg.Persona != "" {
|
|
base = p.cfg.Persona + "\n\n" + base
|
|
}
|
|
return base
|
|
}
|
|
|
|
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
|
// knowledge). Prepends the configured persona when set.
|
|
func (p *LLMPhraser) querySystemPrompt() string {
|
|
base := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
|
if p.cfg.Persona != "" {
|
|
base = p.cfg.Persona + "\n\n" + base
|
|
}
|
|
return base
|
|
}
|
|
|
|
func buildNudgePrompt(c loop.Candidate) string {
|
|
var ctxParts []string
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Rule: %s", c.Rule.Name))
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Severity: %s", sevLabel(c.Severity)))
|
|
|
|
if d, ok := c.State.Since(c.Rule.Name); ok {
|
|
ctxParts = append(ctxParts, fmt.Sprintf("Duration since last event: %s", humanDur(d)))
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
`Generate a nudge message. Context:
|
|
%s
|
|
|
|
Respond as JSON: {"response": "...", "mood": "..."}`,
|
|
strings.Join(ctxParts, "\n"),
|
|
)
|
|
}
|
|
|
|
type responseMood struct {
|
|
Response string `json:"response"`
|
|
Mood string `json:"mood"`
|
|
}
|
|
|
|
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
|
// of thinking tokens and extra text before/after the JSON block. Returns
|
|
// ("", "") when no valid JSON is found.
|
|
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 responseMood
|
|
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
|
return "", ""
|
|
}
|
|
return parsed.Response, parsed.Mood
|
|
}
|
|
|
|
func parsePhrase(raw string) (body, summary string) {
|
|
cleaned := strings.TrimSpace(raw)
|
|
start := strings.Index(cleaned, "{")
|
|
end := strings.LastIndex(cleaned, "}")
|
|
if start < 0 || end < 0 || end <= start {
|
|
return "", ""
|
|
}
|
|
var parsed struct {
|
|
Body string `json:"body"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
|
return "", ""
|
|
}
|
|
return parsed.Body, parsed.Summary
|
|
}
|
|
|
|
func extractPort(listen string) string {
|
|
_, port, _ := strings.Cut(listen, ":")
|
|
if port == "" {
|
|
return "0"
|
|
}
|
|
return port
|
|
}
|
|
|
|
// stripThink removes the <think> block that Thinking-variant models emit
|
|
// before the actual response. No-op when no think block is present.
|
|
func stripThink(s string) string {
|
|
if i := strings.LastIndex(s, "</think>"); i >= 0 {
|
|
s = strings.TrimSpace(s[i+8:])
|
|
}
|
|
return s
|
|
}
|