Compare commits

..

3 Commits

Author SHA1 Message Date
claude c69023c310 Merge PR #117 into task/491 (V-383) 2026-08-04 14:08:59 +02:00
claude 6d3f5b5b01 router: a reminder with no subject asks instead of guessing (V-383)
Slots.Text was the raw utterance for every intent, so a reminder could not
have an empty subject. StillMissing never reported SlotText, the question
"О чём напомнить?" was unaskable, and the branch in PendingQuestion.Answer
that fills a text slot could only overwrite the whole request.

The LLM path now keeps the model's own text, empty included, and the gate
turns a subjectless reminder into a question. The classifier path is
unchanged: it has no subject parser, so the utterance is the only signal it
has.
2026-08-04 02:49:02 +04:00
claude eda1112f3b mavgpud: a yield stops writing a core and reads as a yield (V-491)
llama-server aborts inside its own static teardown on SIGTERM — the
handler calls exit(), stream_session_manager's destructor throws, and the
process dies "signal: aborted (core dumped)". mavgpud sends that signal on
every eviction, so a routine yield wrote a multi-gigabyte core into
systemd-coredump and logged the same line a real crash would.

LimitCORE=0 in the unit stops the disk cost. A yielding flag, set by stop
and cleared by start, makes the log distinguish the two: only an exit we
did not ask for is still reported as an exit.

Not filed upstream. Searched ggml-org/llama.cpp for
"ggml_uncaught_exception" with SIGTERM and for stream_session_manager and
found nothing matching, so the issue still wants writing — by someone with
an account on that tracker, which is why it is not in this commit.
2026-08-04 02:01:17 +04:00
22 changed files with 226 additions and 557 deletions
+1 -6
View File
@@ -40,7 +40,6 @@ import (
"context"
"log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -59,14 +58,10 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
// Conversational: build history from dialogue session (prior user turns)
// and let the LLM respond from general knowledge + context.
history := h.chatHistory()
// The phraser hands back its own fallback text alongside the error, so the
// turn survives a dead server and the failure still reaches the log.
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
if err != nil {
log.Printf("voice: chat: %v", err)
}
if reply == "" {
return phraser.ChatFallback()
return "поговорили."
}
return reply
}
+2 -8
View File
@@ -445,13 +445,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
// A note is phrased in Maven's voice; a fact is read back as it was
// stored.
if hit.Meta["type"] == "note" {
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
switch {
case perr != nil:
// Reading the note back verbatim beats the phraser's own fallback,
// which only wraps the same text in "вот что я нашла:".
log.Printf("voice: recall phrase: %v", perr)
case reply != "":
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
return reply, true
}
}
@@ -798,7 +792,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
if errors.Is(err, phraser.ErrNoWorldModel) {
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
return worldGap(), true
return worldGap, true
}
if err != nil || reply == "" {
return "не знаю.", true
+36
View File
@@ -465,3 +465,39 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
t.Fatal("the expired question must be gone")
}
}
// The other half of the subject question: his answer must fill the empty slot,
// not replace the request. Slots.Text used to be the whole raw utterance for
// every intent, so the branch that fills a text slot could only ever overwrite
// (Vikunja #383). Here the parked request holds the hour and the answer holds
// what to say at it, and the reminder that lands has both.
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer to an open question must be consumed as an answer")
}
if reply == clarifyGaveUp {
t.Fatalf("a good answer must not drop the request: %q", reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
if err != nil || len(reminders) != 1 {
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
}
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
if !strings.Contains(reminders[0].Payload, "11") {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
}
}
+3 -3
View File
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
})
reply := askQuery(t, h, q)
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
t.Errorf("reply %q, want the note read back", reply)
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
t.Errorf("reply %q, want %q", reply, want)
}
// One text, the winning memory's — the answer came from the memory
// pass, not from handing the phraser every note in the table.
@@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
{text: "молоко закончилось", score: 0.858, kind: "note"},
})
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
if reply := askQuery(t, h, q); reply != "не знаю." {
t.Errorf("reply %q, want silence", reply)
}
})
+1 -9
View File
@@ -23,11 +23,7 @@ type worldPhraser interface {
// question about his meeting came back as a swimming competition in Nottingham.
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
// being down.
//
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
// variants: this sentence names one specific gap and must not drift into a
// general "I don't know".
func worldGap() string { return phraser.WorldGap() }
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
// phraseWorld asks the world model, or reports the gap.
//
@@ -58,11 +54,7 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
log.Printf("voice: %s: no world model, reading the source back instead", name)
return ""
case err != nil:
// The resident phraser answers this call with its fallback text and the
// error together. Drop the text: these callers hold the passage itself
// and read it back better than "вот что я нашла: <passage>" does.
log.Printf("voice: %s: phrase: %v", name, err)
return ""
}
return reply
}
+6 -6
View File
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
if !ok {
t.Fatal("queryGeneral passed on the last source in the chain")
}
if reply != worldGap() {
if reply != worldGap {
t.Fatalf("reply = %q, want the named gap", reply)
}
if g.worldCalls != 1 {
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
if !ok {
t.Fatal("queryGeneral passed on the last source in the chain")
}
if !phraser.IsUnknownFallback(reply) {
if reply != "не знаю." {
t.Fatalf("reply = %q, want the Stub's answer", reply)
}
}
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
// English in it.
func TestWorldGapIsInPersona(t *testing.T) {
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
if strings.Contains(worldGap(), bad) {
t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
if strings.Contains(worldGap, bad) {
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
}
}
if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
}
}
+17 -3
View File
@@ -24,7 +24,12 @@ type runner struct {
mu sync.Mutex
cmd *exec.Cmd
ready bool
http *http.Client
// yielding — stop() has sent the signal and the exit that follows is ours.
// llama-server aborts on SIGTERM (its static teardown throws, upstream
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
// "signal: aborted" and used to log identically (Vikunja #491).
yielding bool
http *http.Client
}
func newRunner(bin string, args []string, readyURL string) *runner {
@@ -70,13 +75,18 @@ func (r *runner) start() error {
if err := cmd.Start(); err != nil {
return err
}
r.cmd, r.ready = cmd, false
r.cmd, r.ready, r.yielding = cmd, false, false
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
go func() {
err := cmd.Wait()
r.mu.Lock()
r.cmd, r.ready = nil, false
yielded := r.yielding
r.cmd, r.ready, r.yielding = nil, false, false
r.mu.Unlock()
if yielded {
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
return
}
log.Printf("mavgpud: llama-server exited: %v", err)
}()
return nil
@@ -90,6 +100,10 @@ func (r *runner) stop(grace time.Duration) {
r.mu.Lock()
cmd := r.cmd
r.ready = false
if cmd != nil && cmd.Process != nil {
// The exit that follows is ours, not a crash.
r.yielding = true
}
r.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
// fakeServer writes an executable standing in for llama-server: it ignores
// SIGTERM the way the real one effectively does — by dying messily rather than
// cleanly — and reports a non-zero status.
func fakeServer(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-llama-server")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
// A deliberate stop is a yield, and the log has to say so.
//
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
// status of a routine yield is identical to that of a real crash. Reading the
// mavgpud log, the two were indistinguishable (Vikunja #491).
func TestStopMarksTheExitAsAYield(t *testing.T) {
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
if err := r.start(); err != nil {
t.Fatalf("start: %v", err)
}
r.mu.Lock()
if r.yielding {
t.Error("a freshly started server is already marked as yielding")
}
r.mu.Unlock()
r.stop(2 * time.Second)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !r.running() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the child outlived stop")
}
// Stopping when nothing is running must not arm the flag for the next child.
// The next exit after that would be a real crash logged as a yield.
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
r := newRunner("/nonexistent", nil, "")
r.stop(10 * time.Millisecond)
r.mu.Lock()
defer r.mu.Unlock()
if r.yielding {
t.Error("stop armed the yield flag with no child running")
}
}
+4
View File
@@ -19,6 +19,10 @@ RestartSec=5
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
KillSignal=SIGTERM
TimeoutStopSec=60
# llama-server aborts inside its own static teardown on SIGTERM, so every
# routine yield used to write a multi-gigabyte core into systemd-coredump
# (Vikunja #491). Yielding is meant to happen several times a day.
LimitCORE=0
[Install]
WantedBy=default.target
-40
View File
@@ -1,40 +0,0 @@
package eval
import (
"math/rand"
"strings"
"testing"
"github.com/kami/maven/internal/phraser"
)
// TestFallbackPersona scores every line in fallbacks_ru_v1.json on the persona
// checks the nudges are already held to. These lines are heard out loud, and
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
// would otherwise reach him with nothing between it and the speaker.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges: they are what she says when there is no answer.
func TestFallbackPersona(t *testing.T) {
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
persona := map[string]bool{
CheckLang: true, CheckFeminine: true, CheckHisGender: true,
CheckAddress: true, CheckCringe: true, CheckLength: true,
}
variants := fb.Variants()
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
for _, v := range variants {
// {sources} stands for his own notes and never carries persona of its own.
body := strings.ReplaceAll(v, "{sources}", "два литра")
for _, r := range RunChecks(Case{}, body, "neutral") {
if persona[r.Name] && !r.Pass {
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
}
}
}
}
+2 -8
View File
@@ -78,12 +78,6 @@ type TalkCase struct {
Note string `json:"note,omitempty"`
}
// TalkSchemaVersion — the version this loader understands. Separate from the
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
// change on different days, and one shared constant would force a bump on the
// fixture that did not move.
const TalkSchemaVersion = 1
// TalkFixture — the versioned envelope, same gating as Fixture.
type TalkFixture struct {
SchemaVersion int `json:"schema_version"`
@@ -98,8 +92,8 @@ func LoadTalk() (TalkFixture, error) {
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
}
if f.SchemaVersion != TalkSchemaVersion {
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
if f.SchemaVersion != SchemaVersion {
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
}
if len(f.Cases) == 0 {
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
+16 -11
View File
@@ -142,13 +142,19 @@ func TestLLMTalkBaseline(t *testing.T) {
p := phraser.NewLLMPhraserAt(base, cfg)
defer p.Close()
// The model id names the run in the report. Since Vikunja #397 every path
// returns its errors, so a server that dies mid-run shows up in the Errors
// column instead of scoring as bad phrasing — the before-and-after probe that
// used to stand in for that is gone.
// Unreachable server is fatal here, not a logged warning, and that differs
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
// server there shows up honestly in the Errors column. PhraseChat and
// PhraseQuery do NOT: they swallow every failure and return a canned string
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
// a dead server produces a full report with 0 errors and a terrible score —
// a number that looks like bad phrasing and is really no phrasing at all.
// Refusing to score without a confirmed model is the only guard available
// until the phraser reports its failures (Vikunja #397).
model, err := llm.ModelID(ctx, base)
if err != nil {
t.Fatalf("no model at %s: %v", base, err)
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
"and would report a plausible-looking result off a dead server", base, err)
}
t.Logf("scoring model %s at %s", model, base)
@@ -163,11 +169,10 @@ func TestLLMTalkBaseline(t *testing.T) {
}
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
// A run where nothing was phrased is not a low score, it is no measurement.
if rep.Errors == rep.Total {
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
}
if rep.Errors > 0 {
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
// And again afterwards: the run takes minutes, and a server that died or got
// OOM-killed halfway through would leave the first cases scored and the rest
// silently canned. Checking only at the start would not catch that.
if _, err := llm.ModelID(ctx, base); err != nil {
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
}
}
-89
View File
@@ -1,89 +0,0 @@
package phraser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// isFallback — the text she says is picked from that entry's variants, so a test
// pins the entry rather than the wording. Pinning one line would make editing
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
e, ok := DefaultFallbacks().file.Entries[key]
if !ok {
t.Fatalf("no fallback entry %q", key)
}
for _, v := range e.Variants {
if strings.ReplaceAll(v, "{sources}", sources) == got {
return true
}
}
return false
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
// PhraseQuery keep the turn alive with canned text — and every one of those
// lines is also a legitimate reply, so the text alone cannot say which happened.
// The error is the only signal, and before Vikunja #397 it was dropped: the talk
// scorer reported a full run with zero errors off a server that answered nothing.
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
cases := []struct {
name string
call func() (string, error)
key string
sources string
}{
{"chat", func() (string, error) {
return p.PhraseChat(context.Background(), "как дела", nil)
}, fbChat, ""},
{"knowledge", func() (string, error) {
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
}, fbQueryUnknown, ""},
{"evidence", func() (string, error) {
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
}, fbQuerySources, "два литра"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := c.call()
if err == nil {
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
}
if !isFallback(t, c.key, c.sources, got) {
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
}
})
}
}
// An empty answer is a failure too: the server is up and produced no tokens,
// which is not an answer and must not score as one.
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbQueryUnknown, "", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("error = %v; want it to name the empty response", err)
}
}
-238
View File
@@ -1,238 +0,0 @@
package phraser
// The phrasing fallbacks — what she says when the model gave her nothing usable.
//
// They were four string literals spread across phraser.go, llmphraser.go and
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
// This is the same shape nudges_ru_v1.json already uses for nudges: embedded,
// schema-versioned, several variants, and never the same variant twice running.
//
// The floor under the floor is deliberate. These strings exist because something
// already failed, so a broken template file must not be able to take the last
// words she has: every accessor falls back to the literal it replaced.
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"math/rand"
"strings"
"sync"
"time"
)
//go:embed fallbacks_ru_v1.json
var fallbackJSON []byte
// FallbackSchemaVersion — the version this code understands. Its own constant,
// not shared with the nudge templates or the eval fixtures: two files that change
// on different days cannot be versioned by one number (Vikunja #397).
const FallbackSchemaVersion = 1
// The entry keys. Every one of them is read by a method below, so a typo in the
// file is caught at load rather than at the moment she needs the words.
const (
fbChat = "chat"
fbQueryUnknown = "query_unknown"
fbQuerySources = "query_sources"
fbWorldGap = "world_gap"
)
// fbKeys — every key the code requires the file to define.
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var hardFloor = map[string]string{
fbChat: "даже не знаю, что сказать.",
fbQueryUnknown: "не знаю.",
fbQuerySources: "вот что я нашла: {sources}",
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
}
type fallbackEntry struct {
// Fixed — one variant, never picked between. For wording that must not drift
// from turn to turn, like the gap phrase that names an unavailable model.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type fallbackFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]fallbackEntry `json:"entries"`
}
// Fallbacks picks a hand-written Russian fallback line.
//
// Safe for concurrent use. Never the same variant twice in a row for the same
// entry: hearing the identical words every time a request fails is how a failure
// stops registering as one.
type Fallbacks struct {
mu sync.Mutex
rnd *rand.Rand
last map[string]string
file fallbackFile
}
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
var f fallbackFile
if err := json.Unmarshal(fallbackJSON, &f); err != nil {
return nil, fmt.Errorf("fallbacks: parse: %w", err)
}
if f.SchemaVersion != FallbackSchemaVersion {
return nil, fmt.Errorf("fallbacks: schema_version %d, want %d",
f.SchemaVersion, FallbackSchemaVersion)
}
for _, k := range fbKeys {
e, ok := f.Entries[k]
if !ok || len(e.Variants) == 0 {
return nil, fmt.Errorf("fallbacks: entry %q is missing or empty", k)
}
if e.Fixed && len(e.Variants) != 1 {
return nil, fmt.Errorf("fallbacks: entry %q is fixed but has %d variants", k, len(e.Variants))
}
}
// query_sources is the one entry whose whole job is to read something back,
// so a variant without the placeholder would silently drop the sources.
for _, v := range f.Entries[fbQuerySources].Variants {
if !strings.Contains(v, "{sources}") {
return nil, fmt.Errorf("fallbacks: %q variant %q does not use {sources}", fbQuerySources, v)
}
}
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &Fallbacks{rnd: rand.New(src), last: map[string]string{}, file: f}, nil
}
// text returns one variant for key, with {sources} filled in. A nil receiver is
// the unloadable-file case and answers from hardFloor, so the caller never has
// to check whether the templates loaded.
func (f *Fallbacks) text(key, sources string) string {
tmpl := hardFloor[key]
if f != nil {
if e, ok := f.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = f.pick(key, e)
}
}
return strings.ReplaceAll(tmpl, "{sources}", sources)
}
// pick chooses at random, skipping whatever this entry said last time.
func (f *Fallbacks) pick(key string, e fallbackEntry) string {
f.mu.Lock()
defer f.mu.Unlock()
choices := e.Variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
if v != f.last[key] {
fresh = append(fresh, v)
}
}
if len(fresh) > 0 {
choices = fresh
}
}
got := choices[f.rnd.Intn(len(choices))]
f.last[key] = got
return got
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.text(fbChat, "") }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.text(fbQueryUnknown, "") }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.text(fbQuerySources, sources)
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.text(fbWorldGap, "") }
// Variants returns every line the file can produce, for the persona scorer.
// Order is stable so a failure names the same variant twice running.
func (f *Fallbacks) Variants() []string {
var out []string
for _, k := range fbKeys {
out = append(out, f.file.Entries[k].Variants...)
}
return out
}
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
// one — and because a template file that is embedded and validated at load has
// nothing per-instance to configure.
var (
fallbackOnce sync.Once
fallbacks *Fallbacks
)
// DefaultFallbacks returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Fallbacks, which still answers from
// hardFloor — a daemon must not fail to boot over its own copy deck.
func DefaultFallbacks() *Fallbacks {
fallbackOnce.Do(func() {
fb, err := LoadFallbacks(nil)
if err != nil {
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
return
}
fallbacks = fb
})
return fallbacks
}
// ChatFallback — what she says when the chat path produced nothing.
func ChatFallback() string { return DefaultFallbacks().Chat() }
// UnknownFallback — what she says when she has no answer and will not invent one.
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
// SourcesFallback — read the sources back rather than ship a broken fragment.
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
// WorldGap — what he hears when the world model is configured and unreachable.
func WorldGap() string { return DefaultFallbacks().WorldGap() }
// matches reports whether text is a line the given entry could have produced.
// A caller that has to recognise a fallback cannot compare against one literal
// any more, because the entry picks between variants.
func (f *Fallbacks) matches(key, sources, text string) bool {
if strings.ReplaceAll(hardFloor[key], "{sources}", sources) == text {
return true
}
if f == nil {
return false
}
for _, v := range f.file.Entries[key].Variants {
if strings.ReplaceAll(v, "{sources}", sources) == text {
return true
}
}
return false
}
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().matches(fbQueryUnknown, "", text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().matches(fbQuerySources, sources, text)
}
-42
View File
@@ -1,42 +0,0 @@
{
"schema_version": 1,
"name": "russian phrasing fallbacks v1",
"notes": [
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
],
"entries": {
"chat": {
"variants": [
"даже не знаю, что сказать.",
"не могу найти слов.",
"мысль ускользнула, повтори?",
"у меня сейчас пусто в голове."
]
},
"query_unknown": {
"variants": [
"не знаю.",
"не знаю, честно.",
"тут я пас.",
"не скажу, не знаю."
]
},
"query_sources": {
"variants": [
"вот что я нашла: {sources}",
"нашла вот это: {sources}",
"есть только это: {sources}"
]
},
"world_gap": {
"fixed": true,
"variants": [
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
]
}
}
}
-57
View File
@@ -1,57 +0,0 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
// The embedded file must load, or the daemon speaks from hardFloor and nobody
// finds out until he hears the wrong words.
func TestFallbacksLoad(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
t.Errorf("FromSources = %q, want the sources in it", got)
}
if fb.WorldGap() != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
}
}
// A broken or missing file must not take her last words away: every accessor
// answers from the literal it replaced.
func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
var fb *Fallbacks
if got := fb.Chat(); got != hardFloor[fbChat] {
t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
}
if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
}
if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
t.Errorf("FromSources = %q", got)
}
if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q", got)
}
}
// Hearing the identical words every time a request fails is how a failure stops
// registering as one.
func TestFallbacksDoNotRepeat(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(7))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
prev := fb.Chat()
for i := 0; i < 20; i++ {
got := fb.Chat()
if got == prev {
t.Fatalf("chat repeated %q on turn %d", got, i)
}
prev = got
}
}
+18 -27
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -27,11 +26,6 @@ import (
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
// 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
@@ -434,11 +428,8 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
}
// 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.
// 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) {
// 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
@@ -448,15 +439,13 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, 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
if err != nil || resp == "" {
return "не знаю.", nil
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
log.Printf("phraser: PhraseQuery: %v", perr)
return "не знаю.", nil
}
if text != "" {
return text, nil
@@ -468,12 +457,13 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
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
if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr)
}
return SourcesFallback(strings.Join(notes, "; ")),
fmt.Errorf("phrase query (evidence): %w", cause)
if len(notes) == 1 {
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
}
if text != "" {
return text, nil
@@ -482,9 +472,8 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
}
// 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.
// 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{
@@ -501,11 +490,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
log.Printf("phraser: PhraseChat: %v", err)
return "поговорили.", nil
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
log.Printf("phraser: PhraseChat: %v", perr)
return "поговорили.", nil
}
if text != "" {
return text, nil
+6 -3
View File
@@ -70,15 +70,18 @@ func NewStub() *Stub { return &Stub{} }
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return ChatFallback(), nil
return "поговорили.", nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 {
return UnknownFallback(), nil
return "не знаю.", nil
}
return SourcesFallback(strings.Join(notes, "; ")), nil
if len(notes) == 1 {
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
}
// Close implements Phraser.Close (no-op for the stub).
+3 -5
View File
@@ -215,12 +215,10 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
}
// Phrasing degrades to its fallback instead of failing the turn, and since
// Vikunja #397 it reports the error next to that fallback so a measuring
// caller can tell "no model" from "bad phrasing".
// Phrasing degrades to its fallback instead of failing the turn.
got, err := p.PhraseChat(context.Background(), "привет", nil)
if !errors.Is(err, ErrNoBackend) {
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
if err != nil {
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
}
if got == "" {
t.Error("PhraseChat returned empty; the fallback must still say something")
+5 -1
View File
@@ -210,7 +210,11 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
d.Slots.HasKey = a.Key != ""
case IntentReminder:
d.Intent = IntentReminder
d.Slots.Text = firstNonEmpty(a.Text, utterance)
// No utterance fallback here, unlike every other intent below. The
// model returning no text for a reminder means it found no subject,
// and "напомни в 11" is not a subject. Leaving Text empty is what
// lets the gate turn that into a question (Vikunja #383).
d.Slots.Text = a.Text
case IntentNote:
d.Intent = IntentNote
d.Slots.Text = firstNonEmpty(a.Text, utterance)
+32
View File
@@ -356,3 +356,35 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
}
}
// A reminder with a time and no subject must come back empty and gated, not
// backfilled with the raw words. "напомни в 11" carries an hour and nothing to
// say at that hour; parking the utterance in Text made the request look
// complete, so the daemon set a reminder that fires saying "напомни в 11"
// (Vikunja #383).
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
d, err := r.Route(context.Background(), "напомни в 11", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Slots.Text != "" {
t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text)
}
if !d.Clarify {
t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence)
}
}
// The gate is about the subject, not about reminders in general: one that has
// both halves still runs without a question.
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Clarify {
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
}
}
+15 -1
View File
@@ -147,7 +147,15 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
}
}
if d.Slots.Text == "" {
// The extractor's Text is the raw utterance, which is the payload for a
// note, a query or a chat turn but not for a reminder — there Text is the
// subject, what she says at the hour. Backfilling it made Text impossible
// to be empty, so StillMissing never reported SlotText and "О чём
// напомнить?" was unaskable; the answer to a question she did manage to
// ask then overwrote the whole request instead of filling one gap
// (Vikunja #383). A reminder with no subject stays empty and is gated
// below into a question.
if d.Slots.Text == "" && d.Intent != IntentReminder {
d.Slots.Text = ex.Text
}
// Stage stays 1: it says who decided the route, and that was the LLM.
@@ -177,6 +185,12 @@ func (r *Router) gateLLMDecision(d *Decision) {
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
// A reminder with no subject: she knows when but not what to say then.
// Setting it anyway fires an empty reminder at the hour, which reads as a
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
if d.Confidence < r.threshold {
d.Clarify = true
}