301 lines
7.5 KiB
Go
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
|
|
}
|