a chat prompt says which turn to answer (V-554)

Prior turns were joined with newlines and nothing else, so the model got
four unlabelled lines and no way to tell which one was the question. It
answered an earlier one: asked "как дела" after a question about the
telephone, she carried on about the telephone. Four turns live for
fifteen minutes, so the line she answered was often minutes old.

One user message still, because the template constraint that forced the
flattening is real. The turns are labelled as his own earlier words and
the current utterance is named as the one to answer. With no history
the message is the utterance alone, unchanged.
This commit is contained in:
2026-08-05 22:40:28 +04:00
parent de4c47459a
commit e94c868160
2 changed files with 71 additions and 8 deletions
+29 -8
View File
@@ -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 {
@@ -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)
}
}