mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-23 17:06:42 +08:00
fix: prevent thinking content from leaking into visible answer in shared chat (Go mode) (#17225)
This commit is contained in:
@@ -429,11 +429,14 @@ func (s *BotService) ChatbotCompletion(
|
||||
}
|
||||
|
||||
// 5. Translate pipeline results into chatbot frames. The
|
||||
// pipeline streams deltas; the python iframe contract sends the
|
||||
// full accumulated answer in every frame, so we accumulate here
|
||||
// (the front-end accepts both shapes, but full-text frames are
|
||||
// byte-parity with python). The final pipeline result carries
|
||||
// the decorated full answer plus the retrieval reference.
|
||||
// pipeline streams deltas; the python iframe contract also yields
|
||||
// deltas (async_chat yields the value from _stream_with_think_delta).
|
||||
// We accumulate the full answer locally for persistence and for the
|
||||
// final frame, but forward each result's own delta in the SSE frame
|
||||
// so the front-end can build the <think>-wrapped answer correctly.
|
||||
// Sending accumulated full text on every frame interacts badly with
|
||||
// the front-end's start_to_think/end_to_think marker append, causing
|
||||
// reasoning content to leak into the visible answer.
|
||||
out := make(chan ChatbotSSEFrame, 16)
|
||||
go func() {
|
||||
defer close(out)
|
||||
@@ -468,8 +471,10 @@ func (s *BotService) ChatbotCompletion(
|
||||
continue
|
||||
}
|
||||
if res.StartToThink || res.EndToThink {
|
||||
// Marker frames carry no text; the front-end appends
|
||||
// <think> / </think> to the accumulated answer.
|
||||
out <- ChatbotSSEFrame{
|
||||
Data: fullAnswer,
|
||||
Data: "",
|
||||
Reference: map[string]any{},
|
||||
SessionID: session.ID,
|
||||
StartToThink: res.StartToThink,
|
||||
@@ -489,7 +494,7 @@ func (s *BotService) ChatbotCompletion(
|
||||
finalRef = res.Reference
|
||||
}
|
||||
out <- ChatbotSSEFrame{
|
||||
Data: fullAnswer,
|
||||
Data: res.Answer,
|
||||
Reference: map[string]any{},
|
||||
SessionID: session.ID,
|
||||
}
|
||||
|
||||
@@ -53,22 +53,29 @@ type ThinkStreamState struct {
|
||||
}
|
||||
|
||||
// EnterReasoning marks the start of a reasoning block (model-level, not tag-based).
|
||||
// It also writes a <think> marker into fullText so that ExtractVisibleAnswer can
|
||||
// strip the reasoning segment from the final answer when the model exposes
|
||||
// reasoning through a separate channel (e.g. delta.reasoning_content).
|
||||
// Returns true when this is a new transition (reasoning was not already active).
|
||||
func (s *ThinkStreamState) EnterReasoning() bool {
|
||||
if s.inReasoning {
|
||||
return false
|
||||
}
|
||||
s.inReasoning = true
|
||||
s.fullText += "<think>"
|
||||
return true
|
||||
}
|
||||
|
||||
// ExitReasoning marks the end of a reasoning block (model-level, not tag-based).
|
||||
// It writes a matching </think> marker into fullText so the reasoning segment is
|
||||
// closed before the visible answer text begins.
|
||||
// Returns true when this is a new transition (reasoning was active).
|
||||
func (s *ThinkStreamState) ExitReasoning() bool {
|
||||
if !s.inReasoning {
|
||||
return false
|
||||
}
|
||||
s.inReasoning = false
|
||||
s.fullText += "</think>"
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -301,3 +301,39 @@ func TestStreamThinkTagDelta_DeferredClose(t *testing.T) {
|
||||
t.Errorf("second marker = %q", markers[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningChannelWrapsThinkTags verifies that model-level reasoning
|
||||
// (arriving through a separate reason channel rather than <think> tags in
|
||||
// the text) is bracketed by <think></think> in fullText. This prevents the
|
||||
// reasoning content from leaking into the final visible answer.
|
||||
func TestReasoningChannelWrapsThinkTags(t *testing.T) {
|
||||
state := &ThinkStreamState{}
|
||||
|
||||
if !state.EnterReasoning() {
|
||||
t.Fatal("EnterReasoning should return true on first call")
|
||||
}
|
||||
// Simulate the chat pipeline feeding reasoning chunks through NextThinkDelta.
|
||||
deltas := NextThinkDelta(state, "reasoning step 1", 0)
|
||||
if len(deltas) != 1 || deltas[0].Value != "reasoning step 1" {
|
||||
t.Fatalf("unexpected reasoning deltas: %+v", deltas)
|
||||
}
|
||||
deltas = NextThinkDelta(state, " reasoning step 2", 0)
|
||||
if len(deltas) != 1 || deltas[0].Value != " reasoning step 2" {
|
||||
t.Fatalf("unexpected reasoning deltas: %+v", deltas)
|
||||
}
|
||||
|
||||
if state.ExitReasoning() != true {
|
||||
t.Fatal("ExitReasoning should return true when reasoning was active")
|
||||
}
|
||||
|
||||
// Visible answer arrives after reasoning ends.
|
||||
deltas = BufferAnswerDelta(state, "visible answer", 0)
|
||||
if len(deltas) != 1 || deltas[0].Value != "visible answer" {
|
||||
t.Fatalf("unexpected answer deltas: %+v", deltas)
|
||||
}
|
||||
|
||||
wantVisible := "visible answer"
|
||||
if got := ExtractVisibleAnswer(state.fullText); got != wantVisible {
|
||||
t.Errorf("ExtractVisibleAnswer(%q) = %q, want %q", state.fullText, got, wantVisible)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user