Files
Maven/internal/phraser/llmphraser.go
T
2026-07-03 00:32:48 +02:00

301 lines
7.5 KiB
Go

package phraser
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
)
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
}
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 {
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()
return fmt.Errorf("llm: server output: %w", err)
case <-ctx.Done():
_ = cmd.Process.Kill()
return ctx.Err()
case <-time.After(60 * time.Second):
_ = cmd.Process.Kill()
return fmt.Errorf("llm: server did not start within 60s")
}
}
func (p *LLMPhraser) Close() error {
p.cancel()
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
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, summary := parsePhrase(resp)
if body == "" {
body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity))
}
if summary == "" {
summary = c.Rule.Name
}
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, 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: {"body": "...", "summary": "..."}`,
text,
)
resp, err := p.chat(ctx, prompt)
if err != nil {
return delivery.PhrasedReminder{}, err
}
body, summary := parsePhrase(resp)
if body == "" {
body = text
}
if summary == "" {
summary = text
if len(summary) > 60 {
summary = summary[:57] + "..."
}
}
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary}, 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"`
} `json:"message"`
} `json:"choices"`
}
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
req := chatReq{
Messages: []chatMsg{
{Role: "system", Content: systemPrompt()},
{Role: "user", Content: userPrompt},
},
Temperature: 0.7,
MaxTokens: 256,
}
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")
}
return cr.Choices[0].Message.Content, nil
}
func systemPrompt() string {
return `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: {"body": "full voice message", "summary": "brief away-channel version (<60 chars)"}. body is what the user hears on voice; summary is for push notifications (ntfy/telegram) — minimal, no exfil detail.`
}
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: {"body": "...", "summary": "..."}`,
strings.Join(ctxParts, "\n"),
)
}
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
}