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:
姚章浩
2026-09-18 15:53:43 +08:00
parent dd229ec0ad
commit 149e94b34b
5 changed files with 313 additions and 4 deletions
+24 -1
View File
@@ -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" +
"![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)\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, "![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)") {
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
}
+44 -3
View File
@@ -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.
+66
View File
@@ -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\":\"![图](resource://abc)\",\"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 != "我先检索知识库。我已经找到了负载曲线图:![图](resource://abc)" {
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)
}
}
}