eef5d4da4f
The prompts stated the feminine self-reference rule but never said whom she is
speaking to, so the model produced formal plural ("Жду вас") and talked about
him in third person ("Он не ел 11 дней"). Adds the address rule right next to
the feminine one, in the nudge prompt and the confirmation prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
647 lines
21 KiB
Go
647 lines
21 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/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 "вот что я нашла: <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 := 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 <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
|
|
}
|