ad074cea31
Loading a different gguf was a one-line edit to phraser.model_path plus a
restart. It is now an owner-triggered IPC call, off unless configured.
internal/phraser/swap.go holds the safety properties as code:
- Never two models resident. The old llama-server is killed and reaped
before the new one is launched. One 1.7B fits the Vega iGPU; a
blue/green overlap would OOM the box, so it is not offered.
- Atomic from a turn's point of view. Swap drains the in-flight turns
(they finish on the old model), then refuses arrivals with ErrSwapping
until the new server has answered /v1/models. No turn ever sees half a
swap; refused turns fall back to the classifier cascade.
- A failed load rolls back. If the new model does not start or does not
probe, the previous one is reloaded and the call returns RolledBack
with the error. If the rollback also fails the daemon says so and
degrades to the classifier rather than pretending to serve.
Holders of the completion client are re-pointed, not rebuilt: llm.Client
guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the
replier, the mail extractor and the memory evaluator follow the new port
without knowing a swap happened.
Reach is deliberately narrow. phraser.swap_models is an exact-match
allowlist of absolute paths a human wrote, rejected at startup otherwise,
so "swap the model" can never mean "load any file on my disk"; the running
model is always swappable back to. MethodSwapModel is AuthStepUp, the same
rung as mutating the tool allowlist, and /models gates POST through the
same stepUpOK the tools page uses. Nothing calls Swap on a timer and no
act, intent or utterance reaches it.
Vikunja #250
895 lines
32 KiB
Go
895 lines
32 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/persona"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||
|
||
type LLMPhraser struct {
|
||
cfg Config
|
||
client *http.Client
|
||
|
||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||
// Config.LLMNudges. nil only if the template file failed to load.
|
||
tmpl *NudgeTemplates
|
||
|
||
// spawnCtx — the parent of every llama-server this phraser starts, i.e. the
|
||
// daemon's own context. Deliberately NOT the per-request context of the call
|
||
// that asked for a model swap: that one is cancelled the moment the request
|
||
// returns, which would kill the model it had just loaded.
|
||
spawnCtx context.Context
|
||
cancel context.CancelFunc
|
||
|
||
// launch / probe — the two side effects of a swap, injectable so the swap
|
||
// logic is testable without a real llama-server and a real model file.
|
||
// launch is nil when this phraser does not own its server (NewLLMPhraserAt),
|
||
// which is also what makes Swap refuse there.
|
||
launch func(ctx context.Context, cfg Config) (backend, error)
|
||
probe func(ctx context.Context, base string) (string, error)
|
||
|
||
// swapMu — single-flight around Swap. Held for the whole swap, including the
|
||
// model load, so two concurrent swap requests can never both be loading.
|
||
swapMu sync.Mutex
|
||
|
||
// mu guards everything below: the live backend, the swap gate and the
|
||
// in-flight request count. See acquire/quiesce in swap.go.
|
||
mu sync.Mutex
|
||
be backend
|
||
live liveModel
|
||
swapping bool
|
||
inflight int
|
||
observers []func(baseURL string)
|
||
}
|
||
|
||
// liveModel — what is actually loaded right now. Distinct from Config, which
|
||
// stays immutable after construction: a swap changes these three fields and
|
||
// nothing else, so no reader of cfg (prompts, grammar, timeouts) races a swap.
|
||
type liveModel struct {
|
||
ModelPath string
|
||
NGpuLayers int
|
||
NCtx int
|
||
}
|
||
|
||
type Config struct {
|
||
ModelPath string
|
||
BinPath string
|
||
Listen string
|
||
NGpuLayers int
|
||
NCtx int
|
||
Timeout time.Duration
|
||
|
||
// ContextBlock renders the shared context block (who he is, how to
|
||
// address him, the time) fresh for each turn. See internal/persona.
|
||
// nil ⇒ no block, the prompts stand alone.
|
||
ContextBlock func() string
|
||
|
||
// LLMNudges puts the model back in charge of nudge wording.
|
||
//
|
||
// Off by default, and that is a deliberate deprecation of LLM-phrased
|
||
// nudges: hand-written templates (nudges_ru_v1.json) word every nudge now.
|
||
// A nudge has nothing to be creative about, and measured over many runs the
|
||
// 0.8B broke the persona (formal "вы", plural imperatives, masculine
|
||
// self-reference) and invented facts and units. Templates score 15/15 on the
|
||
// nudge fixture, the model 11-13/15.
|
||
//
|
||
// The LLM path is kept, not deleted: flip this on to get it back. Chat,
|
||
// query and reminder phrasing are untouched and still go through the model.
|
||
LLMNudges bool
|
||
|
||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||
// The escape hatch exists because the target resident model — the
|
||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||
// ever fights the grammar, the fix should be a config flip on the
|
||
// deploy box, not a code change and a rebuild.
|
||
NoGrammar bool
|
||
}
|
||
|
||
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},
|
||
tmpl: loadNudgeTemplates(),
|
||
spawnCtx: ctx,
|
||
cancel: cancel,
|
||
launch: spawnLlamaServer,
|
||
probe: defaultProbe,
|
||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||
}
|
||
be, err := p.launch(ctx, cfg)
|
||
if err != nil {
|
||
cancel()
|
||
return nil, err
|
||
}
|
||
p.be = be
|
||
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},
|
||
tmpl: loadNudgeTemplates(),
|
||
spawnCtx: context.Background(),
|
||
cancel: func() {},
|
||
probe: defaultProbe,
|
||
// launch stays nil: we did not start this server, so we must not stop it.
|
||
// Swap therefore refuses here (ErrSwapNotOwned) instead of killing a
|
||
// server another process depends on.
|
||
be: borrowedBackend(strings.TrimSuffix(baseURL, "/")),
|
||
live: liveModel{ModelPath: cfg.ModelPath, NGpuLayers: cfg.NGpuLayers, NCtx: cfg.NCtx},
|
||
}
|
||
}
|
||
|
||
// loadNudgeTemplates loads the Russian nudge templates. A broken template file
|
||
// must not stop the daemon booting, so a failure logs and leaves the LLM path
|
||
// in charge of nudges.
|
||
func loadNudgeTemplates() *NudgeTemplates {
|
||
nt, err := NewNudgeTemplates(nil)
|
||
if err != nil {
|
||
log.Printf("phraser: nudge templates unavailable, using the model: %v", err)
|
||
return nil
|
||
}
|
||
return nt
|
||
}
|
||
|
||
// backend — one llama-server this phraser talks to. Two implementations: a
|
||
// llamaProc we spawned and must reap, and a borrowedBackend someone else owns.
|
||
type backend interface {
|
||
BaseURL() string
|
||
Close() error
|
||
}
|
||
|
||
// borrowedBackend — a server started and owned by someone else (the phrasing
|
||
// scorer's shared llama-server). Closing it is a no-op by construction.
|
||
type borrowedBackend string
|
||
|
||
func (b borrowedBackend) BaseURL() string { return string(b) }
|
||
func (b borrowedBackend) Close() error { return nil }
|
||
|
||
// llamaProc — a llama-server child process plus the goroutine reading its
|
||
// stderr. Close kills and reaps it; see the Pdeathsig note in spawnLlamaServer.
|
||
type llamaProc struct {
|
||
base string
|
||
cmd *exec.Cmd
|
||
cancel context.CancelFunc
|
||
wg sync.WaitGroup
|
||
}
|
||
|
||
func (l *llamaProc) BaseURL() string { return l.base }
|
||
|
||
func (l *llamaProc) Close() error {
|
||
l.cancel()
|
||
if l.cmd != nil && l.cmd.Process != nil {
|
||
_ = l.cmd.Process.Kill()
|
||
_ = l.cmd.Wait() // reap the process — without Wait, the child becomes a zombie
|
||
}
|
||
l.wg.Wait()
|
||
return nil
|
||
}
|
||
|
||
// spawnLlamaServer starts one llama-server for cfg and waits until it says which
|
||
// address it is listening on. ctx owns the process lifetime, so it must be the
|
||
// daemon's context, not a request's.
|
||
func spawnLlamaServer(ctx context.Context, cfg Config) (backend, error) {
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
p, err := startLlamaProc(ctx, cfg)
|
||
if err != nil {
|
||
cancel()
|
||
return nil, err
|
||
}
|
||
p.cancel = cancel
|
||
return p, nil
|
||
}
|
||
|
||
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||
p := &llamaProc{}
|
||
args := []string{
|
||
"-m", cfg.ModelPath,
|
||
"--host", "127.0.0.1",
|
||
"--port", extractPort(cfg.Listen),
|
||
"-c", fmt.Sprintf("%d", cfg.NCtx),
|
||
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
|
||
"--no-webui",
|
||
}
|
||
cmd := exec.CommandContext(ctx, 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 nil, fmt.Errorf("llm: stderr pipe: %w", err)
|
||
}
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
stderr.Close()
|
||
return nil, 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.base = addr
|
||
return p, nil
|
||
case err := <-errCh:
|
||
_ = cmd.Process.Kill()
|
||
_ = cmd.Wait()
|
||
return nil, fmt.Errorf("llm: server output: %w", err)
|
||
case <-ctx.Done():
|
||
_ = cmd.Process.Kill()
|
||
_ = cmd.Wait()
|
||
return nil, ctx.Err()
|
||
case <-time.After(60 * time.Second):
|
||
_ = cmd.Process.Kill()
|
||
_ = cmd.Wait()
|
||
return nil, fmt.Errorf("llm: server did not start within 60s")
|
||
}
|
||
}
|
||
|
||
// BaseURL is the llama-server this phraser talks to right now. It changes when
|
||
// the model is swapped, so callers that cache it must register an observer
|
||
// (OnSwap) rather than keeping the string forever.
|
||
func (p *LLMPhraser) BaseURL() string {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.be == nil {
|
||
return ""
|
||
}
|
||
return p.be.BaseURL()
|
||
}
|
||
|
||
func (p *LLMPhraser) Close() error {
|
||
p.cancel()
|
||
p.mu.Lock()
|
||
be := p.be
|
||
p.be = nil
|
||
p.mu.Unlock()
|
||
if be != nil {
|
||
return be.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||
// Templates first — see Config.LLMNudges for why this is the default.
|
||
if !p.cfg.LLMNudges && p.tmpl != nil {
|
||
return p.tmpl.PhraseNudge(ctx, c)
|
||
}
|
||
prompt := buildNudgePrompt(c)
|
||
resp, err := p.chat(ctx, prompt)
|
||
if err != nil {
|
||
return delivery.PhrasedNudge{}, err
|
||
}
|
||
body, mood, perr := parseResponseMood(resp)
|
||
if perr != nil {
|
||
// Truncated JSON. Not a nudge — use the plain Russian fallback.
|
||
log.Printf("phraser: PhraseNudge: %v", perr)
|
||
body, mood = "", ""
|
||
}
|
||
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 "вот что я нашла: <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 := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
||
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||
if err != nil || resp == "" {
|
||
return "не знаю.", nil
|
||
}
|
||
text, _, perr := parseResponseMood(resp)
|
||
if perr != nil {
|
||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||
return "не знаю.", nil
|
||
}
|
||
if text != "" {
|
||
return text, nil
|
||
}
|
||
return resp, nil
|
||
}
|
||
if len(notes) == 1 {
|
||
notes[0] = strings.TrimSpace(notes[0])
|
||
}
|
||
sys := p.querySystemPrompt()
|
||
prompt := fmt.Sprintf(
|
||
`Он спрашивает: "%s". В твоих заметках по этому вопросу написано: "%s". Ответь ему коротко и своими словами. Если в заметках ответа нет — так и скажи.`,
|
||
utterance, strings.Join(notes, `"; "`),
|
||
)
|
||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||
text, _, perr := parseResponseMood(resp)
|
||
if err != nil || perr != nil {
|
||
// Read the notes out rather than ship a broken fragment.
|
||
if perr != nil {
|
||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||
}
|
||
if len(notes) == 1 {
|
||
return "вот что я нашла: " + notes[0], nil
|
||
}
|
||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||
}
|
||
if 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.ContextBlock)
|
||
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, 768)
|
||
if err != nil {
|
||
log.Printf("phraser: PhraseChat: %v", err)
|
||
return "поговорили.", nil
|
||
}
|
||
text, _, perr := parseResponseMood(resp)
|
||
if perr != nil {
|
||
log.Printf("phraser: PhraseChat: %v", perr)
|
||
return "поговорили.", nil
|
||
}
|
||
if 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 shared context block when the phraser has one.
|
||
func chatSystemPrompt(block func() string) string {
|
||
// No self-introduction here: the persona block prepended one line above
|
||
// already says who she is, same as router.KnowledgePrompt.
|
||
base := `Ты разговариваешь с хозяином. О себе говоришь в женском роде ("я подумала", "я рада"). Он мужчина: обращайся к нему на "ты", в мужском роде ("ты сказал", "ты забыл"). Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||
|
||
Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай.
|
||
|
||
Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.`
|
||
return persona.Prepend(block, 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) {
|
||
base, release, err := p.acquire()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer release()
|
||
req := chatReq{
|
||
Messages: msgs,
|
||
Temperature: 0.7,
|
||
MaxTokens: maxTokens,
|
||
Grammar: p.grammar(),
|
||
}
|
||
body, err := json.Marshal(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/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, perr := parseResponseMood(resp)
|
||
if perr != nil {
|
||
// Truncated JSON. Fall through to the reminder's own text.
|
||
log.Printf("phraser: PhraseReminder: %v", perr)
|
||
body, mood = "", ""
|
||
}
|
||
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"`
|
||
// Grammar is llama-server's `grammar` field (GBNF). Same wiring as
|
||
// internal/llm.Req.Grammar. Empty ⇒ unconstrained sampling.
|
||
Grammar string `json:"grammar,omitempty"`
|
||
}
|
||
|
||
// responseGrammar — GBNF constraining the model to the documented phrasing
|
||
// contract and nothing else: {"response": "<text>", "mood": "<enum>"}.
|
||
//
|
||
// Without it a 0.8B answers roughly one chat turn in three with open reasoning
|
||
// as plain text ("Thinking Process:" …), which no tag-stripper can remove and
|
||
// which eats the token budget before the JSON closes. Modelled on
|
||
// routeGrammar in internal/router/llmrouter.go so the two read alike.
|
||
//
|
||
// text accepts ANY codepoint except the two JSON must escape — the replies are
|
||
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
|
||
// is what lets the model close a string it opened with a quote inside. Length
|
||
// is bounded so a repetition loop truncates the field, not the JSON object.
|
||
//
|
||
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
|
||
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
|
||
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
|
||
// So the token cap was never what stopped it — this rule was. 1000 characters is
|
||
// roughly six Russian sentences, still short enough to stop a repetition loop.
|
||
const responseGrammar = `
|
||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
|
||
ws ::= [ \t\n]*
|
||
`
|
||
|
||
// grammar returns the GBNF to attach to a phrasing request, or "" when the
|
||
// operator turned it off.
|
||
func (p *LLMPhraser) grammar() string {
|
||
if p.cfg.NoGrammar {
|
||
return ""
|
||
}
|
||
return responseGrammar
|
||
}
|
||
|
||
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) {
|
||
base, release, err := p.acquire()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer release()
|
||
req := chatReq{
|
||
Messages: []chatMsg{
|
||
{Role: "system", Content: system},
|
||
{Role: "user", Content: user},
|
||
},
|
||
Temperature: 0.7,
|
||
MaxTokens: maxTokens,
|
||
Grammar: p.grammar(),
|
||
}
|
||
body, err := json.Marshal(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("llm: marshal: %w", err)
|
||
}
|
||
|
||
httpReq, err := http.NewRequestWithContext(ctx, "POST", base+"/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.
|
||
//
|
||
// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his
|
||
// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the
|
||
// unqualified word read as forbidding that too. Hence "ласковые обращения".
|
||
//
|
||
// The examples also never claim a physical act. She has no hands and no smart
|
||
// plug — she can tell him the battery is at three percent, she cannot put the
|
||
// laptop on charge. An example that says she did teaches the model to invent
|
||
// actions Maven never took, which is worse than a missing nudge.
|
||
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 {
|
||
return persona.Prepend(p.cfg.ContextBlock, nudgeSystem)
|
||
}
|
||
|
||
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
||
// knowledge). Prepends the configured persona when set.
|
||
func (p *LLMPhraser) querySystemPrompt() string {
|
||
// No self-introduction here: the persona block prepended one line above
|
||
// already says who she is, same as router.KnowledgePrompt.
|
||
base := "Ты отвечаешь ему по своим заметкам. Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде (\"нашла\", \"записала\"). Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||
return persona.Prepend(p.cfg.ContextBlock, 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"`
|
||
}
|
||
|
||
// errBrokenJSON — the model started a JSON object and never finished it.
|
||
// That is a failed generation, not a reply. Callers must use their fallback.
|
||
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
|
||
|
||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||
// of thinking tokens and extra text before/after the JSON block.
|
||
//
|
||
// Three outcomes:
|
||
// - parsed fine → the fields, nil error.
|
||
// - output never looked like JSON → ("", "", nil). The caller may ship it
|
||
// as-is; small models sometimes answer in bare prose and that is fine.
|
||
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
|
||
// guarantees a valid *prefix*, so a generation that hits the token cap
|
||
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
|
||
// as a reply is the bug this error exists to stop.
|
||
func parseResponseMood(raw string) (response, mood string, err error) {
|
||
cleaned := strings.TrimSpace(raw)
|
||
start := strings.Index(cleaned, "{")
|
||
end := strings.LastIndex(cleaned, "}")
|
||
if start < 0 || end < 0 || end <= start {
|
||
if strings.HasPrefix(cleaned, "{") {
|
||
return "", "", errBrokenJSON
|
||
}
|
||
return "", "", nil
|
||
}
|
||
var parsed responseMood
|
||
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
|
||
if strings.HasPrefix(cleaned, "{") {
|
||
return "", "", errBrokenJSON
|
||
}
|
||
return "", "", nil
|
||
}
|
||
return parsed.Response, parsed.Mood, nil
|
||
}
|
||
|
||
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
|
||
}
|