b9a24334ea
Wording fixes from the review of the clarify + phrasing PRs. - "На когда напомнить?" → "Когда?". After she has just been asked something, the long form is the phrasing of a form field, not of a person. - A reminder now wants a subject as well as a time. "напомни в 11" had a time and nothing to say at 11, and she asked nothing at all — she now asks "О чём напомнить?". Subject first, since a reminder with no subject is not worth setting. - The expiry notice is five phrasings picked at random instead of one fixed sentence. It is the line he hears every time he walks off mid-request, so it is the line that repeats most. - The nudge prompt's ban on "обращения" is now "ласковые обращения". It was meant to forbid "милый"/"дорогой", not his name — "Ками, ноутбук на трёх процентах" is how she talks, and the eval's cringe check already only flags pet names. - The nudge example no longer claims she plugged the laptop in. She has no hands and no smart plug; an example where she acts teaches the model to invent actions Maven never took. - replySystem: "тепло" → "спокойно и без официальных формулировок". A one-word mood instruction a 1.7B can't act on, replaced with the behaviour meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
779 lines
28 KiB
Go
779 lines
28 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
|
||
port string
|
||
cmd *exec.Cmd
|
||
cancel context.CancelFunc
|
||
wg sync.WaitGroup
|
||
|
||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||
// Config.LLMNudges. nil only if the template file failed to load.
|
||
tmpl *NudgeTemplates
|
||
}
|
||
|
||
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},
|
||
cancel: cancel,
|
||
tmpl: loadNudgeTemplates(),
|
||
}
|
||
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() {},
|
||
tmpl: loadNudgeTemplates(),
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
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) {
|
||
// 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) {
|
||
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", 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, 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) {
|
||
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", 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.
|
||
//
|
||
// 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
|
||
}
|