diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 913210c..84d486b 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -512,19 +512,40 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes [] // 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}, } - // Combine history and current utterance into one user message. - // Some model chat templates (Ministral, etc.) reject consecutive user turns. - var combined string - for _, t := range history { - combined += t.Text + "\n" - } - combined += utterance - msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)}) + msgs = append(msgs, chatMsg{Role: "user", Content: chatUserMessage(utterance, history)}) resp, err := p.chatWithMessages(ctx, msgs, 768) if err != nil { diff --git a/internal/phraser/llmphraser_chatmsg_test.go b/internal/phraser/llmphraser_chatmsg_test.go new file mode 100644 index 0000000..466efb5 --- /dev/null +++ b/internal/phraser/llmphraser_chatmsg_test.go @@ -0,0 +1,42 @@ +package phraser + +import ( + "strings" + "testing" + + "github.com/kami/maven/internal/dialogue" +) + +// TestChatUserMessageMarksWhichTurnToAnswer — Vikunja #554. Four prior turns +// were joined with newlines and nothing else, so nothing in the message said +// which line was the question. +func TestChatUserMessageMarksWhichTurnToAnswer(t *testing.T) { + got := chatUserMessage("как дела", []dialogue.Turn{ + {Text: "кто изобрёл телефон"}, + {Text: "а когда это было"}, + }) + if !strings.Contains(got, "кто изобрёл телефон") { + t.Error("the prior turns must survive — they are what anaphora reads") + } + now := strings.LastIndex(got, "как дела") + if now < strings.Index(got, "кто изобрёл телефон") { + t.Error("the current utterance must come last, after the turns it follows") + } + if !strings.Contains(got, "Раньше ты говорил") { + t.Errorf("the prior turns must be labelled as prior: %q", got) + } + if strings.Contains(got, " вы ") || strings.Contains(got, "Вы ") { + t.Errorf("the persona addresses him informally: %q", got) + } +} + +// TestChatUserMessageWithNoHistoryIsJustTheUtterance — the common case must not +// grow a preamble that the model then has to see past. +func TestChatUserMessageWithNoHistoryIsJustTheUtterance(t *testing.T) { + if got := chatUserMessage(" привет ", nil); got != "привет" { + t.Errorf("chatUserMessage = %q, want %q", got, "привет") + } + if got := chatUserMessage("привет", []dialogue.Turn{{Text: " "}}); got != "привет" { + t.Errorf("a blank prior turn must not label anything: %q", got) + } +}