Compare commits

..

11 Commits

82 changed files with 3925 additions and 2964 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
+22 -28
View File
@@ -1,58 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
# values together; leave all four empty to disable. Required when the support
# group notification below is enabled. Never commit real IDs.
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# Optional PDF-answer image egress for DingTalk cards. When any value is set,
# all five values are required. DobotAI downloads only authenticated resource://
# images from WEKNORA_BASE_URL /files, writes content-hashed copies under
# dobotai/im-images/, and emits private OSS GET URLs valid for 2 hours.
# Keep the bucket private and configure an OSS lifecycle rule to delete
# dobotai/im-images/ objects after 1 day. Never commit real credentials.
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
# Optional support group for uncovered-answer notifications. This is the
# group's openConversationId, not a webhook. Leave empty to record coverage
# decisions and pending support cases without sending group notifications.
# When enabled, the FAQ table workflow above must also be configured so the
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+6
View File
@@ -10,6 +10,12 @@ coverage.out
/backups/
/check-live-dobotai.py
# Transient local validation and legacy test-deployment artifacts
/dobotai
/.acceptance-canary/
/.hermes/
/deploy_stable_images_test.py
# Editors and OS
.idea/
.vscode/
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
FROM golang:1.26.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
-o /out/dobotai ./cmd/dobotai
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/dobotai /usr/local/bin/dobotai
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/dobotai"]
+169 -77
View File
@@ -1,115 +1,207 @@
# DobotAI Platform
# DobotAI 平台
DobotAI is the external enterprise integration layer for official WeKnora. It does not import WeKnora packages, modify WeKnora source, or access the WeKnora database.
DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份与会话映射、消息编排、流式卡片、媒体收发、覆盖判定和人工支持通知。它只通过 WeKnora 官方 REST/SSE API 调用知识问答与 Agent Chat:不导入 WeKnora 私有包、不修改 WeKnora 源码,也不访问 WeKnora 数据库。
Current milestone: channel-bound Agent QA core.
## 当前能力
Implemented:
- 通过官方 `POST /api/v1/sessions` 与 `POST /api/v1/agent-chat/:session_id` 调用 WeKnora;
- 严格解析 SSE 的 `answer`、`references`、`tool_call`、`tool_result`、`error` 与终止事件;`error + done=false` 被视为可恢复的工具错误,不会提前结束钉钉卡片;
- 解析普通引用及智能推理工具结果中的检索证据,供覆盖判定与后端审计使用;
- 按渠道、会话和用户维护长期 WeKnora Session,并保证回调幂等;
- 钉钉 Stream 机器人支持文本、richText 以及多图片输入;图片下载经过钉钉官方接口、大小与文件签名校验后,才以 WeKnora 通用多模态请求发送;
- 钉钉 AI 流式卡片与最终消息回退;通道侧保留原始回答,不修改 WeKnora 持久化消息;
- 默认快速问答,支持 `/think <问题>` 单次推理、`/think on` 持续推理、`/think off` 或 `/rag` 关闭推理;快速问答与智能推理共用同一 WeKnora Session;
- 用户级推理轮次、空闲到期、提示开关和首次使用指引;
- 成功回答的覆盖判定、持久化 Support Case、FAQ Outbox 与钉钉人工支持群通知;
- 对已覆盖答案点踩时创建 `feedback_down` 人工处理提醒;对已先因未覆盖创建 Case 的同一问题保持“一题一个 Case”幂等;
- 可选的钉钉问题表异步写入;写入失败不会改变问答成功状态;
- 可选的回答图片外发:仅将 WeKnora `resource://` 图片通过鉴权下载、内容哈希转存到私有 OSS 后,以受控 HTTPS 地址提供给钉钉。
- official `POST /api/v1/sessions` adapter;
- official `POST /api/v1/agent-chat/:session_id` adapter;
- fail-closed SSE parsing for `references`, `answer`, `error`, and terminal `done`;
- per-channel/per-conversation Agent binding;
- long-lived direct-user sessions;
- shared-group sessions, with optional group+user policy;
- external-message idempotency;
- independent PostgreSQL session/turn storage;
- authenticated internal `POST /v1/qa` ingress endpoint.
- unauthenticated read-only `GET /version` runtime build identity endpoint.
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
## 架构边界
## DingTalk channel (Stream, text only)
```text
钉钉 / 内部调用方
↓
DobotAI
├─ 钉钉 Stream、richText、图片、AI 卡片、反馈与通知
├─ 独立 PostgreSQL:渠道身份、消息、会话映射、Turn、覆盖与支持流程
└─ WeKnora 官方 REST / SSE API
↓
WeKnora:检索、Agent、知识与引用
```
- Uses the official DingTalk Stream SDK (`dingtalk-stream-sdk-go`); no public webhook endpoint is exposed.
- Text messages only for now. Images, OpenAPI fallback, and card replies are not implemented yet.
- Group chats are handled only when the bot is @-mentioned; other group messages are ignored.
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
责任边界:
## DingTalk question table sync (Notable v1)
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
## 目录结构
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
```text
.
├── cmd/
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
│ └── directory-import/ 钉钉通讯录导入命令
├── internal/
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
│ ├── weknora/ SSE、工具结果和引用解析
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
│ ├── reasoningmode/ /think 推理模式
│ ├── settings/ /setting 与首位管理员绑定
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
│ ├── questiontable/ 问题表工作流
│ ├── faqtable/ FAQ 表工作流
│ ├── directory/ 钉钉通讯录查询与导入
│ ├── audit/ 审计查询
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
│ ├── core/ 核心领域类型
│ ├── session/ 会话键和会话标题
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
│ ├── feedback/ 点赞/点踩领域逻辑
│ ├── secretstore/ 本地密钥引用存储
│ ├── dingtalkconfig/ 钉钉凭据校验
│ ├── runtime/ 运行时管理
│ └── store/postgres/ PostgreSQL 持久化实现
├── db/migrations/ SQL 迁移及其契约测试
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
├── docs/ 架构说明与 API 边界文档
├── scripts/ 本地部署与部署资产契约测试
├── Dockerfile DobotAI 容器镜像构建
├── .env.example 无真实凭据的配置示例
└── README.md 项目功能、边界和开发说明
```
- `DINGTALK_QUESTION_TABLE_BASE_ID`
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
## 钉钉 Stream
## Uncovered-question FAQ workflow
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
- 群聊仅在机器人被 `@` 时处理;
- 私聊按用户保持长期会话;群聊默认按群共享会话;
- 回调 `sessionWebhook` 严格限制为 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- 启用 Stream 必须同时设置以下三项;三者均为空时渠道禁用,部分配置会失败关闭:
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
```text
DINGTALK_CLIENT_ID
DINGTALK_CLIENT_SECRET
DOBOTAI_TENANT_KEY
```
- `DINGTALK_FAQ_TABLE_BASE_ID`
- `DINGTALK_FAQ_TABLE_SHEET_ID`
- `DINGTALK_FAQ_TABLE_VIEW_ID`
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
## 推理模式命令
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
```text
/think <问题> 仅当前问题使用智能推理
/think on 开启持续智能推理
/think off 或 /rag 关闭持续智能推理
/setting rounds <1-50> 设置持续推理自动关闭轮数
/setting hint <on|off> 设置剩余轮次提示
/help、/帮助、/h 查看当前模式和帮助
```
## WeKnora baseline
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
Target policy: latest stable official Release/Tag. The newest locally fetched stable tag is `v0.6.3`. Online GitHub verification is pending because DNS/proxy access was unavailable during initialization.
## 首位管理员终端绑定
## Run
首位管理员不通过网页生成验证码。先确保钉钉 Stream 机器人已经正常运行,登录部署服务器后执行:
1. Create an independent PostgreSQL database named `dobotai`.
2. Apply `db/migrations/000001_core.up.sql` to that database only.
3. Copy `.env.example` to a local `.env` and supply secrets outside Git.
4. Export the variables and run:
```bash
dobotai bootstrap-admin
```
若二进制未加入 `PATH`,使用其实际绝对路径执行。该命令只读取 `DATABASE_URL` 和 `DOBOTAI_TENANT_KEY`,不会启动 WeKnora、钉钉消费者或问答服务。
命令会在终端使用加密随机数生成一个有效期10分钟的六码绑定码,并保持等待。候选管理员必须私聊机器人发送:
```text
/setting <六码绑定码>
```
验证码正确后,终端自动将该钉钉用户绑定为 `super_admin` 并显示成功。已有有效管理员时命令会拒绝执行,防止通过该入口追加或替换管理员。DobotAI 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。
## 覆盖与人工支持
- 有结构化检索引用的回答标记为已覆盖;
- 没有引用但已执行检索的回答标记为未覆盖;
- 没有引用且未执行检索的文本标记为 `covered_non_knowledge_response`,不创建 Support Case;
- 纯图片视觉识别单独记录为 `covered_visual_recognition`,不计入“未覆盖”;
- 无权威知识时,产品统一回复:`当前暂无已发布的权威知识,暂不建议操作。`;
- 人工通知只带当前问题和同一用户此前最多5个问题,不包含回答、引用或置信度;
- 未覆盖与点踩共用幂等 Support Case,避免对同一 Turn 重复通知。
## 配置
复制 `.env.example` 为仅本机可读的运行时环境文件,并在 Git 外注入真实值。不得提交 API Key、数据库密码、钉钉凭据、OSS AccessKey、预签名 URL 或真实用户/群组标识。
关键配置:
```text
DOBOTAI_LISTEN_ADDR
DOBOTAI_INGRESS_TOKEN
DOBOTAI_AGENT_BINDINGS
DOBOTAI_QUICK_AGENT_ID
DOBOTAI_REASONING_AGENT_ID
WEKNORA_BASE_URL
WEKNORA_API_KEY
DATABASE_URL
```
可选能力必须整组配置或整组留空:
- 钉钉 Stream;
- 钉钉 AI 卡片;
- 未覆盖问题 FAQ 表;
- 人工支持群通知;
- 问题表异步写入;
- 回答图片私有 OSS 外发。
任何部分配置均应在启动时失败关闭,而不是以不完整权限运行。
## 数据库迁移
DobotAI 使用独立 PostgreSQL,禁止指向 WeKnora 数据库。生产部署前必须按版本顺序执行并演练 `db/migrations/` 中的迁移;当前发布范围包含:
```text
000001_core ... 000018_coverage_visual_recognition_reason
000019_reasoning_mode_state
000020_feedback_support_trigger
000021_coverage_non_knowledge_response_reason
```
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
## 本地运行
```bash
go run ./cmd/dobotai
```
Health check:
健康检查:
```bash
curl http://127.0.0.1:8090/healthz
```
Runtime version check:
运行版本:
```bash
curl http://127.0.0.1:8090/version
```
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
accepts the deployment only when `/version` reports the exact source commit.
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
部署脚本应将 `version`、`commit_sha` 和 `build_time` 写入二进制,并仅在 `/version` 返回预期提交时接受部署。候选源码必须干净,避免运行版本掩盖未提交改动。
Internal QA request:
## 安全要求
```bash
curl -X POST http://127.0.0.1:8090/v1/qa \
-H "Authorization: Bearer $DOBOTAI_INGRESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"tenant_key":"default",
"channel":"dingtalk",
"conversation_type":"direct",
"external_user_id":"user-1",
"external_message_id":"message-1",
"query":"hello"
}'
```
- 不修改 WeKnora 源码、Schema 或私有包;
- 不直接连接 WeKnora 数据库;
- 不将凭据、Token、预签名 URL 或知识正文写入日志;
- 不接受任意外部图片 URL 作为可信媒体;
- 对钉钉图片、WeKnora `resource://` 图片和 OSS 返回体均执行来源、大小、类型和签名校验;
- 外发 OSS Bucket 保持私有;
- 生产、测试的钉钉应用、数据库、OSS Bucket 和凭据必须相互隔离;
- 生产上线必须使用已验收的同一构建产物,禁止在生产重新编译。
## Hard boundaries
- No WeKnora source modifications.
- No custom WeKnora tables or columns.
- No direct WeKnora database access.
- No private WeKnora package imports.
- All compatibility logic stays in `internal/weknora`.
- Business data stays in DobotAI PostgreSQL.
- Redis will be used only for queues, retries, idempotency leases, and temporary state.
See `docs/architecture.md` for ownership and migration decisions.
更多设计与边界见 `docs/architecture.md`。
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
const bootstrapAdminTTL = 10 * time.Minute
type bootstrapAdminRepository interface {
AdministratorCount(context.Context, string) (int, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
}
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer store.Close()
repository, err := postgres.NewSettingsRepository(store)
if err != nil {
return err
}
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
}
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
}
count, err := repository.AdministratorCount(ctx, tenant)
if err != nil {
return fmt.Errorf("check active administrators: %w", err)
}
if count > 0 {
return errors.New("an active administrator already exists; use administrator management instead")
}
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
if err != nil {
return fmt.Errorf("generate bootstrap code: %w", err)
}
code := fmt.Sprintf("%06d", codeNumber.Int64())
expiresAt := now().Add(bootstrapAdminTTL)
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
return fmt.Errorf("create bootstrap challenge: %w", err)
}
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
waitFor := expiresAt.Sub(now())
if waitFor <= 0 {
return errors.New("bootstrap binding expiry must be in the future")
}
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
defer cancel()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
err := repository.ConfirmBootstrap(waitCtx, tenant)
if err == nil {
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
return nil
}
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
return fmt.Errorf("confirm bootstrap candidate: %w", err)
}
select {
case <-waitCtx.Done():
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
case <-ticker.C:
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type bootstrapAdminRepoStub struct {
administratorCount int
createdCode string
createdExpiry time.Time
confirmCalls int
}
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
return r.administratorCount, nil
}
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
r.createdCode, r.createdExpiry = code, expiry
return nil
}
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
r.confirmCalls++
if r.confirmCalls == 1 {
return settings.ErrBootstrapCandidatePending
}
return nil
}
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
repo := &bootstrapAdminRepoStub{}
var output bytes.Buffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if repo.createdCode != "000000" {
t.Fatalf("code=%q", repo.createdCode)
}
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("expiry=%s", repo.createdExpiry)
}
if repo.confirmCalls < 2 {
t.Fatalf("confirm calls=%d", repo.confirmCalls)
}
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("output=%q missing %q", output.String(), required)
}
}
}
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
repo := &bootstrapAdminRepoStub{administratorCount: 1}
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("err=%v", err)
}
if repo.createdCode != "" || repo.confirmCalls != 0 {
t.Fatalf("repo mutated: %+v", repo)
}
}
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
repo := &bootstrapAdminRepoStub{}
original := createBootstrapChallenge
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
return errors.New("database unavailable")
}
t.Cleanup(func() { createBootstrapChallenge = original })
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("err=%v", err)
}
}
-81
View File
@@ -1,81 +0,0 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateConfigRepository interface {
CandidateDingTalkConfig(context.Context, string) (string, string, error)
ActivateDingTalkCandidate(context.Context, string, string) error
}
type candidateCredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
var (
candidateTokenURL = ""
candidateTableBaseURL = ""
)
type dingTalkCandidateActivator struct {
tenant string
repo candidateConfigRepository
secrets *secretstore.Store
verifier candidateCredentialVerifier
bindings map[string]settings.ResourceBinding
restart func()
}
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
return errors.New("candidate_activation_unavailable")
}
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
if err != nil {
return errors.New("candidate_not_found")
}
secret, err := a.secrets.Get(secretRef)
if err != nil {
return errors.New("candidate_secret_unavailable")
}
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
return errors.New("candidate_oauth_failed")
}
for _, kind := range []string{"question_table", "faq_table"} {
binding, ok := a.bindings[kind]
if !ok {
continue
}
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
if err != nil {
return errors.New("candidate_table_config_failed")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return errors.New("candidate_table_probe_failed")
}
required := questiontable.QuestionSchema()
if kind == "faq_table" {
required = questiontable.FAQSchema()
}
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
return errors.New("candidate_table_schema_failed")
}
}
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
return errors.New("candidate_database_activation_failed")
}
go func() {
time.Sleep(300 * time.Millisecond)
a.restart()
}()
return nil
}
-75
View File
@@ -1,75 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateRepoStub struct {
activated bool
ref string
}
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "app", r.ref, nil
}
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
r.activated = true
return nil
}
type candidateVerifierStub struct{ ok bool }
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: v.ok}
}
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
if err := a.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected error")
}
if repo.activated {
t.Fatal("must not activate")
}
}
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
restarted := make(chan struct{}, 1)
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
if err := a.ActivateCandidate(context.Background()); err != nil {
t.Fatal(err)
}
if !repo.activated {
t.Fatal("expected activation")
}
select {
case <-restarted:
case <-time.After(time.Second):
t.Fatal("restart not requested")
}
}
+20 -34
View File
@@ -12,7 +12,6 @@ import (
"syscall"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
@@ -22,6 +21,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
@@ -44,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo {
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
@@ -63,8 +69,7 @@ func main() {
address = "127.0.0.1:8090"
}
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
return
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
if err != nil {
@@ -74,6 +79,14 @@ func main() {
if err != nil {
log.Fatal(err)
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
)
if err != nil {
log.Fatal(err)
}
groupPolicy := session.GroupPolicy(strings.TrimSpace(os.Getenv("DOBOTAI_GROUP_SESSION_POLICY")))
if groupPolicy == "" {
groupPolicy = session.GroupShared
@@ -93,6 +106,9 @@ func main() {
qa.WithSucceededTurnObserver(coverageService, func(err error) {
log.Printf("coverage persistence failed: %v", err)
}),
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
log.Printf("reasoning mode completion failed: %T", err)
}),
)
if err != nil {
log.Fatal(err)
@@ -119,11 +135,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
verifier: settingsRuntime.verifier, bindings: activeBindings,
restart: func() { restartRequested.Store(true); cancel() },
})
runtimeReady := make(chan struct{})
faqCfg, err := readFAQConfig()
if err != nil {
@@ -295,6 +306,7 @@ func main() {
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
},
)
@@ -316,37 +328,11 @@ func main() {
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
close(runtimeReady)
_, questionBound := activeBindings["question_table"]
_, faqBound := activeBindings["faq_table"]
_, supportBound := activeBindings["support_group"]
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
if weknoraHealthURL != "" {
weknoraHealthURL += "/health"
}
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
metrics: store,
tenant: dingCfg.TenantKey,
weknoraHealthURL: weknoraHealthURL,
httpClient: &http.Client{Timeout: 3 * time.Second},
now: time.Now,
version: runtimeBuildInfo().Version,
streamRunning: streamClient != nil,
questionRunning: questionTableDone != nil,
faqRunning: faqWorkflowDone != nil,
supportRunning: supportDispatcher != nil,
questionBound: questionBound,
faqBound: faqBound,
supportBound: supportBound,
ossConfigured: outboundOSSConfigured(),
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
+16
View File
@@ -183,3 +183,19 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
t.Fatal("complete stable image configuration should be active")
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, forbidden := range []string{"internal/adminweb", "adminweb.", `"/admin/"`, "runSetupOnly"} {
if strings.Contains(source, forbidden) {
t.Fatalf("main must not expose web administration: found %q", forbidden)
}
}
if !strings.Contains(source, "DingTalk credentials are required") {
t.Fatal("main must fail closed when DingTalk credentials are absent")
}
}
-164
View File
@@ -1,164 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type adminComponent = adminweb.Component
type operationalMetrics = postgres.OperationalMetrics
type operationalMetricsSource interface {
Ping(context.Context) error
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
}
type operationalStatusProvider struct {
metrics operationalMetricsSource
tenant string
weknoraHealthURL string
httpClient *http.Client
now func() time.Time
version string
streamRunning bool
questionRunning bool
faqRunning bool
supportRunning bool
questionBound bool
faqBound bool
supportBound bool
ossConfigured bool
}
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
now := time.Now()
if p.now != nil {
now = p.now()
}
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
metrics := operationalMetrics{}
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
} else {
var err error
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
if err != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
} else {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
}
}
components = append(components,
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
p.weknoraComponent(ctx, now),
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
supportComponent(p, metrics, now),
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
)
return components, nil
}
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
if strings.TrimSpace(p.weknoraHealthURL) == "" {
component.Code = "health_url_missing"
component.Detail = "健康探针地址未配置"
return component
}
client := p.httpClient
if client == nil {
client = &http.Client{Timeout: 3 * time.Second}
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
if err != nil {
return component
}
resp, err := client.Do(req)
if err != nil {
return component
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
component.Code = "health_http_error"
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
return component
}
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
component.LastSuccess = now
return component
}
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
if failed > 0 {
component.State, component.Code = "error", "outbox_failed"
} else if pending > 0 {
component.State, component.Code = "warning", "outbox_pending"
}
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
return component
}
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
switch {
case !p.supportBound:
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
case !p.faqBound:
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
case metrics.SupportFailed > 0:
component.State, component.Code = "error", "notification_failed"
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
case metrics.SupportPending > 0:
component.State, component.Code = "warning", "notification_pending"
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
case p.supportRunning:
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
default:
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
}
return component
}
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
if !bound {
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
}
if !running {
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
}
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
}
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
if ok {
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
}
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
}
func safeVersion(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
if len(value) > 80 {
return value[:80]
}
return value
}
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer health.Close()
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
LastQuestionSuccess: checked.Add(-time.Minute),
}},
weknoraHealthURL: health.URL,
httpClient: health.Client(),
now: func() time.Time { return checked },
version: "settings-test",
streamRunning: true,
questionRunning: true,
faqRunning: false,
supportRunning: false,
questionBound: true,
faqBound: false,
supportBound: true,
ossConfigured: true,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "运行版本", "ok", "version_ok")
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
for _, component := range components {
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
if strings.Contains(component.Detail, forbidden) {
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
}
}
}
}
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{}},
weknoraHealthURL: "http://127.0.0.1:1/health",
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
now: time.Now,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
}
type operationalMetricsStub struct {
value operationalMetrics
err error
}
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
return s.value, s.err
}
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
func componentMap(values []adminComponent) map[string]adminComponent {
out := make(map[string]adminComponent, len(values))
for _, value := range values {
out[value.Name] = value
}
return out
}
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
t.Helper()
value, ok := values[name]
if !ok {
t.Fatalf("missing component %q: %+v", name, values)
}
if value.State != state || value.Code != code {
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type dingTalkReasoningRouter struct {
service *reasoningmode.Service
}
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
}, message.Text)
if err != nil {
return dingtalk.ReasoningDecision{}, err
}
return dingtalk.ReasoningDecision{
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
mainSource, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
wiringSource, err := os.ReadFile("reasoning_wiring.go")
if err != nil {
t.Fatal(err)
}
mainText := string(mainSource)
for _, required := range []string{
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
`reasoningmode.NewService`,
`qa.WithReasoningModeFinalizer`,
`ReasoningRouter:`,
} {
if !strings.Contains(mainText, required) {
t.Errorf("main reasoning wiring missing %q", required)
}
}
for _, required := range []string{
"dingTalkReasoningRouter",
"ReasoningMessage",
"service.Prepare",
} {
if !strings.Contains(string(wiringSource), required) {
t.Errorf("reasoning adapter missing %q", required)
}
}
}
+8 -20
View File
@@ -7,9 +7,7 @@ import (
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
@@ -29,16 +27,12 @@ func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.Settings
}
type settingsRuntime struct {
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
admin *adminweb.ControlService
clientID string
secret string
secretRef string
secrets *secretstore.Store
verifier *dingtalkconfig.Verifier
pendingActivation bool
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
clientID string
secret string
secretRef string
}
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
@@ -62,8 +56,7 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
if err != nil {
return nil, err
}
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
if err != nil {
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
return nil, err
}
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
@@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
return nil, err
}
}
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
if err != nil {
return nil, err
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
if runtime == nil || runtime.admin == nil {
return nil, errors.New("setup runtime unavailable")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
})
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
return mux, nil
}
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
bindings: map[string]settings.ResourceBinding{},
restart: func() { restartRequested.Store(true); cancel() },
})
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
if err != nil {
log.Fatal(err)
}
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI setup-only listener on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("setup HTTP server failed: %v", err)
cancel()
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("setup HTTP shutdown: %v", err)
}
if restartRequested.Load() {
os.Exit(75)
}
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type setupRepoStub struct{}
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "", "", false, nil
}
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "", "", nil
}
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
return nil
}
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
return nil
}
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
type setupVerifierStub struct{}
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{}
}
func setupControl(t *testing.T) *adminweb.ControlService {
t.Helper()
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
if err != nil {
t.Fatal(err)
}
return control
}
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
runtime := &settingsRuntime{admin: setupControl(t)}
h, err := newSetupOnlyHandler(runtime, "admin-token")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("health status=%d", resp.Code)
}
var health map[string]string
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
if health["status"] != "ok" || health["mode"] != "setup_required" {
t.Fatalf("health=%v", health)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
if resp.Code != http.StatusOK {
t.Fatalf("version status=%d", resp.Code)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
if resp.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d", resp.Code)
}
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
resp = httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
if err == nil {
t.Fatal("expected token error")
}
}
@@ -0,0 +1,3 @@
-- 000019_reasoning_mode_state down migration.
DROP TABLE IF EXISTS reasoning_mode_turns;
DROP TABLE IF EXISTS reasoning_mode_states;
@@ -0,0 +1,43 @@
-- 000019_reasoning_mode_state: actor-scoped /think state and
-- per-turn idempotency for successful reasoning-mode completions.
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
external_actor_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'quick'
CHECK (mode IN ('quick', 'reasoning')),
max_rounds INTEGER NOT NULL DEFAULT 10
CHECK (max_rounds BETWEEN 1 AND 50),
rounds_used INTEGER NOT NULL DEFAULT 0
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_activity_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
onboarding_shown_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, channel, external_actor_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
ON reasoning_mode_states (expires_at)
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
external_actor_id TEXT NOT NULL,
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
@@ -0,0 +1,79 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_STATES",
"TENANT_KEY",
"CHANNEL",
"EXTERNAL_ACTOR_ID",
"MODE",
"MAX_ROUNDS",
"ROUNDS_USED",
"HINT_ENABLED",
"EXPIRES_AT",
"ONBOARDING_SHOWN_AT",
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
"MODE IN ('QUICK', 'REASONING')",
"MAX_ROUNDS BETWEEN 1 AND 50",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning state migration missing %q", required)
}
}
}
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_TURNS",
"TURN_ID",
"EXTERNAL_ACTOR_ID",
"EXECUTION_MODE",
"ROUND_NUMBER",
"SHOWED_HINT",
"AUTO_CLOSED",
"SHOWED_ONBOARDING",
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
"REFERENCES TURNS(ID)",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning turn ledger migration missing %q", required)
}
}
}
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning down migration missing %q", required)
}
}
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
if strings.Contains(source, forbidden) {
t.Errorf("reasoning down migration must not contain %q", forbidden)
}
}
}
@@ -0,0 +1,5 @@
-- 000020_feedback_support_trigger down migration.
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS trigger_feedback_id,
DROP COLUMN IF EXISTS trigger_type;
@@ -0,0 +1,11 @@
-- 000020_feedback_support_trigger: let an existing support-case workflow be
-- triggered by a downvote while preserving one case per successful Turn.
ALTER TABLE support_cases
ALTER COLUMN coverage_decision_id DROP NOT NULL,
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
ON support_cases (trigger_type, created_at DESC);
@@ -0,0 +1,50 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE SUPPORT_CASES",
"TRIGGER_TYPE",
"COVERAGE_UNCOVERED",
"FEEDBACK_DOWN",
"TRIGGER_FEEDBACK_ID",
"REFERENCES ANSWER_FEEDBACK(ID)",
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support migration missing %q", required)
}
}
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
}
}
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support down migration missing %q", required)
}
}
if strings.Contains(source, "DROP TABLE") {
t.Fatal("feedback support down migration must not drop shared tables")
}
}
@@ -0,0 +1,18 @@
-- 000021_coverage_non_knowledge_response_reason down migration.
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
UPDATE coverage_decisions
SET reason_code = 'covered_has_references'
WHERE reason_code = 'covered_non_knowledge_response';
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -0,0 +1,14 @@
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
-- did not invoke knowledge search from actual retrieval misses.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition',
'covered_non_knowledge_response'
));
@@ -0,0 +1,44 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_VISUAL_RECOGNITION",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"UPDATE COVERAGE_DECISIONS",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_HAS_REFERENCES",
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
} {
if !strings.Contains(source, required) {
t.Errorf("down migration missing %q", required)
}
}
}
+280
View File
@@ -0,0 +1,280 @@
# DobotAI DingTalk 容器部署手册
> 适用范围:DobotAI 测试环境的容器化运行。容器和 Compose 服务名固定为 `dobotai-dingtalk`。
> 本文不授权生产部署;生产必须使用已验收的同一镜像、独立备份和独立变更审批。
## 1. 定位
DobotAI 是钉钉渠道的对话编排与交付层,不是知识库或文档解析平台。
它负责:
- 接收钉钉 Stream 消息、富文本和图片;
- 将请求路由到 WeKnora Agent,并维护渠道会话;
- 通过钉钉 AI 卡片或 Markdown 流式交付回答;
- 处理 `/think`、`/setting` 等渠道命令;
- 保存 Turn、会话映射、覆盖判定、Support Case、反馈和审计数据;
- 对“真实检索过但没有引用”的回答创建未覆盖提醒;
- 将 `resource://` 图片按私有 OSS 规则安全外发。
它不负责:
- WeKnora 文档、Chunk、向量、图谱、解析或模型运行;
- WeKnora Agent 的知识检索策略;
- 直接暴露 PostgreSQL、WeKnora 或对象存储到公网;
- 替代钉钉应用、机器人或群权限配置。
## 2. 架构
```text
钉钉用户 / 群
│ DingTalk Stream
▼
┌──────────────────────────────────────┐
│ dobotai-dingtalk 容器 │
│ - 消息解析、会话编排、卡片流式交付 │
│ - /think、/setting、首位管理员绑定 │
│ - 覆盖判定、Support Case、审计 │
└───────┬──────────────────┬───────────┘
│ │
│ HTTP Agent Chat │ PostgreSQL
▼ ▼
WeKnora 服务 DobotAI 独立数据库
- Agent/检索 - Turn / 会话 / 覆盖判定
- 文档/Chunk - Support Case / 反馈 / 设置
- 模型与解析
```
### 2.1 容器边界
| 资源 | 归属 | 是否由本容器创建/删除 |
| --- | --- | --- |
| `dobotai-dingtalk` | DobotAI应用进程 | 是,容器可重建 |
| DobotAI PostgreSQL | 独立数据服务 | 否,保留现有数据卷 |
| WeKnora | 独立知识服务 | 否 |
| `/var/lib/dobotai-test/secrets` | 宿主机密钥目录 | 仅挂载读写,不迁移、不输出 |
| `/etc/dobotai-test/dobotai.env` | 受保护运行配置 | Docker读取,脚本原子更新 |
测试环境的 PostgreSQL、WeKnora 已通过宿主机回环地址提供服务,因此 Compose 使用 `network_mode: host`。这不是新开公网端口:DobotAI仍按 `DOBOTAI_LISTEN_ADDR` 监听,默认是 `127.0.0.1:8090`。
### 2.2 容器安全属性
Compose 固定启用:
```text
容器名:dobotai-dingtalk
网络:host(仅兼容现有测试宿主机回环依赖)
运行身份:与原 dobotai-test 服务用户相同的 UID:GID
根文件系统:只读
临时写入:仅 /tmp tmpfs
额外权限:no-new-privileges
重启策略:unless-stopped
```
对应的关键 Compose 配置如下;不要为避免端口冲突额外添加 `ports:` 映射:
```yaml
services:
dobotai-dingtalk:
container_name: dobotai-dingtalk
network_mode: host
read_only: true
restart: unless-stopped
```
## 3. 安装前检查
安装操作人员需要具备测试服务器的 `sudo` 和 Docker 权限。开始前确认:
```text
1. 已构建或导入经审查的 DobotAI 镜像;
2. 现有 /etc/dobotai-test/dobotai.env 存在且权限为 0600;
3. 现有密钥目录 /var/lib/dobotai-test/secrets 存在;
4. DobotAI PostgreSQL 与 WeKnora 已健康;
5. 已记录当前原生服务二进制SHA和运行状态;
6. 如本版本包含 SQL 迁移,已先完成逻辑备份与迁移演练。
```
不要执行:
```text
docker compose down -v
docker volume rm / prune
删除 PostgreSQL / WeKnora 数据卷
将密钥、数据库连接串或 API Key 写入命令行、日志或 Git
```
## 4. 构建和导入镜像
在已审查且工作树干净的源码目录构建。示例中的值均为占位符:
```bash
docker build \
--build-arg VERSION=test-<commit-short> \
--build-arg COMMIT_SHA=<完整提交SHA> \
--build-arg BUILD_TIME=<UTC时间> \
-t dobotai-dingtalk:test-<commit-short> .
```
跨主机传输时先保存镜像归档并记录 SHA-256;测试服务器导入后必须复核归档哈希和镜像标签。生产不得在目标机重新编译,应使用已测试的同一镜像归档或不可变镜像摘要。
## 5. 安装容器资产(不切换服务)
将本目录、候选镜像和已核验的归档放到测试服务器的受控发布目录。确认镜像已经 `docker load` 后执行:
```bash
sudo DOBOTAI_IMAGE=dobotai-dingtalk:test-<commit-short> \
bash ./install-test.sh
```
该脚本会:
```text
1. 检查候选镜像、现有 env 与密钥目录;
2. 备份原生 DobotAI 二进制和 systemd Unit,并写入 SHA-256;
3. 安装 Compose、配置器、启动器和绑定脚本;
4. 生成权限 0600 的 /opt/dobotai-test/container/runtime.env;
5. 保持原生 dobotai-test.service 继续运行。
```
它不会读取或打印环境变量值、不会修改数据库、不会启动容器。
## 6. 配置并启动
日常只需要执行:
```bash
sudo dobotai-dingtalk-configure
```
该命令交互更新 `/etc/dobotai-test/dobotai.env`。每项都会显示用途和脱敏格式示例;已配置的值永不回显,直接回车表示保留。
| 环境变量 | 用途 | 格式示例 |
| --- | --- | --- |
| `DATABASE_URL` | DobotAI独立PostgreSQL连接串,不得填写WeKnora数据库 | `postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable` |
| `WEKNORA_BASE_URL` | 测试WeKnora地址 | `http://127.0.0.1:8080` |
| `WEKNORA_API_KEY` | DobotAI使用的WeKnora API密钥 | `<你的WeKnora API Key>` |
| `DOBOTAI_INGRESS_TOKEN` | `/v1/qa`调用令牌 | `<随机长字符串>` |
| `DOBOTAI_TENANT_KEY` | 与既有DobotAI数据一致的租户键 | `dobotai-test` |
| `DOBOTAI_AGENT_BINDINGS` | 钉钉会话到WeKnora Agent的映射 | `{"dingtalk:*":"你的Agent ID"}` |
| `DINGTALK_CLIENT_ID` | 钉钉应用Client ID/AppKey | `dingxxxxxxxx` |
| `DINGTALK_CLIENT_SECRET` | 钉钉应用Client Secret/AppSecret | `<你的钉钉 AppSecret>` |
保存成功后,配置器自动调用启动器:
```text
停止原生 dobotai-test.service
→ 启动 dobotai-dingtalk
→ 检查 http://127.0.0.1:8090/healthz
→ 健康成功后禁用原生服务
```
首次容器启动失败会恢复原生服务;容器版本升级失败会恢复上一容器镜像。
## 7. 首位管理员绑定
仅当该租户不存在有效管理员时执行:
```bash
sudo dobotai-dingtalk-bind-admin
```
终端会显示一个10分钟有效的随机六码。候选管理员必须私聊钉钉机器人发送:
```text
/setting <六码绑定码>
```
验证成功后,终端自动写入 `super_admin/active` 并退出。已有有效管理员时命令拒绝执行,防止借由该入口追加管理员或意外提权。
## 8. 覆盖判定与未覆盖提醒
覆盖判定基于真实的 `knowledge_searched` 工具事件和结构化引用,不按关键词猜测。
| 场景 | 覆盖结果 | 是否创建 Support Case |
| --- | --- | --- |
| 有引用 | `covered_has_references` | 否 |
| 实际检索过但无引用 | `uncovered_no_references` | 是 |
| 明确无权威知识/无答案 | `uncovered_explicit_no_answer` | 是 |
| 图片未检索、纯视觉识别 | `covered_visual_recognition` | 否 |
| 文本未检索、闲聊或普通非知识响应 | `covered_non_knowledge_response` | 否 |
因此“在吗”“你好”“谢谢”等未触发知识检索的文本不会产生未覆盖提醒;真正检索过但没有权威引用的问题仍会进入人工支持流程。
## 9. 数据库迁移门禁
DobotAI进程只连接数据库,**不会自动发现或执行 SQL 迁移**。包含新迁移的版本必须在容器替换前由发布流程显式完成:
```text
1. 对 DobotAI PostgreSQL 做逻辑备份;
2. 使用 pg_restore --list 验证备份非空;
3. 在事务中执行已审查的 .up.sql;
4. 向 dobotai_schema_migrations 写入对应 version/name/applied_at;
5. 查询约束、列或索引确认生效;
6. 再替换容器镜像并进行健康与业务验收。
```
`000021_coverage_non_knowledge_response_reason` 是一个示例:它只扩展 `coverage_decisions.reason_code` 的 CHECK 约束,不回写历史Turn、覆盖判定或Support Case。
## 10. 日常检查
```bash
# 健康检查
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
# 运行版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
# 容器状态与重启次数
sudo docker inspect -f '{{.Config.Image}}|{{.State.Running}}|{{.RestartCount}}' dobotai-dingtalk
# 最近日志(不要复制含敏感内容的原始上下文)
sudo docker logs --since 10m dobotai-dingtalk
```
验收至少应同时满足:
```text
容器 running,重启次数为0;
/healthz 返回 ok;
/version 的 commit_sha 与发布记录一致;
钉钉 Stream 仅启动一个消费者;
旧 dobotai-test.service 为 inactive + disabled;
目标业务路径已由真实钉钉消息验证。
```
## 11. 升级与回滚
### 升级
```text
构建/导入经审查的新镜像
→ 备份DobotAI PostgreSQL
→ 应用并记录必要迁移
→ 将 runtime.env 中的 DOBOTAI_IMAGE 更新为新标签
→ 执行 sudo dobotai-dingtalk-start
→ 健康、版本、日志、钉钉真实消息验收
```
注意拼写:数据库和应用名称是 `DobotAI`;上述流程中的镜像名为 `dobotai-dingtalk`。
### 自动回滚
启动器会保存替换前的容器镜像:
```text
首次容器化失败:停止候选容器,恢复原生服务;
后续容器升级失败:停止候选容器,恢复上一容器镜像。
```
数据库迁移不能仅靠容器回滚撤销。只有经过审查且已验证的 `.down.sql` 才可用于数据库回滚;任何数据恢复都必须先确认备份可用。
## 12. 明确非目标
```text
不容器化或重建 WeKnora;
不容器化或删除 DobotAI PostgreSQL;
不重新启用已删除的网页管理面;
不在生产环境复用测试凭据、测试镜像或未验收的迁移;
不把密钥、Token、连接串、钉钉凭据写入 Git、命令行或日志。
```
+22
View File
@@ -0,0 +1,22 @@
services:
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}"
env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment:
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
volumes:
- type: bind
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets
read_only: false
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
security_opt:
- no-new-privileges:true
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
from pathlib import Path
import sys
FIELDS = (
("DATABASE_URL", "DobotAI PostgreSQL 连接串", "DobotAI独立数据库,不得填写WeKnora数据库。", "postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable", True),
("WEKNORA_BASE_URL", "WeKnora 地址", "容器通过宿主网络访问测试WeKnora。", "http://127.0.0.1:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "在WeKnora中为DobotAI创建的API密钥。", "<你的WeKnora API Key>", True),
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", "调用 /v1/qa 时使用的独立长随机令牌。", "<随机长字符串>", True),
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", "必须与现有DobotAI数据和Agent绑定使用同一个租户键。", "dobotai-test", 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 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
return lines, values
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
rendered.append(line)
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 容器运行环境")
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 环境文件不存在,请先完成部署。")
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)
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
container="dobotai-dingtalk"
if [[ "$(docker inspect --format '{{.State.Running}}' "$container" 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
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 /opt/dobotai-test/container/configure_env.py \
--env-file /etc/dobotai-test/dobotai.env \
--start-script /usr/local/sbin/dobotai-dingtalk-start
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
container_dir="/opt/dobotai-test/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
legacy_service="dobotai-test.service"
health_url="http://127.0.0.1:8090/healthz"
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
echo "容器运行文件未安装。请先部署 dobotai-dingtalk。" >&2
exit 1
fi
legacy_was_active=false
if systemctl is-active --quiet "$legacy_service"; then
legacy_was_active=true
systemctl stop "$legacy_service"
fi
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()
updated = False
rendered = []
for line in lines:
if line.startswith("DOBOTAI_IMAGE="):
rendered.append("DOBOTAI_IMAGE=" + image)
updated = True
else:
rendered.append(line)
if not updated:
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() {
docker compose --env-file "$runtime_env" -f "$compose_file" down --remove-orphans >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then
set_runtime_image "$previous_image"
docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate >/dev/null 2>&1 || true
elif [[ "$legacy_was_active" == "true" ]]; then
systemctl start "$legacy_service" || true
fi
}
trap rollback ERR
docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
systemctl disable "$legacy_service" >/dev/null
trap - ERR
echo "DobotAI 容器已启动:dobotai-dingtalk"
exit 0
fi
sleep 1
done
if [[ -n "$previous_image" ]]; then
echo "DobotAI 容器健康检查失败,已回滚到上一容器镜像。" >&2
else
echo "DobotAI 容器健康检查失败,已回滚到原生服务。" >&2
fi
exit 1
+54
View File
@@ -0,0 +1,54 @@
#!/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"
@@ -0,0 +1,6 @@
# 非敏感容器运行参数;install-test.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
+16 -16
View File
@@ -1,21 +1,21 @@
# WeKnora API Capability Gaps
# WeKnora API 能力缺口
These gaps must not be solved with private WeKnora source or database changes.
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
| Capability | Current external handling |
| 能力 | 当前外部处理方式 |
|---|---|
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
| Support case, owner, SLA, and status | DobotAI workflow |
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
Allowed responses to a gap:
面对能力缺口时,只允许:
1. adjust the product design;
2. implement it outside WeKnora;
3. open an upstream issue;
4. submit a general upstream pull request.
1. 调整产品设计;
2. 在 WeKnora 外部实现;
3. 提交上游 Issue;
4. 提交通用的上游 Pull Request。
+74 -50
View File
@@ -1,74 +1,98 @@
# DobotAI External Architecture
# DobotAI 外部集成架构
## Boundary
## 系统边界
```text
DingTalk / WeCom / Web / Software
-> DobotAI ingress and workflows
钉钉 / 企业微信 / Web / 业务软件
-> DobotAI 接入层与工作流
-> WeKnora Adapter
-> official WeKnora REST API
-> official WeKnora RAG and Agent
-> WeKnora 官方 REST API
-> WeKnora 官方 RAG 与 Agent
```
DobotAI PostgreSQL owns channel identities, external messages, session mappings, turns, feedback, coverage decisions, support cases, human replies, FAQ workflow state, and content-source mappings. WeKnora owns only its official data model.
DobotAI PostgreSQL 负责渠道身份、外部消息、会话映射、Turn、反馈、覆盖判定、Support Case、人工回复、FAQ 工作流状态和内容源映射。WeKnora 仅负责其官方数据模型。
## Session policy
## 会话策略
Default policy:
默认策略:
- direct: one long-lived session per tenant + channel + Agent + external user;
- group: one shared session per tenant + channel + Agent + external group;
- group messages are admitted only when the bot is mentioned or an explicit command triggers it;
- sender display name is prefixed to the query so a shared Agent can distinguish speakers;
- future controls must include idle rotation and an explicit new-conversation command;
- confidential channels may switch to `per_user`, yielding group + user sessions.
- 私聊:每个 `tenant + channel + Agent + 外部用户` 使用一个长期会话;
- 群聊:每个 `tenant + channel + Agent + 外部群` 使用一个共享会话;
- 群消息仅在机器人被提及或命中显式命令时进入处理;
- 将发送人显示名添加到问题前缀,使共享 Agent 能区分发言者;
- 后续控制项必须包含空闲轮换和显式新建会话命令;
- 保密渠道可切换为 `per_user`,形成群组加用户维度的会话。
Shared group sessions are the common collaboration-bot pattern because all members see the same conversation. Group+user sessions provide stronger context isolation but make replies less coherent for collaborative group discussions.
共享群会话符合协作机器人场景:所有成员能看到相同上下文。群组加用户会话能增强上下文隔离,但会削弱协作讨论中的回复连贯性。
## Agent binding
快速问答与智能推理必须复用同一个 WeKnora Session;`/think` 仅改变当前 Turn 的执行 Agent,不得重建会话映射。
Agents are configuration-owned. Channel callers cannot choose an arbitrary `agent_id`.
## Agent 绑定
Resolution order:
Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
1. exact `<channel>:<external_chat_id>` binding;
2. channel wildcard `<channel>:*`;
3. fail closed when no binding exists.
解析顺序:
## Image failure policy
1. 精确 `<channel>:<external_chat_id>` 绑定;
2. 渠道通配 `<channel>:*`;
3. 没有任何绑定时失败关闭。
Before enabling image traffic, the WeKnora Adapter must run a contract test against the selected official release and configured vision model.
## 首位管理员终端绑定
If any image path fails:
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
1. do not resend the question without the image;
2. do not return partial model output as success;
3. return a typed `vision_unavailable` failure to the channel adapter;
4. DingTalk tells the user that image recognition is temporarily unavailable and asks them to retry or send text;
5. emit an operational alert to verify Agent image upload settings, selected vision model, provider connectivity, and model capability;
6. keep image traffic disabled until the contract test passes.
```bash
dobotai bootstrap-admin
```
No WeKnora source patch is allowed as a workaround.
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
## Service ownership
```text
/setting <六码绑定码>
```
- `internal/weknora`: official REST/SSE compatibility only.
- `internal/qa`: business orchestration and terminal-state ownership.
- `internal/session`: external-to-WeKnora session policy.
- `internal/store/postgres`: DobotAI business persistence only.
- `internal/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
- future `channels/*`: platform callbacks and outbound replies.
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
- model gateway: token accounting, routing, quotas, circuit breaking, and provider observability.
- Redis: queue, retry, short leases, and temporary stream state only.
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
## Upgrade contract
## 图片失败策略
For every official WeKnora upgrade:
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
1. update the pinned official release/tag;
2. run official database migrations against WeKnora only;
3. run DobotAI Adapter contract tests;
4. verify text Agent Chat, references, terminal errors, Stop, images, ingestion, and FAQ;
5. promote the official image only after the contract suite passes.
任一图片路径失败时:
1. 不得去掉图片后重发同一问题;
2. 不得将部分模型输出当作成功结果;
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
6. 契约测试通过前保持图片流量禁用。
不得通过修改 WeKnora 源码规避上述失败。
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
## 服务职责
- `internal/weknora`:仅处理 WeKnora 官方 REST/SSE 兼容;解析普通引用和智能推理工具结果引用;
- `internal/qa`:业务编排与 Turn 终态归属;
- `internal/session`:外部渠道到 WeKnora 的会话策略;
- `internal/store/postgres`:仅保存 DobotAI 业务数据;
- `internal/coverage`:已覆盖/未覆盖/纯视觉识别判定、持久化 Support Case、有限重试和稳定错误码;通知失败不得改变成功问答的结果;
- `internal/channels/dingtalk`:钉钉 Stream 接入、richText 与多图片归一化、AI 流式卡片、命令路由、反馈与通知。仅在 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 和 `DOBOTAI_TENANT_KEY` 均已设置时启用。群聊必须 `@` 机器人;私聊按用户映射长期会话,群聊默认映射共享会话;`sessionWebhook` 必须是 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- `internal/reasoningmode`:`/think` 单次与持续推理、轮数、空闲到期、提示和首次使用引导;
- `internal/outboundimage`:WeKnora `resource://` 图片的受控鉴权下载和私有 OSS 外发;
- `future channels/*`:其他平台的回调与外发回复;
- `future content-sync/*`:钉钉知识库、NAS 与 CMS 同步;
- 模型网关:Token 统计、路由、配额、熔断和供应商可观测性;
- Redis:仅用于队列、重试、短期租约和临时流状态。
## 升级契约
每次升级官方 WeKnora 时:
1. 更新固定的官方 Release/Tag;
2. 仅对 WeKnora 执行官方数据库迁移;
3. 执行 DobotAI Adapter 契约测试;
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
5. 仅在契约测试通过后推广官方镜像。
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
-205
View File
@@ -1,205 +0,0 @@
package adminweb
import (
"context"
"errors"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type ConfigRepository interface {
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
CandidateDingTalkConfig(context.Context, string) (string, string, error)
SaveDingTalkConfig(context.Context, string, string, string, bool) error
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
AdministratorCount(context.Context, string) (int, error)
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
type AdministratorRepository interface {
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
DisableAdministrator(context.Context, string, string) error
}
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
type OperationalStatusProvider interface {
Components(context.Context) ([]Component, error)
}
type CredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
type ControlService struct {
tenant string
repo ConfigRepository
secrets *secretstore.Store
verifier CredentialVerifier
activator CandidateActivator
status OperationalStatusProvider
}
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
return nil, errors.New("admin control dependencies are required")
}
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
}
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
administratorViews := []AdministratorView{}
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
if listErr != nil {
return Snapshot{}, listErr
}
activeCount := 0
for _, administrator := range administrators {
if administrator.Status == "active" {
activeCount++
}
}
for _, administrator := range administrators {
administratorViews = append(administratorViews, AdministratorView{
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
DisplayName: administrator.DisplayName, Department: administrator.Department,
Role: administrator.Role, Status: administrator.Status,
CanDisable: administrator.Status == "active" && activeCount > 1,
})
}
}
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
now := time.Now()
components := []Component{
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
}
if s.status != nil {
operational, probeErr := s.status.Components(ctx)
if probeErr != nil {
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
} else {
components = append(components, operational...)
}
}
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
}
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
r := s.verifier.Verify(ctx, id, secret)
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
}
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
ref, err := s.secrets.Put("dingtalk", secret)
if err != nil {
return err
}
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
_ = s.secrets.Delete(ref)
return err
}
if oldRef != "" && oldRef != ref {
_ = s.secrets.Delete(oldRef)
}
return nil
}
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
s.status = provider
}
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
if s.activator == nil {
return errors.New("candidate activator unavailable")
}
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
if err != nil {
return err
}
if err = s.activator.ActivateCandidate(ctx); err != nil {
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
return errors.New("candidate activation failed")
}
return nil
}
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
}
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
return s.repo.ConfirmBootstrap(ctx, s.tenant)
}
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
staffID = strings.TrimSpace(staffID)
if staffID == "" {
return settings.ErrAdministratorNotFound
}
repo, ok := s.repo.(AdministratorRepository)
if !ok {
return errors.New("administrator management unavailable")
}
return repo.DisableAdministrator(ctx, s.tenant, staffID)
}
func maskID(v string) string {
v = strings.TrimSpace(v)
if len(v) <= 6 {
return v
}
return v[:3] + "***" + v[len(v)-3:]
}
func status(ok bool) string {
if ok {
return "ok"
}
return "unconfigured"
}
func countStatus(n int) string {
if n > 0 {
return "ok"
}
return "unconfigured"
}
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "configured"
}
return "unconfigured"
}
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "binding_active"
}
return "binding_missing"
}
func ternaryCode(ok bool, success, failure string) string {
if ok {
return success
}
return failure
}
func itoa(n int) string {
if n == 0 {
return "0"
}
b := make([]byte, 0, 8)
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
-210
View File
@@ -1,210 +0,0 @@
package adminweb
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type controlRepo struct {
savedVerified bool
savedRef string
candidateRef string
failedCode string
admins []settings.Administrator
disabledStaff string
disableErr error
}
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "old", "old-ref", true, nil
}
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "new", r.candidateRef, nil
}
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
r.savedRef = ref
r.savedVerified = verified
r.candidateRef = ref
return nil
}
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
r.failedCode = code
return nil
}
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
if r.admins == nil {
return 1, nil
}
n := 0
for _, admin := range r.admins {
if admin.Status == "active" {
n++
}
}
return n, nil
}
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
return r.admins, nil
}
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
r.disabledStaff = staffID
return r.disableErr
}
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
type verifyOK struct{}
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
}
type activatorStub struct {
err error
calls int
}
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
type operationalStatusStub struct {
components []Component
err error
}
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
return s.components, s.err
}
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
checked := time.Unix(100, 0)
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
}}})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, component := range snapshot.Components {
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
found = true
}
}
if !found {
t.Fatalf("components=%+v", snapshot.Components)
}
}
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
last := snapshot.Components[len(snapshot.Components)-1]
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
t.Fatalf("component=%+v", last)
}
}
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
t.Fatal(err)
}
if repo.savedVerified {
t.Fatal("candidate must not be active")
}
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
t.Fatalf("ref=%q", repo.savedRef)
}
}
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{candidateRef: "candidate-ref"}
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetCandidateActivator(activator)
if err = service.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected failure")
}
if activator.calls != 1 || repo.failedCode != "activation_failed" {
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
}
}
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{admins: []settings.Administrator{
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
}}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
t.Fatalf("administrators=%+v", snapshot.Administrators)
}
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
}
}
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
t.Fatal(err)
}
if repo.disabledStaff != "staff-old" {
t.Fatalf("disabled=%q", repo.disabledStaff)
}
}
-253
View File
@@ -1,253 +0,0 @@
package adminweb
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type Snapshot struct {
SystemState, ClientIDMasked string
SecretConfigured bool
Components []Component
Administrators []AdministratorView
}
type AdministratorView struct {
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
CanDisable bool
}
type Component struct {
Name, State, Code, Detail string
LastSuccess, CheckedAt time.Time
}
type Verification struct {
OK bool
Code string
CheckedAt time.Time
}
type Service interface {
Snapshot(context.Context) (Snapshot, error)
SaveCredentials(context.Context, string, string) error
VerifyCredentials(context.Context, string, string) Verification
}
type BootstrapService interface {
CreateBootstrap(context.Context, string) error
ConfirmBootstrap(context.Context) error
}
type CandidateService interface {
ActivateCandidate(context.Context) error
}
type AdministratorService interface {
DisableAdministrator(context.Context, string) error
}
type server struct {
service Service
token string
csrfToken string
mux *http.ServeMux
page *template.Template
}
type pageData struct {
Snapshot
CSRFToken string
}
const settingsPage = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>DobotAI 设置</title>
<style>
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
</style>
</head>
<body>
<h1>DobotAI 配置中心</h1>
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
</body></html>`
func New(service Service, token string) http.Handler {
functions := template.FuncMap{"timeText": func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.UTC().Format("2006-01-02 15:04:05")
}}
csrfBytes := make([]byte, 32)
if _, err := rand.Read(csrfBytes); err != nil {
panic("admin CSRF token unavailable")
}
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
s.mux.HandleFunc("GET /admin/settings", s.settings)
s.mux.HandleFunc("GET /admin/setup", s.settings)
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !s.authorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *server) authorized(r *http.Request) bool {
if s.token == "" {
return false
}
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
return true
}
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
}
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return false
}
provided := strings.TrimSpace(r.FormValue("csrf_token"))
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return false
}
return true
}
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
snap, err := s.service.Snapshot(r.Context())
if err != nil {
http.Error(w, "settings unavailable", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
}
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 16<<10) {
return
}
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
result := s.service.VerifyCredentials(r.Context(), id, secret)
if !result.OK {
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
return
}
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
http.Error(w, "credential save failed", 500)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(CandidateService)
if !ok {
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
return
}
if err := service.ActivateCandidate(r.Context()); err != nil {
http.Error(w, "candidate activation failed", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
}
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !sixDigits(code) {
http.Error(w, "binding code must be 6 digits", 400)
return
}
if err := service.CreateBootstrap(r.Context(), code); err != nil {
http.Error(w, "create binding code failed", 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
}
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
if err := service.ConfirmBootstrap(r.Context()); err != nil {
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(AdministratorService)
if !ok {
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
return
}
staffID := strings.TrimSpace(r.FormValue("staff_id"))
if staffID == "" {
http.Error(w, "administrator not found", http.StatusNotFound)
return
}
err := service.DisableAdministrator(r.Context(), staffID)
switch {
case err == nil:
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
case errors.Is(err, settings.ErrLastAdministrator):
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
http.Error(w, "管理员已经停用。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorNotFound):
http.Error(w, "administrator not found", http.StatusNotFound)
default:
http.Error(w, "administrator update failed", http.StatusInternalServerError)
}
}
func sixDigits(value string) bool {
if len(value) != 6 {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
-166
View File
@@ -1,166 +0,0 @@
package adminweb
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type fakeService struct {
snapshot Snapshot
verified bool
saved bool
disabled string
disableErr error
}
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
f.saved = true
return nil
}
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
f.verified = true
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
}
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
f.disabled = staffID
return f.disableErr
}
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
t.Helper()
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.Header.Set("Authorization", "Bearer admin-token")
page := httptest.NewRecorder()
h.ServeHTTP(page, get)
marker := `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("csrf token missing: %s", page.Body.String())
}
start += len(marker)
end := strings.Index(page.Body.String()[start:], `"`)
if end < 0 {
t.Fatal("csrf token terminator missing")
}
return page.Body.String()[start : start+end]
}
func TestDashboardNeverRendersSecrets(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
t.Fatal("secret leaked")
}
if !strings.Contains(body, "ding***") {
t.Fatal("masked client id missing")
}
}
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
if !strings.Contains(body, want) {
t.Fatalf("dashboard missing %q: %s", want, body)
}
}
}
func TestSaveCredentialsRequiresVerification(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
csrf := csrfTokenFromPage(t, h)
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
}
}
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
}
}
if svc.saved || svc.disabled != "" {
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
}
}
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
if !strings.Contains(w.Body.String(), want) {
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
}
}
if strings.Contains(w.Body.String(), ">staff-old<") {
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
}
}
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
}
csrf := csrfTokenFromPage(t, h)
svc.disableErr = settings.ErrLastAdministrator
w := post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
}
svc.disableErr = nil
w = post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
}
}
+6 -2
View File
@@ -237,7 +237,11 @@ func (s *SupportGroupSender) SendTest(ctx context.Context) error {
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
title = "知识库未覆盖提醒"
label := "知识库未覆盖提醒"
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
label = "回答被点踩提醒"
}
title = label
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
@@ -246,7 +250,7 @@ func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification)
}
var b strings.Builder
b.WriteString("## 【知识库未覆盖提醒】\n\n")
b.WriteString("## 【" + label + "】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
@@ -0,0 +1,24 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
)
func TestSupportGroupSenderRendersFeedbackDownTrigger(t *testing.T) {
notification := newTestNotification()
notification.TriggerType = coverage.SupportTriggerFeedbackDown
title, text := (&SupportGroupSender{}).renderMarkdown(notification)
if title != "回答被点踩提醒" || !strings.Contains(text, "【回答被点踩提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
func TestSupportGroupSenderDefaultsHistoricalTriggerToCoverage(t *testing.T) {
title, text := (&SupportGroupSender{}).renderMarkdown(newTestNotification())
if title != "知识库未覆盖提醒" || !strings.Contains(text, "【知识库未覆盖提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
+58 -1
View File
@@ -11,6 +11,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -112,6 +113,27 @@ type SettingsRouter interface {
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
}
type ReasoningMessage struct {
TenantKey string
Channel string
StaffID string
Text string
ConversationID string
}
type ReasoningDecision struct {
Handled bool
Reply string
Query string
AgentID string
Execution reasoningmode.ExecutionMode
Expired bool
}
type ReasoningRouter interface {
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, error)
}
type LanguagePreferenceResolver interface {
GetPreference(context.Context, string, string, string) (string, error)
}
@@ -136,6 +158,7 @@ type HandlerOptions struct {
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
}
@@ -149,6 +172,7 @@ type Handler struct {
directory UserDirectory
outboundRewriter OutboundContentRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
@@ -194,6 +218,7 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
settingsRouter: opts.SettingsRouter,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
@@ -334,6 +359,26 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
if h.reasoningRouter != nil {
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
Text: req.Query, ConversationID: req.ExternalChatID,
})
if err != nil {
return fmt.Errorf("dingtalk reasoning mode: %w", err)
}
if decision.Handled {
if strings.TrimSpace(decision.Reply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, decision.Reply)
}
req.Query = strings.TrimSpace(decision.Query)
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
req.ReasoningExecution = string(decision.Execution)
req.ReasoningExpired = decision.Expired
}
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
}
@@ -449,6 +494,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
if strings.TrimSpace(fullContent) == "" {
return
}
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
@@ -509,7 +555,8 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
@@ -532,6 +579,16 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
}
func decorateReasoningPartial(content, execution string) string {
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
if !strings.HasPrefix(content, "【智能推理模式】") {
return "【智能推理模式】\n\n" + content
}
}
return content
}
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -0,0 +1,21 @@
package dingtalk
import (
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
func TestDecorateReasoningPartialMarksReasoningFromFirstChunk(t *testing.T) {
got := decorateReasoningPartial("部分答案", string(reasoningmode.ExecutionReasoning))
want := "【智能推理模式】\n\n部分答案"
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
}
func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
if got := decorateReasoningPartial("快速答案", string(reasoningmode.ExecutionQuick)); got != "快速答案" {
t.Fatalf("got=%q", got)
}
}
@@ -0,0 +1,81 @@
package dingtalk
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type fakeReasoningRouter struct {
called bool
message ReasoningMessage
decision ReasoningDecision
err error
}
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
f.called = true
f.message = message
return f.decision, f.err
}
func TestReasoningControlCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think on"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
t.Fatalf("router=%+v", router)
}
if qa.called {
t.Fatal("reasoning control command must bypass QA")
}
if !reply.called || reply.lastText != "mode enabled" {
t.Fatalf("reply=%+v", reply)
}
}
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{
Query: "分析根因", AgentID: "builtin-smart-reasoning",
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think 分析根因"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("single-turn reasoning must call QA")
}
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
t.Fatalf("QA request=%+v", qa.lastReq)
}
}
+3
View File
@@ -53,6 +53,9 @@ type QARequest struct {
Region string
ResponseLocale string
Query string
AgentIDOverride string
ReasoningExecution string
ReasoningExpired bool
Images []ImageInput
MessageType string
}
+19 -2
View File
@@ -60,7 +60,10 @@ func (e *Evaluator) Version() string {
// - no references + knowledge searched → uncovered_no_references
// - no references + no knowledge search → covered_visual_recognition
// (pure visual recognition is not a coverage miss)
// 4. Text turns: zero references → uncovered_no_references
// 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
//
// v1 never uses a score threshold. TopScore captures the highest reference score
@@ -126,8 +129,22 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// No references → uncovered.
// Text turns without references are only uncovered if they actually
// triggered knowledge search. This prevents greetings and other normal
// non-knowledge responses from generating artificial support cases.
if len(turn.References) == 0 {
if !turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredNonKnowledgeResponse,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
+27 -7
View File
@@ -66,20 +66,40 @@ func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
func TestEvaluator_TextTurnSearchedButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references after knowledge search", d.Verdict, d.ReasonCode)
}
}
func TestEvaluator_TextTurnWithoutKnowledgeSearchIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "smalltalk-turn",
Status: "succeeded",
Answer: "你好,我在。",
MessageType: "text",
KnowledgeSearched: false,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredNonKnowledgeResponse {
t.Fatalf("verdict=%s reason=%s, want covered_non_knowledge_response", d.Verdict, d.ReasonCode)
}
}
+11 -9
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v3" {
t.Errorf("Evaluator version = %q, want v3", e.Version())
if e.Version() != "v4" {
t.Errorf("Evaluator version = %q, want v4", e.Version())
}
}
@@ -85,10 +85,11 @@ func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T)
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
@@ -105,9 +106,10 @@ func TestEvaluator_UncoveredNoReferences(t *testing.T) {
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
+6
View File
@@ -10,11 +10,17 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const (
SupportTriggerCoverageUncovered = "coverage_uncovered"
SupportTriggerFeedbackDown = "feedback_down"
)
// SupportNotification is a safe, minimal view of the data needed to send a
// notification. It must never contain tokens, webhooks, raw API responses,
// raw error text, reference bodies, or group IDs.
type SupportNotification struct {
CaseID string
TriggerType string
Question string
SenderName string
Channel string
+6 -5
View File
@@ -76,11 +76,12 @@ func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+10
View File
@@ -0,0 +1,10 @@
package coverage
import "testing"
func TestSupportNotificationAcceptsAuditableTriggerType(t *testing.T) {
notification := SupportNotification{TriggerType: SupportTriggerFeedbackDown}
if notification.TriggerType != "feedback_down" {
t.Fatalf("trigger=%q", notification.TriggerType)
}
}
+6 -5
View File
@@ -25,14 +25,15 @@ const (
// ReasonCode values.
const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredNonKnowledgeResponse = "covered_non_knowledge_response"
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v3"
const EvaluatorVersion = "v4"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
@@ -0,0 +1,75 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
func TestKnowledgeToolEventsMarkTurnAsSearched(t *testing.T) {
knowledgeTools := []string{
"knowledge_search",
"grep_chunks",
"list_knowledge_chunks",
"get_document_info",
"wiki_search",
"wiki_read_page",
"wiki_read_source_doc",
"query_knowledge_graph",
}
for index, toolName := range knowledgeTools {
t.Run(toolName, func(t *testing.T) {
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: toolName},
{ResponseType: "answer", FullAnswer: "answer", Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request", Answer: "answer", References: []core.Reference{{KnowledgeID: "doc"}}},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
messageID := "knowledge-tool-" + string(rune('a'+index))
if _, err = service.AskEvents(context.Background(), streamQARequest(messageID), func(weknora.StreamEvent) {}); err != nil {
t.Fatal(err)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:"+messageID]
repo.mu.Unlock()
if !stored.KnowledgeSearched {
t.Fatalf("tool %s did not mark knowledge searched", toolName)
}
})
}
}
func TestNonKnowledgeToolDoesNotMarkTurnAsSearched(t *testing.T) {
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "answer", FullAnswer: "answer", Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request", Answer: "answer"},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
if _, err = service.AskEvents(context.Background(), streamQARequest("non-knowledge-tool"), func(weknora.StreamEvent) {}); err != nil {
t.Fatal(err)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:non-knowledge-tool"]
repo.mu.Unlock()
if stored.KnowledgeSearched {
t.Fatal("non-knowledge tool marked turn as searched")
}
}
+113
View File
@@ -0,0 +1,113 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
)
type fakeReasoningFinalizer struct {
repo *memoryRepository
calls int
input reasoningmode.TurnCompletion
completion reasoningmode.Completion
}
func (f *fakeReasoningFinalizer) Complete(_ context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error) {
f.calls++
f.input = input
f.repo.mu.Lock()
finished := len(f.repo.finishTurns)
f.repo.mu.Unlock()
if finished == 0 {
panic("reasoning completion ran before successful Turn persistence")
}
return f.completion, nil
}
func TestAgentOverrideUsesSameWeKnoraSessionAsDefaultAgent(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
base := core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", SenderName: "张三", Query: "第一问",
}
base.ExternalMessageID = "message-quick"
quick, err := service.Ask(context.Background(), base)
if err != nil {
t.Fatal(err)
}
base.ExternalMessageID = "message-reasoning"
base.Query = "继续深入分析"
base.AgentIDOverride = "agent-reasoning"
reasoning, err := service.Ask(context.Background(), base)
if err != nil {
t.Fatal(err)
}
if quick.WeKnoraSessionID == "" || reasoning.WeKnoraSessionID != quick.WeKnoraSessionID {
t.Fatalf("quick session=%q reasoning session=%q", quick.WeKnoraSessionID, reasoning.WeKnoraSessionID)
}
client.mu.Lock()
defer client.mu.Unlock()
if client.sessions != 1 || len(client.requests) != 2 {
t.Fatalf("sessions=%d requests=%d", client.sessions, len(client.requests))
}
if client.requests[0].AgentID != "agent-1" || client.requests[1].AgentID != "agent-reasoning" {
t.Fatalf("agent requests=%q,%q", client.requests[0].AgentID, client.requests[1].AgentID)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.finishTurns) != 2 || repo.finishTurns[0].AgentID != "agent-1" || repo.finishTurns[1].AgentID != "agent-reasoning" {
t.Fatalf("finished turns=%+v", repo.finishTurns)
}
if repo.finishTurns[0].SessionKey != repo.finishTurns[1].SessionKey {
t.Fatalf("session keys diverged: %q vs %q", repo.finishTurns[0].SessionKey, repo.finishTurns[1].SessionKey)
}
}
func TestReasoningCompletionDecoratesPersistedAndReturnedAnswer(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
finalizer := &fakeReasoningFinalizer{
repo: repo,
completion: reasoningmode.Completion{
RoundNumber: 8,
ShowHint: true,
},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared, WithReasoningModeFinalizer(finalizer))
if err != nil {
t.Fatal(err)
}
request := core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", ExternalMessageID: "message-mode-completion",
Query: "分析", AgentIDOverride: "agent-reasoning",
ReasoningExecution: string(reasoningmode.ExecutionReasoning),
}
result, err := service.Ask(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if finalizer.calls != 1 || finalizer.input.TurnID == "" || finalizer.input.Answer != "answer" {
t.Fatalf("finalizer calls=%d input=%+v", finalizer.calls, finalizer.input)
}
want := "【智能推理模式】\n\nanswer\n\n💡 推理模式还剩2轮,到期将自动关闭,如需继续可发送 /think on 重置轮次"
if result.Answer != want {
t.Fatalf("result answer=%q want=%q", result.Answer, want)
}
repo.mu.Lock()
defer repo.mu.Unlock()
stored := repo.turns["tenant:dingtalk:message-mode-completion"]
if stored.Answer != result.Answer {
t.Fatalf("stored answer=%q result=%q", stored.Answer, result.Answer)
}
}
+70 -13
View File
@@ -10,6 +10,7 @@ import (
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -44,6 +45,10 @@ type SucceededTurnObserver interface {
ObserveSucceededTurn(ctx context.Context, turn core.Turn) error
}
type ReasoningModeFinalizer interface {
Complete(ctx context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error)
}
// ServiceOption configures a Service.
type ServiceOption func(*Service)
@@ -59,14 +64,25 @@ func WithSucceededTurnObserver(observer SucceededTurnObserver, onError func(erro
}
}
func WithReasoningModeFinalizer(finalizer ReasoningModeFinalizer, onError ...func(error)) ServiceOption {
return func(s *Service) {
s.reasoningFinalizer = finalizer
if len(onError) > 0 {
s.reasoningErrorHandler = onError[0]
}
}
}
type Service struct {
repository core.Repository
agents AgentResolver
weknora WeKnora
groupPolicy session.GroupPolicy
observer SucceededTurnObserver
observerErrorHandler func(error)
observerConfigured bool
repository core.Repository
agents AgentResolver
weknora WeKnora
groupPolicy session.GroupPolicy
observer SucceededTurnObserver
observerErrorHandler func(error)
observerConfigured bool
reasoningFinalizer ReasoningModeFinalizer
reasoningErrorHandler func(error)
}
func New(repository core.Repository, agents AgentResolver, client WeKnora, groupPolicy session.GroupPolicy, opts ...ServiceOption) (*Service, error) {
@@ -115,12 +131,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if err := validateRequest(request); err != nil {
return core.QAResult{}, err
}
agentID, err := s.agents.Resolve(request.Channel, request.ExternalChatID)
baseAgentID, err := s.agents.Resolve(request.Channel, request.ExternalChatID)
if err != nil {
return core.QAResult{}, err
}
executionAgentID := baseAgentID
if override := strings.TrimSpace(request.AgentIDOverride); override != "" {
executionAgentID = override
}
sessionKey, subjectID, err := session.BuildKey(session.Identity{
TenantKey: request.TenantKey, Channel: request.Channel, AgentID: agentID,
TenantKey: request.TenantKey, Channel: request.Channel, AgentID: baseAgentID,
ConversationType: request.ConversationType, ExternalChatID: request.ExternalChatID,
ExternalUserID: request.ExternalUserID, GroupPolicy: s.groupPolicy,
})
@@ -149,7 +169,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
Channel: request.Channel,
ExternalMessageID: request.ExternalMessageID,
SessionKey: sessionKey,
AgentID: agentID,
AgentID: executionAgentID,
ConversationType: request.ConversationType,
ConversationTitle: request.ConversationTitle,
ExternalChatID: request.ExternalChatID,
@@ -191,7 +211,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
ConversationTitle: request.ConversationTitle,
})
mapping := core.SessionMapping{SessionKey: sessionKey, TenantKey: request.TenantKey,
Channel: request.Channel, AgentID: agentID, ConversationType: request.ConversationType,
Channel: request.Channel, AgentID: baseAgentID, ConversationType: request.ConversationType,
ExternalSubjectID: subjectID, DisplayTitle: displayTitle}
createSession := func(createCtx context.Context) (string, error) {
return s.weknora.CreateSession(createCtx, displayTitle, "Managed by DobotAI; business state remains outside WeKnora")
@@ -216,7 +236,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
images = append(images, weknora.Image{Data: image.DataURI})
}
chatRequest := weknora.AgentChatRequest{
Query: query, AgentID: agentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
Images: images,
}
// Track whether this turn actually triggered a knowledge-base search by
@@ -224,7 +244,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
// classification (image turns) and is persisted with the turn.
var knowledgeSearched bool
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
if strings.EqualFold(strings.TrimSpace(e.ToolName), "knowledge_search") {
if isKnowledgeTool(e.ToolName) {
knowledgeSearched = true
}
if eventObserver != nil {
@@ -306,6 +326,33 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
if err := s.repository.FinishTurn(ctx, turn); err != nil {
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
}
if s.reasoningFinalizer != nil && strings.TrimSpace(request.ReasoningExecution) != "" {
completion, completionErr := s.reasoningFinalizer.Complete(ctx, reasoningmode.TurnCompletion{
Scope: reasoningmode.Scope{
TenantKey: request.TenantKey, Channel: request.Channel, ExternalActorID: request.ExternalUserID,
},
TurnID: turn.ID, Execution: reasoningmode.ExecutionMode(request.ReasoningExecution),
Answer: turn.Answer, Locale: request.ResponseLocale, Expired: request.ReasoningExpired,
})
if completionErr != nil {
if s.reasoningErrorHandler != nil {
s.reasoningErrorHandler(completionErr)
}
} else {
displayLocale := reasoningmode.ResolveGuideLocale(request.ResponseLocale, request.Query)
turn.Answer = reasoningmode.DecorateAnswerForLocale(
turn.Answer, reasoningmode.ExecutionMode(request.ReasoningExecution),
request.ReasoningExpired, completion, displayLocale,
)
if err := s.repository.FinishTurn(ctx, turn); err != nil {
if s.reasoningErrorHandler != nil {
s.reasoningErrorHandler(err)
}
} else {
chat.Answer = turn.Answer
}
}
}
if s.observer != nil {
if obsErr := s.observer.ObserveSucceededTurn(ctx, turn); obsErr != nil && s.observerErrorHandler != nil {
s.observerErrorHandler(obsErr)
@@ -316,6 +363,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
WeKnoraRequestID: chat.RequestID, Answer: chat.Answer, References: chat.References}, nil
}
func isKnowledgeTool(toolName string) bool {
switch strings.ToLower(strings.TrimSpace(toolName)) {
case "knowledge_search", "grep_chunks", "list_knowledge_chunks", "get_document_info",
"wiki_search", "wiki_read_page", "wiki_read_source_doc", "query_knowledge_graph":
return true
default:
return false
}
}
func localizedQuery(query, locale string) string {
query = strings.TrimSpace(query)
switch strings.TrimSpace(locale) {
+80
View File
@@ -0,0 +1,80 @@
package reasoningmode
import (
"fmt"
"strings"
"unicode"
)
func ResolveGuideLocale(locale, query string) string {
locale = strings.TrimSpace(locale)
if locale != "" && locale != "auto" {
return locale
}
hasLatin := false
for _, value := range query {
if unicode.In(value, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) {
return "zh-CN"
}
if unicode.In(value, unicode.Latin) {
hasLatin = true
}
}
if hasLatin {
return "en"
}
return "zh-CN"
}
func DecorateAnswer(answer string, execution ExecutionMode, expired bool, completion Completion) string {
return DecorateAnswerForLocale(answer, execution, expired, completion, "zh-CN")
}
func DecorateAnswerForLocale(answer string, execution ExecutionMode, expired bool, completion Completion, locale string) string {
content := strings.TrimSpace(answer)
prefixes := make([]string, 0, 2)
if expired {
prefixes = append(prefixes, "💡 智能推理模式已因10分钟无操作自动关闭,当前已切回【快速问答模式】。")
}
if execution == ExecutionReasoning || execution == ExecutionReasoningSingle {
prefixes = append(prefixes, "【智能推理模式】")
}
if len(prefixes) > 0 {
content = strings.Join(prefixes, "\n\n") + "\n\n" + content
}
suffixes := make([]string, 0, 3)
if completion.ShowHint {
suffixes = append(suffixes, "💡 推理模式还剩2轮,到期将自动关闭,如需继续可发送 /think on 重置轮次")
}
if completion.AutoClosed {
suffixes = append(suffixes, fmt.Sprintf(
"💡 持续推理模式已满%d轮,已自动切回【快速问答模式】\n如需继续深度推理,可再次发送 /think on 开启",
completion.RoundNumber,
))
}
if completion.ShowOnboarding {
suffixes = append(suffixes, firstUseGuide(locale))
}
if len(suffixes) > 0 {
content += "\n\n" + strings.Join(suffixes, "\n\n")
}
return content
}
func firstUseGuide(locale string) string {
if strings.EqualFold(strings.TrimSpace(locale), "en") {
return "💡 First-time Guide\n" +
"✅ Default: Fast Q&A Mode — knowledge-based, fast and cost-effective\n" +
"🔹 For deep reasoning & analysis:\n" +
"Continuous use: /think on to enable, auto-off after 10 rounds\n" +
"Single use: /think + your question, auto switch back after reply\n" +
"💬 /help for all commands · /setting for language & preferences"
}
return "💡 首次使用指南\n" +
"✅ 当前默认使用快速问答模式,基于知识库快速作答,成本更低\n" +
"🔹 需深度推理分析时:\n" +
"连续使用:发送 /think on 开启持续模式,10轮对话后自动关闭\n" +
"单次使用:发送 /think + 你的问题,用完自动切回省钱模式\n" +
"💬 /help 查看全部指令・/setting 配置语言与偏好"
}
+50
View File
@@ -0,0 +1,50 @@
package reasoningmode
import (
"strings"
"testing"
)
func TestDecorateAnswerMarksReasoningAndRemainingRounds(t *testing.T) {
answer := DecorateAnswer("分析结果", ExecutionReasoning, false, Completion{RoundNumber: 8, ShowHint: true})
if !strings.HasPrefix(answer, "【智能推理模式】\n\n分析结果") {
t.Fatalf("answer=%q", answer)
}
if !strings.Contains(answer, "推理模式还剩2轮") || !strings.Contains(answer, "/think on") {
t.Fatalf("answer=%q", answer)
}
}
func TestDecorateAnswerMarksSingleReasoningWithoutRoundHint(t *testing.T) {
answer := DecorateAnswer("单次结果", ExecutionReasoningSingle, false, Completion{})
if answer != "【智能推理模式】\n\n单次结果" {
t.Fatalf("answer=%q", answer)
}
}
func TestDecorateAnswerAppendsAutomaticClose(t *testing.T) {
answer := DecorateAnswer("最终结果", ExecutionReasoning, false, Completion{RoundNumber: 10, AutoClosed: true})
if !strings.Contains(answer, "持续推理模式已满10轮") || !strings.Contains(answer, "已自动切回【快速问答模式】") {
t.Fatalf("answer=%q", answer)
}
}
func TestDecorateAnswerExplainsIdleExpiryOnNextQuickAnswer(t *testing.T) {
answer := DecorateAnswer("快速结果", ExecutionQuick, true, Completion{})
if !strings.HasPrefix(answer, "💡 智能推理模式已因10分钟无操作自动关闭") || !strings.HasSuffix(answer, "快速结果") {
t.Fatalf("answer=%q", answer)
}
}
func TestDecorateAnswerAppendsFirstUseGuideOnce(t *testing.T) {
answer := DecorateAnswer("快速结果", ExecutionQuick, false, Completion{ShowOnboarding: true})
for _, required := range []string{"首次使用指南", "默认使用快速问答模式", "/think on", "/think + 你的问题", "/help", "/setting"} {
if !strings.Contains(answer, required) {
t.Fatalf("answer=%q missing %q", answer, required)
}
}
without := DecorateAnswer("快速结果", ExecutionQuick, false, Completion{})
if strings.Contains(without, "首次使用指南") {
t.Fatalf("onboarding repeated: %q", without)
}
}
+15
View File
@@ -0,0 +1,15 @@
package reasoningmode
import "testing"
func TestResolveGuideLocaleUsesQuestionWhenPreferenceIsAuto(t *testing.T) {
if got := ResolveGuideLocale("auto", "How do I configure CRA?"); got != "en" {
t.Fatalf("English auto locale=%q", got)
}
if got := ResolveGuideLocale("auto", "如何配置CRA?"); got != "zh-CN" {
t.Fatalf("Chinese auto locale=%q", got)
}
if got := ResolveGuideLocale("ja", "English text"); got != "ja" {
t.Fatalf("explicit locale changed to %q", got)
}
}
+75
View File
@@ -0,0 +1,75 @@
package reasoningmode
import "strings"
type CommandKind string
const (
CommandEnable CommandKind = "enable"
CommandDisable CommandKind = "disable"
CommandSingle CommandKind = "single"
CommandHelp CommandKind = "help"
CommandInvalid CommandKind = "invalid"
)
type Command struct {
Kind CommandKind
Query string
}
func ParseCommand(raw string) (Command, bool) {
value := strings.TrimSpace(raw)
fields := strings.Fields(value)
if len(fields) == 0 {
return Command{}, false
}
name := strings.ToLower(fields[0])
switch name {
case "/help", "/帮助", "/h":
if len(fields) != 1 {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandHelp}, true
case "/开启推理":
if len(fields) != 1 {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandEnable}, true
case "/关闭推理", "/rag":
if len(fields) != 1 {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandDisable}, true
case "/think":
default:
return Command{}, false
}
if len(fields) == 1 {
return Command{Kind: CommandEnable}, true
}
second := strings.ToLower(fields[1])
switch second {
case "on", "start":
if len(fields) != 2 {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandEnable}, true
case "off", "end":
if len(fields) != 2 {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandDisable}, true
default:
query := strings.TrimSpace(value[len(fields[0]):])
if second == "+" {
query = strings.TrimSpace(strings.TrimPrefix(query, "+"))
}
if query == "" {
return Command{Kind: CommandInvalid}, true
}
return Command{Kind: CommandSingle, Query: query}, true
}
}
+100
View File
@@ -0,0 +1,100 @@
package reasoningmode
import "testing"
func TestParseCommandRecognizesPersistentEnableAliases(t *testing.T) {
tests := []string{
"/think",
"/think on",
"/think start",
"/THINK ON",
"/开启推理",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
command, ok := ParseCommand(input)
if !ok || command.Kind != CommandEnable || command.Query != "" {
t.Fatalf("ParseCommand(%q)=(%+v,%t), want enable", input, command, ok)
}
})
}
}
func TestParseCommandRecognizesDisableAliases(t *testing.T) {
tests := []string{
"/think off",
"/think end",
"/THINK OFF",
"/关闭推理",
"/rag",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
command, ok := ParseCommand(input)
if !ok || command.Kind != CommandDisable || command.Query != "" {
t.Fatalf("ParseCommand(%q)=(%+v,%t), want disable", input, command, ok)
}
})
}
}
func TestParseCommandRecognizesSingleTurnReasoning(t *testing.T) {
command, ok := ParseCommand(" /think 对比 CRA 和 CR 系列 ")
if !ok || command.Kind != CommandSingle || command.Query != "对比 CRA 和 CR 系列" {
t.Fatalf("command=%+v ok=%t", command, ok)
}
}
func TestParseCommandAcceptsDocumentedOptionalPlusSeparator(t *testing.T) {
command, ok := ParseCommand("/think + 分析根因")
if !ok || command.Kind != CommandSingle || command.Query != "分析根因" {
t.Fatalf("command=%+v ok=%t", command, ok)
}
}
func TestParseCommandRecognizesHelpAliases(t *testing.T) {
for _, input := range []string{"/help", "/帮助", "/h", "/H"} {
t.Run(input, func(t *testing.T) {
command, ok := ParseCommand(input)
if !ok || command.Kind != CommandHelp {
t.Fatalf("ParseCommand(%q)=(%+v,%t), want help", input, command, ok)
}
})
}
}
func TestParseCommandRejectsEmbeddedOrSimilarInputs(t *testing.T) {
tests := []string{
"普通问题",
"abc /think on",
"/thinking",
"//think on",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
if command, ok := ParseCommand(input); ok {
t.Fatalf("ParseCommand(%q)=(%+v,true), want no command", input, command)
}
})
}
}
func TestParseCommandInterceptsMalformedControlArguments(t *testing.T) {
tests := []string{
"/think on 多余内容",
"/think off 多余内容",
"/think start 多余内容",
"/think end 多余内容",
"/rag 多余内容",
"/开启推理 多余内容",
"/关闭推理 多余内容",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
command, ok := ParseCommand(input)
if !ok || command.Kind != CommandInvalid {
t.Fatalf("ParseCommand(%q)=(%+v,%t), want invalid command", input, command, ok)
}
})
}
}
+154
View File
@@ -0,0 +1,154 @@
package reasoningmode
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
const defaultIdleTimeout = 10 * time.Minute
type Scope struct {
TenantKey string
Channel string
ExternalActorID string
}
type TurnCompletion struct {
Scope Scope
TurnID string
Execution ExecutionMode
Answer string
Locale string
Expired bool
At time.Time
Idle time.Duration
}
type Repository interface {
ResolveState(context.Context, Scope, time.Time) (State, bool, error)
Enable(context.Context, Scope, time.Time, time.Duration) (State, error)
Disable(context.Context, Scope, time.Time) (State, bool, error)
SetMaxRounds(context.Context, Scope, int, time.Time) (State, error)
SetHintEnabled(context.Context, Scope, bool, time.Time) (State, error)
CompleteTurn(context.Context, TurnCompletion) (Completion, error)
}
type Decision struct {
Handled bool
Reply string
Query string
AgentID string
Execution ExecutionMode
Expired bool
}
type Service struct {
repo Repository
quickAgentID string
reasoningAgentID string
now func() time.Time
idleTimeout time.Duration
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
quickAgentID = strings.TrimSpace(quickAgentID)
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
if repo == nil {
return nil, errors.New("reasoning mode repository is required")
}
if quickAgentID == "" || reasoningAgentID == "" {
return nil, errors.New("quick and reasoning agent IDs are required")
}
if quickAgentID == reasoningAgentID {
return nil, errors.New("quick and reasoning agent IDs must differ")
}
return &Service{
repo: repo,
quickAgentID: quickAgentID,
reasoningAgentID: reasoningAgentID,
now: time.Now,
idleTimeout: defaultIdleTimeout,
}, nil
}
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
if err := validateScope(scope); err != nil {
return Decision{}, err
}
query := strings.TrimSpace(text)
if query == "" {
return Decision{}, errors.New("reasoning mode query is required")
}
if command, ok := ParseCommand(query); ok {
switch command.Kind {
case CommandEnable:
state, err := s.repo.Enable(ctx, scope, s.now(), s.idleTimeout)
if err != nil {
return Decision{}, err
}
return Decision{Handled: true, Reply: fmt.Sprintf(
"✅ 已开启【智能推理模式】(计费标准更高)\n持续模式将在%d轮对话后自动关闭,也可随时输入 /think off 手动关闭",
state.MaxRounds,
)}, nil
case CommandDisable:
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
if err != nil {
return Decision{}, err
}
if !wasReasoning {
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
}
return Decision{Handled: true, Reply: "✅ 已关闭智能推理模式,切回【快速问答模式】"}, nil
case CommandSingle:
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
case CommandHelp:
return Decision{Handled: true, Reply: helpReply()}, nil
default:
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
}
}
state, expired, err := s.repo.ResolveState(ctx, scope, s.now())
if err != nil {
return Decision{}, err
}
if state.Mode == ModeReasoning {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
}
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
}
func helpReply() string {
return "一、模式说明\n" +
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
"二、模式切换指令\n" +
"/think + 你的问题:仅当前问题使用智能推理\n" +
"/think on:开启持续智能推理模式\n" +
"/think off 或 /rag:关闭并切回快速问答模式\n\n" +
"三、其他常用指令\n" +
"/help:查看本帮助说明\n" +
"/setting 或 /设置:配置语言、自动关闭轮数和提示开关\n\n" +
"只有行首以 / 开头的完整指令会被识别。持续推理模式会在剩余2轮时提醒,到期自动切回快速问答模式。"
}
func (s *Service) Complete(ctx context.Context, input TurnCompletion) (Completion, error) {
if input.At.IsZero() {
input.At = s.now()
}
if input.Idle <= 0 {
input.Idle = s.idleTimeout
}
return s.repo.CompleteTurn(ctx, input)
}
func validateScope(scope Scope) error {
if strings.TrimSpace(scope.TenantKey) == "" || strings.TrimSpace(scope.Channel) == "" || strings.TrimSpace(scope.ExternalActorID) == "" {
return errors.New("tenant, channel and external actor are required")
}
return nil
}
+179
View File
@@ -0,0 +1,179 @@
package reasoningmode
import (
"context"
"strings"
"testing"
"time"
)
type fakeRepository struct {
state State
expired bool
enableCalls int
disableCalls int
completeCalls int
lastCompletion TurnCompletion
}
func (f *fakeRepository) ResolveState(context.Context, Scope, time.Time) (State, bool, error) {
return f.state, f.expired, nil
}
func (f *fakeRepository) Enable(context.Context, Scope, time.Time, time.Duration) (State, error) {
f.enableCalls++
f.state.Mode = ModeReasoning
f.state.RoundsUsed = 0
return f.state, nil
}
func (f *fakeRepository) Disable(context.Context, Scope, time.Time) (State, bool, error) {
f.disableCalls++
wasReasoning := f.state.Mode == ModeReasoning
f.state.Mode = ModeQuick
f.state.RoundsUsed = 0
return f.state, wasReasoning, nil
}
func (f *fakeRepository) SetMaxRounds(context.Context, Scope, int, time.Time) (State, error) {
return f.state, nil
}
func (f *fakeRepository) SetHintEnabled(context.Context, Scope, bool, time.Time) (State, error) {
return f.state, nil
}
func (f *fakeRepository) CompleteTurn(_ context.Context, input TurnCompletion) (Completion, error) {
f.completeCalls++
f.lastCompletion = input
return Completion{Next: f.state}, nil
}
func newTestService(t *testing.T, repo Repository) *Service {
t.Helper()
service, err := NewService(repo, "builtin-quick-answer", "builtin-smart-reasoning")
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) }
return service
}
func testScope() Scope {
return Scope{TenantKey: "tenant", Channel: "dingtalk", ExternalActorID: "staff-1"}
}
func TestPrepareEnablesPersistentReasoningLocally(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think on")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || decision.Query != "" || repo.enableCalls != 1 {
t.Fatalf("decision=%+v enableCalls=%d", decision, repo.enableCalls)
}
for _, want := range []string{"已开启【智能推理模式】", "计费标准更高", "10轮", "/think off"} {
if !strings.Contains(decision.Reply, want) {
t.Fatalf("reply=%q missing %q", decision.Reply, want)
}
}
}
func TestPrepareDisablesPersistentReasoningLocally(t *testing.T) {
repo := &fakeRepository{state: State{Mode: ModeReasoning, MaxRounds: 10, HintEnabled: true}}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/rag")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || repo.disableCalls != 1 || !strings.Contains(decision.Reply, "已关闭智能推理模式") {
t.Fatalf("decision=%+v disableCalls=%d", decision, repo.disableCalls)
}
}
func TestPrepareFriendlyReplyWhenReasoningAlreadyOff(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think off")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || !strings.Contains(decision.Reply, "当前未开启推理模式") {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareSingleReasoningDoesNotMutatePersistentState(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think 分析根因")
if err != nil {
t.Fatal(err)
}
if decision.Handled || decision.Query != "分析根因" || decision.AgentID != "builtin-smart-reasoning" || decision.Execution != ExecutionReasoningSingle {
t.Fatalf("decision=%+v", decision)
}
if repo.enableCalls != 0 || repo.disableCalls != 0 {
t.Fatalf("unexpected state mutation: %+v", repo)
}
}
func TestPrepareRoutesPersistentAndQuickQuestions(t *testing.T) {
tests := []struct {
name string
state State
agentID string
execution ExecutionMode
}{
{name: "quick", state: DefaultState(), agentID: "builtin-quick-answer", execution: ExecutionQuick},
{name: "reasoning", state: State{Mode: ModeReasoning, MaxRounds: 10, HintEnabled: true}, agentID: "builtin-smart-reasoning", execution: ExecutionReasoning},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
decision, err := newTestService(t, &fakeRepository{state: test.state}).Prepare(context.Background(), testScope(), "普通问题")
if err != nil {
t.Fatal(err)
}
if decision.Handled || decision.Query != "普通问题" || decision.AgentID != test.agentID || decision.Execution != test.execution {
t.Fatalf("decision=%+v", decision)
}
})
}
}
func TestPrepareRoutesExpiredReasoningToQuickWithNotice(t *testing.T) {
repo := &fakeRepository{state: DefaultState(), expired: true}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "继续")
if err != nil {
t.Fatal(err)
}
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick || !decision.Expired {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareInterceptsInvalidThinkCommand(t *testing.T) {
decision, err := newTestService(t, &fakeRepository{state: DefaultState()}).Prepare(context.Background(), testScope(), "/think on 多余内容")
if err != nil {
t.Fatal(err)
}
if !decision.Handled || !strings.Contains(decision.Reply, "用法") {
t.Fatalf("decision=%+v", decision)
}
}
func TestPrepareReturnsLocalHelpWithoutStateMutation(t *testing.T) {
repo := &fakeRepository{state: DefaultState()}
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/help")
if err != nil {
t.Fatal(err)
}
if !decision.Handled {
t.Fatalf("decision=%+v", decision)
}
for _, required := range []string{"快速问答模式", "智能推理模式", "/think + 你的问题", "/think on", "/think off", "/setting"} {
if !strings.Contains(decision.Reply, required) {
t.Fatalf("help=%q missing %q", decision.Reply, required)
}
}
if repo.enableCalls != 0 || repo.disableCalls != 0 {
t.Fatalf("help mutated mode: %+v", repo)
}
}
+105
View File
@@ -0,0 +1,105 @@
package reasoningmode
import (
"errors"
"time"
)
type Mode string
type ExecutionMode string
const (
ModeQuick Mode = "quick"
ModeReasoning Mode = "reasoning"
ExecutionQuick ExecutionMode = "quick"
ExecutionReasoning ExecutionMode = "reasoning"
ExecutionReasoningSingle ExecutionMode = "reasoning_single"
)
var ErrInvalidState = errors.New("invalid reasoning mode state")
type State struct {
Mode Mode
MaxRounds int
RoundsUsed int
HintEnabled bool
LastActivityAt time.Time
ExpiresAt time.Time
OnboardingShown bool
OnboardingShownAt time.Time
}
type Completion struct {
Next State
RoundNumber int
ShowHint bool
AutoClosed bool
ShowOnboarding bool
}
func DefaultState() State {
return State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true}
}
func EffectiveState(state State, now time.Time) (State, bool) {
if state.Mode != ModeReasoning || state.ExpiresAt.IsZero() || state.ExpiresAt.After(now) {
return state, false
}
state.Mode = ModeQuick
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
return state, true
}
func ApplySuccessfulTurn(state State, execution ExecutionMode, now time.Time, idleTimeout time.Duration) (Completion, error) {
if err := validateState(state); err != nil {
return Completion{}, err
}
if idleTimeout <= 0 {
return Completion{}, ErrInvalidState
}
if execution != ExecutionQuick && execution != ExecutionReasoning && execution != ExecutionReasoningSingle {
return Completion{}, ErrInvalidState
}
result := Completion{Next: state}
if !state.OnboardingShown {
result.ShowOnboarding = true
result.Next.OnboardingShown = true
result.Next.OnboardingShownAt = now
}
if execution != ExecutionReasoning || state.Mode != ModeReasoning {
return result, nil
}
result.RoundNumber = state.RoundsUsed + 1
result.Next.LastActivityAt = now
if result.RoundNumber >= state.MaxRounds {
result.AutoClosed = true
result.Next.Mode = ModeQuick
result.Next.RoundsUsed = 0
result.Next.ExpiresAt = time.Time{}
return result, nil
}
result.Next.RoundsUsed = result.RoundNumber
result.Next.ExpiresAt = now.Add(idleTimeout)
result.ShowHint = state.HintEnabled && state.MaxRounds-result.RoundNumber == 2
return result, nil
}
func validateState(state State) error {
if state.Mode != ModeQuick && state.Mode != ModeReasoning {
return ErrInvalidState
}
if state.MaxRounds < 1 || state.MaxRounds > 50 {
return ErrInvalidState
}
if state.RoundsUsed < 0 || state.RoundsUsed > state.MaxRounds {
return ErrInvalidState
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package reasoningmode
import (
"testing"
"time"
)
func defaultTestState() State {
return State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true}
}
func TestApplySuccessfulTurnShowsOnboardingOnceInQuickMode(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
result, err := ApplySuccessfulTurn(defaultTestState(), ExecutionQuick, now, 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if !result.ShowOnboarding || result.RoundNumber != 0 || result.AutoClosed || result.ShowHint {
t.Fatalf("result=%+v", result)
}
if !result.Next.OnboardingShown || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
t.Fatalf("next=%+v", result.Next)
}
second, err := ApplySuccessfulTurn(result.Next, ExecutionQuick, now.Add(time.Minute), 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if second.ShowOnboarding {
t.Fatal("onboarding must only be shown once")
}
}
func TestApplySuccessfulTurnShowsRemainingTwoHint(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 7, HintEnabled: true, OnboardingShown: true}
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, now, 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if result.RoundNumber != 8 || !result.ShowHint || result.AutoClosed {
t.Fatalf("result=%+v", result)
}
if result.Next.Mode != ModeReasoning || result.Next.RoundsUsed != 8 || !result.Next.ExpiresAt.Equal(now.Add(10*time.Minute)) {
t.Fatalf("next=%+v", result.Next)
}
}
func TestApplySuccessfulTurnAutoClosesAtConfiguredLimit(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 9, HintEnabled: true, OnboardingShown: true}
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, now, 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if result.RoundNumber != 10 || !result.AutoClosed || result.ShowHint {
t.Fatalf("result=%+v", result)
}
if result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 || !result.Next.ExpiresAt.IsZero() {
t.Fatalf("next=%+v", result.Next)
}
}
func TestApplySuccessfulTurnRespectsDisabledHint(t *testing.T) {
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 7, HintEnabled: false, OnboardingShown: true}
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, time.Now(), 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if result.ShowHint {
t.Fatal("disabled hint must remain hidden")
}
}
func TestApplySuccessfulTurnSingleReasoningDoesNotChangePersistentMode(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
state := defaultTestState()
result, err := ApplySuccessfulTurn(state, ExecutionReasoningSingle, now, 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if result.RoundNumber != 0 || result.AutoClosed || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
t.Fatalf("result=%+v", result)
}
}
func TestApplySuccessfulTurnDoesNotReenableModeClosedWhileAnswerRan(t *testing.T) {
state := State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true, OnboardingShown: true}
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, time.Now(), 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if result.RoundNumber != 0 || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
t.Fatalf("result=%+v", result)
}
}
func TestEffectiveStateExpiresReasoningWithoutMutatingPreferences(t *testing.T) {
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
state := State{
Mode: ModeReasoning, MaxRounds: 15, RoundsUsed: 4,
HintEnabled: false, OnboardingShown: true, ExpiresAt: now,
}
effective, expired := EffectiveState(state, now)
if !expired || effective.Mode != ModeQuick || effective.RoundsUsed != 0 || !effective.ExpiresAt.IsZero() {
t.Fatalf("effective=%+v expired=%t", effective, expired)
}
if effective.MaxRounds != 15 || effective.HintEnabled || !effective.OnboardingShown {
t.Fatalf("preferences changed on expiry: %+v", effective)
}
}
+1
View File
@@ -14,4 +14,5 @@ var (
ErrAdministratorNotFound = errors.New("administrator not found")
ErrLastAdministrator = errors.New("last active administrator cannot be disabled")
ErrAdministratorAlreadyDisabled = errors.New("administrator already disabled")
ErrBootstrapCandidatePending = errors.New("bootstrap candidate pending")
)
+34 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
@@ -28,6 +29,8 @@ type SettingInput struct{ TenantKey, Channel, StaffID, UnionID, DisplayName, Dep
type ControllerRepository interface {
IsAdministrator(context.Context, string, string) (bool, error)
SetPreference(context.Context, string, string, string, string) error
SetReasoningMaxRounds(context.Context, string, string, string, int, time.Time) error
SetReasoningHintEnabled(context.Context, string, string, string, bool, time.Time) error
SaveBinding(context.Context, ResourceBinding) error
TestAndActivateSupportGroup(context.Context, ResourceBinding) error
DetectBootstrapCandidate(context.Context, BootstrapCandidate) error
@@ -85,7 +88,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
if err != nil {
return "", err
}
return "身份已验证,请回到服务器命令行等待确认。", nil
return "身份已验证,服务器终端将自动完成管理员绑定。", nil
}
if explicit && len(args) == 0 {
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
@@ -109,6 +112,32 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
}
return "✅ 回答语言已设置为 " + args[1] + "。", nil
}
if action == "rounds" {
if len(args) != 2 {
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
}
rounds, parseErr := strconv.Atoi(args[1])
if parseErr != nil || rounds < 1 || rounds > 50 {
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
}
if err := c.repo.SetReasoningMaxRounds(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, rounds, c.now()); err != nil {
return "", err
}
return fmt.Sprintf("✅ 智能推理将在%d轮对话后自动关闭。", rounds), nil
}
if action == "hint" {
if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
return "用法:/setting hint <on|off>。", nil
}
enabled := args[1] == "on"
if err := c.repo.SetReasoningHintEnabled(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, enabled, c.now()); err != nil {
return "", err
}
if enabled {
return "✅ 智能推理剩余轮次提醒已开启。", nil
}
return "✅ 智能推理剩余轮次提醒已关闭。", nil
}
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
if err != nil {
return "", err
@@ -164,7 +193,10 @@ func settingMenu(admin, _ bool) string {
"- auto:跟随当前提问语言(推荐)\n" +
"- zh-CN:简体中文\n" +
"- en / ja / ko / es:英语 / 日语 / 韩语 / 西班牙语\n" +
"例如:/setting language en"
"例如:/setting language en\n\n" +
"智能推理自动关闭轮数:/setting rounds <1-50>\n" +
"例如:/setting rounds 15\n" +
"剩余轮次提醒:/setting hint <on|off>"
if !admin {
return menu
}
+50 -3
View File
@@ -11,6 +11,8 @@ import (
type memoryRepo struct {
admins map[string]bool
prefs map[string]string
rounds map[string]int
hints map[string]bool
bindings map[string]ResourceBinding
candidate BootstrapCandidate
sessions map[string]SettingSession
@@ -18,7 +20,7 @@ type memoryRepo struct {
}
func newMemoryRepo() *memoryRepo {
return &memoryRepo{admins: map[string]bool{}, prefs: map[string]string{}, bindings: map[string]ResourceBinding{}, sessions: map[string]SettingSession{}}
return &memoryRepo{admins: map[string]bool{}, prefs: map[string]string{}, rounds: map[string]int{}, hints: map[string]bool{}, bindings: map[string]ResourceBinding{}, sessions: map[string]SettingSession{}}
}
func (r *memoryRepo) IsAdministrator(_ context.Context, tenant, staff string) (bool, error) {
return r.admins[tenant+"/"+staff], nil
@@ -27,6 +29,14 @@ func (r *memoryRepo) SetPreference(_ context.Context, tenant, channel, staff, lo
r.prefs[tenant+"/"+staff] = locale
return nil
}
func (r *memoryRepo) SetReasoningMaxRounds(_ context.Context, tenant, channel, staff string, rounds int, _ time.Time) error {
r.rounds[tenant+"/"+channel+"/"+staff] = rounds
return nil
}
func (r *memoryRepo) SetReasoningHintEnabled(_ context.Context, tenant, channel, staff string, enabled bool, _ time.Time) error {
r.hints[tenant+"/"+channel+"/"+staff] = enabled
return nil
}
func (r *memoryRepo) SaveBinding(_ context.Context, b ResourceBinding) error {
if r.saveErr != nil {
return r.saveErr
@@ -69,6 +79,43 @@ func TestControllerLanguageForOrdinaryUser(t *testing.T) {
}
}
func TestControllerReasoningRoundsForOrdinaryUser(t *testing.T) {
r := newMemoryRepo()
c := NewController(r)
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds 15", ConversationType: "direct"})
if err != nil {
t.Fatal(err)
}
if r.rounds["t/dingtalk/u"] != 15 || !strings.Contains(out, "15轮") {
t.Fatalf("rounds=%v out=%q", r.rounds, out)
}
}
func TestControllerRejectsOutOfRangeReasoningRounds(t *testing.T) {
for _, value := range []string{"0", "51", "abc"} {
r := newMemoryRepo()
out, err := NewController(r).Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds " + value, ConversationType: "direct"})
if err != nil {
t.Fatal(err)
}
if len(r.rounds) != 0 || !strings.Contains(out, "1-50") {
t.Fatalf("value=%q rounds=%v out=%q", value, r.rounds, out)
}
}
}
func TestControllerReasoningHintForOrdinaryUser(t *testing.T) {
r := newMemoryRepo()
c := NewController(r)
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting hint off", ConversationType: "direct"})
if err != nil {
t.Fatal(err)
}
if r.hints["t/dingtalk/u"] || !strings.Contains(out, "已关闭") {
t.Fatalf("hints=%v out=%q", r.hints, out)
}
}
func TestControllerSettingMenuExplainsLanguageWithoutAdminActionsToOrdinaryUser(t *testing.T) {
r := newMemoryRepo()
c := NewController(r)
@@ -325,7 +372,7 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) {
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
t.Fatalf("candidate=%+v", r.candidate)
}
if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out)
if !strings.Contains(out, "服务器终端将自动完成管理员绑定") || strings.Contains(out, "后台") {
t.Fatalf("reply=%q must explain terminal auto-confirmation only", out)
}
}
+40
View File
@@ -82,6 +82,11 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
if command.RowsAffected() != 1 {
return feedback.Result{}, errors.New("insert feedback state affected unexpected rows")
}
if record.Value == feedback.ValueDown {
if err = s.ensureFeedbackSupportCase(ctx, tx, record, record.ID, turnID); err != nil {
return feedback.Result{}, err
}
}
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback create: %w", err)
}
@@ -92,6 +97,11 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
}
if sameFeedbackState(existingValue, existingReason, record.Value, record.ReasonCode) {
if record.Value == feedback.ValueDown {
if err = s.ensureFeedbackSupportCase(ctx, tx, record, existingID, turnID); err != nil {
return feedback.Result{}, err
}
}
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback replay: %w", err)
}
@@ -108,12 +118,42 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
if command.RowsAffected() != 1 {
return feedback.Result{}, errors.New("update feedback state affected unexpected rows")
}
if record.Value == feedback.ValueDown {
if err = s.ensureFeedbackSupportCase(ctx, tx, record, existingID, turnID); err != nil {
return feedback.Result{}, err
}
}
if err = tx.Commit(ctx); err != nil {
return feedback.Result{}, fmt.Errorf("commit feedback change: %w", err)
}
return feedback.Result{Changed: true, Value: record.Value, ReasonCode: record.ReasonCode}, nil
}
func (s *Store) ensureFeedbackSupportCase(ctx context.Context, tx pgx.Tx, record feedback.Record, feedbackID, turnID string) error {
var decisionID *string
if err := tx.QueryRow(ctx, `SELECT (SELECT id FROM coverage_decisions
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3)`,
record.TenantKey, record.Channel, turnID,
).Scan(&decisionID); err != nil {
return fmt.Errorf("find feedback coverage decision: %w", err)
}
caseID, err := newUUID()
if err != nil {
return fmt.Errorf("generate feedback support case ID: %w", err)
}
_, err = tx.Exec(ctx, `INSERT INTO support_cases
(id,tenant_key,channel,turn_id,coverage_decision_id,status,faq_export_status,trigger_type,trigger_feedback_id)
VALUES($1,$2,$3,$4,$5,'pending',$6,'feedback_down',$7)
ON CONFLICT (tenant_key,channel,turn_id) DO NOTHING`,
caseID, record.TenantKey, record.Channel, turnID, decisionID,
faqExportStatus(s.faqExportEnabled), feedbackID,
)
if err != nil {
return fmt.Errorf("insert feedback support case: %w", err)
}
return nil
}
func validateFeedbackRecord(record feedback.Record) error {
if record.ID == "" || record.ActorID == "" || record.TenantKey == "" || record.Channel == "" ||
record.ExternalActorID == "" || record.OutTrackID == "" || record.Source != feedback.SourceDingTalkCard {
+26
View File
@@ -75,3 +75,29 @@ func TestSameFeedbackState(t *testing.T) {
t.Fatal("value change must be a state change")
}
}
func TestFeedbackDownvoteEnsuresIdempotentSupportCaseInsideTransaction(t *testing.T) {
data, err := os.ReadFile("feedback.go")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ENSUREFEEDBACKSUPPORTCASE",
"SELECT ID FROM COVERAGE_DECISIONS",
"TENANT_KEY=$1 AND CHANNEL=$2 AND TURN_ID=$3",
"INSERT INTO SUPPORT_CASES",
"TRIGGER_TYPE",
"TRIGGER_FEEDBACK_ID",
"'FEEDBACK_DOWN'",
"ON CONFLICT (TENANT_KEY,CHANNEL,TURN_ID) DO NOTHING",
"FAQEXPORTSTATUS(S.FAQEXPORTENABLED)",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support wiring missing %q", required)
}
}
if strings.Count(source, "ENSUREFEEDBACKSUPPORTCASE") < 4 {
t.Fatal("create, replay, and changed-down paths must all ensure the same idempotent support case")
}
}
@@ -0,0 +1,155 @@
package postgres
import (
"context"
"os"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
func openIntegrationStore(t *testing.T) *Store {
t.Helper()
url := os.Getenv("DOBOTAI_POSTGRES_INTEGRATION_URL")
if url == "" {
t.Skip("DOBOTAI_POSTGRES_INTEGRATION_URL is not set")
}
store, err := Open(context.Background(), url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(store.Close)
return store
}
func seedSucceededTurn(t *testing.T, store *Store, suffix string, deliveredCard bool) {
t.Helper()
ctx := context.Background()
tenant, channel := "tenant-"+suffix, "dingtalk"
actorID, conversationID := "actor-"+suffix, "conversation-"+suffix
requestID, turnID, answerID := "request-"+suffix, "turn-"+suffix, "answer-"+suffix
if _, err := store.pool.Exec(ctx, `INSERT INTO channel_actors(id,tenant_key,channel,external_actor_id,actor_type) VALUES($1,$2,$3,$4,'user')`, actorID, tenant, channel, "staff-"+suffix); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `INSERT INTO channel_conversations(id,tenant_key,channel,external_conversation_id,conversation_type) VALUES($1,$2,$3,$4,'direct')`, conversationID, tenant, channel, "chat-"+suffix); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `INSERT INTO messages(id,tenant_key,channel,conversation_id,actor_id,external_message_id,correlation_id,direction,role,message_type,text_content,status) VALUES($1,$2,$3,$4,$5,$6,$7,'inbound','user','text','question','received')`, requestID, tenant, channel, conversationID, actorID, "external-"+suffix, turnID); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `INSERT INTO turns(id,tenant_key,channel,external_message_id,session_key,question,answer,status,request_message_id,agent_id) VALUES($1,$2,$3,$4,$5,'question','answer','succeeded',$6,'agent')`, turnID, tenant, channel, "external-"+suffix, "session-"+suffix, requestID); err != nil {
t.Fatal(err)
}
messageStatus, method, providerID := "generated", "", ""
if deliveredCard {
messageStatus, method, providerID = "sent", "card", "track-"+suffix
}
if _, err := store.pool.Exec(ctx, `INSERT INTO messages(id,tenant_key,channel,conversation_id,external_message_id,correlation_id,direction,role,message_type,text_content,reply_to_message_id,status,delivery_method,provider_message_id) VALUES($1,$2,$3,$4,'',$5,'outbound','assistant','text','answer',$6,$7,$8,$9)`, answerID, tenant, channel, conversationID, turnID, requestID, messageStatus, method, providerID); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `UPDATE turns SET answer_message_id=$2 WHERE id=$1`, turnID, answerID); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `INSERT INTO coverage_decisions(id,tenant_key,channel,turn_id,evaluator_version,verdict,reason_code,reference_count,top_score) VALUES($1,$2,$3,$4,'v3','covered','covered_has_references',1,0)`, "decision-"+suffix, tenant, channel, turnID); err != nil {
t.Fatal(err)
}
}
func TestIntegrationReasoningCompletionIsIdempotent(t *testing.T) {
store := openIntegrationStore(t)
seedSucceededTurn(t, store, "reasoning", false)
ctx := context.Background()
scope := reasoningmode.Scope{TenantKey: "tenant-reasoning", Channel: "dingtalk", ExternalActorID: "staff-reasoning"}
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
if _, err := store.Enable(ctx, scope, now, 10*time.Minute); err != nil {
t.Fatal(err)
}
if _, err := store.pool.Exec(ctx, `UPDATE reasoning_mode_states SET rounds_used=7 WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID); err != nil {
t.Fatal(err)
}
input := reasoningmode.TurnCompletion{Scope: scope, TurnID: "turn-reasoning", Execution: reasoningmode.ExecutionReasoning, At: now.Add(time.Minute), Idle: 10 * time.Minute}
first, err := store.CompleteTurn(ctx, input)
if err != nil {
t.Fatal(err)
}
second, err := store.CompleteTurn(ctx, input)
if err != nil {
t.Fatal(err)
}
if first.RoundNumber != 8 || !first.ShowHint || second.RoundNumber != 8 || !second.ShowHint {
t.Fatalf("first=%+v second=%+v", first, second)
}
var rounds, ledger int
if err := store.pool.QueryRow(ctx, `SELECT rounds_used FROM reasoning_mode_states WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID).Scan(&rounds); err != nil {
t.Fatal(err)
}
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM reasoning_mode_turns WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`, scope.TenantKey, scope.Channel, input.TurnID).Scan(&ledger); err != nil {
t.Fatal(err)
}
if rounds != 8 || ledger != 1 {
t.Fatalf("rounds=%d ledger=%d", rounds, ledger)
}
}
func TestIntegrationDownvoteCreatesOneFeedbackSupportCase(t *testing.T) {
store := openIntegrationStore(t)
seedSucceededTurn(t, store, "feedback", true)
store.SetExternalTableExports(false, true)
ctx := context.Background()
command := feedback.Record{
ID: "feedback-state-1", TenantKey: "tenant-feedback", Channel: "dingtalk", ActorID: "feedback-actor-1",
ExternalActorID: "staff-feedback", OutTrackID: "track-feedback", Value: feedback.ValueDown,
ReasonCode: feedback.ReasonEmpty, Source: feedback.SourceDingTalkCard,
}
if _, err := store.RecordFeedback(ctx, command); err != nil {
t.Fatal(err)
}
command.ID, command.ActorID = "feedback-state-replay", "feedback-actor-replay"
result, err := store.RecordFeedback(ctx, command)
if err != nil {
t.Fatal(err)
}
if !result.Replayed {
t.Fatalf("result=%+v", result)
}
var count int
var triggerType, faqStatus string
if err := store.pool.QueryRow(ctx, `SELECT count(*),max(trigger_type),max(faq_export_status) FROM support_cases WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`, "tenant-feedback", "dingtalk", "turn-feedback").Scan(&count, &triggerType, &faqStatus); err != nil {
t.Fatal(err)
}
if count != 1 || triggerType != "feedback_down" || faqStatus != "pending" {
t.Fatalf("count=%d trigger=%q faq=%q", count, triggerType, faqStatus)
}
}
func TestIntegrationDownvoteCreatesCaseWithoutCoverageDecision(t *testing.T) {
store := openIntegrationStore(t)
seedSucceededTurn(t, store, "feedback-no-decision", true)
store.SetExternalTableExports(false, true)
ctx := context.Background()
if _, err := store.pool.Exec(ctx, `DELETE FROM coverage_decisions
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`,
"tenant-feedback-no-decision", "dingtalk", "turn-feedback-no-decision"); err != nil {
t.Fatal(err)
}
command := feedback.Record{
ID: "feedback-state-no-decision", TenantKey: "tenant-feedback-no-decision", Channel: "dingtalk", ActorID: "feedback-actor-no-decision",
ExternalActorID: "staff-feedback-no-decision", OutTrackID: "track-feedback-no-decision", Value: feedback.ValueDown,
ReasonCode: feedback.ReasonEmpty, Source: feedback.SourceDingTalkCard,
}
if _, err := store.RecordFeedback(ctx, command); err != nil {
t.Fatal(err)
}
var count int
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM support_cases
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3 AND coverage_decision_id IS NULL
AND trigger_type='feedback_down'`,
"tenant-feedback-no-decision", "dingtalk", "turn-feedback-no-decision").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("count=%d", count)
}
}
+325
View File
@@ -0,0 +1,325 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
func (s *Store) ResolveState(ctx context.Context, scope reasoningmode.Scope, now time.Time) (reasoningmode.State, bool, error) {
if err := validateReasoningScope(scope); err != nil {
return reasoningmode.State{}, false, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.State{}, false, fmt.Errorf("begin reasoning state resolve: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err = ensureReasoningState(ctx, tx, scope); err != nil {
return reasoningmode.State{}, false, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
if err != nil {
return reasoningmode.State{}, false, err
}
effective, expired := reasoningmode.EffectiveState(state, now)
if expired {
if err = updateReasoningState(ctx, tx, scope, effective, now); err != nil {
return reasoningmode.State{}, false, err
}
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.State{}, false, fmt.Errorf("commit reasoning state resolve: %w", err)
}
return effective, expired, nil
}
func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.Time, idle time.Duration) (reasoningmode.State, error) {
if err := validateReasoningScope(scope); err != nil || idle <= 0 {
if err != nil {
return reasoningmode.State{}, err
}
return reasoningmode.State{}, reasoningmode.ErrInvalidState
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.State{}, fmt.Errorf("begin reasoning enable: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err = ensureReasoningState(ctx, tx, scope); err != nil {
return reasoningmode.State{}, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
if err != nil {
return reasoningmode.State{}, err
}
state.Mode = reasoningmode.ModeReasoning
state.RoundsUsed = 0
state.LastActivityAt = now
state.ExpiresAt = now.Add(idle)
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
return reasoningmode.State{}, err
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.State{}, fmt.Errorf("commit reasoning enable: %w", err)
}
return state, nil
}
func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time.Time) (reasoningmode.State, bool, error) {
if err := validateReasoningScope(scope); err != nil {
return reasoningmode.State{}, false, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.State{}, false, fmt.Errorf("begin reasoning disable: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err = ensureReasoningState(ctx, tx, scope); err != nil {
return reasoningmode.State{}, false, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
if err != nil {
return reasoningmode.State{}, false, err
}
state, _ = reasoningmode.EffectiveState(state, now)
wasReasoning := state.Mode == reasoningmode.ModeReasoning
state.Mode = reasoningmode.ModeQuick
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
return reasoningmode.State{}, false, err
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.State{}, false, fmt.Errorf("commit reasoning disable: %w", err)
}
return state, wasReasoning, nil
}
func (s *Store) SetMaxRounds(ctx context.Context, scope reasoningmode.Scope, maxRounds int, now time.Time) (reasoningmode.State, error) {
if err := validateReasoningScope(scope); err != nil {
return reasoningmode.State{}, err
}
if maxRounds < 1 || maxRounds > 50 {
return reasoningmode.State{}, reasoningmode.ErrInvalidState
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.State{}, fmt.Errorf("begin reasoning rounds update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err = ensureReasoningState(ctx, tx, scope); err != nil {
return reasoningmode.State{}, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
if err != nil {
return reasoningmode.State{}, err
}
state.MaxRounds = maxRounds
if state.RoundsUsed >= maxRounds {
state.Mode = reasoningmode.ModeQuick
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
}
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
return reasoningmode.State{}, err
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.State{}, fmt.Errorf("commit reasoning rounds update: %w", err)
}
return state, nil
}
func (s *Store) SetHintEnabled(ctx context.Context, scope reasoningmode.Scope, enabled bool, now time.Time) (reasoningmode.State, error) {
if err := validateReasoningScope(scope); err != nil {
return reasoningmode.State{}, err
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.State{}, fmt.Errorf("begin reasoning hint update: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err = ensureReasoningState(ctx, tx, scope); err != nil {
return reasoningmode.State{}, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
if err != nil {
return reasoningmode.State{}, err
}
state.HintEnabled = enabled
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
return reasoningmode.State{}, err
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.State{}, fmt.Errorf("commit reasoning hint update: %w", err)
}
return state, nil
}
func (s *Store) CompleteTurn(ctx context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error) {
if err := validateReasoningScope(input.Scope); err != nil {
return reasoningmode.Completion{}, err
}
if strings.TrimSpace(input.TurnID) == "" || input.At.IsZero() || input.Idle <= 0 {
return reasoningmode.Completion{}, reasoningmode.ErrInvalidState
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return reasoningmode.Completion{}, fmt.Errorf("begin reasoning turn completion: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var existing reasoningmode.Completion
err = tx.QueryRow(ctx, `SELECT round_number,showed_hint,auto_closed,showed_onboarding
FROM reasoning_mode_turns
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3 AND external_actor_id=$4
FOR UPDATE`, input.Scope.TenantKey, input.Scope.Channel, input.TurnID, input.Scope.ExternalActorID).Scan(
&existing.RoundNumber, &existing.ShowHint, &existing.AutoClosed, &existing.ShowOnboarding,
)
if err == nil {
if existing.Next, err = selectReasoningState(ctx, tx, input.Scope); err != nil {
return reasoningmode.Completion{}, err
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.Completion{}, fmt.Errorf("commit replayed reasoning completion: %w", err)
}
return existing, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return reasoningmode.Completion{}, fmt.Errorf("find reasoning turn completion: %w", err)
}
var status string
if err = tx.QueryRow(ctx, `SELECT t.status FROM turns t
JOIN messages m ON m.id=t.request_message_id
JOIN channel_actors a ON a.id=m.actor_id
WHERE t.id=$1 AND t.tenant_key=$2 AND t.channel=$3
AND a.external_actor_id=$4
FOR UPDATE OF t`, input.TurnID, input.Scope.TenantKey, input.Scope.Channel, input.Scope.ExternalActorID).Scan(&status); err != nil {
return reasoningmode.Completion{}, fmt.Errorf("lock reasoning completion turn: %w", err)
}
if status != "succeeded" {
return reasoningmode.Completion{}, errors.New("reasoning completion requires a succeeded turn")
}
if err = ensureReasoningState(ctx, tx, input.Scope); err != nil {
return reasoningmode.Completion{}, err
}
state, err := selectReasoningStateForUpdate(ctx, tx, input.Scope)
if err != nil {
return reasoningmode.Completion{}, err
}
completion, err := reasoningmode.ApplySuccessfulTurn(state, input.Execution, input.At, input.Idle)
if err != nil {
return reasoningmode.Completion{}, err
}
if err = updateReasoningState(ctx, tx, input.Scope, completion.Next, input.At); err != nil {
return reasoningmode.Completion{}, err
}
tag, err := tx.Exec(ctx, `INSERT INTO reasoning_mode_turns
(id,tenant_key,channel,turn_id,external_actor_id,execution_mode,round_number,showed_hint,auto_closed,showed_onboarding)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (tenant_key,channel,turn_id) DO NOTHING`,
newSettingsID(), input.Scope.TenantKey, input.Scope.Channel, input.TurnID,
input.Scope.ExternalActorID, string(input.Execution), completion.RoundNumber,
completion.ShowHint, completion.AutoClosed, completion.ShowOnboarding,
)
if err != nil {
return reasoningmode.Completion{}, fmt.Errorf("insert reasoning turn completion: %w", err)
}
if tag.RowsAffected() != 1 {
return reasoningmode.Completion{}, errors.New("insert reasoning turn completion affected unexpected rows")
}
if err = tx.Commit(ctx); err != nil {
return reasoningmode.Completion{}, fmt.Errorf("commit reasoning turn completion: %w", err)
}
return completion, nil
}
func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) error {
_, err := tx.Exec(ctx, `INSERT INTO reasoning_mode_states
(tenant_key,channel,external_actor_id,mode,max_rounds,rounds_used,hint_enabled)
VALUES($1,$2,$3,'quick',10,0,TRUE)
ON CONFLICT (tenant_key,channel,external_actor_id) DO NOTHING`,
scope.TenantKey, scope.Channel, scope.ExternalActorID,
)
if err != nil {
return fmt.Errorf("ensure reasoning state: %w", err)
}
return nil
}
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
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,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))
}
func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
var (
state reasoningmode.State
mode string
last pgtype.Timestamptz
expires pgtype.Timestamptz
onboarding pgtype.Timestamptz
)
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)
if last.Valid {
state.LastActivityAt = last.Time
}
if expires.Valid {
state.ExpiresAt = expires.Time
}
if onboarding.Valid {
state.OnboardingShown = true
state.OnboardingShownAt = onboarding.Time
}
return state, nil
}
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,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.MaxRounds, state.RoundsUsed, state.HintEnabled,
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
)
if err != nil {
return fmt.Errorf("update reasoning state: %w", err)
}
return nil
}
func nullableTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
func validateReasoningScope(scope reasoningmode.Scope) error {
if strings.TrimSpace(scope.TenantKey) == "" || strings.TrimSpace(scope.Channel) == "" || strings.TrimSpace(scope.ExternalActorID) == "" {
return errors.New("reasoning mode scope is required")
}
return nil
}
var _ reasoningmode.Repository = (*Store)(nil)
@@ -0,0 +1,59 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestReasoningModeRepositoryUsesScopedAtomicStateTransitions(t *testing.T) {
data, err := os.ReadFile("reasoning_mode.go")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_STATES",
"TENANT_KEY=$1",
"CHANNEL=$2",
"EXTERNAL_ACTOR_ID=$3",
"FOR UPDATE",
"REASONINGMODE.MODEQUICK",
"REASONINGMODE.MODEREASONING",
"STRING(STATE.MODE)",
"EXPIRES_AT",
"MAX_ROUNDS",
"HINT_ENABLED",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning repository missing %q", required)
}
}
if strings.Count(source, "BEGINTX") < 4 || strings.Count(source, "TX.COMMIT") < 4 {
t.Fatal("state transitions must use explicit transactions")
}
}
func TestReasoningModeRepositoryMakesTurnCompletionIdempotent(t *testing.T) {
data, err := os.ReadFile("reasoning_mode.go")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_TURNS",
"ON CONFLICT (TENANT_KEY,CHANNEL,TURN_ID) DO NOTHING",
"ROWSAFFECTED",
"APPLYSUCCESSFULTURN",
"SELECT ROUND_NUMBER,SHOWED_HINT,AUTO_CLOSED,SHOWED_ONBOARDING",
"JOIN MESSAGES M ON M.ID=T.REQUEST_MESSAGE_ID",
"JOIN CHANNEL_ACTORS A ON A.ID=M.ACTOR_ID",
"T.ID=$1 AND T.TENANT_KEY=$2 AND T.CHANNEL=$3",
"A.EXTERNAL_ACTOR_ID=$4",
"STATUS != \"SUCCEEDED\"",
} {
if !strings.Contains(source, required) {
t.Errorf("turn completion repository missing %q", required)
}
}
}
+12
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"github.com/jackc/pgx/v5"
)
@@ -37,6 +38,14 @@ func (r *SettingsRepository) SetPreference(ctx context.Context, tenant, channel,
_, err := r.store.pool.Exec(ctx, `INSERT INTO user_preferences(tenant_key,channel,external_actor_id,language_mode,locale) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,channel,external_actor_id) DO UPDATE SET language_mode=EXCLUDED.language_mode,locale=EXCLUDED.locale,updated_at=NOW()`, tenant, channel, staff, mode, locale)
return err
}
func (r *SettingsRepository) SetReasoningMaxRounds(ctx context.Context, tenant, channel, staff string, rounds int, now time.Time) error {
_, err := r.store.SetMaxRounds(ctx, reasoningmode.Scope{TenantKey: tenant, Channel: channel, ExternalActorID: staff}, rounds, now)
return err
}
func (r *SettingsRepository) SetReasoningHintEnabled(ctx context.Context, tenant, channel, staff string, enabled bool, now time.Time) error {
_, err := r.store.SetHintEnabled(ctx, reasoningmode.Scope{TenantKey: tenant, Channel: channel, ExternalActorID: staff}, enabled, now)
return err
}
func (r *SettingsRepository) GetPreference(ctx context.Context, tenant, channel, staff string) (string, error) {
var locale string
err := r.store.pool.QueryRow(ctx, `SELECT locale FROM user_preferences WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, tenant, channel, staff).Scan(&locale)
@@ -138,6 +147,9 @@ func (r *SettingsRepository) ConfirmBootstrap(ctx context.Context, tenant string
var challengeID, staff, union, name, dept string
err = tx.QueryRow(ctx, `SELECT id,candidate_staff_id,candidate_union_id,candidate_display_name,candidate_department FROM admin_bootstrap_challenges WHERE tenant_key=$1 AND status='candidate_detected' AND expires_at>NOW() FOR UPDATE`, tenant).Scan(&challengeID, &staff, &union, &name, &dept)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return settings.ErrBootstrapCandidatePending
}
return err
}
if strings.TrimSpace(staff) == "" {
@@ -49,7 +49,7 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
FROM candidates c
WHERE sc.id = c.id
RETURNING sc.id, sc.tenant_key, sc.channel, sc.turn_id,
sc.notification_attempts, sc.created_at, sc.faq_record_id
sc.notification_attempts, sc.created_at, sc.faq_record_id, sc.trigger_type
)
SELECT
cl.id,
@@ -62,7 +62,8 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
COALESCE(c.conversation_type, 'direct') AS conversation_type,
m.created_at AS asked_at,
COALESCE(history.previous_questions, ARRAY[]::TEXT[]) AS previous_questions,
cl.faq_record_id
cl.faq_record_id,
cl.trigger_type
FROM claimed cl
JOIN turns t ON t.id = cl.turn_id
AND t.tenant_key = cl.tenant_key
@@ -110,16 +111,18 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
askedAt time.Time
previousQuestions []string
faqRecordID string
triggerType string
)
if err := rows.Scan(&id, &tenantKey, &channel, &turnID, &attempts,
&question, &senderName, &conversationType, &askedAt,
&previousQuestions, &faqRecordID); err != nil {
&previousQuestions, &faqRecordID, &triggerType); err != nil {
rows.Close()
return nil, fmt.Errorf("claim scan: %w", err)
}
cases = append(cases, coverage.PendingSupportCase{
SupportNotification: coverage.SupportNotification{
CaseID: id,
TriggerType: triggerType,
Question: question,
SenderName: senderName,
Channel: channel,
@@ -0,0 +1,20 @@
package postgres
import (
"os"
"strings"
"testing"
)
func TestSupportNotificationClaimReadsTriggerType(t *testing.T) {
data, err := os.ReadFile("support_notifications.go")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{"SC.TRIGGER_TYPE", "CL.TRIGGER_TYPE", "TRIGGERTYPE"} {
if !strings.Contains(source, required) {
t.Errorf("support claim missing %q", required)
}
}
}
+165 -4
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
@@ -269,12 +270,16 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
if event.Content == "" {
event.Content = "weknora returned an unspecified error"
}
// Notify with error type but no content, then return the error
// WeKnora also uses response_type=error, done=false for a failed
// agent tool that the reasoning loop can recover from. Preserve the
// safe event signal, but only abort on an explicitly terminal error.
notify(StreamEvent{
ResponseType: "error",
Done: true,
Done: event.Done,
})
return errors.New(event.Content)
if event.Done {
return errors.New(event.Content)
}
case "thinking", "reflection":
notify(StreamEvent{
ResponseType: event.ResponseType,
@@ -282,6 +287,9 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
})
case "tool_call", "tool_result":
toolName := extractToolName(event.Data)
if event.ResponseType == "tool_result" {
result.References = appendUniqueReferences(result.References, extractToolResultReferences(event.Data))
}
notify(StreamEvent{
ResponseType: event.ResponseType,
Done: event.Done,
@@ -344,7 +352,10 @@ func extractToolName(data json.RawMessage) string {
}
v, ok := m["tool_name"]
if !ok {
return ""
v, ok = m["name"]
if !ok {
return ""
}
}
var s string
if err := json.Unmarshal(v, &s); err != nil {
@@ -352,3 +363,153 @@ func extractToolName(data json.RawMessage) string {
}
return s
}
func extractToolResultReferences(data json.RawMessage) []core.Reference {
var envelope struct {
ToolName string `json:"tool_name"`
Name string `json:"name"`
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Result *struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
} `json:"result"`
}
if json.Unmarshal(data, &envelope) != nil {
return nil
}
success := envelope.Success
payloadData := envelope.Data
if envelope.Result != nil {
success = envelope.Result.Success
payloadData = envelope.Result.Data
} else if len(payloadData) == 0 || string(payloadData) == "null" {
// Live WeKnora SSE flattens sanitized tool result fields into the
// event data object alongside tool_name and success. Older fixtures
// and SDK variants wrap the result fields in data or result.data.
payloadData = data
}
if !success || len(payloadData) == 0 {
return nil
}
toolName := strings.TrimSpace(envelope.ToolName)
if toolName == "" {
toolName = strings.TrimSpace(envelope.Name)
}
var payload struct {
Count int `json:"count"`
DocumentCount int `json:"document_count"`
FetchedChunks int `json:"fetched_chunks"`
TotalDocs int `json:"total_docs"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
Results []struct {
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
} `json:"results"`
KnowledgeResults []struct {
KnowledgeID string `json:"knowledge_id"`
KnowledgeTitle string `json:"knowledge_title"`
ChunkHitCount int `json:"chunk_hit_count"`
} `json:"knowledge_results"`
Documents []struct {
KnowledgeID string `json:"knowledge_id"`
Title string `json:"title"`
ChunkCount int `json:"chunk_count"`
} `json:"documents"`
FoundKBs map[string][]string `json:"found_kbs"`
}
if json.Unmarshal(payloadData, &payload) != nil {
return nil
}
references := make([]core.Reference, 0)
switch toolName {
case "knowledge_search":
if payload.Count <= 0 && len(payload.Results) == 0 {
return nil
}
for _, item := range payload.Results {
if id := strings.TrimSpace(item.KnowledgeID); id != "" {
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.KnowledgeTitle)})
}
}
case "grep_chunks":
if payload.DocumentCount <= 0 && len(payload.KnowledgeResults) == 0 {
return nil
}
for _, item := range payload.KnowledgeResults {
if id := strings.TrimSpace(item.KnowledgeID); id != "" && item.ChunkHitCount > 0 {
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.KnowledgeTitle)})
}
}
case "list_knowledge_chunks":
if payload.FetchedChunks > 0 && strings.TrimSpace(payload.KnowledgeID) != "" {
references = append(references, core.Reference{
KnowledgeID: strings.TrimSpace(payload.KnowledgeID), KnowledgeTitle: strings.TrimSpace(payload.KnowledgeTitle),
})
}
case "get_document_info":
if payload.TotalDocs <= 0 && len(payload.Documents) == 0 {
return nil
}
for _, item := range payload.Documents {
if id := strings.TrimSpace(item.KnowledgeID); id != "" && item.ChunkCount > 0 {
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.Title)})
}
}
case "wiki_search", "wiki_read_page":
slugs := make([]string, 0, len(payload.FoundKBs))
for slug := range payload.FoundKBs {
slugs = append(slugs, slug)
}
sort.Strings(slugs)
for _, slug := range slugs {
kbIDs := append([]string(nil), payload.FoundKBs[slug]...)
sort.Strings(kbIDs)
for _, kbID := range kbIDs {
if strings.TrimSpace(slug) == "" || strings.TrimSpace(kbID) == "" {
continue
}
references = append(references, core.Reference{
ID: "wiki:" + strings.TrimSpace(kbID) + ":" + strings.TrimSpace(slug),
KnowledgeTitle: strings.TrimSpace(slug),
})
}
}
}
return references
}
func appendUniqueReferences(current, incoming []core.Reference) []core.Reference {
result := append([]core.Reference(nil), current...)
seen := make(map[string]struct{}, len(result)+len(incoming))
for _, reference := range result {
if key := referenceKey(reference); key != "" {
seen[key] = struct{}{}
}
}
for _, reference := range incoming {
key := referenceKey(reference)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, reference)
}
return result
}
func referenceKey(reference core.Reference) string {
if value := strings.TrimSpace(reference.KnowledgeID); value != "" {
return "knowledge:" + value
}
if value := strings.TrimSpace(reference.ID); value != "" {
return "id:" + value
}
return ""
}
+18
View File
@@ -144,6 +144,24 @@ func TestParseSSEErrorDominatesPartialAnswer(t *testing.T) {
}
}
func TestParseSSENonTerminalToolErrorContinuesToCompleteAnswer(t *testing.T) {
stream := "data: {\"response_type\":\"error\",\"content\":\"tool failed\",\"done\":false}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"complete answer\",\"done\":true}\n\n"
var observed []StreamEvent
result, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(event StreamEvent) {
observed = append(observed, event)
})
if err != nil {
t.Fatalf("non-terminal tool error must not abort stream: %v", err)
}
if result.Answer != "complete answer" {
t.Fatalf("answer=%q, want complete answer", result.Answer)
}
if len(observed) != 2 || observed[0].ResponseType != "error" || observed[0].Done {
t.Fatalf("events=%+v, want a non-terminal error followed by answer", observed)
}
}
func TestAgentChatRequestJSONSerializesImages(t *testing.T) {
req := AgentChatRequest{
Query: "请描述并分析这张图片。",
+85
View File
@@ -0,0 +1,85 @@
package weknora
import (
"strings"
"testing"
)
func TestLiveDirectToolResultShapeBecomesStructuredReferences(t *testing.T) {
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"get_document_info\",\"success\":true,\"data\":{\"total_docs\":1,\"documents\":[{\"knowledge_id\":\"doc-live\",\"title\":\"现场文档\",\"chunk_count\":8}]}}}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"wiki_search\",\"success\":true,\"data\":{\"found_kbs\":{\"entity/cra\":[\"wiki-kb\"]}}}}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
result, err := parseSSE(strings.NewReader(stream))
if err != nil {
t.Fatal(err)
}
if len(result.References) != 2 {
t.Fatalf("references=%+v", result.References)
}
if result.References[0].KnowledgeID != "doc-live" || result.References[1].ID != "wiki:wiki-kb:entity/cra" {
t.Fatalf("references=%+v", result.References)
}
}
func TestLiveFlattenedToolResultShapeBecomesStructuredReferences(t *testing.T) {
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"success\":true,\"count\":1,\"results\":[{\"knowledge_id\":\"doc-flat\",\"knowledge_title\":\"现场文档\"}]}}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
result, err := parseSSE(strings.NewReader(stream))
if err != nil {
t.Fatal(err)
}
if len(result.References) != 1 || result.References[0].KnowledgeID != "doc-flat" {
t.Fatalf("references=%+v", result.References)
}
}
func TestCanonicalReferencesReplaceSyntheticToolFallbacks(t *testing.T) {
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"success\":true,\"data\":{\"count\":1,\"results\":[{\"knowledge_id\":\"doc-1\",\"knowledge_title\":\"标题\"}]}}}\n\n" +
"data: {\"response_type\":\"references\",\"knowledge_references\":[{\"id\":\"chunk-1\",\"knowledge_id\":\"doc-1\",\"content\":\"完整引用一\",\"score\":0.9},{\"id\":\"chunk-2\",\"knowledge_id\":\"doc-1\",\"content\":\"完整引用二\",\"score\":0.8}]}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
result, err := parseSSE(strings.NewReader(stream))
if err != nil {
t.Fatal(err)
}
if len(result.References) != 2 || result.References[0].ID != "chunk-1" || result.References[1].ID != "chunk-2" {
t.Fatalf("canonical references were not preserved: %+v", result.References)
}
}
func TestSmartReasoningToolResultsBecomeStructuredReferences(t *testing.T) {
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"result\":{\"success\":true,\"data\":{\"count\":2,\"results\":[{\"knowledge_id\":\"doc-1\",\"knowledge_title\":\"CRA介绍\"},{\"knowledge_id\":\"doc-2\",\"knowledge_title\":\"CRA手册\"}]}}}}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"grep_chunks\",\"result\":{\"success\":true,\"data\":{\"document_count\":2,\"knowledge_results\":[{\"knowledge_id\":\"doc-2\",\"knowledge_title\":\"CRA手册\",\"chunk_hit_count\":3},{\"knowledge_id\":\"doc-3\",\"knowledge_title\":\"案例视频\",\"chunk_hit_count\":5}]}}}}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"list_knowledge_chunks\",\"result\":{\"success\":true,\"data\":{\"knowledge_id\":\"doc-3\",\"knowledge_title\":\"案例视频\",\"fetched_chunks\":20}}}}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"完整回答\",\"done\":true}\n\n"
result, err := parseSSE(strings.NewReader(stream))
if err != nil {
t.Fatal(err)
}
if len(result.References) != 3 {
t.Fatalf("references=%+v", result.References)
}
for index, want := range []string{"doc-1", "doc-2", "doc-3"} {
if result.References[index].KnowledgeID != want {
t.Fatalf("references[%d]=%+v want knowledge %s", index, result.References[index], want)
}
}
}
func TestSmartReasoningEmptyOrFailedToolResultsRemainUncovered(t *testing.T) {
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"result\":{\"success\":true,\"data\":{\"count\":0,\"results\":[]}}}}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"grep_chunks\",\"result\":{\"success\":false,\"data\":{\"document_count\":2,\"knowledge_results\":[{\"knowledge_id\":\"must-not-count\",\"chunk_hit_count\":2}]}}}}\n\n" +
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"list_knowledge_chunks\",\"result\":{\"success\":true,\"data\":{\"knowledge_id\":\"also-empty\",\"fetched_chunks\":0}}}}\n\n" +
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
result, err := parseSSE(strings.NewReader(stream))
if err != nil {
t.Fatal(err)
}
if len(result.References) != 0 {
t.Fatalf("empty/failed tools produced references: %+v", result.References)
}
}
+117
View File
@@ -0,0 +1,117 @@
package scripts
import (
"os"
"strings"
"testing"
)
func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testing.T) {
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"dobotai-dingtalk:",
"container_name: dobotai-dingtalk",
"network_mode: host",
"env_file:",
"read_only: true",
"/var/lib/dobotai/secrets",
} {
if !strings.Contains(string(compose), required) {
t.Errorf("compose missing %q", required)
}
}
configure, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-configure")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"configure_env.py",
"/etc/dobotai-test/dobotai.env",
"dobotai-dingtalk-start",
} {
if !strings.Contains(string(configure), required) {
t.Errorf("configure command missing %q", required)
}
}
bind, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin")
if err != nil {
t.Fatal(err)
}
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 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{"getpass.getpass", "os.replace", "0o600", "DINGTALK_CLIENT_SECRET"} {
if !strings.Contains(source, required) {
t.Errorf("credential configurator missing %q", required)
}
}
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)
}
}
}
func TestDobotAIDingTalkCredentialConfigurationExplainsEveryInputWithRedactedExamples(t *testing.T) {
configurator, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
if err != nil {
t.Fatal(err)
}
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{
"postgres://USER:<数据库密码>@127.0.0.1:55432/dobotai_test?sslmode=disable",
"http://127.0.0.1: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)
}
}
}
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\"",
"docker compose --env-file \"$runtime_env\" -f \"$compose_file\" up -d --no-build --force-recreate",
} {
if !strings.Contains(source, required) {
t.Errorf("start script missing container upgrade rollback %q", required)
}
}
}