Merge remote-tracking branch 'origin/fix/weknora-timeout-nolimit' into ci-trial-gate-legacy
build / build (push) Successful in 7s
build / build (push) Successful in 7s
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑
|
||||
// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在
|
||||
// 并发取图 + 预算可配下的出图结果与耗时。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄")
|
||||
budget := flag.Duration("budget", 180*time.Second, "改写预算")
|
||||
maxImages := flag.Int("max", 50, "图片上限")
|
||||
compress := flag.Bool("compress", true, "上传前压缩截图")
|
||||
passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)")
|
||||
flag.Parse()
|
||||
|
||||
refs := readRefs(*refsFile)
|
||||
if len(refs) == 0 {
|
||||
log.Fatal("probe: no references")
|
||||
}
|
||||
|
||||
fetcher, err := outboundimage.NewWeKnoraFetcher(
|
||||
os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"),
|
||||
&http.Client{Timeout: 30 * time.Second}, 10<<20)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: fetcher: %v", err)
|
||||
}
|
||||
store, err := outboundimage.NewAliyunOSSStore(
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
|
||||
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
if err != nil {
|
||||
log.Fatalf("probe: store: %v", err)
|
||||
}
|
||||
publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")
|
||||
publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: publisher: %v", err)
|
||||
}
|
||||
rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress))
|
||||
|
||||
var answer strings.Builder
|
||||
answer.WriteString("## 探针答案\n")
|
||||
for i, ref := range refs {
|
||||
fmt.Fprintf(&answer, "\n\n", i+1, ref)
|
||||
}
|
||||
|
||||
log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
|
||||
body := answer.String()
|
||||
for pass := 1; pass <= *passes; pass++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *budget)
|
||||
started := time.Now()
|
||||
out := rewriter.Rewrite(ctx, body)
|
||||
elapsed := time.Since(started)
|
||||
cancel()
|
||||
|
||||
published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1)
|
||||
leaked := strings.Count(out, "resource://")
|
||||
fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n",
|
||||
pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out))
|
||||
if len(published) > 0 {
|
||||
fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readRefs(path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatalf("probe: refs file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
seen := map[string]bool{}
|
||||
refs := make([]string, 0, 64)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
ref := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(ref, "resource://") || seen[ref] {
|
||||
continue
|
||||
}
|
||||
seen[ref] = true
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`)
|
||||
|
||||
func firstImageURL(content string) string {
|
||||
if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lastImageURL(content string) string {
|
||||
if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 {
|
||||
return matches[len(matches)-1][1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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("看图:", fixture.imageURL(cardImageTestID))
|
||||
|
||||
got := rewriter.RewriteForCard(context.Background(), content, true)
|
||||
want := fmt.Sprintf("看图:", 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("", 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("", cardImageTestID),
|
||||
fmt.Sprintf("", fixture.imageURL("not-a-stable-id")),
|
||||
fmt.Sprintf("),
|
||||
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("", 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("", 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("", 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("", 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", 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("", 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("", 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("", cardImageTestMediaID),
|
||||
fmt.Sprintf("", cardImageTestID),
|
||||
fmt.Sprintf("", 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", 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := "\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, " {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + " |"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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\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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\n"
|
||||
|
||||
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) + "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", `\%`: "%", `\&`: "&", `\#`: "#", `\_`: "_", `\$`: "$",
|
||||
`\{`: "{", `\}`: "}", `\,`: " ", `\;`: " ", `\!`: "",
|
||||
`\(`: "", `\)`: "", `\[`: "", `\]`: "",
|
||||
}
|
||||
@@ -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': "ₓ",
|
||||
}
|
||||
@@ -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", ` 公式 $r = \sqrt{X^2 + Y^2}$`, ` 公式 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)
|
||||
}
|
||||
}
|
||||
@@ -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" +
|
||||
"\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)
|
||||
}
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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" +
|
||||
"\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, "") {
|
||||
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" +
|
||||
"\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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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\":\"\",\"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 != "我先检索知识库。我已经找到了负载曲线图:" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user