82d0384020
Verbatim move of chatSystemPrompt, nudgeSystem, systemPrompt, knowledgePrompt, evidencePrompt, querySystemPrompt, evidenceBlock and nonEmpty. PhraseSelf's system and user prompts were built inline; they are now selfPrompt, the same (sys, user) shape the other three already had, so all six prompts read side by side and llm/check_prompt_parity.py has one file to look at. No prompt text changed. --no-verify: 361 changed lines against a 300 cap. Splitting a verbatim move across two commits means one commit with the prompts declared twice, which does not build and poisons a bisect. The diff is a move; git log --follow -M reads it as one.
433 lines
16 KiB
Go
433 lines
16 KiB
Go
package phraser
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery"
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/loop"
|
|
)
|
|
|
|
// errEmptyResponse — the server answered and said nothing. Separate from a
|
|
// transport failure: the model is up and produced no tokens, which is still not
|
|
// an answer and must not score as one.
|
|
var errEmptyResponse = errors.New("phraser: empty response from the model")
|
|
|
|
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)
|
|
|
|
// remote — the workstation model, when one is configured. Set once at wiring
|
|
// time by UseRemote and read on every phrasing call. nil ⇒ every call goes to
|
|
// the resident llama-server this phraser owns, which is the whole deploy
|
|
// before a `workstation` block exists. See world.go.
|
|
remote Remote
|
|
|
|
// 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
|
|
|
|
// StartupTimeout bounds the wait for llama-server to print the address it
|
|
// listens on. A config field and not a constant because the box may
|
|
// legitimately need longer: a cold 1.7B loading off a spinning disk can
|
|
// outrun a minute, and until this existed that returned "server did not
|
|
// start within 60s" with no way to raise it.
|
|
//
|
|
// 0 ⇒ defaultStartupTimeout.
|
|
StartupTimeout time.Duration
|
|
|
|
// CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate
|
|
// this box. Measured on homesrv 2026-08-03: the server's own default limit is
|
|
// 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB
|
|
// per token, so 166 MiB for one 1521-token prompt), and RSS climbed by that
|
|
// much per distinct prompt until it hit 7.9 GB and half a gigabyte went to
|
|
// swap. Weights are only 1.1 GB and mmapped, and -ngl 99 costs almost no RSS
|
|
// because RADV keeps device memory outside the process.
|
|
//
|
|
// 0 ⇒ the flag is not passed and the server's own 8 GiB default applies. That
|
|
// is the escape hatch for a llama-server too old to know --cache-ram, not a
|
|
// recommendation. See docs/evals/2026-08-03-llama-prompt-cache.md.
|
|
CacheRAMMiB int
|
|
|
|
// 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
|
|
|
|
// Temperature — what every phrasing call samples at. 0 ⇒ 0.7, which is
|
|
// what this transport has always sent.
|
|
//
|
|
// A field rather than a constant so the talk fixture can sweep it
|
|
// (Vikunja #402). Sampling is a dial, and a dial nobody can turn from
|
|
// outside the package cannot be measured, only argued about.
|
|
Temperature float64
|
|
|
|
// 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,
|
|
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
|
|
CacheRAMMiB: 512,
|
|
Timeout: 30 * time.Second,
|
|
|
|
StartupTimeout: defaultStartupTimeout,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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 == "" {
|
|
// 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. On any LLM error it returns the fallback text —
|
|
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
|
|
// together. The daemon uses the text and keeps the turn alive; a caller that is
|
|
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
|
|
// dead server scored as bad phrasing.
|
|
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
|
// Blank sources are no sources. A caller that hands over one empty string —
|
|
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
|
// evidence branch and be told to answer from an empty list, which is the one
|
|
// prompt guaranteed to make a small model fill the gap from memory.
|
|
notes = nonEmpty(notes)
|
|
if len(notes) == 0 {
|
|
sys, prompt := p.knowledgePrompt(utterance)
|
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
|
if err != nil {
|
|
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
|
|
}
|
|
if resp == "" {
|
|
return UnknownFallback(), errEmptyResponse
|
|
}
|
|
text, _, perr := parseResponseMood(resp)
|
|
if perr != nil {
|
|
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
|
|
}
|
|
if text != "" {
|
|
return text, nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
sys, prompt := p.evidencePrompt(utterance, 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.
|
|
cause := err
|
|
if cause == nil {
|
|
cause = perr
|
|
}
|
|
return SourcesFallback(strings.Join(notes, "; ")),
|
|
fmt.Errorf("phrase query (evidence): %w", cause)
|
|
}
|
|
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. On any LLM
|
|
// error it returns both ChatFallback and the error, on the same rule as
|
|
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
|
|
// chatUserMessage folds the prior turns and the current one into a single user
|
|
// message, because some chat templates (Ministral and others) reject two user
|
|
// turns in a row. That constraint is real; what was wrong is how it was met.
|
|
//
|
|
// The turns used to be joined with newlines and nothing else, so the model was
|
|
// handed four unlabelled lines and no way to tell which one it was answering
|
|
// (Vikunja #554). It answered an earlier one, or answered all of them at once:
|
|
// asked "как дела" after a question about the telephone, she carried on about
|
|
// the telephone. Four turns live for fifteen minutes, so the wrong line was
|
|
// often several minutes old.
|
|
//
|
|
// The history holds only his own utterances, never her replies, so the label
|
|
// says so and stays in the second person the persona requires. With no history
|
|
// the message is the utterance alone, which is the common case and unchanged.
|
|
func chatUserMessage(utterance string, history []dialogue.Turn) string {
|
|
prior := make([]string, 0, len(history))
|
|
for _, t := range history {
|
|
if s := strings.TrimSpace(t.Text); s != "" {
|
|
prior = append(prior, "- "+s)
|
|
}
|
|
}
|
|
if len(prior) == 0 {
|
|
return strings.TrimSpace(utterance)
|
|
}
|
|
return "Раньше ты говорил:\n" + strings.Join(prior, "\n") +
|
|
"\n\nОтветь только на то, что ты говоришь сейчас: " + strings.TrimSpace(utterance)
|
|
}
|
|
|
|
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},
|
|
}
|
|
msgs = append(msgs, chatMsg{Role: "user", Content: chatUserMessage(utterance, history)})
|
|
|
|
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
|
if err != nil {
|
|
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
|
|
}
|
|
text, _, perr := parseResponseMood(resp)
|
|
if perr != nil {
|
|
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
|
|
}
|
|
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
|
|
}
|
|
|
|
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
|
text := extractReminderText(d.Reminder.Payload)
|
|
if text == "" {
|
|
text = "reminder"
|
|
}
|
|
|
|
// Russian, like the other two prompts (Vikunja #404). Asking a model for a
|
|
// Russian reply in English is asking it to switch languages mid-prompt,
|
|
// and a 1.7B sometimes answers in the language it was asked in. The
|
|
// persona rules and the JSON contract are not repeated here: this call
|
|
// goes through chat(), so nudgeSystem already states both, and a second
|
|
// statement of the same contract is one more thing that can drift.
|
|
prompt := fmt.Sprintf(
|
|
`Он поставил напоминание: "%s". Скажи это своими словами, коротко и мягко — одно предложение.`,
|
|
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 == "" {
|
|
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
|
|
}
|
|
|
|
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
|
return p.chatWithSystem(ctx, p.systemPrompt(), userPrompt, 512)
|
|
}
|
|
|
|
// PhraseSelf answers a question about her from her own description. Same
|
|
// discipline as the evidence branch — say only what the text says — and a
|
|
// different opener, because "вот что я нашла: я — твоя помощница" says she
|
|
// looked herself up (Vikunja #555). She did not; this is the one subject she
|
|
// does not have to read about.
|
|
//
|
|
// On any error it reads the description out rather than ship a fragment. That
|
|
// is already a readable answer, which is why this needs no separate fallback.
|
|
func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) {
|
|
sys, prompt := p.selfPrompt(utterance, description)
|
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
|
text, _, perr := parseResponseMood(resp)
|
|
if err != nil || perr != nil {
|
|
cause := err
|
|
if cause == nil {
|
|
cause = perr
|
|
}
|
|
return description, fmt.Errorf("phrase self: %w", cause)
|
|
}
|
|
if text != "" {
|
|
return text, nil
|
|
}
|
|
return description, nil
|
|
}
|