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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user