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:
@@ -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\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 ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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", `\%`: "%", `\&`: "&", `\#`: "#", `\_`: "_", `\$`: "$",
|
||||
`\{`: "{", `\}`: "}", `\,`: " ", `\;`: " ", `\!`: "",
|
||||
`\(`: "", `\)`: "", `\[`: "", `\]`: "",
|
||||
}
|
||||
@@ -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': "ₓ",
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user