fix(outbound): keep single-run answers whole and show formulas

The DingTalk binding can point at the quick-answer agent, which publishes the
whole answer as one answer run plus tool_call/tool_result steps. The narration
boundary was rejected for that shape (start=0), so the text-only heuristic ran
and dropped the answer lead-in together with its image.

- answer_observation: a single answer run means there is no narration to cut.
- outboundtext: rewrite LaTeX formulas into plain Unicode text, because
  DingTalk cards render no math typesetting (r = sqrt(X^2 + Y^2) -> r = √(X² + Y²)).
- dingtalk handler: normalize formulas on both the partial and final card paths.
This commit is contained in:
姚章浩
2026-09-18 16:21:37 +08:00
parent dfce8cb9f6
commit 5ccaeb3b96
8 changed files with 439 additions and 1 deletions
+118
View File
@@ -0,0 +1,118 @@
// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑
// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在
// 并发取图 + 预算可配下的出图结果与耗时。
package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
func main() {
refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄")
budget := flag.Duration("budget", 180*time.Second, "改写预算")
maxImages := flag.Int("max", 50, "图片上限")
compress := flag.Bool("compress", true, "上传前压缩截图")
passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)")
flag.Parse()
refs := readRefs(*refsFile)
if len(refs) == 0 {
log.Fatal("probe: no references")
}
fetcher, err := outboundimage.NewWeKnoraFetcher(
os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"),
&http.Client{Timeout: 30 * time.Second}, 10<<20)
if err != nil {
log.Fatalf("probe: fetcher: %v", err)
}
store, err := outboundimage.NewAliyunOSSStore(
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
if err != nil {
log.Fatalf("probe: store: %v", err)
}
publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")
publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase)
if err != nil {
log.Fatalf("probe: publisher: %v", err)
}
rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress))
var answer strings.Builder
answer.WriteString("## 探针答案\n")
for i, ref := range refs {
fmt.Fprintf(&answer, "\n![图%d](%s)\n", i+1, ref)
}
log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
body := answer.String()
for pass := 1; pass <= *passes; pass++ {
ctx, cancel := context.WithTimeout(context.Background(), *budget)
started := time.Now()
out := rewriter.Rewrite(ctx, body)
elapsed := time.Since(started)
cancel()
published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1)
leaked := strings.Count(out, "resource://")
fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n",
pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out))
if len(published) > 0 {
fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out))
}
}
}
func readRefs(path string) []string {
if path == "" {
return nil
}
file, err := os.Open(path)
if err != nil {
log.Fatalf("probe: refs file: %v", err)
}
defer file.Close()
seen := map[string]bool{}
refs := make([]string, 0, 64)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
ref := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(ref, "resource://") || seen[ref] {
continue
}
seen[ref] = true
refs = append(refs, ref)
}
return refs
}
var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`)
func firstImageURL(content string) string {
if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 {
return match[1]
}
return ""
}
func lastImageURL(content string) string {
if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 {
return matches[len(matches)-1][1]
}
return ""
}
+3
View File
@@ -11,6 +11,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundtext"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -675,6 +676,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
}
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
content = outboundtext.NormalizeMath(content)
options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal}
if h.outboundRewriter == nil {
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
@@ -692,6 +694,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) string {
content = outboundtext.NormalizeMath(content)
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
+55
View File
@@ -0,0 +1,55 @@
package outboundtext
import (
"regexp"
)
// DingTalk cards render a small Markdown subset and no math typesetting, so a
// LaTeX formula reaches the user as raw source ($r = \sqrt{X^2 + Y^2}$).
// NormalizeMath rewrites such formulas into plain Unicode text that the card
// can show. It only touches content that actually carries math delimiters or
// well-known math commands, so ordinary prose is left byte-identical.
var (
mathBlockDelimRE = regexp.MustCompile(`\$\$([\s\S]+?)\$\$`)
mathInlineDelimRE = regexp.MustCompile(`\$([^$\n]{1,400})\$`)
parenDelimRE = regexp.MustCompile(`\\\(([\s\S]+?)\\\)`)
bracketDelimRE = regexp.MustCompile(`\\\[([\s\S]+?)\\\]`)
sqrtRE = regexp.MustCompile(`\\sqrt\s*(?:\[([^\]]{1,20})\])?\s*\{([^{}]{1,200})\}`)
fracRE = regexp.MustCompile(`\\frac\s*\{([^{}]{1,200})\}\s*\{([^{}]{1,200})\}`)
supBracedRE = regexp.MustCompile(`\^\s*\{([^{}]{1,40})\}`)
subBracedRE = regexp.MustCompile(`_\s*\{([^{}]{1,40})\}`)
supCharRE = regexp.MustCompile(`\^([0-9A-Za-z+\-=()])`)
subCharRE = regexp.MustCompile(`_([0-9A-Za-z+\-=()])`)
commandRE = regexp.MustCompile(`\\([A-Za-z]+)`)
braceDelimRE = regexp.MustCompile(`\{([^{}]{0,200})\}`)
blankRunRE = regexp.MustCompile(`[ \t]{2,}`)
)
var mathCommands = map[string]string{
"times": "×", "cdot": "·", "div": "÷", "pm": "±", "mp": "∓",
"leq": "≤", "le": "≤", "geq": "≥", "ge": "≥", "neq": "≠", "ne": "≠",
"approx": "≈", "sim": "∼", "simeq": "≃", "equiv": "≡", "propto": "∝",
"infty": "∞", "partial": "∂", "nabla": "∇",
"alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "Delta": "Δ",
"epsilon": "ε", "theta": "θ", "lambda": "λ", "mu": "μ", "rho": "ρ",
"sigma": "σ", "tau": "τ", "phi": "φ", "omega": "ω", "Omega": "Ω",
"eta": "η", "kappa": "κ", "nu": "ν", "xi": "ξ", "zeta": "ζ", "psi": "ψ",
"sum": "Σ", "prod": "Π", "int": "∫", "iint": "∬", "oint": "∮",
"in": "∈", "notin": "∉", "subset": "⊂", "subseteq": "⊆", "cup": "∪", "cap": "∩",
"rightarrow": "→", "to": "→", "longrightarrow": "→", "leftarrow": "←",
"Rightarrow": "⇒", "leftrightarrow": "↔", "mapsto": "↦",
"forall": "∀", "exists": "∃", "angle": "∠", "degree": "°", "circ": "°",
"perp": "⊥", "parallel": "∥", "therefore": "∴", "because": "∵",
"ldots": "…", "cdots": "…", "dots": "…", "prime": "′",
"quad": " ", "qquad": " ", "limits": "", "displaystyle": "",
"left": "", "right": "", "bmod": "mod ", "pmod": "mod ",
"text": "", "mathrm": "", "mathit": "", "operatorname": "",
"overline": "", "underline": "",
}
var escapedPunctuation = map[string]string{
`\\`: "\n", `\%`: "%", `\&`: "&", `\#`: "#", `\_`: "_", `\$`: "$",
`\{`: "{", `\}`: "}", `\,`: " ", `\;`: " ", `\!`: "",
`\(`: "", `\)`: "", `\[`: "", `\]`: "",
}
+107
View File
@@ -0,0 +1,107 @@
package outboundtext
import "strings"
func NormalizeMath(markdown string) string {
if !carriesMath(markdown) {
return markdown
}
content := markdown
for from, to := range escapedPunctuation {
content = strings.ReplaceAll(content, from, to)
}
content = mathBlockDelimRE.ReplaceAllString(content, "\n$1\n")
content = parenDelimRE.ReplaceAllString(content, "$1")
content = bracketDelimRE.ReplaceAllString(content, "$1")
content = mathInlineDelimRE.ReplaceAllString(content, "$1")
for i := 0; i < 3; i++ {
next := sqrtRE.ReplaceAllStringFunc(content, expandRoot)
next = fracRE.ReplaceAllString(next, "($1)/($2)")
next = supBracedRE.ReplaceAllStringFunc(next, convertSuperscript)
next = subBracedRE.ReplaceAllStringFunc(next, convertSubscript)
next = supCharRE.ReplaceAllStringFunc(next, convertSuperscript)
next = subCharRE.ReplaceAllStringFunc(next, convertSubscript)
if next == content {
break
}
content = next
}
content = commandRE.ReplaceAllStringFunc(content, replaceCommand)
content = braceDelimRE.ReplaceAllString(content, "$1")
content = blankRunRE.ReplaceAllString(content, " ")
return content
}
func carriesMath(markdown string) bool {
if !strings.Contains(markdown, "$") && !strings.Contains(markdown, `\(`) &&
!strings.Contains(markdown, `\[`) && !strings.Contains(markdown, `\sqrt`) &&
!strings.Contains(markdown, `\frac`) && !strings.Contains(markdown, `\times`) &&
!strings.Contains(markdown, `\times`) {
return false
}
return true
}
func expandRoot(match string) string {
parts := sqrtRE.FindStringSubmatch(match)
degree, radicand := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2])
if degree != "" {
return "√[" + degree + "](" + radicand + ")"
}
return "√(" + radicand + ")"
}
func convertSuperscript(match string) string {
body := strings.TrimPrefix(match, "^")
body = strings.TrimSuffix(strings.TrimPrefix(body, "{"), "}")
if mapped, ok := mapRun(body, superscripts); ok {
return mapped
}
return "^(" + body + ")"
}
func convertSubscript(match string) string {
body := strings.TrimPrefix(match, "_")
body = strings.TrimSuffix(strings.TrimPrefix(body, "{"), "}")
if mapped, ok := mapRun(body, subscripts); ok {
return mapped
}
return "_(" + body + ")"
}
func mapRun(body string, table map[rune]string) (string, bool) {
if body == "" {
return "", false
}
var b strings.Builder
for _, r := range body {
mapped, ok := table[r]
if !ok {
return "", false
}
b.WriteString(mapped)
}
return b.String(), true
}
func replaceCommand(match string) string {
name := strings.TrimPrefix(match, `\`)
if mapped, ok := mathCommands[name]; ok {
return mapped
}
return name
}
var superscripts = map[rune]string{
'0': "⁰", '1': "¹", '2': "²", '3': "³", '4': "⁴", '5': "⁵", '6': "⁶",
'7': "⁷", '8': "⁸", '9': "⁹", '+': "⁺", '-': "⁻", '=': "⁼",
'(': "⁽", ')': "⁾", 'n': "ⁿ", 'i': "ⁱ", 'T': "ᵀ",
}
var subscripts = map[rune]string{
'0': "₀", '1': "₁", '2': "₂", '3': "₃", '4': "₄", '5': "₅", '6': "₆",
'7': "₇", '8': "₈", '9': "₉", '+': "₊", '-': "₋", '=': "₌",
'(': "₍", ')': "₎", 'a': "ₐ", 'e': "ₑ", 'i': "ᵢ", 'j': "ⱼ", 'k': "ₖ",
'n': "ₙ", 'o': "ₒ", 'p': "ₚ", 'r': "ᵣ", 's': "ₛ", 't': "ₜ", 'u': "ᵤ",
'v': "ᵥ", 'x': "ₓ",
}
+34
View File
@@ -0,0 +1,34 @@
package outboundtext
import "testing"
func TestNormalizeMath(t *testing.T) {
cases := []struct{ name, in, want string }{
{"inline sqrt", `$r = \sqrt{X^2 + Y^2}$`, `r = √(X² + Y²)`},
{"inline inside prose", `计算公式为 $r = \sqrt{X^2 + Y^2}$(单位:cm)`, `计算公式为 r = √(X² + Y²)(单位:cm)`},
{"block frac times", `$$\frac{a}{b} \times c$$`, "\n(a)/(b) × c\n"},
{"paren delimiters", `\(x \leq y\)`, `x ≤ y`},
{"bracket delimiters", `\[ \sum_{i=1}^{n} x_i \]`, ` Σᵢ₌₁ⁿ xᵢ `},
{"nth root", `$\sqrt[3]{V}$`, `√[3](V)`},
{"no math untouched", `C:\new\folder 与 100% 覆盖`, `C:\new\folder 与 100% 覆盖`},
{"dollar free prose", `价格是 5 元, 无公式`, `价格是 5 元, 无公式`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := NormalizeMath(tc.in)
if got != tc.want {
t.Fatalf("NormalizeMath(%q) = %q, want %q", tc.in, got, tc.want)
}
if again := NormalizeMath(got); again != got {
t.Fatalf("NormalizeMath is not idempotent: %q -> %q", got, again)
}
})
}
}
func TestNormalizeMathKeepsPlainMarkdown(t *testing.T) {
plain := "### 标题\n\n- 项目一\n- 项目二\n\n普通段落,没有公式。"
if got := NormalizeMath(plain); got != plain {
t.Fatalf("plain markdown changed: %q", got)
}
}
+102
View File
@@ -0,0 +1,102 @@
package qa
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
// TestReplayRecordedStreamDiagnostics replays a recorded WeKnora SSE stream
// (path in WK_RAW_STREAM) through the real client and prints what the narration
// observation sees. Temporary diagnostic, removed after the investigation.
func TestReplayRecordedStreamDiagnostics(t *testing.T) {
path := os.Getenv("WK_RAW_STREAM")
if path == "" {
t.Skip("WK_RAW_STREAM not set")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read stream: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write(raw)
}))
defer server.Close()
client, err := weknora.NewClient(server.URL, "test-key", 30*time.Second)
if err != nil {
t.Fatalf("client: %v", err)
}
var observation reasoningmode.NarrationObservation
runStarts := []string{}
eventTypes := map[string]int{}
observer := func(e weknora.StreamEvent) {
eventTypes[e.ResponseType]++
switch e.ResponseType {
case "answer":
if e.AnswerRunID != "" {
observation.Observed = true
}
if e.AnswerRunID != "" && e.AnswerRunID != lastRunID(runStarts) {
runStarts = append(runStarts, fmt.Sprintf("%s@%d", e.AnswerRunID, e.AnswerRunStart))
observation.AnswerRuns++
observation.LastRunStart = e.AnswerRunStart
observation.LastRunClean = true
}
case "thinking", "tool_call", "tool_result":
observation.ToolEvents++
observation.LastRunClean = false
}
}
result, err := client.AgentChatEvents(context.Background(), "session", weknora.AgentChatRequest{AgentID: "agent", Query: "q"}, observer)
if err != nil {
t.Fatalf("agent chat events: %v", err)
}
t.Logf("eventTypes=%v", eventTypes)
t.Logf("runs=%v", runStarts)
t.Logf("observation: observed=%v runs=%d tools=%d lastRunStart=%d lastRunClean=%v",
observation.Observed, observation.AnswerRuns, observation.ToolEvents, observation.LastRunStart, observation.LastRunClean)
content := result.Answer
observed := reasoningmode.TrimReasoningNarrationObserved(content, observation)
heuristic := reasoningmode.TrimReasoningNarration(content)
t.Logf("contentLen=%d observedKept=%d heuristicKept=%d", len(content), len(observed), len(heuristic))
if n := len(content) - len(observed); n > 0 {
t.Logf("observedDropPrefix=%q", firstRunes(content[:n], 160))
}
if n := len(content) - len(heuristic); n > 0 {
t.Logf("heuristicDropPrefix=%q", firstRunes(content[:n], 160))
}
t.Logf("observedKeptHead=%q", firstRunes(observed, 160))
}
func lastRunID(runs []string) string {
if len(runs) == 0 {
return ""
}
id := runs[len(runs)-1]
if i := strings.LastIndex(id, "@"); i >= 0 {
return id[:i]
}
return id
}
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
@@ -40,6 +40,11 @@ func (o NarrationObservation) BodyStartOffset(contentLength int) (int, bool) {
if !o.Observed {
return 0, false
}
// A single answer run carries the whole answer: the agent published no
// separate step narration, so there is nothing that can be cut safely.
if o.AnswerRuns <= 1 {
return 0, true
}
if o.ToolEvents == 0 {
return 0, true
}
@@ -55,7 +55,7 @@ func TestNarrationObservationFallsBackWithoutUsableBoundary(t *testing.T) {
}{
{"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}},
{"multi run starting at zero", NarrationObservation{Observed: true, AnswerRuns: 3, ToolEvents: 5, 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 {
@@ -68,6 +68,20 @@ func TestNarrationObservationFallsBackWithoutUsableBoundary(t *testing.T) {
}
}
func TestNarrationObservationKeepsSingleRunAnswer(t *testing.T) {
// The quick-answer agent publishes the whole answer as a single answer run
// (with tool_call/tool_result steps around it). Nothing in that stream is
// narration, so the lead-in and the image must survive.
observation := NarrationObservation{Observed: true, AnswerRuns: 1, ToolEvents: 4, LastRunStart: 0, LastRunClean: true}
got := TrimReasoningNarrationObserved(craLoadCurveAnswer, observation)
if got != craLoadCurveAnswer {
t.Fatalf("single answer run must keep the whole answer, got %q", firstLine(got))
}
if !strings.Contains(got, "resource://") {
t.Fatalf("the image reference must survive for a single answer run")
}
}
func TestNarrationObservationKeepsWholeAnswerWithoutToolSteps(t *testing.T) {
answer := "本机提供以下三种接线方式:\n\n| 方式 | 说明 |\n| --- | --- |\n| A | 直连 |\n"
observation := NarrationObservation{Observed: true, AnswerRuns: 1, ToolEvents: 0}