Compare commits

..

1 Commits

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