Merge remote-tracking branch 'origin/fix/weknora-timeout-nolimit' into ci-trial-gate-legacy
build / build (push) Successful in 7s

This commit is contained in:
姚章浩
2026-11-11 16:45:14 +08:00
46 changed files with 4101 additions and 60 deletions
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"log"
"os"
"strings"
)
// cardWideTableGroupingMode returns the configured handling of over-wide answer
// tables (see dingtalk.tableGroupingForMode):
//
// unset / on → grouped list on every frame (historical behaviour)
// off → real tables on every frame
// stream → grouped list while streaming, real table on the final frame
//
// The default stays "on" so an environment without the variable renders exactly
// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes.
func cardWideTableGroupingMode() string {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP")))
switch mode {
case "off", "0", "false", "no", "stream", "streaming", "partial":
log.Printf("DingTalk wide-table grouping mode=%s", mode)
return mode
default:
return ""
}
}
+9 -1
View File
@@ -71,7 +71,10 @@ func main() {
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
// 0 = no client-side total timeout. WeKnora's smart-reasoning agent can run
// 20+ tool rounds and stream far longer than 120s; the total-time cap cut the
// SSE mid-answer and the failure branch overwrote the streamed card.
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 0)
if err != nil {
log.Fatal(err)
}
@@ -307,6 +310,7 @@ func main() {
}
cardSender = cs
}
cardImageMedia := buildCardImageMedia(outboundImages.publicURLPrefix, dingCfg.ClientID, dingCfg.ClientSecret, address)
handler, err := dingtalk.NewHandlerWithOptions(
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
@@ -317,9 +321,13 @@ func main() {
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
CardImageMedia: cardImageMedia,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
WideTableGrouping: cardWideTableGroupingMode(),
},
)
if err != nil {
+50
View File
@@ -5,6 +5,7 @@ import (
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
@@ -184,6 +185,55 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
}
}
func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
if outboundImageRewriteTimeoutDefault != 180*time.Second {
t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "")
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("unset budget=%s", got)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s")
if got := outboundImageRewriteTimeout(); got != 150*time.Second {
t.Fatalf("parsed budget=%s", got)
}
for _, invalid := range []string{"-1s", "180", "soon"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid)
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("invalid budget %q resolved to %s", invalid, got)
}
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "")
if !outboundImageCompressionEnabled() {
t.Fatal("compression must default to on")
}
for _, disabled := range []string{"off", "0", "false", "no"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled)
if outboundImageCompressionEnabled() {
t.Fatalf("compression must be off for %q", disabled)
}
}
}
func TestCardWideTableGroupingMode(t *testing.T) {
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("unset must keep the historical mode, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
if got := cardWideTableGroupingMode(); got != "off" {
t.Fatalf("off must be passed through, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ")
if got := cardWideTableGroupingMode(); got != "stream" {
t.Fatalf("stream must be normalised, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got)
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
+115 -7
View File
@@ -2,7 +2,11 @@ package main
import (
"errors"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -11,12 +15,63 @@ import (
)
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
outboundImageMaxCountDefault = 12
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
// outboundImageRewriteTimeoutDefault is the budget for one answer's image
// rewrite. Manual screenshots are multi-megabyte uploads and the container's
// uplink is the real ceiling (measured ~0.6MB/s), so long answers need a
// budget with headroom: the historical 15s delivery timeout truncated them.
outboundImageRewriteTimeoutDefault = 180 * time.Second
)
// outboundImageMaxCount resolves how many images one answer may rewrite.
// DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT overrides the default; invalid values fall back.
func outboundImageMaxCount() int {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT"))
if raw == "" {
return outboundImageMaxCountDefault
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT=%q; using default %d", raw, outboundImageMaxCountDefault)
return outboundImageMaxCountDefault
}
return n
}
// outboundImageRewriteTimeout resolves the budget for the image rewrite stage.
// DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT accepts a Go duration ("90s", "2m");
// invalid or non-positive values fall back to the default.
func outboundImageRewriteTimeout() time.Duration {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT"))
if raw == "" {
return outboundImageRewriteTimeoutDefault
}
duration, err := time.ParseDuration(raw)
if err != nil || duration <= 0 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT=%q; using default %s", raw, outboundImageRewriteTimeoutDefault)
return outboundImageRewriteTimeoutDefault
}
return duration
}
// outboundImageCompressionEnabled reports whether published screenshots are
// re-encoded as smaller JPEGs before upload. It is on by default because the
// container uplink, not the rewrite code, caps how many images fit in the
// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original
// bytes exactly as fetched.
func outboundImageCompressionEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) {
case "off", "0", "false", "no":
log.Printf("outbound image: compression disabled by configuration")
return false
default:
return true
}
}
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
@@ -32,6 +87,11 @@ type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
// rewriteTimeout is the budget the channel gives the image rewrite stage.
rewriteTimeout time.Duration
// publicURLPrefix is the prefix of every published image URL, used by the
// DingTalk card image uploader to recognise (and refetch) our own images.
publicURLPrefix string
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
@@ -81,9 +141,11 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())),
publisher: publisher,
handler: handler,
rewriteTimeout: outboundImageRewriteTimeout(),
publicURLPrefix: strings.TrimRight(cfg.PublicBaseURL, "/") + "/",
}, nil
}
@@ -91,3 +153,49 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
// buildCardImageMedia wires the DingTalk media-id rewriter used for AI card
// bodies. It returns nil when the feature is switched off or not configured, so
// a disabled uploader simply keeps external image URLs in place.
func buildCardImageMedia(publicImagePrefix, clientID, clientSecret, listenAddr string) dingtalk.CardImageMediaRewriter {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_MEDIA"))) {
case "off", "0", "false", "no":
log.Printf("DingTalk card image media ids disabled by configuration")
return nil
}
resolver, err := dingtalk.NewCardImageMediaRewriter(dingtalk.CardImageMediaOptions{
ClientID: clientID,
ClientSecret: clientSecret,
ImageURLPrefix: publicImagePrefix,
FetchURLPrefix: cardImageFetchPrefix(listenAddr),
})
if err != nil {
log.Printf("DingTalk card image media ids disabled: %v", err)
return nil
}
if resolver == nil {
return nil
}
log.Printf("DingTalk card images will be delivered through DingTalk media ids")
return resolver
}
// cardImageFetchPrefix derives the prefix used to download our own published
// images before uploading them to DingTalk. An explicit
// DINGTALK_CARD_IMAGE_FETCH_BASE_URL (host only, without the image route) wins;
// otherwise the local listen port is reused so the fetch stays on loopback.
func cardImageFetchPrefix(listenAddr string) string {
base := strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_FETCH_BASE_URL"))
if base == "" {
addr := strings.TrimSpace(listenAddr)
if addr == "" {
return ""
}
_, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return ""
}
base = "http://127.0.0.1:" + port
}
return strings.TrimRight(base, "/") + outboundImageRoute
}
+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 ""
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
Sends two cards to the owner's private robot chat so PC width can be compared:
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
B: control, same body without the layout field
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
Credentials are read from the running container's environment and are never
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
"""
import json
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
CONTAINER = "dobotai-dingtalk"
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
CONTENT = (
"**{tag}**\n\n"
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
"- 投放体里的布局字段:{field}\n"
"- 期望:{expect}\n"
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
"移动端请忽略,宽屏只影响桌面端渲染。"
)
def env(key: str) -> str:
out = subprocess.run(
["docker", "exec", CONTAINER, "printenv", key],
capture_output=True, text=True, check=True,
).stdout.strip()
if not out:
sys.exit(f"missing env {key}")
return out
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("x-acs-dingtalk-access-token", token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read(4096).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read(4096).decode("utf-8", "replace")
def mask(v: str) -> str:
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
def main() -> None:
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
token = json.loads(body).get("accessToken", "")
print(f"token: http={status} got={'yes' if token else 'NO'}")
if not token:
sys.exit("no token")
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
param_map = {
"content": "",
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
}
if with_wide:
param_map["sys_full_json_obj"] = WIDE
out_track_id = str(uuid.uuid4())
card_body = {
"cardTemplateId": tpl,
"outTrackId": out_track_id,
"callbackType": "STREAM",
"cardData": {"cardParamMap": param_map},
"userIdType": 1,
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
"imRobotOpenSpaceModel": {"supportForward": True},
"imRobotOpenDeliverModel": {
"robotCode": app_key,
"spaceType": "IM_ROBOT",
"extension": {},
},
}
st, rb = call("POST", CREATE_URL, card_body, token)
print(f"[{tag}] create http={st} resp={rb[:300]}")
text = CONTENT.format(
tag=tag,
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
)
stream_body = {
"outTrackId": out_track_id,
"guid": str(uuid.uuid4()),
"key": "content",
"content": text,
"isFull": True,
"isFinalize": True,
"isError": False,
}
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
\pset pager off
SELECT table_schema || '.' || table_name AS t
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1;
+8
View File
@@ -0,0 +1,8 @@
\pset pager off
\echo == columns ==
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'channel_actors'
ORDER BY ordinal_position;
\echo == rows ==
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Prod-server switch to the combined release:
# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics
# + wide-screen (autoLayout) card payload
# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh.
#
# Usage: bash prod_switch_reasoning_widescreen.sh <new-image-ref>
#
# Safety: requires the new image locally; tags the running image as a rollback tag;
# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears
# exactly once before and once after the edit and the line count is unchanged;
# refuses unless the new binary carries every release literal.
# Prints only image refs / variable names / literal names - never env values.
set -euo pipefail
NEW_IMAGE="${1:-}"
[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; }
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
BACKUP_DIR="/opt/dobotai/container/backups"
COMPOSE_SERVICE="dobotai-dingtalk"
RELEASE_SLUG="reasoning-widescreen"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')"
ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; }
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then
echo "literal $lit: PRESENT"
else
echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2
exit 1
fi
done
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")"
install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP"
[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV"
echo "backup=$BACKUP mode=$ORIG_MODE"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
chmod "$ORIG_MODE" "$tmp"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
echo "== readback =="
sleep 4
curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo
docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "running literal $lit: PRESENT"
else
echo "running literal $lit: ABSENT"
fi
done
echo "== log scan (since 90s) =="
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines"
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6
echo "== rollback (one command, run on this host) =="
echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE"
echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Test-server switch to the build that delivers card images as DingTalk media ids.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback at
# the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Test-server switch to the wide-screen (autoLayout) card image.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback
# at the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Generic test-server image switch: rollback tag first, 0600 runtime.env backup,
# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count),
# container recreate, then read-only readback incl. binary literal presence.
# Usage: bash test_switch_image.sh <image:tag> <label>
set -euo pipefail
NEW_IMAGE="${1:?usage: test_switch_image.sh <image:tag> <label>}"
LABEL="${2:?usage: test_switch_image.sh <image:tag> <label>}"
RUNTIME_ENV=/opt/dobotai/container/runtime.env
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-$LABEL"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR=/opt/dobotai/container/backups
BACKUP="$BACKUP_DIR/runtime.env.before-$LABEL.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
[ "$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" = "1" ] || { echo "ABORT: DOBOTAI_IMAGE line count != 1" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in PartialFeed autoLayout 智能推理模式; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -8
echo "rollback: set DOBOTAI_IMAGE=$OLD_IMAGE in $RUNTIME_ENV (backup $BACKUP) then: docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk"
+19
View File
@@ -21,6 +21,24 @@ const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
// cardWideScreenJSONKey is DingTalk's reserved system field inside
// cardData.cardParamMap. Template variables whose value is a complex type
// (object / array of objects) must be passed through this field as a JSON
// string; plain string/number/boolean variables go in cardParamMap
// directly. Putting a complex value anywhere else (for example as a
// sibling of cardParamMap under cardData) leaves the template variable
// unassigned and silently falls back to the default layout.
cardWideScreenJSONKey = "sys_full_json_obj"
// cardWideScreenJSON configures the AI card's root "config": PC clients
// render the card wide (auto layout) when autoLayout is true. The card
// template must declare an object variable "config" with a boolean child
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
// passed when the card is created - setting it statically inside the
// template does not take effect. Mobile clients keep their own adaptive
// layout, so this only widens the card on desktop.
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
)
// CardRequest is the input for sending a final AI card.
@@ -229,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
cardWideScreenJSONKey: cardWideScreenJSON,
},
},
"userIdType": 1,
+56 -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,63 @@ 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
imageRefs int
externalImageRefs int
mediaImageRefs 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.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content)
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 +97,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 image_refs=%d external_image_refs=%d media_image_refs=%d",
isFinalize,
isError,
shape.bytes,
@@ -60,5 +107,13 @@ func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
shape.bodyOK,
shape.bodyLine,
shape.bodyPrefixRunes,
shape.bannerPos,
shape.firstHeadingLine,
shape.imageRefs,
shape.externalImageRefs,
shape.mediaImageRefs,
)
}
@@ -0,0 +1,488 @@
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"strings"
"sync"
"time"
)
// DingTalk AI cards only render images that live in DingTalk's own media store:
// an external HTTPS image URL shows up in a normal markdown message but is
// dropped inside a streaming/short-card body (verified on both desktop and
// mobile, 2026-09-17). The card content therefore rewrites our own published
// image URLs into DingTalk media_id values obtained through the legacy
// oapi media upload endpoint, which both clients render.
const (
defaultCardImageTokenURL = "https://oapi.dingtalk.com/gettoken"
defaultCardImageUploadURL = "https://oapi.dingtalk.com/media/upload"
defaultCardImageMediaTTL = 12 * time.Hour
defaultCardImageWaitTimeout = 5 * time.Second
defaultCardImageFetchTimeout = 15 * time.Second
defaultCardImageUploadTimeout = 20 * time.Second
defaultCardImageMaxBytes = 8 << 20
cardImageTokenRefreshMargin = 5 * time.Minute
maxConcurrentCardImageUploads = 3
)
// cardImageStableIDRE matches the deterministic image id produced by
// outboundimage.StableHashPublisher (UUIDv5 shaped, lowercase hex).
var cardImageStableIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
// cardMarkdownImageRE captures the URL of a complete markdown image, including
// the optional title slot. Incomplete image syntax (still streaming) is not
// matched, so it is never rewritten.
var cardMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)\r\n]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^)\r\n]*\)))?\)`)
// CardImageMediaRewriter converts card-body image references into DingTalk
// media ids. Implementations must be safe for concurrent use and must never
// fail a card update: on any error the original reference is kept.
type CardImageMediaRewriter interface {
RewriteForCard(ctx context.Context, content string, wait bool) string
}
// CardImageMediaOptions configures the resolver. ClientID/ClientSecret are the
// same Robot app credentials used for access tokens, and ImageURLPrefix is the
// public prefix of our own published images.
type CardImageMediaOptions struct {
ClientID string
ClientSecret string
ImageURLPrefix string
// FetchURLPrefix optionally replaces ImageURLPrefix when downloading the
// bytes back (for example a loopback address), so the upload never depends
// on the container being able to reach its own public host name.
FetchURLPrefix string
TokenURL string
UploadURL string
HTTPClient *http.Client
TTL time.Duration
WaitTimeout time.Duration
MaxBytes int64
}
type cardImageMediaEntry struct {
mediaID string
expiresAt time.Time
}
type cardImageMediaJob struct {
done chan struct{}
mediaID string
err error
}
type cardImageMediaResolver struct {
clientID string
clientSecret string
imageURLPrefix string
fetchURLPrefix string
tokenURL string
uploadURL string
client *http.Client
ttl time.Duration
waitTimeout time.Duration
maxBytes int64
sem chan struct{}
now func() time.Time
mu sync.Mutex
entries map[string]cardImageMediaEntry
inflight map[string]*cardImageMediaJob
token string
tokenExpiry time.Time
}
// NewCardImageMediaRewriter builds the resolver. It returns a nil rewriter and
// no error when the feature is not configured, which keeps the caller simple.
func NewCardImageMediaRewriter(opts CardImageMediaOptions) (CardImageMediaRewriter, error) {
clientID := strings.TrimSpace(opts.ClientID)
clientSecret := strings.TrimSpace(opts.ClientSecret)
prefix := strings.TrimSpace(opts.ImageURLPrefix)
if clientID == "" || clientSecret == "" || prefix == "" {
return nil, nil
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
tokenURL := strings.TrimSpace(opts.TokenURL)
if tokenURL == "" {
tokenURL = defaultCardImageTokenURL
}
uploadURL := strings.TrimSpace(opts.UploadURL)
if uploadURL == "" {
uploadURL = defaultCardImageUploadURL
}
ttl := opts.TTL
if ttl <= 0 {
ttl = defaultCardImageMediaTTL
}
waitTimeout := opts.WaitTimeout
if waitTimeout <= 0 {
waitTimeout = defaultCardImageWaitTimeout
}
maxBytes := opts.MaxBytes
if maxBytes <= 0 {
maxBytes = defaultCardImageMaxBytes
}
client := opts.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
client = &http.Client{Timeout: defaultCardImageFetchTimeout, Transport: transport}
}
return &cardImageMediaResolver{
clientID: clientID,
clientSecret: clientSecret,
imageURLPrefix: prefix,
fetchURLPrefix: strings.TrimSpace(opts.FetchURLPrefix),
tokenURL: tokenURL,
uploadURL: uploadURL,
client: client,
ttl: ttl,
waitTimeout: waitTimeout,
maxBytes: maxBytes,
sem: make(chan struct{}, maxConcurrentCardImageUploads),
now: time.Now,
entries: make(map[string]cardImageMediaEntry),
inflight: make(map[string]*cardImageMediaJob),
}, nil
}
// RewriteForCard replaces complete markdown image references that point at our
// own published images with a DingTalk media id. With wait=false the rewrite
// only uses media ids that are already known and kicks off the upload in the
// background, so streaming frames never block on DingTalk; with wait=true it
// waits (bounded) for in-flight uploads, which is what the final card update
// uses.
func (r *cardImageMediaResolver) RewriteForCard(ctx context.Context, content string, wait bool) string {
if r == nil || strings.TrimSpace(content) == "" {
return content
}
var waitCtx context.Context
if wait {
bound := r.waitTimeout
if bound <= 0 {
bound = defaultCardImageWaitTimeout
}
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), bound)
defer cancel()
}
rewritten := cardMarkdownImageRE.ReplaceAllStringFunc(content, func(image string) string {
match := cardMarkdownImageRE.FindStringSubmatch(image)
if len(match) < 2 {
return image
}
rawURL := strings.TrimSpace(match[1])
if rawURL == "" || !r.isOwnImageURL(rawURL) {
return image
}
if mediaID, ok := r.lookup(rawURL); ok {
return replaceCardImageURL(image, match[1], mediaID)
}
job := r.startUpload(rawURL)
if !wait {
return image
}
select {
case <-job.done:
case <-waitCtx.Done():
}
if job.mediaID != "" {
return replaceCardImageURL(image, match[1], job.mediaID)
}
if job.err != nil {
log.Printf("DingTalk card image media upload failed, keeping external reference")
}
return image
})
return rewritten
}
func replaceCardImageURL(image, rawURL, mediaID string) string {
if mediaID == "" || rawURL == "" {
return image
}
return strings.Replace(image, rawURL, mediaID, 1)
}
func (r *cardImageMediaResolver) isOwnImageURL(rawURL string) bool {
if r.imageURLPrefix == "" || !strings.HasPrefix(rawURL, r.imageURLPrefix) {
return false
}
id := strings.TrimPrefix(rawURL, r.imageURLPrefix)
if id == "" || strings.ContainsAny(id, "/?#") {
return false
}
return cardImageStableIDRE.MatchString(id)
}
func (r *cardImageMediaResolver) lookup(rawURL string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[rawURL]
if !ok || r.now().After(entry.expiresAt) {
return "", false
}
return entry.mediaID, true
}
// startUpload returns a job for the image, reusing an in-flight upload or a
// cached media id when available. The upload itself runs on a background
// context so a streaming frame's cancellation cannot abort it half way.
func (r *cardImageMediaResolver) startUpload(rawURL string) *cardImageMediaJob {
r.mu.Lock()
if entry, ok := r.entries[rawURL]; ok && r.now().Before(entry.expiresAt) {
job := &cardImageMediaJob{done: make(chan struct{}), mediaID: entry.mediaID}
close(job.done)
r.mu.Unlock()
return job
}
if job, ok := r.inflight[rawURL]; ok {
r.mu.Unlock()
return job
}
job := &cardImageMediaJob{done: make(chan struct{})}
r.inflight[rawURL] = job
r.mu.Unlock()
go func() {
uploadCtx, cancel := context.WithTimeout(context.Background(), defaultCardImageUploadTimeout)
defer cancel()
mediaID, err := r.upload(uploadCtx, rawURL)
job.mediaID = mediaID
job.err = err
r.mu.Lock()
delete(r.inflight, rawURL)
if err == nil && mediaID != "" {
r.entries[rawURL] = cardImageMediaEntry{mediaID: mediaID, expiresAt: r.now().Add(r.ttl)}
}
r.mu.Unlock()
close(job.done)
}()
return job
}
func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (string, error) {
select {
case r.sem <- struct{}{}:
case <-ctx.Done():
return "", ctx.Err()
}
defer func() { <-r.sem }()
data, contentType, err := r.fetch(ctx, rawURL)
if err != nil {
return "", err
}
token, err := r.accessToken(ctx)
if err != nil {
return "", err
}
return r.uploadMedia(ctx, token, data, contentType)
}
func (r *cardImageMediaResolver) fetchURL(rawURL string) string {
targets := r.fetchURLs(rawURL)
if len(targets) == 0 {
return rawURL
}
return targets[0]
}
// fetchURLs lists the download candidates for one of our published images: the
// loopback prefix first (cheap, stays inside the host) and the public URL as a
// fallback so a missing local route never turns into a missing card image.
func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string {
relative := ""
if strings.HasPrefix(rawURL, r.imageURLPrefix) {
relative = strings.TrimPrefix(rawURL, r.imageURLPrefix)
}
targets := make([]string, 0, 2)
if r.fetchURLPrefix != "" && relative != "" {
targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative)
}
if relative == "" || r.fetchURLPrefix == "" {
targets = append(targets, rawURL)
} else {
targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative)
}
return targets
}
func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
var firstErr error
for _, target := range r.fetchURLs(rawURL) {
data, contentType, err := r.fetchOnce(ctx, target)
if err == nil {
return data, contentType, nil
}
if firstErr == nil {
firstErr = err
}
if ctx.Err() != nil {
break
}
}
if firstErr == nil {
firstErr = errors.New("card image fetch: no candidate URL")
}
return nil, "", firstErr
}
func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", err
}
resp, err := r.client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("card image fetch: unexpected status %d", resp.StatusCode)
}
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
return nil, "", fmt.Errorf("card image fetch: unexpected content type")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, r.maxBytes+1))
if err != nil {
return nil, "", err
}
if int64(len(data)) > r.maxBytes {
return nil, "", errors.New("card image fetch: image exceeds upload size limit")
}
if len(data) == 0 {
return nil, "", errors.New("card image fetch: empty image")
}
return data, contentType, nil
}
// cardImageRefCounts splits the markdown image references of a card body into
// externally hosted URLs and DingTalk media ids, so operators can tell from the
// logs whether a card body can actually render its images.
func cardImageRefCounts(content string) (total, external, media int) {
for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) {
if len(match) < 2 {
continue
}
total++
if strings.Contains(strings.TrimSpace(match[1]), "://") {
external++
} else {
media++
}
}
return total, external, media
}
func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) {
r.mu.Lock()
if r.token != "" && r.now().Before(r.tokenExpiry) {
token := r.token
r.mu.Unlock()
return token, nil
}
r.mu.Unlock()
values := map[string]string{"appkey": r.clientID, "appsecret": r.clientSecret}
payload, err := json.Marshal(values)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.tokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("card image token: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.AccessToken) == "" {
return "", fmt.Errorf("card image token: rejected with errcode %d", parsed.ErrCode)
}
expiresIn := parsed.ExpiresIn
if expiresIn <= 0 {
expiresIn = 7200
}
r.mu.Lock()
r.token = parsed.AccessToken
r.tokenExpiry = r.now().Add(time.Duration(expiresIn)*time.Second - cardImageTokenRefreshMargin)
r.mu.Unlock()
return parsed.AccessToken, nil
}
func (r *cardImageMediaResolver) uploadMedia(ctx context.Context, token string, data []byte, contentType string) (string, error) {
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="media"; filename="card-image"`)
if contentType == "" {
contentType = "image/png"
}
header.Set("Content-Type", contentType)
part, err := writer.CreatePart(header)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
uploadURL := r.uploadURL + "?access_token=" + token + "&type=image"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buffer)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", errors.New("card image upload: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", fmt.Errorf("card image upload: rejected with errcode %d", parsed.ErrCode)
}
return parsed.MediaID, nil
}
@@ -0,0 +1,398 @@
package dingtalk
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const cardImageTestID = "5f0a1c2e-3b4d-5e6f-8a9b-0c1d2e3f4a5b"
const cardImageTestMediaID = "@lADP-test-media-id"
type cardImageTestServer struct {
server *httptest.Server
imageBytes []byte
oversizeBytes []byte
uploadErrcode int
tokenCalls int
uploadCalls int
uploadedBytes int
fetched []string
mu sync.Mutex
uploadedC chan struct{}
}
func newCardImageTestServer(t *testing.T) *cardImageTestServer {
t.Helper()
fixture := &cardImageTestServer{
imageBytes: []byte("\x89PNG\r\n\x1a\nfixture-bytes"),
oversizeBytes: make([]byte, 512),
uploadedC: make(chan struct{}, 4),
}
mux := http.NewServeMux()
serveImage := func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.fetched = append(fixture.fetched, r.URL.Path)
fixture.mu.Unlock()
body := fixture.imageBytes
if strings.HasSuffix(r.URL.Path, "big") {
body = fixture.oversizeBytes
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(body)
}
mux.HandleFunc("/dingtalk/i/", serveImage)
mux.HandleFunc("/images/", serveImage)
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.tokenCalls++
fixture.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": 0, "errmsg": "ok", "access_token": "test-token", "expires_in": 7200,
})
})
mux.HandleFunc("/media/upload", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") != "test-token" || r.URL.Query().Get("type") != "image" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40001, "errmsg": "bad token"})
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
file, _, err := r.FormFile("media")
if err == nil {
data, _ := io.ReadAll(file)
_ = file.Close()
fixture.mu.Lock()
fixture.uploadedBytes += len(data)
fixture.mu.Unlock()
}
fixture.mu.Lock()
fixture.uploadCalls++
code := fixture.uploadErrcode
fixture.mu.Unlock()
select {
case fixture.uploadedC <- struct{}{}:
default:
}
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": code, "errmsg": "ok", "media_id": cardImageTestMediaID,
})
})
fixture.server = httptest.NewServer(mux)
t.Cleanup(fixture.server.Close)
return fixture
}
func (f *cardImageTestServer) resolver(t *testing.T, mutate func(*CardImageMediaOptions)) CardImageMediaRewriter {
t.Helper()
opts := CardImageMediaOptions{
ClientID: "robot-client-id",
ClientSecret: "robot-client-secret",
ImageURLPrefix: f.server.URL + "/dingtalk/i/",
TokenURL: f.server.URL + "/gettoken",
UploadURL: f.server.URL + "/media/upload",
HTTPClient: f.server.Client(),
}
if mutate != nil {
mutate(&opts)
}
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil {
t.Fatalf("NewCardImageMediaRewriter: %v", err)
}
if rewriter == nil {
t.Fatal("expected a configured rewriter")
}
return rewriter
}
func (f *cardImageTestServer) counts() (token, upload int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.tokenCalls, f.uploadCalls
}
func (f *cardImageTestServer) uploadedByteCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.uploadedBytes
}
func (f *cardImageTestServer) imageURL(id string) string {
return f.server.URL + "/dingtalk/i/" + id
}
func (f *cardImageTestServer) waitForUpload(t *testing.T) {
t.Helper()
select {
case <-f.uploadedC:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the media upload")
}
}
func TestCardImageMediaRewritesOwnImageAndCachesMediaID(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("看图:![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
want := fmt.Sprintf("看图:![设备图](%s)", cardImageTestMediaID)
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
if got := fixture.uploadedByteCount(); got != len(fixture.imageBytes) {
t.Fatalf("uploaded bytes=%d want %d", got, len(fixture.imageBytes))
}
// A second rewrite must reuse the cached media id without another upload,
// and the token must only be fetched once.
again := rewriter.RewriteForCard(context.Background(), content, true)
if again != want {
t.Fatalf("cached rewrite got=%q want=%q", again, want)
}
tokens, uploads := fixture.counts()
if uploads != 1 || tokens != 1 {
t.Fatalf("tokens=%d uploads=%d want 1/1", tokens, uploads)
}
}
func TestCardImageMediaPartialFramesNeverBlockAndPickUpLater(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
// A streaming frame keeps the external URL but starts the upload.
if got := rewriter.RewriteForCard(context.Background(), content, false); got != content {
t.Fatalf("streaming frame must stay unchanged, got=%q", got)
}
fixture.waitForUpload(t)
deadline := time.Now().Add(2 * time.Second)
for {
got := rewriter.RewriteForCard(context.Background(), content, false)
if strings.Contains(got, cardImageTestMediaID) {
break
}
if time.Now().After(deadline) {
t.Fatalf("media id never became available, got=%q", got)
}
time.Sleep(10 * time.Millisecond)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
}
func TestCardImageMediaIgnoresForeignAndIncompleteReferences(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := strings.Join([]string{
fmt.Sprintf("![外部图](https://example.com/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![流水线](%s)", fixture.imageURL("not-a-stable-id")),
fmt.Sprintf("![未闭合](%s", fixture.imageURL(cardImageTestID)),
fmt.Sprintf("纯链接 %s", fixture.imageURL(cardImageTestID)),
}, "\n")
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("content must be untouched, got=%q", got)
}
tokens, uploads := fixture.counts()
if tokens != 0 || uploads != 0 {
t.Fatalf("tokens=%d uploads=%d want 0/0", tokens, uploads)
}
}
func TestCardImageMediaFallsBackWhenUploadFails(t *testing.T) {
fixture := newCardImageTestServer(t)
fixture.uploadErrcode = 40035
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("failed upload must keep the external reference, got=%q", got)
}
}
func TestCardImageMediaFallsBackForOversizeImages(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.MaxBytes = 128
})
content := fmt.Sprintf("![大图](%s/big)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("oversize image must keep the external reference, got=%q", got)
}
if _, uploads := fixture.counts(); uploads != 0 {
t.Fatalf("uploads=%d want 0", uploads)
}
}
func TestCardImageMediaFetchUsesLoopbackPrefix(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.ImageURLPrefix = "https://weknora-api.invalid/dingtalk/i/"
opts.FetchURLPrefix = fixture.server.URL + "/images/"
})
content := fmt.Sprintf("![设备图](https://weknora-api.invalid/dingtalk/i/%s)", cardImageTestID)
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("loopback fetch must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/images/") {
t.Fatalf("fetched=%v want one loopback path", fixture.fetched)
}
}
func TestCardImageMediaDeduplicatesConcurrentUploads(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rewriter.RewriteForCard(context.Background(), content, true)
}()
}
wg.Wait()
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1 for concurrent callers", uploads)
}
}
func TestCardImageMediaRewriterRequiresFullConfiguration(t *testing.T) {
for name, opts := range map[string]CardImageMediaOptions{
"missing client id": {ClientSecret: "s", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing secret": {ClientID: "id", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing prefix": {ClientID: "id", ClientSecret: "s"},
} {
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil || rewriter != nil {
t.Fatalf("%s: rewriter=%v err=%v want nil/nil", name, rewriter, err)
}
}
}
func TestStreamingCardDeliverySendsMediaIDAndKeepsMarkdownExternal(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("CR20V 接线如下:\n\n![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-card"}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(card.updates) == 0 {
t.Fatal("no card update was recorded")
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize {
t.Fatalf("last update must finalize the card, got %+v", final)
}
if !strings.Contains(final.content, cardImageTestMediaID) {
t.Fatalf("final card body must carry the media id, got=%q", final.content)
}
if strings.Contains(final.content, fixture.server.URL) {
t.Fatalf("final card body must not keep the external URL, got=%q", final.content)
}
}
func TestCardFinalizeFallbackKeepsExternalImageURL(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-fallback", updateErr: errors.New("card unavailable")}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if !reply.called {
t.Fatal("markdown fallback was not sent")
}
if !strings.Contains(reply.lastText, fixture.server.URL) {
t.Fatalf("markdown fallback must keep the external URL, got=%q", reply.lastText)
}
if strings.Contains(reply.lastText, cardImageTestMediaID) {
t.Fatalf("markdown fallback must not carry a media id, got=%q", reply.lastText)
}
}
func TestCardImageMediaFallsBackToPublicURLWhenLoopbackMisses(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
// The loopback route exists but does not serve this object.
opts.FetchURLPrefix = fixture.server.URL + "/missing/"
})
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("public fallback must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/dingtalk/i/") {
t.Fatalf("fetched=%v want a single public fetch after the loopback miss", fixture.fetched)
}
}
func TestCardImageRefCountsSplitMediaAndExternalReferences(t *testing.T) {
content := strings.Join([]string{
fmt.Sprintf("![图一](%s)", cardImageTestMediaID),
fmt.Sprintf("![图二](https://weknora-api.dobot.cn/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![图三](%s)", cardImageTestMediaID),
}, "\n")
total, external, media := cardImageRefCounts(content)
if total != 3 || external != 1 || media != 2 {
t.Fatalf("total=%d external=%d media=%d want 3/1/2", total, external, media)
}
if total, external, media := cardImageRefCounts("无图回答"); total != 0 || external != 0 || media != 0 {
t.Fatalf("plain text must report no image refs, got %d/%d/%d", total, external, media)
}
}
func TestCardUpdateDiagnosticReportsImageReferenceShape(t *testing.T) {
content := fmt.Sprintf("正文\n\n![图](%s)", cardImageTestMediaID)
got := formatCardUpdateDiagnostic(content, true, false)
if !strings.Contains(got, "image_refs=1") || !strings.Contains(got, "media_image_refs=1") || !strings.Contains(got, "external_image_refs=0") {
t.Fatalf("diagnostic must report image reference shape: %q", got)
}
if strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("diagnostic must not leak the media id: %q", got)
}
}
+111
View File
@@ -1289,6 +1289,117 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: PC wide-screen layout config is delivered through the reserved
// cardParamMap system field, never as a sibling of cardParamMap
// ---------------------------------------------------------------------------
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
cases := []struct {
name string
req CardRequest
}{
{
name: "direct",
req: CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
{
name: "group",
req: CardRequest{
ConversationType: core.ConversationGroup,
ExternalChatID: "chat-789",
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
t.Fatalf("StartCard: %v", err)
}
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
if !ok {
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
}
// Regression guard: a complex-typed value placed next to
// cardParamMap leaves the template variable unassigned and the
// card silently renders in the default narrow layout.
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
if _, present := cardData[badKey]; present {
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
}
}
paramMap, ok := cardData["cardParamMap"].(map[string]any)
if !ok {
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
}
raw, ok := paramMap[cardWideScreenJSONKey].(string)
if !ok {
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
}
var decoded struct {
Config struct {
AutoLayout bool `json:"autoLayout"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
}
if !decoded.Config.AutoLayout {
t.Fatalf("config.autoLayout=false in %q, want true", raw)
}
// Plain string variables must keep going through cardParamMap directly.
if paramMap["content"] != tc.req.Content {
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
}
})
}
}
// ---------------------------------------------------------------------------
// Test: streaming updates only carry content (no layout config)
// ---------------------------------------------------------------------------
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "placeholder",
})
if err != nil {
t.Fatalf("StartCard: %v", err)
}
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
t.Fatalf("UpdateCard: %v", err)
}
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
}
if ts.lastStreamBody["key"] != "content" {
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -30,4 +30,4 @@ func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
}
+86 -16
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"
)
@@ -157,9 +158,22 @@ type HandlerOptions struct {
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
// CardImageMedia rewrites our own published image URLs in card bodies into
// DingTalk media ids; nil keeps external URLs as-is.
CardImageMedia CardImageMediaRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
// OutboundRewriteTimeout bounds the outbound image rewrite stage (fetch +
// publish). Answers carrying many large screenshots need more than the
// delivery timeout, otherwise the remaining images are dropped. Zero keeps
// finalDeliveryTimeout.
OutboundRewriteTimeout time.Duration
// WideTableGrouping selects how over-wide answer tables are adapted, per
// frame: "" or "on" groups on every frame (historical), "off" never groups,
// "stream" groups only while the answer is still streaming and keeps the
// real table on the final frame. See tableGroupingForMode.
WideTableGrouping string
}
type Handler struct {
@@ -171,12 +185,22 @@ type Handler struct {
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
cardImageMedia CardImageMediaRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
// outboundRewriteTimeout applies to the image rewrite stage only; zero
// falls back to finalDeliveryTimeout.
outboundRewriteTimeout time.Duration
// groupTablesPartial / groupTablesFinal decide per frame whether an
// over-wide table becomes a grouped list. Grouped lists survive the
// streaming frames where a table is still incomplete; the final frame can
// keep the real table, which the PC wide-screen card lays out itself.
groupTablesPartial bool
groupTablesFinal bool
}
// NewHandler accepts at most one image downloader. A nil/omitted downloader
@@ -208,6 +232,8 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
if reply == nil {
return nil, errors.New("dingtalk handler: markdown sender is required")
}
groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping)
return &Handler{
cfg: cfg,
qa: qa,
@@ -217,12 +243,17 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
cardImageMedia: opts.CardImageMedia,
settingsRouter: opts.SettingsRouter,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
groupTablesPartial: groupPartial,
groupTablesFinal: groupFinal,
}, nil
}
@@ -440,7 +471,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
ConversationType: req.ConversationType,
ExternalUserID: req.ExternalUserID,
ExternalChatID: req.ExternalChatID,
Content: content,
Content: h.rewriteCardImages(ctx, content, true),
}
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
@@ -490,14 +521,16 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
visible := feed.OnAnswer(fullContent)
if strings.TrimSpace(visible) == "" {
return
}
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
fullContent = decorateReasoningPartial(visible, req.ReasoningExecution)
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
partialContent := h.sanitizeOutboundPartial(ctx, fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
@@ -551,17 +584,26 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
return streamingCard.UpdateCard(updateCtx, outTrackID, content, false, false)
})
answerStarted := false
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
if event.ResponseType == "answer" {
visible := feed.OnAnswer(event.FullAnswer)
if strings.TrimSpace(visible) == "" {
return
}
visibleAnswer := decorateReasoningPartial(visible, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(ctx, visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
switch event.ResponseType {
case "tool_call", "tool_result", "thinking", "reflection":
// A reasoning step ended: the next narrative segment starts a new
// line so the card shows the reasoning process line by line.
feed.StepBoundary()
}
if feed.Started() {
return
}
if content := renderProgressEvent(event); content != "" {
@@ -602,7 +644,13 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
// Final card convergence must still be attempted when the QA context was
// canceled or timed out; otherwise the card can remain "processing".
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, content, true, isError)
cardContent := content
if !isError {
// Card bodies render DingTalk media ids only, so the images are
// converted here while the markdown fallback keeps the external URLs.
cardContent = h.rewriteCardImages(finalCtx, content, true)
}
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, cardContent, true, isError)
cancelFinal()
if updateErr == nil {
if !isError {
@@ -628,24 +676,46 @@ 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 normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
}
timeout := h.outboundRewriteTimeout
if timeout <= 0 {
timeout = h.finalDeliveryTimeout
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
return normalizeMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)), options)
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
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)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial}
content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false)
}
// rewriteCardImages converts image references in card content into DingTalk
// media ids. It is a no-op when the feature is not configured and never fails
// the caller: unuploadable images keep their original external reference.
func (h *Handler) rewriteCardImages(ctx context.Context, content string, wait bool) string {
if h.cardImageMedia == nil || strings.TrimSpace(content) == "" {
return content
}
rewritten := h.cardImageMedia.RewriteForCard(ctx, content, wait)
if strings.TrimSpace(rewritten) == "" {
return content
}
return rewritten
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
@@ -77,4 +77,4 @@ func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
}
@@ -0,0 +1,40 @@
package dingtalk
import (
"context"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
type diagFetcher struct{}
func (diagFetcher) Fetch(ctx context.Context, ref string) ([]byte, string, error) {
return []byte("PNGDATA"), "image/png", nil
}
type diagPublisher struct{}
func (diagPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
return "https://weknora-api-test.dobot.cn/dingtalk/i/a87f73b8-15cb-546e-ba53-bf93dade916f", nil
}
// The outbound rewrite must publish a resource image and keep it in the card
// body. A reference whose handle contains "_x" (…ZM_pltQ) used to be mangled by
// the math normalizer, which made the image unpublishable and the sanitizer
// drop the whole line.
func TestRewriteOutboundContentKeepsPublishedImage(t *testing.T) {
content := "![CR10A 末端负载曲线](resource://efVTXx0gY9Lk2vuZM_pltQ)\n\n公式:$r = \\sqrt{X^2 + Y^2}$\n"
h := &Handler{
outboundRewriter: outboundimage.NewRewriter(diagFetcher{}, diagPublisher{}, 50),
groupTablesFinal: true,
}
out := h.rewriteOutboundContent(context.Background(), content)
if !strings.Contains(out, "![CR10A 末端负载曲线](https://weknora-api-test.dobot.cn/dingtalk/i/") {
t.Fatalf("published image missing from card content: %q", out)
}
if !strings.Contains(out, "r = √(X² + Y²)") {
t.Fatalf("formula not converted: %q", out)
}
}
@@ -1,6 +1,7 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
@@ -19,3 +20,38 @@ func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
t.Fatalf("got=%q", got)
}
}
func TestReasoningStreamingShowsStepsLineByLineThenDropsThemForBody(t *testing.T) {
execution := string(reasoningmode.ExecutionReasoning)
first := "我先确认一下设备的型号和手册。"
second := "手册里有关节扭矩表,让我核对一下。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
feed := reasoningmode.NewPartialFeed(execution)
if got, want := decorateReasoningPartial(feed.OnAnswer(first), execution), "【智能推理模式】\n\n"+first; got != want {
t.Fatalf("step1 got=%q want=%q", got, want)
}
feed.StepBoundary()
if got, want := decorateReasoningPartial(feed.OnAnswer(first+second), execution), "【智能推理模式】\n\n"+first+"\n\n"+second; got != want {
t.Fatalf("step2 got=%q want=%q", got, want)
}
answer := first + second + "\n\n" + body
if got, want := decorateReasoningPartial(feed.OnAnswer(answer), execution), "【智能推理模式】\n\n"+body; got != want {
t.Fatalf("refresh got=%q want=%q", got, want)
}
got := feed.OnAnswer(answer + "\n| J1 | 98 N·m |")
if strings.Contains(got, first) || !strings.Contains(got, "J1") {
t.Fatalf("reasoning must stay hidden while the body streams, got=%q", got)
}
}
func TestQuickStreamingKeepsAccumulatedAnswer(t *testing.T) {
feed := reasoningmode.NewPartialFeed(string(reasoningmode.ExecutionQuick))
if got := feed.OnAnswer("快速答案"); got != "快速答案" {
t.Fatalf("got=%q", got)
}
feed.StepBoundary()
if got := feed.OnAnswer("快速答案追加"); got != "快速答案追加" {
t.Fatalf("got=%q", got)
}
}
+41 -4
View File
@@ -10,6 +10,37 @@ import (
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// tableNormalizeOptions controls how a final answer is adapted to the card
// renderer. The zero value keeps the historical behaviour.
type tableNormalizeOptions struct {
// groupWideTables rewrites tables containing over-wide cells into grouped
// lists (bold row heading + "- header:value" lines). Disable it when the
// client lays out wide tables itself, so the answer keeps its real table.
groupWideTables bool
}
func defaultTableNormalizeOptions() tableNormalizeOptions {
return tableNormalizeOptions{groupWideTables: true}
}
// tableGroupingForMode maps the configured mode to the two frames the card
// renders. An unknown or empty mode keeps the historical behaviour (group on
// every frame), so an unset environment never changes rendering.
//
// ""/"on" group on every frame
// "off" never group: real tables everywhere
// "stream" group while streaming, real table on the final frame
func tableGroupingForMode(mode string) (partial, final bool) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "off", "0", "false", "no":
return false, false
case "stream", "streaming", "partial":
return true, false
default:
return true, true
}
}
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
// cards without changing the knowledge itself. It (1) replaces <br> inside
// table cells with a full-width separator, and (2) rewrites any table that
@@ -17,6 +48,10 @@ const maxTableCellVisualWidth = 32
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
return normalizeMarkdownTables(content, defaultTableNormalizeOptions())
}
func normalizeMarkdownTables(content string, options tableNormalizeOptions) string {
if !strings.Contains(content, "|") {
return content
}
@@ -36,7 +71,7 @@ func normalizeWideMarkdownTables(content string) string {
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1])...)
out = append(out, normalizeTableBlock(lines[i:end+1], options)...)
i = end + 1
continue
}
@@ -46,7 +81,7 @@ func normalizeWideMarkdownTables(content string) string {
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine) []string {
func normalizeTableBlock(rows []markdownStreamLine, options tableNormalizeOptions) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
@@ -63,9 +98,11 @@ func normalizeTableBlock(rows []markdownStreamLine) []string {
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth {
if maxWidth <= maxTableCellVisualWidth || !options.groupWideTables {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells.
// <br>, which the card renderer mishandles inside cells. The same path
// serves over-wide tables when grouping is disabled: the table stays a
// table and only <br> is replaced.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
@@ -1,6 +1,7 @@
package dingtalk
import (
"context"
"strings"
"testing"
)
@@ -56,6 +57,100 @@ func TestNormalizeSkipsFencedTables(t *testing.T) {
}
}
func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
options := defaultTableNormalizeOptions()
options.groupWideTables = false
got := normalizeMarkdownTables(content, options)
if !strings.Contains(got, "| 对比项 | CR 系列 | CRA 系列 |") || !strings.Contains(got, "| **关节最大速度** |") {
t.Fatalf("wide table must stay a table when grouping is disabled, got %q", got)
}
if strings.Contains(got, "**关节最大速度**\n- CR 系列:") {
t.Fatalf("grouped list must not be produced when grouping is disabled, got %q", got)
}
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must still be replaced inside cells, got %q", got)
}
if !strings.Contains(got, ";") {
t.Fatalf("cell line breaks must become the full-width separator, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
}
func TestTableGroupingForMode(t *testing.T) {
cases := []struct {
mode string
wantPartial bool
wantFinal bool
}{
{mode: "", wantPartial: true, wantFinal: true},
{mode: "on", wantPartial: true, wantFinal: true},
{mode: "weird", wantPartial: true, wantFinal: true},
{mode: "off", wantPartial: false, wantFinal: false},
{mode: "NO", wantPartial: false, wantFinal: false},
{mode: "stream", wantPartial: true, wantFinal: false},
{mode: " Stream ", wantPartial: true, wantFinal: false},
}
for _, tc := range cases {
partial, final := tableGroupingForMode(tc.mode)
if partial != tc.wantPartial || final != tc.wantFinal {
t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal)
}
}
}
// TestWideTableGroupingPerFrame proves the switch reaches both frames the card
// renders: the streaming frames and the final one. The streaming path drops
// unstable table blocks, so with grouping off a partial frame simply carries no
// table at all.
func TestWideTableGroupingPerFrame(t *testing.T) {
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:"
cases := []struct {
name string
mode string
partialWant [2]bool // [grouped, table]
finalWant [2]bool // [grouped, table]
}{
{name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}},
{name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}},
{name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
&fakeQA{},
&fakeReply{},
HandlerOptions{WideTableGrouping: tc.mode},
)
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
frames := map[string]struct {
got string
want [2]bool
}{
"partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant},
"final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant},
}
for name, frame := range frames {
grouped := strings.Contains(frame.got, groupMarker)
table := strings.Contains(frame.got, tableMarker)
if grouped != frame.want[0] || table != frame.want[1] {
t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q",
name, grouped, table, frame.want[0], frame.want[1], frame.got)
}
}
})
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
+13
View File
@@ -49,6 +49,19 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
return nil
}
// Exists reports whether an object is already stored under key, so identical
// content is never uploaded twice.
func (s *AliyunOSSStore) Exists(ctx context.Context, key string) (bool, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" {
return false, errors.New("outbound image: invalid OSS exists request")
}
exists, err := s.client.IsObjectExist(ctx, s.bucket, key)
if err != nil {
return false, errors.New("outbound image: OSS exists check failed")
}
return exists, nil
}
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
return nil, "", errors.New("outbound image: invalid OSS get request")
+137
View File
@@ -0,0 +1,137 @@
package outboundimage
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
"strings"
)
const (
// compressMaxEdge bounds the longest side of a published screenshot. Manual
// screenshots are captured at 2x-3x, so capping the longest side keeps text
// readable while cutting the upload size by an order of magnitude.
compressMaxEdge = 1600
// compressJPEGQuality is the re-encode quality for compressed screenshots.
compressJPEGQuality = 82
// compressMinBytes skips images that are already small: re-encoding them
// saves little upload time and could only lose detail.
compressMinBytes = 128 << 10
// compressMaxPixels refuses to decode huge images so one oversized
// screenshot cannot exhaust the container's memory.
compressMaxPixels = 24 << 20
)
// CompressImage re-encodes an outbound screenshot as a smaller JPEG. It returns
// the payload to publish together with its content type, and reports whether
// compression applied. Images that are undecodable, already small, animated, or
// not smaller after re-encoding are returned untouched, so publishing always
// has a usable payload.
func CompressImage(data []byte, contentType string) ([]byte, string, bool) {
if !compressibleImageType(contentType) || len(data) < compressMinBytes {
return data, contentType, false
}
decoded, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, contentType, false
}
bounds := decoded.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx()*bounds.Dy() > compressMaxPixels {
return data, contentType, false
}
encoded := encodeCompressedImage(downscaleRGBA(flattenToRGBA(decoded), compressMaxEdge))
if encoded == nil || len(encoded) >= len(data) {
return data, contentType, false
}
return encoded, "image/jpeg", true
}
// compressibleImageType reports whether the content type is a still raster
// format this package can decode and re-encode. GIF and WebP are left alone to
// preserve animation and format fidelity.
func compressibleImageType(contentType string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/png", "image/jpeg", "image/jpg":
return true
default:
return false
}
}
func encodeCompressedImage(source image.Image) []byte {
var encoded bytes.Buffer
if err := jpeg.Encode(&encoded, source, &jpeg.Options{Quality: compressJPEGQuality}); err != nil {
return nil
}
return encoded.Bytes()
}
// flattenToRGBA copies an image onto an opaque white canvas. JPEG has no alpha
// channel, so transparent regions must be composited before encoding or they
// would be rendered black.
func flattenToRGBA(source image.Image) *image.RGBA {
bounds := source.Bounds()
target := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(target, target.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(target, target.Bounds(), source, bounds.Min, draw.Over)
return target
}
// downscaleRGBA shrinks an image so neither side exceeds maxEdge, averaging the
// source pixels that fall into each destination pixel (box filter) so
// screenshot text stays legible.
func downscaleRGBA(source *image.RGBA, maxEdge int) *image.RGBA {
width, height := source.Rect.Dx(), source.Rect.Dy()
if width <= maxEdge && height <= maxEdge {
return source
}
targetWidth, targetHeight := width, height
if width >= height {
targetWidth = maxEdge
targetHeight = height * maxEdge / width
} else {
targetHeight = maxEdge
targetWidth = width * maxEdge / height
}
if targetWidth < 1 {
targetWidth = 1
}
if targetHeight < 1 {
targetHeight = 1
}
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
startY := y * height / targetHeight
endY := (y + 1) * height / targetHeight
if endY <= startY {
endY = startY + 1
}
for x := 0; x < targetWidth; x++ {
startX := x * width / targetWidth
endX := (x + 1) * width / targetWidth
if endX <= startX {
endX = startX + 1
}
var red, green, blue, count uint32
for row := startY; row < endY; row++ {
line := source.Pix[row*source.Stride:]
for column := startX; column < endX; column++ {
offset := column * 4
red += uint32(line[offset])
green += uint32(line[offset+1])
blue += uint32(line[offset+2])
count++
}
}
offset := y*target.Stride + x*4
target.Pix[offset] = uint8(red / count)
target.Pix[offset+1] = uint8(green / count)
target.Pix[offset+2] = uint8(blue / count)
target.Pix[offset+3] = 0xff
}
}
return target
}
+188
View File
@@ -0,0 +1,188 @@
package outboundimage
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
)
func pngFixture(t *testing.T, width, height int, alpha bool) []byte {
t.Helper()
source := image.NewRGBA(image.Rect(0, 0, width, height))
seed := uint32(12345)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// A pseudo-random pattern keeps the PNG as large as a real manual
// screenshot, so the size thresholds are exercised for real.
seed = seed*1664525 + 1013904223
alphaValue := uint8(0xff)
if alpha && x < width/2 {
alphaValue = 0x00
}
source.SetRGBA(x, y, color.RGBA{
R: uint8(seed >> 24),
G: uint8(seed >> 16),
B: uint8(seed >> 8),
A: alphaValue,
})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, source); err != nil {
t.Fatal(err)
}
return encoded.Bytes()
}
func TestCompressImageDownscalesLargeScreenshotToSmallerJPEG(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
data, contentType, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a large screenshot to be compressed")
}
if contentType != "image/jpeg" {
t.Fatalf("contentType=%q", contentType)
}
if len(data) >= len(original) {
t.Fatalf("compressed size=%d original=%d", len(data), len(original))
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("compressed payload is not a JPEG: %v", err)
}
if decoded.Bounds().Dx() != compressMaxEdge || decoded.Bounds().Dy() != compressMaxEdge*1800/3200 {
t.Fatalf("bounds=%v", decoded.Bounds())
}
}
func TestCompressImageFlattensTransparencyInsteadOfBlack(t *testing.T) {
original := pngFixture(t, 2000, 2000, true)
data, _, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a transparent screenshot to be compressed")
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
red, green, blue, _ := decoded.At(10, 10).RGBA()
if red < 0xf000 || green < 0xf000 || blue < 0xf000 {
t.Fatalf("transparent area rendered as %v instead of white", []uint32{red, green, blue})
}
}
func TestCompressImageLeavesSmallOrUndecodablePayloadsUntouched(t *testing.T) {
small := pngFixture(t, 64, 64, false)
if len(small) >= compressMinBytes {
t.Fatalf("fixture is not small: %d", len(small))
}
for _, test := range []struct {
name string
data []byte
contentType string
}{
{name: "small", data: small, contentType: "image/png"},
{name: "not an image", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/png"},
{name: "gif stays animated", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/gif"},
{name: "text", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "text/plain"},
} {
t.Run(test.name, func(t *testing.T) {
data, contentType, applied := CompressImage(test.data, test.contentType)
if applied || contentType != test.contentType || !bytes.Equal(data, test.data) {
t.Fatalf("payload changed: applied=%v contentType=%q bytes=%d", applied, contentType, len(data))
}
})
}
}
type capturePublisher struct {
data []byte
contentType string
calls int
}
func (p *capturePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.calls++
p.data = append([]byte(nil), data...)
p.contentType = contentType
return "https://img.example/1.png", nil
}
type pngFetcher struct {
data []byte
}
func (f *pngFetcher) Fetch(_ context.Context, _ string) ([]byte, string, error) {
return f.data, "image/png", nil
}
func TestRewritePublishesCompressedScreenshots(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
reference := "resource://AbCdEfGhIjKlMnOpQrStUv"
input := "步骤一\n![图1](" + reference + ")\n"
publisher := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, publisher, 5).Rewrite(context.Background(), input)
if publisher.calls != 1 || publisher.contentType != "image/jpeg" {
t.Fatalf("calls=%d contentType=%q", publisher.calls, publisher.contentType)
}
if len(publisher.data) >= len(original) {
t.Fatalf("published bytes=%d original=%d", len(publisher.data), len(original))
}
disabled := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, disabled, 5, WithImageCompression(false)).Rewrite(context.Background(), input)
if disabled.contentType != "image/png" || !bytes.Equal(disabled.data, original) {
t.Fatalf("compression opt-out changed the payload: contentType=%q bytes=%d", disabled.contentType, len(disabled.data))
}
}
func TestStableHashPublisherReusesExistingObject(t *testing.T) {
store := &existingObjectStore{}
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
if err != nil {
t.Fatal(err)
}
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if store.puts != 1 {
t.Fatalf("puts=%d", store.puts)
}
store.exists = true
second, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("url1=%q url2=%q", first, second)
}
if store.puts != 1 {
t.Fatalf("existing object was uploaded again: puts=%d", store.puts)
}
if !strings.HasPrefix(second, "https://img.example/i/") {
t.Fatalf("url=%q", second)
}
}
type existingObjectStore struct {
exists bool
puts int
}
func (s *existingObjectStore) Put(_ context.Context, _ string, _ []byte, _ string) error {
s.puts++
return nil
}
func (s *existingObjectStore) Exists(_ context.Context, _ string) (bool, error) {
return s.exists, nil
}
+14
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log"
"net/url"
"path"
"regexp"
@@ -18,6 +19,13 @@ type ObjectWriter interface {
Put(ctx context.Context, key string, data []byte, contentType string) error
}
// ObjectStater reports whether an object already exists at key. Stores that
// implement it let the publisher skip re-uploading identical content, which is
// what makes a repeated question instant: object keys are content hashes.
type ObjectStater interface {
Exists(ctx context.Context, key string) (bool, error)
}
type StableHashPublisher struct {
store ObjectWriter
prefix string
@@ -52,6 +60,12 @@ func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentT
idHex := hex.EncodeToString(idBytes)
id := fmt.Sprintf("%s-%s-%s-%s-%s", idHex[:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:])
key := path.Join(p.prefix, id)
if stater, ok := p.store.(ObjectStater); ok {
if exists, err := stater.Exists(ctx, key); err == nil && exists {
log.Printf("outbound image: existing object reused key=%s bytes=%d contentType=%s", key, len(data), contentType)
return p.publicBaseURL + "/" + id, nil
}
}
if err := p.store.Put(ctx, key, data, contentType); err != nil {
return "", errors.New("outbound image: publish object failed")
}
+140 -23
View File
@@ -2,11 +2,15 @@ package outboundimage
import (
"context"
"log"
"net"
"net/url"
"path"
"regexp"
"sort"
"strings"
"sync"
"time"
)
var (
@@ -38,41 +42,56 @@ type Rewriter struct {
fetcher Fetcher
publisher Publisher
maxImages int
compress bool
}
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter {
return &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages}
// RewriterOption customises how one answer's images are prepared for delivery.
type RewriterOption func(*Rewriter)
// WithImageCompression switches the pre-publish JPEG re-encode. It is enabled by
// default because multi-megabyte screenshots dominate the delivery budget.
func WithImageCompression(enabled bool) RewriterOption {
return func(r *Rewriter) { r.compress = enabled }
}
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int, options ...RewriterOption) *Rewriter {
rewriter := &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages, compress: true}
for _, option := range options {
if option != nil {
option(rewriter)
}
}
return rewriter
}
// rewriteConcurrency bounds how many images are fetched and published in
// parallel. Manual screenshots are multi-megabyte objects whose upload time
// dominates the delivery budget, so a serial pass cannot publish a long answer
// before the delivery deadline and silently loses the remaining images.
const rewriteConcurrency = 4
func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
if r == nil || r.fetcher == nil || r.publisher == nil || r.maxImages <= 0 {
markdown = normalizePublicHTMLImages(stripResourceImages(markdown))
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
urls := map[string]string{}
attempted := 0
started := time.Now()
urls := r.resolveReferences(ctx, markdown)
resolve := func(reference string) string {
if publishedURL, ok := urls[reference]; ok {
return publishedURL
return urls[reference]
}
if len(urls) > 0 {
published, dropped := 0, 0
for _, publishedURL := range urls {
if publishedURL == "" {
dropped++
continue
}
published++
}
if attempted >= r.maxImages {
urls[reference] = ""
return ""
}
attempted++
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
urls[reference] = ""
return ""
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
urls[reference] = ""
return ""
}
urls[reference] = publishedURL
return publishedURL
log.Printf("outbound image: rewrite finished references=%d published=%d dropped=%d elapsed=%s",
len(urls), published, dropped, time.Since(started).Round(time.Millisecond))
}
markdown = resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
@@ -94,6 +113,104 @@ func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
return sanitizePublicMarkdownImages(markdown)
}
// resolveReferences returns reference -> public URL for every image reference
// in content, in document order and deduplicated, capped at maxImages. Each
// image is fetched and published with at most rewriteConcurrency workers; a
// reference that cannot be published maps to "".
func (r *Rewriter) resolveReferences(ctx context.Context, content string) map[string]string {
references := r.collectReferences(content)
if len(references) == 0 {
return map[string]string{}
}
results := make(map[string]string, len(references))
var mutex sync.Mutex
var budgetOnce sync.Once
slots := make(chan struct{}, rewriteConcurrency)
var wait sync.WaitGroup
for _, reference := range references {
wait.Add(1)
go func(reference string) {
defer wait.Done()
slots <- struct{}{}
defer func() { <-slots }()
publishedURL := ""
if err := ctx.Err(); err != nil {
budgetOnce.Do(func() {
log.Printf("outbound image: delivery budget exhausted, dropping remaining images: %v", err)
})
} else {
publishedURL = r.publishReference(ctx, reference)
}
mutex.Lock()
results[reference] = publishedURL
mutex.Unlock()
}(reference)
}
wait.Wait()
return results
}
// collectReferences gathers the resource references that carry a real image,
// in document order, dropping duplicates and anything beyond maxImages. Bare or
// unquoted references are never fetched.
func (r *Rewriter) collectReferences(content string) []string {
type hit struct {
at int
reference string
}
hits := make([]hit, 0, 16)
for _, pattern := range []*regexp.Regexp{resourceMarkdownImageRE, resourceHTMLDoubleImageRE, resourceHTMLSingleImageRE} {
for _, match := range pattern.FindAllStringSubmatchIndex(content, -1) {
if len(match) < 4 || match[2] < 0 || match[3] > len(content) {
continue
}
hits = append(hits, hit{at: match[0], reference: content[match[2]:match[3]]})
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].at < hits[j].at })
references := make([]string, 0, len(hits))
seen := make(map[string]bool, len(hits))
for _, candidate := range hits {
if seen[candidate.reference] {
continue
}
seen[candidate.reference] = true
if len(references) >= r.maxImages {
break
}
references = append(references, candidate.reference)
}
return references
}
// publishReference fetches one image from WeKnora and publishes it to our own
// object storage. Failures are logged with their reason instead of being
// silently dropped from the answer.
func (r *Rewriter) publishReference(ctx context.Context, reference string) string {
started := time.Now()
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
log.Printf("outbound image: fetch failed, image dropped reference=%s error=%v", reference, err)
return ""
}
if r.compress {
if compressed, compressedType, applied := CompressImage(data, contentType); applied {
log.Printf("outbound image: compressed reference=%s bytes=%d contentType=%s compressed_bytes=%d compressed_content_type=%s",
reference, len(data), contentType, len(compressed), compressedType)
data, contentType = compressed, compressedType
}
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
log.Printf("outbound image: publish failed, image dropped reference=%s bytes=%d contentType=%s error=%v",
reference, len(data), contentType, err)
return ""
}
log.Printf("outbound image: published reference=%s bytes=%d contentType=%s elapsed=%s",
reference, len(data), contentType, time.Since(started).Round(time.Millisecond))
return publishedURL
}
func sanitizePublicMarkdownImages(markdown string) string {
markdown = markdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := markdownImageRE.FindStringSubmatch(image)
+47
View File
@@ -4,15 +4,19 @@ import (
"context"
"errors"
"strings"
"sync"
"testing"
)
type fakeFetcher struct {
mutex sync.Mutex
calls map[string]int
fail map[string]bool
}
func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if f.calls == nil {
f.calls = map[string]int{}
}
@@ -24,10 +28,13 @@ func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, erro
}
type fakePublisher struct {
mutex sync.Mutex
calls int
}
func (p *fakePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.calls++
if contentType != "image/png" || len(data) == 0 {
return "", errors.New("bad image")
@@ -375,3 +382,43 @@ func TestDocumentImageDowngradePreservesImageAndLinkContracts(t *testing.T) {
t.Fatalf("unsafe document image became a link: %s", got)
}
}
func TestRewriteDropsImagesWhenDeliveryBudgetIsAlreadyExhausted(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 10)
ctx, cancel := context.WithCancel(context.Background())
cancel()
input := "正文\n![一](resource://FirstRefAbCdEfGhIjKlMnO)\n![二](resource://SecondRefAbCdEfGhIjKlMn)"
got := r.Rewrite(ctx, input)
if strings.Contains(got, "resource://") {
t.Fatalf("exhausted budget leaked a resource reference: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("text was lost: %s", got)
}
if len(fetcher.calls) != 0 || publisher.calls != 0 {
t.Fatalf("no image may be fetched or published without budget: fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestRewritePublishesEveryImageWithinTheLimit(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 12)
input := "正文"
for i := 0; i < 12; i++ {
input += "\n![图](resource://Ref" + string(rune('A'+i)) + "bCdEfGhIjKlMnO)"
}
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") {
t.Fatalf("resource reference remained: %s", got)
}
if publisher.calls != 12 || len(fetcher.calls) != 12 {
t.Fatalf("calls fetch=%d publish=%d, want 12", len(fetcher.calls), publisher.calls)
}
}
+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]+?)\$\$`)
mathSpanRE = regexp.MustCompile(`(?s)\$\$.+?\$\$|\\\[.+?\\\]|\\\(.+?\\\)|\$[^$\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", `\%`: "%", `\&`: "&", `\#`: "#", `\_`: "_", `\$`: "$",
`\{`: "{", `\}`: "}", `\,`: " ", `\;`: " ", `\!`: "",
`\(`: "", `\)`: "", `\[`: "", `\]`: "",
}
+128
View File
@@ -0,0 +1,128 @@
package outboundtext
import "strings"
func NormalizeMath(markdown string) string {
if !carriesMath(markdown) {
return markdown
}
content := mathSpanRE.ReplaceAllStringFunc(markdown, convertMathSpan)
// Outside math spans only LaTeX commands are converted. Identifiers and URLs
// (resource://…_pltQ) must never be rewritten: a mangled reference can no
// longer be published, and the outbound sanitizer then drops the whole image.
content = commandRE.ReplaceAllStringFunc(content, replaceCommand)
content = blankRunRE.ReplaceAllString(content, " ")
return content
}
func convertMathSpan(span string) string {
inner := span
switch {
case strings.HasPrefix(inner, "$$"):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$$"), "$$")
return "\n" + convertMath(inner) + "\n"
case strings.HasPrefix(inner, `\[`):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\[`), `\]`)
case strings.HasPrefix(inner, `\(`):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\(`), `\)`)
default:
inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$"), "$")
}
return convertMath(inner)
}
func convertMath(inner string) string {
content := inner
for from, to := range escapedPunctuation {
content = strings.ReplaceAll(content, from, to)
}
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")
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': "ₓ",
}
+36
View File
@@ -0,0 +1,36 @@
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 元, 无公式`},
{"resource url untouched", `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 $r = \sqrt{X^2 + Y^2}$`, `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 r = √(X² + Y²)`},
{"code span untouched", "`x_1` 与 $x_1$", "`x_1` 与 x₁"},
}
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)
}
}
+153
View File
@@ -0,0 +1,153 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
func reasoningAnswerFixture() (string, string, string) {
narration := "我需要先确认这台设备的型号和手册。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |\n| --- | --- |\n| J1 | 98 N·m |"
return narration, body, narration + "\n\n" + body
}
func reasoningEventClient(answer string) *fakeEventStreamingWeKnora {
return &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "我需要先确认这台设备的型号和手册。"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request-reasoning", Answer: answer},
}
}
func TestAskEventsReasoningKeepsOnlyAnswerBody(t *testing.T) {
_, body, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
client := reasoningEventClient(answer)
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-reasoning")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != body {
t.Fatalf("delivered answer must keep the body only, got %q (streamCalls=%d)", result.Answer, client.streamCalls)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:message-reasoning"]
repo.mu.Unlock()
if stored.Answer != body {
t.Fatalf("stored answer must keep the body only, got %q", stored.Answer)
}
}
func TestAskEventsQuickKeepsFullAnswer(t *testing.T) {
_, _, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
service, err := New(repo, fixedResolver{}, reasoningEventClient(answer), session.GroupShared)
if err != nil {
t.Fatal(err)
}
result, err := service.AskEvents(context.Background(), streamQARequest("message-quick"), func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != answer {
t.Fatalf("quick answer must be untouched, got %q", result.Answer)
}
}
// Production shape (turn efae317e, 给我CRA5的负载图): the agent narrates its
// steps, then publishes the answer — lead-in, image and all — as its own answer
// run. Cutting at the first structural line threw the lead-in and the image
// away; the run boundary keeps them.
func cra5Fixture() (string, string, string) {
narration := "用户想要 CRA5(CR5A)的末端负载曲线图。让我先在知识库中搜索相关的产品手册和 FAQ。"
body := "我已经找到了 CRA5(CR5A)的末端负载曲线图,并确认了负载曲线的读法说明。下面给出完整回答。\n\n" +
"CRA5(即 CR5A)的末端负载曲线如下:\n\n" +
"![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)\n\n" +
"---\n\n" +
"## 一、负载曲线怎么读\n\n" +
"负载曲线反映的是:末端负载能力与负载质心偏心距离成负相关。"
return narration, body, narration + body
}
func TestAskEventsReasoningKeepsLeadInAndImageFromAnswerRun(t *testing.T) {
narration, body, answer := cra5Fixture()
// The heuristic on its own drops the lead-in and the image; the observation
// must win over it.
if heuristic := reasoningmode.TrimReasoningNarration(answer); heuristic == body {
t.Fatal("fixture no longer exposes the heuristic defect")
}
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: narration, AnswerRunID: "step-1-answer", AnswerRunStart: 0},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "thinking"},
{ResponseType: "answer", FullAnswer: answer, Done: true, AnswerRunID: "final-answer", AnswerRunStart: len(narration)},
},
streamResult: weknora.ChatResult{RequestID: "request-cra5", Answer: answer},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-cra5")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != body {
t.Fatalf("delivered answer must keep the answer body (lead-in + image), got %q", result.Answer)
}
}
func TestAskEventsReasoningFallsBackWhenFinalRunInterrupted(t *testing.T) {
_, _, answer := reasoningAnswerFixture()
narration := "我需要先确认这台设备的型号和手册。"
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: narration, AnswerRunID: "step-1-answer", AnswerRunStart: 0},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
// The last answer run keeps running across another tool call, so the
// stream cannot prove where the body starts: fall back to the heuristic.
{ResponseType: "answer", FullAnswer: answer, AnswerRunID: "step-2-answer", AnswerRunStart: len(narration)},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "answer", FullAnswer: answer, Done: true, AnswerRunID: "step-2-answer", AnswerRunStart: len(narration)},
},
streamResult: weknora.ChatResult{RequestID: "request-interrupted", Answer: answer},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-interrupted")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if want := reasoningmode.TrimReasoningNarration(answer); result.Answer != want {
t.Fatalf("interrupted run must fall back to the heuristic: want %q, got %q", want, result.Answer)
}
}
+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]) + "…"
}
+33
View File
@@ -248,10 +248,33 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
// observing knowledge_search tool-call events. The flag feeds coverage
// classification (image turns) and is persisted with the turn.
var knowledgeSearched bool
// The stream itself separates the agent's step narration from the final
// answer body: every step publishes its answer chunks under its own answer
// run id (data.event_id), and the last run that no tool/thinking event
// interrupts is the final body. Track that structure so the delivered
// answer keeps the body's lead-in — and any image reference inside it —
// instead of guessing the boundary from the text.
var narrationObservation reasoningmode.NarrationObservation
lastAnswerRunID := ""
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
if isKnowledgeTool(e.ToolName) {
knowledgeSearched = true
}
switch e.ResponseType {
case "answer":
if e.AnswerRunID != "" {
narrationObservation.Observed = true
if e.AnswerRunID != lastAnswerRunID {
lastAnswerRunID = e.AnswerRunID
narrationObservation.AnswerRuns++
narrationObservation.LastRunStart = e.AnswerRunStart
narrationObservation.LastRunClean = true
}
}
case "thinking", "tool_call", "tool_result":
narrationObservation.ToolEvents++
narrationObservation.LastRunClean = false
}
if eventObserver != nil {
eventObserver(e)
}
@@ -328,6 +351,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
turn.WeKnoraRequestID = chat.RequestID
turn.KnowledgeSearched = knowledgeSearched
turn.AnswerMessageID = newID()
// A reasoning run reports every agent step's narrative through WeKnora's
// answer channel and appends the final answer body to that same
// accumulation. Keep the body only in the stored/delivered answer: the
// narration stays visible while the card streams (line by line) and is
// dropped once the final body starts.
if reasoningmode.IsReasoningExecution(request.ReasoningExecution) {
if trimmed := reasoningmode.TrimReasoningNarrationObserved(chat.Answer, narrationObservation); trimmed != chat.Answer {
turn.Answer, chat.Answer = trimmed, trimmed
}
}
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
}
@@ -0,0 +1,90 @@
package reasoningmode
import "strings"
// NarrationObservation carries what the WeKnora event stream knows about the
// boundary between an agent step's narration and the final answer body.
//
// The text-only heuristic (BodyStartOffset) cannot tell a step narrative from
// an answer lead-in such as "我已经找到了 CRA5 的末端负载曲线图…下面给出完整回答。":
// both are plain prose, so the heuristic starts the body at the first
// structural line and silently drops everything before it — including answer
// prose, image references and table rows that sit in that lead-in.
//
// The stream knows the boundary: every agent step writes its narrative to the
// answer channel as its own answer run (WeKnora's data.event_id), and the
// final answer body is the last run that no tool/thinking event interrupts.
type NarrationObservation struct {
// Observed reports that the stream carried answer run information at all
// (WeKnora tags answer chunks with data.event_id). Without it the text
// heuristic stays in charge, so older deployments keep their behaviour.
Observed bool
// AnswerRuns counts distinct answer runs (consecutive chunks sharing one
// event id count as one run).
AnswerRuns int
// ToolEvents counts thinking / tool_call / tool_result events.
ToolEvents int
// LastRunStart is the byte offset in the accumulated answer where the
// most recent answer run started.
LastRunStart int
// LastRunClean reports that no tool/thinking event arrived after that run
// started, which makes the run the final answer body rather than another
// narrative step.
LastRunClean bool
}
// BodyStartOffset returns the observed body start and whether the observation
// is usable. An answer with no tool event carries no narration at all, so the
// whole answer is the body.
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
}
if o.LastRunClean && o.LastRunStart > 0 && o.LastRunStart < contentLength {
return o.LastRunStart, true
}
return 0, false
}
// SplitReasoningAnswerObserved returns the final answer body using the stream
// observation when it is usable and the text heuristic otherwise. ok is false
// when the content carries no body boundary (nothing must be hidden).
func SplitReasoningAnswerObserved(content string, observation NarrationObservation) (string, bool) {
if offset, ok := observation.BodyStartOffset(len(content)); ok {
if offset <= 0 {
// The stream showed no narration at all: the whole answer is body.
return content, true
}
// An image reference in front of the boundary is answer content (the
// agent published a picture in its lead-in), never step narration, so
// the safe answer is to keep everything.
if strings.Contains(content[:offset], "![") {
return content, true
}
body := strings.TrimLeft(content[offset:], "\r\n")
if strings.TrimSpace(body) != "" {
return body, true
}
}
return SplitReasoningAnswer(content)
}
// TrimReasoningNarrationObserved drops the step narration using the stream
// observation, falling back to the text heuristic when the stream carried no
// usable boundary. Unlike the heuristic it never drops the answer lead-in or
// an image reference that belongs to the body.
func TrimReasoningNarrationObserved(content string, observation NarrationObservation) string {
body, ok := SplitReasoningAnswerObserved(content, observation)
if !ok {
return content
}
return body
}
@@ -0,0 +1,127 @@
package reasoningmode
import (
"strings"
"testing"
)
// The production case this observation fixes: the agent narrates its steps,
// then the final answer opens with a lead-in ("我已经找到了…下面给出完整回答。"),
// carries the requested load-curve image, and only then reaches its first
// heading. The text heuristic started the body at the heading and dropped the
// lead-in plus the image reference with it.
const craLoadCurveAnswer = "我需要先确认 CRA5 与 CR5A 是同一型号,然后检索负载曲线。\n" +
"知识库检索命中 3 篇文档,其中用户手册含末端负载曲线图。\n" +
"我已经找到了 CRA5(CR5A)的末端负载曲线图,并确认了负载曲线的读法说明。下面给出完整回答。\n\n" +
"CRA5(即 CR5A)的末端负载曲线如下:\n\n" +
"![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)\n\n" +
"---\n\n" +
"## 一、负载曲线怎么读\n\n" +
"负载曲线反映的是:末端负载能力与负载质心偏心距离成负相关。\n"
func TestNarrationObservationKeepsAnswerLeadInAndImage(t *testing.T) {
narration := strings.Index(craLoadCurveAnswer, "我已经找到了")
observation := NarrationObservation{
Observed: true, AnswerRuns: 3, ToolEvents: 5,
LastRunStart: narration, LastRunClean: true,
}
body := TrimReasoningNarrationObserved(craLoadCurveAnswer, observation)
if !strings.HasPrefix(body, "我已经找到了") {
t.Fatalf("body must start at the answer lead-in, got %q", firstLine(body))
}
if !strings.Contains(body, "![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)") {
t.Fatalf("body must keep the image reference, got %q", firstLine(body))
}
if strings.Contains(body, "我需要先确认") {
t.Fatalf("body must not contain the step narration, got %q", firstLine(body))
}
// The text-only heuristic is what dropped the lead-in and the image.
heuristic := TrimReasoningNarration(craLoadCurveAnswer)
if !strings.HasPrefix(heuristic, "## 一、负载曲线怎么读") {
t.Fatalf("heuristic is expected to start at the heading, got %q", firstLine(heuristic))
}
if strings.Contains(heuristic, "resource://") {
t.Fatalf("heuristic is expected to drop the image reference")
}
}
func TestNarrationObservationFallsBackWithoutUsableBoundary(t *testing.T) {
narration := strings.Index(craLoadCurveAnswer, "我已经找到了")
tests := []struct {
name string
observation NarrationObservation
}{
{"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}},
{"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 {
t.Run(tc.name, func(t *testing.T) {
got := TrimReasoningNarrationObserved(craLoadCurveAnswer, tc.observation)
if want := TrimReasoningNarration(craLoadCurveAnswer); got != want {
t.Fatalf("expected heuristic fallback %q, got %q", firstLine(want), firstLine(got))
}
})
}
}
func TestNarrationObservationKeepsImageLeadIn(t *testing.T) {
// The reported production shape: the lead-in carries the answer prose plus
// the picture, and the boundary observed by the stream only lands after it.
content := "根据知识库中《末端负载曲线-CRA系列》文档,以下是 **CR5A 的末端负载曲线图**:\n\n" +
"![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)\n\n" +
"### 如何查看该负载曲线\n负载能力与偏心距离成负相关。\n"
offset := strings.Index(content, "### 如何查看该负载曲线")
observation := NarrationObservation{Observed: true, AnswerRuns: 4, ToolEvents: 6, LastRunStart: offset, LastRunClean: true}
if got := TrimReasoningNarrationObserved(content, observation); got != content {
t.Fatalf("image lead-in must survive the trim, got %q", got)
}
}
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}
body, ok := SplitReasoningAnswerObserved(answer, observation)
if !ok {
t.Fatalf("a run without tool steps has no narration, so the whole answer is the body")
}
if body != answer {
t.Fatalf("expected the whole answer, got %q", body)
}
}
func TestNarrationObservationTrimsBlankLineAtRunStart(t *testing.T) {
content := "叙述步骤。\n\n\n## 标题\n\n正文。\n"
observation := NarrationObservation{Observed: true, AnswerRuns: 2, ToolEvents: 1,
LastRunStart: strings.Index(content, "\n\n\n##"), LastRunClean: true}
body := TrimReasoningNarrationObserved(content, observation)
if body != "## 标题\n\n正文。\n" {
t.Fatalf("expected the blank lines before the run content to be trimmed, got %q", body)
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+116
View File
@@ -0,0 +1,116 @@
package reasoningmode
import (
"regexp"
"strings"
)
// Reasoning agent runs report every intermediate step through WeKnora's
// response_type=answer channel: the step narratives are concatenated without
// any separator and the final answer body is appended to that same
// accumulation. A card update therefore shows one run-on "narration + body"
// blob. These helpers locate the boundary so callers can hide the narration
// and keep the body only.
//
// Calibration against 118 production answers of the built-in smart-reasoning
// agent: 106 had a structural first body line (97 heading, 4 bold-only line,
// 3 table row, 2 blockquote) and every one of them was directly preceded by a
// blank line; the remaining 12 answers were greetings without narration. The
// hidden narration was a short lead-in (median 111 chars, max 526 chars).
var (
answerHeadingLineRE = regexp.MustCompile(`^#{1,6}[\t ]+\S`)
answerBoldLineRE = regexp.MustCompile(`^\*\*[^*\n]+\*\*[\t ]*$`)
answerTableRowRE = regexp.MustCompile(`^\|.*\|[\t ]*$`)
answerQuoteLineRE = regexp.MustCompile(`^>[\t ]*\S`)
answerFenceLineRE = regexp.MustCompile("^(`{3,}|~{3,})")
)
// IsReasoningExecution reports whether execution runs the multi-step reasoning
// agent whose answer stream mixes step narration with the final answer body.
func IsReasoningExecution(execution string) bool {
switch ExecutionMode(strings.TrimSpace(execution)) {
case ExecutionReasoning, ExecutionReasoningSingle:
return true
}
return false
}
// SplitReasoningAnswer returns the final answer body. ok is false when the
// content carries no body boundary yet (nothing must be hidden in that case).
func SplitReasoningAnswer(content string) (string, bool) {
offset, ok := BodyStartOffset(content)
if !ok {
return content, false
}
return content[offset:], true
}
// TrimReasoningNarration drops the step narration that precedes the final
// answer body. Content without a body boundary is returned unchanged.
func TrimReasoningNarration(content string) string {
body, ok := SplitReasoningAnswer(content)
if !ok {
return content
}
return body
}
// BodyStartOffset returns the byte offset where the final answer body starts.
// 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 != "" &&
(fence == "" || strings.HasPrefix(trimmed, fence)) {
if fence == "" {
fence = marker
} else {
fence = ""
}
seenContent = true
previousBlank = false
offset += len(raw) + 1
continue
}
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
}
return 0, false
}
func isAnswerBodyLine(trimmed string) bool {
if trimmed == "" {
return false
}
return answerHeadingLineRE.MatchString(trimmed) ||
answerBoldLineRE.MatchString(trimmed) ||
answerTableRowRE.MatchString(trimmed) ||
answerQuoteLineRE.MatchString(trimmed)
}
+116
View File
@@ -0,0 +1,116 @@
package reasoningmode
import (
"strings"
"testing"
)
func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) {
leadIn := "根据检索到的资料,关于 CR20V 各关节电机最大旋转扭矩的说明如下:"
heading := "## CR20V 关节电机最大旋转扭矩"
tableBody := "**Magician 传感器套件(Magician Sensor Kit)重量如下:**\n\n| 项目 | 重量 |\n| --- | --- |\n| 整机 | 1.2 kg |"
rowBody := "| 关节 | 扭矩 |\n| --- | --- |"
quoteBody := "> 提示:以下参数来自产品手册。"
tests := []struct {
name string
answer string
want string
ok bool
}{
{
name: "narration then heading",
answer: "我已经收集了充分的信息。现在我可以明确回答用户的问题了。\n\n关键发现:\n1. 手册给出额定扭矩\n\n需要向用户说明\n\n" + heading + "\n\n各关节参数如下。",
want: heading + "\n\n各关节参数如下。",
ok: true,
},
{
name: "lead-in then bold body line",
answer: "我已经获取到完整且明确的信息,可以给出准确答案了。\n\n" + tableBody,
want: tableBody,
ok: true,
},
{
name: "short lead-in then table",
answer: leadIn + "\n\n" + rowBody,
want: rowBody,
ok: true,
},
{
name: "narration then blockquote",
answer: "我需要先确认使用场景。\n\n" + quoteBody,
want: quoteBody,
ok: true,
},
{
name: "greeting has no body boundary",
answer: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
want: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
ok: false,
},
{
name: "answer opens with its body: nothing is hidden",
answer: heading + "\n\n各关节参数如下。",
want: heading + "\n\n各关节参数如下。",
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",
answer: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
want: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
ok: false,
},
{
name: "structural line glued to narration is not a boundary",
answer: "需要向用户说明" + heading,
want: "需要向用户说明" + heading,
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, ok := SplitReasoningAnswer(tt.answer)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
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)
}
})
}
}
func TestIsReasoningExecution(t *testing.T) {
tests := map[string]bool{
"reasoning": true,
" reasoning ": true,
"reasoning_single": true,
"quick": false,
"": false,
"something": false,
}
for execution, want := range tests {
if got := IsReasoningExecution(execution); got != want {
t.Fatalf("IsReasoningExecution(%q) = %v, want %v", execution, got, want)
}
}
}
@@ -0,0 +1,108 @@
package reasoningmode
import (
"bufio"
"encoding/json"
"os"
"testing"
)
// TestReplayProductionCorpusThroughFeed replays production reasoning answers
// through the streaming feed the card pump actually uses, and checks two
// properties of every frame the card receives:
//
// 1. a frame that already contains a structural body line must start at that
// body (no narration glued in front of it). A frame that shows narration
// above the body is exactly the "the last reasoning sentence stays on the
// card" defect.
// 2. once a frame is body-only, no later frame may go back to showing
// narration.
//
// Enable with REASONING_CORPUS=<path to reasoning_corpus.jsonl>.
func TestReplayProductionCorpusThroughFeed(t *testing.T) {
path := os.Getenv("REASONING_CORPUS")
if path == "" {
t.Skip("set REASONING_CORPUS to replay production answers")
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("open corpus: %v", err)
}
defer file.Close()
chunkSizes := []int{11, 47, 199, 1001}
var rows, frames, bodyFrames int
type violation struct {
id string
size int
marks bool
prefix int
bytes int
}
var violations []violation
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1<<20), 1<<22)
for scanner.Scan() {
var row struct {
ID string `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatalf("decode row: %v", err)
}
rows++
for _, size := range chunkSizes {
for _, marks := range []bool{false, true} {
feed := NewPartialFeed("reasoning")
bodySeen := false
for start := 0; start < len(row.Content); start += size {
end := start + size
if end > len(row.Content) {
end = len(row.Content)
}
frame := feed.OnAnswer(row.Content[:end])
frames++
if firstStructuralLine(frame) == "" {
// no body in this frame yet: narration only, expected.
continue
}
offset, ok := BodyStartOffset(frame)
prefix := -1
if ok {
prefix = len([]rune(frame[:offset]))
}
if ok && prefix == 0 {
bodySeen = true
bodyFrames++
continue
}
// The frame carries a structural line but does not start at
// it: narration is still visible above the body.
violations = append(violations, violation{
id: row.ID,
size: size,
marks: marks,
prefix: prefix,
bytes: len(frame),
})
_ = bodySeen
}
}
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan corpus: %v", err)
}
t.Logf("rows=%d frames=%d body_only_frames=%d narration_above_body_frames=%d", rows, frames, bodyFrames, len(violations))
for i, v := range violations {
if i >= 8 {
t.Logf("... %d more", len(violations)-i)
break
}
t.Logf("violation id=%s chunk=%d step_boundaries=%t body_prefix_runes=%d bytes=%d", v.id, v.size, v.marks, v.prefix, v.bytes)
}
if len(violations) > 0 {
t.Errorf("%d frames showed narration above the answer body", len(violations))
}
}
@@ -0,0 +1,92 @@
package reasoningmode
import (
"bufio"
"encoding/json"
"os"
"strings"
"testing"
)
// TestReplayProductionCorpus replays real production answers of the built-in
// smart-reasoning agent through the body-boundary rule.
// Enable with REASONING_CORPUS=<path to reasoning_corpus.jsonl>.
func TestReplayProductionCorpus(t *testing.T) {
path := os.Getenv("REASONING_CORPUS")
if path == "" {
t.Skip("set REASONING_CORPUS to replay production answers")
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("open corpus: %v", err)
}
defer file.Close()
var total, trimmed int
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1<<20), 1<<22)
for scanner.Scan() {
var row struct {
ID string `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatalf("decode row: %v", err)
}
total++
body, ok := SplitReasoningAnswer(row.Content)
if !ok {
continue
}
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)
}
t.Logf("total=%d narration_trimmed=%d unchanged=%d", total, trimmed, total-trimmed)
}
func shorten(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
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 ""
}
+86
View File
@@ -0,0 +1,86 @@
package reasoningmode
import "strings"
// PartialFeed assembles the streaming card content of one turn.
//
// A reasoning run streams every agent step's narrative through the answer
// channel and appends the final answer body to the same accumulation, so the
// raw text reaches the card as one run-on paragraph. The feed
//
// - joins step segments with a blank line, so the card shows the reasoning
// line by line instead of glued sentences, and
// - switches to the final body only as soon as the body starts. The DingTalk
// card update replaces the card content, so that switch is what the user
// sees as the "refresh": the reasoning block disappears and the body is
// streamed on its own.
//
// Quick executions are a pass-through and keep the previous behaviour.
type PartialFeed struct {
reasoning bool
seen string
display string
stepPending bool
bodyOnly string
bodyStarted bool
started bool
}
// NewPartialFeed builds the feed for one turn's execution mode.
func NewPartialFeed(execution string) *PartialFeed {
return &PartialFeed{reasoning: IsReasoningExecution(execution)}
}
// Started reports whether any answer text has been streamed yet.
func (f *PartialFeed) Started() bool {
return f.started
}
// StepBoundary marks the end of an agent step (tool call or tool result) so
// the next narrative segment starts on a new line.
func (f *PartialFeed) StepBoundary() {
if !f.reasoning {
return
}
f.stepPending = true
}
// OnAnswer records the accumulated answer text reported by the stream and
// returns the content the card must show right now.
func (f *PartialFeed) OnAnswer(fullAnswer string) string {
if !f.reasoning {
if strings.TrimSpace(fullAnswer) != "" {
f.started = true
}
return fullAnswer
}
delta := fullAnswer
switch {
case strings.HasPrefix(fullAnswer, f.seen):
delta = fullAnswer[len(f.seen):]
case fullAnswer != f.seen:
// The stream restarted (new WeKnora session or a retry): treat the
// text as a fresh segment instead of gluing it to the previous one.
f.stepPending = true
}
f.seen = fullAnswer
if delta != "" {
f.started = true
if f.stepPending && strings.TrimSpace(f.display) != "" {
f.display += "\n\n"
}
f.stepPending = false
f.display += delta
}
if body, ok := SplitReasoningAnswer(f.display); ok {
f.bodyStarted = true
f.bodyOnly = body
}
if f.bodyStarted {
return f.bodyOnly
}
return f.display
}
@@ -0,0 +1,87 @@
package reasoningmode
import (
"strings"
"testing"
)
func TestPartialFeedJoinsReasoningSegmentsAndRefreshesToBody(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
// Step 1: first narrative segment streams in.
step1 := "我先查一下知识库。"
if got := feed.OnAnswer(step1); got != step1 {
t.Fatalf("first segment = %q, want %q", got, step1)
}
if !feed.Started() {
t.Fatal("Started must be true after the first answer text")
}
// Step 1 ends with a tool call, step 2 starts with its own narrative.
feed.StepBoundary()
step2 := "找到了相关文档,我确认一下参数。"
got := feed.OnAnswer(step1 + step2)
want := step1 + "\n\n" + step2
if got != want {
t.Fatalf("second segment = %q, want %q (segments must be line separated)", got, want)
}
// Step 2 ends, step 3 carries the final answer body.
feed.StepBoundary()
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
got = feed.OnAnswer(step1 + step2 + body)
if got != body {
t.Fatalf("body-only content = %q, want %q (reasoning must be hidden)", got, body)
}
// The body keeps streaming; the visible content stays body-only.
grown := body + "\n| J1 | 100 N·m |"
if got := feed.OnAnswer(step1 + step2 + grown); got != grown {
t.Fatalf("grown body = %q, want %q", got, grown)
}
if strings.Contains(got, "我先查一下知识库") {
t.Fatalf("reasoning leaked into the refreshed card: %q", got)
}
}
func TestPartialFeedKeepsGluedNarrationReadable(t *testing.T) {
// WeKnora concatenates step narratives without any separator; the feed must
// not let the card show them as one run-on sentence.
feed := NewPartialFeed(string(ExecutionReasoning))
first := "我正在理解问题。"
feed.OnAnswer(first)
feed.StepBoundary()
second := "正在检索知识库。"
if got := feed.OnAnswer(first + second); got != first+"\n\n"+second {
t.Fatalf("glued narration = %q", got)
}
}
func TestPartialFeedStartsANewSegmentWhenTheStreamRestarts(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
feed.OnAnswer("第一段推理。")
got := feed.OnAnswer("重新开始的一段推理。")
if got != "第一段推理。\n\n重新开始的一段推理。" {
t.Fatalf("restart segment = %q", got)
}
}
func TestPartialFeedQuickExecutionIsPassThrough(t *testing.T) {
feed := NewPartialFeed(string(ExecutionQuick))
narrativeAndBody := "我已收集信息。\n\n## 标题\n\n正文"
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
t.Fatalf("quick execution = %q, want the raw stream unchanged", got)
}
feed.StepBoundary()
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
t.Fatalf("quick execution after boundary = %q, want the raw stream unchanged", got)
}
}
func TestPartialFeedWithoutBoundaryKeepsShowingReasoning(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
greeting := "你好!我是越疆智能助手。"
if got := feed.OnAnswer(greeting); got != greeting {
t.Fatalf("greeting = %q, want unchanged", got)
}
}
+46 -6
View File
@@ -34,6 +34,12 @@ type StreamEvent struct {
FullAnswer string
ReferenceCount int
ToolName string
// AnswerRunID / AnswerRunStart describe the answer run the event belongs
// to. WeKnora tags every agent step's answer chunks with its own
// data.event_id, so a change of AnswerRunID marks the start of a new run
// and AnswerRunStart is the accumulated answer offset where it began.
AnswerRunID string
AnswerRunStart int
}
// StreamObserver is called with a safe StreamEvent for each SSE event.
@@ -80,9 +86,8 @@ func NewClient(baseURL, apiKey string, timeout time.Duration) (*Client, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, errors.New("WEKNORA_API_KEY is required")
}
if timeout <= 0 {
timeout = 120 * time.Second
}
// timeout <= 0 keeps Go's default: no client-side total timeout on the HTTP
// client, so long-lived agent SSE streams are never cut by a duration cap.
return &Client{baseURL: parsed.String(), apiKey: apiKey, http: &http.Client{Timeout: timeout}}, nil
}
@@ -227,6 +232,12 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
var result ChatResult
var dataLines []string
terminal := false
// A reasoning agent publishes every step narrative and the final answer
// body through the same answer channel, but each step is its own answer
// run (data.event_id). A change of run id marks a new run start, which is
// what lets callers separate narration from the final body instead of
// guessing from the text.
answerRunID, answerRunStart := "", 0
notify := func(e StreamEvent) {
if eventObserver != nil {
@@ -262,15 +273,20 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
ReferenceCount: len(event.KnowledgeReferences),
})
case "answer":
if runID := extractAnswerRunID(event.Data); runID != "" && runID != answerRunID {
answerRunID, answerRunStart = runID, len(result.Answer)
}
hasContent := strings.TrimSpace(event.Content) != ""
result.Answer += event.Content
if hasContent && answerObserver != nil {
answerObserver(result.Answer)
}
notify(StreamEvent{
ResponseType: "answer",
Done: event.Done,
FullAnswer: result.Answer,
ResponseType: "answer",
Done: event.Done,
FullAnswer: result.Answer,
AnswerRunID: answerRunID,
AnswerRunStart: answerRunStart,
})
if event.Done {
terminal = true
@@ -348,6 +364,30 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
return result, nil
}
// extractAnswerRunID extracts data.event_id, the identifier of the answer run
// an answer chunk belongs to. A reasoning agent gives every step its own id,
// so a change of id marks the start of a new run and, once no tool event
// follows, of the final answer body. Older deployments that do not tag answer
// events yield an empty id, which callers treat as "no observation".
func extractAnswerRunID(data json.RawMessage) string {
if len(data) == 0 {
return ""
}
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
return ""
}
v, ok := m["event_id"]
if !ok {
return ""
}
var s string
if err := json.Unmarshal(v, &s); err != nil {
return ""
}
return strings.TrimSpace(s)
}
// extractToolName extracts the tool_name string from a JSON Data field.
// The Data may be any JSON shape; we look for the "tool_name" key and return
// its string value. Returns empty string if not found or not a string.
+66
View File
@@ -267,3 +267,69 @@ func TestAgentChatEventsErrorTurn(t *testing.T) {
t.Fatalf("expected error done event, got %+v", events[0])
}
}
// TestStreamEventAnswerRuns verifies answer chunks are attributed to the answer
// run that produced them: a reasoning agent publishes every step narrative and
// the final answer body through the same answer channel, each under its own
// data.event_id, so the run start marks where the final body begins.
func TestStreamEventAnswerRuns(t *testing.T) {
stream := "data: {\"response_type\":\"thinking\",\"content\":\"inner monologue\",\"done\":false}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"我先检索知识库。\",\"data\":{\"event_id\":\"step-1-answer\"},\"done\":false}\n\n" +
"data: {\"response_type\":\"tool_call\",\"data\":{\"tool_name\":\"knowledge_search\",\"event_id\":\"step-1-tool\"},\"done\":false}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"event_id\":\"step-1-tool\"},\"done\":false}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"我已经找到了负载曲线图:\",\"data\":{\"event_id\":\"final-answer\"},\"done\":false}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"![图](resource://abc)\",\"data\":{\"event_id\":\"final-answer\"},\"done\":true}\n\n"
var events []StreamEvent
result, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(e StreamEvent) {
events = append(events, e)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := len([]rune("我先检索知识库。")); got == 0 {
t.Fatal("unreachable")
}
narration := len("我先检索知识库。")
if result.Answer != "我先检索知识库。我已经找到了负载曲线图:![图](resource://abc)" {
t.Fatalf("unexpected answer: %q", result.Answer)
}
var answers []StreamEvent
for _, e := range events {
if e.ResponseType == "answer" {
answers = append(answers, e)
}
}
if len(answers) != 3 {
t.Fatalf("expected 3 answer events, got %d: %+v", len(answers), answers)
}
if answers[0].AnswerRunID != "step-1-answer" || answers[0].AnswerRunStart != 0 {
t.Fatalf("narration run: want id 'step-1-answer' start 0, got %+v", answers[0])
}
if answers[1].AnswerRunID != "final-answer" || answers[1].AnswerRunStart != narration {
t.Fatalf("final run must start at %d, got %+v", narration, answers[1])
}
if answers[2].AnswerRunID != "final-answer" || answers[2].AnswerRunStart != narration {
t.Fatalf("chunks of one run must share its start, got %+v", answers[2])
}
}
// TestStreamEventAnswerRunWithoutID verifies streams that carry no run id leave
// the run fields empty, so consumers fall back to the text heuristic.
func TestStreamEventAnswerRunWithoutID(t *testing.T) {
stream := "data: {\"response_type\":\"answer\",\"content\":\"hello \",\"done\":false}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"world\",\"done\":true}\n\n"
var events []StreamEvent
if _, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(e StreamEvent) {
events = append(events, e)
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, e := range events {
if e.AnswerRunID != "" || e.AnswerRunStart != 0 {
t.Fatalf("event[%d] must carry no run info, got %+v", i, e)
}
}
}