fix(reasoning): keep the answer body's lead-in and images when trimming narration
The DingTalk card dropped the answer's opening (and any image reference in it) whenever the agent narrated its steps before answering: the text-only boundary heuristic (reasoningmode.BodyStartOffset) treats everything before the first structural line as narration, so a lead-in like "我已经找到了…" plus the image that follows it disappeared, leaving the card to start at the first heading. The event stream already knows the boundary: every agent step publishes its answer chunks under its own answer run id (data.event_id), and the final body is the last run that no tool/thinking event interrupts. WeKnora StreamEvent now carries the run id and run start offset; qa.Service tracks the observation and prefers it over the heuristic, falling back to the heuristic when the stream carries no run information. Verified: production turn efae317e (给我CRA5的负载图) lost 131 chars — lead-in + the load-curve image + separator — and now keeps them.
This commit is contained in:
+24
-1
@@ -248,10 +248,33 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
// observing knowledge_search tool-call events. The flag feeds coverage
|
||||
// classification (image turns) and is persisted with the turn.
|
||||
var knowledgeSearched bool
|
||||
// The stream itself separates the agent's step narration from the final
|
||||
// answer body: every step publishes its answer chunks under its own answer
|
||||
// run id (data.event_id), and the last run that no tool/thinking event
|
||||
// interrupts is the final body. Track that structure so the delivered
|
||||
// answer keeps the body's lead-in — and any image reference inside it —
|
||||
// instead of guessing the boundary from the text.
|
||||
var narrationObservation reasoningmode.NarrationObservation
|
||||
lastAnswerRunID := ""
|
||||
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
|
||||
if isKnowledgeTool(e.ToolName) {
|
||||
knowledgeSearched = true
|
||||
}
|
||||
switch e.ResponseType {
|
||||
case "answer":
|
||||
if e.AnswerRunID != "" {
|
||||
narrationObservation.Observed = true
|
||||
if e.AnswerRunID != lastAnswerRunID {
|
||||
lastAnswerRunID = e.AnswerRunID
|
||||
narrationObservation.AnswerRuns++
|
||||
narrationObservation.LastRunStart = e.AnswerRunStart
|
||||
narrationObservation.LastRunClean = true
|
||||
}
|
||||
}
|
||||
case "thinking", "tool_call", "tool_result":
|
||||
narrationObservation.ToolEvents++
|
||||
narrationObservation.LastRunClean = false
|
||||
}
|
||||
if eventObserver != nil {
|
||||
eventObserver(e)
|
||||
}
|
||||
@@ -334,7 +357,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
// narration stays visible while the card streams (line by line) and is
|
||||
// dropped once the final body starts.
|
||||
if reasoningmode.IsReasoningExecution(request.ReasoningExecution) {
|
||||
if trimmed := reasoningmode.TrimReasoningNarration(chat.Answer); trimmed != chat.Answer {
|
||||
if trimmed := reasoningmode.TrimReasoningNarrationObserved(chat.Answer, narrationObservation); trimmed != chat.Answer {
|
||||
turn.Answer, chat.Answer = trimmed, trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package reasoningmode
|
||||
|
||||
import "strings"
|
||||
|
||||
// NarrationObservation carries what the WeKnora event stream knows about the
|
||||
// boundary between an agent step's narration and the final answer body.
|
||||
//
|
||||
// The text-only heuristic (BodyStartOffset) cannot tell a step narrative from
|
||||
// an answer lead-in such as "我已经找到了 CRA5 的末端负载曲线图…下面给出完整回答。":
|
||||
// both are plain prose, so the heuristic starts the body at the first
|
||||
// structural line and silently drops everything before it — including answer
|
||||
// prose, image references and table rows that sit in that lead-in.
|
||||
//
|
||||
// The stream knows the boundary: every agent step writes its narrative to the
|
||||
// answer channel as its own answer run (WeKnora's data.event_id), and the
|
||||
// final answer body is the last run that no tool/thinking event interrupts.
|
||||
type NarrationObservation struct {
|
||||
// Observed reports that the stream carried answer run information at all
|
||||
// (WeKnora tags answer chunks with data.event_id). Without it the text
|
||||
// heuristic stays in charge, so older deployments keep their behaviour.
|
||||
Observed bool
|
||||
// AnswerRuns counts distinct answer runs (consecutive chunks sharing one
|
||||
// event id count as one run).
|
||||
AnswerRuns int
|
||||
// ToolEvents counts thinking / tool_call / tool_result events.
|
||||
ToolEvents int
|
||||
// LastRunStart is the byte offset in the accumulated answer where the
|
||||
// most recent answer run started.
|
||||
LastRunStart int
|
||||
// LastRunClean reports that no tool/thinking event arrived after that run
|
||||
// started, which makes the run the final answer body rather than another
|
||||
// narrative step.
|
||||
LastRunClean bool
|
||||
}
|
||||
|
||||
// BodyStartOffset returns the observed body start and whether the observation
|
||||
// is usable. An answer with no tool event carries no narration at all, so the
|
||||
// whole answer is the body.
|
||||
func (o NarrationObservation) BodyStartOffset(contentLength int) (int, bool) {
|
||||
if !o.Observed {
|
||||
return 0, false
|
||||
}
|
||||
if o.ToolEvents == 0 {
|
||||
return 0, true
|
||||
}
|
||||
if o.LastRunClean && o.LastRunStart > 0 && o.LastRunStart < contentLength {
|
||||
return o.LastRunStart, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// SplitReasoningAnswerObserved returns the final answer body using the stream
|
||||
// observation when it is usable and the text heuristic otherwise. ok is false
|
||||
// when the content carries no body boundary (nothing must be hidden).
|
||||
func SplitReasoningAnswerObserved(content string, observation NarrationObservation) (string, bool) {
|
||||
if offset, ok := observation.BodyStartOffset(len(content)); ok {
|
||||
if offset <= 0 {
|
||||
// The stream showed no narration at all: the whole answer is body.
|
||||
return content, true
|
||||
}
|
||||
body := strings.TrimLeft(content[offset:], "\r\n")
|
||||
if strings.TrimSpace(body) != "" {
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
return SplitReasoningAnswer(content)
|
||||
}
|
||||
|
||||
// TrimReasoningNarrationObserved drops the step narration using the stream
|
||||
// observation, falling back to the text heuristic when the stream carried no
|
||||
// usable boundary. Unlike the heuristic it never drops the answer lead-in or
|
||||
// an image reference that belongs to the body.
|
||||
func TrimReasoningNarrationObserved(content string, observation NarrationObservation) string {
|
||||
body, ok := SplitReasoningAnswerObserved(content, observation)
|
||||
if !ok {
|
||||
return content
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The production case this observation fixes: the agent narrates its steps,
|
||||
// then the final answer opens with a lead-in ("我已经找到了…下面给出完整回答。"),
|
||||
// carries the requested load-curve image, and only then reaches its first
|
||||
// heading. The text heuristic started the body at the heading and dropped the
|
||||
// lead-in plus the image reference with it.
|
||||
const craLoadCurveAnswer = "我需要先确认 CRA5 与 CR5A 是同一型号,然后检索负载曲线。\n" +
|
||||
"知识库检索命中 3 篇文档,其中用户手册含末端负载曲线图。\n" +
|
||||
"我已经找到了 CRA5(CR5A)的末端负载曲线图,并确认了负载曲线的读法说明。下面给出完整回答。\n\n" +
|
||||
"CRA5(即 CR5A)的末端负载曲线如下:\n\n" +
|
||||
"\n\n" +
|
||||
"---\n\n" +
|
||||
"## 一、负载曲线怎么读\n\n" +
|
||||
"负载曲线反映的是:末端负载能力与负载质心偏心距离成负相关。\n"
|
||||
|
||||
func TestNarrationObservationKeepsAnswerLeadInAndImage(t *testing.T) {
|
||||
narration := strings.Index(craLoadCurveAnswer, "我已经找到了")
|
||||
observation := NarrationObservation{
|
||||
Observed: true, AnswerRuns: 3, ToolEvents: 5,
|
||||
LastRunStart: narration, LastRunClean: true,
|
||||
}
|
||||
|
||||
body := TrimReasoningNarrationObserved(craLoadCurveAnswer, observation)
|
||||
if !strings.HasPrefix(body, "我已经找到了") {
|
||||
t.Fatalf("body must start at the answer lead-in, got %q", firstLine(body))
|
||||
}
|
||||
if !strings.Contains(body, "") {
|
||||
t.Fatalf("body must keep the image reference, got %q", firstLine(body))
|
||||
}
|
||||
if strings.Contains(body, "我需要先确认") {
|
||||
t.Fatalf("body must not contain the step narration, got %q", firstLine(body))
|
||||
}
|
||||
|
||||
// The text-only heuristic is what dropped the lead-in and the image.
|
||||
heuristic := TrimReasoningNarration(craLoadCurveAnswer)
|
||||
if !strings.HasPrefix(heuristic, "## 一、负载曲线怎么读") {
|
||||
t.Fatalf("heuristic is expected to start at the heading, got %q", firstLine(heuristic))
|
||||
}
|
||||
if strings.Contains(heuristic, "resource://") {
|
||||
t.Fatalf("heuristic is expected to drop the image reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrationObservationFallsBackWithoutUsableBoundary(t *testing.T) {
|
||||
narration := strings.Index(craLoadCurveAnswer, "我已经找到了")
|
||||
tests := []struct {
|
||||
name string
|
||||
observation NarrationObservation
|
||||
}{
|
||||
{"not observed", NarrationObservation{AnswerRuns: 3, ToolEvents: 5, LastRunStart: narration, LastRunClean: true}},
|
||||
{"last run interrupted by a tool", NarrationObservation{Observed: true, AnswerRuns: 3, ToolEvents: 5, LastRunStart: narration}},
|
||||
{"run starts at the very beginning", NarrationObservation{Observed: true, AnswerRuns: 1, ToolEvents: 2, LastRunStart: 0, LastRunClean: true}},
|
||||
{"run start beyond the answer", NarrationObservation{Observed: true, AnswerRuns: 3, ToolEvents: 5, LastRunStart: len(craLoadCurveAnswer) + 10, LastRunClean: true}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := TrimReasoningNarrationObserved(craLoadCurveAnswer, tc.observation)
|
||||
if want := TrimReasoningNarration(craLoadCurveAnswer); got != want {
|
||||
t.Fatalf("expected heuristic fallback %q, got %q", firstLine(want), firstLine(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrationObservationKeepsWholeAnswerWithoutToolSteps(t *testing.T) {
|
||||
answer := "本机提供以下三种接线方式:\n\n| 方式 | 说明 |\n| --- | --- |\n| A | 直连 |\n"
|
||||
observation := NarrationObservation{Observed: true, AnswerRuns: 1, ToolEvents: 0}
|
||||
|
||||
body, ok := SplitReasoningAnswerObserved(answer, observation)
|
||||
if !ok {
|
||||
t.Fatalf("a run without tool steps has no narration, so the whole answer is the body")
|
||||
}
|
||||
if body != answer {
|
||||
t.Fatalf("expected the whole answer, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrationObservationTrimsBlankLineAtRunStart(t *testing.T) {
|
||||
content := "叙述步骤。\n\n\n## 标题\n\n正文。\n"
|
||||
observation := NarrationObservation{Observed: true, AnswerRuns: 2, ToolEvents: 1,
|
||||
LastRunStart: strings.Index(content, "\n\n\n##"), LastRunClean: true}
|
||||
|
||||
body := TrimReasoningNarrationObserved(content, observation)
|
||||
if body != "## 标题\n\n正文。\n" {
|
||||
t.Fatalf("expected the blank lines before the run content to be trimmed, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -34,6 +34,12 @@ type StreamEvent struct {
|
||||
FullAnswer string
|
||||
ReferenceCount int
|
||||
ToolName string
|
||||
// AnswerRunID / AnswerRunStart describe the answer run the event belongs
|
||||
// to. WeKnora tags every agent step's answer chunks with its own
|
||||
// data.event_id, so a change of AnswerRunID marks the start of a new run
|
||||
// and AnswerRunStart is the accumulated answer offset where it began.
|
||||
AnswerRunID string
|
||||
AnswerRunStart int
|
||||
}
|
||||
|
||||
// StreamObserver is called with a safe StreamEvent for each SSE event.
|
||||
@@ -227,6 +233,12 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
|
||||
var result ChatResult
|
||||
var dataLines []string
|
||||
terminal := false
|
||||
// A reasoning agent publishes every step narrative and the final answer
|
||||
// body through the same answer channel, but each step is its own answer
|
||||
// run (data.event_id). A change of run id marks a new run start, which is
|
||||
// what lets callers separate narration from the final body instead of
|
||||
// guessing from the text.
|
||||
answerRunID, answerRunStart := "", 0
|
||||
|
||||
notify := func(e StreamEvent) {
|
||||
if eventObserver != nil {
|
||||
@@ -262,15 +274,20 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
|
||||
ReferenceCount: len(event.KnowledgeReferences),
|
||||
})
|
||||
case "answer":
|
||||
if runID := extractAnswerRunID(event.Data); runID != "" && runID != answerRunID {
|
||||
answerRunID, answerRunStart = runID, len(result.Answer)
|
||||
}
|
||||
hasContent := strings.TrimSpace(event.Content) != ""
|
||||
result.Answer += event.Content
|
||||
if hasContent && answerObserver != nil {
|
||||
answerObserver(result.Answer)
|
||||
}
|
||||
notify(StreamEvent{
|
||||
ResponseType: "answer",
|
||||
Done: event.Done,
|
||||
FullAnswer: result.Answer,
|
||||
ResponseType: "answer",
|
||||
Done: event.Done,
|
||||
FullAnswer: result.Answer,
|
||||
AnswerRunID: answerRunID,
|
||||
AnswerRunStart: answerRunStart,
|
||||
})
|
||||
if event.Done {
|
||||
terminal = true
|
||||
@@ -348,6 +365,30 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractAnswerRunID extracts data.event_id, the identifier of the answer run
|
||||
// an answer chunk belongs to. A reasoning agent gives every step its own id,
|
||||
// so a change of id marks the start of a new run and, once no tool event
|
||||
// follows, of the final answer body. Older deployments that do not tag answer
|
||||
// events yield an empty id, which callers treat as "no observation".
|
||||
func extractAnswerRunID(data json.RawMessage) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m["event_id"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// extractToolName extracts the tool_name string from a JSON Data field.
|
||||
// The Data may be any JSON shape; we look for the "tool_name" key and return
|
||||
// its string value. Returns empty string if not found or not a string.
|
||||
|
||||
@@ -267,3 +267,69 @@ func TestAgentChatEventsErrorTurn(t *testing.T) {
|
||||
t.Fatalf("expected error done event, got %+v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamEventAnswerRuns verifies answer chunks are attributed to the answer
|
||||
// run that produced them: a reasoning agent publishes every step narrative and
|
||||
// the final answer body through the same answer channel, each under its own
|
||||
// data.event_id, so the run start marks where the final body begins.
|
||||
func TestStreamEventAnswerRuns(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"thinking\",\"content\":\"inner monologue\",\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"我先检索知识库。\",\"data\":{\"event_id\":\"step-1-answer\"},\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"tool_call\",\"data\":{\"tool_name\":\"knowledge_search\",\"event_id\":\"step-1-tool\"},\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"event_id\":\"step-1-tool\"},\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"我已经找到了负载曲线图:\",\"data\":{\"event_id\":\"final-answer\"},\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"\",\"data\":{\"event_id\":\"final-answer\"},\"done\":true}\n\n"
|
||||
|
||||
var events []StreamEvent
|
||||
result, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(e StreamEvent) {
|
||||
events = append(events, e)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := len([]rune("我先检索知识库。")); got == 0 {
|
||||
t.Fatal("unreachable")
|
||||
}
|
||||
narration := len("我先检索知识库。")
|
||||
if result.Answer != "我先检索知识库。我已经找到了负载曲线图:" {
|
||||
t.Fatalf("unexpected answer: %q", result.Answer)
|
||||
}
|
||||
|
||||
var answers []StreamEvent
|
||||
for _, e := range events {
|
||||
if e.ResponseType == "answer" {
|
||||
answers = append(answers, e)
|
||||
}
|
||||
}
|
||||
if len(answers) != 3 {
|
||||
t.Fatalf("expected 3 answer events, got %d: %+v", len(answers), answers)
|
||||
}
|
||||
if answers[0].AnswerRunID != "step-1-answer" || answers[0].AnswerRunStart != 0 {
|
||||
t.Fatalf("narration run: want id 'step-1-answer' start 0, got %+v", answers[0])
|
||||
}
|
||||
if answers[1].AnswerRunID != "final-answer" || answers[1].AnswerRunStart != narration {
|
||||
t.Fatalf("final run must start at %d, got %+v", narration, answers[1])
|
||||
}
|
||||
if answers[2].AnswerRunID != "final-answer" || answers[2].AnswerRunStart != narration {
|
||||
t.Fatalf("chunks of one run must share its start, got %+v", answers[2])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamEventAnswerRunWithoutID verifies streams that carry no run id leave
|
||||
// the run fields empty, so consumers fall back to the text heuristic.
|
||||
func TestStreamEventAnswerRunWithoutID(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"answer\",\"content\":\"hello \",\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"world\",\"done\":true}\n\n"
|
||||
|
||||
var events []StreamEvent
|
||||
if _, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(e StreamEvent) {
|
||||
events = append(events, e)
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.AnswerRunID != "" || e.AnswerRunStart != 0 {
|
||||
t.Fatalf("event[%d] must carry no run info, got %+v", i, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user