fix(dingtalk): 推理模式卡片隐藏推理并只留正文
- reasoningmode: 新增正文边界识别(结构行 + 前置空行)与流式 PartialFeed: 分步叙述按行拼接显示,正文开始时只输出正文,卡片更新即刷新清掉推理区 - dingtalk: 事件流与流式回调统一改走 PartialFeed - qa: 推理执行的最终答案在入库/投递前去掉步骤叙述(快速模式行为不变) 语料回归:生产 118 条内建智能推理答案中 106 条识别出正文边界(被隐藏的 均为 59-164 字过渡句),12 条无推理的问候类答案保持原样;go vet 与 go test ./... 全绿。
This commit is contained in:
@@ -490,11 +490,13 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
// Streaming observer with the configured update throttle (500ms by default).
|
||||
var lastUpdate time.Time
|
||||
var lastPartialContent string
|
||||
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
|
||||
observer := func(fullContent string) {
|
||||
if strings.TrimSpace(fullContent) == "" {
|
||||
visible := feed.OnAnswer(fullContent)
|
||||
if strings.TrimSpace(visible) == "" {
|
||||
return
|
||||
}
|
||||
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
|
||||
fullContent = decorateReasoningPartial(visible, req.ReasoningExecution)
|
||||
now := h.now()
|
||||
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
|
||||
partialContent := h.sanitizeOutboundPartial(fullContent)
|
||||
@@ -551,17 +553,26 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
|
||||
return streamingCard.UpdateCard(updateCtx, outTrackID, content, false, false)
|
||||
})
|
||||
answerStarted := false
|
||||
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
|
||||
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
|
||||
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
|
||||
answerStarted = true
|
||||
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
|
||||
if event.ResponseType == "answer" {
|
||||
visible := feed.OnAnswer(event.FullAnswer)
|
||||
if strings.TrimSpace(visible) == "" {
|
||||
return
|
||||
}
|
||||
visibleAnswer := decorateReasoningPartial(visible, req.ReasoningExecution)
|
||||
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
|
||||
pump.Offer(partialContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if answerStarted {
|
||||
switch event.ResponseType {
|
||||
case "tool_call", "tool_result", "thinking", "reflection":
|
||||
// A reasoning step ended: the next narrative segment starts a new
|
||||
// line so the card shows the reasoning process line by line.
|
||||
feed.StepBoundary()
|
||||
}
|
||||
if feed.Started() {
|
||||
return
|
||||
}
|
||||
if content := renderProgressEvent(event); content != "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
@@ -19,3 +20,38 @@ func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningStreamingShowsStepsLineByLineThenDropsThemForBody(t *testing.T) {
|
||||
execution := string(reasoningmode.ExecutionReasoning)
|
||||
first := "我先确认一下设备的型号和手册。"
|
||||
second := "手册里有关节扭矩表,让我核对一下。"
|
||||
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
|
||||
feed := reasoningmode.NewPartialFeed(execution)
|
||||
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(first), execution), "【智能推理模式】\n\n"+first; got != want {
|
||||
t.Fatalf("step1 got=%q want=%q", got, want)
|
||||
}
|
||||
feed.StepBoundary()
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(first+second), execution), "【智能推理模式】\n\n"+first+"\n\n"+second; got != want {
|
||||
t.Fatalf("step2 got=%q want=%q", got, want)
|
||||
}
|
||||
answer := first + second + "\n\n" + body
|
||||
if got, want := decorateReasoningPartial(feed.OnAnswer(answer), execution), "【智能推理模式】\n\n"+body; got != want {
|
||||
t.Fatalf("refresh got=%q want=%q", got, want)
|
||||
}
|
||||
got := feed.OnAnswer(answer + "\n| J1 | 98 N·m |")
|
||||
if strings.Contains(got, first) || !strings.Contains(got, "J1") {
|
||||
t.Fatalf("reasoning must stay hidden while the body streams, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickStreamingKeepsAccumulatedAnswer(t *testing.T) {
|
||||
feed := reasoningmode.NewPartialFeed(string(reasoningmode.ExecutionQuick))
|
||||
if got := feed.OnAnswer("快速答案"); got != "快速答案" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
feed.StepBoundary()
|
||||
if got := feed.OnAnswer("快速答案追加"); got != "快速答案追加" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package qa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
func reasoningAnswerFixture() (string, string, string) {
|
||||
narration := "我需要先确认这台设备的型号和手册。"
|
||||
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |\n| --- | --- |\n| J1 | 98 N·m |"
|
||||
return narration, body, narration + "\n\n" + body
|
||||
}
|
||||
|
||||
func reasoningEventClient(answer string) *fakeEventStreamingWeKnora {
|
||||
return &fakeEventStreamingWeKnora{
|
||||
fakeWeKnora: &fakeWeKnora{},
|
||||
events: []weknora.StreamEvent{
|
||||
{ResponseType: "answer", FullAnswer: "我需要先确认这台设备的型号和手册。"},
|
||||
{ResponseType: "tool_call", ToolName: "knowledge_search"},
|
||||
{ResponseType: "tool_result", ToolName: "knowledge_search"},
|
||||
{ResponseType: "answer", FullAnswer: answer, Done: true},
|
||||
},
|
||||
streamResult: weknora.ChatResult{RequestID: "request-reasoning", Answer: answer},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskEventsReasoningKeepsOnlyAnswerBody(t *testing.T) {
|
||||
_, body, answer := reasoningAnswerFixture()
|
||||
repo := newMemoryRepository()
|
||||
client := reasoningEventClient(answer)
|
||||
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := streamQARequest("message-reasoning")
|
||||
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
|
||||
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
|
||||
if err != nil {
|
||||
t.Fatalf("AskEvents: %v", err)
|
||||
}
|
||||
if result.Answer != body {
|
||||
t.Fatalf("delivered answer must keep the body only, got %q (streamCalls=%d)", result.Answer, client.streamCalls)
|
||||
}
|
||||
repo.mu.Lock()
|
||||
stored := repo.turns["tenant:dingtalk:message-reasoning"]
|
||||
repo.mu.Unlock()
|
||||
if stored.Answer != body {
|
||||
t.Fatalf("stored answer must keep the body only, got %q", stored.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskEventsQuickKeepsFullAnswer(t *testing.T) {
|
||||
_, _, answer := reasoningAnswerFixture()
|
||||
repo := newMemoryRepository()
|
||||
service, err := New(repo, fixedResolver{}, reasoningEventClient(answer), session.GroupShared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.AskEvents(context.Background(), streamQARequest("message-quick"), func(weknora.StreamEvent) {})
|
||||
if err != nil {
|
||||
t.Fatalf("AskEvents: %v", err)
|
||||
}
|
||||
if result.Answer != answer {
|
||||
t.Fatalf("quick answer must be untouched, got %q", result.Answer)
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
turn.WeKnoraRequestID = chat.RequestID
|
||||
turn.KnowledgeSearched = knowledgeSearched
|
||||
turn.AnswerMessageID = newID()
|
||||
// A reasoning run reports every agent step's narrative through WeKnora's
|
||||
// answer channel and appends the final answer body to that same
|
||||
// accumulation. Keep the body only in the stored/delivered answer: the
|
||||
// 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 {
|
||||
turn.Answer, chat.Answer = trimmed, trimmed
|
||||
}
|
||||
}
|
||||
if err := s.repository.FinishTurn(ctx, turn); err != nil {
|
||||
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Reasoning agent runs report every intermediate step through WeKnora's
|
||||
// response_type=answer channel: the step narratives are concatenated without
|
||||
// any separator and the final answer body is appended to that same
|
||||
// accumulation. A card update therefore shows one run-on "narration + body"
|
||||
// blob. These helpers locate the boundary so callers can hide the narration
|
||||
// and keep the body only.
|
||||
//
|
||||
// Calibration against 118 production answers of the built-in smart-reasoning
|
||||
// agent: 106 had a structural first body line (97 heading, 4 bold-only line,
|
||||
// 3 table row, 2 blockquote) and every one of them was directly preceded by a
|
||||
// blank line; the remaining 12 answers were greetings without narration. The
|
||||
// hidden narration was a short lead-in (median 111 chars, max 526 chars).
|
||||
|
||||
var (
|
||||
answerHeadingLineRE = regexp.MustCompile(`^#{1,6}[\t ]+\S`)
|
||||
answerBoldLineRE = regexp.MustCompile(`^\*\*[^*\n]+\*\*[\t ]*$`)
|
||||
answerTableRowRE = regexp.MustCompile(`^\|.*\|[\t ]*$`)
|
||||
answerQuoteLineRE = regexp.MustCompile(`^>[\t ]*\S`)
|
||||
answerFenceLineRE = regexp.MustCompile("^(`{3,}|~{3,})")
|
||||
)
|
||||
|
||||
// IsReasoningExecution reports whether execution runs the multi-step reasoning
|
||||
// agent whose answer stream mixes step narration with the final answer body.
|
||||
func IsReasoningExecution(execution string) bool {
|
||||
switch ExecutionMode(strings.TrimSpace(execution)) {
|
||||
case ExecutionReasoning, ExecutionReasoningSingle:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SplitReasoningAnswer returns the final answer body. ok is false when the
|
||||
// content carries no body boundary yet (nothing must be hidden in that case).
|
||||
func SplitReasoningAnswer(content string) (string, bool) {
|
||||
offset, ok := BodyStartOffset(content)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
return content[offset:], true
|
||||
}
|
||||
|
||||
// TrimReasoningNarration drops the step narration that precedes the final
|
||||
// answer body. Content without a body boundary is returned unchanged.
|
||||
func TrimReasoningNarration(content string) string {
|
||||
body, ok := SplitReasoningAnswer(content)
|
||||
if !ok {
|
||||
return content
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// BodyStartOffset returns the byte offset where the final answer body starts.
|
||||
// A line qualifies as the body start only when it is a structural Markdown
|
||||
// line (heading, bold-only line, table row or blockquote), directly follows a
|
||||
// blank line and is not inside a fenced code block.
|
||||
func BodyStartOffset(content string) (int, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
fence := ""
|
||||
offset := 0
|
||||
previousBlank := false
|
||||
for i, raw := range lines {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if marker := answerFenceLineRE.FindString(trimmed); marker != "" &&
|
||||
(fence == "" || strings.HasPrefix(trimmed, fence)) {
|
||||
if fence == "" {
|
||||
fence = marker
|
||||
} else {
|
||||
fence = ""
|
||||
}
|
||||
previousBlank = false
|
||||
offset += len(raw) + 1
|
||||
continue
|
||||
}
|
||||
if i > 0 && fence == "" && previousBlank && isAnswerBodyLine(trimmed) {
|
||||
return offset, true
|
||||
}
|
||||
previousBlank = trimmed == ""
|
||||
offset += len(raw) + 1
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isAnswerBodyLine(trimmed string) bool {
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
return answerHeadingLineRE.MatchString(trimmed) ||
|
||||
answerBoldLineRE.MatchString(trimmed) ||
|
||||
answerTableRowRE.MatchString(trimmed) ||
|
||||
answerQuoteLineRE.MatchString(trimmed)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package reasoningmode
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) {
|
||||
leadIn := "根据检索到的资料,关于 CR20V 各关节电机最大旋转扭矩的说明如下:"
|
||||
heading := "## CR20V 关节电机最大旋转扭矩"
|
||||
tableBody := "**Magician 传感器套件(Magician Sensor Kit)重量如下:**\n\n| 项目 | 重量 |\n| --- | --- |\n| 整机 | 1.2 kg |"
|
||||
rowBody := "| 关节 | 扭矩 |\n| --- | --- |"
|
||||
quoteBody := "> 提示:以下参数来自产品手册。"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
answer string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "narration then heading",
|
||||
answer: "我已经收集了充分的信息。现在我可以明确回答用户的问题了。\n\n关键发现:\n1. 手册给出额定扭矩\n\n需要向用户说明\n\n" + heading + "\n\n各关节参数如下。",
|
||||
want: heading + "\n\n各关节参数如下。",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "lead-in then bold body line",
|
||||
answer: "我已经获取到完整且明确的信息,可以给出准确答案了。\n\n" + tableBody,
|
||||
want: tableBody,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "short lead-in then table",
|
||||
answer: leadIn + "\n\n" + rowBody,
|
||||
want: rowBody,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "narration then blockquote",
|
||||
answer: "我需要先确认使用场景。\n\n" + quoteBody,
|
||||
want: quoteBody,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "greeting has no body boundary",
|
||||
answer: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
|
||||
want: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "body from the first line is left alone",
|
||||
answer: heading + "\n\n各关节参数如下。",
|
||||
want: heading + "\n\n各关节参数如下。",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "structural line inside a fence is ignored",
|
||||
answer: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
|
||||
want: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "structural line glued to narration is not a boundary",
|
||||
answer: "需要向用户说明" + heading,
|
||||
want: "需要向用户说明" + heading,
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, ok := SplitReasoningAnswer(tt.answer)
|
||||
if ok != tt.ok {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.ok)
|
||||
}
|
||||
if body != tt.want {
|
||||
t.Fatalf("body = %q, want %q", body, tt.want)
|
||||
}
|
||||
if trimmed := TrimReasoningNarration(tt.answer); trimmed != tt.want {
|
||||
t.Fatalf("TrimReasoningNarration = %q, want %q", trimmed, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReasoningExecution(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"reasoning": true,
|
||||
" reasoning ": true,
|
||||
"reasoning_single": true,
|
||||
"quick": false,
|
||||
"": false,
|
||||
"something": false,
|
||||
}
|
||||
for execution, want := range tests {
|
||||
if got := IsReasoningExecution(execution); got != want {
|
||||
t.Fatalf("IsReasoningExecution(%q) = %v, want %v", execution, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReplayProductionCorpus replays real production answers of the built-in
|
||||
// smart-reasoning agent through the body-boundary rule.
|
||||
// Enable with REASONING_CORPUS=<path to reasoning_corpus.jsonl>.
|
||||
func TestReplayProductionCorpus(t *testing.T) {
|
||||
path := os.Getenv("REASONING_CORPUS")
|
||||
if path == "" {
|
||||
t.Skip("set REASONING_CORPUS to replay production answers")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open corpus: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var total, trimmed int
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 1<<20), 1<<22)
|
||||
for scanner.Scan() {
|
||||
var row struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
|
||||
t.Fatalf("decode row: %v", err)
|
||||
}
|
||||
total++
|
||||
body, ok := SplitReasoningAnswer(row.Content)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed++
|
||||
hidden := strings.TrimSpace(strings.TrimSuffix(row.Content, body))
|
||||
t.Logf("id=%s hidden=%d body=%d head=%q", row.ID[:8], len([]rune(hidden)), len([]rune(body)), shorten(hidden, 60))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan corpus: %v", err)
|
||||
}
|
||||
t.Logf("total=%d narration_trimmed=%d unchanged=%d", total, trimmed, total-trimmed)
|
||||
}
|
||||
|
||||
func shorten(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[len(runes)-limit:])
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package reasoningmode
|
||||
|
||||
import "strings"
|
||||
|
||||
// PartialFeed assembles the streaming card content of one turn.
|
||||
//
|
||||
// A reasoning run streams every agent step's narrative through the answer
|
||||
// channel and appends the final answer body to the same accumulation, so the
|
||||
// raw text reaches the card as one run-on paragraph. The feed
|
||||
//
|
||||
// - joins step segments with a blank line, so the card shows the reasoning
|
||||
// line by line instead of glued sentences, and
|
||||
// - switches to the final body only as soon as the body starts. The DingTalk
|
||||
// card update replaces the card content, so that switch is what the user
|
||||
// sees as the "refresh": the reasoning block disappears and the body is
|
||||
// streamed on its own.
|
||||
//
|
||||
// Quick executions are a pass-through and keep the previous behaviour.
|
||||
type PartialFeed struct {
|
||||
reasoning bool
|
||||
seen string
|
||||
display string
|
||||
stepPending bool
|
||||
bodyOnly string
|
||||
bodyStarted bool
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewPartialFeed builds the feed for one turn's execution mode.
|
||||
func NewPartialFeed(execution string) *PartialFeed {
|
||||
return &PartialFeed{reasoning: IsReasoningExecution(execution)}
|
||||
}
|
||||
|
||||
// Started reports whether any answer text has been streamed yet.
|
||||
func (f *PartialFeed) Started() bool {
|
||||
return f.started
|
||||
}
|
||||
|
||||
// StepBoundary marks the end of an agent step (tool call or tool result) so
|
||||
// the next narrative segment starts on a new line.
|
||||
func (f *PartialFeed) StepBoundary() {
|
||||
if !f.reasoning {
|
||||
return
|
||||
}
|
||||
f.stepPending = true
|
||||
}
|
||||
|
||||
// OnAnswer records the accumulated answer text reported by the stream and
|
||||
// returns the content the card must show right now.
|
||||
func (f *PartialFeed) OnAnswer(fullAnswer string) string {
|
||||
if !f.reasoning {
|
||||
if strings.TrimSpace(fullAnswer) != "" {
|
||||
f.started = true
|
||||
}
|
||||
return fullAnswer
|
||||
}
|
||||
|
||||
delta := fullAnswer
|
||||
switch {
|
||||
case strings.HasPrefix(fullAnswer, f.seen):
|
||||
delta = fullAnswer[len(f.seen):]
|
||||
case fullAnswer != f.seen:
|
||||
// The stream restarted (new WeKnora session or a retry): treat the
|
||||
// text as a fresh segment instead of gluing it to the previous one.
|
||||
f.stepPending = true
|
||||
}
|
||||
f.seen = fullAnswer
|
||||
|
||||
if delta != "" {
|
||||
f.started = true
|
||||
if f.stepPending && strings.TrimSpace(f.display) != "" {
|
||||
f.display += "\n\n"
|
||||
}
|
||||
f.stepPending = false
|
||||
f.display += delta
|
||||
}
|
||||
|
||||
if body, ok := SplitReasoningAnswer(f.display); ok {
|
||||
f.bodyStarted = true
|
||||
f.bodyOnly = body
|
||||
}
|
||||
if f.bodyStarted {
|
||||
return f.bodyOnly
|
||||
}
|
||||
return f.display
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPartialFeedJoinsReasoningSegmentsAndRefreshesToBody(t *testing.T) {
|
||||
feed := NewPartialFeed(string(ExecutionReasoning))
|
||||
|
||||
// Step 1: first narrative segment streams in.
|
||||
step1 := "我先查一下知识库。"
|
||||
if got := feed.OnAnswer(step1); got != step1 {
|
||||
t.Fatalf("first segment = %q, want %q", got, step1)
|
||||
}
|
||||
if !feed.Started() {
|
||||
t.Fatal("Started must be true after the first answer text")
|
||||
}
|
||||
|
||||
// Step 1 ends with a tool call, step 2 starts with its own narrative.
|
||||
feed.StepBoundary()
|
||||
step2 := "找到了相关文档,我确认一下参数。"
|
||||
got := feed.OnAnswer(step1 + step2)
|
||||
want := step1 + "\n\n" + step2
|
||||
if got != want {
|
||||
t.Fatalf("second segment = %q, want %q (segments must be line separated)", got, want)
|
||||
}
|
||||
|
||||
// Step 2 ends, step 3 carries the final answer body.
|
||||
feed.StepBoundary()
|
||||
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
|
||||
got = feed.OnAnswer(step1 + step2 + body)
|
||||
if got != body {
|
||||
t.Fatalf("body-only content = %q, want %q (reasoning must be hidden)", got, body)
|
||||
}
|
||||
|
||||
// The body keeps streaming; the visible content stays body-only.
|
||||
grown := body + "\n| J1 | 100 N·m |"
|
||||
if got := feed.OnAnswer(step1 + step2 + grown); got != grown {
|
||||
t.Fatalf("grown body = %q, want %q", got, grown)
|
||||
}
|
||||
if strings.Contains(got, "我先查一下知识库") {
|
||||
t.Fatalf("reasoning leaked into the refreshed card: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFeedKeepsGluedNarrationReadable(t *testing.T) {
|
||||
// WeKnora concatenates step narratives without any separator; the feed must
|
||||
// not let the card show them as one run-on sentence.
|
||||
feed := NewPartialFeed(string(ExecutionReasoning))
|
||||
first := "我正在理解问题。"
|
||||
feed.OnAnswer(first)
|
||||
feed.StepBoundary()
|
||||
second := "正在检索知识库。"
|
||||
if got := feed.OnAnswer(first + second); got != first+"\n\n"+second {
|
||||
t.Fatalf("glued narration = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFeedStartsANewSegmentWhenTheStreamRestarts(t *testing.T) {
|
||||
feed := NewPartialFeed(string(ExecutionReasoning))
|
||||
feed.OnAnswer("第一段推理。")
|
||||
got := feed.OnAnswer("重新开始的一段推理。")
|
||||
if got != "第一段推理。\n\n重新开始的一段推理。" {
|
||||
t.Fatalf("restart segment = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFeedQuickExecutionIsPassThrough(t *testing.T) {
|
||||
feed := NewPartialFeed(string(ExecutionQuick))
|
||||
narrativeAndBody := "我已收集信息。\n\n## 标题\n\n正文"
|
||||
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
|
||||
t.Fatalf("quick execution = %q, want the raw stream unchanged", got)
|
||||
}
|
||||
feed.StepBoundary()
|
||||
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
|
||||
t.Fatalf("quick execution after boundary = %q, want the raw stream unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialFeedWithoutBoundaryKeepsShowingReasoning(t *testing.T) {
|
||||
feed := NewPartialFeed(string(ExecutionReasoning))
|
||||
greeting := "你好!我是越疆智能助手。"
|
||||
if got := feed.OnAnswer(greeting); got != greeting {
|
||||
t.Fatalf("greeting = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user