Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b90952e55 | |||
| aa8f5b2ee2 | |||
| d7cdcb63bd | |||
| c7dadc97d9 |
@@ -0,0 +1,150 @@
|
|||||||
|
# Conversational phrasing eval — 31-07-2026
|
||||||
|
|
||||||
|
Every score measured tonight, on the three paths the nudge eval never touched:
|
||||||
|
chat, query-with-notes, and general knowledge.
|
||||||
|
|
||||||
|
**Short version: the plumbing got fixed and the score barely moved.** Grammar and
|
||||||
|
Russian prompts together took the composite from ~9 to ~14 of 27. Everything
|
||||||
|
still failing is the model not knowing things or not holding a constraint, and
|
||||||
|
prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 /
|
||||||
|
#400.
|
||||||
|
|
||||||
|
## How to reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf
|
||||||
|
MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \
|
||||||
|
deps/go/go/bin/go test -count=1 -timeout 40m \
|
||||||
|
-run TestLLMTalkBaseline ./internal/phraser/eval/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Three runs per configuration, always. The fixture is 27 cases, so one reply
|
||||||
|
changing moves the composite by 3.7 points — a single run cannot tell a real
|
||||||
|
change from sampling noise. This was learned the expensive way: an earlier claim
|
||||||
|
that "one nudge case fails every run" turned out to be three different cases
|
||||||
|
across three runs.
|
||||||
|
|
||||||
|
**Run the box otherwise idle.** See the contamination note at the bottom.
|
||||||
|
|
||||||
|
## Composite, per configuration
|
||||||
|
|
||||||
|
| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 |
|
||||||
|
| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 |
|
||||||
|
| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 |
|
||||||
|
| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 |
|
||||||
|
| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | |
|
||||||
|
|
||||||
|
"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."`
|
||||||
|
or `"поговорили."`. It is not a check, it is a health signal: those strings mean
|
||||||
|
the phraser gave up, and the eval scores them as ordinary bad replies.
|
||||||
|
|
||||||
|
## Per-check
|
||||||
|
|
||||||
|
| check | no grammar | + grammar | + RU prompts | + truncation fix |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 |
|
||||||
|
| feminine | — | — | 25, 24, 26 | 25, 25, 27 |
|
||||||
|
| address | — | — | 21, 22, 22 | 22, 21, 22 |
|
||||||
|
| ontopic | — | — | 17, 24, 18 | 17, 19, 14 |
|
||||||
|
|
||||||
|
`nonempty` reading 27/27 everywhere is not good news — it was a broken check.
|
||||||
|
It tested for a non-blank string, so replies of literally `{` and `"15-16"`
|
||||||
|
passed it. Fixed on `overnight/fix-truncation`; it needs a letter now.
|
||||||
|
|
||||||
|
## What each change actually bought
|
||||||
|
|
||||||
|
**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes
|
||||||
|
`Thinking Process:` as plain text with no tags, `stripThink` only handles
|
||||||
|
`</think>`, so the JSON never closed and the plain-text fallback shipped the
|
||||||
|
literal reasoning. `ellipsis` went 20→27 and `lang` 13→26. The router had been
|
||||||
|
using a grammar for ages; the phraser asking nicely in the prompt was the
|
||||||
|
oversight.
|
||||||
|
|
||||||
|
**Russian prompts (#400) — modest, plus a large latency win.** Chat 1.3→3.0
|
||||||
|
average, query 4.7→6.3, knowledge 6.3→5.0. All inside the run-to-run spread, so
|
||||||
|
"probably better on the paths it targeted, not provable in three runs". p50
|
||||||
|
latency dropped from ~11.5s to ~2.3s and that part is consistent across all
|
||||||
|
three runs — shorter prompts, and she stopped emitting English reasoning first.
|
||||||
|
|
||||||
|
**Truncation fix — necessary, and did not help the score.** Two real bugs
|
||||||
|
(replies of `{`, and a `nonempty` check that passed them), both fixed, and the
|
||||||
|
composite went nowhere. A complete rambling wrong answer fails the same checks a
|
||||||
|
truncated one did. Worth doing anyway: the daemon was shipping `{` to a
|
||||||
|
text-to-speech voice.
|
||||||
|
|
||||||
|
## The truncation bug, since the cause was counter-intuitive
|
||||||
|
|
||||||
|
The grammar's `string ::= ... {0,400}` rule was the cause, not the token cap.
|
||||||
|
Measured against Qwen3.5-0.8B at three caps — 256, 768 and 2048 — the reply came
|
||||||
|
back **exactly 400 characters every time, cut mid-word** (`"Нужно записать и,"`).
|
||||||
|
|
||||||
|
Then I raised the bound to 1000 while the cap was 768 tokens and made it worse:
|
||||||
|
Russian runs ~1.5 characters per token here, so generation died on the *token*
|
||||||
|
cap instead, mid-object, and the new guard correctly refused it and shipped
|
||||||
|
`"не знаю."` — 3, 3 and 6 fallbacks per run, from zero. **The two limits have to
|
||||||
|
agree.** 600 characters needs ~400 tokens; the cap is 1024.
|
||||||
|
|
||||||
|
## Where the remaining failures live
|
||||||
|
|
||||||
|
`address` is stuck at 21-22 of 27 and `ontopic` at 14-19. Both resist prompting.
|
||||||
|
|
||||||
|
**The prompt now explicitly forbids exactly what she does.** It says never "вы",
|
||||||
|
use the singular — and she writes `вашей`, `подождите`, `делаете`, `хотите`,
|
||||||
|
`напишите`. Telling a 0.8B "never do X" does not work. Same for
|
||||||
|
`feminine`: `я готов`, `я понял`, `я нашел`, `я заметил`, `я сказал`.
|
||||||
|
|
||||||
|
**Some of `ontopic` is the fixture, not the model.** `chat-how-are-you` got
|
||||||
|
`"Привет! Я здесь, чтобы поговорить. Как дела сегодня?"` — a fine reply that
|
||||||
|
fails because `want_any` is `[норм, хорош, порядк, тут, работ]`. It fails in
|
||||||
|
every run, so it inflates the count. The `ontopic` column currently measures the
|
||||||
|
fixture as much as the model. Not fixed yet, deliberately: changing it would
|
||||||
|
break comparability with the runs above.
|
||||||
|
|
||||||
|
**Two replies worth reading, because they are not fixable by prompting:**
|
||||||
|
|
||||||
|
- Thunder and lightning: *"Скорость молнии — 8-10 тысяч километров в секунду, но
|
||||||
|
звук — 300 метров в секунду, что делает молнию громче."* Confidently wrong,
|
||||||
|
and it concludes lightning is *louder* rather than sound being *slower*.
|
||||||
|
- "расскажи обо мне": *"Ты — прекрасное существо, с душой и вниманием… Спасибо за
|
||||||
|
твою улыбку… О тебе — заповедь любви."* Sycophantic filler, zero information,
|
||||||
|
and precisely the "not a relationship" non-goal.
|
||||||
|
- Boiling an egg: `"15-16"` one run, `"1"` another. No unit, wrong number.
|
||||||
|
|
||||||
|
The first argues for reading instead of recalling (#403 — Kiwix retrieval scores
|
||||||
|
8/8 on the same questions given English keywords). The second and third argue
|
||||||
|
for templates on the paths where correctness matters (#392).
|
||||||
|
|
||||||
|
## Contamination note — how the last row got voided
|
||||||
|
|
||||||
|
I started the query-rewrite agent against the same llama-server the sweep was
|
||||||
|
using, and assumed contention would only affect latency. It did not. The
|
||||||
|
knowledge path collapsed to 0 of 9 with eight canned `"не знаю."` replies, p95
|
||||||
|
tripled to 23.7s, and **the report still said "0 errors"**.
|
||||||
|
|
||||||
|
That is Vikunja #397, and it is worse than filed: a merely *busy* server
|
||||||
|
produces a clean-looking report with a third of the fixture silently answering
|
||||||
|
`"не знаю."`. `PhraseChat` and `PhraseQuery` swallow every failure and return a
|
||||||
|
hardcoded string, so infrastructure trouble is indistinguishable from bad
|
||||||
|
phrasing in the score. The talk test guards the *start* and *end* of a run with
|
||||||
|
a model check, which catches a dead server but not a loaded one.
|
||||||
|
|
||||||
|
**Until #397 is fixed, treat any run made on a busy box as void.**
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Re-run 600ch/1024tok clean, to fill the void row.
|
||||||
|
- Score `Qwen3.5-2B-UD-Q4_K_XL` (already at `/mnt/hdd1/llms/qwen3.5/`, never
|
||||||
|
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||||
|
this box, owner's call.
|
||||||
|
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||||
|
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
||||||
|
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||||
|
older generation, so that result does not predict the small ones.
|
||||||
|
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||||
|
measures the model.
|
||||||
|
- #397 first if anything, since it decides whether any of the above is
|
||||||
|
trustworthy.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A reply that starts a JSON object and never finishes it is a failed
|
||||||
|
// generation, not a reply. Before this, the parser returned ("", "") for these
|
||||||
|
// and every caller then shipped the raw fragment as the thing Maven said. A
|
||||||
|
// real run produced replies of literally "{" and "{\n \"".
|
||||||
|
func TestParseResponseMoodRejectsUnfinishedJSON(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
`{`,
|
||||||
|
"{\n \"",
|
||||||
|
`{"response": "неполн`,
|
||||||
|
`{"response": "текст", "mood":`,
|
||||||
|
} {
|
||||||
|
text, mood, err := parseResponseMood(raw)
|
||||||
|
if !errors.Is(err, errBrokenJSON) {
|
||||||
|
t.Errorf("parseResponseMood(%q) err = %v, want errBrokenJSON", raw, err)
|
||||||
|
}
|
||||||
|
if text != "" || mood != "" {
|
||||||
|
t.Errorf("parseResponseMood(%q) leaked %q/%q — a fragment must never come back as a reply", raw, text, mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare prose is still fine. Small models sometimes answer without any JSON at
|
||||||
|
// all, and that reply is usable — so the new error must not swallow it.
|
||||||
|
func TestParseResponseMoodAllowsBareProse(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"норм, а ты как?",
|
||||||
|
"вот что я нашла: ключ у соседа",
|
||||||
|
} {
|
||||||
|
text, mood, err := parseResponseMood(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parseResponseMood(%q) err = %v, want nil", raw, err)
|
||||||
|
}
|
||||||
|
// No JSON means no fields; the caller ships raw as-is.
|
||||||
|
if text != "" || mood != "" {
|
||||||
|
t.Errorf("parseResponseMood(%q) = %q/%q, want empty", raw, text, mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measured failure: the model wants more than 400 characters and the old
|
||||||
|
// grammar cut it off mid-word. Guards the bound against being tightened back.
|
||||||
|
func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
|
||||||
|
if !strings.Contains(responseGrammar, "{0,1000}") {
|
||||||
|
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,35 @@ func TestAddressDeduplicates(t *testing.T) {
|
|||||||
t.Errorf("detail repeats the same break %d times: %q", n, res.Detail)
|
t.Errorf("detail repeats the same break %d times: %q", n, res.Detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fragments a real run produced. All of them scored as non-empty replies
|
||||||
|
// before checkNonEmpty looked for letters.
|
||||||
|
func TestNonEmptyNeedsLetters(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"{",
|
||||||
|
"{\n \"",
|
||||||
|
"15-16",
|
||||||
|
`{"`,
|
||||||
|
" ",
|
||||||
|
"...",
|
||||||
|
} {
|
||||||
|
if got := checkNonEmpty(body); got.Pass {
|
||||||
|
t.Errorf("checkNonEmpty(%q) passed — that is not a reply", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it must not start failing real replies. Latin counts as well as Cyrillic:
|
||||||
|
// answers about ssd or vpn are legitimately part English.
|
||||||
|
func TestNonEmptyAcceptsRealReplies(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"норм, а ты как?",
|
||||||
|
"вот что я нашла: ключ у соседа",
|
||||||
|
"ssd быстрее hdd.",
|
||||||
|
"9 минут.",
|
||||||
|
} {
|
||||||
|
if got := checkNonEmpty(body); !got.Pass {
|
||||||
|
t.Errorf("checkNonEmpty(%q) failed: %s", body, got.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -623,11 +623,24 @@ const (
|
|||||||
CheckEllipsis = "ellipsis" // she finished the sentence
|
CheckEllipsis = "ellipsis" // she finished the sentence
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A reply needs words in it, not just characters. This check used to test for a
|
||||||
|
// non-empty string, which scored 27/27 on a run where two replies were "{" and
|
||||||
|
// "{\n \"" — punctuation passed as content. Braces, quotes, digits and spaces
|
||||||
|
// are all empty in the only sense that matters.
|
||||||
|
//
|
||||||
|
// Digits alone fail too, and that is deliberate: the same run answered "сколько
|
||||||
|
// варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the
|
||||||
|
// wrong number. Whatever that is, it is not something she said.
|
||||||
func checkNonEmpty(body string) Result {
|
func checkNonEmpty(body string) Result {
|
||||||
if strings.TrimSpace(body) == "" {
|
if strings.TrimSpace(body) == "" {
|
||||||
return Result{CheckNonEmpty, false, "empty reply"}
|
return Result{CheckNonEmpty, false, "empty reply"}
|
||||||
}
|
}
|
||||||
return Result{CheckNonEmpty, true, ""}
|
for _, r := range body {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
return Result{CheckNonEmpty, true, ""}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
|
// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
|||||||
// Russian body with an escaped quote inside, hand-built to test the contract.
|
// Russian body with an escaped quote inside, hand-built to test the contract.
|
||||||
func TestGrammarShapedJSONParses(t *testing.T) {
|
func TestGrammarShapedJSONParses(t *testing.T) {
|
||||||
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
|
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
|
||||||
text, mood := parseResponseMood(raw)
|
text, mood, err := parseResponseMood(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grammar-shaped JSON did not parse: %v", err)
|
||||||
|
}
|
||||||
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
|
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
|
||||||
t.Errorf("response = %q, want %q", text, want)
|
t.Errorf("response = %q, want %q", text, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,12 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.PhrasedNudge{}, err
|
return delivery.PhrasedNudge{}, err
|
||||||
}
|
}
|
||||||
body, mood := parseResponseMood(resp)
|
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 == "" {
|
if body == "" {
|
||||||
// fallback: try old body/summary format
|
// fallback: try old body/summary format
|
||||||
body, _ = parsePhrase(resp)
|
body, _ = parsePhrase(resp)
|
||||||
@@ -216,11 +221,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
// prompt is the single tested source in router.KnowledgePrompt.
|
// prompt is the single tested source in router.KnowledgePrompt.
|
||||||
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
||||||
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||||
if err != nil || resp == "" {
|
if err != nil || resp == "" {
|
||||||
return "не знаю.", nil
|
return "не знаю.", nil
|
||||||
}
|
}
|
||||||
if text, _ := parseResponseMood(resp); text != "" {
|
text, _, perr := parseResponseMood(resp)
|
||||||
|
if perr != nil {
|
||||||
|
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||||
|
return "не знаю.", nil
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -230,17 +240,22 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
}
|
}
|
||||||
sys := p.querySystemPrompt()
|
sys := p.querySystemPrompt()
|
||||||
prompt := fmt.Sprintf(
|
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.`,
|
`Он спрашивает: "%s". В твоих заметках по этому вопросу написано: "%s". Ответь ему коротко и своими словами. Если в заметках ответа нет — так и скажи.`,
|
||||||
utterance, strings.Join(notes, `"; "`),
|
utterance, strings.Join(notes, `"; "`),
|
||||||
)
|
)
|
||||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||||
if err != nil {
|
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 {
|
if len(notes) == 1 {
|
||||||
return "вот что я нашла: " + notes[0], nil
|
return "вот что я нашла: " + notes[0], nil
|
||||||
}
|
}
|
||||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||||
}
|
}
|
||||||
if text, _ := parseResponseMood(resp); text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -263,12 +278,17 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
|||||||
combined += utterance
|
combined += utterance
|
||||||
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
||||||
|
|
||||||
resp, err := p.chatWithMessages(ctx, msgs, 512)
|
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("phraser: PhraseChat: %v", err)
|
log.Printf("phraser: PhraseChat: %v", err)
|
||||||
return "поговорили.", nil
|
return "поговорили.", nil
|
||||||
}
|
}
|
||||||
if text, _ := parseResponseMood(resp); text != "" {
|
text, _, perr := parseResponseMood(resp)
|
||||||
|
if perr != nil {
|
||||||
|
log.Printf("phraser: PhraseChat: %v", perr)
|
||||||
|
return "поговорили.", nil
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
// fallback: plain text without JSON
|
// fallback: plain text without JSON
|
||||||
@@ -281,11 +301,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
|||||||
// chatSystemPrompt returns the system prompt for conversational chat.
|
// chatSystemPrompt returns the system prompt for conversational chat.
|
||||||
// Prepends the shared context block when the phraser has one.
|
// Prepends the shared context block when the phraser has one.
|
||||||
func chatSystemPrompt(block func() string) string {
|
func chatSystemPrompt(block func() string) string {
|
||||||
base := `You are maven, a self-hosted personal assistant. You're talking with your owner.
|
// No self-introduction here: the persona block prepended one line above
|
||||||
Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm.
|
// already says who she is, same as router.KnowledgePrompt.
|
||||||
Respond in the user's language (Russian or English, matching their last message).
|
base := `Ты разговариваешь с хозяином. О себе говоришь в женском роде ("я подумала", "я рада"). Он мужчина: обращайся к нему на "ты", в мужском роде ("ты сказал", "ты забыл"). Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||||
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).`
|
Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай.
|
||||||
|
|
||||||
|
Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.`
|
||||||
return persona.Prepend(block, base)
|
return persona.Prepend(block, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +371,12 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.PhrasedReminder{}, err
|
return delivery.PhrasedReminder{}, err
|
||||||
}
|
}
|
||||||
body, mood := parseResponseMood(resp)
|
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 == "" {
|
if body == "" {
|
||||||
// fallback: try old body/summary format
|
// fallback: try old body/summary format
|
||||||
body, _ = parsePhrase(resp)
|
body, _ = parsePhrase(resp)
|
||||||
@@ -394,10 +421,16 @@ type chatReq struct {
|
|||||||
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
|
// 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 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.
|
// 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 = `
|
const responseGrammar = `
|
||||||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,400} "\""
|
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
|
||||||
ws ::= [ \t\n]*
|
ws ::= [ \t\n]*
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -507,7 +540,9 @@ func (p *LLMPhraser) systemPrompt() string {
|
|||||||
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
||||||
// knowledge). Prepends the configured persona when set.
|
// knowledge). Prepends the configured persona when set.
|
||||||
func (p *LLMPhraser) querySystemPrompt() string {
|
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\"}."
|
// 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)
|
return persona.Prepend(p.cfg.ContextBlock, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,21 +664,39 @@ type responseMood struct {
|
|||||||
Mood string `json:"mood"`
|
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
|
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||||||
// of thinking tokens and extra text before/after the JSON block. Returns
|
// of thinking tokens and extra text before/after the JSON block.
|
||||||
// ("", "") when no valid JSON is found.
|
//
|
||||||
func parseResponseMood(raw string) (response, mood string) {
|
// 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)
|
cleaned := strings.TrimSpace(raw)
|
||||||
start := strings.Index(cleaned, "{")
|
start := strings.Index(cleaned, "{")
|
||||||
end := strings.LastIndex(cleaned, "}")
|
end := strings.LastIndex(cleaned, "}")
|
||||||
if start < 0 || end < 0 || end <= start {
|
if start < 0 || end < 0 || end <= start {
|
||||||
return "", ""
|
if strings.HasPrefix(cleaned, "{") {
|
||||||
|
return "", "", errBrokenJSON
|
||||||
|
}
|
||||||
|
return "", "", nil
|
||||||
}
|
}
|
||||||
var parsed responseMood
|
var parsed responseMood
|
||||||
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
|
||||||
return "", ""
|
if strings.HasPrefix(cleaned, "{") {
|
||||||
|
return "", "", errBrokenJSON
|
||||||
|
}
|
||||||
|
return "", "", nil
|
||||||
}
|
}
|
||||||
return parsed.Response, parsed.Mood
|
return parsed.Response, parsed.Mood, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePhrase(raw string) (body, summary string) {
|
func parsePhrase(raw string) (body, summary string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user