fix(dingtalk): 推理正文边界支持「首行即正文」并补齐卡片结构诊断

- BodyStartOffset:内容首个非空行即结构行时正文从头开始,避免收尾裁剪
  把标题+首段一起删掉(118 条生产样本中 31 条、最多 526 字)
- 语料回归守卫:隐藏前缀不得含结构行、上限 600 字;单测补 suffix 断言
- 卡片诊断新增 body_ok/body_line/body_prefix_runes/banner_pos/first_heading_line
  (定位流式残留推理末句,只看结构不看文本)
This commit is contained in:
姚章浩
2026-09-17 14:01:10 +08:00
parent fcca33050a
commit a1a2aabab4
4 changed files with 129 additions and 9 deletions
+49 -1
View File
@@ -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,
)
}
+23 -5
View File
@@ -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
+21 -3
View File
@@ -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)
}
@@ -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 ""
}