4dbeca5a2e
Qwen3-1.7B pretty-prints its JSON: it opens the object and writes three newlines before the first key. escapeRawControls rewrote those structural newlines into a literal backslash-n, which is legal nowhere outside a string, so the object stopped parsing and came back as errBrokenJSON. The comment claimed escaping unconditionally could not turn valid JSON into anything else, on the grounds that JSON permits no control character outside a string. It permits three: newline, tab and return are whitespace between tokens, and that is what pretty-printing is made of. Measured on the talk fixture against the resident model: 31 of 36 conversational cases were failing generations and answered from the stub. Every chat reply and every knowledge answer the resident model wrote was being discarded. Now 25/36 pass every check, 0 errors, and the 15 nudges stay at 15/15.
138 lines
5.2 KiB
Go
138 lines
5.2 KiB
Go
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)")
|
||
}
|
||
}
|
||
|
||
// A multi-line reply is the sixty-failure shape from the 2026-08-05 temperature
|
||
// sweep (Vikunja #537). The model wrote a literal newline inside the string,
|
||
// which the old grammar allowed and json.Unmarshal rejects with "invalid
|
||
// character '\n' in string literal". The object starts with "{", so it came back
|
||
// as errBrokenJSON and the case answered with nothing at all.
|
||
//
|
||
// The grammar is the fix. This is the parser's own second line, for the paths
|
||
// that send no grammar: the reply is readable, so read it.
|
||
func TestParseResponseMoodRepairsARawNewline(t *testing.T) {
|
||
raw := "{\"response\": \"первая строка\nвторая строка\", \"mood\": \"neutral\"}"
|
||
text, mood, err := parseResponseMood(raw)
|
||
if err != nil {
|
||
t.Fatalf("err = %v, want nil — a raw newline is repairable, not a failed generation", err)
|
||
}
|
||
if want := "первая строка\nвторая строка"; text != want {
|
||
t.Errorf("response = %q, want %q", text, want)
|
||
}
|
||
if mood != "neutral" {
|
||
t.Errorf("mood = %q, want neutral", mood)
|
||
}
|
||
}
|
||
|
||
// Repairing must not rewrite JSON that was already fine: an escaped newline
|
||
// stays one newline, and a backslash the model escaped properly is left alone.
|
||
func TestEscapeRawControlsLeavesValidJSONAlone(t *testing.T) {
|
||
raw := `{"response": "строка\nдве \\ и \"кавычки\"", "mood": "happy"}`
|
||
if got := escapeRawControls(raw); got != raw {
|
||
t.Errorf("escapeRawControls rewrote valid JSON:\n got %q\nwant %q", got, raw)
|
||
}
|
||
text, _, err := parseResponseMood(raw)
|
||
if err != nil {
|
||
t.Fatalf("err = %v", err)
|
||
}
|
||
if want := "строка\nдве \\ и \"кавычки\""; text != want {
|
||
t.Errorf("response = %q, want %q", text, want)
|
||
}
|
||
}
|
||
|
||
// Pretty-printed JSON is what Qwen3-1.7B writes: it opens the object and puts
|
||
// three newlines before the first key. Escaping those structural newlines made
|
||
// the object unparseable, so 31 of 36 conversational cases in the talk fixture
|
||
// answered from the stub (Vikunja #44, measured 2026-08-05).
|
||
func TestParseResponseMoodReadsPrettyPrintedJSON(t *testing.T) {
|
||
raw := "{\n\n\n \"response\": \"Хорошо настроение.\",\n \"mood\": \"neutral\"\n}"
|
||
text, mood, err := parseResponseMood(raw)
|
||
if err != nil {
|
||
t.Fatalf("err = %v, want nil — this is valid JSON, not a failed generation", err)
|
||
}
|
||
if want := "Хорошо настроение."; text != want {
|
||
t.Errorf("response = %q, want %q", text, want)
|
||
}
|
||
if mood != "neutral" {
|
||
t.Errorf("mood = %q, want neutral", mood)
|
||
}
|
||
}
|
||
|
||
// Both at once: structural newlines outside the strings, a raw one inside.
|
||
func TestParseResponseMoodRepairsInsideAndKeepsOutside(t *testing.T) {
|
||
raw := "{\n\t\"response\": \"первая\nвторая\",\n\t\"mood\": \"tired\"\n}"
|
||
text, mood, err := parseResponseMood(raw)
|
||
if err != nil {
|
||
t.Fatalf("err = %v, want nil", err)
|
||
}
|
||
if want := "первая\nвторая"; text != want {
|
||
t.Errorf("response = %q, want %q", text, want)
|
||
}
|
||
if mood != "tired" {
|
||
t.Errorf("mood = %q, want tired", mood)
|
||
}
|
||
}
|
||
|
||
// A tab and a bare control byte take the same path as the newline.
|
||
func TestParseResponseMoodRepairsOtherControls(t *testing.T) {
|
||
raw := "{\"response\": \"таб\tи \x01байт\", \"mood\": \"tired\"}"
|
||
text, _, err := parseResponseMood(raw)
|
||
if err != nil {
|
||
t.Fatalf("err = %v, want nil", err)
|
||
}
|
||
if want := "таб\tи \x01байт"; text != want {
|
||
t.Errorf("response = %q, want %q", text, want)
|
||
}
|
||
}
|