Compare commits

..

1 Commits

Author SHA1 Message Date
姚章浩 0fc73c4152 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Successful in 27s
2026-09-28 18:35:51 +08:00
49 changed files with 537 additions and 2309 deletions
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""提交信息规范校验(CI 侧权威闸门)。
规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。
放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。
"""
import json
import os
import re
import subprocess
import sys
TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
RE = re.compile(r"^(?:" + TYPES + r")(?:\([a-zA-Z0-9._/-]+\))?!?: .{3,}$")
ALLOW = re.compile(r'^(?:Merge |Revert "|fixup! |squash! |amend! )')
HELP = """
\u274c 提交信息不合规,已拦截。
规范 : <类型>(<范围>): <简短描述>
类型 : feat 新功能 | fix 修缺陷 | docs 文档 | style 格式(不改逻辑)
refactor 重构 | perf 性能 | test 测试 | build 构建/依赖
ci CI 配置 | chore 杂务 | revert 回滚
示例 : feat(asset): 新增按歌曲生成应援序列
fix(parser): 修复表格解析越界导致的 500
docs: 补充 OSS 短签名接口说明
要点 : 类型小写、冒号后一个空格、描述 \u22653 字符
标题 \u226472 字符、结尾不加句号
破坏性变更类型后加 !,正文写 BREAKING CHANGE:
详见 : docs/提交规范.md
"""
def collect():
"""优先用 Gitea 事件负载里的本次推送提交,取不到则退回 git log。"""
msgs = []
ev = os.environ.get("GITHUB_EVENT_PATH") or ""
if ev and os.path.exists(ev):
try:
with open(ev, encoding="utf-8") as fh:
d = json.load(fh)
for c in (d.get("commits") or []):
m = (c.get("message") or "").strip()
if m:
msgs.append(m)
except Exception as e:
print(" 事件负载解析失败(%s),回退到 git log" % e)
if not msgs:
out = subprocess.run(["git", "log", "-n", "5", "--format=%B%x00"],
capture_output=True, text=True).stdout
msgs = [x.strip() for x in out.split("\x00") if x.strip()]
return msgs
def subject_of(m):
for line in m.splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
return s
return ""
def judge(subj):
if not subj:
return "空提交信息"
if ALLOW.match(subj):
return ""
if len(subj) > 72:
return "标题 %d 字符 > 72" % len(subj)
if not RE.match(subj):
return "不符合 <类型>(<范围>): <描述>"
if subj.endswith(".") or subj.endswith("\u3002"):
return "标题结尾不要加句号"
return ""
def main():
msgs = collect()
bad = []
print("待校验提交 %d 条:" % len(msgs))
for m in msgs:
subj = subject_of(m)
why = judge(subj)
if why:
bad.append((subj, why))
print(" \u274c [%s] %s" % (why, subj))
else:
print(" \u2705 %s" % subj)
try:
with open("/tmp/ci-gate-status", "w") as fh:
fh.write("failure" if bad else "success")
except Exception:
pass
if bad:
print(HELP)
return 1
print("\u2705 提交信息全部合规")
return 0
if __name__ == "__main__":
sys.exit(main())
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""把 CI 结果上报给主机侧 ci-bridge(bridge 负责落报告页 + 推微信)。"""
import json
import os
import subprocess
import urllib.request
def rd(path, default=""):
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except Exception:
return default
def git(*args):
try:
return subprocess.run(["git"] + list(args), capture_output=True,
text=True).stdout.strip()
except Exception:
return ""
def main():
repo = os.environ.get("GITHUB_REPOSITORY", "unknown/unknown")
sha = rd("/tmp/ci-sha") or git("rev-parse", "HEAD")
branch = os.environ.get("GITHUB_REF_NAME") or \
os.environ.get("GITHUB_REF", "").replace("refs/heads/", "")
gate = rd("/tmp/ci-gate-status", "skipped")
build = rd("/tmp/ci-build-status", "failure")
status = "success" if (gate == "success" and build == "success") else "failure"
log = rd("/tmp/ci-build-tail")
if gate != "success":
log = "提交信息规范校验未通过\n" + log
try:
count = int(rd("/tmp/ci-commits-count", "1") or 1)
except Exception:
count = 1
rep = {
"repo": repo,
"sha": sha,
"branch": branch,
"actor": os.environ.get("GITHUB_ACTOR", ""),
"status": status,
"commit_subject": git("log", "-1", "--format=%s"),
"commit_count": count,
"duration_s": rd("/tmp/ci-duration", "?"),
"preview_url": rd("/tmp/ci-preview-url"),
"log": log[-3500:],
"steps": [
{"name": "提交信息规范校验", "status": gate},
{"name": "编译与自检", "status": build},
],
}
if rep["preview_url"]:
print("\U0001F310 网页预览: %s" % rep["preview_url"])
print("结果: %s | 构建: %s | 提交闸门: %s | 耗时: %ss"
% (status, build, gate, rep["duration_s"]))
base = os.environ.get("CI_BRIDGE_RESOLVED", "").rstrip("/")
if not base:
print("跳过上报:未找到可达的 ci-bridge")
return 0
data = json.dumps(rep, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
base + "/api/ci/report", data=data, method="POST",
headers={"Content-Type": "application/json",
"X-CI-Token": os.environ.get("BRIDGE_TOKEN", "")})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print("\u2705 已上报 ci-bridge:", resp.read().decode()[:300])
except Exception as e:
print("\u274c 上报 ci-bridge 失败:", e)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
name: build
# 提交 -> 本机 Runner 编译 -> 微信通知 + 网页预览
# 实测结论(勿随意改动):
# 1. 不用任何第三方 action(作业容器不联网拉 action,全用镜像自带工具)
# 2. 必须自己 clone —— Runner 不会自动检出代码,工作区初始为空
# 3. act 以 `bash -e -o pipefail` 执行每步 -> 禁止 `cmd | head` 这种会 SIGPIPE 提前退出的管道
# 4. 工具链来自主机只读挂载 /opt/citools(go/uv),缓存写 /opt/citools-cache
# 5. bridge 从容器内走 docker 网关 172.17.0.1:8899(实测可达)
# 6. 校验/上报逻辑放在 .gitea/scripts/ 下的脚本里,YAML 只负责调用(避免 YAML 里嵌深缩进代码)
on:
push:
branches:
- "**"
workflow_dispatch:
env:
BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }}
BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }}
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码
run: |
set -uo pipefail
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HP="${SRV#*://}"
BR="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HP}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BR"
git checkout -q -B "$BR" FETCH_HEAD
git rev-parse HEAD > /tmp/ci-sha
date +%s > /tmp/ci-start
: > /tmp/ci-preview-url
echo skipped > /tmp/ci-build-status
git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BR}"
git log -1 --format=' %h %an %s'
- name: 提交信息规范校验
run: python3 .gitea/scripts/ci_gate.py
- name: 编译与自检
run: |
set +e; set +o pipefail
LOG=/tmp/ci-build.log; : > "$LOG"
run() { echo "-- $* --"; "$@" 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
runin(){ local d="$1"; shift; echo "-- ($d) $* --"; ( cd "$d" && "$@" ) 2>&1 | tee -a "$LOG"; return "${PIPESTATUS[0]}"; }
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod UV_CACHE_DIR=/opt/citools-cache/uv
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod GOPATH=/opt/citools-cache/gopath
mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null
echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)"
fail=0; ran=""
if [ -f go.mod ]; then
ran="$ran go"
run go build ./... || fail=1
run go vet ./... || fail=1
run go test ./... || fail=1
fi
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then
ran="$ran python"
if grep -qE '^check:' Makefile 2>/dev/null; then
run make UV=uv check || fail=1
else
run uv sync || fail=1
run uv run pytest -q || fail=1
fi
fi
WEBPKG=""
for p in package.json web/package.json frontend/package.json ui/package.json; do
if [ -f "$p" ]; then WEBPKG="$p"; break; fi
done
if [ -n "$WEBPKG" ]; then
ran="$ran node"
D="$(dirname "$WEBPKG")"
if [ -f "$D/package-lock.json" ]; then
runin "$D" npm ci || fail=1
else
runin "$D" npm install || fail=1
fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run build || fail=1
fi
fi
if [ -z "$ran" ] && [ -f Makefile ]; then
ran="$ran make"
run make || fail=1
fi
if [ -z "$ran" ]; then
echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译"
fi
if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi
tail -c 4000 "$LOG" > /tmp/ci-build-tail
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 发布网页预览
if: always()
run: |
set +e; set +o pipefail
: > /tmp/ci-preview-url
if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then
echo "构建未成功,跳过预览发布"; exit 0
fi
WEB=""
for d in web/dist dist build out public web/build frontend/dist; do
if [ -f "$d/index.html" ]; then WEB="$d"; break; fi
done
if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi
tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; }
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
SHA="$(cat /tmp/ci-sha)"
echo "发布预览到 $B(产物目录 $WEB)"
curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" -H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' --data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json
cat /tmp/ci-pub.json; echo
URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)"
if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi
- name: 通知微信
if: always()
run: |
set +e; set +o pipefail
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then
curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }
fi
done
echo "使用 ci-bridge: $B"
S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S
echo $((S - T)) > /tmp/ci-duration
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
+15 -25
View File
@@ -185,24 +185,6 @@ func main() {
}()
log.Printf("DingTalk support notification dispatcher started")
}
// Outbound image runtime (stable-image publishing) is built from environment
// variables independently of the Stream ingress, so both the question-table
// answer rewriter and the inbound-image publisher can share it.
outboundImages, err := buildOutboundImageRuntime(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
// Question table dispatcher (optional, fail-closed).
qtCfg, err := readQuestionTableConfig()
if err != nil {
@@ -218,7 +200,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg, answerRewriterAdapter{rewriter: outboundImages.rewriter})
qtDispatcher, err := buildQuestionTableDispatcher(qtRepo, dingCfg.ClientID, dingCfg.ClientSecret, qtCfg)
if err != nil {
log.Fatalf("DingTalk question table dispatcher init failed: %v", err)
}
@@ -250,6 +232,18 @@ func main() {
if err != nil {
log.Fatalf("DingTalk directory resolver init failed: %v", err)
}
outboundRewriter, err := buildOutboundImageRewriter(outboundImageConfig{
WeKnoraBaseURL: os.Getenv("WEKNORA_BASE_URL"),
WeKnoraAPIKey: os.Getenv("WEKNORA_API_KEY"),
Endpoint: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
Region: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
AccessKeyID: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
AccessKeySecret: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
Bucket: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"),
})
if err != nil {
log.Fatalf("DingTalk outbound image init failed: %v", err)
}
feedbackService, err := feedback.NewService(dingCfg.TenantKey, "dingtalk", store)
if err != nil {
log.Fatal(err)
@@ -290,10 +284,9 @@ func main() {
sender,
dingtalk.HandlerOptions{
ImageDownloader: downloader,
ImagePublisher: outboundImages.publisher,
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
OutboundRewriter: outboundRewriter,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
LanguageResolver: settingsRuntime.repository,
},
@@ -341,9 +334,6 @@ func main() {
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
@@ -387,7 +377,7 @@ func outboundOSSConfigured() bool {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
} {
if strings.TrimSpace(os.Getenv(key)) == "" {
return false
+8 -47
View File
@@ -116,21 +116,21 @@ func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
}
}
func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
func TestBuildOutboundImageRewriterConfig(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{})
if err != nil || runtime.rewriter != nil || runtime.handler != nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{})
if err != nil || rewriter != nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
}
})
t.Run("partial configuration rejected", func(t *testing.T) {
_, err := buildOutboundImageRuntime(outboundImageConfig{Bucket: "private-bucket"})
_, err := buildOutboundImageRewriter(outboundImageConfig{Bucket: "private-bucket"})
if err == nil {
t.Fatal("expected configuration error")
}
})
t.Run("complete configuration", func(t *testing.T) {
runtime, err := buildOutboundImageRuntime(outboundImageConfig{
rewriter, err := buildOutboundImageRewriter(outboundImageConfig{
WeKnoraBaseURL: "http://127.0.0.1:8080",
WeKnoraAPIKey: "api-key",
Endpoint: "https://oss-cn-shenzhen.aliyuncs.com",
@@ -138,48 +138,9 @@ func TestBuildOutboundImageRuntimeConfig(t *testing.T) {
AccessKeyID: "access-key-id",
AccessKeySecret: "access-key-secret",
Bucket: "private-bucket",
PublicBaseURL: "https://weknora-api-test.dobot.cn/dingtalk/i",
})
if err != nil || runtime.rewriter == nil || runtime.handler == nil {
t.Fatalf("runtime=%+v err=%v", runtime, err)
if err != nil || rewriter == nil {
t.Fatalf("rewriter=%v err=%v", rewriter, err)
}
})
}
func TestMainWiresStableOutboundImageRuntime(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
`buildOutboundImageRuntime(outboundImageConfig{`,
`PublicBaseURL: os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")`,
`OutboundRewriter: outboundImages.rewriter`,
`rootHandler.Handle(outboundImageRoute, outboundImages.handler)`,
} {
if !strings.Contains(source, fragment) {
t.Errorf("main stable image wiring missing %q", fragment)
}
}
}
func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
for _, key := range []string{
"DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT",
"DOBOTAI_OUTBOUND_IMAGE_OSS_REGION",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID",
"DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET",
"DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET",
} {
t.Setenv(key, "configured")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "")
if outboundOSSConfigured() {
t.Fatal("stable public base URL must be required")
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "https://img.example/dingtalk/i")
if !outboundOSSConfigured() {
t.Fatal("complete stable image configuration should be active")
}
}
+9 -32
View File
@@ -13,8 +13,8 @@ import (
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImageURLTTL = 2 * time.Hour
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
)
type outboundImageConfig struct {
@@ -25,17 +25,10 @@ type outboundImageConfig struct {
AccessKeyID string
AccessKeySecret string
Bucket string
PublicBaseURL string
}
type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket, cfg.PublicBaseURL}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
ossValues := []string{cfg.Endpoint, cfg.Region, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.Bucket}
enabled := false
for _, value := range ossValues {
if strings.TrimSpace(value) != "" {
@@ -44,11 +37,11 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
}
}
if !enabled {
return outboundImageRuntime{}, nil
return nil, nil
}
for _, value := range append([]string{cfg.WeKnoraBaseURL, cfg.WeKnoraAPIKey}, ossValues...) {
if strings.TrimSpace(value) == "" {
return outboundImageRuntime{}, errors.New("outbound image: complete WeKnora, OSS, and public URL configuration is required")
return nil, errors.New("outbound image: complete WeKnora and OSS configuration is required")
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
@@ -60,7 +53,7 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
outboundImageMaxBytes,
)
if err != nil {
return outboundImageRuntime{}, err
return nil, err
}
store, err := outboundimage.NewAliyunOSSStore(
cfg.Endpoint,
@@ -70,24 +63,8 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
cfg.Bucket,
)
if err != nil {
return outboundImageRuntime{}, err
return nil, err
}
publisher, err := outboundimage.NewStableHashPublisher(store, outboundImagePrefix, cfg.PublicBaseURL)
if err != nil {
return outboundImageRuntime{}, err
}
handler, err := outboundimage.NewStableImageHandler(store, outboundImagePrefix, outboundImageRoute, outboundImageMaxBytes)
if err != nil {
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
}, nil
}
func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundContentRewriter, error) {
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
publisher := outboundimage.NewHashPublisher(store, outboundImagePrefix, outboundImageURLTTL)
return outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount), nil
}
+4 -24
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
@@ -50,11 +49,9 @@ func readQuestionTableConfig() (questionTableConfig, error) {
// buildQuestionTableDispatcher creates a validated question-table dispatcher
// when the repo is non-nil and all required credentials are present.
// The optional answer rewriter converts the stored answer into the same
// outbound form used for DingTalk delivery (resource images to stable HTTPS,
// document links, anchor cleanup). No HTTP request occurs at build time.
// It returns (nil, nil) when the question-table feature is disabled.
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig, rewriter questiontable.AnswerRewriter) (*questiontable.Dispatcher, error) {
// No HTTP request occurs at build time. It returns (nil, nil) when the
// question-table feature is disabled (empty config).
func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID, clientSecret string, cfg questionTableConfig) (*questiontable.Dispatcher, error) {
// Disabled: all blank config.
if cfg.BaseID == "" && cfg.SheetID == "" && cfg.OperatorID == "" {
return nil, nil
@@ -76,24 +73,7 @@ func buildQuestionTableDispatcher(repo questiontable.ExportRepository, clientID,
return nil, err
}
return questiontable.NewDispatcher(repo, client, &questiontable.DispatcherOptions{
Rewriter: rewriter,
SendTimeout: 60 * time.Second,
})
}
// answerRewriterAdapter adapts a blocking outbound content rewriter to the
// AnswerRewriter contract (caller never gets an error, so the original answer
// is always written when conversion cannot run).
type answerRewriterAdapter struct {
rewriter dingtalk.OutboundContentRewriter
}
func (a answerRewriterAdapter) Rewrite(ctx context.Context, content string) (string, error) {
if a.rewriter == nil {
return content, nil
}
return a.rewriter.Rewrite(ctx, content), nil
return questiontable.NewDispatcher(repo, client, nil)
}
// runQuestionTablePoller runs the dispatcher immediately, then every interval.
+5 -5
View File
@@ -32,7 +32,7 @@ func TestReadQuestionTableConfigDisabledAllBlank(t *testing.T) {
}
func TestBuildQuestionTableDispatcherDisabled(t *testing.T) {
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{}, nil)
d, err := buildQuestionTableDispatcher(nil, "", "", questionTableConfig{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -87,7 +87,7 @@ func TestReadQuestionTableConfigPartialErrors(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg, nil)
_, err := buildQuestionTableDispatcher(nil, "cid", "secret", cfg)
if err == nil {
t.Fatal("expected error when repo is nil")
}
@@ -99,7 +99,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingRepo(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg, nil)
_, err := buildQuestionTableDispatcher(repo, "", "secret", cfg)
if err == nil {
t.Fatal("expected error when client id is empty")
}
@@ -108,7 +108,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingClientID(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg, nil)
_, err := buildQuestionTableDispatcher(repo, "cid", "", cfg)
if err == nil {
t.Fatal("expected error when secret is empty")
}
@@ -117,7 +117,7 @@ func TestBuildQuestionTableDispatcherEnabledMissingSecret(t *testing.T) {
func TestBuildQuestionTableDispatcherEnabledCompleteConfig(t *testing.T) {
repo := &questionTableExportRepoStub{}
cfg := questionTableConfig{BaseID: "b1", SheetID: "sh1", OperatorID: "op1"}
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg, nil)
d, err := buildQuestionTableDispatcher(repo, "cid", "secret", cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -1,3 +0,0 @@
-- 000016_question_image_urls down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS question_image_urls_json;
@@ -1,6 +0,0 @@
-- 000016_question_image_urls: stable published URLs for inbound question images.
-- Stores the object-storage HTTPS URLs of images a user sent in a question,
-- so the question-table export can render a readable, traceable record without
-- persisting download codes or data URIs.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS question_image_urls_json JSONB NOT NULL DEFAULT '[]'::JSONB;
@@ -1,3 +0,0 @@
-- 000017_knowledge_searched down migration.
ALTER TABLE turns
DROP COLUMN IF EXISTS knowledge_searched;
@@ -1,6 +0,0 @@
-- 000017_knowledge_searched: records whether a turn actually triggered a
-- knowledge-base search (knowledge_search tool call). Coverage classification
-- uses this to distinguish pure visual recognition from a searched-but-missed
-- knowledge question.
ALTER TABLE turns
ADD COLUMN IF NOT EXISTS knowledge_searched BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,11 +0,0 @@
-- 000018_coverage_visual_recognition_reason down migration.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer'
));
@@ -1,14 +0,0 @@
-- 000018_coverage_visual_recognition_reason: extend the coverage_decisions
-- reason_code allowlist with the new image-visual-recognition reason emitted by
-- coverage Evaluator v3 (visual recognition is not a knowledge-coverage miss).
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
-2
View File
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
@@ -207,7 +206,6 @@ func (s *CardSender) UpdateCard(ctx context.Context, outTrackID, content string,
if len(content) > 64*1024 {
return errors.New("dingtalk card: content exceeds 64 KiB")
}
log.Print(formatCardUpdateDiagnostic(content, isFinalize, isError))
token, err := s.accessToken(ctx)
if err != nil {
@@ -1,64 +0,0 @@
package dingtalk
import (
"fmt"
"strings"
)
type cardContentShape struct {
bytes int
lines int
pipeLines int
leadingPipeLines int
delimiterLines int
confirmedTable bool
confirmedTablePos int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
if isTableHeaderCandidate(line.text) {
shape.leadingPipeLines++
}
if !line.inFence && isCompleteTableDelimiter(line.text) {
shape.delimiterLines++
}
if shape.confirmedTable || i == 0 || line.inFence || lines[i-1].inFence {
continue
}
if isTableHeaderCandidate(lines[i-1].text) && isCompleteTableDelimiter(line.text) {
shape.confirmedTable = true
shape.confirmedTablePos = lines[i-1].start
}
}
return shape
}
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",
isFinalize,
isError,
shape.bytes,
shape.lines,
shape.pipeLines,
shape.leadingPipeLines,
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
)
}
@@ -1,93 +0,0 @@
package dingtalk
import (
"bytes"
"context"
"fmt"
"log"
"strings"
"testing"
)
func TestCardSenderUpdateCardLogsOnlySafeContentShape(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("client-id", "client-secret", "template-id", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
previousWriter := log.Writer()
previousFlags := log.Flags()
previousPrefix := log.Prefix()
log.SetOutput(&output)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
log.SetPrefix(previousPrefix)
})
content := "DO_NOT_LOG_BOUNDARY_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
if err := sender.UpdateCard(context.Background(), "track-diagnostic", content, false, false); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "DingTalk card update shape:") || !strings.Contains(got, "confirmed_table=true") || !strings.Contains(got, "finalize=false") {
t.Fatalf("missing safe boundary diagnostic: %q", got)
}
if strings.Contains(got, "DO_NOT_LOG_BOUNDARY_TEXT") || strings.Contains(got, "CR5") || strings.Contains(got, "track-diagnostic") {
t.Fatalf("boundary diagnostic leaked content or card identifier: %q", got)
}
}
func TestFormatCardUpdateDiagnosticReportsStructureWithoutContent(t *testing.T) {
content := "DO_NOT_LOG_ANSWER_TEXT\n\n| 型号 | 负载 |\n| --- | --- |\n| CR5 | 5kg |"
got := formatCardUpdateDiagnostic(content, false, false)
wantFields := []string{
"DingTalk card update shape:",
"finalize=false",
"error=false",
fmt.Sprintf("bytes=%d", len(content)),
"lines=5",
"pipe_lines=3",
"leading_pipe_lines=3",
"delimiter_lines=1",
"confirmed_table=true",
fmt.Sprintf("table_start=%d", strings.Index(content, "| 型号")),
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "DO_NOT_LOG_ANSWER_TEXT") || strings.Contains(got, "CR5") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
func TestFormatCardUpdateDiagnosticDistinguishesFinalPlainText(t *testing.T) {
got := formatCardUpdateDiagnostic("普通回答", true, false)
wantFields := []string{
"finalize=true",
"error=false",
"lines=1",
"pipe_lines=0",
"leading_pipe_lines=0",
"delimiter_lines=0",
"confirmed_table=false",
"table_start=-1",
}
for _, field := range wantFields {
if !strings.Contains(got, field) {
t.Fatalf("diagnostic %q missing field %q", got, field)
}
}
if strings.Contains(got, "普通回答") {
t.Fatalf("diagnostic leaked answer content: %q", got)
}
}
-6
View File
@@ -39,7 +39,6 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
if stopTicks != nil {
defer stopTicks()
}
lastSuccessful := ""
for {
select {
case <-ctx.Done():
@@ -53,14 +52,9 @@ func (p *cardUpdatePump) run(ctx context.Context, ticks <-chan time.Time, stopTi
default:
continue
}
if latest == lastSuccessful {
continue
}
if err := update(ctx, latest); err != nil {
log.Printf("DingTalk async card update failed, continuing")
continue
}
lastSuccessful = latest
}
}
}
@@ -1,31 +0,0 @@
package dingtalk
import (
"regexp"
"strings"
)
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
spaceAfterDisplayColon = regexp.MustCompile(`([::])[\t ]+`)
)
func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
}
@@ -1,66 +0,0 @@
package dingtalk
import "testing"
func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent inline markers",
input: "结论支持该功能[[ref id=\"c2\"]][[ref id=\"c4\"]]。",
want: "结论支持该功能。",
},
{
name: "leading marker group and chinese colon",
input: "[[ref id=\"c2\"]][[ref id=\"c4\"]]:建议先检查配置。",
want: "建议先检查配置。",
},
{
name: "leading marker group and ascii colon on second line",
input: "第一段。\n[[ref id='c12']] : Second paragraph.",
want: "第一段。\nSecond paragraph.",
},
{
name: "normal punctuation remains natural",
input: "标题:[[ref id=\"c1\"]] 详细说明",
want: "标题:详细说明",
},
{
name: "spaces around marker collapse to one",
input: "Feature A [[ref id=\"c3\"]] remains supported.",
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
want: "Protocol: value ; keep spacing\n indented",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeDisplayAnswer(tt.input); got != tt.want {
t.Fatalf("sanitizeDisplayAnswer()=%q, want %q", got, tt.want)
}
})
}
}
func TestSanitizeDisplayAnswerHoldsBackDanglingStreamingReference(t *testing.T) {
for _, input := range []string{
"答案正文[[ref",
"答案正文[[ref id=\"c",
"答案正文[[ref id=\"c2\"",
} {
if got := sanitizeDisplayAnswer(input); got != "答案正文" {
t.Fatalf("sanitizeDisplayAnswer(%q)=%q", input, got)
}
}
}
@@ -1,33 +0,0 @@
package dingtalk
import (
"testing"
)
func TestEffectiveDownloadCodesDeduplicatesAndPreservesOrder(t *testing.T) {
cb := BotCallback{
DownloadCode: "code-first",
DownloadCodes: []string{"code-first", "code-second", " code-third "},
}
codes := effectiveDownloadCodes(cb)
if len(codes) != 3 {
t.Fatalf("codes=%v, want 3", codes)
}
if codes[0] != "code-first" || codes[1] != "code-second" || codes[2] != "code-third" {
t.Fatalf("codes=%v, want ordered de-dup", codes)
}
}
func TestEffectiveDownloadCodesSingleOnly(t *testing.T) {
cb := BotCallback{DownloadCode: " solo "}
codes := effectiveDownloadCodes(cb)
if len(codes) != 1 || codes[0] != "solo" {
t.Fatalf("codes=%v, want [solo]", codes)
}
}
func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
+10 -106
View File
@@ -2,7 +2,6 @@ package dingtalk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -46,7 +45,6 @@ type BotCallback struct {
SessionWebhook string
Text string
DownloadCode string
DownloadCodes []string
}
type QAAsker interface {
@@ -77,12 +75,6 @@ type ImageDownloader interface {
Download(ctx context.Context, downloadCode string) (core.ImageInput, error)
}
// ImagePublisher publishes a downloaded image to object storage and returns a
// stable HTTPS URL. Implementation satisfies outboundimage.Publisher.
type ImagePublisher interface {
Publish(ctx context.Context, data []byte, contentType string) (string, error)
}
type UserDirectory interface {
ResolveUser(ctx context.Context, tenantKey, staffID, fallbackName string) (directory.Profile, error)
}
@@ -91,10 +83,6 @@ type OutboundContentRewriter interface {
Rewrite(ctx context.Context, content string) string
}
type OutboundPartialSanitizer interface {
SanitizePartial(content string) string
}
type SettingsMessage struct {
TenantKey string
Channel string
@@ -131,7 +119,6 @@ type StreamingCardSender interface {
type HandlerOptions struct {
ImageDownloader ImageDownloader
ImagePublisher ImagePublisher
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
@@ -144,7 +131,6 @@ type Handler struct {
qa QAAsker
reply MarkdownSender
downloader ImageDownloader
imagePublisher ImagePublisher
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
@@ -189,7 +175,6 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
qa: qa,
reply: reply,
downloader: opts.ImageDownloader,
imagePublisher: opts.ImagePublisher,
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
@@ -302,8 +287,7 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return errors.New("dingtalk handler: text message content is empty")
}
case msgTypePicture:
codes := effectiveDownloadCodes(cb)
if len(codes) == 0 {
if strings.TrimSpace(cb.DownloadCode) == "" {
log.Printf("DingTalk image preparation failed: stage=missing_download_code")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
@@ -311,25 +295,16 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
log.Printf("DingTalk image preparation failed: stage=downloader_unavailable")
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
images := make([]core.ImageInput, 0, len(codes))
for _, code := range codes {
image, err := h.downloader.Download(ctx, code)
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
if h.imagePublisher != nil {
if url := h.publishQuestionImage(ctx, image); url != "" {
image.PublishedURL = url
}
}
images = append(images, image)
image, err := h.downloader.Download(ctx, strings.TrimSpace(cb.DownloadCode))
if err != nil {
log.Printf("DingTalk image preparation failed: stage=%s", imageFailureStage(err))
return h.sendReply(ctx, webhookURL, imageErrorReply)
}
req.Query = strings.TrimSpace(cb.Text)
if req.Query == "" {
req.Query = imageQuery
}
req.Images = images
req.Images = []core.ImageInput{image}
default:
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
@@ -444,26 +419,19 @@ 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
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
return
}
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
// Advance the throttle window before I/O so a failing DingTalk API
// cannot be hammered once per model chunk. The final update below is
// always attempted regardless of this timestamp.
lastUpdate = now
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, partialContent, false, false); updateErr != nil {
if updateErr := streamingCard.UpdateCard(ctx, outTrackID, fullContent, false, false); updateErr != nil {
log.Printf("DingTalk streaming card update failed, continuing")
return
}
lastPartialContent = partialContent
}
}
@@ -509,9 +477,7 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
pump.Offer(event.FullAnswer)
return
}
if answerStarted {
@@ -572,7 +538,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
return content
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -580,15 +546,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
return h.outboundRewriter.Rewrite(rewriteCtx, content)
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
@@ -713,57 +671,3 @@ func (h *Handler) sendReply(ctx context.Context, webhookURL, text string) error
}
return nil
}
// publishQuestionImage best-effort publishes a downloaded image's bytes to
// object storage for traceability. It returns the stable HTTPS URL, or an
// empty string when publishing is unavailable or fails, so that an image
// question is never blocked by storage.
func (h *Handler) publishQuestionImage(ctx context.Context, image core.ImageInput) string {
if h.imagePublisher == nil {
return ""
}
data, err := decodeImageDataURI(image.DataURI)
if err != nil || len(data) == 0 {
log.Printf("DingTalk question image publish failed: stage=decode")
return ""
}
url, err := h.imagePublisher.Publish(ctx, data, image.MIMEType)
if err != nil || strings.TrimSpace(url) == "" {
log.Printf("DingTalk question image publish failed: stage=publish")
return ""
}
return strings.TrimSpace(url)
}
// decodeImageDataURI strips the "data:<mime>;base64," prefix and decodes the
// payload into raw bytes. It is used only to publish inbound images and never
// writes the data URI to storage.
func decodeImageDataURI(dataURI string) ([]byte, error) {
const prefix = "base64,"
index := strings.Index(dataURI, prefix)
if index < 0 {
return nil, errors.New("dingtalk image: data URI missing base64 payload")
}
return base64.StdEncoding.DecodeString(dataURI[index+len(prefix):])
}
// effectiveDownloadCodes returns the ordered, de-duplicated list of image
// download codes for a picture callback. DownloadCode equals the first code
// and DownloadCodes holds every code (richText with several pictures), so the
// single value is used to canonicalize the order without duplication.
func effectiveDownloadCodes(cb BotCallback) []string {
var codes []string
seen := make(map[string]struct{})
for _, code := range append([]string{cb.DownloadCode}, cb.DownloadCodes...) {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
codes = append(codes, code)
}
return codes
}
+8 -141
View File
@@ -3,7 +3,6 @@ package dingtalk
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
@@ -982,10 +981,9 @@ type cardUpdate struct {
}
type fakeOutboundRewriter struct {
calls int
partialCalls int
from string
to string
calls int
from string
to string
}
func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string {
@@ -993,11 +991,6 @@ func (f *fakeOutboundRewriter) Rewrite(_ context.Context, content string) string
return strings.ReplaceAll(content, f.from, f.to)
}
func (f *fakeOutboundRewriter) SanitizePartial(content string) string {
f.partialCalls++
return strings.ReplaceAll(content, f.from, "")
}
func (f *fakeStreamingCard) SendFinalCard(_ context.Context, req CardRequest) (string, error) {
f.finalCalled = true
f.finalReq = req
@@ -1675,16 +1668,15 @@ func TestRenderProgressEventUsesOnlySafeFixedText(t *testing.T) {
}
func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T) {
rawAnswer := "完整答案[[ref id=\"c8\"]]"
card := &fakeStreamingCard{startID: "track-events"}
qa := &fakeEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: rawAnswer}},
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: "完整答案"}},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "references", ReferenceCount: 3},
{ResponseType: "answer", FullAnswer: "部分[[ref"},
{ResponseType: "answer", FullAnswer: rawAnswer, Done: true},
{ResponseType: "answer", FullAnswer: "部分"},
{ResponseType: "answer", FullAnswer: "完整答案", Done: true},
},
}
reply := &fakeReply{}
@@ -1706,9 +1698,6 @@ func TestHandleEventStreamingFinalizesLatestAnswerWithoutMarkdown(t *testing.T)
if last.content != "完整答案" || !last.isFinalize || last.isError {
t.Fatalf("final update=%+v", last)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
if reply.called {
t.Fatal("SendMarkdown must not be called")
}
@@ -2091,8 +2080,8 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
if len(card.updates) < 2 {
t.Fatalf("updates=%d", len(card.updates))
}
if strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content leaked internal resource: %q", card.updates[0].content)
if !strings.Contains(card.updates[0].content, resource) {
t.Fatalf("streaming intermediate content should remain untouched: %q", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || strings.Contains(final.content, resource) || !strings.Contains(final.content, "https://img.example/signed.jpg") {
@@ -2101,126 +2090,4 @@ func TestHandleStreamingRewritesOnlyFinalOutboundImageContent(t *testing.T) {
if rewriter.calls != 1 {
t.Fatalf("rewrite calls=%d, want 1", rewriter.calls)
}
if rewriter.partialCalls == 0 {
t.Fatal("streaming intermediate content was not sanitized")
}
}
func TestHandleFiltersReferenceMarkersAtDingTalkDisplayBoundary(t *testing.T) {
rawAnswer := "结论[[ref id=\"c2\"]][[ref id=\"c4\"]]。"
wantDisplay := "结论。"
t.Run("final card", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{returnID: "track-reference-filter"}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if card.lastReq.Content != wantDisplay {
t.Fatalf("card content=%q, want %q", card.lastReq.Content, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
t.Run("markdown fallback", func(t *testing.T) {
qa := &fakeQA{result: core.QAResult{Answer: rawAnswer}}
reply := &fakeReply{}
card := &fakeCard{err: errors.New("card unavailable")}
h := newTestHandlerWithCard(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if reply.lastText != wantDisplay {
t.Fatalf("markdown content=%q, want %q", reply.lastText, wantDisplay)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
})
}
func TestHandleStreamingHoldsBackPartialReferenceMarkers(t *testing.T) {
rawAnswer := "答案正文[[ref id=\"c2\"]]继续。"
qa := &fakeStreamingQA{
chunks: []string{"答案正文[[ref", " id=\"c2\"]]继续。"},
result: core.QAResult{Answer: rawAnswer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-reference-filter"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
if len(card.updates) < 2 {
t.Fatalf("updates=%d, want at least one partial plus final", len(card.updates))
}
for i, update := range card.updates {
if strings.Contains(update.content, "[[ref") {
t.Fatalf("update[%d] leaked reference marker: %q", i, update.content)
}
}
if card.updates[0].content != "答案正文" {
t.Fatalf("first partial=%q, want dangling marker held back", card.updates[0].content)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != "答案正文继续。" {
t.Fatalf("final content=%q finalize=%t", final.content, final.isFinalize)
}
if qa.result.Answer != rawAnswer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
func TestHandleStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &fakeStreamingQA{
chunks: []string{
"说明\n\n",
"| 检查项",
" | 操作要求 |\n",
"| --- | --",
"- |\n",
"| 接地检查 | 上电前确",
"认 |\n\n结论",
},
result: core.QAResult{Answer: answer},
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-stream-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
callCount := int64(0)
h.now = func() time.Time {
callCount++
return time.Unix(0, callCount*int64(time.Second))
}
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
if qa.result.Answer != answer {
t.Fatalf("raw QA answer mutated: %q", qa.result.Answer)
}
}
@@ -1,80 +0,0 @@
package dingtalk
import (
"context"
"encoding/base64"
"errors"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
type fakeImagePublisher struct {
published []byte
mime string
url string
err error
calls int
}
func (f *fakeImagePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
f.calls++
f.published = data
f.mime = contentType
return f.url, f.err
}
func TestDecodeImageDataURI(t *testing.T) {
raw := []byte("hello-image-bytes")
uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
decoded, err := decodeImageDataURI(uri)
if err != nil {
t.Fatalf("decodeImageDataURI: %v", err)
}
if string(decoded) != string(raw) {
t.Fatalf("decoded=%q, want %q", decoded, raw)
}
if _, err := decodeImageDataURI("data:image/png;base64"); err == nil {
t.Fatal("expected error for missing base64 payload")
}
if _, err := decodeImageDataURI("no comma at all"); err == nil {
t.Fatal("expected error for non-data URI")
}
}
func TestPublishQuestionImagePublishesDecodedBytes(t *testing.T) {
pub := &fakeImagePublisher{url: "https://weknora-api.example/i/001"}
raw := []byte{0x89, 0x50, 0x4e, 0x47}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw),
MIMEType: "image/png",
}
h := &Handler{imagePublisher: pub}
got := h.publishQuestionImage(context.Background(), image)
if got != "https://weknora-api.example/i/001" {
t.Fatalf("url=%q, want published", got)
}
if pub.calls != 1 || pub.mime != "image/png" || string(pub.published) != string(raw) {
t.Fatalf("publisher calls=%d mime=%q bytes=%q", pub.calls, pub.mime, pub.published)
}
}
func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
h := &Handler{imagePublisher: nil}
if got := h.publishQuestionImage(context.Background(), core.ImageInput{}); got != "" {
t.Fatalf("expected empty URL without publisher, got %q", got)
}
pub := &fakeImagePublisher{err: errors.New("oss down")}
h2 := &Handler{imagePublisher: pub}
image := core.ImageInput{
DataURI: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("x")),
MIMEType: "image/png",
}
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
@@ -1,114 +0,0 @@
package dingtalk
import "strings"
type markdownStreamLine struct {
start int
text string
inFence bool
}
// stableMarkdownPrefix returns the cumulative content that is safe to render
// while Markdown is still streaming. Once a table is confirmed, the table and
// every following token stay hidden until the terminal card update.
func stableMarkdownPrefix(content string) string {
lines := splitMarkdownStreamLines(content)
for i := 0; i < len(lines); i++ {
if lines[i].inFence || !isTableHeaderCandidate(lines[i].text) {
continue
}
if i == len(lines)-1 {
return content[:lines[i].start]
}
if lines[i+1].inFence || !isTableDelimiterCandidate(lines[i+1].text) {
continue
}
if !isCompleteTableDelimiter(lines[i+1].text) {
return content[:lines[i].start]
}
// DingTalk only lays out Markdown tables reliably on the terminal
// isFinalize=true update. Keep the table and every following partial
// token hidden; the authoritative final update bypasses this helper.
return content[:lines[i].start]
}
return content
}
func splitMarkdownStreamLines(content string) []markdownStreamLine {
lines := make([]markdownStreamLine, 0, strings.Count(content, "\n")+1)
fence := ""
for start := 0; start < len(content); {
end := strings.IndexByte(content[start:], '\n')
if end < 0 {
end = len(content)
} else {
end += start + 1
}
text := strings.TrimSuffix(content[start:end], "\n")
text = strings.TrimSuffix(text, "\r")
trimmed := strings.TrimSpace(text)
line := markdownStreamLine{start: start, text: text, inFence: fence != ""}
if marker := markdownFenceMarker(trimmed); marker != "" {
line.inFence = true
if fence == "" {
fence = marker
} else if marker == fence {
fence = ""
}
}
lines = append(lines, line)
start = end
}
return lines
}
func markdownFenceMarker(trimmed string) string {
switch {
case strings.HasPrefix(trimmed, "```"):
return "```"
case strings.HasPrefix(trimmed, "~~~"):
return "~~~"
default:
return ""
}
}
func isTableHeaderCandidate(line string) bool {
return strings.HasPrefix(strings.TrimSpace(line), "|")
}
func isTableDelimiterCandidate(line string) bool {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "|") {
return false
}
for _, r := range trimmed {
switch r {
case '|', ':', '-', ' ', '\t':
default:
return false
}
}
return true
}
func isCompleteTableDelimiter(line string) bool {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
cells := strings.Split(trimmed, "|")
if len(cells) < 2 {
return false
}
for _, cell := range cells {
cell = strings.TrimSpace(cell)
cell = strings.TrimPrefix(cell, ":")
cell = strings.TrimSuffix(cell, ":")
if len(cell) < 3 || strings.Trim(cell, "-") != "" {
return false
}
}
return true
}
@@ -1,65 +0,0 @@
package dingtalk
import (
"context"
"reflect"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
type pacedTableEventQA struct {
fakeStreamingQA
events []weknora.StreamEvent
pause time.Duration
}
func (f *pacedTableEventQA) AskEvents(_ context.Context, req core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
f.lastReq = req
for _, event := range f.events {
if observer != nil {
observer(event)
}
time.Sleep(f.pause)
}
return f.result, f.err
}
func TestHandleEventStreamingPublishesTableOnlyOnFinalUpdate(t *testing.T) {
answer := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n结论"
qa := &pacedTableEventQA{
fakeStreamingQA: fakeStreamingQA{result: core.QAResult{Answer: answer}},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "说明\n\n"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项"},
{ResponseType: "answer", FullAnswer: "说明\n\n| 检查项 | 操作要求 |\n| --- | --"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
pause: 20 * time.Millisecond,
}
reply := &fakeReply{}
card := &fakeStreamingCard{startID: "track-event-table"}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardUpdateInterval = time.Millisecond
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatal(err)
}
var partials []string
for _, update := range card.updates {
if !update.isFinalize {
partials = append(partials, update.content)
}
}
wantPartials := []string{"说明\n\n"}
if !reflect.DeepEqual(partials, wantPartials) {
t.Fatalf("event partial updates=%q, want only pre-table stream %q", partials, wantPartials)
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize || final.content != answer {
t.Fatalf("final=%+v, want unchanged complete answer", final)
}
}
@@ -1,101 +0,0 @@
package dingtalk
import "testing"
func TestStableMarkdownPrefixHoldsCompletedTableUntilFinalization(t *testing.T) {
content := "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n表后正文仍在生成"
want := "说明\n\n"
if got := stableMarkdownPrefix(content); got != want {
t.Fatalf("stableMarkdownPrefix()=%q, want table and trailing stream held as %q", got, want)
}
}
func TestStableMarkdownPrefixHoldsBackTableUntilFinalization(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "split header",
content: "说明\n\n| 检查项",
want: "说明\n\n",
},
{
name: "header waiting for delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n",
want: "说明\n\n",
},
{
name: "partial delimiter",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --",
want: "说明\n\n",
},
{
name: "partial data row",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确",
want: "说明\n\n",
},
{
name: "complete table without terminator",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n",
want: "说明\n\n",
},
{
name: "complete table followed by blank line",
content: "说明\n\n| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n\n后文",
want: "说明\n\n",
},
{
name: "complete table followed by heading",
content: "| 检查项 | 操作要求 |\n| --- | --- |\n| 接地检查 | 上电前确认 |\n## 后续",
want: "",
},
{
name: "second consecutive table remains hidden",
content: "| A | B |\n| --- | --- |\n| 1 | 2 |\n\n| C | D",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.want {
t.Fatalf("stableMarkdownPrefix()=%q, want %q", got, tt.want)
}
})
}
}
func TestStableMarkdownPrefixLeavesNonTablesUnchanged(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "ordinary text",
content: "普通段落继续流式显示。",
},
{
name: "ordinary inline pipe",
content: "状态使用 ready | running | done 表示。",
},
{
name: "pipe inside fenced code",
content: "```text\n| 这不是 | 表格 |\n| --- | --- |\n```",
},
{
name: "completed prose line starting with pipe",
content: "| 这是引用样式文本\n下一段普通文字",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stableMarkdownPrefix(tt.content); got != tt.content {
t.Fatalf("stableMarkdownPrefix()=%q, want unchanged %q", got, tt.content)
}
})
}
}
+16 -17
View File
@@ -86,13 +86,10 @@ func BotCallbackFromSDK(data *chatbot.BotCallbackDataModel) (BotCallback, error)
Text: data.Text.Content,
}
if msgtype == "richtext" {
rtMsgtype, rtText, rtCodes := parseRichText(data.Content)
rtMsgtype, rtText, rtCode := parseRichText(data.Content)
cb.Msgtype = rtMsgtype
cb.Text = rtText
cb.DownloadCodes = rtCodes
if len(rtCodes) > 0 {
cb.DownloadCode = rtCodes[0]
}
cb.DownloadCode = rtCode
return cb, nil
}
if msgtype == "link" {
@@ -181,19 +178,19 @@ func safeHTTPURL(raw string) string {
// 3. Nested envelope: {"content":{"richText":[...]}}
//
// Picture items are identified by a "type" field (case-insensitive trimmed)
// equal to "picture". All non-empty download codes are collected in order;
// text items are joined with newlines.
// equal to "picture". The first non-empty downloadCode is used; subsequent
// picture items are ignored. Text items are joined with newlines.
//
// Returns: (msgtype, text, downloadCodes). Msgtype is "picture" if any picture
// Returns: (msgtype, text, downloadCode). Msgtype is "picture" if any picture
// item exists, "text" if only text items, "richtext" if malformed/empty.
func parseRichText(raw interface{}) (string, string, []string) {
func parseRichText(raw interface{}) (string, string, string) {
items := extractRichTextItems(raw)
if len(items) == 0 {
return "richtext", "", nil
return "richtext", "", ""
}
var textParts []string
var downloadCodes []string
var downloadCode string
hasPicture := false
for _, item := range items {
@@ -201,9 +198,11 @@ func parseRichText(raw interface{}) (string, string, []string) {
if itemType == "picture" {
hasPicture = true
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCodes = append(downloadCodes, code)
if downloadCode == "" {
code := strings.TrimSpace(item.DownloadCode)
if code != "" {
downloadCode = code
}
}
continue
}
@@ -217,12 +216,12 @@ func parseRichText(raw interface{}) (string, string, []string) {
text := strings.Join(textParts, "\n")
if hasPicture {
return "picture", text, downloadCodes
return "picture", text, downloadCode
}
if text != "" {
return "text", text, nil
return "text", text, ""
}
return "richtext", "", nil
return "richtext", "", ""
}
// richTextItem is a single item in a richText array.
+2 -6
View File
@@ -2,7 +2,6 @@ package dingtalk
import (
"context"
"reflect"
"strings"
"sync"
"testing"
@@ -187,7 +186,7 @@ func TestBotCallbackFromSDK(t *testing.T) {
SessionWebhook: "https://oapi.dingtalk.com/robot/sendBySession?session=abc",
Text: " 你好 ",
}
if !reflect.DeepEqual(cb, want) {
if cb != want {
t.Fatalf("BotCallbackFromSDK() = %+v, want %+v", cb, want)
}
})
@@ -451,7 +450,7 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
}
})
t.Run("all picture downloadCodes collected in order", func(t *testing.T) {
t.Run("first non-empty downloadCode used, subsequent ignored", func(t *testing.T) {
t.Parallel()
data := &chatbot.BotCallbackDataModel{
ConversationType: "1",
@@ -469,9 +468,6 @@ func TestBotCallbackFromSDKRichText(t *testing.T) {
if cb.DownloadCode != "code-first" {
t.Fatalf("DownloadCode = %q, want first code-first", cb.DownloadCode)
}
if len(cb.DownloadCodes) != 2 || cb.DownloadCodes[0] != "code-first" || cb.DownloadCodes[1] != "code-second" {
t.Fatalf("DownloadCodes = %+v, want [code-first code-second]", cb.DownloadCodes)
}
})
t.Run("picture item missing downloadCode still picture", func(t *testing.T) {
+6 -10
View File
@@ -17,15 +17,13 @@ var ErrTurnInProgress = errors.New("turn is already processing")
// ImageInput contains the image data for a QA request. The DataURI is a
// full data: URI (e.g. "data:image/png;base64,...") and must never be
// persisted to the audit log. PublishedURL is an optional stable HTTPS URL
// produced after the image is published to object storage for traceability.
// persisted to the audit log.
type ImageInput struct {
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
PublishedURL string
DataURI string
MIMEType string
SizeBytes int64
Width int
Height int
}
// MediaAudit records metadata about media in a turn for audit purposes.
@@ -119,8 +117,6 @@ type Turn struct {
Answer string
References []Reference
Media []MediaAudit
QuestionImageURLs []string
KnowledgeSearched bool
Status string
ErrorMessage string
}
+2 -56
View File
@@ -54,14 +54,8 @@ func (e *Evaluator) Version() string {
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Image turns (image media present) are classified by whether the question
// actually triggered a knowledge-base search:
// - has references → covered_has_references
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns: zero references → uncovered_no_references
// 5. Has references → covered_has_references
// 3. Zero references (nil or empty) → uncovered_no_references
// 4. Has references → covered_has_references
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
@@ -88,44 +82,6 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// Image turns are classified by whether knowledge search occurred.
if hasImageMedia(turn.Media) {
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
if turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredNoReferences,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredVisualRecognition,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
// No references → uncovered.
if len(turn.References) == 0 {
return Decision{
@@ -153,16 +109,6 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// hasImageMedia reports whether the turn carried at least one image input.
func hasImageMedia(media []core.MediaAudit) bool {
for _, m := range media {
if strings.EqualFold(strings.TrimSpace(m.Type), "image") {
return true
}
}
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
-85
View File
@@ -1,85 +0,0 @@
package coverage
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func imageTurn(answer string, refs []core.Reference, searched bool) core.Turn {
return core.Turn{
ID: "img-turn",
Status: "succeeded",
MessageType: "image",
Answer: answer,
References: refs,
Media: []core.MediaAudit{{Type: "image", MIMEType: "image/jpeg"}},
KnowledgeSearched: searched,
}
}
func TestEvaluator_ImageVisualRecognitionNoSearch(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("这张图片展示了一个手写的知识库数据处理与发布流程图。", nil, false)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered (visual recognition is not a coverage miss)", d.Verdict)
}
if d.ReasonCode != ReasonCoveredVisualRecognition {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredVisualRecognition)
}
}
func TestEvaluator_ImageSearchButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据图片中的错误信息,未能在知识库中找到对应的解决方案。", nil, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict=%s, want uncovered (image turn searched KB but got no refs)", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonUncoveredNoReferences)
}
}
func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := imageTurn("根据知识库资料,这个是故障代码E5,请检查...", []core.Reference{{ID: "r1", Score: 0.9}}, true)
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict=%s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code=%s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
}
}
-1
View File
@@ -28,7 +28,6 @@ const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
)
// EvaluatorVersion is the current version of the evaluator.
+8 -23
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"context"
"errors"
"io"
"strings"
"time"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
@@ -49,31 +49,16 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
return 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")
func (s *AliyunOSSStore) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || ttl <= 0 {
return "", errors.New("outbound image: invalid OSS presign request")
}
result, err := s.client.GetObject(ctx, &oss.GetObjectRequest{
result, err := s.client.Presign(ctx, &oss.GetObjectRequest{
Bucket: oss.Ptr(s.bucket),
Key: oss.Ptr(key),
})
}, oss.PresignExpires(ttl))
if err != nil {
return nil, "", errors.New("outbound image: OSS get failed")
return "", errors.New("outbound image: OSS presign failed")
}
defer result.Body.Close()
if result.ContentLength > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
data, err := io.ReadAll(io.LimitReader(result.Body, maxBytes+1))
if err != nil {
return nil, "", errors.New("outbound image: OSS read failed")
}
if int64(len(data)) > maxBytes {
return nil, "", errors.New("outbound image: OSS object exceeds size limit")
}
contentType := ""
if result.ContentType != nil {
contentType = strings.TrimSpace(*result.ContentType)
}
return data, contentType, nil
return result.URL, nil
}
-58
View File
@@ -1,58 +0,0 @@
package outboundimage
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
func TestAliyunOSSStoreGetReturnsBoundedImageBytesAndMIME(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/private-bucket/dobotai/im-images/id" {
t.Fatalf("method=%s path=%s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("PNG!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
data, contentType, err := store.Get(context.Background(), "dobotai/im-images/id", 4)
if err != nil {
t.Fatal(err)
}
if string(data) != "PNG!" || contentType != "image/png" {
t.Fatalf("data=%q contentType=%q", data, contentType)
}
}
func TestAliyunOSSStoreGetRejectsOversizedObjects(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", "5")
_, _ = w.Write([]byte("PNG!!"))
}))
defer server.Close()
config := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test-id", "test-secret", "")).
WithRegion("cn-test").
WithEndpoint(server.URL).
WithUsePathStyle(true)
store := &AliyunOSSStore{client: oss.NewClient(config), bucket: "private-bucket"}
if _, _, err := store.Get(context.Background(), "dobotai/im-images/id", 4); err == nil {
t.Fatal("expected oversized object error")
}
}
+26 -35
View File
@@ -5,57 +5,48 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"time"
)
var stableImageIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
type ObjectWriter interface {
type ObjectStore interface {
Put(ctx context.Context, key string, data []byte, contentType string) error
PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
}
type StableHashPublisher struct {
store ObjectWriter
prefix string
publicBaseURL string
type HashPublisher struct {
store ObjectStore
prefix string
ttl time.Duration
}
func NewStableHashPublisher(store ObjectWriter, prefix, publicBaseURL string) (*StableHashPublisher, error) {
prefix = strings.Trim(strings.TrimSpace(prefix), "/")
publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
parsed, err := url.Parse(publicBaseURL)
if store == nil || prefix == "" || err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("outbound image: invalid stable publisher configuration")
func NewHashPublisher(store ObjectStore, prefix string, ttl time.Duration) *HashPublisher {
return &HashPublisher{store: store, prefix: strings.Trim(strings.TrimSpace(prefix), "/"), ttl: ttl}
}
func (p *HashPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
if p == nil || p.store == nil || len(data) == 0 || p.prefix == "" || p.ttl <= 0 {
return "", errors.New("outbound image: invalid publisher configuration")
}
return &StableHashPublisher{
store: store,
prefix: prefix,
publicBaseURL: publicBaseURL,
}, nil
}
func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
if p == nil || p.store == nil || len(data) == 0 || p.prefix == "" || p.publicBaseURL == "" {
return "", errors.New("outbound image: invalid stable publisher configuration")
}
if imageExtension(contentType) == "" {
extension := imageExtension(contentType)
if extension == "" {
return "", errors.New("outbound image: unsupported image content type")
}
sum := sha256.Sum256(data)
idBytes := sum[:16]
idBytes[6] = (idBytes[6] & 0x0f) | 0x50
idBytes[8] = (idBytes[8] & 0x3f) | 0x80
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)
digest := hex.EncodeToString(sum[:])
key := path.Join(p.prefix, digest[:2], digest+extension)
if err := p.store.Put(ctx, key, data, contentType); err != nil {
return "", errors.New("outbound image: publish object failed")
}
return p.publicBaseURL + "/" + id, nil
signedURL, err := p.store.PresignGet(ctx, key, p.ttl)
if err != nil {
return "", errors.New("outbound image: sign object URL failed")
}
if !strings.HasPrefix(strings.TrimSpace(signedURL), "https://") {
return "", errors.New("outbound image: signed object URL must use HTTPS")
}
return signedURL, nil
}
func imageExtension(contentType string) string {
+15 -50
View File
@@ -4,12 +4,14 @@ import (
"context"
"strings"
"testing"
"time"
)
type fakeObjectStore struct {
key string
data []byte
contentType string
ttl time.Duration
}
func (s *fakeObjectStore) Put(_ context.Context, key string, data []byte, contentType string) error {
@@ -19,16 +21,14 @@ func (s *fakeObjectStore) Put(_ context.Context, key string, data []byte, conten
return nil
}
func TestStableHashPublisherUsesDeterministicUUIDURLWithoutPresigning(t *testing.T) {
func (s *fakeObjectStore) PresignGet(_ context.Context, key string, ttl time.Duration) (string, error) {
s.ttl = ttl
return "https://private-oss.example/" + key + "?signature=short", nil
}
func TestHashPublisherUsesDeterministicTemporaryKeyAndTwoHourHTTPSURL(t *testing.T) {
store := &fakeObjectStore{}
publisher, err := NewStableHashPublisher(
store,
"dobotai/im-images",
"https://weknora-api-test.dobot.cn/dingtalk/i",
)
if err != nil {
t.Fatal(err)
}
publisher := NewHashPublisher(store, "dobotai/im-images", 2*time.Hour)
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
@@ -43,48 +43,13 @@ func TestStableHashPublisherUsesDeterministicUUIDURLWithoutPresigning(t *testing
if first != second || firstKey != store.key {
t.Fatalf("publish is not deterministic: url1=%q url2=%q key1=%q key2=%q", first, second, firstKey, store.key)
}
wantURLPrefix := "https://weknora-api-test.dobot.cn/dingtalk/i/"
if !strings.HasPrefix(first, wantURLPrefix) {
t.Fatalf("url=%q", first)
if !strings.HasPrefix(store.key, "dobotai/im-images/") || !strings.HasSuffix(store.key, ".jpg") {
t.Fatalf("key=%q", store.key)
}
id := strings.TrimPrefix(first, wantURLPrefix)
if !stableImageIDRE.MatchString(id) {
t.Fatalf("stable image id=%q", id)
if strings.Contains(store.key, "same-image") {
t.Fatalf("key leaks content: %q", store.key)
}
if store.key != "dobotai/im-images/"+id {
t.Fatalf("key=%q id=%q", store.key, id)
}
}
func TestStableHashPublisherRejectsUnsafeConfigurationAndNonImages(t *testing.T) {
store := &fakeObjectStore{}
for _, test := range []struct {
name string
store ObjectWriter
prefix string
baseURL string
}{
{name: "nil store", prefix: "dobotai/im-images", baseURL: "https://img.example/i"},
{name: "empty prefix", store: store, baseURL: "https://img.example/i"},
{name: "http base", store: store, prefix: "dobotai/im-images", baseURL: "http://img.example/i"},
{name: "query base", store: store, prefix: "dobotai/im-images", baseURL: "https://img.example/i?token=secret"},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := NewStableHashPublisher(test.store, test.prefix, test.baseURL); err == nil {
t.Fatal("expected configuration error")
}
})
}
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
if err != nil {
t.Fatal(err)
}
if _, err := publisher.Publish(context.Background(), nil, "image/png"); err == nil {
t.Fatal("expected empty image error")
}
if _, err := publisher.Publish(context.Background(), []byte("text"), "text/plain"); err == nil {
t.Fatal("expected non-image error")
if store.ttl != 2*time.Hour || !strings.HasPrefix(first, "https://") {
t.Fatalf("ttl=%s url=%q", store.ttl, first)
}
}
+17 -164
View File
@@ -2,29 +2,11 @@ package outboundimage
import (
"context"
"net"
"net/url"
"path"
"regexp"
"strings"
)
var (
resourceMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\((resource://[0-9A-Za-z_-]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?\)`)
resourceHTMLDoubleImageRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*"(resource://[0-9A-Za-z_-]+)"[^>]*>`)
resourceHTMLSingleImageRE = regexp.MustCompile(`(?i)<img\b[^>]*\bsrc\s*=\s*'(resource://[0-9A-Za-z_-]+)'[^>]*>`)
resourceHTMLUnsafeImageRE = regexp.MustCompile(`(?i)<img\b[^>]*resource://[0-9A-Za-z_-]+[^>]*>`)
markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^\)\r\n]*\)))?\)`)
incompleteMarkdownImageRE = regexp.MustCompile(`!\[[^\]\r\n]*\]\([^\)\r\n]*$`)
htmlImageRE = regexp.MustCompile(`(?i)<img\b[^>]*>`)
incompleteHTMLImageRE = regexp.MustCompile(`(?i)<img\b[^>]*$`)
htmlDoubleSrcRE = regexp.MustCompile(`(?i)\bsrc\s*=\s*"([^"]+)"`)
htmlSingleSrcRE = regexp.MustCompile(`(?i)\bsrc\s*=\s*'([^']+)'`)
htmlDoubleAltRE = regexp.MustCompile(`(?i)\balt\s*=\s*"([^"]*)"`)
htmlSingleAltRE = regexp.MustCompile(`(?i)\balt\s*=\s*'([^']*)'`)
resourceReferenceRE = regexp.MustCompile(`resource://[0-9A-Za-z_-]+`)
dataImageReferenceRE = regexp.MustCompile(`(?i)data:image[^\s]*`)
)
var resourceMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\((resource://[0-9A-Za-z_-]+)\)`)
type Fetcher interface {
Fetch(ctx context.Context, reference string) ([]byte, string, error)
@@ -46,15 +28,21 @@ func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter
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)
return resourceMarkdownImageRE.ReplaceAllString(markdown, "")
}
urls := map[string]string{}
attempted := 0
resolve := func(reference string) string {
if publishedURL, ok := urls[reference]; ok {
return publishedURL
return resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := resourceMarkdownImageRE.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
reference := match[1]
if signedURL, ok := urls[reference]; ok {
if signedURL == "" {
return ""
}
return strings.Replace(image, reference, signedURL, 1)
}
if attempted >= r.maxImages {
urls[reference] = ""
@@ -66,147 +54,12 @@ func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
urls[reference] = ""
return ""
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
signedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(signedURL, "https://") {
urls[reference] = ""
return ""
}
urls[reference] = publishedURL
return publishedURL
}
markdown = resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := resourceMarkdownImageRE.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
reference := match[1]
publishedURL := resolve(reference)
if publishedURL == "" {
return ""
}
return strings.Replace(image, reference, publishedURL, 1)
})
markdown = rewriteHTMLImages(markdown, resourceHTMLDoubleImageRE, resolve)
markdown = rewriteHTMLImages(markdown, resourceHTMLSingleImageRE, resolve)
markdown = normalizePublicHTMLImages(markdown)
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
func sanitizePublicMarkdownImages(markdown string) string {
markdown = markdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := markdownImageRE.FindStringSubmatch(image)
if len(match) != 2 || !isSafePublicImageURL(match[1]) {
return ""
}
if isKnownDocumentURL(match[1]) {
return strings.TrimPrefix(image, "!")
}
return image
})
markdown = incompleteMarkdownImageRE.ReplaceAllString(markdown, "")
markdown = incompleteHTMLImageRE.ReplaceAllString(markdown, "")
return dataImageReferenceRE.ReplaceAllString(markdown, "")
}
func isKnownDocumentURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
switch strings.ToLower(path.Ext(u.Path)) {
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".csv", ".txt", ".zip", ".rar", ".7z":
return true
default:
return false
}
}
func rewriteHTMLImages(content string, pattern *regexp.Regexp, resolve func(string) string) string {
return pattern.ReplaceAllStringFunc(content, func(image string) string {
match := pattern.FindStringSubmatch(image)
if len(match) != 2 {
return ""
}
publishedURL := resolve(match[1])
if publishedURL == "" {
return ""
}
alt := "图片"
for _, pattern := range []*regexp.Regexp{htmlDoubleAltRE, htmlSingleAltRE} {
if altMatch := pattern.FindStringSubmatch(image); len(altMatch) == 2 && strings.TrimSpace(altMatch[1]) != "" {
alt = strings.ReplaceAll(strings.TrimSpace(altMatch[1]), "]", `\]`)
break
}
}
return "![" + alt + "](" + publishedURL + ")"
urls[reference] = signedURL
return strings.Replace(image, reference, signedURL, 1)
})
}
func normalizePublicHTMLImages(content string) string {
return htmlImageRE.ReplaceAllStringFunc(content, func(image string) string {
source := ""
for _, pattern := range []*regexp.Regexp{htmlDoubleSrcRE, htmlSingleSrcRE} {
if match := pattern.FindStringSubmatch(image); len(match) == 2 {
source = strings.TrimSpace(match[1])
break
}
}
if !isSafePublicImageURL(source) {
return ""
}
return "![" + imageAlt(image) + "](" + source + ")"
})
}
func imageAlt(image string) string {
for _, pattern := range []*regexp.Regexp{htmlDoubleAltRE, htmlSingleAltRE} {
if match := pattern.FindStringSubmatch(image); len(match) == 2 && strings.TrimSpace(match[1]) != "" {
return strings.ReplaceAll(strings.TrimSpace(match[1]), "]", `\]`)
}
}
return "图片"
}
func isSafePublicImageURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
return false
}
hostname := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
if hostname == "" || hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || strings.HasSuffix(hostname, ".local") || strings.HasSuffix(hostname, ".internal") {
return false
}
if port := u.Port(); port != "" && port != "443" {
return false
}
if ip := net.ParseIP(hostname); ip != nil {
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
} else if !strings.Contains(hostname, ".") {
return false
}
for key := range u.Query() {
normalized := strings.ToLower(key)
if strings.Contains(normalized, "signature") || normalized == "ossaccesskeyid" || strings.Contains(normalized, "credential") {
return false
}
}
return true
}
func stripResourceImages(content string) string {
content = resourceMarkdownImageRE.ReplaceAllString(content, "")
content = resourceHTMLDoubleImageRE.ReplaceAllString(content, "")
content = resourceHTMLSingleImageRE.ReplaceAllString(content, "")
return resourceHTMLUnsafeImageRE.ReplaceAllString(content, "")
}
func (r *Rewriter) SanitizePartial(content string) string {
content = resourceReferenceRE.ReplaceAllString(stripResourceImages(content), "")
content = normalizePublicHTMLImages(content)
return sanitizePublicMarkdownImages(content)
}
-299
View File
@@ -76,302 +76,3 @@ func TestRewriteMarkdownResourceImagesDropsFailuresAndImagesBeyondLimit(t *testi
t.Fatalf("publisher calls=%d, want 2", publisher.calls)
}
}
func TestRewriteHTMLResourceImagesAsDingTalkMarkdown(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 5)
input := "前文\n<img alt=\"步骤图\" width=\"100\" src=\"" + resource + "\"/>\n" +
"重复\n<img src='" + resource + "' class='diagram'>\n后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("internal HTML image remained: %s", got)
}
if !strings.Contains(got, "![步骤图](https://img.example/1.png?sig=short)") {
t.Fatalf("alt image not normalized: %s", got)
}
if !strings.Contains(got, "![图片](https://img.example/1.png?sig=short)") {
t.Fatalf("default-alt image not normalized: %s", got)
}
if fetcher.calls[resource] != 1 || publisher.calls != 1 {
t.Fatalf("calls fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestSanitizePartialRemovesInternalImageNodesAndBareReferences(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![图](resource://AbCdEfGhIjKlMnOpQrStUv)\n" +
"<img alt='图' src='resource://SecondRefAbCdEfGhIjKlMn'>\n" +
"内部引用 resource://BareRefAbCdEfGhIjKlMn"
got := r.SanitizePartial(input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content leaked an internal image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "内部引用") {
t.Fatalf("partial text was lost: %s", got)
}
}
func TestRewriteFailsClosedOnUnquotedHTMLAndBareResourceReferences(t *testing.T) {
fetcher := &fakeFetcher{}
r := NewRewriter(fetcher, &fakePublisher{}, 5)
input := "正文\n<img src=resource://UnsafeRefAbCdEfGhIjKlMn>\n内部引用 resource://BareRefAbCdEfGhIjKlMn"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("final content leaked an internal reference: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "内部引用") {
t.Fatalf("final text was lost: %s", got)
}
if len(fetcher.calls) != 0 {
t.Fatalf("unsafe references must not be fetched: %v", fetcher.calls)
}
}
func TestRewriteRemovesUnsafeImageLocatorsAndSignedURLs(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := strings.Join([]string{
"正文",
"![local](local://bucket/a.png)",
"![oss](oss://private/a.png)",
"![private](https://127.0.0.1/a.png)",
"![signed](https://bucket.oss-cn-shenzhen.aliyuncs.com/a.png?x-oss-signature=secret)",
"![public](https://example.com/a.png)",
}, "\n")
got := r.Rewrite(context.Background(), input)
for _, forbidden := range []string{"local://", "oss://", "127.0.0.1", "x-oss-signature"} {
if strings.Contains(strings.ToLower(got), forbidden) {
t.Fatalf("final content retained %q: %s", forbidden, got)
}
}
if !strings.Contains(got, "![public](https://example.com/a.png)") {
t.Fatalf("public HTTPS image was removed: %s", got)
}
}
func TestRewriteNormalizesPublicHTMLImageAsDingTalkMarkdown(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `<img class="diagram" src="https://example.com/a.png" alt="公网图" width="100">`
got := r.Rewrite(context.Background(), input)
if got != "![公网图](https://example.com/a.png)" {
t.Fatalf("normalized public HTML image=%q", got)
}
}
func TestRewriteRemovesDataImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0KGgo= \"\")\n后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("final content retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestSanitizePartialRemovesDataImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0KGgo= \"\")\n后文"
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("partial content retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewritePublishesResourceImageWithOptionalTitle(t *testing.T) {
resource := "resource://AbCdEfGhIjKlMnOpQrStUv"
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 5)
input := "![步骤图](" + resource + " \"安装说明\")"
got := r.Rewrite(context.Background(), input)
if got != "![步骤图](https://img.example/1.png?sig=short \"安装说明\")" {
t.Fatalf("resource image with title=%q", got)
}
if fetcher.calls[resource] != 1 || publisher.calls != 1 {
t.Fatalf("calls fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestSanitizePartialRemovesDataHTMLImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `正文<img alt="占位图" src="data:image/png;base64,iVBORw0KGgo=">后文`
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content retained a data HTML image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewritePreservesPublicHTTPSImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![官网](https://example.com/a.png "产品图")`
if got := r.Rewrite(context.Background(), input); got != input {
t.Fatalf("final public HTTPS image=%q", got)
}
}
func TestSanitizePartialPreservesPublicHTTPSImageWithOptionalTitle(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![官网](https://example.com/a.png "产品图")`
if got := r.SanitizePartial(input); got != input {
t.Fatalf("partial public HTTPS image=%q", got)
}
}
func TestSanitizePartialRemovesIncompleteDataMarkdownImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0K"
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("partial content retained an incomplete data image: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestSanitizePartialRemovesIncompleteDataHTMLImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `正文<img alt="占位图" src="data:image/png;base64,iVBORw0K`
got := r.SanitizePartial(input)
if strings.Contains(got, "data:image") || strings.Contains(strings.ToLower(got), "<img") {
t.Fatalf("partial content retained an incomplete data HTML image: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteRemovesBareDataImageReference(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文 data:image/png;base64,iVBORw0KGgo= 后文"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") {
t.Fatalf("final content retained a bare data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteRemovesIncompleteDataMarkdownImage(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := "正文\n![占位图](data:image/png;base64,iVBORw0K"
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") || strings.Contains(got, "![") {
t.Fatalf("final content retained a broken data image marker: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteWithoutPublisherRemovesDataImage(t *testing.T) {
r := NewRewriter(nil, nil, 0)
input := `正文 ![占位图](data:image/png;base64,iVBORw0KGgo= "") 后文`
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "data:image") || strings.Contains(got, "占位图") {
t.Fatalf("fail-closed rewrite retained a data image: %s", got)
}
if !strings.Contains(got, "正文") || !strings.Contains(got, "后文") {
t.Fatalf("surrounding text was lost: %s", got)
}
}
func TestRewriteDowngradesPDFImageToDocumentLink(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := `![机械臂示教与应用课件](https://sales-edu.example/06%20robot-teaching.PDF?tenant=demo "课件")`
want := `[机械臂示教与应用课件](https://sales-edu.example/06%20robot-teaching.PDF?tenant=demo "课件")`
if got := r.Rewrite(context.Background(), input); got != want {
t.Fatalf("PDF image rewrite=%q, want %q", got, want)
}
}
func TestFinalAndPartialDowngradeKnownDocumentImages(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
for _, extension := range []string{
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".csv", ".txt", ".zip", ".rar", ".7z",
} {
t.Run(extension, func(t *testing.T) {
url := "https://docs.example/manual" + extension + "?tenant=demo"
input := "![资料](" + url + ")"
want := "[资料](" + url + ")"
if got := r.Rewrite(context.Background(), input); got != want {
t.Fatalf("final rewrite=%q, want %q", got, want)
}
if got := r.SanitizePartial(input); got != want {
t.Fatalf("partial rewrite=%q, want %q", got, want)
}
})
}
html := `<img src="https://docs.example/manual.xlsx" alt="参数表">`
if got, want := r.Rewrite(context.Background(), html), `[参数表](https://docs.example/manual.xlsx)`; got != want {
t.Fatalf("HTML document image rewrite=%q, want %q", got, want)
}
if got, want := r.SanitizePartial(html), `[参数表](https://docs.example/manual.xlsx)`; got != want {
t.Fatalf("partial HTML document image rewrite=%q, want %q", got, want)
}
}
func TestDocumentImageDowngradePreservesImageAndLinkContracts(t *testing.T) {
r := NewRewriter(&fakeFetcher{}, &fakePublisher{}, 5)
input := strings.Join([]string{
`![稳定图片](https://weknora-api.example/i/006d001d-7d0b-542e-b5ab-6ee7d7cdce54)`,
`[已有PDF链接](https://docs.example/manual.pdf)`,
`![危险PDF](https://127.0.0.1/manual.pdf)`,
}, "\n")
got := r.Rewrite(context.Background(), input)
if !strings.Contains(got, `![稳定图片](https://weknora-api.example/i/006d001d-7d0b-542e-b5ab-6ee7d7cdce54)`) {
t.Fatalf("extensionless stable image changed: %s", got)
}
if !strings.Contains(got, `[已有PDF链接](https://docs.example/manual.pdf)`) {
t.Fatalf("existing document link changed: %s", got)
}
if strings.Contains(got, "127.0.0.1") || strings.Contains(got, "危险PDF") {
t.Fatalf("unsafe document image became a link: %s", got)
}
}
-48
View File
@@ -1,48 +0,0 @@
package outboundimage
import (
"context"
"errors"
"net/http"
"path"
"strconv"
"strings"
)
type ObjectReader interface {
Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error)
}
func NewStableImageHandler(reader ObjectReader, objectPrefix, routePrefix string, maxBytes int64) (http.Handler, error) {
objectPrefix = strings.Trim(strings.TrimSpace(objectPrefix), "/")
routePrefix = "/" + strings.Trim(strings.TrimSpace(routePrefix), "/") + "/"
if reader == nil || objectPrefix == "" || maxBytes <= 0 {
return nil, errors.New("outbound image: invalid stable image handler configuration")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, routePrefix)
if r.URL.Path == id || strings.Contains(id, "/") || !stableImageIDRE.MatchString(id) {
http.NotFound(w, r)
return
}
data, contentType, err := reader.Get(r.Context(), path.Join(objectPrefix, id), maxBytes)
if err != nil || len(data) == 0 || imageExtension(contentType) == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", strings.TrimSpace(strings.Split(contentType, ";")[0]))
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "public, max-age=86400, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(data)
}), nil
}
@@ -1,91 +0,0 @@
package outboundimage
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
type fakeImageReader struct {
key string
maxBytes int64
data []byte
contentType string
err error
}
func (r *fakeImageReader) Get(_ context.Context, key string, maxBytes int64) ([]byte, string, error) {
r.key = key
r.maxBytes = maxBytes
return append([]byte(nil), r.data...), r.contentType, r.err
}
func TestStableImageHandlerReturnsImageBytesDirectly(t *testing.T) {
const id = "ca13f02b-58b6-5f8f-a80d-b41873a97425"
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/dingtalk/i/"+id, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d", recorder.Code)
}
if recorder.Header().Get("Location") != "" {
t.Fatalf("stable image endpoint must not redirect: location=%q", recorder.Header().Get("Location"))
}
if recorder.Header().Get("Content-Type") != "image/jpeg" {
t.Fatalf("content-type=%q", recorder.Header().Get("Content-Type"))
}
if recorder.Body.String() != "JPEG" {
t.Fatalf("body=%q", recorder.Body.String())
}
if reader.key != "dobotai/im-images/"+id || reader.maxBytes != 10<<20 {
t.Fatalf("key=%q maxBytes=%d", reader.key, reader.maxBytes)
}
}
func TestStableImageHandlerAllowsHeadWithoutBody(t *testing.T) {
const id = "ca13f02b-58b6-5f8f-a80d-b41873a97425"
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodHead, "/dingtalk/i/"+id, nil))
if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Content-Length") != "4" {
t.Fatalf("content-length=%q", recorder.Header().Get("Content-Length"))
}
}
func TestStableImageHandlerRejectsPostAndInvalidIDsWithoutReadingOSS(t *testing.T) {
reader := &fakeImageReader{data: []byte("JPEG"), contentType: "image/jpeg"}
handler, err := NewStableImageHandler(reader, "dobotai/im-images", "/dingtalk/i/", 10<<20)
if err != nil {
t.Fatal(err)
}
post := httptest.NewRecorder()
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/dingtalk/i/ca13f02b-58b6-5f8f-a80d-b41873a97425", nil))
if post.Code != http.StatusMethodNotAllowed {
t.Fatalf("post status=%d", post.Code)
}
invalid := httptest.NewRecorder()
handler.ServeHTTP(invalid, httptest.NewRequest(http.MethodGet, "/dingtalk/i/../../secret", nil))
if invalid.Code != http.StatusNotFound {
t.Fatalf("invalid status=%d", invalid.Code)
}
if reader.key != "" {
t.Fatalf("invalid requests reached OSS key=%q", reader.key)
}
}
+3 -21
View File
@@ -129,15 +129,11 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
}
media := make([]core.MediaAudit, 0, len(request.Images))
questionImageURLs := make([]string, 0, len(request.Images))
for _, image := range request.Images {
media = append(media, core.MediaAudit{
Type: "image", MIMEType: image.MIMEType, SizeBytes: image.SizeBytes,
Width: image.Width, Height: image.Height,
})
if url := strings.TrimSpace(image.PublishedURL); url != "" {
questionImageURLs = append(questionImageURLs, url)
}
}
messageType := normalizeMessageType(request)
if messageType == "" {
@@ -165,7 +161,6 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
RequestMessageID: newID(),
Question: request.Query,
Media: media,
QuestionImageURLs: questionImageURLs,
Status: "processing",
}
stored, created, err := s.repository.BeginTurn(ctx, turn)
@@ -219,32 +214,20 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
Query: query, AgentID: agentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Images: images,
}
// Track whether this turn actually triggered a knowledge-base search by
// observing knowledge_search tool-call events. The flag feeds coverage
// classification (image turns) and is persisted with the turn.
var knowledgeSearched bool
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
if strings.EqualFold(strings.TrimSpace(e.ToolName), "knowledge_search") {
knowledgeSearched = true
}
if eventObserver != nil {
eventObserver(e)
}
})
runChat := func(targetSessionID string) (weknora.ChatResult, error) {
var result weknora.ChatResult
var runErr error
if eventObserver != nil {
// Prefer AgentChatEvents; fallback to AgentChatStream adaptation; then AgentChat.
if eventStream, ok := s.weknora.(eventStreamingWeKnora); ok {
result, runErr = eventStream.AgentChatEvents(ctx, targetSessionID, chatRequest, effectiveEventObserver)
result, runErr = eventStream.AgentChatEvents(ctx, targetSessionID, chatRequest, eventObserver)
} else if streaming, ok := s.weknora.(streamingWeKnora); ok {
// Adapt AgentChatStream answer chunks to StreamEvent
result, runErr = streaming.AgentChatStream(ctx, targetSessionID, chatRequest, func(full string) {
if answerObserver != nil {
answerObserver(full)
}
effectiveEventObserver(weknora.StreamEvent{
eventObserver(weknora.StreamEvent{
ResponseType: "answer",
FullAnswer: full,
})
@@ -255,7 +238,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if answerObserver != nil {
answerObserver(result.Answer)
}
effectiveEventObserver(weknora.StreamEvent{
eventObserver(weknora.StreamEvent{
ResponseType: "answer",
FullAnswer: result.Answer,
Done: true,
@@ -301,7 +284,6 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
}
turn.Status, turn.Answer, turn.References = "succeeded", chat.Answer, chat.References
turn.WeKnoraRequestID = chat.RequestID
turn.KnowledgeSearched = knowledgeSearched
turn.AnswerMessageID = newID()
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
+1 -43
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"reflect"
"strings"
"time"
)
@@ -39,15 +38,6 @@ type RecordInserter interface {
InsertRecord(ctx context.Context, row Row) (string, error)
}
// AnswerRewriter optionally transforms the answer before it is written to the
// question table, applying the same outbound conversion used for DingTalk
// delivery (resource images to stable HTTPS, document links, anchor cleanup).
// When nil or when Rewrite returns an error, the original answer is written so
// the record is never lost.
type AnswerRewriter interface {
Rewrite(ctx context.Context, content string) (string, error)
}
// DispatcherStats holds the outcome counters for a single RunOnce invocation.
type DispatcherStats struct {
Claimed int
@@ -61,7 +51,6 @@ type DispatcherOptions struct {
MaxAttempts int
ClaimLease time.Duration
SendTimeout time.Duration
Rewriter AnswerRewriter
}
// Dispatcher runs the background dispatch loop that claims pending exports
@@ -69,7 +58,6 @@ type DispatcherOptions struct {
type Dispatcher struct {
repo ExportRepository
inserter RecordInserter
rewriter AnswerRewriter
batch int
maxAttempts int
@@ -108,10 +96,8 @@ func NewDispatcher(repo ExportRepository, inserter RecordInserter, opts *Dispatc
maxAttempts := 5
claimLease := 5 * time.Minute
sendTimeout := 10 * time.Second
var rewriter AnswerRewriter
if opts != nil {
rewriter = opts.Rewriter
if opts.Batch < 0 {
return nil, fmt.Errorf("questiontable dispatcher: batch must be >= 0, got %d", opts.Batch)
}
@@ -144,7 +130,6 @@ func NewDispatcher(repo ExportRepository, inserter RecordInserter, opts *Dispatc
return &Dispatcher{
repo: repo,
inserter: inserter,
rewriter: rewriter,
batch: batch,
maxAttempts: maxAttempts,
claimLease: claimLease,
@@ -180,18 +165,8 @@ func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
return stats, fmt.Errorf("questiontable dispatcher: context cancelled during dispatch: %w", err)
}
row := item.Row
if err := d.rewriteAnswer(ctx, &row); err != nil {
// Rewriting is best-effort: the original answer is written so the
// question-table record is never lost. Only the row is skipped when
// the parent context is cancelled.
if ctx.Err() != nil {
return stats, fmt.Errorf("questiontable dispatcher: context cancelled during rewrite: %w", ctx.Err())
}
}
sendCtx, cancel := context.WithTimeout(ctx, d.sendTimeout)
recordID, insertErr := d.inserter.InsertRecord(sendCtx, row)
recordID, insertErr := d.inserter.InsertRecord(sendCtx, item.Row)
cancel()
if insertErr != nil {
@@ -214,20 +189,3 @@ func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
return stats, nil
}
// rewriteAnswer transforms the outbound answer when an AnswerRewriter is
// configured. A nil rewriter or a rewriter error leaves the original answer
// intact; the row is never skipped for a rewriter error.
func (d *Dispatcher) rewriteAnswer(ctx context.Context, row *Row) error {
if d.rewriter == nil {
return nil
}
rewritten, err := d.rewriter.Rewrite(ctx, row.Answer)
if err != nil {
return err
}
if strings.TrimSpace(rewritten) != "" {
row.Answer = rewritten
}
return nil
}
@@ -1,137 +0,0 @@
package questiontable
import (
"context"
"strings"
"testing"
"time"
)
type answerRewriterStub struct {
rewritten string
err error
}
func (s answerRewriterStub) Rewrite(ctx context.Context, content string) (string, error) {
return s.rewritten, s.err
}
func TestRunOnceRewritesAnswerBeforeInsert(t *testing.T) {
repo := newStubRepo()
inserter := newStubInserter()
inserter.recordID = "rec-rewrite-1"
repo.claimFn = func(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingExport, error) {
return []PendingExport{
{
ExportID: "exp-rewrite",
Row: Row{
Question: "机械臂如何学习使用呢",
Answer: "原始答案含[[3](#_Toc1)]和![图](resource://abc)",
AskedTime: time.Now(),
AskerName: "张三",
AskerUnionID: "union-z",
TriggerPerson: "张三",
Department: "研发部",
Region: "华东",
QuestionImageURLs: []string{"https://weknora-api.example/i/001"},
},
Attempts: 0,
},
}, nil
}
rw := answerRewriterStub{rewritten: "转换后答案[3]", err: nil}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{Rewriter: rw})
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Claimed != 1 || stats.Exported != 1 || stats.Failed != 0 {
t.Fatalf("stats=%+v", stats)
}
if len(inserter.rows) != 1 {
t.Fatalf("rows=%d, want 1", len(inserter.rows))
}
row := inserter.rows[0]
if row.Answer != "转换后答案[3]" {
t.Fatalf("Answer=%q, want rewritten", row.Answer)
}
}
func TestRunOnceRewriterErrorFallsBackToOriginalAnswer(t *testing.T) {
repo := newStubRepo()
inserter := newStubInserter()
inserter.recordID = "rec-fallback-1"
repo.claimFn = func(ctx context.Context, limit int, maxAttempts int, staleBefore time.Time) ([]PendingExport, error) {
return []PendingExport{
{
ExportID: "exp-fallback",
Row: Row{
Question: "问题",
Answer: "原始答案",
AskedTime: time.Now(),
},
Attempts: 0,
},
}, nil
}
rw := answerRewriterStub{rewritten: "", err: context.DeadlineExceeded}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{Rewriter: rw})
if err != nil {
t.Fatalf("NewDispatcher: %v", err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.Failed != 0 || stats.Exported != 1 {
t.Fatalf("stats=%+v", stats)
}
if len(inserter.rows) != 1 || inserter.rows[0].Answer != "原始答案" {
t.Fatalf("rows=%+v, want fallback original answer", inserter.rows)
}
}
func TestRowToFieldsIncludesQuestionImageURLsAndImageMarker(t *testing.T) {
r := Row{
Question: "请描述并分析这张图片。",
Answer: "回答了。",
AskedTime: time.Now(),
AskerName: "张三",
AskerUnionID: "union-z",
TriggerPerson: "张三",
Department: "研发部",
Region: "华东",
QuestionImageURLs: []string{
"https://weknora-api.example/i/001",
"https://weknora-api.example/i/002",
},
}
fields := r.ToFields()
original, ok := fields[FieldOriginalQuestion].(string)
if !ok {
t.Fatalf("OriginalQuestion type=%T", fields[FieldOriginalQuestion])
}
if !strings.HasPrefix(original, "[图片]") {
t.Fatalf("OriginalQuestion=%q, want [图片] prefix", original)
}
// Image URLs must be rendered as markdown image syntax so the Notable cell
// previews them (same as the answer column), not as a plain-text link.
if !strings.Contains(original, "![图片1](https://weknora-api.example/i/001)") ||
!strings.Contains(original, "![图片2](https://weknora-api.example/i/002)") {
t.Fatalf("OriginalQuestion=%q, missing markdown image syntax", original)
}
// Without image URLs, no [图片] prefix.
plain := Row{Question: "普通问题", AskedTime: time.Now()}
if got := plain.ToFields()[FieldOriginalQuestion]; got != "普通问题" {
t.Fatalf("plain OriginalQuestion=%q, want unchanged", got)
}
}
+10 -37
View File
@@ -1,7 +1,6 @@
package questiontable
import (
"strconv"
"strings"
"time"
"unicode"
@@ -24,15 +23,14 @@ const (
// Row holds the data needed to produce a Notable v1 typed record.
type Row struct {
Question string
Answer string
AskedTime time.Time
AskerName string
AskerUnionID string
TriggerPerson string
Department string
Region string
QuestionImageURLs []string
Question string
Answer string
AskedTime time.Time
AskerName string
AskerUnionID string
TriggerPerson string
Department string
Region string
}
// ToFields returns a typed field-ID → value map suitable for the Notable v1
@@ -69,8 +67,8 @@ func (r Row) ToFields() map[string]interface{} {
}
}
// OriginalQuestion: include image marker and stable URLs for image turns.
fields[FieldOriginalQuestion] = sanitise(questionWithImageURLs(r.Question, r.QuestionImageURLs))
// OriginalQuestion.
fields[FieldOriginalQuestion] = sanitise(r.Question)
// Language.
fields[FieldLanguage] = sanitise(lang)
@@ -136,28 +134,3 @@ func sanitise(s string) string {
}
return s
}
// questionWithImageURLs prefixes an image marker and renders the stable image
// URLs as markdown image syntax when the turn carried image input. Markdown
// image syntax lets the Notable cell preview the image (same as the answer
// column); a plain-text URL would render as an unpreviewable link.
func questionWithImageURLs(question string, imageURLs []string) string {
if len(imageURLs) == 0 {
return question
}
var builder strings.Builder
builder.WriteString("[图片] ")
builder.WriteString(strings.TrimSpace(question))
for index, imageURL := range imageURLs {
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
continue
}
builder.WriteString("\n![图片")
builder.WriteString(strconv.Itoa(index + 1))
builder.WriteString("](")
builder.WriteString(imageURL)
builder.WriteString(")")
}
return builder.String()
}
+10 -3
View File
@@ -85,7 +85,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
if err != nil {
return "", err
}
return "身份已验证,请回到服务器命令行等待确认。", nil
return "身份已验证,请回到后台确认首位管理员。", nil
}
if explicit && len(args) == 0 {
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
@@ -158,7 +158,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
return "", err
}
func settingMenu(admin, _ bool) string {
func settingMenu(admin, direct bool) string {
menu := "⚙️ 个人设置\n入口:/setting 或 /设置\n" +
"回答语言:/setting language <auto|zh-CN|en|ja|ko|es>\n" +
"- auto:跟随当前提问语言(推荐)\n" +
@@ -172,7 +172,14 @@ func settingMenu(admin, _ bool) string {
"- support:在目标技术支持群@机器人发送 /setting support 保存;再发送 /setting support test,测试成功后激活。\n" +
"- question:私聊发送 /setting question,再在10分钟内发送问题AI表链接;Schema验证通过后激活并短暂重启。\n" +
"- faq:私聊发送 /setting faq,再在10分钟内发送FAQ表链接;Schema验证通过后激活并短暂重启。"
return menu
if !direct {
return menu + "\n\n🌐 管理后台与管理员更换:请私聊机器人发送 /setting 查看。"
}
return menu + "\n\n🌐 管理后台(仅PC)\n" +
"1. 通过SSH隧道将本地8090转发到服务器127.0.0.1:8090。\n" +
"2. 浏览器打开:http://127.0.0.1:8090/admin/settings\n" +
"3. Basic Auth用户名任意;密码为服务器DOBOTAI_ADMIN_TOKEN(仅从服务器安全获取,禁止发送到钉钉)。\n" +
"4. 更换管理员:后台生成6位码 → 新管理员私聊 /setting <6位码> → 回后台确认 → 新管理员生效后再停用旧管理员。"
}
func tableBindingFailureReply(action string, err error) string {
+6 -15
View File
@@ -88,7 +88,7 @@ func TestControllerSettingMenuExplainsLanguageWithoutAdminActionsToOrdinaryUser(
}
}
func TestControllerSettingMenuExplainsAdminActionsWithoutBackendAccess(t *testing.T) {
func TestControllerSettingMenuExplainsAdminActionsAndBackendAccess(t *testing.T) {
r := newMemoryRepo()
r.admins["t/a"] = true
c := NewController(r)
@@ -103,21 +103,15 @@ func TestControllerSettingMenuExplainsAdminActionsWithoutBackendAccess(t *testin
"/setting question",
"/setting faq",
"Schema验证通过后激活并短暂重启",
} {
if !strings.Contains(out, want) {
t.Fatalf("reply=%q must contain %q", out, want)
}
}
for _, forbidden := range []string{
"管理后台",
"SSH隧道",
"127.0.0.1:8090/admin/settings",
"Basic Auth",
"DOBOTAI_ADMIN_TOKEN",
"回后台确认",
"新管理员私聊 /setting <6位码>",
"新管理员生效后再停用旧管理员",
} {
if strings.Contains(out, forbidden) {
t.Fatalf("reply=%q must not contain %q", out, forbidden)
if !strings.Contains(out, want) {
t.Fatalf("reply=%q must contain %q", out, want)
}
}
}
@@ -318,14 +312,11 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) {
r := newMemoryRepo()
c := NewController(r)
c.now = func() time.Time { return time.Unix(100, 0) }
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", UnionID: "union", DisplayName: "Alice", Text: "/setting 123456", ConversationType: "direct"})
_, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", UnionID: "union", DisplayName: "Alice", Text: "/setting 123456", ConversationType: "direct"})
if err != nil {
t.Fatal(err)
}
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
t.Fatalf("candidate=%+v", r.candidate)
}
if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out)
}
}
@@ -2,7 +2,6 @@ package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -83,7 +82,6 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
cl.attempts,
t.question,
t.answer,
t.question_image_urls_json,
m.created_at,
COALESCE(m.actor_name_snapshot, '') AS actor_name_snapshot,
COALESCE(m.actor_union_id_snapshot, '') AS actor_union_id_snapshot,
@@ -111,7 +109,6 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
attempts int
question string
answer string
imageURLsJSON []byte
askedTime time.Time
actorNameSnapshot string
actorUnionIDSnapshot string
@@ -120,32 +117,25 @@ func (r *QuestionTableExportRepository) Claim(ctx context.Context, limit int, ma
regionSnap string
)
if err := rows.Scan(&exportID, &attempts,
&question, &answer, &imageURLsJSON,
&question, &answer,
&askedTime,
&actorNameSnapshot, &actorUnionIDSnapshot,
&triggerPersonSnap, &departmentSnap, &regionSnap); err != nil {
rows.Close()
return nil, fmt.Errorf("questiontable export claim scan: %w", err)
}
var imageURLs []string
if len(imageURLsJSON) > 0 {
if err := json.Unmarshal(imageURLsJSON, &imageURLs); err != nil {
imageURLs = nil
}
}
exports = append(exports, questiontable.PendingExport{
ExportID: exportID,
Attempts: attempts,
Row: questiontable.Row{
Question: question,
Answer: answer,
AskedTime: askedTime,
AskerName: actorNameSnapshot,
AskerUnionID: actorUnionIDSnapshot,
TriggerPerson: triggerPersonSnap,
Department: departmentSnap,
Region: regionSnap,
QuestionImageURLs: imageURLs,
Question: question,
Answer: answer,
AskedTime: askedTime,
AskerName: actorNameSnapshot,
AskerUnionID: actorUnionIDSnapshot,
TriggerPerson: triggerPersonSnap,
Department: departmentSnap,
Region: regionSnap,
},
})
}
+5 -13
View File
@@ -56,14 +56,6 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
if err != nil {
return core.Turn{}, false, err
}
imageURLs := turn.QuestionImageURLs
if imageURLs == nil {
imageURLs = []string{}
}
imageURLsJSON, err := json.Marshal(imageURLs)
if err != nil {
return core.Turn{}, false, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return core.Turn{}, false, err
@@ -114,10 +106,10 @@ func (s *Store) BeginTurn(ctx context.Context, turn core.Turn) (core.Turn, bool,
}
command, err := tx.Exec(ctx, `INSERT INTO turns
(id,tenant_key,channel,external_message_id,session_key,question,status,media_json,request_message_id,agent_id,question_image_urls_json)
VALUES($1,$2,$3,$4,$5,$6,'processing',$7,$8,$9,$10) ON CONFLICT DO NOTHING`,
(id,tenant_key,channel,external_message_id,session_key,question,status,media_json,request_message_id,agent_id)
VALUES($1,$2,$3,$4,$5,$6,'processing',$7,$8,$9) ON CONFLICT DO NOTHING`,
turn.ID, turn.TenantKey, turn.Channel, turn.ExternalMessageID, turn.SessionKey, turn.Question,
mediaJSON, turn.RequestMessageID, turn.AgentID, imageURLsJSON)
mediaJSON, turn.RequestMessageID, turn.AgentID)
if err != nil {
return core.Turn{}, false, err
}
@@ -248,9 +240,9 @@ func (s *Store) FinishTurn(ctx context.Context, turn core.Turn) error {
}
}
}
_, err = tx.Exec(ctx, `UPDATE turns SET weknora_session_id=$2,weknora_request_id=$3,answer=$4,references_json=$5,media_json=$6,status=$7,error_message=$8,answer_message_id=COALESCE($9::TEXT,answer_message_id),knowledge_searched=$10,updated_at=NOW() WHERE id=$1`,
_, err = tx.Exec(ctx, `UPDATE turns SET weknora_session_id=$2,weknora_request_id=$3,answer=$4,references_json=$5,media_json=$6,status=$7,error_message=$8,answer_message_id=COALESCE($9::TEXT,answer_message_id),updated_at=NOW() WHERE id=$1`,
turn.ID, turn.WeKnoraSessionID, turn.WeKnoraRequestID, turn.Answer, references, mediaJSON,
turn.Status, turn.ErrorMessage, answerMessageID, turn.KnowledgeSearched)
turn.Status, turn.ErrorMessage, answerMessageID)
if err != nil {
return err
}