Compare commits

..

3 Commits

Author SHA1 Message Date
姚章浩 9871760bc0 fix(deploy): make clone installer build dependencies reliably 2026-09-11 11:20:27 +08:00
姚章浩 f533c385ea feat(deploy): add clone-and-run docker installer 2026-09-11 11:12:28 +08:00
姚章浩 bcb2f303ff feat(deploy): bootstrap compose managed postgres stack 2026-09-11 10:51:45 +08:00
66 changed files with 1015 additions and 4385 deletions
+2 -3
View File
@@ -2,9 +2,8 @@ FROM golang:1.26.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
RUN go mod download
ARG GOPROXY=https://goproxy.cn,direct
RUN GOPROXY=$GOPROXY go mod download
COPY . ./
ARG VERSION=dev
+17 -13
View File
@@ -78,32 +78,36 @@ DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份
## 部署方式
| 方式 | 适用场景 | 数据库 | 入口 |
| --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` |
| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` |
| 方式 | 适用场景 | 入口 | 数据库与迁移 | 推荐程度 |
| --- | --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试、临时问题定位 | `go run ./cmd/dobotai` 或受控构建的二进制 | 操作人员准备独立PostgreSQL并显式执行迁移 | 仅开发 |
| Docker Compose | 测试、预发布、生产候选 | 仓库根目录`install-docker.sh` | 脚本构建镜像、创建`dobotai-postgres`和`dobotai-postgres-data`,并按序执行迁移 | 推荐 |
### 源码部署
### 源码/原生部署
```bash
# 开发机:使用Git外的环境文件注入真实配置
go run ./cmd/dobotai
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl http://127.0.0.1:8090/healthz
```
源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。
源码方式不自动创建数据库、不管理密钥目录、不自动执行SQL迁移,也不适合作为长期测试或生产运行方式。
### Docker + 阿里云RDS部署
克隆仓库后在根目录执行:
### Docker Compose 部署
```bash
# 克隆后进入仓库根目录执行:自动安装Docker/Compose(如缺失)、构建镜像、
# 准备目录、独立PostgreSQL、命名卷、env模板和迁移。
sudo bash ./install-docker.sh
# 配置WeKnora和钉钉后自动启动应用
sudo dobotai-dingtalk-configure
# 仅首次且没有有效管理员时执行
sudo dobotai-dingtalk-bind-admin
```
第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。
完整安装、RDS检查、升级和回滚见 [Docker部署手册](deploy/dobotai-dingtalk/README.md)。
完整的安装、现有数据库迁移、备份、回滚和验收说明见 [Docker 部署手册](deploy/dobotai-dingtalk/README.md)。
## 钉钉 Stream
-27
View File
@@ -1,27 +0,0 @@
package main
import (
"log"
"os"
"strings"
)
// cardWideTableGroupingMode returns the configured handling of over-wide answer
// tables (see dingtalk.tableGroupingForMode):
//
// unset / on → grouped list on every frame (historical behaviour)
// off → real tables on every frame
// stream → grouped list while streaming, real table on the final frame
//
// The default stays "on" so an environment without the variable renders exactly
// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes.
func cardWideTableGroupingMode() string {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP")))
switch mode {
case "off", "0", "false", "no", "stream", "streaming", "partial":
log.Printf("DingTalk wide-table grouping mode=%s", mode)
return mode
default:
return ""
}
}
-17
View File
@@ -79,22 +79,10 @@ func main() {
if err != nil {
log.Fatal(err)
}
defaultMode := reasoningmode.ModeQuick
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
switch raw {
case "reasoning":
defaultMode = reasoningmode.ModeReasoning
case "quick":
defaultMode = reasoningmode.ModeQuick
default:
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
}
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
reasoningmode.WithDefaultMode(defaultMode),
)
if err != nil {
log.Fatal(err)
@@ -307,7 +295,6 @@ func main() {
}
cardSender = cs
}
cardImageMedia := buildCardImageMedia(outboundImages.publicURLPrefix, dingCfg.ClientID, dingCfg.ClientSecret, address)
handler, err := dingtalk.NewHandlerWithOptions(
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
@@ -318,13 +305,9 @@ func main() {
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
CardImageMedia: cardImageMedia,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
WideTableGrouping: cardWideTableGroupingMode(),
},
)
if err != nil {
-50
View File
@@ -5,7 +5,6 @@ import (
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
@@ -185,55 +184,6 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
}
}
func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
if outboundImageRewriteTimeoutDefault != 180*time.Second {
t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "")
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("unset budget=%s", got)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s")
if got := outboundImageRewriteTimeout(); got != 150*time.Second {
t.Fatalf("parsed budget=%s", got)
}
for _, invalid := range []string{"-1s", "180", "soon"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid)
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("invalid budget %q resolved to %s", invalid, got)
}
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "")
if !outboundImageCompressionEnabled() {
t.Fatal("compression must default to on")
}
for _, disabled := range []string{"off", "0", "false", "no"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled)
if outboundImageCompressionEnabled() {
t.Fatalf("compression must be off for %q", disabled)
}
}
}
func TestCardWideTableGroupingMode(t *testing.T) {
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("unset must keep the historical mode, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
if got := cardWideTableGroupingMode(); got != "off" {
t.Fatalf("off must be passed through, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ")
if got := cardWideTableGroupingMode(); got != "stream" {
t.Fatalf("stream must be normalised, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got)
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
+7 -115
View File
@@ -2,11 +2,7 @@ package main
import (
"errors"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -15,63 +11,12 @@ import (
)
const (
outboundImageMaxCountDefault = 12
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
// outboundImageRewriteTimeoutDefault is the budget for one answer's image
// rewrite. Manual screenshots are multi-megabyte uploads and the container's
// uplink is the real ceiling (measured ~0.6MB/s), so long answers need a
// budget with headroom: the historical 15s delivery timeout truncated them.
outboundImageRewriteTimeoutDefault = 180 * time.Second
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
)
// outboundImageMaxCount resolves how many images one answer may rewrite.
// DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT overrides the default; invalid values fall back.
func outboundImageMaxCount() int {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT"))
if raw == "" {
return outboundImageMaxCountDefault
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT=%q; using default %d", raw, outboundImageMaxCountDefault)
return outboundImageMaxCountDefault
}
return n
}
// outboundImageRewriteTimeout resolves the budget for the image rewrite stage.
// DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT accepts a Go duration ("90s", "2m");
// invalid or non-positive values fall back to the default.
func outboundImageRewriteTimeout() time.Duration {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT"))
if raw == "" {
return outboundImageRewriteTimeoutDefault
}
duration, err := time.ParseDuration(raw)
if err != nil || duration <= 0 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT=%q; using default %s", raw, outboundImageRewriteTimeoutDefault)
return outboundImageRewriteTimeoutDefault
}
return duration
}
// outboundImageCompressionEnabled reports whether published screenshots are
// re-encoded as smaller JPEGs before upload. It is on by default because the
// container uplink, not the rewrite code, caps how many images fit in the
// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original
// bytes exactly as fetched.
func outboundImageCompressionEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) {
case "off", "0", "false", "no":
log.Printf("outbound image: compression disabled by configuration")
return false
default:
return true
}
}
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
@@ -87,11 +32,6 @@ type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
// rewriteTimeout is the budget the channel gives the image rewrite stage.
rewriteTimeout time.Duration
// publicURLPrefix is the prefix of every published image URL, used by the
// DingTalk card image uploader to recognise (and refetch) our own images.
publicURLPrefix string
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
@@ -141,11 +81,9 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())),
publisher: publisher,
handler: handler,
rewriteTimeout: outboundImageRewriteTimeout(),
publicURLPrefix: strings.TrimRight(cfg.PublicBaseURL, "/") + "/",
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
}, nil
}
@@ -153,49 +91,3 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
// buildCardImageMedia wires the DingTalk media-id rewriter used for AI card
// bodies. It returns nil when the feature is switched off or not configured, so
// a disabled uploader simply keeps external image URLs in place.
func buildCardImageMedia(publicImagePrefix, clientID, clientSecret, listenAddr string) dingtalk.CardImageMediaRewriter {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_MEDIA"))) {
case "off", "0", "false", "no":
log.Printf("DingTalk card image media ids disabled by configuration")
return nil
}
resolver, err := dingtalk.NewCardImageMediaRewriter(dingtalk.CardImageMediaOptions{
ClientID: clientID,
ClientSecret: clientSecret,
ImageURLPrefix: publicImagePrefix,
FetchURLPrefix: cardImageFetchPrefix(listenAddr),
})
if err != nil {
log.Printf("DingTalk card image media ids disabled: %v", err)
return nil
}
if resolver == nil {
return nil
}
log.Printf("DingTalk card images will be delivered through DingTalk media ids")
return resolver
}
// cardImageFetchPrefix derives the prefix used to download our own published
// images before uploading them to DingTalk. An explicit
// DINGTALK_CARD_IMAGE_FETCH_BASE_URL (host only, without the image route) wins;
// otherwise the local listen port is reused so the fetch stays on loopback.
func cardImageFetchPrefix(listenAddr string) string {
base := strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_FETCH_BASE_URL"))
if base == "" {
addr := strings.TrimSpace(listenAddr)
if addr == "" {
return ""
}
_, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return ""
}
base = "http://127.0.0.1:" + port
}
return strings.TrimRight(base, "/") + outboundImageRoute
}
@@ -1,2 +0,0 @@
ALTER TABLE reasoning_mode_states
DROP COLUMN user_selected;
@@ -1,6 +0,0 @@
-- 000022_reasoning_default_mode: support an operator-configurable default
-- mode for the /think reasoning router. The new column records whether a
-- user has explicitly chosen a mode (/think on|off); users who never have
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
ALTER TABLE reasoning_mode_states
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
@@ -1,34 +0,0 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE REASONING_MODE_STATES",
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown022DropsUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
}
}
-121
View File
@@ -1,121 +0,0 @@
#!/usr/bin/env python3
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
Sends two cards to the owner's private robot chat so PC width can be compared:
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
B: control, same body without the layout field
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
Credentials are read from the running container's environment and are never
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
"""
import json
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
CONTAINER = "dobotai-dingtalk"
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
CONTENT = (
"**{tag}**\n\n"
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
"- 投放体里的布局字段:{field}\n"
"- 期望:{expect}\n"
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
"移动端请忽略,宽屏只影响桌面端渲染。"
)
def env(key: str) -> str:
out = subprocess.run(
["docker", "exec", CONTAINER, "printenv", key],
capture_output=True, text=True, check=True,
).stdout.strip()
if not out:
sys.exit(f"missing env {key}")
return out
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("x-acs-dingtalk-access-token", token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read(4096).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read(4096).decode("utf-8", "replace")
def mask(v: str) -> str:
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
def main() -> None:
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
token = json.loads(body).get("accessToken", "")
print(f"token: http={status} got={'yes' if token else 'NO'}")
if not token:
sys.exit("no token")
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
param_map = {
"content": "",
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
}
if with_wide:
param_map["sys_full_json_obj"] = WIDE
out_track_id = str(uuid.uuid4())
card_body = {
"cardTemplateId": tpl,
"outTrackId": out_track_id,
"callbackType": "STREAM",
"cardData": {"cardParamMap": param_map},
"userIdType": 1,
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
"imRobotOpenSpaceModel": {"supportForward": True},
"imRobotOpenDeliverModel": {
"robotCode": app_key,
"spaceType": "IM_ROBOT",
"extension": {},
},
}
st, rb = call("POST", CREATE_URL, card_body, token)
print(f"[{tag}] create http={st} resp={rb[:300]}")
text = CONTENT.format(
tag=tag,
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
)
stream_body = {
"outTrackId": out_track_id,
"guid": str(uuid.uuid4()),
"key": "content",
"content": text,
"isFull": True,
"isFinalize": True,
"isError": False,
}
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
if __name__ == "__main__":
main()
+211 -106
View File
@@ -1,157 +1,262 @@
# DobotAI Docker部署(阿里云RDS)
# DobotAI DingTalk Docker 部署手册
## 定位
> 适用范围:DobotAI 的 Docker Compose 部署。容器名固定为 `dobotai-dingtalk`,数据库容器名固定为 `dobotai-postgres`。
>
> 本文面向测试/预发布。生产必须使用已验收的同一镜像、独立备份和独立审批,禁止在生产重新编译。
DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。
## 1. 定位
DobotAI 是钉钉渠道的对话编排与交付层,不是知识库、文档解析器或模型平台。
```text
钉钉 Stream
↓
dobotai-dingtalk 容器
├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定
└─ 既有WeKnora:Agent、检索、知识、模型
DobotAI 负责:
钉钉 Stream、富文本/图片、会话映射、WeKnora Agent 调用、卡片交付、
/think、/setting、覆盖判定、Support Case、反馈、渠道审计。
DobotAI 不负责:
WeKnora 文档/Chunk/向量/图谱/解析/模型;钉钉应用创建;知识库治理。
```
禁止创建或使用:
## 2. Docker 架构
```text
dobotai-postgres 容器
dobotai-postgres-data 数据卷
本地PostgreSQL作为DobotAI目标数据库
钉钉用户/群
│ DingTalk Stream(出站长连接)
▼
┌────────────────────────────────────────┐
│ dobotai-dingtalk │
│ 消息、会话、卡片、覆盖与支持流程 │
│ 仅映射到宿主机 127.0.0.1:8090 │
└───────┬──────────────────────┬───────────┘
│ Compose 网络 │ host.docker.internal
▼ ▼
┌────────────────────┐ 已有 WeKnora 服务
│ dobotai-postgres │ Agent / 检索 / 知识 / 模型
│ dobotai 独立数据库 │
│ 命名卷持久化 │
└────────────────────┘
```
## 架构与目录
Compose 管理的资源:
| 资源 | 名称 | 数据策略 |
| --- | --- | --- |
| 应用容器 | `dobotai-dingtalk` | 可重建,不保存业务数据卷 |
| PostgreSQL容器 | `dobotai-postgres` | 可重建,数据在命名卷 |
| PostgreSQL命名卷 | `dobotai-postgres-data` | 持久数据,禁止常规删除 |
| Compose网络 | `dobotai-network` | 应用与数据库内部通信 |
| 宿主机密钥目录 | `<DOBOTAI_HOME>/secrets` | bind mount,不进入Git |
关键Compose属性:
```yaml
services:
dobotai-postgres:
container_name: dobotai-postgres
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
dobotai-dingtalk:
container_name: dobotai-dingtalk
depends_on:
dobotai-postgres:
condition: service_healthy
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
```
应用不使用 `network_mode: host`:它以`dobotai-postgres:5432`访问Compose数据库,以`http://host.docker.internal:8080`访问宿主机的WeKnora。
## 3. 自动安装准备
`install-docker.sh`自动完成:
```text
宿主机
├── dobotai-dingtalk 唯一应用容器,host网络
├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本
├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置
├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore
└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷
阿里云RDS
└── DobotAI专用PostgreSQL数据库
检查 Docker、Docker Compose 和已导入的候选镜像
创建 <DOBOTAI_HOME>/container、config、secrets、backups、migrations
生成权限0600的 config/postgres.env 与 config/dobotai.env
生成独立数据库密码、Ingress Token、Admin Token
创建并启动 dobotai-postgres
等待 PostgreSQL healthcheck
按顺序应用 db/migrations/*.up.sql
在 dobotai_schema_migrations 记录版本、名称和时间
安装 configure / start / bind-admin 三条短命令
```
应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。
它不会:
## 安装
```text
启动尚未配置的钉钉应用容器
覆盖已有配置或数据库数据
删除任何 Docker 卷
输出密码、API Key、Token 或连接串
```
克隆完整仓库后,只执行:
默认安装根目录:
```text
/opt/dobotai
├── container/ Compose、脚本、SQL迁移、运行参数
├── config/ dobotai.env、postgres.env(0600)
├── secrets/ DobotAI SecretStore(0700)
└── backups/ 逻辑备份和SHA清单
```
可以用`DOBOTAI_HOME=/绝对路径`改安装位置。测试环境建议使用独立目录,例如`/opt/dobotai-test-compose`。
## 4. 前置条件
```text
部署用户具有 sudo 和 Docker daemon 访问权限
宿主机 WeKnora 已健康,容器可经 host.docker.internal:8080 访问
```
Docker与Docker Compose缺失时由根安装脚本自动安装(当前支持APT系统)。不必手工构建、导入或命名镜像。
## 5. 全新安装
从源码仓库克隆后,进入仓库根目录执行唯一安装入口:
```bash
sudo bash ./install-docker.sh
```
根脚本自动:
脚本内部以当前Git提交构建固定内部镜像名:
```text
检查或安装Docker Engine和Docker Compose v2(APT系统)
以当前Git提交构建 dobotai-dingtalk:local-<Git短SHA>
创建 /opt/dobotai/{container,config,secrets,backups}
复制应用Compose、RDS检查器和21个SQL迁移
生成权限0600的应用环境模板
安装三条短命令
dobotai-dingtalk:local-<当前Git短SHA>
```
它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。
镜像名、PostgreSQL镜像和构建元数据只由根目录`install-docker.sh`维护,用户不传`DOBOTAI_IMAGE`。脚本完成后数据库和迁移已就绪,应用尚未启动。然后执行:
## 配置、RDS检查与启动
安装后执行:
构建期Go依赖使用脚本内置的公开模块代理`https://goproxy.cn,direct`;它只存在于镜像构建命令,不写入DobotAI运行配置。
```bash
sudo dobotai-dingtalk-configure
```
配置器分字段录入阿里云RDS(主机、端口、数据库名、账号、密码),自动组装连接串;WeKnora和钉钉凭证密钥不回显,环境文件原子写入且权限0600。
配置器逐项显示用途和脱敏格式示例;已有值不会回显,直接回车表示保留。数据库连接串、数据库密码、Ingress Token和Admin Token由安装脚本生成,交互配置器不要求输入。
`DOBOTAI_TENANT_KEY`不是WeKnora Agent租户键。它是DobotAI内部数据隔离键,首次安装时自动生成并固定保存;配置器不会要求填写或改动它。WeKnora侧只需要填写实际的两个Agent ID。
主要业务连接配置:
配置器会要求两个不同的WeKnora Agent ID,并自动生成`DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"快速问答Agent ID"}`。`DOBOTAI_INGRESS_TOKEN`是保护本机`POST /v1/qa`内部接口的Bearer Token,不用于钉钉或WeKnora;同样由安装器自动生成。
| 配置 | 示例 | 作用 |
| 变量 | 用途 | 示例 |
| --- | --- | --- |
| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL |
| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora |
| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key |
| `DOBOTAI_QUICK_AGENT_ID` | `<快速问答Agent ID>` | 默认快速问答实际调用的Agent |
| `DOBOTAI_REASONING_AGENT_ID` | `<智能推理Agent ID>` | `/think`和持续智能推理实际调用的Agent |
| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey |
| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret |
| `WEKNORA_BASE_URL` | Docker内访问宿主机WeKnora | `http://host.docker.internal:8080` |
| `WEKNORA_API_KEY` | DobotAI专用WeKnora API Key | `<你的WeKnora API Key>` |
| `DOBOTAI_TENANT_KEY` | DobotAI租户隔离键 | `dobotai` |
| `DOBOTAI_AGENT_BINDINGS` | 渠道到WeKnora Agent映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` |
| `DINGTALK_CLIENT_SECRET` | 钉钉应用Secret/AppSecret | `<你的钉钉 AppSecret>` |
回答图片外发OSS同为分字段录入的可选整组(紧随RDS之后、WeKnora/钉钉凭证之前):
配置器成功后自动启动`dobotai-dingtalk`,并检查PostgreSQL健康、`http://127.0.0.1:8090/healthz`和容器运行状态。
| 配置 | 示例 | 作用 |
| --- | --- | --- |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT` | `https://oss-cn-shenzhen.aliyuncs.com` | 私有Bucket接入地址 |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_REGION` | `cn-shenzhen` | Bucket所在地域 |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET` | `dobot-xxx` | 外发图片专用私有Bucket |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID` | `<AK ID>` | 仅授权该Bucket的AK |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET` | `<AK Secret>` | 仅授权该Bucket的SK(不回显) |
| `DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL` | `https://host/dingtalk/i` | 钉钉可见的稳定HTTPS入口,不含凭据 |
## 7. 首位管理员绑定
OSS规则:六项全部留空即整组禁用图片外发(答案含`resource://`图片时按无图降级);填写任一项则六项必填,缺项拒绝保存。运行时仅改写WeKnora `resource://`引用为短签HTTPS,私有Bucket由稳定基础地址代理,`<img>`外链与`data:`剥除。禁止把OSS凭据写入Git或命令行。
保存后脚本自动执行以下RDS门禁:
```text
1. 使用RDS连接串执行 pg_isready
2. 检查DobotAI核心表
3. 数据库没有任何public表:按000001~000021初始化
4. 所有核心表存在:判定正常,不执行任何DDL
5. 部分表、缺表或非空异常库:拒绝初始化和启动
6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz
```
这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。
## 日常命令
```bash
# 重新检查RDS并启动/升级应用
sudo dobotai-dingtalk-start
# 交互更新RDS、WeKnora、钉钉配置后启动
sudo dobotai-dingtalk-configure
# 仅首位管理员且无有效管理员时使用
sudo dobotai-dingtalk-bind-admin
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
```
## 升级与回滚
应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。
RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。
## 首位管理员
应用和钉钉Stream运行后:
仅在租户没有有效管理员时执行:
```bash
sudo dobotai-dingtalk-bind-admin
```
终端会显示10分钟有效的六码;候选管理员私聊机器人:
终端显示10分钟有效的一次性六码。候选管理员私聊钉钉机器人:
```text
/setting <六码绑定码>
```
已有有效管理员时绑定命令会拒绝,避免提权。
终端自动确认后写入`super_admin/active`。已有管理员时命令拒绝执行,防止意外提权。
## 不执行的操作
## 8. 迁移现有DobotAI实例
旧实例已有Turn、会话、Support Case、管理员或反馈时,不能直接新建空库后切换。先安装目标Compose基础设施,再运行:
```bash
sudo DOBOTAI_HOME=/opt/dobotai-compose \
SOURCE_POSTGRES_CONTAINER=dobotai-test-postgres \
SOURCE_APP_CONTAINER=dobotai-dingtalk \
SOURCE_DOBOTAI_ENV=/etc/dobotai-test/dobotai.env \
/opt/dobotai-compose/container/migrate-existing-database.sh
```
迁移脚本固定顺序:
```text
不创建本地PostgreSQL容器或数据卷
不迁移、删除或覆盖现有本地 dobotai-test-postgres
不创建、删除或重置阿里云RDS数据库
不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志
不重建WeKnora、Neo4j、Redis、Elasticsearch
启动目标 dobotai-postgres 并等待健康
→ 停止并改名旧应用容器(不删除)
→ pg_dump -Fc 导出旧数据库
→ pg_restore --list 验证备份可读
→ 写入备份SHA-256
→ 复制旧应用配置,只替换数据库、监听和WeKnora地址
→ 恢复到 dobotai-postgres-data
→ 应用缺失迁移
→ 启动新 dobotai-dingtalk
→ 健康失败则恢复旧容器名称和旧容器
```
源数据库不会删除。成功后旧应用容器会以`*-legacy-*`保留,确认稳定前不得删除旧容器或旧数据库。
## 9. 数据库迁移
DobotAI应用本身不扫描SQL。安装/迁移流程由:
```bash
sudo /opt/dobotai/container/apply-migrations.sh
```
执行`migrations/*.up.sql`,每个成功迁移在同一事务写入:
```text
dobotai_schema_migrations(version, name, applied_at)
```
任何数据库升级前都必须有经`pg_restore --list`验证的逻辑备份。`.down.sql`只能在审查通过后用于数据库回滚,不能用应用镜像回滚替代数据库回滚。
## 10. 日常运行与升级
```bash
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
# Compose状态
sudo docker compose --env-file /opt/dobotai/container/runtime.env \
-f /opt/dobotai/container/compose.yaml ps
# 日志:不要转发含用户内容或凭据的原始上下文
sudo docker logs --since 10m dobotai-dingtalk
# 数据库状态
sudo docker inspect -f '{{.State.Health.Status}}|{{.RestartCount}}' dobotai-postgres
```
升级顺序:导入候选镜像 → 备份并应用必要迁移 → 更新`runtime.env`中的`DOBOTAI_IMAGE` → 执行:
```bash
sudo dobotai-dingtalk-start
```
应用健康失败时启动器恢复上一应用镜像;数据库迁移仍必须按审查过的SQL回滚流程处理。
## 11. 覆盖判定边界
```text
有结构化引用 → covered_has_references
实际检索过但没有引用 → uncovered_no_references → Support Case
明确无权威知识/无答案 → uncovered_explicit_no_answer → Support Case
图片未检索、纯视觉识别 → covered_visual_recognition
文本未检索、闲聊/普通非知识响应 → covered_non_knowledge_response
```
因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会创建Support Case或发送未覆盖提醒。
## 12. 禁止操作
```text
docker compose down -v
docker volume rm dobotai-postgres-data
docker system prune --volumes
把 config/*.env、secrets/、备份Dump、真实Token或凭据提交Git
在生产重新编译或使用未经测试的镜像
```
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
migrations_dir="$container_dir/migrations"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" || ! -d "$migrations_dir" ]]; then
echo "迁移运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d --no-build dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 未健康,拒绝迁移。" >&2; exit 1; }
psql_db() {
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai "$@"
}
psql_db -c 'CREATE TABLE IF NOT EXISTS dobotai_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())'
for file in "$migrations_dir"/*.up.sql; do
[[ -f "$file" ]] || continue
base="$(basename "$file")"
version_text="${base%%_*}"
version=$((10#$version_text))
name="${base#*_}"
name="${name%.up.sql}"
case "$name" in *[!a-z0-9_]* ) echo "非法迁移文件名:$base" >&2; exit 1;; esac
applied=$(psql_db -At -c "SELECT EXISTS(SELECT 1 FROM dobotai_schema_migrations WHERE version=$version)")
[[ "$applied" == "t" ]] && continue
{
printf 'BEGIN;\n'
cat "$file"
printf "\nINSERT INTO dobotai_schema_migrations(version,name) VALUES (%s,'%s');\nCOMMIT;\n" "$version" "$name"
} | "${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai
echo "已应用迁移:$base"
done
+36 -2
View File
@@ -1,9 +1,28 @@
services:
dobotai-postgres:
image: ${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}
container_name: dobotai-postgres
env_file:
- ${DOBOTAI_POSTGRES_ENV_FILE:?set DOBOTAI_POSTGRES_ENV_FILE in runtime.env}
volumes:
- dobotai-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 30
restart: unless-stopped
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
dobotai-dingtalk:
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
container_name: dobotai-dingtalk
network_mode: host
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
depends_on:
dobotai-postgres:
condition: service_healthy
env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment:
@@ -13,6 +32,10 @@ services:
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets
read_only: false
ports:
- "127.0.0.1:8090:8090"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
init: true
read_only: true
@@ -20,3 +43,14 @@ services:
- /tmp:rw,noexec,nosuid,size=32m
security_opt:
- no-new-privileges:true
networks:
- dobotai-network
volumes:
dobotai-postgres-data:
name: dobotai-postgres-data
networks:
dobotai-network:
name: dobotai-network
driver: bridge
+61 -101
View File
@@ -1,121 +1,81 @@
#!/usr/bin/env python3
import argparse
import getpass
import json
import os
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit
import subprocess
import sys
FIELDS = (
("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True),
("DOBOTAI_QUICK_AGENT_ID", "快速问答Agent ID", "默认问答实际调用的WeKnora Agent。", "<快速问答Agent ID>", False),
("DOBOTAI_REASONING_AGENT_ID", "智能推理Agent ID", "/think和持续智能推理实际调用的WeKnora Agent。", "<智能推理Agent ID>", False),
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
("WEKNORA_BASE_URL", "WeKnora 地址", "Docker 容器访问宿主机 WeKnora 时使用 host.docker.internal。", "http://host.docker.internal:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "在 WeKnora 中为 DobotAI 创建的独立 API Key。", "<你的WeKnora API Key>", True),
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True),
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "同一 DobotAI 数据库内的渠道隔离键。", "dobotai", False),
("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", "钉钉会话匹配到 WeKnora Agent 的映射。", '{"dingtalk:*":"你的Agent ID"}', False),
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用的 Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用的 Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
)
def prompt_rds(values):
"""分字段录入阿里云RDS并组装DATABASE_URL;已有值作为默认。"""
existing = values.get("DATABASE_URL", "").strip()
host = port = database = user = password = ""
if existing:
u = urlsplit(existing)
host = u.hostname or ""
port = str(u.port or "") if u.port else ""
database = u.path.lstrip("/")
user = unquote(u.username or "")
password = unquote(u.password or "")
def ask(key, label, default, secret=False):
suffix = "(已配置,直接回车保留)" if default else "(必填)"
reader = getpass.getpass if secret else input
entered = reader(f"{label}{suffix}: ").strip()
return entered or default
print("\n阿里云RDS PostgreSQL(分字段填写)\n用途:DobotAI专用数据库;空库自动初始化,已有表只校验不改结构。")
host = ask("host", "RDS主机地址", host)
port = ask("port", "RDS端口", port or "5432")
database = ask("database", "数据库名", database)
user = ask("user", "数据库账号", user)
password = ask("password", "数据库密码", password, secret=True)
if not all((host, port, database, user, password)):
raise SystemExit("RDS字段不完整,未保存。")
if not database.startswith(("dobotai", "dobotai_")):
print("警告:数据库名不是DobotAI专用库,继续前请确认。")
values["DATABASE_URL"] = f"postgres://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{database}"
OSS_FIELDS = (
("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "OSS Endpoint", "私有Bucket的接入地址。", "https://oss-cn-shenzhen.aliyuncs.com", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION", "OSS Region", "Bucket所在地域。", "cn-shenzhen", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "OSS Bucket", "外发图片专用私有Bucket。", "dobot-xxx", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "OSS AccessKey ID", "仅授权该Bucket的AK。", "<AK ID>", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET", "OSS AccessKey Secret", "仅授权该Bucket的SK。", "<AK Secret>", True),
("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "图片稳定外发基础地址", "钉钉可见的稳定HTTPS入口,不含凭据。", "https://host/dingtalk/i", False),
)
def prompt_oss(values):
"""OSS整组录入:全部留空即禁用;任一项填写则六项必填。"""
print("\n回答图片外发OSS(可选,OSS整组录入;直接回车表示整组禁用)")
entered = {}
for key, label, description, example, secret in OSS_FIELDS:
default = values.get(key, "").strip()
suffix = "(已配置,直接回车保留)" if default else ""
print(f"用途:{description} 格式示例:{example}")
reader = getpass.getpass if secret else input
value = reader(f"{label}{suffix}: ").strip()
entered[key] = value or default
if not any(entered.values()):
for key, *_ in OSS_FIELDS:
values.pop(key, None)
return
missing = [label for key, label, *_ in OSS_FIELDS if not entered[key]]
if missing:
raise SystemExit("OSS为整组可选能力,以下项未填写:" + "、".join(missing))
values.update(entered)
def parse_env(path):
lines = path.read_text(encoding="utf-8").splitlines(); values = {}
def parse_env(path: Path):
lines = path.read_text(encoding="utf-8").splitlines()
values = {}
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key, value = line.split("=", 1); values[key.strip()] = value
key, value = line.split("=", 1)
values[key.strip()] = value
return lines, values
def write_env(path, lines, values):
rendered=[]; written=set()
def prompt(label: str, description: str, example: str, secret: bool, configured: bool) -> str:
suffix = "(已配置,直接回车保留)" if configured else "(必填)"
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
if secret:
return getpass.getpass(f"{label}{suffix}: ").strip()
return input(f"{label}{suffix}: ").strip()
def write_env(path: Path, lines, values):
rendered = []
written = set()
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key=line.split("=",1)[0].strip()
if key in values: rendered.append(f"{key}={values[key]}"); written.add(key); continue
key = line.split("=", 1)[0].strip()
if key in values:
rendered.append(f"{key}={values[key]}")
written.add(key)
continue
rendered.append(line)
rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written)
temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path)
for key, value in values.items():
if key not in written:
rendered.append(f"{key}={value}")
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
def main():
parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证")
parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True)
args=parser.parse_args(); path=Path(args.env_file)
if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。")
lines,values=parse_env(path)
prompt_rds(values)
prompt_oss(values)
for key,label,description,example,secret in FIELDS:
suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)"
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").strip()
if entered: values[key]=entered
missing=[label for key,label,*_ in FIELDS if not values.get(key,"").strip()]
if missing: raise SystemExit("以下必填项未配置:"+"、".join(missing))
if values["DOBOTAI_QUICK_AGENT_ID"] == values["DOBOTAI_REASONING_AGENT_ID"]:
raise SystemExit("快速问答Agent ID与智能推理Agent ID必须不同。")
values["DOBOTAI_AGENT_BINDINGS"] = json.dumps({"dingtalk:*": values["DOBOTAI_QUICK_AGENT_ID"]}, separators=(",", ":"))
write_env(path,lines,values)
subprocess.run([args.schema_script], check=True)
os.execv(args.start_script,[args.start_script])
if __name__=="__main__":
try: main()
except KeyboardInterrupt: sys.exit(130)
parser = argparse.ArgumentParser(description="配置 DobotAI Docker 应用连接")
parser.add_argument("--env-file", required=True)
parser.add_argument("--start-script", required=True)
args = parser.parse_args()
env_file = Path(args.env_file)
if not env_file.is_file():
raise SystemExit("DobotAI 环境文件不存在,请先完成 Docker 安装。")
lines, values = parse_env(env_file)
for key, label, description, example, secret in FIELDS:
entered = prompt(label, description, example, secret, bool(values.get(key, "").strip()))
if entered:
values[key] = entered
missing = [label for key, label, _, _, _ in FIELDS if not values.get(key, "").strip()]
if missing:
raise SystemExit("以下必填项未配置:" + "、".join(missing))
write_env(env_file, lines, values)
os.execv(args.start_script, [args.start_script])
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then echo "DobotAI容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2; exit 1; fi
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then
echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2
exit 1
fi
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
+41 -6
View File
@@ -1,23 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
health_url="http://127.0.0.1:8090/healthz"
[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; }
"$container_dir/rds-schema-init.sh"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
echo "Docker 运行文件未安装。请先执行 install-docker.sh。" >&2
exit 1
fi
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
previous_image="$(docker inspect --format '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
set_runtime_image() {
local image="$1"
python3 - "$runtime_env" "$image" <<'PY'
from pathlib import Path
import os
import sys
path = Path(sys.argv[1])
image = sys.argv[2]
lines = path.read_text(encoding="utf-8").splitlines()
rendered = ["DOBOTAI_IMAGE=" + image if line.startswith("DOBOTAI_IMAGE=") else line for line in lines]
if not any(line.startswith("DOBOTAI_IMAGE=") for line in lines):
rendered.append("DOBOTAI_IMAGE=" + image)
temporary = path.with_name(path.name + ".new")
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
os.chmod(temporary, 0o600)
os.replace(temporary, path)
PY
}
rollback() {
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi
if [[ -n "$previous_image" ]]; then
set_runtime_image "$previous_image"
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true
fi
}
trap rollback ERR
"${compose[@]}" up -d --no-build dobotai-postgres
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then trap - ERR; echo "DobotAI容器已启动:dobotai-dingtalk"; exit 0; fi
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
trap - ERR
echo "DobotAI 容器已启动:dobotai-dingtalk"
exit 0
fi
sleep 1
done
echo "DobotAI健康检查失败,已恢复上一应用容器(如存在)。" >&2
echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像(如存在)。" >&2
exit 1
+83 -38
View File
@@ -9,68 +9,113 @@ config_dir="$home/config"
secret_dir="$home/secrets"
backup_dir="$home/backups"
app_env="$config_dir/dobotai.env"
postgres_env="$config_dir/postgres.env"
runtime_env="$container_dir/runtime.env"
image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to a reviewed DobotAI image tag}"
postgres_image="${DOBOTAI_POSTGRES_IMAGE:-postgres:16-alpine}"
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行 Docker 安装脚本。" >&2
exit 1
fi
command -v docker >/dev/null || { echo "Docker 未安装。" >&2; exit 1; }
docker compose version >/dev/null || { echo "Docker Compose 未安装。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "候选应用镜像不存在:$image" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir"
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
install -d -m 0750 "$container_dir/migrations"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
install -m 0750 "$script_dir/rds-schema-init.sh" "$container_dir/rds-schema-init.sh"
install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start"
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin"
install -m 0750 "$script_dir/apply-migrations.sh" "$container_dir/apply-migrations.sh"
install -m 0750 "$script_dir/migrate-existing-database.sh" "$container_dir/migrate-existing-database.sh"
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
install -m 0644 "$source_root"/db/migrations/*.down.sql "$container_dir/migrations/"
if [[ ! -f "$app_env" ]]; then
if [[ ! -f "$postgres_env" || ! -f "$app_env" ]]; then
umask 077
read -r tenant_suffix ingress_token < <(python3 -c 'import secrets; print(secrets.token_hex(12), secrets.token_urlsafe(32))')
cat > "$app_env" <<'EOF'
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DATABASE_URL=
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_AGENT_BINDINGS=
DOBOTAI_QUICK_AGENT_ID=
DOBOTAI_REASONING_AGENT_ID=
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
EOF
printf 'DOBOTAI_TENANT_KEY=dobotai-%s\n' "$tenant_suffix" >> "$app_env"
printf 'DOBOTAI_INGRESS_TOKEN=%s\n' "$ingress_token" >> "$app_env"
chmod 0600 "$app_env"
python3 - "$postgres_env" "$app_env" <<'PY'
from pathlib import Path
import os
import secrets
import sys
postgres_env = Path(sys.argv[1])
app_env = Path(sys.argv[2])
password = secrets.token_urlsafe(32)
ingress = secrets.token_urlsafe(32)
admin = secrets.token_urlsafe(32)
postgres_env.write_text(
"POSTGRES_DB=dobotai\n"
"POSTGRES_USER=dobotai\n"
f"POSTGRES_PASSWORD={password}\n",
encoding="utf-8",
)
os.chmod(postgres_env, 0o600)
app_env.write_text(
"DOBOTAI_LISTEN_ADDR=0.0.0.0:8090\n"
f"DATABASE_URL=postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable\n"
f"DOBOTAI_INGRESS_TOKEN={ingress}\n"
f"DOBOTAI_ADMIN_TOKEN={admin}\n"
"DOBOTAI_GROUP_SESSION_POLICY=shared\n"
"DOBOTAI_TENANT_KEY=dobotai\n"
"DOBOTAI_AGENT_BINDINGS=\n"
"DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer\n"
"DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning\n"
"WEKNORA_BASE_URL=http://host.docker.internal:8080\n"
"WEKNORA_API_KEY=\n"
"DINGTALK_CLIENT_ID=\n"
"DINGTALK_CLIENT_SECRET=\n",
encoding="utf-8",
)
os.chmod(app_env, 0o600)
PY
fi
umask 077
cat > "$runtime_env" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_RUNTIME_UID=10001
DOBOTAI_RUNTIME_GID=10001
DOBOTAI_POSTGRES_IMAGE=$postgres_image
DOBOTAI_ENV_FILE=$app_env
DOBOTAI_POSTGRES_ENV_FILE=$postgres_env
DOBOTAI_SECRET_DIR_HOST=$secret_dir
DOBOTAI_MIGRATIONS_DIR=$container_dir/migrations
EOF
chmod 0600 "$runtime_env"
install -d -m 0755 "$bin_dir"
for command in configure start bind-admin; do
target="$bin_dir/dobotai-dingtalk-$command"
cat > "$target" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-$command'
EOF
chmod 0750 "$target"
done
# configure is a Python entrypoint rather than a shell asset.
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --schema-script '$container_dir/rds-schema-init.sh' --start-script '$bin_dir/dobotai-dingtalk-start'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --start-script '$bin_dir/dobotai-dingtalk-start'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure"
cat > "$bin_dir/dobotai-dingtalk-start" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-start'
EOF
cat > "$bin_dir/dobotai-dingtalk-bind-admin" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-bind-admin'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure" "$bin_dir/dobotai-dingtalk-start" "$bin_dir/dobotai-dingtalk-bind-admin"
echo "应用容器资产已安装;尚未连接或修改阿里云RDS。"
compose=(docker compose --env-file "$runtime_env" -f "$container_dir/compose.yaml")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "PostgreSQL 初始化失败。" >&2; exit 1; }
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
echo "Docker 基础设施已准备完成:数据库与迁移已就绪,应用尚未启动。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
+1 -51
View File
@@ -1,54 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行安装脚本。" >&2
exit 1
fi
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "候选镜像不存在:$image" >&2
exit 1
fi
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
exit 1
fi
uid="$(id -u "$runtime_user")"
gid="$(id -g "$runtime_user")"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
backup_dir="$backup_root/pre-container-$stamp"
install -d -m 0700 "$backup_dir" "$container_dir"
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
runtime_tmp="$container_dir/runtime.env.new"
umask 077
cat > "$runtime_tmp" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_RUNTIME_UID=$uid
DOBOTAI_RUNTIME_GID=$gid
DOBOTAI_ENV_FILE=$env_file
DOBOTAI_SECRET_DIR_HOST=$secret_dir
EOF
chmod 0600 "$runtime_tmp"
mv -f "$runtime_tmp" "$container_dir/runtime.env"
echo "容器资产已安装,旧服务仍在运行。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
exec "$script_dir/install-docker.sh" "$@"
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
runtime_env="$container_dir/runtime.env"
compose_file="$container_dir/compose.yaml"
source_container="${SOURCE_POSTGRES_CONTAINER:-dobotai-test-postgres}"
source_app_container="${SOURCE_APP_CONTAINER:-dobotai-dingtalk}"
source_env="${SOURCE_DOBOTAI_ENV:-/etc/dobotai-test/dobotai.env}"
app_env="$config_dir/dobotai.env"
backup_dir="$home/backups"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
legacy_app="${source_app_container}-legacy-${stamp}"
[[ ${EUID} -eq 0 ]] || { echo "请使用 sudo 执行现有数据库迁移脚本。" >&2; exit 1; }
[[ -r "$runtime_env" && -r "$compose_file" && -r "$source_env" ]] || { echo "运行文件或旧环境文件不存在。请先执行 install-docker.sh。" >&2; exit 1; }
docker inspect "$source_container" >/dev/null 2>&1 || { echo "源 PostgreSQL 容器不存在:$source_container" >&2; exit 1; }
docker inspect "$source_app_container" >/dev/null 2>&1 || { echo "源 DobotAI 容器不存在:$source_app_container" >&2; exit 1; }
source_pg_user=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_USER=')), ''))")
source_pg_db=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$source_container" | python3 -c "import sys; print(next((x.strip().split('=',1)[1] for x in sys.stdin if x.startswith('POSTGRES_DB=')), ''))")
test -n "$source_pg_user" && test -n "$source_pg_db"
install -d -m 0700 "$backup_dir"
dump="$backup_dir/pre-compose-adoption-${stamp}.dump"
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
"${compose[@]}" up -d dobotai-postgres
for _ in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' dobotai-postgres 2>/dev/null || true)
[[ "$status" == "healthy" ]] && break
sleep 1
done
[[ "${status:-}" == "healthy" ]] || { echo "目标 PostgreSQL 未健康。" >&2; exit 1; }
old_running=$(docker inspect -f '{{.State.Running}}' "$source_app_container")
rollback() {
docker compose --env-file "$runtime_env" -f "$compose_file" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if docker inspect "$legacy_app" >/dev/null 2>&1; then
docker rename "$legacy_app" "$source_app_container" || true
[[ "$old_running" == "true" ]] && docker start "$source_app_container" >/dev/null 2>&1 || true
fi
}
trap rollback ERR
[[ "$old_running" == "true" ]] && docker stop "$source_app_container" >/dev/null
docker rename "$source_app_container" "$legacy_app"
docker exec -e PGUSER="$source_pg_user" "$source_container" pg_dump -Fc --no-owner -d "$source_pg_db" > "$dump"
test -s "$dump"
docker exec -i dobotai-postgres pg_restore --list < "$dump" >/dev/null
sha256sum "$dump" > "$dump.sha256"
# Replace only deployment-managed endpoints; preserve all existing application credentials.
python3 - "$source_env" "$app_env" "$config_dir/postgres.env" <<'PY'
from pathlib import Path
import os
import sys
source, target, postgres_env = map(Path, sys.argv[1:])
password = next(line.split('=', 1)[1].strip() for line in postgres_env.read_text().splitlines() if line.startswith('POSTGRES_PASSWORD='))
values = {}
for line in source.read_text().splitlines():
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
values[key] = value
values['DOBOTAI_LISTEN_ADDR'] = '0.0.0.0:8090'
values['DATABASE_URL'] = f'postgres://dobotai:{password}@dobotai-postgres:5432/dobotai?sslmode=disable'
values['WEKNORA_BASE_URL'] = 'http://host.docker.internal:8080'
temporary = target.with_name(target.name + '.new')
temporary.write_text('\n'.join(f'{k}={v}' for k, v in values.items()) + '\n', encoding='utf-8')
os.chmod(temporary, 0o600)
os.replace(temporary, target)
PY
"${compose[@]}" exec -T dobotai-postgres psql -v ON_ERROR_STOP=1 -U dobotai -d dobotai -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
docker exec -i dobotai-postgres pg_restore -U dobotai -d dobotai --no-owner < "$dump"
DOBOTAI_HOME="$home" "$container_dir/apply-migrations.sh"
"${compose[@]}" up -d --no-build dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 http://127.0.0.1:8090/healthz >/dev/null; then
trap - ERR
echo "现有DobotAI数据库已迁移到 dobotai-postgres-data;旧应用容器保留为 $legacy_app。"
exit 0
fi
sleep 1
done
echo "迁移后的应用健康检查失败,正在恢复旧容器。" >&2
exit 1
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
app_env="$home/config/dobotai.env"
migrations_dir="$home/container/migrations"
[[ -r "$app_env" && -d "$migrations_dir" ]] || { echo "RDS检查资产未安装。" >&2; exit 1; }
temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT
python3 - "$app_env" "$temp_env" <<'PY'
from pathlib import Path
import os,sys
values={}
for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines():
if line and not line.startswith('#') and '=' in line:
key,value=line.split('=',1); values[key]=value
url=values.get('DATABASE_URL','').strip()
if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。')
Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600)
PY
rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; }
rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' || true
if ! probe_error="$(rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL" -c "SELECT 1"' 2>&1)"; then
case "$probe_error" in
*"does not exist"*|*"不存在"*)
echo "阿里云RDS目标数据库不存在;请RDS管理员创建DobotAI专用数据库并授予应用账号连接和建表权限。" >&2
;;
*)
echo "无法连接阿里云RDS目标数据库;请管理员检查网络、白名单、TLS、账号权限和连接串。" >&2
;;
esac
exit 1
fi
sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at)
case "$state" in
normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;;
inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;;
empty) echo "阿里云RDS为空,开始初始化DobotAI表。";;
*) echo "无法判定阿里云RDS表状态。" >&2; exit 1;;
esac
for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql
echo "阿里云RDS初始化完成。"
+6 -5
View File
@@ -1,6 +1,7 @@
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
# 非敏感运行参数;由 install-docker.sh 生成实际文件。
DOBOTAI_IMAGE=dobotai-dingtalk:test
DOBOTAI_RUNTIME_UID=996
DOBOTAI_RUNTIME_GID=988
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
DOBOTAI_POSTGRES_IMAGE=postgres:16-alpine
DOBOTAI_ENV_FILE=/opt/dobotai/config/dobotai.env
DOBOTAI_POSTGRES_ENV_FILE=/opt/dobotai/config/postgres.env
DOBOTAI_SECRET_DIR_HOST=/opt/dobotai/secrets
DOBOTAI_MIGRATIONS_DIR=/opt/dobotai/container/migrations
-5
View File
@@ -1,5 +0,0 @@
\pset pager off
SELECT table_schema || '.' || table_name AS t
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1;
-8
View File
@@ -1,8 +0,0 @@
\pset pager off
\echo == columns ==
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'channel_actors'
ORDER BY ordinal_position;
\echo == rows ==
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Prod-server switch to the combined release:
# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics
# + wide-screen (autoLayout) card payload
# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh.
#
# Usage: bash prod_switch_reasoning_widescreen.sh <new-image-ref>
#
# Safety: requires the new image locally; tags the running image as a rollback tag;
# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears
# exactly once before and once after the edit and the line count is unchanged;
# refuses unless the new binary carries every release literal.
# Prints only image refs / variable names / literal names - never env values.
set -euo pipefail
NEW_IMAGE="${1:-}"
[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; }
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
BACKUP_DIR="/opt/dobotai/container/backups"
COMPOSE_SERVICE="dobotai-dingtalk"
RELEASE_SLUG="reasoning-widescreen"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')"
ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; }
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then
echo "literal $lit: PRESENT"
else
echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2
exit 1
fi
done
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")"
install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP"
[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV"
echo "backup=$BACKUP mode=$ORIG_MODE"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
chmod "$ORIG_MODE" "$tmp"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
echo "== readback =="
sleep 4
curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo
docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "running literal $lit: PRESENT"
else
echo "running literal $lit: ABSENT"
fi
done
echo "== log scan (since 90s) =="
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines"
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6
echo "== rollback (one command, run on this host) =="
echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE"
echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# Test-server switch to the build that delivers card images as DingTalk media ids.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback at
# the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# Test-server switch to the wide-screen (autoLayout) card image.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback
# at the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Generic test-server image switch: rollback tag first, 0600 runtime.env backup,
# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count),
# container recreate, then read-only readback incl. binary literal presence.
# Usage: bash test_switch_image.sh <image:tag> <label>
set -euo pipefail
NEW_IMAGE="${1:?usage: test_switch_image.sh <image:tag> <label>}"
LABEL="${2:?usage: test_switch_image.sh <image:tag> <label>}"
RUNTIME_ENV=/opt/dobotai/container/runtime.env
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-$LABEL"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR=/opt/dobotai/container/backups
BACKUP="$BACKUP_DIR/runtime.env.before-$LABEL.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
[ "$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" = "1" ] || { echo "ABORT: DOBOTAI_IMAGE line count != 1" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in PartialFeed autoLayout 智能推理模式; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -8
echo "rollback: set DOBOTAI_IMAGE=$OLD_IMAGE in $RUNTIME_ENV (backup $BACKUP) then: docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk"
+40 -13
View File
@@ -1,19 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# 唯一安装入口:镜像仓库名与标签策略只在本脚本维护。
postgres_image="postgres:16-alpine"
if [[ ${EUID} -ne 0 ]]; then
echo "请在仓库根目录执行:sudo bash ./install-docker.sh" >&2
echo "请在克隆仓库根目录执行:sudo bash ./install-docker.sh" >&2
exit 1
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || { echo "必须从完整DobotAI源码仓库根目录运行。" >&2; exit 1; }
command -v git >/dev/null || { echo "未找到git。" >&2; exit 1; }
git rev-parse --is-inside-work-tree >/dev/null || { echo "请从git clone得到的仓库中执行。" >&2; exit 1; }
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || {
echo "安装脚本必须从完整DobotAI源码仓库根目录运行。" >&2
exit 1
}
command -v git >/dev/null || { echo "未找到 git,无法确定当前源码版本。" >&2; exit 1; }
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null || {
echo "请从 git clone 得到的仓库中执行本脚本。" >&2
exit 1
}
install_docker_if_missing() {
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return; fi
command -v apt-get >/dev/null || { echo "请先安装Docker Engine和Docker Compose v2。" >&2; exit 1; }
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
return
fi
command -v apt-get >/dev/null || {
echo "本脚本仅自动安装基于 apt 的 Docker;请先安装 Docker Engine 和 Docker Compose v2。" >&2
exit 1
}
export DEBIAN_FRONTEND=noninteractive
apt-get update
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
@@ -21,20 +36,32 @@ install_docker_if_missing() {
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-plugin
else
echo "APT源缺少Docker Compose v2插件。" >&2; exit 1
echo "当前APT源没有 Docker Compose v2 插件,无法安全继续。" >&2
exit 1
fi
systemctl enable --now docker
docker compose version >/dev/null
}
install_docker_if_missing
docker compose version >/dev/null
commit_sha="$(git rev-parse HEAD)"
commit_short="$(git rev-parse --short=12 HEAD)"
commit_sha="$(git -C "$repo_root" rev-parse HEAD)"
commit_short="$(git -C "$repo_root" rev-parse --short=12 HEAD)"
build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
image="dobotai-dingtalk:local-${commit_short}"
echo "[1/2] 构建DobotAI应用镜像:$image"
docker build --build-arg "GOPROXY=https://goproxy.cn,direct" --build-arg "VERSION=local-${commit_short}" --build-arg "COMMIT_SHA=${commit_sha}" --build-arg "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root"
echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化"
echo "[1/3] 构建 DobotAI 镜像:$image"
docker build \
--build-arg "GOPROXY=https://goproxy.cn,direct" \
--build-arg "VERSION=local-${commit_short}" \
--build-arg "COMMIT_SHA=${commit_sha}" \
--build-arg "BUILD_TIME=${build_time}" \
--label "org.opencontainers.image.revision=${commit_sha}" \
--tag "$image" \
"$repo_root"
echo "[2/3] 准备独立PostgreSQL、目录、配置文件和迁移"
export DOBOTAI_IMAGE="$image"
export DOBOTAI_POSTGRES_IMAGE="$postgres_image"
export DOBOTAI_SOURCE_ROOT="$repo_root"
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
-19
View File
@@ -21,24 +21,6 @@ const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
// cardWideScreenJSONKey is DingTalk's reserved system field inside
// cardData.cardParamMap. Template variables whose value is a complex type
// (object / array of objects) must be passed through this field as a JSON
// string; plain string/number/boolean variables go in cardParamMap
// directly. Putting a complex value anywhere else (for example as a
// sibling of cardParamMap under cardData) leaves the template variable
// unassigned and silently falls back to the default layout.
cardWideScreenJSONKey = "sys_full_json_obj"
// cardWideScreenJSON configures the AI card's root "config": PC clients
// render the card wide (auto layout) when autoLayout is true. The card
// template must declare an object variable "config" with a boolean child
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
// passed when the card is created - setting it statically inside the
// template does not take effect. Mobile clients keep their own adaptive
// layout, so this only widens the card on desktop.
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
)
// CardRequest is the input for sending a final AI card.
@@ -247,7 +229,6 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
cardWideScreenJSONKey: cardWideScreenJSON,
},
},
"userIdType": 1,
+1 -56
View File
@@ -3,12 +3,8 @@ package dingtalk
import (
"fmt"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
const reasoningBannerMarker = "【智能推理模式】"
type cardContentShape struct {
bytes int
lines int
@@ -17,63 +13,20 @@ type cardContentShape struct {
delimiterLines int
confirmedTable bool
confirmedTablePos int
// Structural split state: where the answer body starts inside this exact
// payload. bodyPrefixRunes > 0 means the update still carries narration in
// front of the body (the reasoning text the card is supposed to hide).
bodyOK bool
bodyLine int
bodyPrefixRunes int
bannerPos int
firstHeadingLine int
imageRefs int
externalImageRefs int
mediaImageRefs int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
bodyLine: -1,
firstHeadingLine: -1,
bannerPos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
shape.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content)
shape.bannerPos = strings.Index(content, reasoningBannerMarker)
// The streaming path decorates every frame with the reasoning banner, so the
// prefix in front of the body has to be measured after that banner: only
// what remains is narration the card is meant to hide.
bannerSkip := 0
if shape.bannerPos == 0 {
if idx := strings.Index(content, "\n"); idx >= 0 {
bannerSkip = idx + 1
for bannerSkip < len(content) && content[bannerSkip] == '\n' {
bannerSkip++
}
}
}
if offset, ok := reasoningmode.BodyStartOffset(content); ok {
shape.bodyOK = true
shape.bodyLine = strings.Count(content[:offset], "\n")
prefix := content[:offset]
if bannerSkip > 0 && bannerSkip <= len(prefix) {
prefix = prefix[bannerSkip:]
}
shape.bodyPrefixRunes = len([]rune(prefix))
}
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
trimmed := strings.TrimSpace(line.text)
if shape.firstHeadingLine < 0 && !line.inFence &&
strings.HasPrefix(trimmed, "#") && strings.TrimLeft(trimmed, "#") != "" {
shape.firstHeadingLine = i
}
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
@@ -97,7 +50,7 @@ func inspectCardContent(content string) cardContentShape {
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
shape := inspectCardContent(content)
return fmt.Sprintf(
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d image_refs=%d external_image_refs=%d media_image_refs=%d",
"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,
@@ -107,13 +60,5 @@ func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
shape.bodyOK,
shape.bodyLine,
shape.bodyPrefixRunes,
shape.bannerPos,
shape.firstHeadingLine,
shape.imageRefs,
shape.externalImageRefs,
shape.mediaImageRefs,
)
}
@@ -1,488 +0,0 @@
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"strings"
"sync"
"time"
)
// DingTalk AI cards only render images that live in DingTalk's own media store:
// an external HTTPS image URL shows up in a normal markdown message but is
// dropped inside a streaming/short-card body (verified on both desktop and
// mobile, 2026-09-17). The card content therefore rewrites our own published
// image URLs into DingTalk media_id values obtained through the legacy
// oapi media upload endpoint, which both clients render.
const (
defaultCardImageTokenURL = "https://oapi.dingtalk.com/gettoken"
defaultCardImageUploadURL = "https://oapi.dingtalk.com/media/upload"
defaultCardImageMediaTTL = 12 * time.Hour
defaultCardImageWaitTimeout = 5 * time.Second
defaultCardImageFetchTimeout = 15 * time.Second
defaultCardImageUploadTimeout = 20 * time.Second
defaultCardImageMaxBytes = 8 << 20
cardImageTokenRefreshMargin = 5 * time.Minute
maxConcurrentCardImageUploads = 3
)
// cardImageStableIDRE matches the deterministic image id produced by
// outboundimage.StableHashPublisher (UUIDv5 shaped, lowercase hex).
var cardImageStableIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
// cardMarkdownImageRE captures the URL of a complete markdown image, including
// the optional title slot. Incomplete image syntax (still streaming) is not
// matched, so it is never rewritten.
var cardMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)\r\n]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^)\r\n]*\)))?\)`)
// CardImageMediaRewriter converts card-body image references into DingTalk
// media ids. Implementations must be safe for concurrent use and must never
// fail a card update: on any error the original reference is kept.
type CardImageMediaRewriter interface {
RewriteForCard(ctx context.Context, content string, wait bool) string
}
// CardImageMediaOptions configures the resolver. ClientID/ClientSecret are the
// same Robot app credentials used for access tokens, and ImageURLPrefix is the
// public prefix of our own published images.
type CardImageMediaOptions struct {
ClientID string
ClientSecret string
ImageURLPrefix string
// FetchURLPrefix optionally replaces ImageURLPrefix when downloading the
// bytes back (for example a loopback address), so the upload never depends
// on the container being able to reach its own public host name.
FetchURLPrefix string
TokenURL string
UploadURL string
HTTPClient *http.Client
TTL time.Duration
WaitTimeout time.Duration
MaxBytes int64
}
type cardImageMediaEntry struct {
mediaID string
expiresAt time.Time
}
type cardImageMediaJob struct {
done chan struct{}
mediaID string
err error
}
type cardImageMediaResolver struct {
clientID string
clientSecret string
imageURLPrefix string
fetchURLPrefix string
tokenURL string
uploadURL string
client *http.Client
ttl time.Duration
waitTimeout time.Duration
maxBytes int64
sem chan struct{}
now func() time.Time
mu sync.Mutex
entries map[string]cardImageMediaEntry
inflight map[string]*cardImageMediaJob
token string
tokenExpiry time.Time
}
// NewCardImageMediaRewriter builds the resolver. It returns a nil rewriter and
// no error when the feature is not configured, which keeps the caller simple.
func NewCardImageMediaRewriter(opts CardImageMediaOptions) (CardImageMediaRewriter, error) {
clientID := strings.TrimSpace(opts.ClientID)
clientSecret := strings.TrimSpace(opts.ClientSecret)
prefix := strings.TrimSpace(opts.ImageURLPrefix)
if clientID == "" || clientSecret == "" || prefix == "" {
return nil, nil
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
tokenURL := strings.TrimSpace(opts.TokenURL)
if tokenURL == "" {
tokenURL = defaultCardImageTokenURL
}
uploadURL := strings.TrimSpace(opts.UploadURL)
if uploadURL == "" {
uploadURL = defaultCardImageUploadURL
}
ttl := opts.TTL
if ttl <= 0 {
ttl = defaultCardImageMediaTTL
}
waitTimeout := opts.WaitTimeout
if waitTimeout <= 0 {
waitTimeout = defaultCardImageWaitTimeout
}
maxBytes := opts.MaxBytes
if maxBytes <= 0 {
maxBytes = defaultCardImageMaxBytes
}
client := opts.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
client = &http.Client{Timeout: defaultCardImageFetchTimeout, Transport: transport}
}
return &cardImageMediaResolver{
clientID: clientID,
clientSecret: clientSecret,
imageURLPrefix: prefix,
fetchURLPrefix: strings.TrimSpace(opts.FetchURLPrefix),
tokenURL: tokenURL,
uploadURL: uploadURL,
client: client,
ttl: ttl,
waitTimeout: waitTimeout,
maxBytes: maxBytes,
sem: make(chan struct{}, maxConcurrentCardImageUploads),
now: time.Now,
entries: make(map[string]cardImageMediaEntry),
inflight: make(map[string]*cardImageMediaJob),
}, nil
}
// RewriteForCard replaces complete markdown image references that point at our
// own published images with a DingTalk media id. With wait=false the rewrite
// only uses media ids that are already known and kicks off the upload in the
// background, so streaming frames never block on DingTalk; with wait=true it
// waits (bounded) for in-flight uploads, which is what the final card update
// uses.
func (r *cardImageMediaResolver) RewriteForCard(ctx context.Context, content string, wait bool) string {
if r == nil || strings.TrimSpace(content) == "" {
return content
}
var waitCtx context.Context
if wait {
bound := r.waitTimeout
if bound <= 0 {
bound = defaultCardImageWaitTimeout
}
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), bound)
defer cancel()
}
rewritten := cardMarkdownImageRE.ReplaceAllStringFunc(content, func(image string) string {
match := cardMarkdownImageRE.FindStringSubmatch(image)
if len(match) < 2 {
return image
}
rawURL := strings.TrimSpace(match[1])
if rawURL == "" || !r.isOwnImageURL(rawURL) {
return image
}
if mediaID, ok := r.lookup(rawURL); ok {
return replaceCardImageURL(image, match[1], mediaID)
}
job := r.startUpload(rawURL)
if !wait {
return image
}
select {
case <-job.done:
case <-waitCtx.Done():
}
if job.mediaID != "" {
return replaceCardImageURL(image, match[1], job.mediaID)
}
if job.err != nil {
log.Printf("DingTalk card image media upload failed, keeping external reference")
}
return image
})
return rewritten
}
func replaceCardImageURL(image, rawURL, mediaID string) string {
if mediaID == "" || rawURL == "" {
return image
}
return strings.Replace(image, rawURL, mediaID, 1)
}
func (r *cardImageMediaResolver) isOwnImageURL(rawURL string) bool {
if r.imageURLPrefix == "" || !strings.HasPrefix(rawURL, r.imageURLPrefix) {
return false
}
id := strings.TrimPrefix(rawURL, r.imageURLPrefix)
if id == "" || strings.ContainsAny(id, "/?#") {
return false
}
return cardImageStableIDRE.MatchString(id)
}
func (r *cardImageMediaResolver) lookup(rawURL string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[rawURL]
if !ok || r.now().After(entry.expiresAt) {
return "", false
}
return entry.mediaID, true
}
// startUpload returns a job for the image, reusing an in-flight upload or a
// cached media id when available. The upload itself runs on a background
// context so a streaming frame's cancellation cannot abort it half way.
func (r *cardImageMediaResolver) startUpload(rawURL string) *cardImageMediaJob {
r.mu.Lock()
if entry, ok := r.entries[rawURL]; ok && r.now().Before(entry.expiresAt) {
job := &cardImageMediaJob{done: make(chan struct{}), mediaID: entry.mediaID}
close(job.done)
r.mu.Unlock()
return job
}
if job, ok := r.inflight[rawURL]; ok {
r.mu.Unlock()
return job
}
job := &cardImageMediaJob{done: make(chan struct{})}
r.inflight[rawURL] = job
r.mu.Unlock()
go func() {
uploadCtx, cancel := context.WithTimeout(context.Background(), defaultCardImageUploadTimeout)
defer cancel()
mediaID, err := r.upload(uploadCtx, rawURL)
job.mediaID = mediaID
job.err = err
r.mu.Lock()
delete(r.inflight, rawURL)
if err == nil && mediaID != "" {
r.entries[rawURL] = cardImageMediaEntry{mediaID: mediaID, expiresAt: r.now().Add(r.ttl)}
}
r.mu.Unlock()
close(job.done)
}()
return job
}
func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (string, error) {
select {
case r.sem <- struct{}{}:
case <-ctx.Done():
return "", ctx.Err()
}
defer func() { <-r.sem }()
data, contentType, err := r.fetch(ctx, rawURL)
if err != nil {
return "", err
}
token, err := r.accessToken(ctx)
if err != nil {
return "", err
}
return r.uploadMedia(ctx, token, data, contentType)
}
func (r *cardImageMediaResolver) fetchURL(rawURL string) string {
targets := r.fetchURLs(rawURL)
if len(targets) == 0 {
return rawURL
}
return targets[0]
}
// fetchURLs lists the download candidates for one of our published images: the
// loopback prefix first (cheap, stays inside the host) and the public URL as a
// fallback so a missing local route never turns into a missing card image.
func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string {
relative := ""
if strings.HasPrefix(rawURL, r.imageURLPrefix) {
relative = strings.TrimPrefix(rawURL, r.imageURLPrefix)
}
targets := make([]string, 0, 2)
if r.fetchURLPrefix != "" && relative != "" {
targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative)
}
if relative == "" || r.fetchURLPrefix == "" {
targets = append(targets, rawURL)
} else {
targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative)
}
return targets
}
func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
var firstErr error
for _, target := range r.fetchURLs(rawURL) {
data, contentType, err := r.fetchOnce(ctx, target)
if err == nil {
return data, contentType, nil
}
if firstErr == nil {
firstErr = err
}
if ctx.Err() != nil {
break
}
}
if firstErr == nil {
firstErr = errors.New("card image fetch: no candidate URL")
}
return nil, "", firstErr
}
func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", err
}
resp, err := r.client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("card image fetch: unexpected status %d", resp.StatusCode)
}
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
return nil, "", fmt.Errorf("card image fetch: unexpected content type")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, r.maxBytes+1))
if err != nil {
return nil, "", err
}
if int64(len(data)) > r.maxBytes {
return nil, "", errors.New("card image fetch: image exceeds upload size limit")
}
if len(data) == 0 {
return nil, "", errors.New("card image fetch: empty image")
}
return data, contentType, nil
}
// cardImageRefCounts splits the markdown image references of a card body into
// externally hosted URLs and DingTalk media ids, so operators can tell from the
// logs whether a card body can actually render its images.
func cardImageRefCounts(content string) (total, external, media int) {
for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) {
if len(match) < 2 {
continue
}
total++
if strings.Contains(strings.TrimSpace(match[1]), "://") {
external++
} else {
media++
}
}
return total, external, media
}
func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) {
r.mu.Lock()
if r.token != "" && r.now().Before(r.tokenExpiry) {
token := r.token
r.mu.Unlock()
return token, nil
}
r.mu.Unlock()
values := map[string]string{"appkey": r.clientID, "appsecret": r.clientSecret}
payload, err := json.Marshal(values)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.tokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("card image token: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.AccessToken) == "" {
return "", fmt.Errorf("card image token: rejected with errcode %d", parsed.ErrCode)
}
expiresIn := parsed.ExpiresIn
if expiresIn <= 0 {
expiresIn = 7200
}
r.mu.Lock()
r.token = parsed.AccessToken
r.tokenExpiry = r.now().Add(time.Duration(expiresIn)*time.Second - cardImageTokenRefreshMargin)
r.mu.Unlock()
return parsed.AccessToken, nil
}
func (r *cardImageMediaResolver) uploadMedia(ctx context.Context, token string, data []byte, contentType string) (string, error) {
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="media"; filename="card-image"`)
if contentType == "" {
contentType = "image/png"
}
header.Set("Content-Type", contentType)
part, err := writer.CreatePart(header)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
uploadURL := r.uploadURL + "?access_token=" + token + "&type=image"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buffer)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", errors.New("card image upload: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", fmt.Errorf("card image upload: rejected with errcode %d", parsed.ErrCode)
}
return parsed.MediaID, nil
}
@@ -1,398 +0,0 @@
package dingtalk
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const cardImageTestID = "5f0a1c2e-3b4d-5e6f-8a9b-0c1d2e3f4a5b"
const cardImageTestMediaID = "@lADP-test-media-id"
type cardImageTestServer struct {
server *httptest.Server
imageBytes []byte
oversizeBytes []byte
uploadErrcode int
tokenCalls int
uploadCalls int
uploadedBytes int
fetched []string
mu sync.Mutex
uploadedC chan struct{}
}
func newCardImageTestServer(t *testing.T) *cardImageTestServer {
t.Helper()
fixture := &cardImageTestServer{
imageBytes: []byte("\x89PNG\r\n\x1a\nfixture-bytes"),
oversizeBytes: make([]byte, 512),
uploadedC: make(chan struct{}, 4),
}
mux := http.NewServeMux()
serveImage := func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.fetched = append(fixture.fetched, r.URL.Path)
fixture.mu.Unlock()
body := fixture.imageBytes
if strings.HasSuffix(r.URL.Path, "big") {
body = fixture.oversizeBytes
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(body)
}
mux.HandleFunc("/dingtalk/i/", serveImage)
mux.HandleFunc("/images/", serveImage)
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.tokenCalls++
fixture.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": 0, "errmsg": "ok", "access_token": "test-token", "expires_in": 7200,
})
})
mux.HandleFunc("/media/upload", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") != "test-token" || r.URL.Query().Get("type") != "image" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40001, "errmsg": "bad token"})
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
file, _, err := r.FormFile("media")
if err == nil {
data, _ := io.ReadAll(file)
_ = file.Close()
fixture.mu.Lock()
fixture.uploadedBytes += len(data)
fixture.mu.Unlock()
}
fixture.mu.Lock()
fixture.uploadCalls++
code := fixture.uploadErrcode
fixture.mu.Unlock()
select {
case fixture.uploadedC <- struct{}{}:
default:
}
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": code, "errmsg": "ok", "media_id": cardImageTestMediaID,
})
})
fixture.server = httptest.NewServer(mux)
t.Cleanup(fixture.server.Close)
return fixture
}
func (f *cardImageTestServer) resolver(t *testing.T, mutate func(*CardImageMediaOptions)) CardImageMediaRewriter {
t.Helper()
opts := CardImageMediaOptions{
ClientID: "robot-client-id",
ClientSecret: "robot-client-secret",
ImageURLPrefix: f.server.URL + "/dingtalk/i/",
TokenURL: f.server.URL + "/gettoken",
UploadURL: f.server.URL + "/media/upload",
HTTPClient: f.server.Client(),
}
if mutate != nil {
mutate(&opts)
}
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil {
t.Fatalf("NewCardImageMediaRewriter: %v", err)
}
if rewriter == nil {
t.Fatal("expected a configured rewriter")
}
return rewriter
}
func (f *cardImageTestServer) counts() (token, upload int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.tokenCalls, f.uploadCalls
}
func (f *cardImageTestServer) uploadedByteCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.uploadedBytes
}
func (f *cardImageTestServer) imageURL(id string) string {
return f.server.URL + "/dingtalk/i/" + id
}
func (f *cardImageTestServer) waitForUpload(t *testing.T) {
t.Helper()
select {
case <-f.uploadedC:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the media upload")
}
}
func TestCardImageMediaRewritesOwnImageAndCachesMediaID(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("看图:![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
want := fmt.Sprintf("看图:![设备图](%s)", cardImageTestMediaID)
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
if got := fixture.uploadedByteCount(); got != len(fixture.imageBytes) {
t.Fatalf("uploaded bytes=%d want %d", got, len(fixture.imageBytes))
}
// A second rewrite must reuse the cached media id without another upload,
// and the token must only be fetched once.
again := rewriter.RewriteForCard(context.Background(), content, true)
if again != want {
t.Fatalf("cached rewrite got=%q want=%q", again, want)
}
tokens, uploads := fixture.counts()
if uploads != 1 || tokens != 1 {
t.Fatalf("tokens=%d uploads=%d want 1/1", tokens, uploads)
}
}
func TestCardImageMediaPartialFramesNeverBlockAndPickUpLater(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
// A streaming frame keeps the external URL but starts the upload.
if got := rewriter.RewriteForCard(context.Background(), content, false); got != content {
t.Fatalf("streaming frame must stay unchanged, got=%q", got)
}
fixture.waitForUpload(t)
deadline := time.Now().Add(2 * time.Second)
for {
got := rewriter.RewriteForCard(context.Background(), content, false)
if strings.Contains(got, cardImageTestMediaID) {
break
}
if time.Now().After(deadline) {
t.Fatalf("media id never became available, got=%q", got)
}
time.Sleep(10 * time.Millisecond)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
}
func TestCardImageMediaIgnoresForeignAndIncompleteReferences(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := strings.Join([]string{
fmt.Sprintf("![外部图](https://example.com/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![流水线](%s)", fixture.imageURL("not-a-stable-id")),
fmt.Sprintf("![未闭合](%s", fixture.imageURL(cardImageTestID)),
fmt.Sprintf("纯链接 %s", fixture.imageURL(cardImageTestID)),
}, "\n")
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("content must be untouched, got=%q", got)
}
tokens, uploads := fixture.counts()
if tokens != 0 || uploads != 0 {
t.Fatalf("tokens=%d uploads=%d want 0/0", tokens, uploads)
}
}
func TestCardImageMediaFallsBackWhenUploadFails(t *testing.T) {
fixture := newCardImageTestServer(t)
fixture.uploadErrcode = 40035
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("failed upload must keep the external reference, got=%q", got)
}
}
func TestCardImageMediaFallsBackForOversizeImages(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.MaxBytes = 128
})
content := fmt.Sprintf("![大图](%s/big)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("oversize image must keep the external reference, got=%q", got)
}
if _, uploads := fixture.counts(); uploads != 0 {
t.Fatalf("uploads=%d want 0", uploads)
}
}
func TestCardImageMediaFetchUsesLoopbackPrefix(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.ImageURLPrefix = "https://weknora-api.invalid/dingtalk/i/"
opts.FetchURLPrefix = fixture.server.URL + "/images/"
})
content := fmt.Sprintf("![设备图](https://weknora-api.invalid/dingtalk/i/%s)", cardImageTestID)
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("loopback fetch must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/images/") {
t.Fatalf("fetched=%v want one loopback path", fixture.fetched)
}
}
func TestCardImageMediaDeduplicatesConcurrentUploads(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rewriter.RewriteForCard(context.Background(), content, true)
}()
}
wg.Wait()
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1 for concurrent callers", uploads)
}
}
func TestCardImageMediaRewriterRequiresFullConfiguration(t *testing.T) {
for name, opts := range map[string]CardImageMediaOptions{
"missing client id": {ClientSecret: "s", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing secret": {ClientID: "id", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing prefix": {ClientID: "id", ClientSecret: "s"},
} {
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil || rewriter != nil {
t.Fatalf("%s: rewriter=%v err=%v want nil/nil", name, rewriter, err)
}
}
}
func TestStreamingCardDeliverySendsMediaIDAndKeepsMarkdownExternal(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("CR20V 接线如下:\n\n![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-card"}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(card.updates) == 0 {
t.Fatal("no card update was recorded")
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize {
t.Fatalf("last update must finalize the card, got %+v", final)
}
if !strings.Contains(final.content, cardImageTestMediaID) {
t.Fatalf("final card body must carry the media id, got=%q", final.content)
}
if strings.Contains(final.content, fixture.server.URL) {
t.Fatalf("final card body must not keep the external URL, got=%q", final.content)
}
}
func TestCardFinalizeFallbackKeepsExternalImageURL(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-fallback", updateErr: errors.New("card unavailable")}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if !reply.called {
t.Fatal("markdown fallback was not sent")
}
if !strings.Contains(reply.lastText, fixture.server.URL) {
t.Fatalf("markdown fallback must keep the external URL, got=%q", reply.lastText)
}
if strings.Contains(reply.lastText, cardImageTestMediaID) {
t.Fatalf("markdown fallback must not carry a media id, got=%q", reply.lastText)
}
}
func TestCardImageMediaFallsBackToPublicURLWhenLoopbackMisses(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
// The loopback route exists but does not serve this object.
opts.FetchURLPrefix = fixture.server.URL + "/missing/"
})
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("public fallback must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/dingtalk/i/") {
t.Fatalf("fetched=%v want a single public fetch after the loopback miss", fixture.fetched)
}
}
func TestCardImageRefCountsSplitMediaAndExternalReferences(t *testing.T) {
content := strings.Join([]string{
fmt.Sprintf("![图一](%s)", cardImageTestMediaID),
fmt.Sprintf("![图二](https://weknora-api.dobot.cn/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![图三](%s)", cardImageTestMediaID),
}, "\n")
total, external, media := cardImageRefCounts(content)
if total != 3 || external != 1 || media != 2 {
t.Fatalf("total=%d external=%d media=%d want 3/1/2", total, external, media)
}
if total, external, media := cardImageRefCounts("无图回答"); total != 0 || external != 0 || media != 0 {
t.Fatalf("plain text must report no image refs, got %d/%d/%d", total, external, media)
}
}
func TestCardUpdateDiagnosticReportsImageReferenceShape(t *testing.T) {
content := fmt.Sprintf("正文\n\n![图](%s)", cardImageTestMediaID)
got := formatCardUpdateDiagnostic(content, true, false)
if !strings.Contains(got, "image_refs=1") || !strings.Contains(got, "media_image_refs=1") || !strings.Contains(got, "external_image_refs=0") {
t.Fatalf("diagnostic must report image reference shape: %q", got)
}
if strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("diagnostic must not leak the media id: %q", got)
}
}
-111
View File
@@ -1289,117 +1289,6 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: PC wide-screen layout config is delivered through the reserved
// cardParamMap system field, never as a sibling of cardParamMap
// ---------------------------------------------------------------------------
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
cases := []struct {
name string
req CardRequest
}{
{
name: "direct",
req: CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
{
name: "group",
req: CardRequest{
ConversationType: core.ConversationGroup,
ExternalChatID: "chat-789",
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
t.Fatalf("StartCard: %v", err)
}
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
if !ok {
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
}
// Regression guard: a complex-typed value placed next to
// cardParamMap leaves the template variable unassigned and the
// card silently renders in the default narrow layout.
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
if _, present := cardData[badKey]; present {
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
}
}
paramMap, ok := cardData["cardParamMap"].(map[string]any)
if !ok {
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
}
raw, ok := paramMap[cardWideScreenJSONKey].(string)
if !ok {
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
}
var decoded struct {
Config struct {
AutoLayout bool `json:"autoLayout"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
}
if !decoded.Config.AutoLayout {
t.Fatalf("config.autoLayout=false in %q, want true", raw)
}
// Plain string variables must keep going through cardParamMap directly.
if paramMap["content"] != tc.req.Content {
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
}
})
}
}
// ---------------------------------------------------------------------------
// Test: streaming updates only carry content (no layout config)
// ---------------------------------------------------------------------------
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "placeholder",
})
if err != nil {
t.Fatalf("StartCard: %v", err)
}
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
t.Fatalf("UpdateCard: %v", err)
}
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
}
if ts.lastStreamBody["key"] != "content" {
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -8,11 +8,7 @@ import (
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
// bareInternalSourceHandleRE matches WeKnora request-local source aliases
// leaked by a model (for example [d21], [c12], or [w3]). It intentionally
// does not match ordinary Markdown links or arbitrary bracketed content.
bareInternalSourceHandleRE = regexp.MustCompile(`(?i)\[(?:[dcw][0-9]+)(?:[ ]*[,,][ ]*(?:[dcw][0-9]+))*\]`)
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
@@ -23,13 +19,12 @@ func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) && !bareInternalSourceHandleRE.MatchString(content) {
if !displayReferenceMarkerRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = bareInternalSourceHandleRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
@@ -34,20 +34,10 @@ func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax and ordinary link are preserved",
name: "unrelated double bracket syntax is preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "bare internal source handles",
input: "Nova支持虚拟控制器离线仿真[d21],并可搭配VX500[w3]。",
want: "Nova支持虚拟控制器离线仿真,并可搭配VX500。",
},
{
name: "grouped bare internal source handles",
input: "该结论由多个资料支持 [d21, c12,w3]。",
want: "该结论由多个资料支持。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
+16 -83
View File
@@ -157,22 +157,9 @@ type HandlerOptions struct {
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
// CardImageMedia rewrites our own published image URLs in card bodies into
// DingTalk media ids; nil keeps external URLs as-is.
CardImageMedia CardImageMediaRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
// OutboundRewriteTimeout bounds the outbound image rewrite stage (fetch +
// publish). Answers carrying many large screenshots need more than the
// delivery timeout, otherwise the remaining images are dropped. Zero keeps
// finalDeliveryTimeout.
OutboundRewriteTimeout time.Duration
// WideTableGrouping selects how over-wide answer tables are adapted, per
// frame: "" or "on" groups on every frame (historical), "off" never groups,
// "stream" groups only while the answer is still streaming and keeps the
// real table on the final frame. See tableGroupingForMode.
WideTableGrouping string
}
type Handler struct {
@@ -184,22 +171,12 @@ type Handler struct {
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
cardImageMedia CardImageMediaRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
// outboundRewriteTimeout applies to the image rewrite stage only; zero
// falls back to finalDeliveryTimeout.
outboundRewriteTimeout time.Duration
// groupTablesPartial / groupTablesFinal decide per frame whether an
// over-wide table becomes a grouped list. Grouped lists survive the
// streaming frames where a table is still incomplete; the final frame can
// keep the real table, which the PC wide-screen card lays out itself.
groupTablesPartial bool
groupTablesFinal bool
}
// NewHandler accepts at most one image downloader. A nil/omitted downloader
@@ -231,8 +208,6 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
if reply == nil {
return nil, errors.New("dingtalk handler: markdown sender is required")
}
groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping)
return &Handler{
cfg: cfg,
qa: qa,
@@ -242,17 +217,12 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
cardImageMedia: opts.CardImageMedia,
settingsRouter: opts.SettingsRouter,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
groupTablesPartial: groupPartial,
groupTablesFinal: groupFinal,
}, nil
}
@@ -470,7 +440,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
ConversationType: req.ConversationType,
ExternalUserID: req.ExternalUserID,
ExternalChatID: req.ExternalChatID,
Content: h.rewriteCardImages(ctx, content, true),
Content: content,
}
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
@@ -520,16 +490,14 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
observer := func(fullContent string) {
visible := feed.OnAnswer(fullContent)
if strings.TrimSpace(visible) == "" {
if strings.TrimSpace(fullContent) == "" {
return
}
fullContent = decorateReasoningPartial(visible, req.ReasoningExecution)
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(ctx, fullContent)
partialContent := h.sanitizeOutboundPartial(fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
@@ -583,26 +551,17 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
return streamingCard.UpdateCard(updateCtx, outTrackID, content, false, false)
})
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
answerStarted := false
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" {
visible := feed.OnAnswer(event.FullAnswer)
if strings.TrimSpace(visible) == "" {
return
}
visibleAnswer := decorateReasoningPartial(visible, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(ctx, visibleAnswer); strings.TrimSpace(partialContent) != "" {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
switch event.ResponseType {
case "tool_call", "tool_result", "thinking", "reflection":
// A reasoning step ended: the next narrative segment starts a new
// line so the card shows the reasoning process line by line.
feed.StepBoundary()
}
if feed.Started() {
if answerStarted {
return
}
if content := renderProgressEvent(event); content != "" {
@@ -643,13 +602,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
// Final card convergence must still be attempted when the QA context was
// canceled or timed out; otherwise the card can remain "processing".
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
cardContent := content
if !isError {
// Card bodies render DingTalk media ids only, so the images are
// converted here while the markdown fallback keeps the external URLs.
cardContent = h.rewriteCardImages(finalCtx, content, true)
}
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, cardContent, true, isError)
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, content, true, isError)
cancelFinal()
if updateErr == nil {
if !isError {
@@ -675,44 +628,24 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
}
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal}
if h.outboundRewriter == nil {
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
}
timeout := h.outboundRewriteTimeout
if timeout <= 0 {
timeout = h.finalDeliveryTimeout
return sanitizeDisplayAnswer(content)
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return normalizeMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)), options)
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
}
func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) string {
func (h *Handler) sanitizeOutboundPartial(content string) string {
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial}
content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false)
}
// rewriteCardImages converts image references in card content into DingTalk
// media ids. It is a no-op when the feature is not configured and never fails
// the caller: unuploadable images keep their original external reference.
func (h *Handler) rewriteCardImages(ctx context.Context, content string, wait bool) string {
if h.cardImageMedia == nil || strings.TrimSpace(content) == "" {
return content
}
rewritten := h.cardImageMedia.RewriteForCard(ctx, content, wait)
if strings.TrimSpace(rewritten) == "" {
return content
}
return rewritten
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
@@ -1,7 +1,6 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
@@ -20,38 +19,3 @@ func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
t.Fatalf("got=%q", got)
}
}
func TestReasoningStreamingShowsStepsLineByLineThenDropsThemForBody(t *testing.T) {
execution := string(reasoningmode.ExecutionReasoning)
first := "我先确认一下设备的型号和手册。"
second := "手册里有关节扭矩表,让我核对一下。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
feed := reasoningmode.NewPartialFeed(execution)
if got, want := decorateReasoningPartial(feed.OnAnswer(first), execution), "【智能推理模式】\n\n"+first; got != want {
t.Fatalf("step1 got=%q want=%q", got, want)
}
feed.StepBoundary()
if got, want := decorateReasoningPartial(feed.OnAnswer(first+second), execution), "【智能推理模式】\n\n"+first+"\n\n"+second; got != want {
t.Fatalf("step2 got=%q want=%q", got, want)
}
answer := first + second + "\n\n" + body
if got, want := decorateReasoningPartial(feed.OnAnswer(answer), execution), "【智能推理模式】\n\n"+body; got != want {
t.Fatalf("refresh got=%q want=%q", got, want)
}
got := feed.OnAnswer(answer + "\n| J1 | 98 N·m |")
if strings.Contains(got, first) || !strings.Contains(got, "J1") {
t.Fatalf("reasoning must stay hidden while the body streams, got=%q", got)
}
}
func TestQuickStreamingKeepsAccumulatedAnswer(t *testing.T) {
feed := reasoningmode.NewPartialFeed(string(reasoningmode.ExecutionQuick))
if got := feed.OnAnswer("快速答案"); got != "快速答案" {
t.Fatalf("got=%q", got)
}
feed.StepBoundary()
if got := feed.OnAnswer("快速答案追加"); got != "快速答案追加" {
t.Fatalf("got=%q", got)
}
}
@@ -1,255 +0,0 @@
package dingtalk
import (
"strings"
"unicode"
)
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
// display columns; a cell wider than this tends to collapse the column layout
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// tableNormalizeOptions controls how a final answer is adapted to the card
// renderer. The zero value keeps the historical behaviour.
type tableNormalizeOptions struct {
// groupWideTables rewrites tables containing over-wide cells into grouped
// lists (bold row heading + "- header:value" lines). Disable it when the
// client lays out wide tables itself, so the answer keeps its real table.
groupWideTables bool
}
func defaultTableNormalizeOptions() tableNormalizeOptions {
return tableNormalizeOptions{groupWideTables: true}
}
// tableGroupingForMode maps the configured mode to the two frames the card
// renders. An unknown or empty mode keeps the historical behaviour (group on
// every frame), so an unset environment never changes rendering.
//
// ""/"on" group on every frame
// "off" never group: real tables everywhere
// "stream" group while streaming, real table on the final frame
func tableGroupingForMode(mode string) (partial, final bool) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "off", "0", "false", "no":
return false, false
case "stream", "streaming", "partial":
return true, false
default:
return true, true
}
}
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
// cards without changing the knowledge itself. It (1) replaces <br> inside
// table cells with a full-width separator, and (2) rewrites any table that
// still contains an over-wide cell into a grouped list — a bold row heading
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
return normalizeMarkdownTables(content, defaultTableNormalizeOptions())
}
func normalizeMarkdownTables(content string, options tableNormalizeOptions) string {
if !strings.Contains(content, "|") {
return content
}
lines := splitMarkdownStreamLines(content)
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); {
line := lines[i]
if line.inFence {
out = append(out, line.text)
i++
continue
}
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
isCompleteTableDelimiter(lines[i+1].text) {
end := i + 1
for end+1 < len(lines) && !lines[end+1].inFence &&
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1], options)...)
i = end + 1
continue
}
out = append(out, line.text)
i++
}
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine, options tableNormalizeOptions) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
continue
}
cells = append(cells, splitTableRow(row.text))
}
maxWidth := 0
for _, row := range cells {
for _, cell := range row {
if w := visualWidth(cell); w > maxWidth {
maxWidth = w
}
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth || !options.groupWideTables {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells. The same path
// serves over-wide tables when grouping is disabled: the table stays a
// table and only <br> is replaced.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
rebuilt[i] = row.text
}
return rebuilt
}
var rebuilt []string
for idx, row := range cells {
joined := "| " + strings.Join(row, " | ") + " |"
if idx == 0 {
rebuilt = append(rebuilt, joined)
delims := make([]string, len(row))
for c := range row {
delims[c] = "---"
}
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
} else {
rebuilt = append(rebuilt, joined)
}
}
return rebuilt
}
var out []string
for gi, row := range cells[1:] {
if gi > 0 {
out = append(out, "")
}
var title string
var rest []string
for c, cell := range row {
if c == 0 {
title = cell
continue
}
label := strings.TrimSpace(header[min(c, len(header)-1)])
if label == "" {
label = "第" + itoa(c+1) + "项"
}
if cell == "" {
continue
}
rest = append(rest, "- "+label+":"+cell)
}
if title == "" {
title = label2(row[0])
}
out = append(out, "**"+strings.Trim(title, "* ")+"**")
out = append(out, rest...)
}
return out
}
func blockHasBreak(rows []markdownStreamLine) bool {
for _, row := range rows {
lower := strings.ToLower(row.text)
if strings.Contains(lower, "<br") {
return true
}
}
return false
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := splitEscaped(trimmed)
for i := range parts {
cell := strings.TrimSpace(parts[i])
cell = strings.ReplaceAll(cell, "<br>", ";")
cell = strings.ReplaceAll(cell, "<br/>", ";")
cell = strings.ReplaceAll(cell, "<br />", ";")
cell = strings.ReplaceAll(cell, "\\|", "|")
parts[i] = strings.TrimSpace(cell)
}
return parts
}
// splitEscaped splits on unescaped pipe separators.
func splitEscaped(s string) []string {
var parts []string
var b strings.Builder
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 < len(s) && s[i+1] == '|' {
b.WriteString("\\|")
i++
continue
}
b.WriteByte(s[i])
case '|':
parts = append(parts, b.String())
b.Reset()
default:
b.WriteByte(s[i])
}
}
parts = append(parts, b.String())
return parts
}
func label2(cell string) string { return strings.Trim(cell, "* ") }
func min(a, b int) int {
if a < b {
return a
}
return b
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var d []byte
for n > 0 {
d = append([]byte{byte('0' + n%10)}, d...)
n /= 10
}
return string(d)
}
func visualWidth(s string) int {
w := 0
for _, r := range s {
if r == '\n' {
if w > 0 {
w++ // separator counted conservatively
}
continue
}
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
w += 2
continue
}
w++
}
return w
}
func fullwidthPunct(r rune) bool {
switch r {
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
return true
}
return false
}
@@ -1,167 +0,0 @@
package dingtalk
import (
"context"
"strings"
"testing"
)
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("compact table must pass through untouched, got %q", got)
}
}
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
// compact row (控制柜) from the same table is now a list entry too
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
}
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
t.Fatalf("every row must become a group, got %q", got)
}
if strings.Contains(got, "J1: 180°/s") == false {
t.Fatalf("cell content must be preserved, got %q", got)
}
}
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must be replaced, got %q", got)
}
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
t.Fatalf("short table must stay a table with ; separator, got %q", got)
}
}
func TestNormalizeSkipsFencedTables(t *testing.T) {
long := strings.Repeat("长", 30)
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("fenced content must never be rewritten, got %q", got)
}
}
func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
options := defaultTableNormalizeOptions()
options.groupWideTables = false
got := normalizeMarkdownTables(content, options)
if !strings.Contains(got, "| 对比项 | CR 系列 | CRA 系列 |") || !strings.Contains(got, "| **关节最大速度** |") {
t.Fatalf("wide table must stay a table when grouping is disabled, got %q", got)
}
if strings.Contains(got, "**关节最大速度**\n- CR 系列:") {
t.Fatalf("grouped list must not be produced when grouping is disabled, got %q", got)
}
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must still be replaced inside cells, got %q", got)
}
if !strings.Contains(got, ";") {
t.Fatalf("cell line breaks must become the full-width separator, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
}
func TestTableGroupingForMode(t *testing.T) {
cases := []struct {
mode string
wantPartial bool
wantFinal bool
}{
{mode: "", wantPartial: true, wantFinal: true},
{mode: "on", wantPartial: true, wantFinal: true},
{mode: "weird", wantPartial: true, wantFinal: true},
{mode: "off", wantPartial: false, wantFinal: false},
{mode: "NO", wantPartial: false, wantFinal: false},
{mode: "stream", wantPartial: true, wantFinal: false},
{mode: " Stream ", wantPartial: true, wantFinal: false},
}
for _, tc := range cases {
partial, final := tableGroupingForMode(tc.mode)
if partial != tc.wantPartial || final != tc.wantFinal {
t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal)
}
}
}
// TestWideTableGroupingPerFrame proves the switch reaches both frames the card
// renders: the streaming frames and the final one. The streaming path drops
// unstable table blocks, so with grouping off a partial frame simply carries no
// table at all.
func TestWideTableGroupingPerFrame(t *testing.T) {
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:"
cases := []struct {
name string
mode string
partialWant [2]bool // [grouped, table]
finalWant [2]bool // [grouped, table]
}{
{name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}},
{name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}},
{name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
&fakeQA{},
&fakeReply{},
HandlerOptions{WideTableGrouping: tc.mode},
)
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
frames := map[string]struct {
got string
want [2]bool
}{
"partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant},
"final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant},
}
for name, frame := range frames {
grouped := strings.Contains(frame.got, groupMarker)
table := strings.Contains(frame.got, tableMarker)
if grouped != frame.want[0] || table != frame.want[1] {
t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q",
name, grouped, table, frame.want[0], frame.want[1], frame.got)
}
}
})
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| a\\|b") {
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
}
if !strings.Contains(got, "a|b") {
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
}
if !strings.Contains(got, "第2项:") {
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
}
}
+59 -35
View File
@@ -6,11 +6,36 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// canonicalNoAnswer is the only response that carries the explicit-no-answer
// machine contract. It must be the complete answer, never a substring match:
// an otherwise supported answer may legitimately say that one specific detail
// was not found.
const canonicalNoAnswer = "当前暂无已发布的权威知识,暂不建议操作。"
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
// returns when it cannot find relevant information. These are matched as
// substrings after trimming whitespace.
var noAnswerPhrases = []string{
"未找到",
"没有找到",
"无法确定",
"暂无相关",
"暂无已发布的权威知识",
"知识库中没有",
"产品不存在于资料中",
}
// retrievalGapPhrases are only considered a no-answer signal when the answer
// also directs the user to a human channel. Requiring both signals avoids
// misclassifying valid negative results such as "未查询到异常记录".
var retrievalGapPhrases = []string{
"未查询到",
"未检索到",
"未能查询到",
"未能检索到",
}
var humanEscalationPhrases = []string{
"建议您联系",
"建议联系",
"请联系",
"联系技术支持",
"联系官方销售",
}
// Evaluator classifies a successful turn into a coverage verdict.
type Evaluator struct{}
@@ -28,20 +53,20 @@ func (e *Evaluator) Version() string {
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Has references → covered_has_references (references are the coverage
// evidence; wording in a supported answer never overrides them)
// 3. Complete canonical no-answer response with no references
// → uncovered_explicit_no_answer
// 4. Image turns (image media present) are classified by whether the question
// 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
// 5. Text turns with zero references are classified by whether the question
// (pure visual recognition is not a coverage miss)
// 4. Text turns with zero references are classified by whether the question
// actually triggered a knowledge-base search:
// - knowledge searched → uncovered_no_references
// - no knowledge search → covered_non_knowledge_response
// 5. Has references → covered_has_references
//
// v5 never uses a score threshold. TopScore captures the highest reference score
// v1 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
if turn.Status != "succeeded" {
@@ -51,25 +76,9 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
// Normalize answer for no-answer phrase detection.
trimmed := strings.TrimSpace(turn.Answer)
// References are the durable coverage evidence. A supported answer may
// correctly note that one sub-detail is absent, so its wording cannot turn
// the entire turn into a knowledge gap.
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
}
// An explicit abstention is a stable machine contract only when it is the
// complete answer. Do not use broad natural-language substring matching.
if isCanonicalNoAnswer(trimmed) {
// Check for explicit no-answer phrases first — this takes priority
// over reference presence.
if isNoAnswer(trimmed) {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
@@ -77,8 +86,8 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredExplicitNoAnswer,
ReferenceCount: 0,
TopScore: 0,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
@@ -171,8 +180,23 @@ func hasImageMedia(media []core.MediaAudit) bool {
return false
}
func isCanonicalNoAnswer(answer string) bool {
return strings.TrimSpace(answer) == canonicalNoAnswer
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
if strings.Contains(lower, strings.ToLower(phrase)) {
return true
}
}
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
}
func containsAny(value string, phrases []string) bool {
for _, phrase := range phrases {
if strings.Contains(value, strings.ToLower(phrase)) {
return true
}
}
return false
}
func maxScore(refs []core.Reference) float64 {
+53 -38
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v5" {
t.Errorf("Evaluator version = %q, want v5", e.Version())
if e.Version() != "v4" {
t.Errorf("Evaluator version = %q, want v4", e.Version())
}
}
@@ -30,40 +30,55 @@ func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
}
}
func TestEvaluator_UncoveredCanonicalNoAnswerWithoutReferences(t *testing.T) {
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
noAnswerPhrases := []string{
"未找到相关结果",
"没有找到你需要的信息",
"无法确定该问题的答案",
"暂无相关信息",
"知识库中没有找到相关内容",
"这个问题知识库中没有可用的信息",
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, VerdictUncovered, ReasonUncoveredExplicitNoAnswer)
for _, answer := range noAnswerPhrases {
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: answer,
References: []core.Reference{
{ID: "r1", Score: 0.9},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error for answer %q: %v", answer, err)
}
if d.Verdict != VerdictUncovered {
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
}
func TestEvaluator_NovaPartialNotFoundWithSevenReferencesIsCovered(t *testing.T) {
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "nova-partial-turn",
ID: "missing-product-turn",
Status: "succeeded",
Answer: "已找到Nova系列的相关资料;未找到VX500 2.5D视觉一体方案的明确说明。",
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
References: []core.Reference{
{ID: "r1"}, {ID: "r2"}, {ID: "r3"}, {ID: "r4"},
{ID: "r5"}, {ID: "r6"}, {ID: "r7"},
{ID: "unrelated-1", Score: 0.395940},
{ID: "unrelated-2", Score: 0.311595},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredHasReferences || d.ReferenceCount != 7 {
t.Fatalf("verdict/reason/references = %s/%s/%d, want %s/%s/7", d.Verdict, d.ReasonCode, d.ReferenceCount, VerdictCovered, ReasonCoveredHasReferences)
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
}
}
@@ -190,9 +205,9 @@ func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
}
}
func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
// References are durable coverage evidence; a natural-language phrase must
// not override them.
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
// When answer contains explicit "no answer" phrase, it should be uncovered
// even if references exist. No-answer detection takes priority.
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
@@ -207,11 +222,11 @@ func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered {
t.Errorf("answer with references: verdict = %s, want covered", d.Verdict)
if d.Verdict != VerdictUncovered {
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Errorf("answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
@@ -230,12 +245,12 @@ func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t *testing.T) {
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "cra-price-turn",
@@ -250,11 +265,11 @@ func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if d.Verdict != VerdictCovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictCovered)
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
}
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
@@ -267,10 +282,10 @@ func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
reason string
}{
{
name: "retrieval wording with evidence remains covered",
name: "production NOVA price answer",
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
verdict: VerdictCovered,
reason: ReasonCoveredHasReferences,
verdict: VerdictUncovered,
reason: ReasonUncoveredExplicitNoAnswer,
},
{
name: "retrieval gap without escalation is a valid negative result",
+8 -6
View File
@@ -104,12 +104,14 @@ func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "未找到相关结果",
References: []core.Reference{
{ID: "r1", Score: 0.5},
},
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+1 -1
View File
@@ -33,7 +33,7 @@ const (
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v5"
const EvaluatorVersion = "v4"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
-13
View File
@@ -49,19 +49,6 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
return nil
}
// Exists reports whether an object is already stored under key, so identical
// content is never uploaded twice.
func (s *AliyunOSSStore) Exists(ctx context.Context, key string) (bool, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" {
return false, errors.New("outbound image: invalid OSS exists request")
}
exists, err := s.client.IsObjectExist(ctx, s.bucket, key)
if err != nil {
return false, errors.New("outbound image: OSS exists check failed")
}
return exists, nil
}
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
return nil, "", errors.New("outbound image: invalid OSS get request")
-137
View File
@@ -1,137 +0,0 @@
package outboundimage
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
"strings"
)
const (
// compressMaxEdge bounds the longest side of a published screenshot. Manual
// screenshots are captured at 2x-3x, so capping the longest side keeps text
// readable while cutting the upload size by an order of magnitude.
compressMaxEdge = 1600
// compressJPEGQuality is the re-encode quality for compressed screenshots.
compressJPEGQuality = 82
// compressMinBytes skips images that are already small: re-encoding them
// saves little upload time and could only lose detail.
compressMinBytes = 128 << 10
// compressMaxPixels refuses to decode huge images so one oversized
// screenshot cannot exhaust the container's memory.
compressMaxPixels = 24 << 20
)
// CompressImage re-encodes an outbound screenshot as a smaller JPEG. It returns
// the payload to publish together with its content type, and reports whether
// compression applied. Images that are undecodable, already small, animated, or
// not smaller after re-encoding are returned untouched, so publishing always
// has a usable payload.
func CompressImage(data []byte, contentType string) ([]byte, string, bool) {
if !compressibleImageType(contentType) || len(data) < compressMinBytes {
return data, contentType, false
}
decoded, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, contentType, false
}
bounds := decoded.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx()*bounds.Dy() > compressMaxPixels {
return data, contentType, false
}
encoded := encodeCompressedImage(downscaleRGBA(flattenToRGBA(decoded), compressMaxEdge))
if encoded == nil || len(encoded) >= len(data) {
return data, contentType, false
}
return encoded, "image/jpeg", true
}
// compressibleImageType reports whether the content type is a still raster
// format this package can decode and re-encode. GIF and WebP are left alone to
// preserve animation and format fidelity.
func compressibleImageType(contentType string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/png", "image/jpeg", "image/jpg":
return true
default:
return false
}
}
func encodeCompressedImage(source image.Image) []byte {
var encoded bytes.Buffer
if err := jpeg.Encode(&encoded, source, &jpeg.Options{Quality: compressJPEGQuality}); err != nil {
return nil
}
return encoded.Bytes()
}
// flattenToRGBA copies an image onto an opaque white canvas. JPEG has no alpha
// channel, so transparent regions must be composited before encoding or they
// would be rendered black.
func flattenToRGBA(source image.Image) *image.RGBA {
bounds := source.Bounds()
target := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(target, target.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(target, target.Bounds(), source, bounds.Min, draw.Over)
return target
}
// downscaleRGBA shrinks an image so neither side exceeds maxEdge, averaging the
// source pixels that fall into each destination pixel (box filter) so
// screenshot text stays legible.
func downscaleRGBA(source *image.RGBA, maxEdge int) *image.RGBA {
width, height := source.Rect.Dx(), source.Rect.Dy()
if width <= maxEdge && height <= maxEdge {
return source
}
targetWidth, targetHeight := width, height
if width >= height {
targetWidth = maxEdge
targetHeight = height * maxEdge / width
} else {
targetHeight = maxEdge
targetWidth = width * maxEdge / height
}
if targetWidth < 1 {
targetWidth = 1
}
if targetHeight < 1 {
targetHeight = 1
}
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
startY := y * height / targetHeight
endY := (y + 1) * height / targetHeight
if endY <= startY {
endY = startY + 1
}
for x := 0; x < targetWidth; x++ {
startX := x * width / targetWidth
endX := (x + 1) * width / targetWidth
if endX <= startX {
endX = startX + 1
}
var red, green, blue, count uint32
for row := startY; row < endY; row++ {
line := source.Pix[row*source.Stride:]
for column := startX; column < endX; column++ {
offset := column * 4
red += uint32(line[offset])
green += uint32(line[offset+1])
blue += uint32(line[offset+2])
count++
}
}
offset := y*target.Stride + x*4
target.Pix[offset] = uint8(red / count)
target.Pix[offset+1] = uint8(green / count)
target.Pix[offset+2] = uint8(blue / count)
target.Pix[offset+3] = 0xff
}
}
return target
}
-188
View File
@@ -1,188 +0,0 @@
package outboundimage
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
)
func pngFixture(t *testing.T, width, height int, alpha bool) []byte {
t.Helper()
source := image.NewRGBA(image.Rect(0, 0, width, height))
seed := uint32(12345)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// A pseudo-random pattern keeps the PNG as large as a real manual
// screenshot, so the size thresholds are exercised for real.
seed = seed*1664525 + 1013904223
alphaValue := uint8(0xff)
if alpha && x < width/2 {
alphaValue = 0x00
}
source.SetRGBA(x, y, color.RGBA{
R: uint8(seed >> 24),
G: uint8(seed >> 16),
B: uint8(seed >> 8),
A: alphaValue,
})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, source); err != nil {
t.Fatal(err)
}
return encoded.Bytes()
}
func TestCompressImageDownscalesLargeScreenshotToSmallerJPEG(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
data, contentType, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a large screenshot to be compressed")
}
if contentType != "image/jpeg" {
t.Fatalf("contentType=%q", contentType)
}
if len(data) >= len(original) {
t.Fatalf("compressed size=%d original=%d", len(data), len(original))
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("compressed payload is not a JPEG: %v", err)
}
if decoded.Bounds().Dx() != compressMaxEdge || decoded.Bounds().Dy() != compressMaxEdge*1800/3200 {
t.Fatalf("bounds=%v", decoded.Bounds())
}
}
func TestCompressImageFlattensTransparencyInsteadOfBlack(t *testing.T) {
original := pngFixture(t, 2000, 2000, true)
data, _, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a transparent screenshot to be compressed")
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
red, green, blue, _ := decoded.At(10, 10).RGBA()
if red < 0xf000 || green < 0xf000 || blue < 0xf000 {
t.Fatalf("transparent area rendered as %v instead of white", []uint32{red, green, blue})
}
}
func TestCompressImageLeavesSmallOrUndecodablePayloadsUntouched(t *testing.T) {
small := pngFixture(t, 64, 64, false)
if len(small) >= compressMinBytes {
t.Fatalf("fixture is not small: %d", len(small))
}
for _, test := range []struct {
name string
data []byte
contentType string
}{
{name: "small", data: small, contentType: "image/png"},
{name: "not an image", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/png"},
{name: "gif stays animated", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/gif"},
{name: "text", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "text/plain"},
} {
t.Run(test.name, func(t *testing.T) {
data, contentType, applied := CompressImage(test.data, test.contentType)
if applied || contentType != test.contentType || !bytes.Equal(data, test.data) {
t.Fatalf("payload changed: applied=%v contentType=%q bytes=%d", applied, contentType, len(data))
}
})
}
}
type capturePublisher struct {
data []byte
contentType string
calls int
}
func (p *capturePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.calls++
p.data = append([]byte(nil), data...)
p.contentType = contentType
return "https://img.example/1.png", nil
}
type pngFetcher struct {
data []byte
}
func (f *pngFetcher) Fetch(_ context.Context, _ string) ([]byte, string, error) {
return f.data, "image/png", nil
}
func TestRewritePublishesCompressedScreenshots(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
reference := "resource://AbCdEfGhIjKlMnOpQrStUv"
input := "步骤一\n![图1](" + reference + ")\n"
publisher := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, publisher, 5).Rewrite(context.Background(), input)
if publisher.calls != 1 || publisher.contentType != "image/jpeg" {
t.Fatalf("calls=%d contentType=%q", publisher.calls, publisher.contentType)
}
if len(publisher.data) >= len(original) {
t.Fatalf("published bytes=%d original=%d", len(publisher.data), len(original))
}
disabled := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, disabled, 5, WithImageCompression(false)).Rewrite(context.Background(), input)
if disabled.contentType != "image/png" || !bytes.Equal(disabled.data, original) {
t.Fatalf("compression opt-out changed the payload: contentType=%q bytes=%d", disabled.contentType, len(disabled.data))
}
}
func TestStableHashPublisherReusesExistingObject(t *testing.T) {
store := &existingObjectStore{}
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
if err != nil {
t.Fatal(err)
}
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if store.puts != 1 {
t.Fatalf("puts=%d", store.puts)
}
store.exists = true
second, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("url1=%q url2=%q", first, second)
}
if store.puts != 1 {
t.Fatalf("existing object was uploaded again: puts=%d", store.puts)
}
if !strings.HasPrefix(second, "https://img.example/i/") {
t.Fatalf("url=%q", second)
}
}
type existingObjectStore struct {
exists bool
puts int
}
func (s *existingObjectStore) Put(_ context.Context, _ string, _ []byte, _ string) error {
s.puts++
return nil
}
func (s *existingObjectStore) Exists(_ context.Context, _ string) (bool, error) {
return s.exists, nil
}
-14
View File
@@ -6,7 +6,6 @@ import (
"encoding/hex"
"errors"
"fmt"
"log"
"net/url"
"path"
"regexp"
@@ -19,13 +18,6 @@ type ObjectWriter interface {
Put(ctx context.Context, key string, data []byte, contentType string) error
}
// ObjectStater reports whether an object already exists at key. Stores that
// implement it let the publisher skip re-uploading identical content, which is
// what makes a repeated question instant: object keys are content hashes.
type ObjectStater interface {
Exists(ctx context.Context, key string) (bool, error)
}
type StableHashPublisher struct {
store ObjectWriter
prefix string
@@ -60,12 +52,6 @@ func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentT
idHex := hex.EncodeToString(idBytes)
id := fmt.Sprintf("%s-%s-%s-%s-%s", idHex[:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:])
key := path.Join(p.prefix, id)
if stater, ok := p.store.(ObjectStater); ok {
if exists, err := stater.Exists(ctx, key); err == nil && exists {
log.Printf("outbound image: existing object reused key=%s bytes=%d contentType=%s", key, len(data), contentType)
return p.publicBaseURL + "/" + id, nil
}
}
if err := p.store.Put(ctx, key, data, contentType); err != nil {
return "", errors.New("outbound image: publish object failed")
}
+23 -140
View File
@@ -2,15 +2,11 @@ package outboundimage
import (
"context"
"log"
"net"
"net/url"
"path"
"regexp"
"sort"
"strings"
"sync"
"time"
)
var (
@@ -42,56 +38,41 @@ type Rewriter struct {
fetcher Fetcher
publisher Publisher
maxImages int
compress bool
}
// RewriterOption customises how one answer's images are prepared for delivery.
type RewriterOption func(*Rewriter)
// WithImageCompression switches the pre-publish JPEG re-encode. It is enabled by
// default because multi-megabyte screenshots dominate the delivery budget.
func WithImageCompression(enabled bool) RewriterOption {
return func(r *Rewriter) { r.compress = enabled }
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter {
return &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages}
}
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int, options ...RewriterOption) *Rewriter {
rewriter := &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages, compress: true}
for _, option := range options {
if option != nil {
option(rewriter)
}
}
return rewriter
}
// rewriteConcurrency bounds how many images are fetched and published in
// parallel. Manual screenshots are multi-megabyte objects whose upload time
// dominates the delivery budget, so a serial pass cannot publish a long answer
// before the delivery deadline and silently loses the remaining images.
const rewriteConcurrency = 4
func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
if r == nil || r.fetcher == nil || r.publisher == nil || r.maxImages <= 0 {
markdown = normalizePublicHTMLImages(stripResourceImages(markdown))
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
started := time.Now()
urls := r.resolveReferences(ctx, markdown)
urls := map[string]string{}
attempted := 0
resolve := func(reference string) string {
return urls[reference]
}
if len(urls) > 0 {
published, dropped := 0, 0
for _, publishedURL := range urls {
if publishedURL == "" {
dropped++
continue
}
published++
if publishedURL, ok := urls[reference]; ok {
return publishedURL
}
log.Printf("outbound image: rewrite finished references=%d published=%d dropped=%d elapsed=%s",
len(urls), published, dropped, time.Since(started).Round(time.Millisecond))
if attempted >= r.maxImages {
urls[reference] = ""
return ""
}
attempted++
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
urls[reference] = ""
return ""
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
urls[reference] = ""
return ""
}
urls[reference] = publishedURL
return publishedURL
}
markdown = resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
@@ -113,104 +94,6 @@ func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
return sanitizePublicMarkdownImages(markdown)
}
// resolveReferences returns reference -> public URL for every image reference
// in content, in document order and deduplicated, capped at maxImages. Each
// image is fetched and published with at most rewriteConcurrency workers; a
// reference that cannot be published maps to "".
func (r *Rewriter) resolveReferences(ctx context.Context, content string) map[string]string {
references := r.collectReferences(content)
if len(references) == 0 {
return map[string]string{}
}
results := make(map[string]string, len(references))
var mutex sync.Mutex
var budgetOnce sync.Once
slots := make(chan struct{}, rewriteConcurrency)
var wait sync.WaitGroup
for _, reference := range references {
wait.Add(1)
go func(reference string) {
defer wait.Done()
slots <- struct{}{}
defer func() { <-slots }()
publishedURL := ""
if err := ctx.Err(); err != nil {
budgetOnce.Do(func() {
log.Printf("outbound image: delivery budget exhausted, dropping remaining images: %v", err)
})
} else {
publishedURL = r.publishReference(ctx, reference)
}
mutex.Lock()
results[reference] = publishedURL
mutex.Unlock()
}(reference)
}
wait.Wait()
return results
}
// collectReferences gathers the resource references that carry a real image,
// in document order, dropping duplicates and anything beyond maxImages. Bare or
// unquoted references are never fetched.
func (r *Rewriter) collectReferences(content string) []string {
type hit struct {
at int
reference string
}
hits := make([]hit, 0, 16)
for _, pattern := range []*regexp.Regexp{resourceMarkdownImageRE, resourceHTMLDoubleImageRE, resourceHTMLSingleImageRE} {
for _, match := range pattern.FindAllStringSubmatchIndex(content, -1) {
if len(match) < 4 || match[2] < 0 || match[3] > len(content) {
continue
}
hits = append(hits, hit{at: match[0], reference: content[match[2]:match[3]]})
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].at < hits[j].at })
references := make([]string, 0, len(hits))
seen := make(map[string]bool, len(hits))
for _, candidate := range hits {
if seen[candidate.reference] {
continue
}
seen[candidate.reference] = true
if len(references) >= r.maxImages {
break
}
references = append(references, candidate.reference)
}
return references
}
// publishReference fetches one image from WeKnora and publishes it to our own
// object storage. Failures are logged with their reason instead of being
// silently dropped from the answer.
func (r *Rewriter) publishReference(ctx context.Context, reference string) string {
started := time.Now()
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
log.Printf("outbound image: fetch failed, image dropped reference=%s error=%v", reference, err)
return ""
}
if r.compress {
if compressed, compressedType, applied := CompressImage(data, contentType); applied {
log.Printf("outbound image: compressed reference=%s bytes=%d contentType=%s compressed_bytes=%d compressed_content_type=%s",
reference, len(data), contentType, len(compressed), compressedType)
data, contentType = compressed, compressedType
}
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
log.Printf("outbound image: publish failed, image dropped reference=%s bytes=%d contentType=%s error=%v",
reference, len(data), contentType, err)
return ""
}
log.Printf("outbound image: published reference=%s bytes=%d contentType=%s elapsed=%s",
reference, len(data), contentType, time.Since(started).Round(time.Millisecond))
return publishedURL
}
func sanitizePublicMarkdownImages(markdown string) string {
markdown = markdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := markdownImageRE.FindStringSubmatch(image)
-47
View File
@@ -4,19 +4,15 @@ import (
"context"
"errors"
"strings"
"sync"
"testing"
)
type fakeFetcher struct {
mutex sync.Mutex
calls map[string]int
fail map[string]bool
}
func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if f.calls == nil {
f.calls = map[string]int{}
}
@@ -28,13 +24,10 @@ func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, erro
}
type fakePublisher struct {
mutex sync.Mutex
calls int
}
func (p *fakePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.calls++
if contentType != "image/png" || len(data) == 0 {
return "", errors.New("bad image")
@@ -382,43 +375,3 @@ func TestDocumentImageDowngradePreservesImageAndLinkContracts(t *testing.T) {
t.Fatalf("unsafe document image became a link: %s", got)
}
}
func TestRewriteDropsImagesWhenDeliveryBudgetIsAlreadyExhausted(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 10)
ctx, cancel := context.WithCancel(context.Background())
cancel()
input := "正文\n![一](resource://FirstRefAbCdEfGhIjKlMnO)\n![二](resource://SecondRefAbCdEfGhIjKlMn)"
got := r.Rewrite(ctx, input)
if strings.Contains(got, "resource://") {
t.Fatalf("exhausted budget leaked a resource reference: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("text was lost: %s", got)
}
if len(fetcher.calls) != 0 || publisher.calls != 0 {
t.Fatalf("no image may be fetched or published without budget: fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestRewritePublishesEveryImageWithinTheLimit(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 12)
input := "正文"
for i := 0; i < 12; i++ {
input += "\n![图](resource://Ref" + string(rune('A'+i)) + "bCdEfGhIjKlMnO)"
}
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") {
t.Fatalf("resource reference remained: %s", got)
}
if publisher.calls != 12 || len(fetcher.calls) != 12 {
t.Fatalf("calls fetch=%d publish=%d, want 12", len(fetcher.calls), publisher.calls)
}
}
+20
View File
@@ -0,0 +1,20 @@
package qa
import "testing"
func TestLocalizedQueryPreservesOriginalWhenAuto(t *testing.T) {
if got := localizedQuery("原始问题", "auto"); got != "原始问题" {
t.Fatalf("got %q", got)
}
}
func TestLocalizedQueryAddsRequestedLanguageWithoutChangingInput(t *testing.T) {
original := "机械臂怎么复位?"
got := localizedQuery(original, "ja")
if got != "日本語で回答してください。\n\n"+original {
t.Fatalf("got %q", got)
}
if original != "机械臂怎么复位?" {
t.Fatal("original query changed")
}
}
-70
View File
@@ -1,70 +0,0 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
func reasoningAnswerFixture() (string, string, string) {
narration := "我需要先确认这台设备的型号和手册。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |\n| --- | --- |\n| J1 | 98 N·m |"
return narration, body, narration + "\n\n" + body
}
func reasoningEventClient(answer string) *fakeEventStreamingWeKnora {
return &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "我需要先确认这台设备的型号和手册。"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request-reasoning", Answer: answer},
}
}
func TestAskEventsReasoningKeepsOnlyAnswerBody(t *testing.T) {
_, body, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
client := reasoningEventClient(answer)
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-reasoning")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != body {
t.Fatalf("delivered answer must keep the body only, got %q (streamCalls=%d)", result.Answer, client.streamCalls)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:message-reasoning"]
repo.mu.Unlock()
if stored.Answer != body {
t.Fatalf("stored answer must keep the body only, got %q", stored.Answer)
}
}
func TestAskEventsQuickKeepsFullAnswer(t *testing.T) {
_, _, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
service, err := New(repo, fixedResolver{}, reasoningEventClient(answer), session.GroupShared)
if err != nil {
t.Fatal(err)
}
result, err := service.AskEvents(context.Background(), streamQARequest("message-quick"), func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != answer {
t.Fatalf("quick answer must be untouched, got %q", result.Answer)
}
}
+19 -16
View File
@@ -227,7 +227,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
return s.weknora.UpdateSessionTitle(updateCtx, existingSessionID, title, "Managed by DobotAI; business state remains outside WeKnora")
})
query := request.Query
query := localizedQuery(request.Query, request.ResponseLocale)
if request.ConversationType == core.ConversationGroup && strings.TrimSpace(request.SenderName) != "" {
query = request.SenderName + ": " + query
}
@@ -238,11 +238,6 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
chatRequest := weknora.AgentChatRequest{
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Images: images,
// The language preference travels as an Accept-Language request
// header (see weknora.resolveLocale), never as prompt text: WeKnora's
// system prompt enforces "ALWAYS respond in {{language}}" which is
// fed by that header, so a prompt-prefix instruction loses to it.
Locale: strings.TrimSpace(request.ResponseLocale),
}
// Track whether this turn actually triggered a knowledge-base search by
// observing knowledge_search tool-call events. The flag feeds coverage
@@ -328,16 +323,6 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
turn.WeKnoraRequestID = chat.RequestID
turn.KnowledgeSearched = knowledgeSearched
turn.AnswerMessageID = newID()
// A reasoning run reports every agent step's narrative through WeKnora's
// answer channel and appends the final answer body to that same
// accumulation. Keep the body only in the stored/delivered answer: the
// narration stays visible while the card streams (line by line) and is
// dropped once the final body starts.
if reasoningmode.IsReasoningExecution(request.ReasoningExecution) {
if trimmed := reasoningmode.TrimReasoningNarration(chat.Answer); trimmed != chat.Answer {
turn.Answer, chat.Answer = trimmed, trimmed
}
}
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
}
@@ -388,6 +373,24 @@ func isKnowledgeTool(toolName string) bool {
}
}
func localizedQuery(query, locale string) string {
query = strings.TrimSpace(query)
switch strings.TrimSpace(locale) {
case "zh-CN":
return "请使用简体中文回答。\n\n" + query
case "en":
return "Answer in English.\n\n" + query
case "ja":
return "日本語で回答してください。\n\n" + query
case "ko":
return "한국어로 답변해 주세요.\n\n" + query
case "es":
return "Responde en español.\n\n" + query
default:
return query
}
}
func normalizeRequestIdentity(request core.QARequest) core.QARequest {
request.TenantKey = strings.TrimSpace(request.TenantKey)
request.Channel = strings.TrimSpace(request.Channel)
-116
View File
@@ -1,116 +0,0 @@
package reasoningmode
import (
"regexp"
"strings"
)
// Reasoning agent runs report every intermediate step through WeKnora's
// response_type=answer channel: the step narratives are concatenated without
// any separator and the final answer body is appended to that same
// accumulation. A card update therefore shows one run-on "narration + body"
// blob. These helpers locate the boundary so callers can hide the narration
// and keep the body only.
//
// Calibration against 118 production answers of the built-in smart-reasoning
// agent: 106 had a structural first body line (97 heading, 4 bold-only line,
// 3 table row, 2 blockquote) and every one of them was directly preceded by a
// blank line; the remaining 12 answers were greetings without narration. The
// hidden narration was a short lead-in (median 111 chars, max 526 chars).
var (
answerHeadingLineRE = regexp.MustCompile(`^#{1,6}[\t ]+\S`)
answerBoldLineRE = regexp.MustCompile(`^\*\*[^*\n]+\*\*[\t ]*$`)
answerTableRowRE = regexp.MustCompile(`^\|.*\|[\t ]*$`)
answerQuoteLineRE = regexp.MustCompile(`^>[\t ]*\S`)
answerFenceLineRE = regexp.MustCompile("^(`{3,}|~{3,})")
)
// IsReasoningExecution reports whether execution runs the multi-step reasoning
// agent whose answer stream mixes step narration with the final answer body.
func IsReasoningExecution(execution string) bool {
switch ExecutionMode(strings.TrimSpace(execution)) {
case ExecutionReasoning, ExecutionReasoningSingle:
return true
}
return false
}
// SplitReasoningAnswer returns the final answer body. ok is false when the
// content carries no body boundary yet (nothing must be hidden in that case).
func SplitReasoningAnswer(content string) (string, bool) {
offset, ok := BodyStartOffset(content)
if !ok {
return content, false
}
return content[offset:], true
}
// TrimReasoningNarration drops the step narration that precedes the final
// answer body. Content without a body boundary is returned unchanged.
func TrimReasoningNarration(content string) string {
body, ok := SplitReasoningAnswer(content)
if !ok {
return content
}
return body
}
// BodyStartOffset returns the byte offset where the final answer body starts.
// A line qualifies as the body start when it is a structural Markdown line
// (heading, bold-only line, table row or blockquote) and is not inside a
// fenced code block, either directly following a blank line or being the very
// first non-blank line of the content.
//
// The first-line rule matters: 31 of 118 production reasoning answers open
// directly with their body ("# 标题" first). Without it the scan would skip
// that opening line and fire on the *next* structural line, so finalize
// deleted the answer's title plus its opening paragraph (up to 526 chars) while
// the streaming feed, whose display inserts blank lines between steps, kept
// them - mid-stream and finalize text disagreed.
func BodyStartOffset(content string) (int, bool) {
lines := strings.Split(content, "\n")
fence := ""
offset := 0
previousBlank := false
seenContent := false
for i, raw := range lines {
trimmed := strings.TrimSpace(raw)
if marker := answerFenceLineRE.FindString(trimmed); marker != "" &&
(fence == "" || strings.HasPrefix(trimmed, fence)) {
if fence == "" {
fence = marker
} else {
fence = ""
}
seenContent = true
previousBlank = false
offset += len(raw) + 1
continue
}
if fence == "" {
if !seenContent && isAnswerBodyLine(trimmed) {
return offset, true
}
if i > 0 && previousBlank && isAnswerBodyLine(trimmed) {
return offset, true
}
}
if trimmed != "" {
seenContent = true
}
previousBlank = trimmed == ""
offset += len(raw) + 1
}
return 0, false
}
func isAnswerBodyLine(trimmed string) bool {
if trimmed == "" {
return false
}
return answerHeadingLineRE.MatchString(trimmed) ||
answerBoldLineRE.MatchString(trimmed) ||
answerTableRowRE.MatchString(trimmed) ||
answerQuoteLineRE.MatchString(trimmed)
}
-116
View File
@@ -1,116 +0,0 @@
package reasoningmode
import (
"strings"
"testing"
)
func TestBodyStartOffsetRecognizesStructuralBodyLines(t *testing.T) {
leadIn := "根据检索到的资料,关于 CR20V 各关节电机最大旋转扭矩的说明如下:"
heading := "## CR20V 关节电机最大旋转扭矩"
tableBody := "**Magician 传感器套件(Magician Sensor Kit)重量如下:**\n\n| 项目 | 重量 |\n| --- | --- |\n| 整机 | 1.2 kg |"
rowBody := "| 关节 | 扭矩 |\n| --- | --- |"
quoteBody := "> 提示:以下参数来自产品手册。"
tests := []struct {
name string
answer string
want string
ok bool
}{
{
name: "narration then heading",
answer: "我已经收集了充分的信息。现在我可以明确回答用户的问题了。\n\n关键发现:\n1. 手册给出额定扭矩\n\n需要向用户说明\n\n" + heading + "\n\n各关节参数如下。",
want: heading + "\n\n各关节参数如下。",
ok: true,
},
{
name: "lead-in then bold body line",
answer: "我已经获取到完整且明确的信息,可以给出准确答案了。\n\n" + tableBody,
want: tableBody,
ok: true,
},
{
name: "short lead-in then table",
answer: leadIn + "\n\n" + rowBody,
want: rowBody,
ok: true,
},
{
name: "narration then blockquote",
answer: "我需要先确认使用场景。\n\n" + quoteBody,
want: quoteBody,
ok: true,
},
{
name: "greeting has no body boundary",
answer: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
want: "你好!我是越疆智能助手,可以帮你查询机械臂资料。",
ok: false,
},
{
name: "answer opens with its body: nothing is hidden",
answer: heading + "\n\n各关节参数如下。",
want: heading + "\n\n各关节参数如下。",
ok: true,
},
{
name: "title and opening paragraph survive finalize",
answer: "# 200万高职具身智能实训室搭配方案\n\n结合越疆现有的一体化实训平台,围绕具身智能教学场景,我们从教具、软件、课程三方面给出搭配建议。\n\n## 一、先明确三条搭配原则\n\n- 匹配课程大纲\n- 控制预算\n",
want: "# 200万高职具身智能实训室搭配方案\n\n结合越疆现有的一体化实训平台,围绕具身智能教学场景,我们从教具、软件、课程三方面给出搭配建议。\n\n## 一、先明确三条搭配原则\n\n- 匹配课程大纲\n- 控制预算\n",
ok: true,
},
{
name: "leading blank lines before the title",
answer: "\n\n# 标题\n\n正文。",
want: "# 标题\n\n正文。",
ok: true,
},
{
name: "structural line inside a fence is ignored",
answer: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
want: "我先确认一下配置项。\n\n```\n## 这是代码里的注释\n| a | b |\n```\n\n参考上面的写法。",
ok: false,
},
{
name: "structural line glued to narration is not a boundary",
answer: "需要向用户说明" + heading,
want: "需要向用户说明" + heading,
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, ok := SplitReasoningAnswer(tt.answer)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
if body != tt.want {
t.Fatalf("body = %q, want %q", body, tt.want)
}
if !strings.HasSuffix(tt.answer, body) {
t.Fatalf("body %q is not a suffix of the answer: the rule ate content", body)
}
if trimmed := TrimReasoningNarration(tt.answer); trimmed != tt.want {
t.Fatalf("TrimReasoningNarration = %q, want %q", trimmed, tt.want)
}
})
}
}
func TestIsReasoningExecution(t *testing.T) {
tests := map[string]bool{
"reasoning": true,
" reasoning ": true,
"reasoning_single": true,
"quick": false,
"": false,
"something": false,
}
for execution, want := range tests {
if got := IsReasoningExecution(execution); got != want {
t.Fatalf("IsReasoningExecution(%q) = %v, want %v", execution, got, want)
}
}
}
@@ -1,108 +0,0 @@
package reasoningmode
import (
"bufio"
"encoding/json"
"os"
"testing"
)
// TestReplayProductionCorpusThroughFeed replays production reasoning answers
// through the streaming feed the card pump actually uses, and checks two
// properties of every frame the card receives:
//
// 1. a frame that already contains a structural body line must start at that
// body (no narration glued in front of it). A frame that shows narration
// above the body is exactly the "the last reasoning sentence stays on the
// card" defect.
// 2. once a frame is body-only, no later frame may go back to showing
// narration.
//
// Enable with REASONING_CORPUS=<path to reasoning_corpus.jsonl>.
func TestReplayProductionCorpusThroughFeed(t *testing.T) {
path := os.Getenv("REASONING_CORPUS")
if path == "" {
t.Skip("set REASONING_CORPUS to replay production answers")
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("open corpus: %v", err)
}
defer file.Close()
chunkSizes := []int{11, 47, 199, 1001}
var rows, frames, bodyFrames int
type violation struct {
id string
size int
marks bool
prefix int
bytes int
}
var violations []violation
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1<<20), 1<<22)
for scanner.Scan() {
var row struct {
ID string `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatalf("decode row: %v", err)
}
rows++
for _, size := range chunkSizes {
for _, marks := range []bool{false, true} {
feed := NewPartialFeed("reasoning")
bodySeen := false
for start := 0; start < len(row.Content); start += size {
end := start + size
if end > len(row.Content) {
end = len(row.Content)
}
frame := feed.OnAnswer(row.Content[:end])
frames++
if firstStructuralLine(frame) == "" {
// no body in this frame yet: narration only, expected.
continue
}
offset, ok := BodyStartOffset(frame)
prefix := -1
if ok {
prefix = len([]rune(frame[:offset]))
}
if ok && prefix == 0 {
bodySeen = true
bodyFrames++
continue
}
// The frame carries a structural line but does not start at
// it: narration is still visible above the body.
violations = append(violations, violation{
id: row.ID,
size: size,
marks: marks,
prefix: prefix,
bytes: len(frame),
})
_ = bodySeen
}
}
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan corpus: %v", err)
}
t.Logf("rows=%d frames=%d body_only_frames=%d narration_above_body_frames=%d", rows, frames, bodyFrames, len(violations))
for i, v := range violations {
if i >= 8 {
t.Logf("... %d more", len(violations)-i)
break
}
t.Logf("violation id=%s chunk=%d step_boundaries=%t body_prefix_runes=%d bytes=%d", v.id, v.size, v.marks, v.prefix, v.bytes)
}
if len(violations) > 0 {
t.Errorf("%d frames showed narration above the answer body", len(violations))
}
}
@@ -1,92 +0,0 @@
package reasoningmode
import (
"bufio"
"encoding/json"
"os"
"strings"
"testing"
)
// TestReplayProductionCorpus replays real production answers of the built-in
// smart-reasoning agent through the body-boundary rule.
// Enable with REASONING_CORPUS=<path to reasoning_corpus.jsonl>.
func TestReplayProductionCorpus(t *testing.T) {
path := os.Getenv("REASONING_CORPUS")
if path == "" {
t.Skip("set REASONING_CORPUS to replay production answers")
}
file, err := os.Open(path)
if err != nil {
t.Fatalf("open corpus: %v", err)
}
defer file.Close()
var total, trimmed int
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1<<20), 1<<22)
for scanner.Scan() {
var row struct {
ID string `json:"id"`
Content string `json:"content"`
}
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatalf("decode row: %v", err)
}
total++
body, ok := SplitReasoningAnswer(row.Content)
if !ok {
continue
}
trimmed++
hidden := strings.TrimSpace(strings.TrimSuffix(row.Content, body))
t.Logf("id=%s hidden=%d body=%d head=%q", row.ID[:8], len([]rune(hidden)), len([]rune(body)), shorten(hidden, 60))
// Guard: the hidden prefix must be narration only. A structural line in
// there means the rule fired past the body start and ate answer content
// (that is exactly how the opening title + paragraph of 31 answers were
// deleted before the first-line rule existed).
if line := firstStructuralLine(hidden); line != "" {
t.Errorf("id=%s hidden prefix carries a structural body line %q: the rule ate answer content", row.ID[:8], shorten(line, 40))
}
if runes := len([]rune(hidden)); runes > maxHiddenNarrationRunes {
t.Errorf("id=%s hidden prefix is %d runes, over the %d-rune narration bound", row.ID[:8], runes, maxHiddenNarrationRunes)
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan corpus: %v", err)
}
t.Logf("total=%d narration_trimmed=%d unchanged=%d", total, trimmed, total-trimmed)
}
func shorten(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[len(runes)-limit:])
}
// maxHiddenNarrationRunes bounds the narration the rule may hide. Calibration
// on 118 production answers: the hidden lead-in was at most 526 runes.
const maxHiddenNarrationRunes = 600
// firstStructuralLine returns the first line that qualifies as answer body
// (heading, bold-only line, table row or blockquote), skipping fenced code.
func firstStructuralLine(text string) string {
fence := ""
for _, raw := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(raw)
if marker := answerFenceLineRE.FindString(trimmed); marker != "" &&
(fence == "" || strings.HasPrefix(trimmed, fence)) {
if fence == "" {
fence = marker
} else {
fence = ""
}
continue
}
if fence == "" && isAnswerBodyLine(trimmed) {
return trimmed
}
}
return ""
}
-86
View File
@@ -1,86 +0,0 @@
package reasoningmode
import "strings"
// PartialFeed assembles the streaming card content of one turn.
//
// A reasoning run streams every agent step's narrative through the answer
// channel and appends the final answer body to the same accumulation, so the
// raw text reaches the card as one run-on paragraph. The feed
//
// - joins step segments with a blank line, so the card shows the reasoning
// line by line instead of glued sentences, and
// - switches to the final body only as soon as the body starts. The DingTalk
// card update replaces the card content, so that switch is what the user
// sees as the "refresh": the reasoning block disappears and the body is
// streamed on its own.
//
// Quick executions are a pass-through and keep the previous behaviour.
type PartialFeed struct {
reasoning bool
seen string
display string
stepPending bool
bodyOnly string
bodyStarted bool
started bool
}
// NewPartialFeed builds the feed for one turn's execution mode.
func NewPartialFeed(execution string) *PartialFeed {
return &PartialFeed{reasoning: IsReasoningExecution(execution)}
}
// Started reports whether any answer text has been streamed yet.
func (f *PartialFeed) Started() bool {
return f.started
}
// StepBoundary marks the end of an agent step (tool call or tool result) so
// the next narrative segment starts on a new line.
func (f *PartialFeed) StepBoundary() {
if !f.reasoning {
return
}
f.stepPending = true
}
// OnAnswer records the accumulated answer text reported by the stream and
// returns the content the card must show right now.
func (f *PartialFeed) OnAnswer(fullAnswer string) string {
if !f.reasoning {
if strings.TrimSpace(fullAnswer) != "" {
f.started = true
}
return fullAnswer
}
delta := fullAnswer
switch {
case strings.HasPrefix(fullAnswer, f.seen):
delta = fullAnswer[len(f.seen):]
case fullAnswer != f.seen:
// The stream restarted (new WeKnora session or a retry): treat the
// text as a fresh segment instead of gluing it to the previous one.
f.stepPending = true
}
f.seen = fullAnswer
if delta != "" {
f.started = true
if f.stepPending && strings.TrimSpace(f.display) != "" {
f.display += "\n\n"
}
f.stepPending = false
f.display += delta
}
if body, ok := SplitReasoningAnswer(f.display); ok {
f.bodyStarted = true
f.bodyOnly = body
}
if f.bodyStarted {
return f.bodyOnly
}
return f.display
}
@@ -1,87 +0,0 @@
package reasoningmode
import (
"strings"
"testing"
)
func TestPartialFeedJoinsReasoningSegmentsAndRefreshesToBody(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
// Step 1: first narrative segment streams in.
step1 := "我先查一下知识库。"
if got := feed.OnAnswer(step1); got != step1 {
t.Fatalf("first segment = %q, want %q", got, step1)
}
if !feed.Started() {
t.Fatal("Started must be true after the first answer text")
}
// Step 1 ends with a tool call, step 2 starts with its own narrative.
feed.StepBoundary()
step2 := "找到了相关文档,我确认一下参数。"
got := feed.OnAnswer(step1 + step2)
want := step1 + "\n\n" + step2
if got != want {
t.Fatalf("second segment = %q, want %q (segments must be line separated)", got, want)
}
// Step 2 ends, step 3 carries the final answer body.
feed.StepBoundary()
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
got = feed.OnAnswer(step1 + step2 + body)
if got != body {
t.Fatalf("body-only content = %q, want %q (reasoning must be hidden)", got, body)
}
// The body keeps streaming; the visible content stays body-only.
grown := body + "\n| J1 | 100 N·m |"
if got := feed.OnAnswer(step1 + step2 + grown); got != grown {
t.Fatalf("grown body = %q, want %q", got, grown)
}
if strings.Contains(got, "我先查一下知识库") {
t.Fatalf("reasoning leaked into the refreshed card: %q", got)
}
}
func TestPartialFeedKeepsGluedNarrationReadable(t *testing.T) {
// WeKnora concatenates step narratives without any separator; the feed must
// not let the card show them as one run-on sentence.
feed := NewPartialFeed(string(ExecutionReasoning))
first := "我正在理解问题。"
feed.OnAnswer(first)
feed.StepBoundary()
second := "正在检索知识库。"
if got := feed.OnAnswer(first + second); got != first+"\n\n"+second {
t.Fatalf("glued narration = %q", got)
}
}
func TestPartialFeedStartsANewSegmentWhenTheStreamRestarts(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
feed.OnAnswer("第一段推理。")
got := feed.OnAnswer("重新开始的一段推理。")
if got != "第一段推理。\n\n重新开始的一段推理。" {
t.Fatalf("restart segment = %q", got)
}
}
func TestPartialFeedQuickExecutionIsPassThrough(t *testing.T) {
feed := NewPartialFeed(string(ExecutionQuick))
narrativeAndBody := "我已收集信息。\n\n## 标题\n\n正文"
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
t.Fatalf("quick execution = %q, want the raw stream unchanged", got)
}
feed.StepBoundary()
if got := feed.OnAnswer(narrativeAndBody); got != narrativeAndBody {
t.Fatalf("quick execution after boundary = %q, want the raw stream unchanged", got)
}
}
func TestPartialFeedWithoutBoundaryKeepsShowingReasoning(t *testing.T) {
feed := NewPartialFeed(string(ExecutionReasoning))
greeting := "你好!我是越疆智能助手。"
if got := feed.OnAnswer(greeting); got != greeting {
t.Fatalf("greeting = %q, want unchanged", got)
}
}
+8 -57
View File
@@ -49,28 +49,11 @@ type Service struct {
repo Repository
quickAgentID string
reasoningAgentID string
defaultMode Mode
now func() time.Time
idleTimeout time.Duration
}
// Option customises a reasoning mode Service.
type Option func(*Service) error
// WithDefaultMode sets the mode applied to users who never explicitly chose
// one with /think. Passing ModeQuick (the default when unset) preserves the
// historical behaviour of answering with the quick agent.
func WithDefaultMode(mode Mode) Option {
return func(s *Service) error {
if mode != ModeQuick && mode != ModeReasoning {
return fmt.Errorf("invalid default reasoning mode %q", mode)
}
s.defaultMode = mode
return nil
}
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string, opts ...Option) (*Service, error) {
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
quickAgentID = strings.TrimSpace(quickAgentID)
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
if repo == nil {
@@ -82,29 +65,13 @@ func NewService(repo Repository, quickAgentID, reasoningAgentID string, opts ...
if quickAgentID == reasoningAgentID {
return nil, errors.New("quick and reasoning agent IDs must differ")
}
service := &Service{
return &Service{
repo: repo,
quickAgentID: quickAgentID,
reasoningAgentID: reasoningAgentID,
defaultMode: ModeQuick,
now: time.Now,
idleTimeout: defaultIdleTimeout,
}
for _, opt := range opts {
if err := opt(service); err != nil {
return nil, err
}
}
return service, nil
}
// effectivelyReasoning reports whether a turn should be served by the
// reasoning agent given the resolved state and the configured default mode.
func (s *Service) effectivelyReasoning(state State) bool {
if state.Mode == ModeReasoning {
return true
}
return !state.UserSelected && s.defaultMode == ModeReasoning
}, nil
}
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
@@ -128,14 +95,10 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
state.MaxRounds,
)}, nil
case CommandDisable:
previous, _, err := s.repo.ResolveState(ctx, scope, s.now())
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
if err != nil {
return Decision{}, err
}
wasReasoning := s.effectivelyReasoning(previous)
if _, _, err = s.repo.Disable(ctx, scope, s.now()); err != nil {
return Decision{}, err
}
if !wasReasoning {
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
}
@@ -143,7 +106,7 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
case CommandSingle:
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
case CommandHelp:
return Decision{Handled: true, Reply: helpReply(s.defaultMode)}, nil
return Decision{Handled: true, Reply: helpReply()}, nil
default:
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
}
@@ -156,25 +119,13 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
if state.Mode == ModeReasoning {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
}
// The user never chose a mode explicitly: follow the operator default.
// Default-mode reasoning runs as a single reasoning turn so it never
// enters the rounds/idle-expiry machinery; the user can pin quick mode
// any time with /think off.
if s.effectivelyReasoning(state) {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
}
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
}
func helpReply(defaultMode Mode) string {
defaultLabel := "默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n"
if defaultMode == ModeReasoning {
defaultLabel = "默认模式:智能推理模式,支持多步思考与工具调用,效果更强、计费标准更高。\n" +
"快速问答模式:基于知识库快速准确作答,响应快、成本低,可用 /think off 切换并保持。\n"
}
func helpReply() string {
return "一、模式说明\n" +
defaultLabel + "\n" +
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
"二、模式切换指令\n" +
"/think + 你的问题:仅当前问题使用智能推理\n" +
"/think on:开启持续智能推理模式\n" +
-86
View File
@@ -23,7 +23,6 @@ func (f *fakeRepository) ResolveState(context.Context, Scope, time.Time) (State,
func (f *fakeRepository) Enable(context.Context, Scope, time.Time, time.Duration) (State, error) {
f.enableCalls++
f.state.Mode = ModeReasoning
f.state.UserSelected = true
f.state.RoundsUsed = 0
return f.state, nil
}
@@ -32,7 +31,6 @@ func (f *fakeRepository) Disable(context.Context, Scope, time.Time) (State, bool
f.disableCalls++
wasReasoning := f.state.Mode == ModeReasoning
f.state.Mode = ModeQuick
f.state.UserSelected = true
f.state.RoundsUsed = 0
return f.state, wasReasoning, nil
}
@@ -179,87 +177,3 @@ func TestPrepareReturnsLocalHelpWithoutStateMutation(t *testing.T) {
t.Fatalf("help mutated mode: %+v", repo)
}
}
func newTestServiceWithDefault(t *testing.T, repo Repository, mode Mode) *Service {
t.Helper()
service, err := NewService(repo, "builtin-quick-answer", "builtin-smart-reasoning", WithDefaultMode(mode))
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) }
return service
}
func TestNewServiceRejectsInvalidDefaultMode(t *testing.T) {
if _, err := NewService(&fakeRepository{}, "q", "r", WithDefaultMode("turbo")); err == nil {
t.Fatal("expected invalid default mode to fail")
}
}
func TestPrepareDefaultReasoningRoutesUnselectedUserToReasoningSingle(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestServiceWithDefault(t, repo, ModeReasoning).Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-smart-reasoning" || decision.Execution != ExecutionReasoningSingle {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareDefaultReasoningHonoursUserQuickSelection(t *testing.T) {
state := DefaultState()
state.UserSelected = true
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: state}, ModeReasoning).
Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareDefaultQuickKeepsLegacyBehaviour(t *testing.T) {
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeQuick).
Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick {
t.Fatalf("decision=%+v", decision)
}
}
func TestThinkOffPinsQuickModeUnderReasoningDefault(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
service := newTestServiceWithDefault(t, repo, ModeReasoning)
decision, err := service.Prepare(context.Background(), testScope(), "/think off")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || !strings.Contains(decision.Reply, "切回") {
t.Fatalf("decision=%+v", decision)
}
if !repo.state.UserSelected || repo.state.Mode != ModeQuick {
t.Fatalf("state=%+v", repo.state)
}
followUp, err := service.Prepare(context.Background(), testScope(), "后续问题")
if err != nil {
t.Fatal(err)
}
if followUp.AgentID != "builtin-quick-answer" {
t.Fatalf("follow-up escaped user quick selection: %+v", followUp)
}
}
func TestHelpReflectsReasoningDefaultMode(t *testing.T) {
decision, err := newTestServiceWithDefault(t, &fakeRepository{state: DefaultState()}, ModeReasoning).
Prepare(context.Background(), testScope(), "/help")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(decision.Reply, "默认模式:智能推理模式") {
t.Fatalf("help=%q", decision.Reply)
}
}
-1
View File
@@ -22,7 +22,6 @@ var ErrInvalidState = errors.New("invalid reasoning mode state")
type State struct {
Mode Mode
UserSelected bool
MaxRounds int
RoundsUsed int
HintEnabled bool
+6 -8
View File
@@ -61,7 +61,6 @@ func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.
return reasoningmode.State{}, err
}
state.Mode = reasoningmode.ModeReasoning
state.UserSelected = true
state.RoundsUsed = 0
state.LastActivityAt = now
state.ExpiresAt = now.Add(idle)
@@ -93,7 +92,6 @@ func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time
state, _ = reasoningmode.EffectiveState(state, now)
wasReasoning := state.Mode == reasoningmode.ModeReasoning
state.Mode = reasoningmode.ModeQuick
state.UserSelected = true
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
@@ -258,14 +256,14 @@ func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Sc
}
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3
FOR UPDATE`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
func selectReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
@@ -278,7 +276,7 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
expires pgtype.Timestamptz
onboarding pgtype.Timestamptz
)
if err := row.Scan(&mode, &state.UserSelected, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
if err := row.Scan(&mode, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
return reasoningmode.State{}, fmt.Errorf("scan reasoning state: %w", err)
}
state.Mode = reasoningmode.Mode(mode)
@@ -297,11 +295,11 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
func updateReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope, state reasoningmode.State, now time.Time) error {
_, err := tx.Exec(ctx, `UPDATE reasoning_mode_states SET
mode=$4,user_selected=$5,max_rounds=$6,rounds_used=$7,hint_enabled=$8,
last_activity_at=$9,expires_at=$10,onboarding_shown_at=$11,updated_at=$12
mode=$4,max_rounds=$5,rounds_used=$6,hint_enabled=$7,
last_activity_at=$8,expires_at=$9,onboarding_shown_at=$10,updated_at=$11
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`,
scope.TenantKey, scope.Channel, scope.ExternalActorID,
string(state.Mode), state.UserSelected, state.MaxRounds, state.RoundsUsed, state.HintEnabled,
string(state.Mode), state.MaxRounds, state.RoundsUsed, state.HintEnabled,
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
)
if err != nil {
-9
View File
@@ -54,12 +54,6 @@ type AgentChatRequest struct {
DisableTitle bool `json:"disable_title,omitempty"`
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
Images []Image `json:"images,omitempty"`
// Locale is the response language preference ("zh-CN", "en", "ja", "ko",
// "es", or "auto"). It is transmitted as the Accept-Language request
// header — WeKnora's Language middleware turns it into the {{language}}
// prompt placeholder. "auto" and "" mean: detect from the query text.
// Not serialized in the JSON body.
Locale string `json:"-"`
}
type Image struct {
@@ -154,9 +148,6 @@ func (c *Client) agentChat(ctx context.Context, sessionID string, request AgentC
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("X-API-Key", c.apiKey)
if locale := resolveLocale(request.Locale, request.Query); locale != "" {
req.Header.Set("Accept-Language", locale)
}
resp, err := c.http.Do(req)
if err != nil {
return ChatResult{}, fmt.Errorf("call weknora agent chat: %w", err)
-67
View File
@@ -1,67 +0,0 @@
package weknora
import (
"strings"
"unicode"
)
// fixedLocales are the response languages the platform understands end-to-end
// (settings command, preference storage, and WeKnora's LanguageLocaleName map).
var fixedLocales = map[string]bool{
"zh-CN": true, "en": true, "ja": true, "ko": true, "es": true,
}
// resolveLocale maps a user preference + query text to an Accept-Language
// value understood by WeKnora's Language middleware.
//
// - "" -> "" (send no header; WeKnora keeps its own fallback)
// - "auto" -> best-effort detection from the query script
// - a known fixed locale (e.g. "en", "ja") -> passed through verbatim
//
// WeKnora matches the first Accept-Language tag and maps it to a human
// language name for its prompts, so the returned tag must be one it knows
// ("zh-CN", "en", "ja", "ko", "es", ...). An "auto" query whose script
// cannot be classified returns "" so the server behaves exactly as before.
func resolveLocale(locale, query string) string {
locale = strings.TrimSpace(locale)
if fixedLocales[locale] {
return locale
}
if locale == "auto" {
return detectLocale(query)
}
return ""
}
// detectLocale classifies a query by the dominant non-ASCII script. Latin
// text maps to "en"; CJK ideographs to "zh-CN"; kana to "ja"; hangul to "ko";
// Spanish is not reliably script-detectable and is never auto-selected.
// Pure punctuation/digits/empty text return "" (no guess).
func detectLocale(query string) string {
var cjk, kana, hangul, latin int
for _, r := range query {
switch {
case unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
kana++
case unicode.Is(unicode.Han, r):
cjk++
case unicode.Is(unicode.Hangul, r):
hangul++
case r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z':
latin++
}
}
// Japanese mixes kanji in, so any kana presence wins over Han count.
switch {
case kana > 0:
return "ja"
case hangul > 0:
return "ko"
case cjk > 0:
return "zh-CN"
case latin > 0:
return "en"
default:
return ""
}
}
-96
View File
@@ -1,96 +0,0 @@
package weknora
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestResolveLocaleFixedPrefersPreference(t *testing.T) {
// A fixed preference must win over the query's own script.
for _, locale := range []string{"zh-CN", "en", "ja", "ko", "es"} {
if got := resolveLocale(locale, "how many CR series do we have?"); got != locale {
t.Fatalf("resolveLocale(%q) = %q, want %q", locale, got, locale)
}
}
}
func TestResolveLocaleAutoDetectsFromQuery(t *testing.T) {
cases := []struct{ query, want string }{
{"how many serise do we have for CR?", "en"},
{"cr一共有几个系列", "zh-CN"},
{"ロボットのアームリセット方法", "ja"},
{"机器人复位怎么操作", "zh-CN"},
{"로봇 암 리셋 방법", "ko"},
{"5013", ""}, // no script evidence: no guess
{"!!! ??", ""}, // punctuation only: no guess
}
for _, tc := range cases {
if got := resolveLocale("auto", tc.query); got != tc.want {
t.Fatalf("resolveLocale(auto, %q) = %q, want %q", tc.query, got, tc.want)
}
}
}
func TestResolveLocaleUnknownPreferenceSendsNothing(t *testing.T) {
// Empty and unrecognized locales must not fabricate a header; WeKnora
// keeps its own fallback behaviour, exactly like before this feature.
if got := resolveLocale("", "hello"); got != "" {
t.Fatalf("empty locale = %q, want none", got)
}
if got := resolveLocale("fr-CA", "hello"); got != "" {
t.Fatalf("unknown locale = %q, want none", got)
}
}
func TestAgentChatSendsAcceptLanguageHeader(t *testing.T) {
var gotHeader string
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("Accept-Language")
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client, err := NewClient(server.URL, "test-key", 0)
if err != nil {
t.Fatal(err)
}
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
Query: "how many serise do we have for CR?", AgentID: "a1", Locale: "en",
}); err != nil {
t.Fatal(err)
}
if gotHeader != "en" {
t.Fatalf("Accept-Language = %q, want %q", gotHeader, "en")
}
}
func TestAgentChatOmitsAcceptLanguageWhenNoPreference(t *testing.T) {
var sent bool
body := "data: {\"response_type\":\"answer\",\"content\":\"ok\"}\n\ndata: {\"response_type\":\"complete\",\"done\":true}\n\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, sent = r.Header["Accept-Language"]
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client, _ := NewClient(server.URL, "test-key", 0)
if _, err := client.AgentChat(context.Background(), "s1", AgentChatRequest{
Query: "cr有哪些系列", AgentID: "a1",
}); err != nil {
t.Fatal(err)
}
if sent {
t.Fatal("Accept-Language header must be absent without a locale")
}
}
func TestAgentChatRequestLocaleIsNotSerialized(t *testing.T) {
// Locale rides on the header only; the JSON body contract is unchanged.
// (Guards against a future refactor accidentally adding it to the payload.)
if got := resolveLocale("auto", "How to reset the robot arm?"); got != "en" {
t.Fatalf("auto detection broken: %q", got)
}
}
+153 -58
View File
@@ -6,96 +6,191 @@ import (
"testing"
)
func TestDockerDeploymentContainsOnlyDobotAIApplication(t *testing.T) {
func TestDobotAIDingTalkComposeOwnsApplicationAndPostgres(t *testing.T) {
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
if err != nil {
t.Fatal(err)
}
source := string(compose)
for _, required := range []string{"dobotai-dingtalk:", "container_name: dobotai-dingtalk", "network_mode: host", "env_file:", "read_only: true"} {
if !strings.Contains(source, required) {
for _, required := range []string{
"dobotai-dingtalk:",
"container_name: dobotai-dingtalk",
"dobotai-postgres:",
"container_name: dobotai-postgres",
"dobotai-postgres-data:",
"depends_on:",
"condition: service_healthy",
"host.docker.internal:host-gateway",
"127.0.0.1:8090:8090",
"env_file:",
"read_only: true",
"/var/lib/dobotai/secrets",
} {
if !strings.Contains(string(compose), required) {
t.Errorf("compose missing %q", required)
}
}
for _, forbidden := range []string{"dobotai-postgres", "postgres-data", "name: dobotai-network"} {
if strings.Contains(source, forbidden) {
t.Errorf("compose must not manage a local database resource %q", forbidden)
}
}
}
func TestRootInstallerBuildsImageWithoutOperatorImageArgument(t *testing.T) {
data, err := os.ReadFile("../install-docker.sh")
configure, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-configure")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{"docker build", "dobotai-dingtalk:local-", "install_docker_if_missing", "DOBOTAI_SOURCE_ROOT", "deploy/dobotai-dingtalk/install-docker.sh"} {
if !strings.Contains(source, required) {
t.Errorf("root installer missing %q", required)
for _, required := range []string{"configure_env.py", "dobotai-dingtalk-start"} {
if !strings.Contains(string(configure), required) {
t.Errorf("configure command missing %q", required)
}
}
if strings.Contains(source, "${DOBOTAI_IMAGE") {
t.Fatal("root installer must not require DOBOTAI_IMAGE")
}
}
func TestDockerfilePropagatesConfiguredGoProxyToModuleDownload(t *testing.T) {
data, err := os.ReadFile("../Dockerfile")
bind, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{"ARG GOPROXY=", "ENV GOPROXY=${GOPROXY}", "RUN go mod download"} {
if !strings.Contains(source, required) {
t.Errorf("Dockerfile missing %q", required)
}
}
if !(strings.Index(source, "ENV GOPROXY=${GOPROXY}") < strings.Index(source, "RUN go mod download")) {
t.Fatal("Dockerfile must set GOPROXY before downloading modules")
}
}
func TestRDSInitializerOnlyInitializesEmptySchemaAndRejectsInconsistentSchema(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/rds-schema-init.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{"pg_isready", "to_regclass('public.turns')", "empty", "normal", "inconsistent", "数据库不存在", "*.up.sql", "DATABASE_URL", "postgres:16-alpine"} {
if !strings.Contains(source, required) {
t.Errorf("RDS initializer missing %q", required)
}
}
for _, forbidden := range []string{"docker compose", "dobotai-postgres", "pg_dump -Fc", "DROP DATABASE", "DROP SCHEMA"} {
if strings.Contains(source, forbidden) {
t.Errorf("RDS initializer must not manage or destroy databases: %q", forbidden)
for _, required := range []string{"docker exec -it dobotai-dingtalk", "/usr/local/bin/dobotai bootstrap-admin"} {
if !strings.Contains(string(bind), required) {
t.Errorf("bind command missing %q", required)
}
}
}
func TestRDSCredentialConfiguratorPromptsForExternalDatabase(t *testing.T) {
func TestDobotAIDingTalkCredentialConfigurationNeverPassesSecretsOnCommandLine(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{"DATABASE_URL", "阿里云RDS", "RDS主机地址", "RDS端口", "数据库名", "数据库账号", "数据库密码", "快速问答Agent ID", "智能推理Agent ID", "getpass.getpass", "os.replace", "0o600", "OSS整组录入", "DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"} {
for _, required := range []string{"getpass.getpass", "os.replace", "0o600", "DINGTALK_CLIENT_SECRET"} {
if !strings.Contains(source, required) {
t.Errorf("RDS configurator missing %q", required)
t.Errorf("credential configurator missing %q", required)
}
}
if strings.Contains(source, "RDS PostgreSQL连接串") {
t.Fatal("operator must not be asked to type a whole DATABASE_URL string")
for _, forbidden := range []string{"subprocess.run([", "-v", "DINGTALK_CLIENT_SECRET="} {
if strings.Contains(source, forbidden) {
t.Errorf("credential configurator must not expose secret through process arguments: %q", forbidden)
}
}
if strings.Contains(source, "DobotAI租户键") || strings.Contains(source, "Agent Bindings JSON") || strings.Contains(source, "DobotAI Ingress Token") {
t.Fatal("operator must only be prompted for real RDS, WeKnora and DingTalk configuration")
}
installer, err := os.ReadFile("../deploy/dobotai-dingtalk/install-docker.sh")
}
func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExamples(t *testing.T) {
configurator, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(installer), "secrets.token_hex") || !strings.Contains(string(installer), "secrets.token_urlsafe") || !strings.Contains(string(installer), "DOBOTAI_TENANT_KEY=dobotai-") {
t.Fatal("installer must generate and persist internal tenant and ingress tokens")
documentation, err := os.ReadFile("../deploy/dobotai-dingtalk/README.md")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(configurator), "格式示例:{example}") || !strings.Contains(string(documentation), "格式示例") {
t.Fatal("configuration guidance must label every example")
}
for _, example := range []string{
"http://host.docker.internal:8080",
`{"dingtalk:*":"你的Agent ID"}`,
"DINGTALK_CLIENT_SECRET",
} {
if !strings.Contains(string(configurator), example) {
t.Errorf("configurator missing redacted example %q", example)
}
if !strings.Contains(string(documentation), example) {
t.Errorf("documentation missing redacted example %q", example)
}
}
if strings.Contains(string(configurator), "DATABASE_URL") {
t.Fatal("database URL must be deployment-managed, not requested interactively")
}
if !strings.Contains(string(documentation), "dobotai-postgres") {
t.Fatal("documentation must explain the Compose-managed database")
}
}
func TestDobotAIDingTalkStartRollsBackToPreviousContainerImage(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-start")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"previous_image=",
"set_runtime_image",
"docker inspect --format",
"set_runtime_image \"$previous_image\"",
"\"${compose[@]}\" up -d --no-build --force-recreate dobotai-dingtalk",
} {
if !strings.Contains(source, required) {
t.Errorf("start script missing container upgrade rollback %q", required)
}
}
}
func TestDobotAIDingTalkDockerInstallerPreparesDirectoriesDatabaseAndMigrations(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/install-docker.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"container", "secrets", "backups", "migrations", "postgres.env",
"dobotai-postgres", "apply-migrations.sh", "docker compose", "umask 077",
} {
if !strings.Contains(source, required) {
t.Errorf("docker installer missing %q", required)
}
}
if strings.Contains(source, "dobotai-dingtalk up -d") {
t.Fatal("installer must prepare database but must not start the unconfigured application")
}
}
func TestDobotAIDingTalkExistingDatabaseMigrationHasBackupAndRollbackBoundary(t *testing.T) {
data, err := os.ReadFile("../deploy/dobotai-dingtalk/migrate-existing-database.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"pg_dump -Fc", "pg_restore --list", "dobotai-postgres-data",
"dobotai-dingtalk", "rollback", "apply-migrations.sh",
} {
if !strings.Contains(source, required) {
t.Errorf("database adoption script missing %q", required)
}
}
}
func TestRootDockerInstallerIsCloneAndRunEntryPoint(t *testing.T) {
data, err := os.ReadFile("../install-docker.sh")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, required := range []string{
"docker build",
"dobotai-dingtalk:local-",
"install_docker_if_missing",
"docker compose version",
"DOBOTAI_SOURCE_ROOT",
"deploy/dobotai-dingtalk/install-docker.sh",
} {
if !strings.Contains(source, required) {
t.Errorf("root installer missing %q", required)
}
}
if strings.Contains(source, "${DOBOTAI_IMAGE") || strings.Contains(source, "DOBOTAI_IMAGE=?") {
t.Fatal("root installer must not require an operator supplied DOBOTAI_IMAGE")
}
}
func TestRootDockerInstallerBuildsWithInternalGoModuleProxy(t *testing.T) {
installer, err := os.ReadFile("../install-docker.sh")
if err != nil {
t.Fatal(err)
}
dockerfile, err := os.ReadFile("../Dockerfile")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(installer), "GOPROXY=https://goproxy.cn,direct") {
t.Fatal("root installer must pass an internal default GOPROXY to docker build")
}
if !strings.Contains(string(dockerfile), "ARG GOPROXY") || !strings.Contains(string(dockerfile), "GOPROXY=$GOPROXY go mod download") {
t.Fatal("Dockerfile must accept and use the root installer GOPROXY")
}
}