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 } // NewLLMPhraserAt wires a phraser to a llama-server that someone else started // and owns. It spawns nothing, so Close does not kill anything. // // This exists for the phrasing scorer (internal/phraser/eval), which must // measure the phrasing against a shared llama-server without taking the model // load hit per run or killing a server another process depends on. The daemon // still uses NewLLMPhraser and still owns its own child process. func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser { return &LLMPhraser{ cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}, port: strings.TrimSuffix(baseURL, "/"), cancel: func() {}, } } 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 == "" { // The model said nothing usable. Say it in Russian anyway — this text // goes straight to a Russian piper voice, so the old "water — care" // fallback was unspeakable. body = fallbackNudge(c) } 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 "вот что я нашла: " 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 } // nudgeSystem — the phrasing contract for nudges. // // Written as filled-in examples, not as a schema with "..." in it. A 0.8B // copies whatever sits in the response slot, so a literal placeholder there // teaches it to answer with the placeholder. Measured: 7/15 nudges came back // as "..." before this. See PHRASING-EVAL-31-07-2026.md. // // Russian only, feminine self-reference, second person masculine (the owner is // a man). She talks TO him, informally, singular — never "вы", never "он". // One short sentence — the nudge is spoken aloud. const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл"). Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу. Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood". "response" — сам текст напоминания. "mood" — ровно одно из: neutral, happy, thinking, tired, confused. Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет: {"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"} {"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"} Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` func (p *LLMPhraser) systemPrompt() string { base := nudgeSystem 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 } // ruleTopics — Russian gloss for each built-in rule name. The rule names are // English identifiers; a 0.8B asked to nudge about "netdata_critical" writes // about nothing. The daemon knows what its own rules mean, so it says so. var ruleTopics = map[string]string{ "water": "он давно не пил воду", "meal": "он давно не ел", "break": "он давно без перерыва, пора встать и размяться", "service_down": "сервис не отвечает, лежит", "netdata_critical": "критический алярм в netdata, проблема с диском или местом", } // ruleKeywords — the word the message must contain. The 0.8B drifts to // whatever topic it saw last unless the required word is named outright. var ruleKeywords = map[string]string{ "water": "воду", "meal": "поешь", "break": "перерыв", "service_down": "сервис", "netdata_critical": "диск", } // ruleTopic turns a rule name into a Russian description of the situation. // "routine:зарядка" and "morning:утро" carry their own Russian suffix. func ruleTopic(rule string) string { if t, ok := ruleTopics[rule]; ok { return t } if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { switch rule[:i] { case "morning": return "утро, пора начать день: " + rule[i+1:] default: return "пора сделать по распорядку: " + rule[i+1:] } } return rule } // ruleKeyword — the word the nudge must contain, or "" when the rule name's // own Russian suffix already is that word. func ruleKeyword(rule string) string { if k, ok := ruleKeywords[rule]; ok { return k } if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { return rule[i+1:] } return "" } // ruDur — duration in Russian. humanDur is English and its output was landing // verbatim in the message. func ruDur(d time.Duration) string { if d < 0 { d = 0 } h, m := int(d.Hours()), int(d.Minutes())%60 switch { case h >= 2: return fmt.Sprintf("%d ч", h) case h == 1 && m >= 30: return "полтора часа" case h == 1: return "час" default: return fmt.Sprintf("%d мин", m) } } // fallbackNudge — plain Russian for when the model returns nothing parseable. var fallbackNudges = map[string]string{ "water": "Ты давно не пил воду.", "meal": "Ты давно не ел, поешь.", "break": "Пора сделать перерыв.", "service_down": "Сервис не отвечает.", "netdata_critical": "Критический алярм: проверь диск.", } func fallbackNudge(c loop.Candidate) string { if s, ok := fallbackNudges[c.Rule.Name]; ok { return s } if kw := ruleKeyword(c.Rule.Name); kw != "" { return "Напоминаю: " + kw + "." } return "Напоминаю о деле." } func buildNudgePrompt(c loop.Candidate) string { var ctxParts []string ctxParts = append(ctxParts, "Ситуация: "+ruleTopic(c.Rule.Name)) if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name { ctxParts = append(ctxParts, "Что именно: "+f.Key) } if d, ok := c.State.Since(c.Rule.Name); ok { ctxParts = append(ctxParts, "Прошло: "+ruDur(d)) } switch sevLabel(c.Severity) { case "alarm": ctxParts = append(ctxParts, "Срочно, скажи прямо.") case "ops": ctxParts = append(ctxParts, "Это про сервер, не про здоровье.") } tail := "Напиши напоминание про эту ситуацию. Одно предложение, по-русски, в JSON." if kw := ruleKeyword(c.Rule.Name); kw != "" { // Last line on purpose: a 0.8B weights the end of the prompt hardest, // and without the required word it drifts back to the examples. tail += " Ответ ДОЛЖЕН содержать слово «" + kw + "»." } return strings.Join(ctxParts, "\n") + "\n\n" + tail } 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 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, ""); i >= 0 { s = strings.TrimSpace(s[i+8:]) } return s }