diff --git a/internal/channels/dingtalk/card_diagnostics.go b/internal/channels/dingtalk/card_diagnostics.go index 717e50e..85dd3dd 100644 --- a/internal/channels/dingtalk/card_diagnostics.go +++ b/internal/channels/dingtalk/card_diagnostics.go @@ -3,8 +3,12 @@ package dingtalk import ( "fmt" "strings" + + "gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode" ) +const reasoningBannerMarker = "【智能推理模式】" + type cardContentShape struct { bytes int lines int @@ -13,20 +17,59 @@ type cardContentShape struct { delimiterLines int confirmedTable bool confirmedTablePos int + // Structural split state: where the answer body starts inside this exact + // payload. bodyPrefixRunes > 0 means the update still carries narration in + // front of the body (the reasoning text the card is supposed to hide). + bodyOK bool + bodyLine int + bodyPrefixRunes int + bannerPos int + firstHeadingLine int } func inspectCardContent(content string) cardContentShape { shape := cardContentShape{ bytes: len(content), confirmedTablePos: -1, + bodyLine: -1, + firstHeadingLine: -1, + bannerPos: -1, } if content == "" { return shape } shape.lines = strings.Count(content, "\n") + 1 + shape.bannerPos = strings.Index(content, reasoningBannerMarker) + // The streaming path decorates every frame with the reasoning banner, so the + // prefix in front of the body has to be measured after that banner: only + // what remains is narration the card is meant to hide. + bannerSkip := 0 + if shape.bannerPos == 0 { + if idx := strings.Index(content, "\n"); idx >= 0 { + bannerSkip = idx + 1 + for bannerSkip < len(content) && content[bannerSkip] == '\n' { + bannerSkip++ + } + } + } + if offset, ok := reasoningmode.BodyStartOffset(content); ok { + shape.bodyOK = true + shape.bodyLine = strings.Count(content[:offset], "\n") + prefix := content[:offset] + if bannerSkip > 0 && bannerSkip <= len(prefix) { + prefix = prefix[bannerSkip:] + } + shape.bodyPrefixRunes = len([]rune(prefix)) + } + lines := splitMarkdownStreamLines(content) for i, line := range lines { + trimmed := strings.TrimSpace(line.text) + if shape.firstHeadingLine < 0 && !line.inFence && + strings.HasPrefix(trimmed, "#") && strings.TrimLeft(trimmed, "#") != "" { + shape.firstHeadingLine = i + } if strings.Contains(line.text, "|") { shape.pipeLines++ } @@ -50,7 +93,7 @@ func inspectCardContent(content string) cardContentShape { func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string { shape := inspectCardContent(content) return fmt.Sprintf( - "DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d", + "DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d", isFinalize, isError, shape.bytes, @@ -60,5 +103,10 @@ func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string shape.delimiterLines, shape.confirmedTable, shape.confirmedTablePos, + shape.bodyOK, + shape.bodyLine, + shape.bodyPrefixRunes, + shape.bannerPos, + shape.firstHeadingLine, ) } diff --git a/internal/reasoningmode/answer_split.go b/internal/reasoningmode/answer_split.go index 9fbc2d1..842f594 100644 --- a/internal/reasoningmode/answer_split.go +++ b/internal/reasoningmode/answer_split.go @@ -57,14 +57,23 @@ func TrimReasoningNarration(content string) string { } // 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. +// A line qualifies as the body start when it is a structural Markdown line +// (heading, bold-only line, table row or blockquote) and is not inside a +// fenced code block, either directly following a blank line or being the very +// first non-blank line of the content. +// +// The first-line rule matters: 31 of 118 production reasoning answers open +// directly with their body ("# 标题" first). Without it the scan would skip +// that opening line and fire on the *next* structural line, so finalize +// deleted the answer's title plus its opening paragraph (up to 526 chars) while +// the streaming feed, whose display inserts blank lines between steps, kept +// them - mid-stream and finalize text disagreed. func BodyStartOffset(content string) (int, bool) { lines := strings.Split(content, "\n") fence := "" offset := 0 previousBlank := false + seenContent := false for i, raw := range lines { trimmed := strings.TrimSpace(raw) if marker := answerFenceLineRE.FindString(trimmed); marker != "" && @@ -74,12 +83,21 @@ func BodyStartOffset(content string) (int, bool) { } else { fence = "" } + seenContent = true previousBlank = false offset += len(raw) + 1 continue } - if i > 0 && fence == "" && previousBlank && isAnswerBodyLine(trimmed) { - return offset, true + if fence == "" { + if !seenContent && isAnswerBodyLine(trimmed) { + return offset, true + } + if i > 0 && previousBlank && isAnswerBodyLine(trimmed) { + return offset, true + } + } + if trimmed != "" { + seenContent = true } previousBlank = trimmed == "" offset += len(raw) + 1 diff --git a/internal/reasoningmode/answer_split_test.go b/internal/reasoningmode/answer_split_test.go index 34be957..91062c6 100644 --- a/internal/reasoningmode/answer_split_test.go +++ b/internal/reasoningmode/answer_split_test.go @@ -1,6 +1,9 @@ package reasoningmode -import "testing" +import ( + "strings" + "testing" +) func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) { leadIn := "根据检索到的资料,关于 CR20V 各关节电机最大旋转扭矩的说明如下:" @@ -46,10 +49,22 @@ func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) { ok: false, }, { - name: "body from the first line is left alone", + name: "answer opens with its body: nothing is hidden", answer: heading + "\n\n各关节参数如下。", want: heading + "\n\n各关节参数如下。", - ok: false, + ok: true, + }, + { + name: "title and opening paragraph survive finalize", + answer: "# 200万高职具身智能实训室搭配方案\n\n结合越疆现有的一体化实训平台,围绕具身智能教学场景,我们从教具、软件、课程三方面给出搭配建议。\n\n## 一、先明确三条搭配原则\n\n- 匹配课程大纲\n- 控制预算\n", + want: "# 200万高职具身智能实训室搭配方案\n\n结合越疆现有的一体化实训平台,围绕具身智能教学场景,我们从教具、软件、课程三方面给出搭配建议。\n\n## 一、先明确三条搭配原则\n\n- 匹配课程大纲\n- 控制预算\n", + ok: true, + }, + { + name: "leading blank lines before the title", + answer: "\n\n# 标题\n\n正文。", + want: "# 标题\n\n正文。", + ok: true, }, { name: "structural line inside a fence is ignored", @@ -74,6 +89,9 @@ func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) { if body != tt.want { t.Fatalf("body = %q, want %q", body, tt.want) } + if !strings.HasSuffix(tt.answer, body) { + t.Fatalf("body %q is not a suffix of the answer: the rule ate content", body) + } if trimmed := TrimReasoningNarration(tt.answer); trimmed != tt.want { t.Fatalf("TrimReasoningNarration = %q, want %q", trimmed, tt.want) } diff --git a/internal/reasoningmode/corpus_replay_test.go b/internal/reasoningmode/corpus_replay_test.go index 3713a4a..b7cd2f7 100644 --- a/internal/reasoningmode/corpus_replay_test.go +++ b/internal/reasoningmode/corpus_replay_test.go @@ -40,6 +40,16 @@ func TestReplayProductionCorpus(t *testing.T) { 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)) + // Guard: the hidden prefix must be narration only. A structural line in + // there means the rule fired past the body start and ate answer content + // (that is exactly how the opening title + paragraph of 31 answers were + // deleted before the first-line rule existed). + if line := firstStructuralLine(hidden); line != "" { + t.Errorf("id=%s hidden prefix carries a structural body line %q: the rule ate answer content", row.ID[:8], shorten(line, 40)) + } + if runes := len([]rune(hidden)); runes > maxHiddenNarrationRunes { + t.Errorf("id=%s hidden prefix is %d runes, over the %d-rune narration bound", row.ID[:8], runes, maxHiddenNarrationRunes) + } } if err := scanner.Err(); err != nil { t.Fatalf("scan corpus: %v", err) @@ -54,3 +64,29 @@ func shorten(value string, limit int) string { } return string(runes[len(runes)-limit:]) } + +// maxHiddenNarrationRunes bounds the narration the rule may hide. Calibration +// on 118 production answers: the hidden lead-in was at most 526 runes. +const maxHiddenNarrationRunes = 600 + +// firstStructuralLine returns the first line that qualifies as answer body +// (heading, bold-only line, table row or blockquote), skipping fenced code. +func firstStructuralLine(text string) string { + fence := "" + for _, raw := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(raw) + if marker := answerFenceLineRE.FindString(trimmed); marker != "" && + (fence == "" || strings.HasPrefix(trimmed, fence)) { + if fence == "" { + fence = marker + } else { + fence = "" + } + continue + } + if fence == "" && isAnswerBodyLine(trimmed) { + return trimmed + } + } + return "" +}