Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9871760bc0 | |||
| f533c385ea | |||
| bcb2f303ff |
+2
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+211
-106
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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初始化完成。"
|
||||
@@ -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
|
||||
|
||||
+40
-13
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -629,7 +629,7 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
|
||||
|
||||
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
|
||||
if h.outboundRewriter == nil {
|
||||
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(content))
|
||||
return sanitizeDisplayAnswer(content)
|
||||
}
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
@@ -637,7 +637,7 @@ func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) st
|
||||
}
|
||||
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
defer cancel()
|
||||
return normalizeWideMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)))
|
||||
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
|
||||
}
|
||||
|
||||
func (h *Handler) sanitizeOutboundPartial(content string) string {
|
||||
|
||||
@@ -1,218 +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
|
||||
|
||||
// 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 {
|
||||
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])...)
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
out = append(out, line.text)
|
||||
i++
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func normalizeTableBlock(rows []markdownStreamLine) []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 {
|
||||
// Compact tables keep their exact original bytes unless a cell carries
|
||||
// <br>, which the card renderer mishandles inside cells.
|
||||
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,72 +0,0 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"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 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -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
|
||||
@@ -378,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)
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user