Compare commits

...

55 Commits

Author SHA1 Message Date
姚章浩 16ee1ddfbe fix(ci): 存量历史提交只告警不拦截,新增提交仍严格校验
build / build (push) Successful in 18s
把早已推送到其它远端分支的 lineage 合进 main 时,被带进来的提交不是本次新增,
它们标题不合规属既有事实。闸门先抓全部远端分支头,用 git branch -r --contains
取证:能证明已存在于其它远端分支的只告警放行;证不出来的照旧拦截(fail-closed)。
2026-11-11 16:42:15 +08:00
姚章浩 d24457d9d5 Merge remote-tracking branch 'origin/fix/weknora-timeout-nolimit' into HEAD
build / build (push) Failing after 4s
2026-11-11 15:18:47 +08:00
zheromiku 1b42ac7765 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:36 +08:00
zheromiku 65490b1aaa ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:34 +08:00
zheromiku f803365a20 ci: 接入提交规范闸门与自动编译流水线(本机Runner+微信通知)
build / build (push) Has been cancelled
2026-09-28 18:38:33 +08:00
姚章浩 70b144f1e5 fix(weknora): drop 120s client total timeout so long agent streams are not cut
The weknora HTTP client used a hardcoded 120s total-duration timeout, which
kills any smart-reasoning agent stream that legitimately runs longer (22 tool
rounds measured in prod) and the failure branch then overwrote the already
streamed card with the error text. Pass 0 and keep Go's default (no timeout).
2026-09-22 16:10:07 +08:00
姚章浩 1bbaf2f07a fix(outboundtext): keep resource handles intact when normalizing formulas 2026-09-18 17:14:45 +08:00
姚章浩 48b741984f fix(reasoning): never cut a lead-in that carries an image 2026-09-18 16:49:16 +08:00
姚章浩 5ccaeb3b96 fix(outbound): keep single-run answers whole and show formulas
The DingTalk binding can point at the quick-answer agent, which publishes the
whole answer as one answer run plus tool_call/tool_result steps. The narration
boundary was rejected for that shape (start=0), so the text-only heuristic ran
and dropped the answer lead-in together with its image.

- answer_observation: a single answer run means there is no narration to cut.
- outboundtext: rewrite LaTeX formulas into plain Unicode text, because
  DingTalk cards render no math typesetting (r = sqrt(X^2 + Y^2) -> r = √(X² + Y²)).
- dingtalk handler: normalize formulas on both the partial and final card paths.
2026-09-18 16:21:42 +08:00
姚章浩 dfce8cb9f6 test(qa): pin the answer-run boundary used to trim narration
Adds the production CRA5 shape (narration run + tool steps + final answer run
that opens with a lead-in and an image) and a control case where the last
answer run is interrupted by a tool call and the heuristic still applies.
2026-09-18 15:58:14 +08:00
姚章浩 149e94b34b fix(reasoning): keep the answer body's lead-in and images when trimming narration
The DingTalk card dropped the answer's opening (and any image reference in
it) whenever the agent narrated its steps before answering: the text-only
boundary heuristic (reasoningmode.BodyStartOffset) treats everything before
the first structural line as narration, so a lead-in like "我已经找到了…"
plus the image that follows it disappeared, leaving the card to start at
the first heading.

The event stream already knows the boundary: every agent step publishes its
answer chunks under its own answer run id (data.event_id), and the final
body is the last run that no tool/thinking event interrupts. WeKnora
StreamEvent now carries the run id and run start offset; qa.Service tracks
the observation and prefers it over the heuristic, falling back to the
heuristic when the stream carries no run information.

Verified: production turn efae317e (给我CRA5的负载图) lost 131 chars —
lead-in + the load-curve image + separator — and now keeps them.
2026-09-18 15:53:43 +08:00
hermes dd229ec0ad feat(dingtalk): per-frame wide-table grouping (stream keeps tables flowing while streaming) 2026-09-18 11:55:45 +08:00
hermes 92324b4242 feat(dingtalk): make wide-table grouping switchable for wide-screen PC cards
DINGTALK_CARD_WIDE_TABLE_GROUP=off keeps over-wide answer tables as tables on the final frame (still replaces <br> inside cells) so the PC wide-screen card can lay them out itself.
2026-09-18 11:45:55 +08:00
agent 882ac62433 perf(dingtalk): compress outbound screenshots, reuse uploaded objects, raise rewrite budget
- outboundimage: re-encode large PNG/JPEG screenshots as downscaled JPEG (1600px long edge, q82, transparency flattened onto white) before upload; undecodable, small, GIF/WebP payloads are published untouched
- outboundimage: StableHashPublisher skips the upload when the content-addressed object already exists (ObjectStater); OSS store implements it via IsObjectExist
- cmd/dobotai: DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT default 60s -> 180s; DOBOTAI_OUTBOUND_IMAGE_COMPRESS (on by default, off restores raw bytes)
2026-09-18 11:14:13 +08:00
agent 8a9c1eaf82 perf(dingtalk): rewrite answer images concurrently and make the rewrite budget configurable
- outboundimage: fetch and publish up to 4 images in parallel, dedupe in document order, log per-image outcome plus a rewrite summary
- dingtalk handler: OutboundRewriteTimeout applies to the image rewrite stage only, falling back to finalDeliveryTimeout
- cmd/dobotai: DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT (default 60s, invalid values fall back)
2026-09-18 10:57:32 +08:00
agent 9de1ed66aa feat(dingtalk): make outbound image count configurable (DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT, default 12) 2026-09-18 10:16:14 +08:00
姚章浩 cfe3271fa2 feat(dingtalk): fall back to the public image URL and report image ref shape
The loopback fetch now degrades to the public URL ...
2026-09-17 18:20:02 +08:00
姚章浩 dfc7c8dba9 feat(dingtalk): deliver card images through DingTalk media ids
AI card bodies render only DingTalk-hosted media, so published image URLs are
rewritten into media ids inside card content (streaming frames never block,
final frame waits briefly), while the markdown fallback keeps external URLs.
2026-09-17 18:14:03 +08:00
姚章浩 06d11135e1 deploy(prod): 生产切换脚本(门控字面量+回滚标签+env 0600 备份) 2026-09-17 14:25:30 +08:00
姚章浩 5f1206b545 fix(dingtalk): 推理正文边界支持「首行即正文」并补齐卡片结构诊断 + 流式分帧重放守卫 2026-09-17 14:16:40 +08:00
姚章浩 a1a2aabab4 fix(dingtalk): 推理正文边界支持「首行即正文」并补齐卡片结构诊断
- BodyStartOffset:内容首个非空行即结构行时正文从头开始,避免收尾裁剪
  把标题+首段一起删掉(118 条生产样本中 31 条、最多 526 字)
- 语料回归守卫:隐藏前缀不得含结构行、上限 600 字;单测补 suffix 断言
- 卡片诊断新增 body_ok/body_line/body_prefix_runes/banner_pos/first_heading_line
  (定位流式残留推理末句,只看结构不看文本)
2026-09-17 14:01:10 +08:00
姚章浩 fcca33050a test: 通用的测试服镜像切换脚本(先打回滚标签+env 0600 备份,读回校验) 2026-09-17 11:08:23 +08:00
姚章浩 cd3f92aaaa verify: 合并宽屏自适应与推理输出改造(测试服验证用) 2026-09-17 11:03:05 +08:00
姚章浩 15f2a11162 fix(dingtalk): 推理模式卡片隐藏推理并只留正文
- reasoningmode: 新增正文边界识别(结构行 + 前置空行)与流式 PartialFeed:
  分步叙述按行拼接显示,正文开始时只输出正文,卡片更新即刷新清掉推理区
- dingtalk: 事件流与流式回调统一改走 PartialFeed
- qa: 推理执行的最终答案在入库/投递前去掉步骤叙述(快速模式行为不变)

语料回归:生产 118 条内建智能推理答案中 106 条识别出正文边界(被隐藏的
均为 59-164 字过渡句),12 条无推理的问候类答案保持原样;go vet 与
go test ./... 全绿。
2026-09-17 10:58:59 +08:00
hermes-agent a096ba5f51 fix(dingtalk): 投放 AI 卡片时开启宽屏自适应布局
PC 端卡片宽度过窄:创建卡片(createAndDeliver)请求体从未请求宽屏布局。

修复落在创建卡片的 JSON 载荷层 —— cardData.cardParamMap 的钉钉保留系统
字段 sys_full_json_obj 内传 {"config":{"autoLayout":true}}。config 为
对象类型,钉钉规定复杂类型变量必须走该保留字段;放在 cardParamMap 兄弟
节点会因变量未赋值而静默失效(宽度不变、无报错)。

- 仅影响 PC 宽屏渲染;移动端保持自适应布局不变。
- 未改动富文本组件宽度/padding,也未改动模板内静态内容。
- 流式更新(streaming)请求体不带该字段,避免中途变更布局。

新增回归守卫测试:投放体(direct/group)必须含该字段且 autoLayout=true,
流式更新体必须不含;防止退化为静默失效写法。

验证:gofmt 干净、go vet ok、go test ./... 全绿;
A/B 探针(A 带字段 / B 对照)经 PC 端人工确认 A 卡片变宽。
2026-09-17 09:59:39 +08:00
zheromiku ba090d1705 Merge pull request 'feat: default smart reasoning and robust channel delivery' (#16) from feat/default-reasoning-mode into main 2026-09-15 17:13:32 +08:00
姚章浩 4e65028d1d fix(dingtalk): strip leaked internal source handles 2026-09-15 17:08:33 +08:00
姚章浩 c7d2629a77 fix(coverage): let references override partial no-answer wording 2026-09-15 16:53:12 +08:00
姚章浩 b49cb5eaf9 feat(dingtalk): normalize wide tables into grouped lists on final card 2026-09-15 16:09:43 +08:00
姚章浩 16aa981028 test(reasoning): cover default-mode routing, user pinning and 000022 contract 2026-09-15 14:01:00 +08:00
姚章浩 f800127461 feat(reasoning): support operator-configurable default mode via DOBOTAI_DEFAULT_MODE 2026-09-15 12:25:09 +08:00
姚章浩 d6a781ba5b fix(dingtalk): send answer language via Accept-Language header
WeKnora's system prompt hard-enforces 'ALWAYS respond in {{language}}',
fed by its Language middleware (WEKNORA_LANGUAGE env -> Accept-Language
header -> zh-CN fallback). The previous localizedQuery() prompt prefix
lost against that instruction, so a user-set 'en' preference still got
Chinese answers on English questions.

- AgentChatRequest.Locale is transmitted as the Accept-Language header
  (never in the JSON body); WeKnora maps it to the prompt language
- 'auto' detects the query script client-side (latin->en, Han->zh-CN,
  kana->ja, Hangul->ko); es is never auto-guessed
- unset/unknown locale sends no header, preserving prior server behavior
- drop localizedQuery prompt prefixes entirely
2026-09-14 16:58:52 +08:00
姚章浩 168e401bc0 docs(deploy): table the optional outbound-image OSS fields 2026-09-11 20:38:06 +08:00
zheromiku 58b37f1c24 Merge pull request 'fix(deploy): configure outbound image OSS as an optional group' (#15) from feat/docker-external-rds into main 2026-09-11 18:48:56 +08:00
姚章浩 6a3f505965 fix(deploy): configure outbound image OSS as an optional group 2026-09-11 18:48:15 +08:00
zheromiku a0f1fbf391 Merge pull request 'feat(deploy): external-RDS Docker deployment with fielded RDS config' (#14) from feat/docker-external-rds into main 2026-09-11 18:39:20 +08:00
姚章浩 49e0789fa5 feat(deploy): prompt RDS config as separate fields 2026-09-11 18:04:38 +08:00
姚章浩 d77fbd675b fix(deploy): honor configured Go module proxy 2026-09-11 17:27:20 +08:00
姚章浩 a615693bd0 fix(deploy): configure distinct quick and reasoning agents 2026-09-11 13:26:17 +08:00
姚章浩 febf99b549 fix(deploy): generate internal tenant key automatically 2026-09-11 13:20:44 +08:00
姚章浩 b4b61c0aa5 fix(deploy): explain missing RDS database to administrators 2026-09-11 12:25:31 +08:00
姚章浩 6153533b46 fix(deploy): isolate installer command directory 2026-09-11 12:10:08 +08:00
姚章浩 27d4d45af4 feat(deploy): run Docker application against external RDS 2026-09-11 12:09:29 +08:00
zheromiku a31156dc4e Merge pull request 'docs: 清理历史计划并说明仓库目录结构' (#13) from feat/cli-admin-bootstrap into main 2026-09-11 10:18:28 +08:00
姚章浩 f408096611 docs: document repository structure and remove stale plan 2026-09-11 10:17:03 +08:00
hermes-bot 218ab657d6 Merge pull request 'Feat/cli admin bootstrap' (#12) from feat/cli-admin-bootstrap into main
Reviewed-on: #12
2026-09-10 22:38:04 +08:00
姚章浩 5c5e4494cb test(migrations): cover non-knowledge response reason 2026-09-10 22:25:48 +08:00
姚章浩 e584b17929 docs(dobotai): add container deployment guide 2026-09-10 22:25:19 +08:00
姚章浩 8027635330 fix(coverage): skip alerts for non-knowledge text turns 2026-09-10 22:02:59 +08:00
姚章浩 563329320b docs(dobotai): guide container credential input 2026-09-10 18:31:55 +08:00
姚章浩 9abbb2cb2b feat(dobotai): containerize test runtime and admin bootstrap 2026-09-10 18:22:21 +08:00
hermes-bot 4c5b1e3b5a Merge pull request 'docs: 中文化文档与环境示例注释' (#11) from docs/chinese-documentation-r1 into main
Reviewed-on: #11
2026-09-10 17:04:31 +08:00
姚章浩 fa938ca1eb docs: translate documentation and environment comments to Chinese 2026-09-10 16:59:22 +08:00
zheromiku 7661e1b8f7 Merge pull request 'feat(dingtalk): merge tested multimodal, reasoning, and support workflows' (#10) from feat/think-mode-support-alerts into main 2026-09-10 16:40:42 +08:00
姚章浩 bfa1b1b25f feat(dingtalk): add reasoning mode and support evidence 2026-09-10 16:24:56 +08:00
136 changed files with 9209 additions and 3179 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.env
.env.*
!.env.example
bin/
.acceptance-canary/
.hermes/
__pycache__/
*.pyc
+22 -28
View File
@@ -1,58 +1,52 @@
# DobotAI configuration
# DobotAI 基础配置
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DOBOTAI_INGRESS_TOKEN=replace-me
# Optional local-only audit API. Use a distinct secret from DOBOTAI_INGRESS_TOKEN.
# When set, DOBOTAI_TENANT_KEY must be set and the listen address must be loopback-only.
# 可选的仅本机审计 API。必须使用不同于 DOBOTAI_INGRESS_TOKEN 的独立密钥。
# 设置后必须同时设置 DOBOTAI_TENANT_KEY,且监听地址必须仅为回环地址。
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_GROUP_SESSION_POLICY=shared
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
# `/think` 路由必填。上面的基础绑定保持稳定的会话身份;以下配置仅选择每个 Turn 的执行 Agent。
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
# Official WeKnora API only
# 仅使用 WeKnora 官方 API
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=replace-me
# DingTalk Stream mode (official SDK). Leave all three empty to disable the
# DingTalk channel; set all three to enable it. Never commit real values.
# 钉钉 Stream 模式(官方 SDK)。三项均留空时禁用钉钉渠道;三项同时设置时启用。不得提交真实值。
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
# Tenant key this DingTalk ingress serves (matches tenant_key used by QA).
# 该钉钉接入服务的租户键(必须与 QA 使用的 tenant_key 一致)。
DOBOTAI_TENANT_KEY=
# Optional DingTalk AI card template ID. Leave empty to use markdown-only replies.
# When set, a successful QA answer will be delivered as an AI card; markdown
# is only sent as a fallback when card delivery fails. Never commit real values.
# 可选的钉钉 AI 卡片模板 ID。留空时仅使用 Markdown 回复。
# 设置后,成功的 QA 回答优先通过 AI 卡片发送;仅当卡片发送失败时才回退 Markdown。不得提交真实值。
DINGTALK_CARD_TEMPLATE_ID=
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
# Keep empty outside a controlled debug session; never commit a real user ID.
# 可选、临时的钉钉卡片调试白名单。设置后,只有该指定钉钉用户会在 per-user privateData 中收到 _CARD_DEBUG_TOOL_ENTRY=show。
# 非受控调试期间必须留空;不得提交真实用户 ID。
DINGTALK_CARD_DEBUG_USER_ID=
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
# values together; leave all four empty to disable. Required when the support
# group notification below is enabled. Never commit real IDs.
# 可选的钉钉 FAQ 表工作流,用于未覆盖问题。四项必须同时设置;四项均留空时禁用。
# 启用下方人工支持群通知时,此工作流为必需项。不得提交真实 ID。
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# Optional PDF-answer image egress for DingTalk cards. When any value is set,
# all five values are required. DobotAI downloads only authenticated resource://
# images from WEKNORA_BASE_URL /files, writes content-hashed copies under
# dobotai/im-images/, and emits private OSS GET URLs valid for 2 hours.
# Keep the bucket private and configure an OSS lifecycle rule to delete
# dobotai/im-images/ objects after 1 day. Never commit real credentials.
# 可选的钉钉卡片回答图片外发。任一项设置后,以下五项都必须设置。
# DobotAI 仅从 WEKNORA_BASE_URL 的 /files 鉴权下载 resource:// 图片,在 dobotai/im-images/ 下写入内容哈希副本,并生成有效期2小时的私有 OSS GET URL。
# Bucket 必须保持私有,并配置 OSS 生命周期规则,在1天后删除 dobotai/im-images/ 对象。不得提交真实凭据。
DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT=
DOBOTAI_OUTBOUND_IMAGE_OSS_REGION=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID=
DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET=
DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET=
# Optional support group for uncovered-answer notifications. This is the
# group's openConversationId, not a webhook. Leave empty to record coverage
# decisions and pending support cases without sending group notifications.
# When enabled, the FAQ table workflow above must also be configured so the
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
# 可选的未覆盖回答人工支持群通知。该值是群的 openConversationId,不是 webhook。
# 留空时仍记录覆盖判定和待处理 Support Case,但不发送群通知。
# 启用后必须同时配置上方 FAQ 表工作流,使 ActionCard 能链接到准确的 FAQ 记录。不得提交真实群 ID。
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
# 独立的 DobotAI PostgreSQL;不得指向 WeKnora 数据库
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""提交信息规范校验(CI 侧权威闸门)。
规则与本机 ~/.githooks/commit-msg 完全一致,防止 --no-verify 绕过。
放行:Merge / Revert " / fixup! / squash! / amend! 开头的自动生成提交。
存量历史口径:合并一条早已推送到别的远端分支的lineage(例如把生产分支合进 main)时,
被带进来的提交并非本次新增,它们的标题不合规属于既有事实。判定为「已在其它远端分支存在」
的提交只告警、不判红;本次真正新造的提交一律照旧严格校验。
豁免必须是可证明的:先抓全部远端分支头,用 `git branch -r --contains <sha>` 取证;
抓不到 / 证不出 ⇒ 不豁免(宁可红,也不假绿)。
"""
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! )')
# 本次推送的分支名,用来把「当前分支自己」排除在豁免证据之外
PUSH_BRANCH = (os.environ.get("GITHUB_REF_NAME") or "").strip()
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 sh(args):
return subprocess.run(args, capture_output=True, text=True)
def collect():
"""返回 [(sha, message)]:优先 Gitea 事件负载,取不到则退回 git log。"""
items = []
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:
items.append(((c.get("id") or "").strip(), m))
except Exception as e:
print(" 事件负载解析失败(%s),回退到 git log" % e)
if not items:
out = sh(["git", "log", "-n", "5", "--format=%H%x1f%B%x1e"]).stdout
for rec in out.split("\x1e"):
rec = rec.strip()
if not rec:
continue
sha, _, msg = rec.partition("\x1f")
if msg.strip():
items.append((sha.strip(), msg.strip()))
return items
_fetched = None
def ensure_remote_heads():
"""抓全部远端分支头(浅历史足够判断 lineage 末端)。"""
global _fetched
if _fetched is None:
r = sh(["git", "fetch", "-q", "--depth", "200", "origin", "+refs/heads/*:refs/remotes/origin/*"])
_fetched = (r.returncode == 0, (r.stderr or "").strip())
if not _fetched[0]:
print(" \u26a0 无法抓取远端分支,存量豁免不可用(将严格校验): %s" % _fetched[1][:200])
return _fetched[0]
def refs_containing(sha):
"""除本次推送分支外,还有哪些远端分支包含该提交。"""
if not ensure_remote_heads():
return []
r = sh(["git", "branch", "-r", "--contains", sha])
if r.returncode != 0:
return []
refs = []
for line in r.stdout.splitlines():
ref = line.strip().lstrip("*").strip()
if not ref or "->" in ref:
continue
if PUSH_BRANCH and ref == "origin/" + PUSH_BRANCH:
continue
refs.append(ref)
return refs
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():
items = collect()
bad = []
excused = []
print("待校验提交 %d 条:" % len(items))
for sha, msg in items:
subj = subject_of(msg)
why = judge(subj)
if not why:
print(" \u2705 %s" % subj)
continue
refs = refs_containing(sha) if sha else []
if refs:
excused.append((sha, subj, why, refs))
print(" \u21aa [%s] %s ← 已存在于 %s,按存量历史放行" % (why, subj, ",".join(refs[:3])))
continue
bad.append((subj, why))
print(" \u274c [%s] %s" % (why, subj))
if excused:
print("")
print("存量豁免 %d 条(提交早于本次合并,已在其它远端分支存在):" % len(excused))
for sha, subj, why, refs in excused:
print(" %s %s [%s]" % (sha[:10], subj, ",".join(refs[:3])))
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,6 +10,12 @@ coverage.out
/backups/
/check-live-dobotai.py
# Transient local validation and legacy test-deployment artifacts
/dobotai
/.acceptance-canary/
/.hermes/
/deploy_stable_images_test.py
# Editors and OS
.idea/
.vscode/
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
FROM golang:1.26.5-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
RUN go mod download
COPY . ./
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
-o /out/dobotai ./cmd/dobotai
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/dobotai /usr/local/bin/dobotai
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/dobotai"]
+198 -77
View File
@@ -1,115 +1,236 @@
# DobotAI Platform
# DobotAI 平台
DobotAI is the external enterprise integration layer for official WeKnora. It does not import WeKnora packages, modify WeKnora source, or access the WeKnora database.
DobotAI 是 WeKnora 的企业外部渠道集成层,负责钉钉协议、身份与会话映射、消息编排、流式卡片、媒体收发、覆盖判定和人工支持通知。它只通过 WeKnora 官方 REST/SSE API 调用知识问答与 Agent Chat:不导入 WeKnora 私有包、不修改 WeKnora 源码,也不访问 WeKnora 数据库。
Current milestone: channel-bound Agent QA core.
## 当前能力
Implemented:
- 通过官方 `POST /api/v1/sessions` 与 `POST /api/v1/agent-chat/:session_id` 调用 WeKnora;
- 严格解析 SSE 的 `answer`、`references`、`tool_call`、`tool_result`、`error` 与终止事件;`error + done=false` 被视为可恢复的工具错误,不会提前结束钉钉卡片;
- 解析普通引用及智能推理工具结果中的检索证据,供覆盖判定与后端审计使用;
- 按渠道、会话和用户维护长期 WeKnora Session,并保证回调幂等;
- 钉钉 Stream 机器人支持文本、richText 以及多图片输入;图片下载经过钉钉官方接口、大小与文件签名校验后,才以 WeKnora 通用多模态请求发送;
- 钉钉 AI 流式卡片与最终消息回退;通道侧保留原始回答,不修改 WeKnora 持久化消息;
- 默认快速问答,支持 `/think <问题>` 单次推理、`/think on` 持续推理、`/think off` 或 `/rag` 关闭推理;快速问答与智能推理共用同一 WeKnora Session;
- 用户级推理轮次、空闲到期、提示开关和首次使用指引;
- 成功回答的覆盖判定、持久化 Support Case、FAQ Outbox 与钉钉人工支持群通知;
- 对已覆盖答案点踩时创建 `feedback_down` 人工处理提醒;对已先因未覆盖创建 Case 的同一问题保持“一题一个 Case”幂等;
- 可选的钉钉问题表异步写入;写入失败不会改变问答成功状态;
- 可选的回答图片外发:仅将 WeKnora `resource://` 图片通过鉴权下载、内容哈希转存到私有 OSS 后,以受控 HTTPS 地址提供给钉钉。
- official `POST /api/v1/sessions` adapter;
- official `POST /api/v1/agent-chat/:session_id` adapter;
- fail-closed SSE parsing for `references`, `answer`, `error`, and terminal `done`;
- per-channel/per-conversation Agent binding;
- long-lived direct-user sessions;
- shared-group sessions, with optional group+user policy;
- external-message idempotency;
- independent PostgreSQL session/turn storage;
- authenticated internal `POST /v1/qa` ingress endpoint.
- unauthenticated read-only `GET /version` runtime build identity endpoint.
- DingTalk Stream text ingress (official SDK) wired into `cmd/dobotai`.
- v1 knowledge coverage decisions, durable support cases, and optional DingTalk support-group notifications.
## 架构边界
## DingTalk channel (Stream, text only)
```text
钉钉 / 内部调用方
↓
DobotAI
├─ 钉钉 Stream、richText、图片、AI 卡片、反馈与通知
├─ 独立 PostgreSQL:渠道身份、消息、会话映射、Turn、覆盖与支持流程
└─ WeKnora 官方 REST / SSE API
↓
WeKnora:检索、Agent、知识与引用
```
- Uses the official DingTalk Stream SDK (`dingtalk-stream-sdk-go`); no public webhook endpoint is exposed.
- Text messages only for now. Images, OpenAPI fallback, and card replies are not implemented yet.
- Group chats are handled only when the bot is @-mentioned; other group messages are ignored.
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
责任边界:
## DingTalk question table sync (Notable v1)
- **DobotAI**:钉钉协议、媒体收发、会话映射、命令路由、卡片、覆盖判定、人工支持、渠道审计。
- **WeKnora**:通用 Agent Chat、检索、知识存储、引用与模型调用。
- **DobotAI PostgreSQL**:只保存渠道与业务编排数据,不保存 WeKnora 知识正文。
- **Redis**:仅用于队列、重试、幂等租约和短期状态。
Successful DingTalk QA turns can be appended asynchronously to a DingTalk Notable AI table through a durable PostgreSQL outbox. Notable failures never change the successful QA result; the worker retries with bounded backoff and stores only stable failure codes.
## 目录结构
Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the worker. The three table settings are optional as a group: all blank disables dispatch, while a partial configuration fails startup closed.
```text
.
├── cmd/
│ ├── dobotai/ 主程序入口、运行时装配与后台 Worker
│ └── directory-import/ 钉钉通讯录导入命令
├── internal/
│ ├── channels/dingtalk/ 钉钉 Stream、卡片、媒体、反馈与群通知
│ ├── qa/ 问答主流程、WeKnora 调用和检索信号记录
│ ├── weknora/ SSE、工具结果和引用解析
│ ├── coverage/ 覆盖判定、Support Case 和人工提醒
│ ├── reasoningmode/ /think 推理模式
│ ├── settings/ /setting 与首位管理员绑定
│ ├── outboundimage/ resource:// 图片私有 OSS 外发
│ ├── questiontable/ 问题表工作流
│ ├── faqtable/ FAQ 表工作流
│ ├── directory/ 钉钉通讯录查询与导入
│ ├── audit/ 审计查询
│ ├── httpapi/ /healthz、/version、/v1/qa 与审计 API
│ ├── core/ 核心领域类型
│ ├── session/ 会话键和会话标题
│ ├── agentbinding/ 渠道到 WeKnora Agent 的绑定
│ ├── feedback/ 点赞/点踩领域逻辑
│ ├── secretstore/ 本地密钥引用存储
│ ├── dingtalkconfig/ 钉钉凭据校验
│ ├── runtime/ 运行时管理
│ └── store/postgres/ PostgreSQL 持久化实现
├── db/migrations/ SQL 迁移及其契约测试
├── deploy/dobotai-dingtalk/ 容器 Compose、安装/配置/启动/绑定脚本和部署手册
├── docs/ 架构说明与 API 边界文档
├── scripts/ 本地部署与部署资产契约测试
├── Dockerfile DobotAI 容器镜像构建
├── .env.example 无真实凭据的配置示例
└── README.md 项目功能、边界和开发说明
```
- `DINGTALK_QUESTION_TABLE_BASE_ID`
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
目录职责规则:`cmd/`只装配进程,`internal/`承载业务模块,`internal/store/postgres/`只实现数据库持久化,`db/migrations/`只放Schema变更,`deploy/`只放容器运行资产。历史计划、备份、镜像归档和真实环境文件均不进入Git。
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
## 部署方式
## Uncovered-question FAQ workflow
| 方式 | 适用场景 | 数据库 | 入口 |
| --- | --- | --- | --- |
| 源码/原生二进制 | 本地开发、快速调试 | 手工配置阿里云RDS连接 | `go run ./cmd/dobotai` |
| Docker | 测试、预发布、生产候选 | 既有阿里云RDS;Docker不运行数据库 | `sudo bash ./install-docker.sh` |
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.
### 源码部署
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
```bash
go run ./cmd/dobotai
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
```
- `DINGTALK_FAQ_TABLE_BASE_ID`
- `DINGTALK_FAQ_TABLE_SHEET_ID`
- `DINGTALK_FAQ_TABLE_VIEW_ID`
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
源码方式不自动安装Docker、不创建目录、不检查RDS表,也不适合作为长期渠道消费者。
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.
### Docker + 阿里云RDS部署
## WeKnora baseline
克隆仓库后在根目录执行:
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.
```bash
sudo bash ./install-docker.sh
sudo dobotai-dingtalk-configure
```
## Run
第一条命令自动安装Docker/Compose(如缺失)、构建应用镜像、准备目录和配置模板;第二条命令安全录入RDS、WeKnora与钉钉配置,然后检查RDS表:空库初始化`000001`至`000021`,完整表不写入,异常/半成品表拒绝启动。Docker只运行`dobotai-dingtalk`,不创建`dobotai-postgres`、本地数据卷或本地数据库。
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:
完整安装、RDS检查、升级和回滚见 [Docker部署手册](deploy/dobotai-dingtalk/README.md)。
## 钉钉 Stream
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
- 群聊仅在机器人被 `@` 时处理;
- 私聊按用户保持长期会话;群聊默认按群共享会话;
- 回调 `sessionWebhook` 严格限制为 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- 启用 Stream 必须同时设置以下三项;三者均为空时渠道禁用,部分配置会失败关闭:
```text
DINGTALK_CLIENT_ID
DINGTALK_CLIENT_SECRET
DOBOTAI_TENANT_KEY
```
## 推理模式命令
```text
/think <问题> 仅当前问题使用智能推理
/think on 开启持续智能推理
/think off 或 /rag 关闭持续智能推理
/setting rounds <1-50> 设置持续推理自动关闭轮数
/setting hint <on|off> 设置剩余轮次提示
/help、/帮助、/h 查看当前模式和帮助
```
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
## 首位管理员终端绑定
首位管理员不通过网页生成验证码。先确保钉钉 Stream 机器人已经正常运行,登录部署服务器后执行:
```bash
dobotai bootstrap-admin
```
若二进制未加入 `PATH`,使用其实际绝对路径执行。该命令只读取 `DATABASE_URL` 和 `DOBOTAI_TENANT_KEY`,不会启动 WeKnora、钉钉消费者或问答服务。
命令会在终端使用加密随机数生成一个有效期10分钟的六码绑定码,并保持等待。候选管理员必须私聊机器人发送:
```text
/setting <六码绑定码>
```
验证码正确后,终端自动将该钉钉用户绑定为 `super_admin` 并显示成功。已有有效管理员时命令会拒绝执行,防止通过该入口追加或替换管理员。DobotAI 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。
## 覆盖与人工支持
- 有结构化检索引用的回答标记为已覆盖;
- 没有引用但已执行检索的回答标记为未覆盖;
- 没有引用且未执行检索的文本标记为 `covered_non_knowledge_response`,不创建 Support Case;
- 纯图片视觉识别单独记录为 `covered_visual_recognition`,不计入“未覆盖”;
- 无权威知识时,产品统一回复:`当前暂无已发布的权威知识,暂不建议操作。`;
- 人工通知只带当前问题和同一用户此前最多5个问题,不包含回答、引用或置信度;
- 未覆盖与点踩共用幂等 Support Case,避免对同一 Turn 重复通知。
## 配置
复制 `.env.example` 为仅本机可读的运行时环境文件,并在 Git 外注入真实值。不得提交 API Key、数据库密码、钉钉凭据、OSS AccessKey、预签名 URL 或真实用户/群组标识。
关键配置:
```text
DOBOTAI_LISTEN_ADDR
DOBOTAI_INGRESS_TOKEN
DOBOTAI_AGENT_BINDINGS
DOBOTAI_QUICK_AGENT_ID
DOBOTAI_REASONING_AGENT_ID
WEKNORA_BASE_URL
WEKNORA_API_KEY
DATABASE_URL
```
可选能力必须整组配置或整组留空:
- 钉钉 Stream;
- 钉钉 AI 卡片;
- 未覆盖问题 FAQ 表;
- 人工支持群通知;
- 问题表异步写入;
- 回答图片私有 OSS 外发。
任何部分配置均应在启动时失败关闭,而不是以不完整权限运行。
## 数据库迁移
DobotAI 使用独立 PostgreSQL,禁止指向 WeKnora 数据库。生产部署前必须按版本顺序执行并演练 `db/migrations/` 中的迁移;当前发布范围包含:
```text
000001_core ... 000018_coverage_visual_recognition_reason
000019_reasoning_mode_state
000020_feedback_support_trigger
000021_coverage_non_knowledge_response_reason
```
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
## 本地运行
```bash
go run ./cmd/dobotai
```
Health check:
健康检查:
```bash
curl http://127.0.0.1:8090/healthz
```
Runtime version check:
运行版本:
```bash
curl http://127.0.0.1:8090/version
```
`scripts/deploy-local.sh` builds from its own worktree (or the explicit
`DOBOTAI_SOURCE_ROOT`), injects `version`, `commit_sha`, and `build_time`, and
accepts the deployment only when `/version` reports the exact source commit.
It refuses dirty worktrees so the reported SHA cannot hide uncommitted source.
部署脚本应将 `version`、`commit_sha` 和 `build_time` 写入二进制,并仅在 `/version` 返回预期提交时接受部署。候选源码必须干净,避免运行版本掩盖未提交改动。
Internal QA request:
## 安全要求
```bash
curl -X POST http://127.0.0.1:8090/v1/qa \
-H "Authorization: Bearer $DOBOTAI_INGRESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"tenant_key":"default",
"channel":"dingtalk",
"conversation_type":"direct",
"external_user_id":"user-1",
"external_message_id":"message-1",
"query":"hello"
}'
```
- 不修改 WeKnora 源码、Schema 或私有包;
- 不直接连接 WeKnora 数据库;
- 不将凭据、Token、预签名 URL 或知识正文写入日志;
- 不接受任意外部图片 URL 作为可信媒体;
- 对钉钉图片、WeKnora `resource://` 图片和 OSS 返回体均执行来源、大小、类型和签名校验;
- 外发 OSS Bucket 保持私有;
- 生产、测试的钉钉应用、数据库、OSS Bucket 和凭据必须相互隔离;
- 生产上线必须使用已验收的同一构建产物,禁止在生产重新编译。
## Hard boundaries
- No WeKnora source modifications.
- No custom WeKnora tables or columns.
- No direct WeKnora database access.
- No private WeKnora package imports.
- All compatibility logic stays in `internal/weknora`.
- Business data stays in DobotAI PostgreSQL.
- Redis will be used only for queues, retries, idempotency leases, and temporary state.
See `docs/architecture.md` for ownership and migration decisions.
更多设计与边界见 `docs/architecture.md`。
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
const bootstrapAdminTTL = 10 * time.Minute
type bootstrapAdminRepository interface {
AdministratorCount(context.Context, string) (int, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
}
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer store.Close()
repository, err := postgres.NewSettingsRepository(store)
if err != nil {
return err
}
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
}
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
}
count, err := repository.AdministratorCount(ctx, tenant)
if err != nil {
return fmt.Errorf("check active administrators: %w", err)
}
if count > 0 {
return errors.New("an active administrator already exists; use administrator management instead")
}
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
if err != nil {
return fmt.Errorf("generate bootstrap code: %w", err)
}
code := fmt.Sprintf("%06d", codeNumber.Int64())
expiresAt := now().Add(bootstrapAdminTTL)
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
return fmt.Errorf("create bootstrap challenge: %w", err)
}
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
waitFor := expiresAt.Sub(now())
if waitFor <= 0 {
return errors.New("bootstrap binding expiry must be in the future")
}
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
defer cancel()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
err := repository.ConfirmBootstrap(waitCtx, tenant)
if err == nil {
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
return nil
}
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
return fmt.Errorf("confirm bootstrap candidate: %w", err)
}
select {
case <-waitCtx.Done():
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
case <-ticker.C:
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type bootstrapAdminRepoStub struct {
administratorCount int
createdCode string
createdExpiry time.Time
confirmCalls int
}
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
return r.administratorCount, nil
}
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
r.createdCode, r.createdExpiry = code, expiry
return nil
}
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
r.confirmCalls++
if r.confirmCalls == 1 {
return settings.ErrBootstrapCandidatePending
}
return nil
}
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
repo := &bootstrapAdminRepoStub{}
var output bytes.Buffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if repo.createdCode != "000000" {
t.Fatalf("code=%q", repo.createdCode)
}
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
t.Fatalf("expiry=%s", repo.createdExpiry)
}
if repo.confirmCalls < 2 {
t.Fatalf("confirm calls=%d", repo.confirmCalls)
}
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
if !strings.Contains(output.String(), required) {
t.Fatalf("output=%q missing %q", output.String(), required)
}
}
}
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
repo := &bootstrapAdminRepoStub{administratorCount: 1}
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("err=%v", err)
}
if repo.createdCode != "" || repo.confirmCalls != 0 {
t.Fatalf("repo mutated: %+v", repo)
}
}
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
repo := &bootstrapAdminRepoStub{}
original := createBootstrapChallenge
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
return errors.New("database unavailable")
}
t.Cleanup(func() { createBootstrapChallenge = original })
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("err=%v", err)
}
}
-81
View File
@@ -1,81 +0,0 @@
package main
import (
"context"
"errors"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateConfigRepository interface {
CandidateDingTalkConfig(context.Context, string) (string, string, error)
ActivateDingTalkCandidate(context.Context, string, string) error
}
type candidateCredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
var (
candidateTokenURL = ""
candidateTableBaseURL = ""
)
type dingTalkCandidateActivator struct {
tenant string
repo candidateConfigRepository
secrets *secretstore.Store
verifier candidateCredentialVerifier
bindings map[string]settings.ResourceBinding
restart func()
}
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
return errors.New("candidate_activation_unavailable")
}
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
if err != nil {
return errors.New("candidate_not_found")
}
secret, err := a.secrets.Get(secretRef)
if err != nil {
return errors.New("candidate_secret_unavailable")
}
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
return errors.New("candidate_oauth_failed")
}
for _, kind := range []string{"question_table", "faq_table"} {
binding, ok := a.bindings[kind]
if !ok {
continue
}
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
if err != nil {
return errors.New("candidate_table_config_failed")
}
fields, err := client.ReadFields(ctx)
if err != nil {
return errors.New("candidate_table_probe_failed")
}
required := questiontable.QuestionSchema()
if kind == "faq_table" {
required = questiontable.FAQSchema()
}
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
return errors.New("candidate_table_schema_failed")
}
}
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
return errors.New("candidate_database_activation_failed")
}
go func() {
time.Sleep(300 * time.Millisecond)
a.restart()
}()
return nil
}
-75
View File
@@ -1,75 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type candidateRepoStub struct {
activated bool
ref string
}
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "app", r.ref, nil
}
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
r.activated = true
return nil
}
type candidateVerifierStub struct{ ok bool }
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: v.ok}
}
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
if err := a.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected error")
}
if repo.activated {
t.Fatal("must not activate")
}
}
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
secrets, _ := secretstore.New(t.TempDir())
ref, _ := secrets.Put("dingtalk", "secret")
repo := &candidateRepoStub{ref: ref}
restarted := make(chan struct{}, 1)
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
if err := a.ActivateCandidate(context.Background()); err != nil {
t.Fatal(err)
}
if !repo.activated {
t.Fatal("expected activation")
}
select {
case <-restarted:
case <-time.After(time.Second):
t.Fatal("restart not requested")
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"log"
"os"
"strings"
)
// cardWideTableGroupingMode returns the configured handling of over-wide answer
// tables (see dingtalk.tableGroupingForMode):
//
// unset / on → grouped list on every frame (historical behaviour)
// off → real tables on every frame
// stream → grouped list while streaming, real table on the final frame
//
// The default stays "on" so an environment without the variable renders exactly
// as before; DINGTALK_CARD_WIDE_TABLE_GROUP selects the other modes.
func cardWideTableGroupingMode() string {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_WIDE_TABLE_GROUP")))
switch mode {
case "off", "0", "false", "no", "stream", "streaming", "partial":
log.Printf("DingTalk wide-table grouping mode=%s", mode)
return mode
default:
return ""
}
}
+41 -35
View File
@@ -12,7 +12,6 @@ import (
"syscall"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
@@ -22,6 +21,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
@@ -44,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo {
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
var restartRequested atomic.Bool
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
defer storeCancel()
@@ -63,10 +69,12 @@ func main() {
address = "127.0.0.1:8090"
}
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
return
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
}
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
// 0 = no client-side total timeout. WeKnora's smart-reasoning agent can run
// 20+ tool rounds and stream far longer than 120s; the total-time cap cut the
// SSE mid-answer and the failure branch overwrote the streamed card.
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 0)
if err != nil {
log.Fatal(err)
}
@@ -74,6 +82,26 @@ func main() {
if err != nil {
log.Fatal(err)
}
defaultMode := reasoningmode.ModeQuick
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
switch raw {
case "reasoning":
defaultMode = reasoningmode.ModeReasoning
case "quick":
defaultMode = reasoningmode.ModeQuick
default:
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
}
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
reasoningmode.WithDefaultMode(defaultMode),
)
if err != nil {
log.Fatal(err)
}
groupPolicy := session.GroupPolicy(strings.TrimSpace(os.Getenv("DOBOTAI_GROUP_SESSION_POLICY")))
if groupPolicy == "" {
groupPolicy = session.GroupShared
@@ -93,6 +121,9 @@ func main() {
qa.WithSucceededTurnObserver(coverageService, func(err error) {
log.Printf("coverage persistence failed: %v", err)
}),
qa.WithReasoningModeFinalizer(reasoningService, func(err error) {
log.Printf("reasoning mode completion failed: %T", err)
}),
)
if err != nil {
log.Fatal(err)
@@ -119,11 +150,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
verifier: settingsRuntime.verifier, bindings: activeBindings,
restart: func() { restartRequested.Store(true); cancel() },
})
runtimeReady := make(chan struct{})
faqCfg, err := readFAQConfig()
if err != nil {
@@ -284,6 +310,7 @@ func main() {
}
cardSender = cs
}
cardImageMedia := buildCardImageMedia(outboundImages.publicURLPrefix, dingCfg.ClientID, dingCfg.ClientSecret, address)
handler, err := dingtalk.NewHandlerWithOptions(
dingtalk.TenantConfig{TenantKey: dingCfg.TenantKey, Channel: "dingtalk"},
qaService,
@@ -294,8 +321,13 @@ func main() {
CardSender: cardSender,
Directory: directoryResolver,
OutboundRewriter: outboundImages.rewriter,
CardImageMedia: cardImageMedia,
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
LanguageResolver: settingsRuntime.repository,
OutboundRewriteTimeout: outboundImages.rewriteTimeout,
WideTableGrouping: cardWideTableGroupingMode(),
},
)
if err != nil {
@@ -316,37 +348,11 @@ func main() {
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
}
close(runtimeReady)
_, questionBound := activeBindings["question_table"]
_, faqBound := activeBindings["faq_table"]
_, supportBound := activeBindings["support_group"]
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
if weknoraHealthURL != "" {
weknoraHealthURL += "/health"
}
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
metrics: store,
tenant: dingCfg.TenantKey,
weknoraHealthURL: weknoraHealthURL,
httpClient: &http.Client{Timeout: 3 * time.Second},
now: time.Now,
version: runtimeBuildInfo().Version,
streamRunning: streamClient != nil,
questionRunning: questionTableDone != nil,
faqRunning: faqWorkflowDone != nil,
supportRunning: supportDispatcher != nil,
questionBound: questionBound,
faqBound: faqBound,
supportBound: supportBound,
ossConfigured: outboundOSSConfigured(),
})
rootHandler := http.NewServeMux()
rootHandler.Handle("/", api.Handler())
if outboundImages.handler != nil {
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
}
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
}
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI ingress listening on %s", address)
+66
View File
@@ -5,6 +5,7 @@ import (
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
)
@@ -183,3 +184,68 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
t.Fatal("complete stable image configuration should be active")
}
}
func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
if outboundImageRewriteTimeoutDefault != 180*time.Second {
t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "")
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("unset budget=%s", got)
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s")
if got := outboundImageRewriteTimeout(); got != 150*time.Second {
t.Fatalf("parsed budget=%s", got)
}
for _, invalid := range []string{"-1s", "180", "soon"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid)
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
t.Fatalf("invalid budget %q resolved to %s", invalid, got)
}
}
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "")
if !outboundImageCompressionEnabled() {
t.Fatal("compression must default to on")
}
for _, disabled := range []string{"off", "0", "false", "no"} {
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled)
if outboundImageCompressionEnabled() {
t.Fatalf("compression must be off for %q", disabled)
}
}
}
func TestCardWideTableGroupingMode(t *testing.T) {
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("unset must keep the historical mode, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "off")
if got := cardWideTableGroupingMode(); got != "off" {
t.Fatalf("off must be passed through, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", " STREAM ")
if got := cardWideTableGroupingMode(); got != "stream" {
t.Fatalf("stream must be normalised, got %q", got)
}
t.Setenv("DINGTALK_CARD_WIDE_TABLE_GROUP", "on")
if got := cardWideTableGroupingMode(); got != "" {
t.Fatalf("on is the default and must not be forwarded as a mode, got %q", got)
}
}
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, forbidden := range []string{"internal/adminweb", "adminweb.", `"/admin/"`, "runSetupOnly"} {
if strings.Contains(source, forbidden) {
t.Fatalf("main must not expose web administration: found %q", forbidden)
}
}
if !strings.Contains(source, "DingTalk credentials are required") {
t.Fatal("main must fail closed when DingTalk credentials are absent")
}
}
-164
View File
@@ -1,164 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
)
type adminComponent = adminweb.Component
type operationalMetrics = postgres.OperationalMetrics
type operationalMetricsSource interface {
Ping(context.Context) error
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
}
type operationalStatusProvider struct {
metrics operationalMetricsSource
tenant string
weknoraHealthURL string
httpClient *http.Client
now func() time.Time
version string
streamRunning bool
questionRunning bool
faqRunning bool
supportRunning bool
questionBound bool
faqBound bool
supportBound bool
ossConfigured bool
}
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
now := time.Now()
if p.now != nil {
now = p.now()
}
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
metrics := operationalMetrics{}
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
} else {
var err error
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
if err != nil {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
} else {
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
}
}
components = append(components,
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
p.weknoraComponent(ctx, now),
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
supportComponent(p, metrics, now),
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
)
return components, nil
}
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
if strings.TrimSpace(p.weknoraHealthURL) == "" {
component.Code = "health_url_missing"
component.Detail = "健康探针地址未配置"
return component
}
client := p.httpClient
if client == nil {
client = &http.Client{Timeout: 3 * time.Second}
}
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
if err != nil {
return component
}
resp, err := client.Do(req)
if err != nil {
return component
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
component.Code = "health_http_error"
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
return component
}
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
component.LastSuccess = now
return component
}
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
if failed > 0 {
component.State, component.Code = "error", "outbox_failed"
} else if pending > 0 {
component.State, component.Code = "warning", "outbox_pending"
}
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
return component
}
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
switch {
case !p.supportBound:
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
case !p.faqBound:
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
case metrics.SupportFailed > 0:
component.State, component.Code = "error", "notification_failed"
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
case metrics.SupportPending > 0:
component.State, component.Code = "warning", "notification_pending"
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
case p.supportRunning:
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
default:
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
}
return component
}
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
if !bound {
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
}
if !running {
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
}
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
}
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
if ok {
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
}
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
}
func safeVersion(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
if len(value) > 80 {
return value[:80]
}
return value
}
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer health.Close()
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
LastQuestionSuccess: checked.Add(-time.Minute),
}},
weknoraHealthURL: health.URL,
httpClient: health.Client(),
now: func() time.Time { return checked },
version: "settings-test",
streamRunning: true,
questionRunning: true,
faqRunning: false,
supportRunning: false,
questionBound: true,
faqBound: false,
supportBound: true,
ossConfigured: true,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "运行版本", "ok", "version_ok")
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
for _, component := range components {
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
if strings.Contains(component.Detail, forbidden) {
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
}
}
}
}
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
provider := &operationalStatusProvider{
metrics: operationalMetricsStub{value: operationalMetrics{}},
weknoraHealthURL: "http://127.0.0.1:1/health",
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
now: time.Now,
}
components, err := provider.Components(context.Background())
if err != nil {
t.Fatal(err)
}
byName := componentMap(components)
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
}
type operationalMetricsStub struct {
value operationalMetrics
err error
}
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
return s.value, s.err
}
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
func componentMap(values []adminComponent) map[string]adminComponent {
out := make(map[string]adminComponent, len(values))
for _, value := range values {
out[value.Name] = value
}
return out
}
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
t.Helper()
value, ok := values[name]
if !ok {
t.Fatalf("missing component %q: %+v", name, values)
}
if value.State != state || value.Code != code {
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
}
}
+115 -7
View File
@@ -2,7 +2,11 @@ package main
import (
"errors"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -11,12 +15,63 @@ import (
)
const (
outboundImageMaxCount = 5
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
outboundImageMaxCountDefault = 12
outboundImageMaxBytes = 10 << 20
outboundImagePrefix = "dobotai/im-images"
outboundImageRoute = "/dingtalk/i/"
// outboundImageRewriteTimeoutDefault is the budget for one answer's image
// rewrite. Manual screenshots are multi-megabyte uploads and the container's
// uplink is the real ceiling (measured ~0.6MB/s), so long answers need a
// budget with headroom: the historical 15s delivery timeout truncated them.
outboundImageRewriteTimeoutDefault = 180 * time.Second
)
// outboundImageMaxCount resolves how many images one answer may rewrite.
// DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT overrides the default; invalid values fall back.
func outboundImageMaxCount() int {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT"))
if raw == "" {
return outboundImageMaxCountDefault
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_MAX_COUNT=%q; using default %d", raw, outboundImageMaxCountDefault)
return outboundImageMaxCountDefault
}
return n
}
// outboundImageRewriteTimeout resolves the budget for the image rewrite stage.
// DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT accepts a Go duration ("90s", "2m");
// invalid or non-positive values fall back to the default.
func outboundImageRewriteTimeout() time.Duration {
raw := strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT"))
if raw == "" {
return outboundImageRewriteTimeoutDefault
}
duration, err := time.ParseDuration(raw)
if err != nil || duration <= 0 {
log.Printf("outbound image: invalid DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT=%q; using default %s", raw, outboundImageRewriteTimeoutDefault)
return outboundImageRewriteTimeoutDefault
}
return duration
}
// outboundImageCompressionEnabled reports whether published screenshots are
// re-encoded as smaller JPEGs before upload. It is on by default because the
// container uplink, not the rewrite code, caps how many images fit in the
// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original
// bytes exactly as fetched.
func outboundImageCompressionEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) {
case "off", "0", "false", "no":
log.Printf("outbound image: compression disabled by configuration")
return false
default:
return true
}
}
type outboundImageConfig struct {
WeKnoraBaseURL string
WeKnoraAPIKey string
@@ -32,6 +87,11 @@ type outboundImageRuntime struct {
rewriter dingtalk.OutboundContentRewriter
publisher *outboundimage.StableHashPublisher
handler http.Handler
// rewriteTimeout is the budget the channel gives the image rewrite stage.
rewriteTimeout time.Duration
// publicURLPrefix is the prefix of every published image URL, used by the
// DingTalk card image uploader to recognise (and refetch) our own images.
publicURLPrefix string
}
func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, error) {
@@ -81,9 +141,11 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
return outboundImageRuntime{}, err
}
return outboundImageRuntime{
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount),
publisher: publisher,
handler: handler,
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())),
publisher: publisher,
handler: handler,
rewriteTimeout: outboundImageRewriteTimeout(),
publicURLPrefix: strings.TrimRight(cfg.PublicBaseURL, "/") + "/",
}, nil
}
@@ -91,3 +153,49 @@ func buildOutboundImageRewriter(cfg outboundImageConfig) (dingtalk.OutboundConte
runtime, err := buildOutboundImageRuntime(cfg)
return runtime.rewriter, err
}
// buildCardImageMedia wires the DingTalk media-id rewriter used for AI card
// bodies. It returns nil when the feature is switched off or not configured, so
// a disabled uploader simply keeps external image URLs in place.
func buildCardImageMedia(publicImagePrefix, clientID, clientSecret, listenAddr string) dingtalk.CardImageMediaRewriter {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_MEDIA"))) {
case "off", "0", "false", "no":
log.Printf("DingTalk card image media ids disabled by configuration")
return nil
}
resolver, err := dingtalk.NewCardImageMediaRewriter(dingtalk.CardImageMediaOptions{
ClientID: clientID,
ClientSecret: clientSecret,
ImageURLPrefix: publicImagePrefix,
FetchURLPrefix: cardImageFetchPrefix(listenAddr),
})
if err != nil {
log.Printf("DingTalk card image media ids disabled: %v", err)
return nil
}
if resolver == nil {
return nil
}
log.Printf("DingTalk card images will be delivered through DingTalk media ids")
return resolver
}
// cardImageFetchPrefix derives the prefix used to download our own published
// images before uploading them to DingTalk. An explicit
// DINGTALK_CARD_IMAGE_FETCH_BASE_URL (host only, without the image route) wins;
// otherwise the local listen port is reused so the fetch stays on loopback.
func cardImageFetchPrefix(listenAddr string) string {
base := strings.TrimSpace(os.Getenv("DINGTALK_CARD_IMAGE_FETCH_BASE_URL"))
if base == "" {
addr := strings.TrimSpace(listenAddr)
if addr == "" {
return ""
}
_, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return ""
}
base = "http://127.0.0.1:" + port
}
return strings.TrimRight(base, "/") + outboundImageRoute
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"context"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type dingTalkReasoningRouter struct {
service *reasoningmode.Service
}
func (r *dingTalkReasoningRouter) Prepare(ctx context.Context, message dingtalk.ReasoningMessage) (dingtalk.ReasoningDecision, error) {
decision, err := r.service.Prepare(ctx, reasoningmode.Scope{
TenantKey: message.TenantKey, Channel: message.Channel, ExternalActorID: message.StaffID,
}, message.Text)
if err != nil {
return dingtalk.ReasoningDecision{}, err
}
return dingtalk.ReasoningDecision{
Handled: decision.Handled, Reply: decision.Reply, Query: decision.Query,
AgentID: decision.AgentID, Execution: decision.Execution, Expired: decision.Expired,
}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestMainWiresReasoningModeRoutingAndCompletion(t *testing.T) {
mainSource, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
wiringSource, err := os.ReadFile("reasoning_wiring.go")
if err != nil {
t.Fatal(err)
}
mainText := string(mainSource)
for _, required := range []string{
`os.Getenv("DOBOTAI_QUICK_AGENT_ID")`,
`os.Getenv("DOBOTAI_REASONING_AGENT_ID")`,
`reasoningmode.NewService`,
`qa.WithReasoningModeFinalizer`,
`ReasoningRouter:`,
} {
if !strings.Contains(mainText, required) {
t.Errorf("main reasoning wiring missing %q", required)
}
}
for _, required := range []string{
"dingTalkReasoningRouter",
"ReasoningMessage",
"service.Prepare",
} {
if !strings.Contains(string(wiringSource), required) {
t.Errorf("reasoning adapter missing %q", required)
}
}
}
+8 -20
View File
@@ -7,9 +7,7 @@ import (
"path/filepath"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
@@ -29,16 +27,12 @@ func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.Settings
}
type settingsRuntime struct {
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
admin *adminweb.ControlService
clientID string
secret string
secretRef string
secrets *secretstore.Store
verifier *dingtalkconfig.Verifier
pendingActivation bool
repository *postgres.SettingsRepository
bindings *validatedSettingsRepository
controller *settings.Controller
clientID string
secret string
secretRef string
}
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
@@ -62,8 +56,7 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
if err != nil {
return nil, err
}
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
if err != nil {
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
return nil, err
}
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
@@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
return nil, err
}
}
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
if err != nil {
return nil, err
}
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
}
-65
View File
@@ -1,65 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
if runtime == nil || runtime.admin == nil {
return nil, errors.New("setup runtime unavailable")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
})
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
return mux, nil
}
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
bindings: map[string]settings.ResourceBinding{},
restart: func() { restartRequested.Store(true); cancel() },
})
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
if err != nil {
log.Fatal(err)
}
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("DobotAI setup-only listener on %s", address)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("setup HTTP server failed: %v", err)
cancel()
}
}()
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("setup HTTP shutdown: %v", err)
}
if restartRequested.Load() {
os.Exit(75)
}
}
-102
View File
@@ -1,102 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type setupRepoStub struct{}
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "", "", false, nil
}
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "", "", nil
}
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
return nil
}
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
return nil
}
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
type setupVerifierStub struct{}
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{}
}
func setupControl(t *testing.T) *adminweb.ControlService {
t.Helper()
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
if err != nil {
t.Fatal(err)
}
return control
}
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
runtime := &settingsRuntime{admin: setupControl(t)}
h, err := newSetupOnlyHandler(runtime, "admin-token")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("health status=%d", resp.Code)
}
var health map[string]string
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
if health["status"] != "ok" || health["mode"] != "setup_required" {
t.Fatalf("health=%v", health)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
if resp.Code != http.StatusOK {
t.Fatalf("version status=%d", resp.Code)
}
resp = httptest.NewRecorder()
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
if resp.Code != http.StatusUnauthorized {
t.Fatalf("unauth status=%d", resp.Code)
}
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
resp = httptest.NewRecorder()
h.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
if err == nil {
t.Fatal("expected token error")
}
}
+118
View File
@@ -0,0 +1,118 @@
// 临时探针(不入库,用完即删):用真实 WeKnora + OSS 端点跑
// internal/outboundimage 的改写链路,量化"一条含 N 张真实图的答案"在
// 并发取图 + 预算可配下的出图结果与耗时。
package main
import (
"bufio"
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
func main() {
refsFile := flag.String("refs-file", "", "文件:每行一个 resource:// 句柄")
budget := flag.Duration("budget", 180*time.Second, "改写预算")
maxImages := flag.Int("max", 50, "图片上限")
compress := flag.Bool("compress", true, "上传前压缩截图")
passes := flag.Int("passes", 1, "重复改写轮数(第二轮应命中已存在对象)")
flag.Parse()
refs := readRefs(*refsFile)
if len(refs) == 0 {
log.Fatal("probe: no references")
}
fetcher, err := outboundimage.NewWeKnoraFetcher(
os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"),
&http.Client{Timeout: 30 * time.Second}, 10<<20)
if err != nil {
log.Fatalf("probe: fetcher: %v", err)
}
store, err := outboundimage.NewAliyunOSSStore(
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET"),
os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
if err != nil {
log.Fatalf("probe: store: %v", err)
}
publicBase := os.Getenv("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL")
publisher, err := outboundimage.NewStableHashPublisher(store, "dobotai/im-images", publicBase)
if err != nil {
log.Fatalf("probe: publisher: %v", err)
}
rewriter := outboundimage.NewRewriter(fetcher, publisher, *maxImages, outboundimage.WithImageCompression(*compress))
var answer strings.Builder
answer.WriteString("## 探针答案\n")
for i, ref := range refs {
fmt.Fprintf(&answer, "\n![图%d](%s)\n", i+1, ref)
}
log.Printf("probe: refs=%d budget=%s max=%d compress=%v passes=%d bucket=%s", len(refs), *budget, *maxImages, *compress, *passes, os.Getenv("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET"))
body := answer.String()
for pass := 1; pass <= *passes; pass++ {
ctx, cancel := context.WithTimeout(context.Background(), *budget)
started := time.Now()
out := rewriter.Rewrite(ctx, body)
elapsed := time.Since(started)
cancel()
published := regexp.MustCompile(`!\[图\d+\]\(https://`).FindAllString(out, -1)
leaked := strings.Count(out, "resource://")
fmt.Printf("RESULT pass=%d elapsed=%s refs=%d published=%d dropped=%d residual_resource_refs=%d out_bytes=%d\n",
pass, elapsed.Round(time.Millisecond), len(refs), len(published), len(refs)-len(published), leaked, len(out))
if len(published) > 0 {
fmt.Printf("FIRST_URL_%d=%s\nLAST_URL_%d=%s\n", pass, firstImageURL(out), pass, lastImageURL(out))
}
}
}
func readRefs(path string) []string {
if path == "" {
return nil
}
file, err := os.Open(path)
if err != nil {
log.Fatalf("probe: refs file: %v", err)
}
defer file.Close()
seen := map[string]bool{}
refs := make([]string, 0, 64)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
ref := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(ref, "resource://") || seen[ref] {
continue
}
seen[ref] = true
refs = append(refs, ref)
}
return refs
}
var imageURLRE = regexp.MustCompile(`!\[图\d+\]\((https://[^)]+)\)`)
func firstImageURL(content string) string {
if match := imageURLRE.FindStringSubmatch(content); len(match) > 1 {
return match[1]
}
return ""
}
func lastImageURL(content string) string {
if matches := imageURLRE.FindAllStringSubmatch(content, -1); len(matches) > 0 {
return matches[len(matches)-1][1]
}
return ""
}
@@ -0,0 +1,3 @@
-- 000019_reasoning_mode_state down migration.
DROP TABLE IF EXISTS reasoning_mode_turns;
DROP TABLE IF EXISTS reasoning_mode_states;
@@ -0,0 +1,43 @@
-- 000019_reasoning_mode_state: actor-scoped /think state and
-- per-turn idempotency for successful reasoning-mode completions.
CREATE TABLE IF NOT EXISTS reasoning_mode_states (
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
external_actor_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'quick'
CHECK (mode IN ('quick', 'reasoning')),
max_rounds INTEGER NOT NULL DEFAULT 10
CHECK (max_rounds BETWEEN 1 AND 50),
rounds_used INTEGER NOT NULL DEFAULT 0
CHECK (rounds_used >= 0 AND rounds_used <= max_rounds),
hint_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_activity_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
onboarding_shown_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_key, channel, external_actor_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_states_expiry
ON reasoning_mode_states (expires_at)
WHERE mode = 'reasoning' AND expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS reasoning_mode_turns (
id TEXT PRIMARY KEY,
tenant_key TEXT NOT NULL,
channel TEXT NOT NULL,
turn_id TEXT NOT NULL REFERENCES turns(id),
external_actor_id TEXT NOT NULL,
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('quick', 'reasoning', 'reasoning_single')),
round_number INTEGER NOT NULL DEFAULT 0 CHECK (round_number >= 0),
showed_hint BOOLEAN NOT NULL DEFAULT FALSE,
auto_closed BOOLEAN NOT NULL DEFAULT FALSE,
showed_onboarding BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_key, channel, turn_id)
);
CREATE INDEX IF NOT EXISTS idx_reasoning_mode_turns_actor_created
ON reasoning_mode_turns (tenant_key, channel, external_actor_id, created_at DESC);
@@ -0,0 +1,79 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp019CreatesActorScopedReasoningModeState(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_STATES",
"TENANT_KEY",
"CHANNEL",
"EXTERNAL_ACTOR_ID",
"MODE",
"MAX_ROUNDS",
"ROUNDS_USED",
"HINT_ENABLED",
"EXPIRES_AT",
"ONBOARDING_SHOWN_AT",
"PRIMARY KEY (TENANT_KEY, CHANNEL, EXTERNAL_ACTOR_ID)",
"MODE IN ('QUICK', 'REASONING')",
"MAX_ROUNDS BETWEEN 1 AND 50",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning state migration missing %q", required)
}
}
}
func TestUp019CreatesTurnIdempotencyLedger(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"REASONING_MODE_TURNS",
"TURN_ID",
"EXTERNAL_ACTOR_ID",
"EXECUTION_MODE",
"ROUND_NUMBER",
"SHOWED_HINT",
"AUTO_CLOSED",
"SHOWED_ONBOARDING",
"UNIQUE (TENANT_KEY, CHANNEL, TURN_ID)",
"REFERENCES TURNS(ID)",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning turn ledger migration missing %q", required)
}
}
}
func TestDown019RemovesOnlyReasoningModeObjects(t *testing.T) {
data, err := os.ReadFile("000019_reasoning_mode_state.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP TABLE IF EXISTS REASONING_MODE_TURNS",
"DROP TABLE IF EXISTS REASONING_MODE_STATES",
} {
if !strings.Contains(source, required) {
t.Errorf("reasoning down migration missing %q", required)
}
}
for _, forbidden := range []string{"DROP TABLE TURNS", "DROP TABLE USER_PREFERENCES"} {
if strings.Contains(source, forbidden) {
t.Errorf("reasoning down migration must not contain %q", forbidden)
}
}
}
@@ -0,0 +1,5 @@
-- 000020_feedback_support_trigger down migration.
DROP INDEX IF EXISTS idx_support_cases_trigger_type_created;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS trigger_feedback_id,
DROP COLUMN IF EXISTS trigger_type;
@@ -0,0 +1,11 @@
-- 000020_feedback_support_trigger: let an existing support-case workflow be
-- triggered by a downvote while preserving one case per successful Turn.
ALTER TABLE support_cases
ALTER COLUMN coverage_decision_id DROP NOT NULL,
ADD COLUMN IF NOT EXISTS trigger_type TEXT NOT NULL DEFAULT 'coverage_uncovered'
CHECK (trigger_type IN ('coverage_uncovered', 'feedback_down')),
ADD COLUMN IF NOT EXISTS trigger_feedback_id TEXT REFERENCES answer_feedback(id);
CREATE INDEX IF NOT EXISTS idx_support_cases_trigger_type_created
ON support_cases (trigger_type, created_at DESC);
@@ -0,0 +1,50 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp020AddsFeedbackSupportTriggerAndMakesCoverageDecisionOptional(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE SUPPORT_CASES",
"TRIGGER_TYPE",
"COVERAGE_UNCOVERED",
"FEEDBACK_DOWN",
"TRIGGER_FEEDBACK_ID",
"REFERENCES ANSWER_FEEDBACK(ID)",
"ALTER COLUMN COVERAGE_DECISION_ID DROP NOT NULL",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support migration missing %q", required)
}
}
if strings.Contains(source, "DROP CONSTRAINT SUPPORT_CASES_COVERAGE_DECISION_ID_FKEY") {
t.Fatal("feedback support must retain the coverage decision foreign-key contract")
}
}
func TestDown020RemovesOnlyFeedbackSupportColumns(t *testing.T) {
data, err := os.ReadFile("000020_feedback_support_trigger.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"DROP COLUMN IF EXISTS TRIGGER_FEEDBACK_ID",
"DROP COLUMN IF EXISTS TRIGGER_TYPE",
} {
if !strings.Contains(source, required) {
t.Errorf("feedback support down migration missing %q", required)
}
}
if strings.Contains(source, "DROP TABLE") {
t.Fatal("feedback support down migration must not drop shared tables")
}
}
@@ -0,0 +1,18 @@
-- 000021_coverage_non_knowledge_response_reason down migration.
-- Map the v4 covered reason to the pre-v4 covered code before restoring the
-- legacy allowlist, so rollback remains valid with persisted v4 decisions.
UPDATE coverage_decisions
SET reason_code = 'covered_has_references'
WHERE reason_code = 'covered_non_knowledge_response';
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition'
));
@@ -0,0 +1,14 @@
-- 000021_coverage_non_knowledge_response_reason: distinguish text turns that
-- did not invoke knowledge search from actual retrieval misses.
ALTER TABLE coverage_decisions
DROP CONSTRAINT IF EXISTS coverage_decisions_reason_code_check;
ALTER TABLE coverage_decisions
ADD CONSTRAINT coverage_decisions_reason_code_check
CHECK (reason_code IN (
'covered_has_references',
'uncovered_no_references',
'uncovered_explicit_no_answer',
'covered_visual_recognition',
'covered_non_knowledge_response'
));
@@ -0,0 +1,44 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp021AddsCoveredNonKnowledgeResponseReason(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_VISUAL_RECOGNITION",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown021MapsNewCoveredReasonBeforeRestoringLegacyConstraint(t *testing.T) {
data, err := os.ReadFile("000021_coverage_non_knowledge_response_reason.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"UPDATE COVERAGE_DECISIONS",
"COVERED_NON_KNOWLEDGE_RESPONSE",
"COVERED_HAS_REFERENCES",
"ALTER TABLE COVERAGE_DECISIONS",
"DROP CONSTRAINT IF EXISTS COVERAGE_DECISIONS_REASON_CODE_CHECK",
} {
if !strings.Contains(source, required) {
t.Errorf("down migration missing %q", required)
}
}
}
@@ -0,0 +1,2 @@
ALTER TABLE reasoning_mode_states
DROP COLUMN user_selected;
@@ -0,0 +1,6 @@
-- 000022_reasoning_default_mode: support an operator-configurable default
-- mode for the /think reasoning router. The new column records whether a
-- user has explicitly chosen a mode (/think on|off); users who never have
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
ALTER TABLE reasoning_mode_states
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,34 @@
package migrations_test
import (
"os"
"strings"
"testing"
)
func TestUp022AddsReasoningUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.up.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
for _, required := range []string{
"ALTER TABLE REASONING_MODE_STATES",
"ADD COLUMN USER_SELECTED BOOLEAN NOT NULL DEFAULT FALSE",
} {
if !strings.Contains(source, required) {
t.Errorf("up migration missing %q", required)
}
}
}
func TestDown022DropsUserSelectedColumn(t *testing.T) {
data, err := os.ReadFile("000022_reasoning_default_mode.down.sql")
if err != nil {
t.Fatal(err)
}
source := strings.ToUpper(string(data))
if !strings.Contains(source, "DROP COLUMN USER_SELECTED") {
t.Errorf("down migration missing %q", "DROP COLUMN USER_SELECTED")
}
}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""One-shot DingTalk AI card probe (test bot, private IM_ROBOT space).
Sends two cards to the owner's private robot chat so PC width can be compared:
A: body carries sys_full_json_obj = {"config":{"autoLayout":true}}
B: control, same body without the layout field
Card body mirrors internal/channels/dingtalk/card.go buildCardBody() exactly.
Credentials are read from the running container's environment and are never
printed. Only masked ids, HTTP statuses and DingTalk response bodies are shown.
"""
import json
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
CONTAINER = "dobotai-dingtalk"
TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
CREATE_URL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
STREAM_URL = "https://api.dingtalk.com/v1.0/card/streaming"
OWNER_ID = "243143382023108067" # 姚章浩 (channel_actors.dingtalk, test tenant)
WIDE = json.dumps({"config": {"autoLayout": True}}, separators=(",", ":"))
CONTENT = (
"**{tag}**\n\n"
"这是一条 PC 宽度对照测试卡片,由测试机器人在私聊里直接推送。\n\n"
"- 投放体里的布局字段:{field}\n"
"- 期望:{expect}\n"
"- 请在 PC 端对比两张卡片的宽度差异\n\n"
"移动端请忽略,宽屏只影响桌面端渲染。"
)
def env(key: str) -> str:
out = subprocess.run(
["docker", "exec", CONTAINER, "printenv", key],
capture_output=True, text=True, check=True,
).stdout.strip()
if not out:
sys.exit(f"missing env {key}")
return out
def call(method: str, url: str, payload: dict, token: str = "") -> tuple[int, str]:
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("x-acs-dingtalk-access-token", token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read(4096).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read(4096).decode("utf-8", "replace")
def mask(v: str) -> str:
return v[:6] + "..." + v[-4:] if len(v) > 12 else "***"
def main() -> None:
app_key, app_secret = env("DINGTALK_CLIENT_ID"), env("DINGTALK_CLIENT_SECRET")
tpl = env("DINGTALK_CARD_TEMPLATE_ID")
print(f"app_key={mask(app_key)} template={mask(tpl)} target={mask(OWNER_ID)}")
status, body = call("POST", TOKEN_URL, {"appKey": app_key, "appSecret": app_secret})
token = json.loads(body).get("accessToken", "")
print(f"token: http={status} got={'yes' if token else 'NO'}")
if not token:
sys.exit("no token")
for tag, with_wide in (("A · 已带 autoLayout=true", True), ("B · 对照组(无布局字段)", False)):
param_map = {
"content": "",
"feedbackSelected": "",
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
}
if with_wide:
param_map["sys_full_json_obj"] = WIDE
out_track_id = str(uuid.uuid4())
card_body = {
"cardTemplateId": tpl,
"outTrackId": out_track_id,
"callbackType": "STREAM",
"cardData": {"cardParamMap": param_map},
"userIdType": 1,
"openSpaceId": "dtv1.card//IM_ROBOT." + OWNER_ID,
"imRobotOpenSpaceModel": {"supportForward": True},
"imRobotOpenDeliverModel": {
"robotCode": app_key,
"spaceType": "IM_ROBOT",
"extension": {},
},
}
st, rb = call("POST", CREATE_URL, card_body, token)
print(f"[{tag}] create http={st} resp={rb[:300]}")
text = CONTENT.format(
tag=tag,
field=("sys_full_json_obj = " + WIDE) if with_wide else "(无)",
expect="PC 端铺满窗口(宽屏)" if with_wide else "PC 端窄卡片(默认宽度)",
)
stream_body = {
"outTrackId": out_track_id,
"guid": str(uuid.uuid4()),
"key": "content",
"content": text,
"isFull": True,
"isFinalize": True,
"isError": False,
}
st2, rb2 = call("PUT", STREAM_URL, stream_body, token)
print(f"[{tag}] finalize http={st2} resp={rb2[:200]} outTrackId={mask(out_track_id)}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
# DobotAI Docker部署(阿里云RDS)
## 定位
DobotAI是钉钉渠道编排与交付服务。Docker只运行应用容器`dobotai-dingtalk`;数据库唯一使用已有的阿里云RDS PostgreSQL。
```text
钉钉 Stream
↓
dobotai-dingtalk 容器
├─ 阿里云RDS PostgreSQL:Turn、会话、反馈、管理员、覆盖判定
└─ 既有WeKnora:Agent、检索、知识、模型
```
禁止创建或使用:
```text
dobotai-postgres 容器
dobotai-postgres-data 数据卷
本地PostgreSQL作为DobotAI目标数据库
```
## 架构与目录
```text
宿主机
├── dobotai-dingtalk 唯一应用容器,host网络
├── /opt/dobotai/container/ Compose、RDS检查器、SQL迁移、启动脚本
├── /opt/dobotai/config/dobotai.env 0600,RDS/WeKnora/钉钉配置
├── /opt/dobotai/secrets/ 0700,DobotAI SecretStore
└── /opt/dobotai/backups/ 发布前RDS备份记录,不保存Docker数据库卷
阿里云RDS
└── DobotAI专用PostgreSQL数据库
```
应用使用`network_mode: host`,因此通过RDS私网地址访问云数据库、通过`127.0.0.1:8080`访问宿主机现有WeKnora。不会开放新的数据库端口;应用HTTP仍由`DOBOTAI_LISTEN_ADDR=127.0.0.1:8090`约束。
## 安装
克隆完整仓库后,只执行:
```bash
sudo bash ./install-docker.sh
```
根脚本自动:
```text
检查或安装Docker Engine和Docker Compose v2(APT系统)
以当前Git提交构建 dobotai-dingtalk:local-<Git短SHA>
创建 /opt/dobotai/{container,config,secrets,backups}
复制应用Compose、RDS检查器和21个SQL迁移
生成权限0600的应用环境模板
安装三条短命令
```
它不会连接、创建或修改RDS,也不会启动未配置的钉钉应用。
## 配置、RDS检查与启动
安装后执行:
```bash
sudo dobotai-dingtalk-configure
```
配置器分字段录入阿里云RDS(主机、端口、数据库名、账号、密码),自动组装连接串;WeKnora和钉钉凭证密钥不回显,环境文件原子写入且权限0600。
`DOBOTAI_TENANT_KEY`不是WeKnora Agent租户键。它是DobotAI内部数据隔离键,首次安装时自动生成并固定保存;配置器不会要求填写或改动它。WeKnora侧只需要填写实际的两个Agent ID。
配置器会要求两个不同的WeKnora Agent ID,并自动生成`DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"快速问答Agent ID"}`。`DOBOTAI_INGRESS_TOKEN`是保护本机`POST /v1/qa`内部接口的Bearer Token,不用于钉钉或WeKnora;同样由安装器自动生成。
| 配置 | 示例 | 作用 |
| --- | --- | --- |
| `DATABASE_URL` | `postgres://USER:***@rds-endpoint:5432/dobotai?sslmode=require` | DobotAI专用阿里云RDS PostgreSQL |
| `WEKNORA_BASE_URL` | `http://127.0.0.1:8080` | 宿主机既有WeKnora |
| `WEKNORA_API_KEY` | `<你的WeKnora API Key>` | DobotAI专用Key |
| `DOBOTAI_QUICK_AGENT_ID` | `<快速问答Agent ID>` | 默认快速问答实际调用的Agent |
| `DOBOTAI_REASONING_AGENT_ID` | `<智能推理Agent ID>` | `/think`和持续智能推理实际调用的Agent |
| `DINGTALK_CLIENT_ID` | `dingxxxxxxxx` | 钉钉AppKey |
| `DINGTALK_CLIENT_SECRET` | `<你的钉钉 AppSecret>` | 钉钉AppSecret |
回答图片外发OSS同为分字段录入的可选整组(紧随RDS之后、WeKnora/钉钉凭证之前):
| 配置 | 示例 | 作用 |
| --- | --- | --- |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT` | `https://oss-cn-shenzhen.aliyuncs.com` | 私有Bucket接入地址 |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_REGION` | `cn-shenzhen` | Bucket所在地域 |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET` | `dobot-xxx` | 外发图片专用私有Bucket |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID` | `<AK ID>` | 仅授权该Bucket的AK |
| `DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET` | `<AK Secret>` | 仅授权该Bucket的SK(不回显) |
| `DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL` | `https://host/dingtalk/i` | 钉钉可见的稳定HTTPS入口,不含凭据 |
OSS规则:六项全部留空即整组禁用图片外发(答案含`resource://`图片时按无图降级);填写任一项则六项必填,缺项拒绝保存。运行时仅改写WeKnora `resource://`引用为短签HTTPS,私有Bucket由稳定基础地址代理,`<img>`外链与`data:`剥除。禁止把OSS凭据写入Git或命令行。
保存后脚本自动执行以下RDS门禁:
```text
1. 使用RDS连接串执行 pg_isready
2. 检查DobotAI核心表
3. 数据库没有任何public表:按000001~000021初始化
4. 所有核心表存在:判定正常,不执行任何DDL
5. 部分表、缺表或非空异常库:拒绝初始化和启动
6. RDS通过后才启动 dobotai-dingtalk 并检查 /healthz
```
这保证不会把已有RDS表误当成空库重建,也不会把半成品或错误库静默升级。
## 日常命令
```bash
# 重新检查RDS并启动/升级应用
sudo dobotai-dingtalk-start
# 交互更新RDS、WeKnora、钉钉配置后启动
sudo dobotai-dingtalk-configure
# 仅首位管理员且无有效管理员时使用
sudo dobotai-dingtalk-bind-admin
# 健康与版本
curl --noproxy '*' -fsS http://127.0.0.1:8090/healthz
curl --noproxy '*' -fsS http://127.0.0.1:8090/version
```
## 升级与回滚
应用升级仅替换`dobotai-dingtalk`镜像。启动前再次检查RDS;健康失败时恢复上一应用镜像(如存在)。RDS不是容器资产,不能被`docker compose down`、镜像替换或本机卷操作影响。
RDS有实际数据时,数据库备份、SQL变更与恢复应在阿里云RDS发布流程中完成并验证;不要把应用镜像回滚误当成数据库回滚。
## 首位管理员
应用和钉钉Stream运行后:
```bash
sudo dobotai-dingtalk-bind-admin
```
终端会显示10分钟有效的六码;候选管理员私聊机器人:
```text
/setting <六码绑定码>
```
已有有效管理员时绑定命令会拒绝,避免提权。
## 不执行的操作
```text
不创建本地PostgreSQL容器或数据卷
不迁移、删除或覆盖现有本地 dobotai-test-postgres
不创建、删除或重置阿里云RDS数据库
不把RDS连接串、Token、钉钉凭据写入Git、命令行或日志
不重建WeKnora、Neo4j、Redis、Elasticsearch
```
+22
View File
@@ -0,0 +1,22 @@
services:
dobotai-dingtalk:
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
container_name: dobotai-dingtalk
network_mode: host
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
env_file:
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
environment:
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
volumes:
- type: bind
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
target: /var/lib/dobotai/secrets
read_only: false
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
security_opt:
- no-new-privileges:true
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
import argparse
import getpass
import json
import os
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit
import subprocess
import sys
FIELDS = (
("WEKNORA_BASE_URL", "WeKnora 地址", "应用容器通过宿主机网络访问既有WeKnora。", "http://127.0.0.1:8080", False),
("WEKNORA_API_KEY", "WeKnora API Key", "DobotAI专用WeKnora API Key。", "<你的WeKnora API Key>", True),
("DOBOTAI_QUICK_AGENT_ID", "快速问答Agent ID", "默认问答实际调用的WeKnora Agent。", "<快速问答Agent ID>", False),
("DOBOTAI_REASONING_AGENT_ID", "智能推理Agent ID", "/think和持续智能推理实际调用的WeKnora Agent。", "<智能推理Agent ID>", False),
("DINGTALK_CLIENT_ID", "钉钉 Client ID", "钉钉应用Client ID/AppKey。", "dingxxxxxxxx", False),
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", "钉钉应用Client Secret/AppSecret。", "<你的钉钉 AppSecret>", True),
)
def prompt_rds(values):
"""分字段录入阿里云RDS并组装DATABASE_URL;已有值作为默认。"""
existing = values.get("DATABASE_URL", "").strip()
host = port = database = user = password = ""
if existing:
u = urlsplit(existing)
host = u.hostname or ""
port = str(u.port or "") if u.port else ""
database = u.path.lstrip("/")
user = unquote(u.username or "")
password = unquote(u.password or "")
def ask(key, label, default, secret=False):
suffix = "(已配置,直接回车保留)" if default else "(必填)"
reader = getpass.getpass if secret else input
entered = reader(f"{label}{suffix}: ").strip()
return entered or default
print("\n阿里云RDS PostgreSQL(分字段填写)\n用途:DobotAI专用数据库;空库自动初始化,已有表只校验不改结构。")
host = ask("host", "RDS主机地址", host)
port = ask("port", "RDS端口", port or "5432")
database = ask("database", "数据库名", database)
user = ask("user", "数据库账号", user)
password = ask("password", "数据库密码", password, secret=True)
if not all((host, port, database, user, password)):
raise SystemExit("RDS字段不完整,未保存。")
if not database.startswith(("dobotai", "dobotai_")):
print("警告:数据库名不是DobotAI专用库,继续前请确认。")
values["DATABASE_URL"] = f"postgres://{quote(user, safe='')}:{quote(password, safe='')}@{host}:{port}/{database}"
OSS_FIELDS = (
("DOBOTAI_OUTBOUND_IMAGE_OSS_ENDPOINT", "OSS Endpoint", "私有Bucket的接入地址。", "https://oss-cn-shenzhen.aliyuncs.com", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_REGION", "OSS Region", "Bucket所在地域。", "cn-shenzhen", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_BUCKET", "OSS Bucket", "外发图片专用私有Bucket。", "dobot-xxx", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_ID", "OSS AccessKey ID", "仅授权该Bucket的AK。", "<AK ID>", False),
("DOBOTAI_OUTBOUND_IMAGE_OSS_ACCESS_KEY_SECRET", "OSS AccessKey Secret", "仅授权该Bucket的SK。", "<AK Secret>", True),
("DOBOTAI_OUTBOUND_IMAGE_PUBLIC_BASE_URL", "图片稳定外发基础地址", "钉钉可见的稳定HTTPS入口,不含凭据。", "https://host/dingtalk/i", False),
)
def prompt_oss(values):
"""OSS整组录入:全部留空即禁用;任一项填写则六项必填。"""
print("\n回答图片外发OSS(可选,OSS整组录入;直接回车表示整组禁用)")
entered = {}
for key, label, description, example, secret in OSS_FIELDS:
default = values.get(key, "").strip()
suffix = "(已配置,直接回车保留)" if default else ""
print(f"用途:{description} 格式示例:{example}")
reader = getpass.getpass if secret else input
value = reader(f"{label}{suffix}: ").strip()
entered[key] = value or default
if not any(entered.values()):
for key, *_ in OSS_FIELDS:
values.pop(key, None)
return
missing = [label for key, label, *_ in OSS_FIELDS if not entered[key]]
if missing:
raise SystemExit("OSS为整组可选能力,以下项未填写:" + "、".join(missing))
values.update(entered)
def parse_env(path):
lines = path.read_text(encoding="utf-8").splitlines(); values = {}
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key, value = line.split("=", 1); values[key.strip()] = value
return lines, values
def write_env(path, lines, values):
rendered=[]; written=set()
for line in lines:
if line and not line.lstrip().startswith("#") and "=" in line:
key=line.split("=",1)[0].strip()
if key in values: rendered.append(f"{key}={values[key]}"); written.add(key); continue
rendered.append(line)
rendered.extend(f"{key}={value}" for key,value in values.items() if key not in written)
temp=path.with_name(path.name+".new"); temp.write_text("\n".join(rendered)+"\n",encoding="utf-8"); os.chmod(temp,0o600); os.replace(temp,path)
def main():
parser=argparse.ArgumentParser(description="配置DobotAI阿里云RDS和渠道凭证")
parser.add_argument("--env-file",required=True); parser.add_argument("--schema-script",required=True); parser.add_argument("--start-script",required=True)
args=parser.parse_args(); path=Path(args.env_file)
if not path.is_file(): raise SystemExit("DobotAI环境文件不存在,请先执行根目录install-docker.sh。")
lines,values=parse_env(path)
prompt_rds(values)
prompt_oss(values)
for key,label,description,example,secret in FIELDS:
suffix="(已配置,直接回车保留)" if values.get(key,"").strip() else "(必填)"
print(f"\n{label}\n用途:{description}\n格式示例:{example}")
entered=(getpass.getpass if secret else input)(f"{label}{suffix}: ").strip()
if entered: values[key]=entered
missing=[label for key,label,*_ in FIELDS if not values.get(key,"").strip()]
if missing: raise SystemExit("以下必填项未配置:"+"、".join(missing))
if values["DOBOTAI_QUICK_AGENT_ID"] == values["DOBOTAI_REASONING_AGENT_ID"]:
raise SystemExit("快速问答Agent ID与智能推理Agent ID必须不同。")
values["DOBOTAI_AGENT_BINDINGS"] = json.dumps({"dingtalk:*": values["DOBOTAI_QUICK_AGENT_ID"]}, separators=(",", ":"))
write_env(path,lines,values)
subprocess.run([args.schema_script], check=True)
os.execv(args.start_script,[args.start_script])
if __name__=="__main__":
try: main()
except KeyboardInterrupt: sys.exit(130)
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
if [[ "$(docker inspect --format '{{.State.Running}}' dobotai-dingtalk 2>/dev/null || true)" != "true" ]]; then echo "DobotAI容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2; exit 1; fi
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 /opt/dobotai-test/container/configure_env.py \
--env-file /etc/dobotai-test/dobotai.env \
--start-script /usr/local/sbin/dobotai-dingtalk-start
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
compose_file="$container_dir/compose.yaml"
runtime_env="$container_dir/runtime.env"
health_url="http://127.0.0.1:8090/healthz"
[[ -r "$compose_file" && -r "$runtime_env" ]] || { echo "容器资产未安装。" >&2; exit 1; }
"$container_dir/rds-schema-init.sh"
compose=(docker compose --env-file "$runtime_env" -f "$compose_file")
previous_image="$(docker inspect -f '{{.Config.Image}}' dobotai-dingtalk 2>/dev/null || true)"
rollback() {
"${compose[@]}" rm -sf dobotai-dingtalk >/dev/null 2>&1 || true
if [[ -n "$previous_image" ]]; then "${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk >/dev/null 2>&1 || true; fi
}
trap rollback ERR
"${compose[@]}" up -d --no-build --force-recreate dobotai-dingtalk
for _ in $(seq 1 30); do
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then trap - ERR; echo "DobotAI容器已启动:dobotai-dingtalk"; exit 0; fi
sleep 1
done
echo "DobotAI健康检查失败,已恢复上一应用容器(如存在)。" >&2
exit 1
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_root="${DOBOTAI_SOURCE_ROOT:-$(cd "$script_dir/../.." && pwd)}"
home="${DOBOTAI_HOME:-/opt/dobotai}"
container_dir="$home/container"
config_dir="$home/config"
secret_dir="$home/secrets"
backup_dir="$home/backups"
app_env="$config_dir/dobotai.env"
runtime_env="$container_dir/runtime.env"
image="${DOBOTAI_IMAGE:?root install-docker.sh must provide the local image}"
bin_dir="${DOBOTAI_BIN_DIR:-/usr/local/sbin}"
[[ ${EUID} -eq 0 ]] || { echo "请使用sudo执行安装脚本。" >&2; exit 1; }
docker image inspect "$image" >/dev/null 2>&1 || { echo "应用镜像不存在。" >&2; exit 1; }
install -d -m 0750 "$home" "$container_dir" "$config_dir" "$backup_dir" "$container_dir/migrations"
install -d -m 0700 -o 10001 -g 10001 "$secret_dir"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
install -m 0750 "$script_dir/rds-schema-init.sh" "$container_dir/rds-schema-init.sh"
install -m 0750 "$script_dir/dobotai-dingtalk-start" "$container_dir/dobotai-dingtalk-start"
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" "$container_dir/dobotai-dingtalk-bind-admin"
install -m 0644 "$source_root"/db/migrations/*.up.sql "$container_dir/migrations/"
if [[ ! -f "$app_env" ]]; then
umask 077
read -r tenant_suffix ingress_token < <(python3 -c 'import secrets; print(secrets.token_hex(12), secrets.token_urlsafe(32))')
cat > "$app_env" <<'EOF'
DOBOTAI_LISTEN_ADDR=127.0.0.1:8090
DATABASE_URL=
WEKNORA_BASE_URL=http://127.0.0.1:8080
WEKNORA_API_KEY=
DOBOTAI_ADMIN_TOKEN=
DOBOTAI_AGENT_BINDINGS=
DOBOTAI_QUICK_AGENT_ID=
DOBOTAI_REASONING_AGENT_ID=
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
EOF
printf 'DOBOTAI_TENANT_KEY=dobotai-%s\n' "$tenant_suffix" >> "$app_env"
printf 'DOBOTAI_INGRESS_TOKEN=%s\n' "$ingress_token" >> "$app_env"
chmod 0600 "$app_env"
fi
umask 077
cat > "$runtime_env" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_RUNTIME_UID=10001
DOBOTAI_RUNTIME_GID=10001
DOBOTAI_ENV_FILE=$app_env
DOBOTAI_SECRET_DIR_HOST=$secret_dir
EOF
chmod 0600 "$runtime_env"
install -d -m 0755 "$bin_dir"
for command in configure start bind-admin; do
target="$bin_dir/dobotai-dingtalk-$command"
cat > "$target" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec '$container_dir/dobotai-dingtalk-$command'
EOF
chmod 0750 "$target"
done
# configure is a Python entrypoint rather than a shell asset.
cat > "$bin_dir/dobotai-dingtalk-configure" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export DOBOTAI_HOME='$home'
exec python3 '$container_dir/configure_env.py' --env-file '$app_env' --schema-script '$container_dir/rds-schema-init.sh' --start-script '$bin_dir/dobotai-dingtalk-start'
EOF
chmod 0750 "$bin_dir/dobotai-dingtalk-configure"
echo "应用容器资产已安装;尚未连接或修改阿里云RDS。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
if [[ ${EUID} -ne 0 ]]; then
echo "请使用 sudo 执行安装脚本。" >&2
exit 1
fi
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "候选镜像不存在:$image" >&2
exit 1
fi
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
exit 1
fi
uid="$(id -u "$runtime_user")"
gid="$(id -g "$runtime_user")"
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
backup_dir="$backup_root/pre-container-$stamp"
install -d -m 0700 "$backup_dir" "$container_dir"
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
runtime_tmp="$container_dir/runtime.env.new"
umask 077
cat > "$runtime_tmp" <<EOF
DOBOTAI_IMAGE=$image
DOBOTAI_RUNTIME_UID=$uid
DOBOTAI_RUNTIME_GID=$gid
DOBOTAI_ENV_FILE=$env_file
DOBOTAI_SECRET_DIR_HOST=$secret_dir
EOF
chmod 0600 "$runtime_tmp"
mv -f "$runtime_tmp" "$container_dir/runtime.env"
echo "容器资产已安装,旧服务仍在运行。"
echo "下一步执行:sudo dobotai-dingtalk-configure"
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
home="${DOBOTAI_HOME:-/opt/dobotai}"
app_env="$home/config/dobotai.env"
migrations_dir="$home/container/migrations"
[[ -r "$app_env" && -d "$migrations_dir" ]] || { echo "RDS检查资产未安装。" >&2; exit 1; }
temp_env="$(mktemp)"; trap 'rm -f "$temp_env"' EXIT
python3 - "$app_env" "$temp_env" <<'PY'
from pathlib import Path
import os,sys
values={}
for line in Path(sys.argv[1]).read_text(encoding='utf-8').splitlines():
if line and not line.startswith('#') and '=' in line:
key,value=line.split('=',1); values[key]=value
url=values.get('DATABASE_URL','').strip()
if not (url.startswith('postgres://') or url.startswith('postgresql://')): raise SystemExit('DATABASE_URL必须是阿里云RDS PostgreSQL连接串。')
Path(sys.argv[2]).write_text('DATABASE_URL='+url+'\n',encoding='utf-8'); os.chmod(sys.argv[2],0o600)
PY
rds_shell() { docker run --rm -i --network host --env-file "$temp_env" postgres:16-alpine sh -ec "$1"; }
rds_shell 'pg_isready -d "$DATABASE_URL" >/dev/null' || true
if ! probe_error="$(rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL" -c "SELECT 1"' 2>&1)"; then
case "$probe_error" in
*"does not exist"*|*"不存在"*)
echo "阿里云RDS目标数据库不存在;请RDS管理员创建DobotAI专用数据库并授予应用账号连接和建表权限。" >&2
;;
*)
echo "无法连接阿里云RDS目标数据库;请管理员检查网络、白名单、TLS、账号权限和连接串。" >&2
;;
esac
exit 1
fi
sql() { rds_shell 'exec psql -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
sql_at() { rds_shell 'exec psql -At -v ON_ERROR_STOP=1 "$DATABASE_URL"'; }
state=$(printf "%s\n" "WITH expected(name) AS (VALUES ('turns'),('session_mappings'),('messages'),('coverage_decisions'),('support_cases'),('system_config_revisions'),('administrators'),('setting_sessions'),('user_preferences'),('reasoning_mode_states'),('reasoning_mode_turns')) SELECT CASE WHEN to_regclass('public.turns') IS NULL AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public')=0 THEN 'empty' WHEN (SELECT COUNT(*) FROM expected e JOIN information_schema.tables t ON t.table_schema='public' AND t.table_name=e.name)=11 THEN 'normal' ELSE 'inconsistent' END;" | sql_at)
case "$state" in
normal) echo "阿里云RDS表结构检查通过;不执行初始化。"; exit 0;;
inconsistent) echo "阿里云RDS表结构不完整或不是空库,拒绝初始化和启动。" >&2; exit 1;;
empty) echo "阿里云RDS为空,开始初始化DobotAI表。";;
*) echo "无法判定阿里云RDS表状态。" >&2; exit 1;;
esac
for migration in "$migrations_dir"/*.up.sql; do printf 'BEGIN;\n'; cat "$migration"; printf '\nCOMMIT;\n'; done | sql
echo "阿里云RDS初始化完成。"
@@ -0,0 +1,6 @@
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
DOBOTAI_IMAGE=dobotai-dingtalk:test
DOBOTAI_RUNTIME_UID=996
DOBOTAI_RUNTIME_GID=988
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
+5
View File
@@ -0,0 +1,5 @@
\pset pager off
SELECT table_schema || '.' || table_name AS t
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1;
+8
View File
@@ -0,0 +1,8 @@
\pset pager off
\echo == columns ==
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'channel_actors'
ORDER BY ordinal_position;
\echo == rows ==
SELECT * FROM channel_actors ORDER BY 1 LIMIT 6;
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Prod-server switch to the combined release:
# reasoning-body boundary refresh + offset-0 body boundary + card structural diagnostics
# + wide-screen (autoLayout) card payload
# RUN ON THE PRODUCTION HOST (120.76.218.137) as root. Do not wrap in ssh.
#
# Usage: bash prod_switch_reasoning_widescreen.sh <new-image-ref>
#
# Safety: requires the new image locally; tags the running image as a rollback tag;
# backs up runtime.env with its original mode; refuses unless DOBOTAI_IMAGE appears
# exactly once before and once after the edit and the line count is unchanged;
# refuses unless the new binary carries every release literal.
# Prints only image refs / variable names / literal names - never env values.
set -euo pipefail
NEW_IMAGE="${1:-}"
[ -n "$NEW_IMAGE" ] || { echo "ABORT: pass the new image ref as \$1" >&2; exit 1; }
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
BACKUP_DIR="/opt/dobotai/container/backups"
COMPOSE_SERVICE="dobotai-dingtalk"
RELEASE_SLUG="reasoning-widescreen"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
OLD_SHA="$(echo "$OLD_IMAGE" | sed 's/.*://')"
ROLLBACK_TAG="dobotai-dingtalk:rollback-${OLD_SHA}-before-${RELEASE_SLUG}"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP="$BACKUP_DIR/runtime.env.before-${RELEASE_SLUG}.${STAMP}"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null || { echo "ABORT: $NEW_IMAGE not present locally (load it first)" >&2; exit 1; }
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker run --rm --entrypoint sh "$NEW_IMAGE" -c "grep -aqF '$lit' /usr/local/bin/dobotai"; then
echo "literal $lit: PRESENT"
else
echo "ABORT: $NEW_IMAGE lacks literal '$lit' (wrong artifact)" >&2
exit 1
fi
done
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
ORIG_MODE="$(stat -c '%a' "$RUNTIME_ENV")"
install -m "$ORIG_MODE" "$RUNTIME_ENV" "$BACKUP"
[ "$ORIG_MODE" = "600" ] || chmod 0600 "$RUNTIME_ENV"
echo "backup=$BACKUP mode=$ORIG_MODE"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
chmod "$ORIG_MODE" "$tmp"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
echo "== readback =="
sleep 4
curl -fsS -m 5 http://127.0.0.1:8090/healthz; echo
docker inspect "$COMPOSE_SERVICE" --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout body_prefix_runes banner_pos PartialFeed; do
if docker exec "$COMPOSE_SERVICE" grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "running literal $lit: PRESENT"
else
echo "running literal $lit: ABSENT"
fi
done
echo "== log scan (since 90s) =="
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | grep -icE 'panic|fatal|level=error| ERROR ' || echo "0 error/panic lines"
docker logs "$COMPOSE_SERVICE" --since 90s 2>&1 | tail -6
echo "== rollback (one command, run on this host) =="
echo "sed -i 's|^DOBOTAI_IMAGE=.*|DOBOTAI_IMAGE=$OLD_IMAGE|' $RUNTIME_ENV && cd /opt/dobotai/container && docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate $COMPOSE_SERVICE"
echo "or restore: install -m $ORIG_MODE $BACKUP $RUNTIME_ENV (then the compose line above)"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Test-server switch to the build that delivers card images as DingTalk media ids.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback at
# the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="${NEW_IMAGE:-dobotai-dingtalk:local-dfc7c8dba956}"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-image-media"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-image-media.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in oapi.dingtalk.com/media/upload DINGTALK_CARD_IMAGE_MEDIA; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -6
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Test-server switch to the wide-screen (autoLayout) card image.
# Rollback tag + 0600 env backup first; refuses unless the DOBOTAI_IMAGE
# substitution is exactly 1 before and exactly 1 after. Read-only readback
# at the end. No secrets are printed.
set -euo pipefail
NEW_IMAGE="dobotai-dingtalk:local-cardfix-ba090d17"
RUNTIME_ENV="/opt/dobotai/container/runtime.env"
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-card-widescreen"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR="/opt/dobotai/container/backups"
BACKUP="$BACKUP_DIR/runtime.env.before-card-widescreen.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
before_count="$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")"
[ "$before_count" = "1" ] || { echo "ABORT: $before_count DOBOTAI_IMAGE lines before edit" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in sys_full_json_obj autoLayout _CARD_DEBUG_TOOL_ENTRY; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -5
echo "rollback: DOBOTAI_IMAGE=$OLD_IMAGE (+ compose up -d --no-deps --force-recreate dobotai-dingtalk)"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Generic test-server image switch: rollback tag first, 0600 runtime.env backup,
# post-edit verification (exactly 1 DOBOTAI_IMAGE line, unchanged line count),
# container recreate, then read-only readback incl. binary literal presence.
# Usage: bash test_switch_image.sh <image:tag> <label>
set -euo pipefail
NEW_IMAGE="${1:?usage: test_switch_image.sh <image:tag> <label>}"
LABEL="${2:?usage: test_switch_image.sh <image:tag> <label>}"
RUNTIME_ENV=/opt/dobotai/container/runtime.env
OLD_IMAGE="$(grep -m1 '^DOBOTAI_IMAGE=' "$RUNTIME_ENV" | cut -d= -f2-)"
ROLLBACK_TAG="dobotai-dingtalk:rollback-$(echo "$OLD_IMAGE" | sed 's/.*://')-before-$LABEL"
STAMP="$(date +%Y%m%dT%H%M%S)"
BACKUP_DIR=/opt/dobotai/container/backups
BACKUP="$BACKUP_DIR/runtime.env.before-$LABEL.$STAMP"
echo "== preflight =="
docker image inspect "$NEW_IMAGE" >/dev/null
[ "$OLD_IMAGE" != "$NEW_IMAGE" ] || { echo "ABORT: runtime.env already points at $NEW_IMAGE" >&2; exit 1; }
[ "$(grep -c '^DOBOTAI_IMAGE=' "$RUNTIME_ENV")" = "1" ] || { echo "ABORT: DOBOTAI_IMAGE line count != 1" >&2; exit 1; }
echo "old_image=$OLD_IMAGE"
echo "new_image=$NEW_IMAGE"
echo "rollback_tag=$ROLLBACK_TAG"
echo "== tag rollback target =="
if docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then
echo "rollback tag already exists, keeping it (no overwrite)"
else
docker tag "$OLD_IMAGE" "$ROLLBACK_TAG"
echo "tagged $OLD_IMAGE -> $ROLLBACK_TAG"
fi
echo "== backup runtime.env =="
mkdir -p "$BACKUP_DIR"
install -m 0600 "$RUNTIME_ENV" "$BACKUP"
echo "backup=$BACKUP"
echo "== rewrite DOBOTAI_IMAGE =="
tmp="$(mktemp /opt/dobotai/container/.runtime.env.XXXXXX)"
awk -v new="$NEW_IMAGE" '/^DOBOTAI_IMAGE=/ { sub(/^DOBOTAI_IMAGE=.*/, "DOBOTAI_IMAGE=" new) } { print }' "$RUNTIME_ENV" >"$tmp"
after_count="$(grep -c "^DOBOTAI_IMAGE=$NEW_IMAGE\$" "$tmp")"
if [ "$after_count" != "1" ] || [ "$(wc -l <"$tmp")" != "$(wc -l <"$RUNTIME_ENV")" ]; then
rm -f "$tmp"
echo "ABORT: post-edit verification failed (match=$after_count)" >&2
exit 1
fi
chmod 0644 "$tmp"
mv "$tmp" "$RUNTIME_ENV"
grep -n '^DOBOTAI_IMAGE=' "$RUNTIME_ENV"
echo "== recreate container =="
cd /opt/dobotai/container
docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk
echo "== readback =="
sleep 3
curl -fsS http://127.0.0.1:8090/healthz; echo
docker inspect dobotai-dingtalk --format 'image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}}'
for lit in PartialFeed autoLayout 智能推理模式; do
if docker exec dobotai-dingtalk grep -aqF "$lit" /proc/1/root/usr/local/bin/dobotai; then
echo "literal $lit: PRESENT"
else
echo "literal $lit: ABSENT"
fi
done
docker logs dobotai-dingtalk --since 60s 2>&1 | tail -8
echo "rollback: set DOBOTAI_IMAGE=$OLD_IMAGE in $RUNTIME_ENV (backup $BACKUP) then: docker compose --env-file runtime.env -f compose.yaml up -d --no-deps --force-recreate dobotai-dingtalk"
+16 -16
View File
@@ -1,21 +1,21 @@
# WeKnora API Capability Gaps
# WeKnora API 能力缺口
These gaps must not be solved with private WeKnora source or database changes.
以下能力缺口不得通过修改 WeKnora 私有源码或数据库解决。
| Capability | Current external handling |
| 能力 | 当前外部处理方式 |
|---|---|
| Like/dislike and unresolved reason | DobotAI PostgreSQL and API |
| Formal covered/not-covered decision | DobotAI evaluation rules using references, scores, answer status, and offline calibration |
| Support case, owner, SLA, and status | DobotAI workflow |
| Human reply to original channel | DobotAI channel adapter; not written to WeKnora history by default |
| Stable external turn correlation | DobotAI mapping of external message, turn, WeKnora session/request, and gateway trace |
| Complete multi-call token usage | Model gateway/Langfuse; unknown remains null/unknown |
| Strict image failure semantics | Adapter contract test; reject image traffic on failure |
| Arbitrary human assistant-message append | Keep in DobotAI and submit an upstream API request only if context injection becomes necessary |
| 点赞、点踩和未解决原因 | DobotAI PostgreSQL 与 API |
| 正式的已覆盖/未覆盖判定 | DobotAI 基于引用、分数、回答状态和离线校准的评估规则 |
| Support Case、负责人、SLA 和状态 | DobotAI 工作流 |
| 向原始渠道发送人工回复 | DobotAI 渠道适配器;默认不写回 WeKnora 历史 |
| 稳定的外部 Turn 关联 | DobotAI 映射外部消息、Turn、WeKnora 会话/请求和网关追踪 |
| 完整的多调用 Token 用量 | 模型网关/Langfuse;未知值保持 `null/unknown` |
| 严格的图片失败语义 | Adapter 契约测试;图片流量失败时拒绝处理 |
| 任意人工助手消息追加 | 保留在 DobotAI;仅当确有上下文注入需求时向上游提交 API 请求 |
Allowed responses to a gap:
面对能力缺口时,只允许:
1. adjust the product design;
2. implement it outside WeKnora;
3. open an upstream issue;
4. submit a general upstream pull request.
1. 调整产品设计;
2. 在 WeKnora 外部实现;
3. 提交上游 Issue;
4. 提交通用的上游 Pull Request。
+74 -50
View File
@@ -1,74 +1,98 @@
# DobotAI External Architecture
# DobotAI 外部集成架构
## Boundary
## 系统边界
```text
DingTalk / WeCom / Web / Software
-> DobotAI ingress and workflows
钉钉 / 企业微信 / Web / 业务软件
-> DobotAI 接入层与工作流
-> WeKnora Adapter
-> official WeKnora REST API
-> official WeKnora RAG and Agent
-> WeKnora 官方 REST API
-> WeKnora 官方 RAG 与 Agent
```
DobotAI PostgreSQL owns channel identities, external messages, session mappings, turns, feedback, coverage decisions, support cases, human replies, FAQ workflow state, and content-source mappings. WeKnora owns only its official data model.
DobotAI PostgreSQL 负责渠道身份、外部消息、会话映射、Turn、反馈、覆盖判定、Support Case、人工回复、FAQ 工作流状态和内容源映射。WeKnora 仅负责其官方数据模型。
## Session policy
## 会话策略
Default policy:
默认策略:
- direct: one long-lived session per tenant + channel + Agent + external user;
- group: one shared session per tenant + channel + Agent + external group;
- group messages are admitted only when the bot is mentioned or an explicit command triggers it;
- sender display name is prefixed to the query so a shared Agent can distinguish speakers;
- future controls must include idle rotation and an explicit new-conversation command;
- confidential channels may switch to `per_user`, yielding group + user sessions.
- 私聊:每个 `tenant + channel + Agent + 外部用户` 使用一个长期会话;
- 群聊:每个 `tenant + channel + Agent + 外部群` 使用一个共享会话;
- 群消息仅在机器人被提及或命中显式命令时进入处理;
- 将发送人显示名添加到问题前缀,使共享 Agent 能区分发言者;
- 后续控制项必须包含空闲轮换和显式新建会话命令;
- 保密渠道可切换为 `per_user`,形成群组加用户维度的会话。
Shared group sessions are the common collaboration-bot pattern because all members see the same conversation. Group+user sessions provide stronger context isolation but make replies less coherent for collaborative group discussions.
共享群会话符合协作机器人场景:所有成员能看到相同上下文。群组加用户会话能增强上下文隔离,但会削弱协作讨论中的回复连贯性。
## Agent binding
快速问答与智能推理必须复用同一个 WeKnora Session;`/think` 仅改变当前 Turn 的执行 Agent,不得重建会话映射。
Agents are configuration-owned. Channel callers cannot choose an arbitrary `agent_id`.
## Agent 绑定
Resolution order:
Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
1. exact `<channel>:<external_chat_id>` binding;
2. channel wildcard `<channel>:*`;
3. fail closed when no binding exists.
解析顺序:
## Image failure policy
1. 精确 `<channel>:<external_chat_id>` 绑定;
2. 渠道通配 `<channel>:*`;
3. 没有任何绑定时失败关闭。
Before enabling image traffic, the WeKnora Adapter must run a contract test against the selected official release and configured vision model.
## 首位管理员终端绑定
If any image path fails:
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
1. do not resend the question without the image;
2. do not return partial model output as success;
3. return a typed `vision_unavailable` failure to the channel adapter;
4. DingTalk tells the user that image recognition is temporarily unavailable and asks them to retry or send text;
5. emit an operational alert to verify Agent image upload settings, selected vision model, provider connectivity, and model capability;
6. keep image traffic disabled until the contract test passes.
```bash
dobotai bootstrap-admin
```
No WeKnora source patch is allowed as a workaround.
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
## Service ownership
```text
/setting <六码绑定码>
```
- `internal/weknora`: official REST/SSE compatibility only.
- `internal/qa`: business orchestration and terminal-state ownership.
- `internal/session`: external-to-WeKnora session policy.
- `internal/store/postgres`: DobotAI business persistence only.
- `internal/coverage`: v1 covered/uncovered evaluation, durable support-case dispatch, finite retries, and stable failure codes. Notification failures never change a successful QA result.
- `internal/channels/dingtalk`: DingTalk Stream ingress (official SDK), text-only robot callbacks, fail-closed session webhook replies (`https`, `*.dingtalk.com`, no redirects). Wired in `cmd/dobotai`; enabled only when `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, and `DOBOTAI_TENANT_KEY` are all set. Group messages require an @-mention; direct chats map to one long-lived session per user, groups to one shared session per group. Images, OpenAPI fallback, and card replies are not implemented yet.
- future `channels/*`: platform callbacks and outbound replies.
- future `content-sync/*`: DingTalk KB, NAS, and CMS synchronization.
- model gateway: token accounting, routing, quotas, circuit breaking, and provider observability.
- Redis: queue, retry, short leases, and temporary stream state only.
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
## Upgrade contract
## 图片失败策略
For every official WeKnora upgrade:
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
1. update the pinned official release/tag;
2. run official database migrations against WeKnora only;
3. run DobotAI Adapter contract tests;
4. verify text Agent Chat, references, terminal errors, Stop, images, ingestion, and FAQ;
5. promote the official image only after the contract suite passes.
任一图片路径失败时:
1. 不得去掉图片后重发同一问题;
2. 不得将部分模型输出当作成功结果;
3. 向渠道适配器返回类型化 `vision_unavailable` 错误;
4. 钉钉提示用户图片识别暂不可用,并请其重试或发送文本;
5. 发送运维告警,核查 Agent 图片上传设置、视觉模型、供应商连通性和模型能力;
6. 契约测试通过前保持图片流量禁用。
不得通过修改 WeKnora 源码规避上述失败。
对钉钉 richText 中的图片,DobotAI 仅通过官方接口下载,验证来源、大小、媒体类型和文件签名后,才转换为 WeKnora 通用多模态请求。对回答中的 `resource://` 图片,仅可在鉴权获取、内容哈希转存私有 OSS 后外发;不得把任意外部 URL 当作可信图片。
## 服务职责
- `internal/weknora`:仅处理 WeKnora 官方 REST/SSE 兼容;解析普通引用和智能推理工具结果引用;
- `internal/qa`:业务编排与 Turn 终态归属;
- `internal/session`:外部渠道到 WeKnora 的会话策略;
- `internal/store/postgres`:仅保存 DobotAI 业务数据;
- `internal/coverage`:已覆盖/未覆盖/纯视觉识别判定、持久化 Support Case、有限重试和稳定错误码;通知失败不得改变成功问答的结果;
- `internal/channels/dingtalk`:钉钉 Stream 接入、richText 与多图片归一化、AI 流式卡片、命令路由、反馈与通知。仅在 `DINGTALK_CLIENT_ID`、`DINGTALK_CLIENT_SECRET` 和 `DOBOTAI_TENANT_KEY` 均已设置时启用。群聊必须 `@` 机器人;私聊按用户映射长期会话,群聊默认映射共享会话;`sessionWebhook` 必须是 `https`、`*.dingtalk.com`、443端口且不允许重定向;
- `internal/reasoningmode`:`/think` 单次与持续推理、轮数、空闲到期、提示和首次使用引导;
- `internal/outboundimage`:WeKnora `resource://` 图片的受控鉴权下载和私有 OSS 外发;
- `future channels/*`:其他平台的回调与外发回复;
- `future content-sync/*`:钉钉知识库、NAS 与 CMS 同步;
- 模型网关:Token 统计、路由、配额、熔断和供应商可观测性;
- Redis:仅用于队列、重试、短期租约和临时流状态。
## 升级契约
每次升级官方 WeKnora 时:
1. 更新固定的官方 Release/Tag;
2. 仅对 WeKnora 执行官方数据库迁移;
3. 执行 DobotAI Adapter 契约测试;
4. 验证文本 Agent Chat、引用、终止错误、停止、图片、导入和 FAQ;
5. 仅在契约测试通过后推广官方镜像。
WeKnora 升级、DobotAI 迁移、钉钉单消费者切换和生产部署是独立变更边界,必须各自备份、演练和验收。
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "请在仓库根目录执行:sudo bash ./install-docker.sh" >&2
exit 1
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$repo_root"
[[ -f Dockerfile && -f deploy/dobotai-dingtalk/install-docker.sh ]] || { echo "必须从完整DobotAI源码仓库根目录运行。" >&2; exit 1; }
command -v git >/dev/null || { echo "未找到git。" >&2; exit 1; }
git rev-parse --is-inside-work-tree >/dev/null || { echo "请从git clone得到的仓库中执行。" >&2; exit 1; }
install_docker_if_missing() {
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then return; fi
command -v apt-get >/dev/null || { echo "请先安装Docker Engine和Docker Compose v2。" >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get update
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-v2
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
apt-get install -y docker.io docker-compose-plugin
else
echo "APT源缺少Docker Compose v2插件。" >&2; exit 1
fi
systemctl enable --now docker
}
install_docker_if_missing
docker compose version >/dev/null
commit_sha="$(git rev-parse HEAD)"
commit_short="$(git rev-parse --short=12 HEAD)"
image="dobotai-dingtalk:local-${commit_short}"
echo "[1/2] 构建DobotAI应用镜像:$image"
docker build --build-arg "GOPROXY=https://goproxy.cn,direct" --build-arg "VERSION=local-${commit_short}" --build-arg "COMMIT_SHA=${commit_sha}" --build-arg "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" --label "org.opencontainers.image.revision=${commit_sha}" --tag "$image" "$repo_root"
echo "[2/2] 安装应用容器资产;阿里云RDS将在配置后检查或初始化"
export DOBOTAI_IMAGE="$image"
export DOBOTAI_SOURCE_ROOT="$repo_root"
exec bash "$repo_root/deploy/dobotai-dingtalk/install-docker.sh"
-205
View File
@@ -1,205 +0,0 @@
package adminweb
import (
"context"
"errors"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type ConfigRepository interface {
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
CandidateDingTalkConfig(context.Context, string) (string, string, error)
SaveDingTalkConfig(context.Context, string, string, string, bool) error
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
AdministratorCount(context.Context, string) (int, error)
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
ConfirmBootstrap(context.Context, string) error
}
type AdministratorRepository interface {
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
DisableAdministrator(context.Context, string, string) error
}
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
type OperationalStatusProvider interface {
Components(context.Context) ([]Component, error)
}
type CredentialVerifier interface {
Verify(context.Context, string, string) dingtalkconfig.Result
}
type ControlService struct {
tenant string
repo ConfigRepository
secrets *secretstore.Store
verifier CredentialVerifier
activator CandidateActivator
status OperationalStatusProvider
}
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
return nil, errors.New("admin control dependencies are required")
}
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
}
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
if err != nil {
return Snapshot{}, err
}
administratorViews := []AdministratorView{}
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
if listErr != nil {
return Snapshot{}, listErr
}
activeCount := 0
for _, administrator := range administrators {
if administrator.Status == "active" {
activeCount++
}
}
for _, administrator := range administrators {
administratorViews = append(administratorViews, AdministratorView{
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
DisplayName: administrator.DisplayName, Department: administrator.Department,
Role: administrator.Role, Status: administrator.Status,
CanDisable: administrator.Status == "active" && activeCount > 1,
})
}
}
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
now := time.Now()
components := []Component{
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
}
if s.status != nil {
operational, probeErr := s.status.Components(ctx)
if probeErr != nil {
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
} else {
components = append(components, operational...)
}
}
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
}
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
r := s.verifier.Verify(ctx, id, secret)
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
}
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
ref, err := s.secrets.Put("dingtalk", secret)
if err != nil {
return err
}
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
_ = s.secrets.Delete(ref)
return err
}
if oldRef != "" && oldRef != ref {
_ = s.secrets.Delete(oldRef)
}
return nil
}
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
s.status = provider
}
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
if s.activator == nil {
return errors.New("candidate activator unavailable")
}
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
if err != nil {
return err
}
if err = s.activator.ActivateCandidate(ctx); err != nil {
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
return errors.New("candidate activation failed")
}
return nil
}
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
}
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
return s.repo.ConfirmBootstrap(ctx, s.tenant)
}
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
staffID = strings.TrimSpace(staffID)
if staffID == "" {
return settings.ErrAdministratorNotFound
}
repo, ok := s.repo.(AdministratorRepository)
if !ok {
return errors.New("administrator management unavailable")
}
return repo.DisableAdministrator(ctx, s.tenant, staffID)
}
func maskID(v string) string {
v = strings.TrimSpace(v)
if len(v) <= 6 {
return v
}
return v[:3] + "***" + v[len(v)-3:]
}
func status(ok bool) string {
if ok {
return "ok"
}
return "unconfigured"
}
func countStatus(n int) string {
if n > 0 {
return "ok"
}
return "unconfigured"
}
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "configured"
}
return "unconfigured"
}
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
if _, ok := m[k]; ok {
return "binding_active"
}
return "binding_missing"
}
func ternaryCode(ok bool, success, failure string) string {
if ok {
return success
}
return failure
}
func itoa(n int) string {
if n == 0 {
return "0"
}
b := make([]byte, 0, 8)
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
-210
View File
@@ -1,210 +0,0 @@
package adminweb
import (
"context"
"errors"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type controlRepo struct {
savedVerified bool
savedRef string
candidateRef string
failedCode string
admins []settings.Administrator
disabledStaff string
disableErr error
}
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
return "old", "old-ref", true, nil
}
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
return "new", r.candidateRef, nil
}
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
r.savedRef = ref
r.savedVerified = verified
r.candidateRef = ref
return nil
}
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
r.failedCode = code
return nil
}
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
if r.admins == nil {
return 1, nil
}
n := 0
for _, admin := range r.admins {
if admin.Status == "active" {
n++
}
}
return n, nil
}
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
return r.admins, nil
}
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
r.disabledStaff = staffID
return r.disableErr
}
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
return map[string]settings.ResourceBinding{}, nil
}
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
return nil
}
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
type verifyOK struct{}
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
}
type activatorStub struct {
err error
calls int
}
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
type operationalStatusStub struct {
components []Component
err error
}
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
return s.components, s.err
}
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
checked := time.Unix(100, 0)
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
}}})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
found := false
for _, component := range snapshot.Components {
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
found = true
}
}
if !found {
t.Fatalf("components=%+v", snapshot.Components)
}
}
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
last := snapshot.Components[len(snapshot.Components)-1]
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
t.Fatalf("component=%+v", last)
}
}
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
t.Fatal(err)
}
if repo.savedVerified {
t.Fatal("candidate must not be active")
}
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
t.Fatalf("ref=%q", repo.savedRef)
}
}
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
secrets, err := secretstore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
repo := &controlRepo{candidateRef: "candidate-ref"}
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
service.SetCandidateActivator(activator)
if err = service.ActivateCandidate(context.Background()); err == nil {
t.Fatal("expected failure")
}
if activator.calls != 1 || repo.failedCode != "activation_failed" {
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
}
}
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{admins: []settings.Administrator{
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
}}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
snapshot, err := service.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
t.Fatalf("administrators=%+v", snapshot.Administrators)
}
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
}
}
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
secrets, _ := secretstore.New(t.TempDir())
repo := &controlRepo{}
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
if err != nil {
t.Fatal(err)
}
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
t.Fatal(err)
}
if repo.disabledStaff != "staff-old" {
t.Fatalf("disabled=%q", repo.disabledStaff)
}
}
-253
View File
@@ -1,253 +0,0 @@
package adminweb
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type Snapshot struct {
SystemState, ClientIDMasked string
SecretConfigured bool
Components []Component
Administrators []AdministratorView
}
type AdministratorView struct {
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
CanDisable bool
}
type Component struct {
Name, State, Code, Detail string
LastSuccess, CheckedAt time.Time
}
type Verification struct {
OK bool
Code string
CheckedAt time.Time
}
type Service interface {
Snapshot(context.Context) (Snapshot, error)
SaveCredentials(context.Context, string, string) error
VerifyCredentials(context.Context, string, string) Verification
}
type BootstrapService interface {
CreateBootstrap(context.Context, string) error
ConfirmBootstrap(context.Context) error
}
type CandidateService interface {
ActivateCandidate(context.Context) error
}
type AdministratorService interface {
DisableAdministrator(context.Context, string) error
}
type server struct {
service Service
token string
csrfToken string
mux *http.ServeMux
page *template.Template
}
type pageData struct {
Snapshot
CSRFToken string
}
const settingsPage = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>DobotAI 设置</title>
<style>
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
</style>
</head>
<body>
<h1>DobotAI 配置中心</h1>
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
</body></html>`
func New(service Service, token string) http.Handler {
functions := template.FuncMap{"timeText": func(value time.Time) string {
if value.IsZero() {
return "—"
}
return value.UTC().Format("2006-01-02 15:04:05")
}}
csrfBytes := make([]byte, 32)
if _, err := rand.Read(csrfBytes); err != nil {
panic("admin CSRF token unavailable")
}
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
s.mux.HandleFunc("GET /admin/settings", s.settings)
s.mux.HandleFunc("GET /admin/setup", s.settings)
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !s.authorized(r) {
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *server) authorized(r *http.Request) bool {
if s.token == "" {
return false
}
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
return true
}
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
}
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return false
}
provided := strings.TrimSpace(r.FormValue("csrf_token"))
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return false
}
return true
}
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
snap, err := s.service.Snapshot(r.Context())
if err != nil {
http.Error(w, "settings unavailable", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
}
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 16<<10) {
return
}
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
result := s.service.VerifyCredentials(r.Context(), id, secret)
if !result.OK {
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
return
}
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
http.Error(w, "credential save failed", 500)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(CandidateService)
if !ok {
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
return
}
if err := service.ActivateCandidate(r.Context()); err != nil {
http.Error(w, "candidate activation failed", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
}
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !sixDigits(code) {
http.Error(w, "binding code must be 6 digits", 400)
return
}
if err := service.CreateBootstrap(r.Context(), code); err != nil {
http.Error(w, "create binding code failed", 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
}
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(BootstrapService)
if !ok {
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
return
}
if err := service.ConfirmBootstrap(r.Context()); err != nil {
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRFForm(w, r, 4<<10) {
return
}
service, ok := s.service.(AdministratorService)
if !ok {
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
return
}
staffID := strings.TrimSpace(r.FormValue("staff_id"))
if staffID == "" {
http.Error(w, "administrator not found", http.StatusNotFound)
return
}
err := service.DisableAdministrator(r.Context(), staffID)
switch {
case err == nil:
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
case errors.Is(err, settings.ErrLastAdministrator):
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
http.Error(w, "管理员已经停用。", http.StatusConflict)
case errors.Is(err, settings.ErrAdministratorNotFound):
http.Error(w, "administrator not found", http.StatusNotFound)
default:
http.Error(w, "administrator update failed", http.StatusInternalServerError)
}
}
func sixDigits(value string) bool {
if len(value) != 6 {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
-166
View File
@@ -1,166 +0,0 @@
package adminweb
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
)
type fakeService struct {
snapshot Snapshot
verified bool
saved bool
disabled string
disableErr error
}
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
f.saved = true
return nil
}
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
f.verified = true
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
}
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
f.disabled = staffID
return f.disableErr
}
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
t.Helper()
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.Header.Set("Authorization", "Bearer admin-token")
page := httptest.NewRecorder()
h.ServeHTTP(page, get)
marker := `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("csrf token missing: %s", page.Body.String())
}
start += len(marker)
end := strings.Index(page.Body.String()[start:], `"`)
if end < 0 {
t.Fatal("csrf token terminator missing")
}
return page.Body.String()[start : start+end]
}
func TestDashboardNeverRendersSecrets(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
t.Fatal("secret leaked")
}
if !strings.Contains(body, "ding***") {
t.Fatal("masked client id missing")
}
}
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body := w.Body.String()
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
if !strings.Contains(body, want) {
t.Fatalf("dashboard missing %q: %s", want, body)
}
}
}
func TestSaveCredentialsRequiresVerification(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
csrf := csrfTokenFromPage(t, h)
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
}
}
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
}
}
if svc.saved || svc.disabled != "" {
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
}
}
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
h := New(svc, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
if !strings.Contains(w.Body.String(), want) {
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
}
}
if strings.Contains(w.Body.String(), ">staff-old<") {
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
}
}
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
svc := &fakeService{}
h := New(svc, "admin-token")
post := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer admin-token")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
}
csrf := csrfTokenFromPage(t, h)
svc.disableErr = settings.ErrLastAdministrator
w := post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
}
svc.disableErr = nil
w = post("staff_id=staff-old&csrf_token=" + csrf)
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
}
}
+19
View File
@@ -21,6 +21,24 @@ const (
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
// cardWideScreenJSONKey is DingTalk's reserved system field inside
// cardData.cardParamMap. Template variables whose value is a complex type
// (object / array of objects) must be passed through this field as a JSON
// string; plain string/number/boolean variables go in cardParamMap
// directly. Putting a complex value anywhere else (for example as a
// sibling of cardParamMap under cardData) leaves the template variable
// unassigned and silently falls back to the default layout.
cardWideScreenJSONKey = "sys_full_json_obj"
// cardWideScreenJSON configures the AI card's root "config": PC clients
// render the card wide (auto layout) when autoLayout is true. The card
// template must declare an object variable "config" with a boolean child
// "autoLayout" (card builder > 数据源 > 编辑变量) and the value must be
// passed when the card is created - setting it statically inside the
// template does not take effect. Mobile clients keep their own adaptive
// layout, so this only widens the card on desktop.
cardWideScreenJSON = `{"config":{"autoLayout":true}}`
)
// CardRequest is the input for sending a final AI card.
@@ -229,6 +247,7 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
"feedbackReasonSelected": "",
"showFeedbackReasons": "false",
"feedbackMessage": "",
cardWideScreenJSONKey: cardWideScreenJSON,
},
},
"userIdType": 1,
+56 -1
View File
@@ -3,8 +3,12 @@ package dingtalk
import (
"fmt"
"strings"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
const reasoningBannerMarker = "【智能推理模式】"
type cardContentShape struct {
bytes int
lines int
@@ -13,20 +17,63 @@ type cardContentShape struct {
delimiterLines int
confirmedTable bool
confirmedTablePos int
// Structural split state: where the answer body starts inside this exact
// payload. bodyPrefixRunes > 0 means the update still carries narration in
// front of the body (the reasoning text the card is supposed to hide).
bodyOK bool
bodyLine int
bodyPrefixRunes int
bannerPos int
firstHeadingLine int
imageRefs int
externalImageRefs int
mediaImageRefs int
}
func inspectCardContent(content string) cardContentShape {
shape := cardContentShape{
bytes: len(content),
confirmedTablePos: -1,
bodyLine: -1,
firstHeadingLine: -1,
bannerPos: -1,
}
if content == "" {
return shape
}
shape.lines = strings.Count(content, "\n") + 1
shape.imageRefs, shape.externalImageRefs, shape.mediaImageRefs = cardImageRefCounts(content)
shape.bannerPos = strings.Index(content, reasoningBannerMarker)
// The streaming path decorates every frame with the reasoning banner, so the
// prefix in front of the body has to be measured after that banner: only
// what remains is narration the card is meant to hide.
bannerSkip := 0
if shape.bannerPos == 0 {
if idx := strings.Index(content, "\n"); idx >= 0 {
bannerSkip = idx + 1
for bannerSkip < len(content) && content[bannerSkip] == '\n' {
bannerSkip++
}
}
}
if offset, ok := reasoningmode.BodyStartOffset(content); ok {
shape.bodyOK = true
shape.bodyLine = strings.Count(content[:offset], "\n")
prefix := content[:offset]
if bannerSkip > 0 && bannerSkip <= len(prefix) {
prefix = prefix[bannerSkip:]
}
shape.bodyPrefixRunes = len([]rune(prefix))
}
lines := splitMarkdownStreamLines(content)
for i, line := range lines {
trimmed := strings.TrimSpace(line.text)
if shape.firstHeadingLine < 0 && !line.inFence &&
strings.HasPrefix(trimmed, "#") && strings.TrimLeft(trimmed, "#") != "" {
shape.firstHeadingLine = i
}
if strings.Contains(line.text, "|") {
shape.pipeLines++
}
@@ -50,7 +97,7 @@ func inspectCardContent(content string) cardContentShape {
func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string {
shape := inspectCardContent(content)
return fmt.Sprintf(
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d",
"DingTalk card update shape: finalize=%t error=%t bytes=%d lines=%d pipe_lines=%d leading_pipe_lines=%d delimiter_lines=%d confirmed_table=%t table_start=%d body_ok=%t body_line=%d body_prefix_runes=%d banner_pos=%d first_heading_line=%d image_refs=%d external_image_refs=%d media_image_refs=%d",
isFinalize,
isError,
shape.bytes,
@@ -60,5 +107,13 @@ func formatCardUpdateDiagnostic(content string, isFinalize, isError bool) string
shape.delimiterLines,
shape.confirmedTable,
shape.confirmedTablePos,
shape.bodyOK,
shape.bodyLine,
shape.bodyPrefixRunes,
shape.bannerPos,
shape.firstHeadingLine,
shape.imageRefs,
shape.externalImageRefs,
shape.mediaImageRefs,
)
}
@@ -0,0 +1,488 @@
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"regexp"
"strings"
"sync"
"time"
)
// DingTalk AI cards only render images that live in DingTalk's own media store:
// an external HTTPS image URL shows up in a normal markdown message but is
// dropped inside a streaming/short-card body (verified on both desktop and
// mobile, 2026-09-17). The card content therefore rewrites our own published
// image URLs into DingTalk media_id values obtained through the legacy
// oapi media upload endpoint, which both clients render.
const (
defaultCardImageTokenURL = "https://oapi.dingtalk.com/gettoken"
defaultCardImageUploadURL = "https://oapi.dingtalk.com/media/upload"
defaultCardImageMediaTTL = 12 * time.Hour
defaultCardImageWaitTimeout = 5 * time.Second
defaultCardImageFetchTimeout = 15 * time.Second
defaultCardImageUploadTimeout = 20 * time.Second
defaultCardImageMaxBytes = 8 << 20
cardImageTokenRefreshMargin = 5 * time.Minute
maxConcurrentCardImageUploads = 3
)
// cardImageStableIDRE matches the deterministic image id produced by
// outboundimage.StableHashPublisher (UUIDv5 shaped, lowercase hex).
var cardImageStableIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
// cardMarkdownImageRE captures the URL of a complete markdown image, including
// the optional title slot. Incomplete image syntax (still streaming) is not
// matched, so it is never rewritten.
var cardMarkdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^\s)\r\n]+)(?:\s+(?:"[^"\r\n]*"|'[^'\r\n]*'|\([^)\r\n]*\)))?\)`)
// CardImageMediaRewriter converts card-body image references into DingTalk
// media ids. Implementations must be safe for concurrent use and must never
// fail a card update: on any error the original reference is kept.
type CardImageMediaRewriter interface {
RewriteForCard(ctx context.Context, content string, wait bool) string
}
// CardImageMediaOptions configures the resolver. ClientID/ClientSecret are the
// same Robot app credentials used for access tokens, and ImageURLPrefix is the
// public prefix of our own published images.
type CardImageMediaOptions struct {
ClientID string
ClientSecret string
ImageURLPrefix string
// FetchURLPrefix optionally replaces ImageURLPrefix when downloading the
// bytes back (for example a loopback address), so the upload never depends
// on the container being able to reach its own public host name.
FetchURLPrefix string
TokenURL string
UploadURL string
HTTPClient *http.Client
TTL time.Duration
WaitTimeout time.Duration
MaxBytes int64
}
type cardImageMediaEntry struct {
mediaID string
expiresAt time.Time
}
type cardImageMediaJob struct {
done chan struct{}
mediaID string
err error
}
type cardImageMediaResolver struct {
clientID string
clientSecret string
imageURLPrefix string
fetchURLPrefix string
tokenURL string
uploadURL string
client *http.Client
ttl time.Duration
waitTimeout time.Duration
maxBytes int64
sem chan struct{}
now func() time.Time
mu sync.Mutex
entries map[string]cardImageMediaEntry
inflight map[string]*cardImageMediaJob
token string
tokenExpiry time.Time
}
// NewCardImageMediaRewriter builds the resolver. It returns a nil rewriter and
// no error when the feature is not configured, which keeps the caller simple.
func NewCardImageMediaRewriter(opts CardImageMediaOptions) (CardImageMediaRewriter, error) {
clientID := strings.TrimSpace(opts.ClientID)
clientSecret := strings.TrimSpace(opts.ClientSecret)
prefix := strings.TrimSpace(opts.ImageURLPrefix)
if clientID == "" || clientSecret == "" || prefix == "" {
return nil, nil
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
tokenURL := strings.TrimSpace(opts.TokenURL)
if tokenURL == "" {
tokenURL = defaultCardImageTokenURL
}
uploadURL := strings.TrimSpace(opts.UploadURL)
if uploadURL == "" {
uploadURL = defaultCardImageUploadURL
}
ttl := opts.TTL
if ttl <= 0 {
ttl = defaultCardImageMediaTTL
}
waitTimeout := opts.WaitTimeout
if waitTimeout <= 0 {
waitTimeout = defaultCardImageWaitTimeout
}
maxBytes := opts.MaxBytes
if maxBytes <= 0 {
maxBytes = defaultCardImageMaxBytes
}
client := opts.HTTPClient
if client == nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
client = &http.Client{Timeout: defaultCardImageFetchTimeout, Transport: transport}
}
return &cardImageMediaResolver{
clientID: clientID,
clientSecret: clientSecret,
imageURLPrefix: prefix,
fetchURLPrefix: strings.TrimSpace(opts.FetchURLPrefix),
tokenURL: tokenURL,
uploadURL: uploadURL,
client: client,
ttl: ttl,
waitTimeout: waitTimeout,
maxBytes: maxBytes,
sem: make(chan struct{}, maxConcurrentCardImageUploads),
now: time.Now,
entries: make(map[string]cardImageMediaEntry),
inflight: make(map[string]*cardImageMediaJob),
}, nil
}
// RewriteForCard replaces complete markdown image references that point at our
// own published images with a DingTalk media id. With wait=false the rewrite
// only uses media ids that are already known and kicks off the upload in the
// background, so streaming frames never block on DingTalk; with wait=true it
// waits (bounded) for in-flight uploads, which is what the final card update
// uses.
func (r *cardImageMediaResolver) RewriteForCard(ctx context.Context, content string, wait bool) string {
if r == nil || strings.TrimSpace(content) == "" {
return content
}
var waitCtx context.Context
if wait {
bound := r.waitTimeout
if bound <= 0 {
bound = defaultCardImageWaitTimeout
}
var cancel context.CancelFunc
waitCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), bound)
defer cancel()
}
rewritten := cardMarkdownImageRE.ReplaceAllStringFunc(content, func(image string) string {
match := cardMarkdownImageRE.FindStringSubmatch(image)
if len(match) < 2 {
return image
}
rawURL := strings.TrimSpace(match[1])
if rawURL == "" || !r.isOwnImageURL(rawURL) {
return image
}
if mediaID, ok := r.lookup(rawURL); ok {
return replaceCardImageURL(image, match[1], mediaID)
}
job := r.startUpload(rawURL)
if !wait {
return image
}
select {
case <-job.done:
case <-waitCtx.Done():
}
if job.mediaID != "" {
return replaceCardImageURL(image, match[1], job.mediaID)
}
if job.err != nil {
log.Printf("DingTalk card image media upload failed, keeping external reference")
}
return image
})
return rewritten
}
func replaceCardImageURL(image, rawURL, mediaID string) string {
if mediaID == "" || rawURL == "" {
return image
}
return strings.Replace(image, rawURL, mediaID, 1)
}
func (r *cardImageMediaResolver) isOwnImageURL(rawURL string) bool {
if r.imageURLPrefix == "" || !strings.HasPrefix(rawURL, r.imageURLPrefix) {
return false
}
id := strings.TrimPrefix(rawURL, r.imageURLPrefix)
if id == "" || strings.ContainsAny(id, "/?#") {
return false
}
return cardImageStableIDRE.MatchString(id)
}
func (r *cardImageMediaResolver) lookup(rawURL string) (string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
entry, ok := r.entries[rawURL]
if !ok || r.now().After(entry.expiresAt) {
return "", false
}
return entry.mediaID, true
}
// startUpload returns a job for the image, reusing an in-flight upload or a
// cached media id when available. The upload itself runs on a background
// context so a streaming frame's cancellation cannot abort it half way.
func (r *cardImageMediaResolver) startUpload(rawURL string) *cardImageMediaJob {
r.mu.Lock()
if entry, ok := r.entries[rawURL]; ok && r.now().Before(entry.expiresAt) {
job := &cardImageMediaJob{done: make(chan struct{}), mediaID: entry.mediaID}
close(job.done)
r.mu.Unlock()
return job
}
if job, ok := r.inflight[rawURL]; ok {
r.mu.Unlock()
return job
}
job := &cardImageMediaJob{done: make(chan struct{})}
r.inflight[rawURL] = job
r.mu.Unlock()
go func() {
uploadCtx, cancel := context.WithTimeout(context.Background(), defaultCardImageUploadTimeout)
defer cancel()
mediaID, err := r.upload(uploadCtx, rawURL)
job.mediaID = mediaID
job.err = err
r.mu.Lock()
delete(r.inflight, rawURL)
if err == nil && mediaID != "" {
r.entries[rawURL] = cardImageMediaEntry{mediaID: mediaID, expiresAt: r.now().Add(r.ttl)}
}
r.mu.Unlock()
close(job.done)
}()
return job
}
func (r *cardImageMediaResolver) upload(ctx context.Context, rawURL string) (string, error) {
select {
case r.sem <- struct{}{}:
case <-ctx.Done():
return "", ctx.Err()
}
defer func() { <-r.sem }()
data, contentType, err := r.fetch(ctx, rawURL)
if err != nil {
return "", err
}
token, err := r.accessToken(ctx)
if err != nil {
return "", err
}
return r.uploadMedia(ctx, token, data, contentType)
}
func (r *cardImageMediaResolver) fetchURL(rawURL string) string {
targets := r.fetchURLs(rawURL)
if len(targets) == 0 {
return rawURL
}
return targets[0]
}
// fetchURLs lists the download candidates for one of our published images: the
// loopback prefix first (cheap, stays inside the host) and the public URL as a
// fallback so a missing local route never turns into a missing card image.
func (r *cardImageMediaResolver) fetchURLs(rawURL string) []string {
relative := ""
if strings.HasPrefix(rawURL, r.imageURLPrefix) {
relative = strings.TrimPrefix(rawURL, r.imageURLPrefix)
}
targets := make([]string, 0, 2)
if r.fetchURLPrefix != "" && relative != "" {
targets = append(targets, strings.TrimRight(r.fetchURLPrefix, "/")+"/"+relative)
}
if relative == "" || r.fetchURLPrefix == "" {
targets = append(targets, rawURL)
} else {
targets = append(targets, strings.TrimRight(r.imageURLPrefix, "/")+"/"+relative)
}
return targets
}
func (r *cardImageMediaResolver) fetch(ctx context.Context, rawURL string) ([]byte, string, error) {
var firstErr error
for _, target := range r.fetchURLs(rawURL) {
data, contentType, err := r.fetchOnce(ctx, target)
if err == nil {
return data, contentType, nil
}
if firstErr == nil {
firstErr = err
}
if ctx.Err() != nil {
break
}
}
if firstErr == nil {
firstErr = errors.New("card image fetch: no candidate URL")
}
return nil, "", firstErr
}
func (r *cardImageMediaResolver) fetchOnce(ctx context.Context, target string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", err
}
resp, err := r.client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("card image fetch: unexpected status %d", resp.StatusCode)
}
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
return nil, "", fmt.Errorf("card image fetch: unexpected content type")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, r.maxBytes+1))
if err != nil {
return nil, "", err
}
if int64(len(data)) > r.maxBytes {
return nil, "", errors.New("card image fetch: image exceeds upload size limit")
}
if len(data) == 0 {
return nil, "", errors.New("card image fetch: empty image")
}
return data, contentType, nil
}
// cardImageRefCounts splits the markdown image references of a card body into
// externally hosted URLs and DingTalk media ids, so operators can tell from the
// logs whether a card body can actually render its images.
func cardImageRefCounts(content string) (total, external, media int) {
for _, match := range cardMarkdownImageRE.FindAllStringSubmatch(content, -1) {
if len(match) < 2 {
continue
}
total++
if strings.Contains(strings.TrimSpace(match[1]), "://") {
external++
} else {
media++
}
}
return total, external, media
}
func (r *cardImageMediaResolver) accessToken(ctx context.Context) (string, error) {
r.mu.Lock()
if r.token != "" && r.now().Before(r.tokenExpiry) {
token := r.token
r.mu.Unlock()
return token, nil
}
r.mu.Unlock()
values := map[string]string{"appkey": r.clientID, "appsecret": r.clientSecret}
payload, err := json.Marshal(values)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.tokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("card image token: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.AccessToken) == "" {
return "", fmt.Errorf("card image token: rejected with errcode %d", parsed.ErrCode)
}
expiresIn := parsed.ExpiresIn
if expiresIn <= 0 {
expiresIn = 7200
}
r.mu.Lock()
r.token = parsed.AccessToken
r.tokenExpiry = r.now().Add(time.Duration(expiresIn)*time.Second - cardImageTokenRefreshMargin)
r.mu.Unlock()
return parsed.AccessToken, nil
}
func (r *cardImageMediaResolver) uploadMedia(ctx context.Context, token string, data []byte, contentType string) (string, error) {
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="media"; filename="card-image"`)
if contentType == "" {
contentType = "image/png"
}
header.Set("Content-Type", contentType)
part, err := writer.CreatePart(header)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
if err := writer.Close(); err != nil {
return "", err
}
uploadURL := r.uploadURL + "?access_token=" + token + "&type=image"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &buffer)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := r.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var parsed struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", errors.New("card image upload: invalid response")
}
if parsed.ErrCode != 0 || strings.TrimSpace(parsed.MediaID) == "" {
return "", fmt.Errorf("card image upload: rejected with errcode %d", parsed.ErrCode)
}
return parsed.MediaID, nil
}
@@ -0,0 +1,398 @@
package dingtalk
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const cardImageTestID = "5f0a1c2e-3b4d-5e6f-8a9b-0c1d2e3f4a5b"
const cardImageTestMediaID = "@lADP-test-media-id"
type cardImageTestServer struct {
server *httptest.Server
imageBytes []byte
oversizeBytes []byte
uploadErrcode int
tokenCalls int
uploadCalls int
uploadedBytes int
fetched []string
mu sync.Mutex
uploadedC chan struct{}
}
func newCardImageTestServer(t *testing.T) *cardImageTestServer {
t.Helper()
fixture := &cardImageTestServer{
imageBytes: []byte("\x89PNG\r\n\x1a\nfixture-bytes"),
oversizeBytes: make([]byte, 512),
uploadedC: make(chan struct{}, 4),
}
mux := http.NewServeMux()
serveImage := func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.fetched = append(fixture.fetched, r.URL.Path)
fixture.mu.Unlock()
body := fixture.imageBytes
if strings.HasSuffix(r.URL.Path, "big") {
body = fixture.oversizeBytes
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(body)
}
mux.HandleFunc("/dingtalk/i/", serveImage)
mux.HandleFunc("/images/", serveImage)
mux.HandleFunc("/gettoken", func(w http.ResponseWriter, r *http.Request) {
fixture.mu.Lock()
fixture.tokenCalls++
fixture.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": 0, "errmsg": "ok", "access_token": "test-token", "expires_in": 7200,
})
})
mux.HandleFunc("/media/upload", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") != "test-token" || r.URL.Query().Get("type") != "image" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40001, "errmsg": "bad token"})
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
file, _, err := r.FormFile("media")
if err == nil {
data, _ := io.ReadAll(file)
_ = file.Close()
fixture.mu.Lock()
fixture.uploadedBytes += len(data)
fixture.mu.Unlock()
}
fixture.mu.Lock()
fixture.uploadCalls++
code := fixture.uploadErrcode
fixture.mu.Unlock()
select {
case fixture.uploadedC <- struct{}{}:
default:
}
_ = json.NewEncoder(w).Encode(map[string]any{
"errcode": code, "errmsg": "ok", "media_id": cardImageTestMediaID,
})
})
fixture.server = httptest.NewServer(mux)
t.Cleanup(fixture.server.Close)
return fixture
}
func (f *cardImageTestServer) resolver(t *testing.T, mutate func(*CardImageMediaOptions)) CardImageMediaRewriter {
t.Helper()
opts := CardImageMediaOptions{
ClientID: "robot-client-id",
ClientSecret: "robot-client-secret",
ImageURLPrefix: f.server.URL + "/dingtalk/i/",
TokenURL: f.server.URL + "/gettoken",
UploadURL: f.server.URL + "/media/upload",
HTTPClient: f.server.Client(),
}
if mutate != nil {
mutate(&opts)
}
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil {
t.Fatalf("NewCardImageMediaRewriter: %v", err)
}
if rewriter == nil {
t.Fatal("expected a configured rewriter")
}
return rewriter
}
func (f *cardImageTestServer) counts() (token, upload int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.tokenCalls, f.uploadCalls
}
func (f *cardImageTestServer) uploadedByteCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.uploadedBytes
}
func (f *cardImageTestServer) imageURL(id string) string {
return f.server.URL + "/dingtalk/i/" + id
}
func (f *cardImageTestServer) waitForUpload(t *testing.T) {
t.Helper()
select {
case <-f.uploadedC:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the media upload")
}
}
func TestCardImageMediaRewritesOwnImageAndCachesMediaID(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("看图:![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
want := fmt.Sprintf("看图:![设备图](%s)", cardImageTestMediaID)
if got != want {
t.Fatalf("got=%q want=%q", got, want)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
if got := fixture.uploadedByteCount(); got != len(fixture.imageBytes) {
t.Fatalf("uploaded bytes=%d want %d", got, len(fixture.imageBytes))
}
// A second rewrite must reuse the cached media id without another upload,
// and the token must only be fetched once.
again := rewriter.RewriteForCard(context.Background(), content, true)
if again != want {
t.Fatalf("cached rewrite got=%q want=%q", again, want)
}
tokens, uploads := fixture.counts()
if uploads != 1 || tokens != 1 {
t.Fatalf("tokens=%d uploads=%d want 1/1", tokens, uploads)
}
}
func TestCardImageMediaPartialFramesNeverBlockAndPickUpLater(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
// A streaming frame keeps the external URL but starts the upload.
if got := rewriter.RewriteForCard(context.Background(), content, false); got != content {
t.Fatalf("streaming frame must stay unchanged, got=%q", got)
}
fixture.waitForUpload(t)
deadline := time.Now().Add(2 * time.Second)
for {
got := rewriter.RewriteForCard(context.Background(), content, false)
if strings.Contains(got, cardImageTestMediaID) {
break
}
if time.Now().After(deadline) {
t.Fatalf("media id never became available, got=%q", got)
}
time.Sleep(10 * time.Millisecond)
}
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1", uploads)
}
}
func TestCardImageMediaIgnoresForeignAndIncompleteReferences(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := strings.Join([]string{
fmt.Sprintf("![外部图](https://example.com/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![流水线](%s)", fixture.imageURL("not-a-stable-id")),
fmt.Sprintf("![未闭合](%s", fixture.imageURL(cardImageTestID)),
fmt.Sprintf("纯链接 %s", fixture.imageURL(cardImageTestID)),
}, "\n")
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("content must be untouched, got=%q", got)
}
tokens, uploads := fixture.counts()
if tokens != 0 || uploads != 0 {
t.Fatalf("tokens=%d uploads=%d want 0/0", tokens, uploads)
}
}
func TestCardImageMediaFallsBackWhenUploadFails(t *testing.T) {
fixture := newCardImageTestServer(t)
fixture.uploadErrcode = 40035
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("failed upload must keep the external reference, got=%q", got)
}
}
func TestCardImageMediaFallsBackForOversizeImages(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.MaxBytes = 128
})
content := fmt.Sprintf("![大图](%s/big)", fixture.imageURL(cardImageTestID))
if got := rewriter.RewriteForCard(context.Background(), content, true); got != content {
t.Fatalf("oversize image must keep the external reference, got=%q", got)
}
if _, uploads := fixture.counts(); uploads != 0 {
t.Fatalf("uploads=%d want 0", uploads)
}
}
func TestCardImageMediaFetchUsesLoopbackPrefix(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
opts.ImageURLPrefix = "https://weknora-api.invalid/dingtalk/i/"
opts.FetchURLPrefix = fixture.server.URL + "/images/"
})
content := fmt.Sprintf("![设备图](https://weknora-api.invalid/dingtalk/i/%s)", cardImageTestID)
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("loopback fetch must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/images/") {
t.Fatalf("fetched=%v want one loopback path", fixture.fetched)
}
}
func TestCardImageMediaDeduplicatesConcurrentUploads(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rewriter.RewriteForCard(context.Background(), content, true)
}()
}
wg.Wait()
if _, uploads := fixture.counts(); uploads != 1 {
t.Fatalf("uploads=%d want 1 for concurrent callers", uploads)
}
}
func TestCardImageMediaRewriterRequiresFullConfiguration(t *testing.T) {
for name, opts := range map[string]CardImageMediaOptions{
"missing client id": {ClientSecret: "s", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing secret": {ClientID: "id", ImageURLPrefix: "https://img.example/dingtalk/i/"},
"missing prefix": {ClientID: "id", ClientSecret: "s"},
} {
rewriter, err := NewCardImageMediaRewriter(opts)
if err != nil || rewriter != nil {
t.Fatalf("%s: rewriter=%v err=%v want nil/nil", name, rewriter, err)
}
}
}
func TestStreamingCardDeliverySendsMediaIDAndKeepsMarkdownExternal(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("CR20V 接线如下:\n\n![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-card"}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if len(card.updates) == 0 {
t.Fatal("no card update was recorded")
}
final := card.updates[len(card.updates)-1]
if !final.isFinalize {
t.Fatalf("last update must finalize the card, got %+v", final)
}
if !strings.Contains(final.content, cardImageTestMediaID) {
t.Fatalf("final card body must carry the media id, got=%q", final.content)
}
if strings.Contains(final.content, fixture.server.URL) {
t.Fatalf("final card body must not keep the external URL, got=%q", final.content)
}
}
func TestCardFinalizeFallbackKeepsExternalImageURL(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, nil)
answer := fmt.Sprintf("![工控柜接线图](%s)", fixture.imageURL(cardImageTestID))
card := &fakeStreamingCard{startID: "track-image-fallback", updateErr: errors.New("card unavailable")}
qa := &fakeStreamingQA{chunks: []string{answer}, result: core.QAResult{Answer: answer}}
reply := &fakeReply{}
h := newTestHandlerWithStreaming(t, qa, reply, card)
h.cardImageMedia = rewriter
if err := h.Handle(context.Background(), directTextCallback()); err != nil {
t.Fatalf("Handle: %v", err)
}
if !reply.called {
t.Fatal("markdown fallback was not sent")
}
if !strings.Contains(reply.lastText, fixture.server.URL) {
t.Fatalf("markdown fallback must keep the external URL, got=%q", reply.lastText)
}
if strings.Contains(reply.lastText, cardImageTestMediaID) {
t.Fatalf("markdown fallback must not carry a media id, got=%q", reply.lastText)
}
}
func TestCardImageMediaFallsBackToPublicURLWhenLoopbackMisses(t *testing.T) {
fixture := newCardImageTestServer(t)
rewriter := fixture.resolver(t, func(opts *CardImageMediaOptions) {
// The loopback route exists but does not serve this object.
opts.FetchURLPrefix = fixture.server.URL + "/missing/"
})
content := fmt.Sprintf("![设备图](%s)", fixture.imageURL(cardImageTestID))
got := rewriter.RewriteForCard(context.Background(), content, true)
if !strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("public fallback must still produce a media id, got=%q", got)
}
fixture.mu.Lock()
defer fixture.mu.Unlock()
if len(fixture.fetched) != 1 || !strings.HasPrefix(fixture.fetched[0], "/dingtalk/i/") {
t.Fatalf("fetched=%v want a single public fetch after the loopback miss", fixture.fetched)
}
}
func TestCardImageRefCountsSplitMediaAndExternalReferences(t *testing.T) {
content := strings.Join([]string{
fmt.Sprintf("![图一](%s)", cardImageTestMediaID),
fmt.Sprintf("![图二](https://weknora-api.dobot.cn/dingtalk/i/%s)", cardImageTestID),
fmt.Sprintf("![图三](%s)", cardImageTestMediaID),
}, "\n")
total, external, media := cardImageRefCounts(content)
if total != 3 || external != 1 || media != 2 {
t.Fatalf("total=%d external=%d media=%d want 3/1/2", total, external, media)
}
if total, external, media := cardImageRefCounts("无图回答"); total != 0 || external != 0 || media != 0 {
t.Fatalf("plain text must report no image refs, got %d/%d/%d", total, external, media)
}
}
func TestCardUpdateDiagnosticReportsImageReferenceShape(t *testing.T) {
content := fmt.Sprintf("正文\n\n![图](%s)", cardImageTestMediaID)
got := formatCardUpdateDiagnostic(content, true, false)
if !strings.Contains(got, "image_refs=1") || !strings.Contains(got, "media_image_refs=1") || !strings.Contains(got, "external_image_refs=0") {
t.Fatalf("diagnostic must report image reference shape: %q", got)
}
if strings.Contains(got, cardImageTestMediaID) {
t.Fatalf("diagnostic must not leak the media id: %q", got)
}
}
+111
View File
@@ -1289,6 +1289,117 @@ func TestOAuthAcrossStartAndUpdates(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// Test: PC wide-screen layout config is delivered through the reserved
// cardParamMap system field, never as a sibling of cardParamMap
// ---------------------------------------------------------------------------
func TestCardWideScreenAutoLayoutParam(t *testing.T) {
cases := []struct {
name string
req CardRequest
}{
{
name: "direct",
req: CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
{
name: "group",
req: CardRequest{
ConversationType: core.ConversationGroup,
ExternalChatID: "chat-789",
ExternalUserID: "user-456",
Content: "Hello from AI",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
if _, err := sender.StartCard(context.Background(), tc.req); err != nil {
t.Fatalf("StartCard: %v", err)
}
cardData, ok := ts.lastCreateBody["cardData"].(map[string]any)
if !ok {
t.Fatalf("cardData missing or wrong type: %#v", ts.lastCreateBody["cardData"])
}
// Regression guard: a complex-typed value placed next to
// cardParamMap leaves the template variable unassigned and the
// card silently renders in the default narrow layout.
for _, badKey := range []string{"config", cardWideScreenJSONKey} {
if _, present := cardData[badKey]; present {
t.Fatalf("%q must not be a sibling of cardParamMap: %#v", badKey, cardData)
}
}
paramMap, ok := cardData["cardParamMap"].(map[string]any)
if !ok {
t.Fatalf("cardParamMap missing or wrong type: %#v", cardData["cardParamMap"])
}
raw, ok := paramMap[cardWideScreenJSONKey].(string)
if !ok {
t.Fatalf("%s must be a JSON string, got %#v", cardWideScreenJSONKey, paramMap[cardWideScreenJSONKey])
}
var decoded struct {
Config struct {
AutoLayout bool `json:"autoLayout"`
} `json:"config"`
}
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
t.Fatalf("%s is not valid JSON (%q): %v", cardWideScreenJSONKey, raw, err)
}
if !decoded.Config.AutoLayout {
t.Fatalf("config.autoLayout=false in %q, want true", raw)
}
// Plain string variables must keep going through cardParamMap directly.
if paramMap["content"] != tc.req.Content {
t.Errorf("content=%v, want %v", paramMap["content"], tc.req.Content)
}
})
}
}
// ---------------------------------------------------------------------------
// Test: streaming updates only carry content (no layout config)
// ---------------------------------------------------------------------------
func TestCardWideScreenNotSentOnStreamingUpdate(t *testing.T) {
ts := newCardTestServer()
defer ts.Close()
sender, err := newCardSenderForTest("id", "secret", "tpl", ts.Client(), ts.URL()+"/token", ts.URL()+"/create")
if err != nil {
t.Fatal(err)
}
outTrackID, err := sender.StartCard(context.Background(), CardRequest{
ConversationType: core.ConversationDirect,
ExternalUserID: "user-456",
Content: "placeholder",
})
if err != nil {
t.Fatalf("StartCard: %v", err)
}
if err := sender.UpdateCard(context.Background(), outTrackID, "streamed chunk", false, false); err != nil {
t.Fatalf("UpdateCard: %v", err)
}
if _, present := ts.lastStreamBody[cardWideScreenJSONKey]; present {
t.Fatalf("streaming body must not carry %s: %#v", cardWideScreenJSONKey, ts.lastStreamBody)
}
if ts.lastStreamBody["key"] != "content" {
t.Errorf("stream key=%v, want content", ts.lastStreamBody["key"])
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -8,7 +8,11 @@ import (
const displayReferenceMarkerPattern = `\[\[ref[\t ]+id[\t ]*=[\t ]*["']c[0-9]+["'][\t ]*\]\]`
var (
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
displayReferenceMarkerRE = regexp.MustCompile(displayReferenceMarkerPattern)
// bareInternalSourceHandleRE matches WeKnora request-local source aliases
// leaked by a model (for example [d21], [c12], or [w3]). It intentionally
// does not match ordinary Markdown links or arbitrary bracketed content.
bareInternalSourceHandleRE = regexp.MustCompile(`(?i)\[(?:[dcw][0-9]+)(?:[ ]*[,,][ ]*(?:[dcw][0-9]+))*\]`)
leadingReferenceGroupColonRE = regexp.MustCompile(`(?m)^[ ]*(?:` + displayReferenceMarkerPattern + `[ ]*)+[::][ ]*`)
spacedReferenceGroupRE = regexp.MustCompile(`[ ]+(?:` + displayReferenceMarkerPattern + `[ ]*)+`)
spaceBeforeDisplayPunctuation = regexp.MustCompile(`[ ]+([,。;:!?、,.;:!?])`)
@@ -19,12 +23,13 @@ func sanitizeDisplayAnswer(content string) string {
if index := strings.LastIndex(content, "[[ref"); index >= 0 && !strings.Contains(content[index:], "]]") {
content = content[:index]
}
if !displayReferenceMarkerRE.MatchString(content) {
if !displayReferenceMarkerRE.MatchString(content) && !bareInternalSourceHandleRE.MatchString(content) {
return content
}
content = leadingReferenceGroupColonRE.ReplaceAllString(content, "")
content = spacedReferenceGroupRE.ReplaceAllString(content, " ")
content = displayReferenceMarkerRE.ReplaceAllString(content, "")
content = bareInternalSourceHandleRE.ReplaceAllString(content, "")
content = spaceBeforeDisplayPunctuation.ReplaceAllString(content, "$1")
content = spaceAfterDisplayColon.ReplaceAllString(content, "$1")
return content
@@ -34,10 +34,20 @@ func TestSanitizeDisplayAnswerRemovesInternalReferenceMarkers(t *testing.T) {
want: "Feature A remains supported.",
},
{
name: "unrelated double bracket syntax is preserved",
name: "unrelated double bracket syntax and ordinary link are preserved",
input: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
want: "请保留 [[wiki|页面名称]] 和 [普通链接](https://example.com)。",
},
{
name: "bare internal source handles",
input: "Nova支持虚拟控制器离线仿真[d21],并可搭配VX500[w3]。",
want: "Nova支持虚拟控制器离线仿真,并可搭配VX500。",
},
{
name: "grouped bare internal source handles",
input: "该结论由多个资料支持 [d21, c12,w3]。",
want: "该结论由多个资料支持。",
},
{
name: "content without references remains byte for byte unchanged",
input: "Protocol: value ; keep spacing\n indented",
@@ -30,4 +30,4 @@ func TestEffectiveDownloadCodesEmpty(t *testing.T) {
if codes := effectiveDownloadCodes(BotCallback{}); len(codes) != 0 {
t.Fatalf("codes=%v, want empty", codes)
}
}
}
+6 -2
View File
@@ -237,7 +237,11 @@ func (s *SupportGroupSender) SendTest(ctx context.Context) error {
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
title = "知识库未覆盖提醒"
label := "知识库未覆盖提醒"
if notif.TriggerType == coverage.SupportTriggerFeedbackDown {
label = "回答被点踩提醒"
}
title = label
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
@@ -246,7 +250,7 @@ func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification)
}
var b strings.Builder
b.WriteString("## 【知识库未覆盖提醒】\n\n")
b.WriteString("## 【" + label + "】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
@@ -0,0 +1,24 @@
package dingtalk
import (
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
)
func TestSupportGroupSenderRendersFeedbackDownTrigger(t *testing.T) {
notification := newTestNotification()
notification.TriggerType = coverage.SupportTriggerFeedbackDown
title, text := (&SupportGroupSender{}).renderMarkdown(notification)
if title != "回答被点踩提醒" || !strings.Contains(text, "【回答被点踩提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
func TestSupportGroupSenderDefaultsHistoricalTriggerToCoverage(t *testing.T) {
title, text := (&SupportGroupSender{}).renderMarkdown(newTestNotification())
if title != "知识库未覆盖提醒" || !strings.Contains(text, "【知识库未覆盖提醒】") {
t.Fatalf("title=%q text=%q", title, text)
}
}
+141 -14
View File
@@ -11,6 +11,8 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundtext"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
@@ -112,6 +114,27 @@ type SettingsRouter interface {
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
}
type ReasoningMessage struct {
TenantKey string
Channel string
StaffID string
Text string
ConversationID string
}
type ReasoningDecision struct {
Handled bool
Reply string
Query string
AgentID string
Execution reasoningmode.ExecutionMode
Expired bool
}
type ReasoningRouter interface {
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, error)
}
type LanguagePreferenceResolver interface {
GetPreference(context.Context, string, string, string) (string, error)
}
@@ -135,8 +158,22 @@ type HandlerOptions struct {
CardSender FinalCardSender
Directory UserDirectory
OutboundRewriter OutboundContentRewriter
// CardImageMedia rewrites our own published image URLs in card bodies into
// DingTalk media ids; nil keeps external URLs as-is.
CardImageMedia CardImageMediaRewriter
SettingsRouter SettingsRouter
ReasoningRouter ReasoningRouter
LanguageResolver LanguagePreferenceResolver
// OutboundRewriteTimeout bounds the outbound image rewrite stage (fetch +
// publish). Answers carrying many large screenshots need more than the
// delivery timeout, otherwise the remaining images are dropped. Zero keeps
// finalDeliveryTimeout.
OutboundRewriteTimeout time.Duration
// WideTableGrouping selects how over-wide answer tables are adapted, per
// frame: "" or "on" groups on every frame (historical), "off" never groups,
// "stream" groups only while the answer is still streaming and keeps the
// real table on the final frame. See tableGroupingForMode.
WideTableGrouping string
}
type Handler struct {
@@ -148,11 +185,22 @@ type Handler struct {
card FinalCardSender
directory UserDirectory
outboundRewriter OutboundContentRewriter
cardImageMedia CardImageMediaRewriter
settingsRouter SettingsRouter
reasoningRouter ReasoningRouter
languageResolver LanguagePreferenceResolver
now func() time.Time
cardUpdateInterval time.Duration
finalDeliveryTimeout time.Duration
// outboundRewriteTimeout applies to the image rewrite stage only; zero
// falls back to finalDeliveryTimeout.
outboundRewriteTimeout time.Duration
// groupTablesPartial / groupTablesFinal decide per frame whether an
// over-wide table becomes a grouped list. Grouped lists survive the
// streaming frames where a table is still incomplete; the final frame can
// keep the real table, which the PC wide-screen card lays out itself.
groupTablesPartial bool
groupTablesFinal bool
}
// NewHandler accepts at most one image downloader. A nil/omitted downloader
@@ -184,6 +232,8 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
if reply == nil {
return nil, errors.New("dingtalk handler: markdown sender is required")
}
groupPartial, groupFinal := tableGroupingForMode(opts.WideTableGrouping)
return &Handler{
cfg: cfg,
qa: qa,
@@ -193,11 +243,17 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
card: opts.CardSender,
directory: opts.Directory,
outboundRewriter: opts.OutboundRewriter,
cardImageMedia: opts.CardImageMedia,
settingsRouter: opts.SettingsRouter,
reasoningRouter: opts.ReasoningRouter,
languageResolver: opts.LanguageResolver,
now: time.Now,
cardUpdateInterval: 500 * time.Millisecond,
finalDeliveryTimeout: defaultHTTPTimeout,
outboundRewriteTimeout: opts.OutboundRewriteTimeout,
groupTablesPartial: groupPartial,
groupTablesFinal: groupFinal,
}, nil
}
@@ -334,6 +390,26 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
}
if h.reasoningRouter != nil {
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
Text: req.Query, ConversationID: req.ExternalChatID,
})
if err != nil {
return fmt.Errorf("dingtalk reasoning mode: %w", err)
}
if decision.Handled {
if strings.TrimSpace(decision.Reply) == "" {
return nil
}
return h.sendReply(ctx, webhookURL, decision.Reply)
}
req.Query = strings.TrimSpace(decision.Query)
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
req.ReasoningExecution = string(decision.Execution)
req.ReasoningExpired = decision.Expired
}
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
}
@@ -395,7 +471,7 @@ func (h *Handler) handleNonStreaming(ctx context.Context, webhookURL string, req
ConversationType: req.ConversationType,
ExternalUserID: req.ExternalUserID,
ExternalChatID: req.ExternalChatID,
Content: content,
Content: h.rewriteCardImages(ctx, content, true),
}
if outTrackID, cardErr := h.card.SendFinalCard(ctx, cardReq); cardErr == nil {
h.recordDelivery(ctx, result, core.DeliverySent, core.DeliveryMethodCard, outTrackID, "")
@@ -445,13 +521,16 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
// Streaming observer with the configured update throttle (500ms by default).
var lastUpdate time.Time
var lastPartialContent string
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
observer := func(fullContent string) {
if strings.TrimSpace(fullContent) == "" {
visible := feed.OnAnswer(fullContent)
if strings.TrimSpace(visible) == "" {
return
}
fullContent = decorateReasoningPartial(visible, req.ReasoningExecution)
now := h.now()
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
partialContent := h.sanitizeOutboundPartial(fullContent)
partialContent := h.sanitizeOutboundPartial(ctx, fullContent)
if strings.TrimSpace(partialContent) == "" || partialContent == lastPartialContent {
return
}
@@ -505,16 +584,26 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
pump := newCardUpdatePump(ctx, h.cardUpdateInterval, func(updateCtx context.Context, content string) error {
return streamingCard.UpdateCard(updateCtx, outTrackID, content, false, false)
})
answerStarted := false
feed := reasoningmode.NewPartialFeed(req.ReasoningExecution)
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
answerStarted = true
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
if event.ResponseType == "answer" {
visible := feed.OnAnswer(event.FullAnswer)
if strings.TrimSpace(visible) == "" {
return
}
visibleAnswer := decorateReasoningPartial(visible, req.ReasoningExecution)
if partialContent := h.sanitizeOutboundPartial(ctx, visibleAnswer); strings.TrimSpace(partialContent) != "" {
pump.Offer(partialContent)
}
return
}
if answerStarted {
switch event.ResponseType {
case "tool_call", "tool_result", "thinking", "reflection":
// A reasoning step ended: the next narrative segment starts a new
// line so the card shows the reasoning process line by line.
feed.StepBoundary()
}
if feed.Started() {
return
}
if content := renderProgressEvent(event); content != "" {
@@ -532,6 +621,16 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
}
func decorateReasoningPartial(content, execution string) string {
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
if !strings.HasPrefix(content, "【智能推理模式】") {
return "【智能推理模式】\n\n" + content
}
}
return content
}
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
@@ -545,7 +644,13 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
// Final card convergence must still be attempted when the QA context was
// canceled or timed out; otherwise the card can remain "processing".
finalCtx, cancelFinal := context.WithTimeout(context.WithoutCancel(ctx), timeout)
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, content, true, isError)
cardContent := content
if !isError {
// Card bodies render DingTalk media ids only, so the images are
// converted here while the markdown fallback keeps the external URLs.
cardContent = h.rewriteCardImages(finalCtx, content, true)
}
updateErr := streamingCard.UpdateCard(finalCtx, outTrackID, cardContent, true, isError)
cancelFinal()
if updateErr == nil {
if !isError {
@@ -571,24 +676,46 @@ func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string,
}
func (h *Handler) rewriteOutboundContent(ctx context.Context, content string) string {
content = outboundtext.NormalizeMath(content)
options := tableNormalizeOptions{groupWideTables: h.groupTablesFinal}
if h.outboundRewriter == nil {
return sanitizeDisplayAnswer(content)
return normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
}
timeout := h.outboundRewriteTimeout
if timeout <= 0 {
timeout = h.finalDeliveryTimeout
}
timeout := h.finalDeliveryTimeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
rewriteCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
return sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content))
return normalizeMarkdownTables(sanitizeDisplayAnswer(h.outboundRewriter.Rewrite(rewriteCtx, content)), options)
}
func (h *Handler) sanitizeOutboundPartial(content string) string {
func (h *Handler) sanitizeOutboundPartial(ctx context.Context, content string) string {
content = outboundtext.NormalizeMath(content)
sanitizer, ok := h.outboundRewriter.(OutboundPartialSanitizer)
if ok {
content = sanitizer.SanitizePartial(content)
}
return stableMarkdownPrefix(sanitizeDisplayAnswer(content))
options := tableNormalizeOptions{groupWideTables: h.groupTablesPartial}
content = normalizeMarkdownTables(sanitizeDisplayAnswer(content), options)
return h.rewriteCardImages(ctx, stableMarkdownPrefix(content), false)
}
// rewriteCardImages converts image references in card content into DingTalk
// media ids. It is a no-op when the feature is not configured and never fails
// the caller: unuploadable images keep their original external reference.
func (h *Handler) rewriteCardImages(ctx context.Context, content string, wait bool) string {
if h.cardImageMedia == nil || strings.TrimSpace(content) == "" {
return content
}
rewritten := h.cardImageMedia.RewriteForCard(ctx, content, wait)
if strings.TrimSpace(rewritten) == "" {
return content
}
return rewritten
}
func (h *Handler) recordDelivery(ctx context.Context, result core.QAResult, status, method, providerMessageID, failureCode string) {
@@ -77,4 +77,4 @@ func TestPublishQuestionImageFailsClosedWithoutURL(t *testing.T) {
if got := h2.publishQuestionImage(context.Background(), image); got != "" {
t.Fatalf("expected empty URL on publish error, got %q", got)
}
}
}
@@ -0,0 +1,40 @@
package dingtalk
import (
"context"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/outboundimage"
)
type diagFetcher struct{}
func (diagFetcher) Fetch(ctx context.Context, ref string) ([]byte, string, error) {
return []byte("PNGDATA"), "image/png", nil
}
type diagPublisher struct{}
func (diagPublisher) Publish(ctx context.Context, data []byte, contentType string) (string, error) {
return "https://weknora-api-test.dobot.cn/dingtalk/i/a87f73b8-15cb-546e-ba53-bf93dade916f", nil
}
// The outbound rewrite must publish a resource image and keep it in the card
// body. A reference whose handle contains "_x" (…ZM_pltQ) used to be mangled by
// the math normalizer, which made the image unpublishable and the sanitizer
// drop the whole line.
func TestRewriteOutboundContentKeepsPublishedImage(t *testing.T) {
content := "![CR10A 末端负载曲线](resource://efVTXx0gY9Lk2vuZM_pltQ)\n\n公式:$r = \\sqrt{X^2 + Y^2}$\n"
h := &Handler{
outboundRewriter: outboundimage.NewRewriter(diagFetcher{}, diagPublisher{}, 50),
groupTablesFinal: true,
}
out := h.rewriteOutboundContent(context.Background(), content)
if !strings.Contains(out, "![CR10A 末端负载曲线](https://weknora-api-test.dobot.cn/dingtalk/i/") {
t.Fatalf("published image missing from card content: %q", out)
}
if !strings.Contains(out, "r = √(X² + Y²)") {
t.Fatalf("formula not converted: %q", out)
}
}
@@ -0,0 +1,57 @@
package dingtalk
import (
"strings"
"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)
}
}
func TestReasoningStreamingShowsStepsLineByLineThenDropsThemForBody(t *testing.T) {
execution := string(reasoningmode.ExecutionReasoning)
first := "我先确认一下设备的型号和手册。"
second := "手册里有关节扭矩表,让我核对一下。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |"
feed := reasoningmode.NewPartialFeed(execution)
if got, want := decorateReasoningPartial(feed.OnAnswer(first), execution), "【智能推理模式】\n\n"+first; got != want {
t.Fatalf("step1 got=%q want=%q", got, want)
}
feed.StepBoundary()
if got, want := decorateReasoningPartial(feed.OnAnswer(first+second), execution), "【智能推理模式】\n\n"+first+"\n\n"+second; got != want {
t.Fatalf("step2 got=%q want=%q", got, want)
}
answer := first + second + "\n\n" + body
if got, want := decorateReasoningPartial(feed.OnAnswer(answer), execution), "【智能推理模式】\n\n"+body; got != want {
t.Fatalf("refresh got=%q want=%q", got, want)
}
got := feed.OnAnswer(answer + "\n| J1 | 98 N·m |")
if strings.Contains(got, first) || !strings.Contains(got, "J1") {
t.Fatalf("reasoning must stay hidden while the body streams, got=%q", got)
}
}
func TestQuickStreamingKeepsAccumulatedAnswer(t *testing.T) {
feed := reasoningmode.NewPartialFeed(string(reasoningmode.ExecutionQuick))
if got := feed.OnAnswer("快速答案"); got != "快速答案" {
t.Fatalf("got=%q", got)
}
feed.StepBoundary()
if got := feed.OnAnswer("快速答案追加"); got != "快速答案追加" {
t.Fatalf("got=%q", got)
}
}
@@ -0,0 +1,81 @@
package dingtalk
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
)
type fakeReasoningRouter struct {
called bool
message ReasoningMessage
decision ReasoningDecision
err error
}
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
f.called = true
f.message = message
return f.decision, f.err
}
func TestReasoningControlCommandBypassesQA(t *testing.T) {
qa := &fakeQA{}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think on"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
t.Fatalf("router=%+v", router)
}
if qa.called {
t.Fatal("reasoning control command must bypass QA")
}
if !reply.called || reply.lastText != "mode enabled" {
t.Fatalf("reply=%+v", reply)
}
}
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
reply := &fakeReply{}
router := &fakeReasoningRouter{decision: ReasoningDecision{
Query: "分析根因", AgentID: "builtin-smart-reasoning",
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
}}
handler, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
qa,
reply,
HandlerOptions{ReasoningRouter: router},
)
if err != nil {
t.Fatal(err)
}
callback := directTextCallback()
callback.Text = "/think 分析根因"
if err = handler.Handle(context.Background(), callback); err != nil {
t.Fatal(err)
}
if !qa.called {
t.Fatal("single-turn reasoning must call QA")
}
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
t.Fatalf("QA request=%+v", qa.lastReq)
}
}
@@ -0,0 +1,255 @@
package dingtalk
import (
"strings"
"unicode"
)
// maxTableCellVisualWidth is the DingTalk-safe cell budget. CJK counts as 2
// display columns; a cell wider than this tends to collapse the column layout
// (one character per line) in the streaming card renderer.
const maxTableCellVisualWidth = 32
// tableNormalizeOptions controls how a final answer is adapted to the card
// renderer. The zero value keeps the historical behaviour.
type tableNormalizeOptions struct {
// groupWideTables rewrites tables containing over-wide cells into grouped
// lists (bold row heading + "- header:value" lines). Disable it when the
// client lays out wide tables itself, so the answer keeps its real table.
groupWideTables bool
}
func defaultTableNormalizeOptions() tableNormalizeOptions {
return tableNormalizeOptions{groupWideTables: true}
}
// tableGroupingForMode maps the configured mode to the two frames the card
// renders. An unknown or empty mode keeps the historical behaviour (group on
// every frame), so an unset environment never changes rendering.
//
// ""/"on" group on every frame
// "off" never group: real tables everywhere
// "stream" group while streaming, real table on the final frame
func tableGroupingForMode(mode string) (partial, final bool) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "off", "0", "false", "no":
return false, false
case "stream", "streaming", "partial":
return true, false
default:
return true, true
}
}
// normalizeWideMarkdownTables makes final answers render reliably on DingTalk
// cards without changing the knowledge itself. It (1) replaces <br> inside
// table cells with a full-width separator, and (2) rewrites any table that
// still contains an over-wide cell into a grouped list — a bold row heading
// followed by one "- header:value" line per column. Compact tables pass
// through unchanged. Tables inside code fences are never touched.
func normalizeWideMarkdownTables(content string) string {
return normalizeMarkdownTables(content, defaultTableNormalizeOptions())
}
func normalizeMarkdownTables(content string, options tableNormalizeOptions) string {
if !strings.Contains(content, "|") {
return content
}
lines := splitMarkdownStreamLines(content)
out := make([]string, 0, len(lines))
for i := 0; i < len(lines); {
line := lines[i]
if line.inFence {
out = append(out, line.text)
i++
continue
}
if isTableHeaderCandidate(line.text) && i+1 < len(lines) &&
isCompleteTableDelimiter(lines[i+1].text) {
end := i + 1
for end+1 < len(lines) && !lines[end+1].inFence &&
strings.HasPrefix(strings.TrimSpace(lines[end+1].text), "|") {
end++
}
out = append(out, normalizeTableBlock(lines[i:end+1], options)...)
i = end + 1
continue
}
out = append(out, line.text)
i++
}
return strings.Join(out, "\n")
}
func normalizeTableBlock(rows []markdownStreamLine, options tableNormalizeOptions) []string {
cells := make([][]string, 0, len(rows))
for idx, row := range rows {
if idx == 1 { // delimiter row
continue
}
cells = append(cells, splitTableRow(row.text))
}
maxWidth := 0
for _, row := range cells {
for _, cell := range row {
if w := visualWidth(cell); w > maxWidth {
maxWidth = w
}
}
}
header := cells[0]
if maxWidth <= maxTableCellVisualWidth || !options.groupWideTables {
// Compact tables keep their exact original bytes unless a cell carries
// <br>, which the card renderer mishandles inside cells. The same path
// serves over-wide tables when grouping is disabled: the table stays a
// table and only <br> is replaced.
if !blockHasBreak(rows) {
rebuilt := make([]string, len(rows))
for i, row := range rows {
rebuilt[i] = row.text
}
return rebuilt
}
var rebuilt []string
for idx, row := range cells {
joined := "| " + strings.Join(row, " | ") + " |"
if idx == 0 {
rebuilt = append(rebuilt, joined)
delims := make([]string, len(row))
for c := range row {
delims[c] = "---"
}
rebuilt = append(rebuilt, "| "+strings.Join(delims, " | ")+" |")
} else {
rebuilt = append(rebuilt, joined)
}
}
return rebuilt
}
var out []string
for gi, row := range cells[1:] {
if gi > 0 {
out = append(out, "")
}
var title string
var rest []string
for c, cell := range row {
if c == 0 {
title = cell
continue
}
label := strings.TrimSpace(header[min(c, len(header)-1)])
if label == "" {
label = "第" + itoa(c+1) + "项"
}
if cell == "" {
continue
}
rest = append(rest, "- "+label+":"+cell)
}
if title == "" {
title = label2(row[0])
}
out = append(out, "**"+strings.Trim(title, "* ")+"**")
out = append(out, rest...)
}
return out
}
func blockHasBreak(rows []markdownStreamLine) bool {
for _, row := range rows {
lower := strings.ToLower(row.text)
if strings.Contains(lower, "<br") {
return true
}
}
return false
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := splitEscaped(trimmed)
for i := range parts {
cell := strings.TrimSpace(parts[i])
cell = strings.ReplaceAll(cell, "<br>", ";")
cell = strings.ReplaceAll(cell, "<br/>", ";")
cell = strings.ReplaceAll(cell, "<br />", ";")
cell = strings.ReplaceAll(cell, "\\|", "|")
parts[i] = strings.TrimSpace(cell)
}
return parts
}
// splitEscaped splits on unescaped pipe separators.
func splitEscaped(s string) []string {
var parts []string
var b strings.Builder
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 < len(s) && s[i+1] == '|' {
b.WriteString("\\|")
i++
continue
}
b.WriteByte(s[i])
case '|':
parts = append(parts, b.String())
b.Reset()
default:
b.WriteByte(s[i])
}
}
parts = append(parts, b.String())
return parts
}
func label2(cell string) string { return strings.Trim(cell, "* ") }
func min(a, b int) int {
if a < b {
return a
}
return b
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var d []byte
for n > 0 {
d = append([]byte{byte('0' + n%10)}, d...)
n /= 10
}
return string(d)
}
func visualWidth(s string) int {
w := 0
for _, r := range s {
if r == '\n' {
if w > 0 {
w++ // separator counted conservatively
}
continue
}
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Katakana, r) ||
unicode.Is(unicode.Hiragana, r) || fullwidthPunct(r) {
w += 2
continue
}
w++
}
return w
}
func fullwidthPunct(r rune) bool {
switch r {
case ',', '。', ':', ';', '(', ')', '、', '【', '】', '《', '》', '“', '”', '‘', '’', ' ':
return true
}
return false
}
@@ -0,0 +1,167 @@
package dingtalk
import (
"context"
"strings"
"testing"
)
func TestNormalizeKeepsCompactTableUnchanged(t *testing.T) {
content := "重量如下:\n\n| 产品型号 | 净重 (本体重量) | 毛重 (含包装) |\n| :--- | :--- | :--- |\n| CR3A | 16.5 kg / 17.5 kg | 28.5 kg |\n| CR5A | 25 kg | 37 kg |\n\n结束"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("compact table must pass through untouched, got %q", got)
}
}
func TestNormalizeConvertsWideTableToGroupedList(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| 对比项") || strings.Contains(got, "<br>") {
t.Fatalf("wide table must be converted and <br> removed, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
// compact row (控制柜) from the same table is now a list entry too
if !strings.Contains(got, "**关节最大速度**\n- CR 系列:") || !strings.Contains(got, "- CRA 系列:") {
t.Fatalf("row key must become bold heading with header-labelled values, got %q", got)
}
if !strings.Contains(got, "**控制柜**\n- CR 系列:CC162\n- CRA 系列:CC262") {
t.Fatalf("every row must become a group, got %q", got)
}
if strings.Contains(got, "J1: 180°/s") == false {
t.Fatalf("cell content must be preserved, got %q", got)
}
}
func TestNormalizeReplacesBrKeepsTableWhenCellsShort(t *testing.T) {
content := "| A | B |\n| --- | --- |\n| 短 | 行一<br>行二 |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must be replaced, got %q", got)
}
if !strings.Contains(got, "| A | B |") || !strings.Contains(got, "行一;行二") {
t.Fatalf("short table must stay a table with ; separator, got %q", got)
}
}
func TestNormalizeSkipsFencedTables(t *testing.T) {
long := strings.Repeat("长", 30)
content := "```\n| 对比 | " + long + " |\n| --- | --- |\n| a | b |\n```\n正文"
got := normalizeWideMarkdownTables(content)
if got != content {
t.Fatalf("fenced content must never be rewritten, got %q", got)
}
}
func TestNormalizeKeepsWideTableWhenGroupingDisabled(t *testing.T) {
longCell := "J1: 180°/s (CR3A/5A/7A), 150°/s (CR10A/12A/16A)<br>J2: 180°/s (CR3A/5A/7A)"
content := "差异如下:\n\n| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " + longCell + " | J1: 180°/s (CR3A/5A/7A)<br>J2: 更多参数说明 |\n| 控制柜 | CC162 | CC262 |\n\n结论"
options := defaultTableNormalizeOptions()
options.groupWideTables = false
got := normalizeMarkdownTables(content, options)
if !strings.Contains(got, "| 对比项 | CR 系列 | CRA 系列 |") || !strings.Contains(got, "| **关节最大速度** |") {
t.Fatalf("wide table must stay a table when grouping is disabled, got %q", got)
}
if strings.Contains(got, "**关节最大速度**\n- CR 系列:") {
t.Fatalf("grouped list must not be produced when grouping is disabled, got %q", got)
}
if strings.Contains(got, "<br>") {
t.Fatalf("<br> must still be replaced inside cells, got %q", got)
}
if !strings.Contains(got, ";") {
t.Fatalf("cell line breaks must become the full-width separator, got %q", got)
}
if !strings.HasPrefix(got, "差异如下:\n\n") || !strings.HasSuffix(got, "\n\n结论") {
t.Fatalf("surrounding text must be preserved, got %q", got)
}
}
func TestTableGroupingForMode(t *testing.T) {
cases := []struct {
mode string
wantPartial bool
wantFinal bool
}{
{mode: "", wantPartial: true, wantFinal: true},
{mode: "on", wantPartial: true, wantFinal: true},
{mode: "weird", wantPartial: true, wantFinal: true},
{mode: "off", wantPartial: false, wantFinal: false},
{mode: "NO", wantPartial: false, wantFinal: false},
{mode: "stream", wantPartial: true, wantFinal: false},
{mode: " Stream ", wantPartial: true, wantFinal: false},
}
for _, tc := range cases {
partial, final := tableGroupingForMode(tc.mode)
if partial != tc.wantPartial || final != tc.wantFinal {
t.Fatalf("tableGroupingForMode(%q) = (%v,%v), want (%v,%v)", tc.mode, partial, final, tc.wantPartial, tc.wantFinal)
}
}
}
// TestWideTableGroupingPerFrame proves the switch reaches both frames the card
// renders: the streaming frames and the final one. The streaming path drops
// unstable table blocks, so with grouping off a partial frame simply carries no
// table at all.
func TestWideTableGroupingPerFrame(t *testing.T) {
wide := "| 对比项 | CR 系列 | CRA 系列 |\n| :--- | :--- | :--- |\n| **关节最大速度** | " +
strings.Repeat("J1: 180°/s (CR3A/5A/7A) ", 6) + " | CC262 |"
const tableMarker, groupMarker = "| 对比项 |", "- CR 系列:"
cases := []struct {
name string
mode string
partialWant [2]bool // [grouped, table]
finalWant [2]bool // [grouped, table]
}{
{name: "default groups every frame", mode: "", partialWant: [2]bool{true, false}, finalWant: [2]bool{true, false}},
{name: "off keeps the table on the final frame", mode: "off", partialWant: [2]bool{false, false}, finalWant: [2]bool{false, true}},
{name: "stream groups partial and keeps the final table", mode: "stream", partialWant: [2]bool{true, false}, finalWant: [2]bool{false, true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, err := NewHandlerWithOptions(
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
&fakeQA{},
&fakeReply{},
HandlerOptions{WideTableGrouping: tc.mode},
)
if err != nil {
t.Fatalf("NewHandlerWithOptions: %v", err)
}
frames := map[string]struct {
got string
want [2]bool
}{
"partial": {got: h.sanitizeOutboundPartial(context.Background(), wide), want: tc.partialWant},
"final": {got: h.rewriteOutboundContent(context.Background(), wide), want: tc.finalWant},
}
for name, frame := range frames {
grouped := strings.Contains(frame.got, groupMarker)
table := strings.Contains(frame.got, tableMarker)
if grouped != frame.want[0] || table != frame.want[1] {
t.Fatalf("%s frame: grouped=%v table=%v, want grouped=%v table=%v, got %q",
name, grouped, table, frame.want[0], frame.want[1], frame.got)
}
}
})
}
}
func TestNormalizeHandlesEscapedPipesAndEmptyHeaders(t *testing.T) {
long := strings.Repeat("x", 40)
content := "| 项 | | \n| --- | --- |\n| a\\|b | " + long + " |"
got := normalizeWideMarkdownTables(content)
if strings.Contains(got, "| a\\|b") {
t.Fatalf("escaped pipe cell should be converted out of table form, got %q", got)
}
if !strings.Contains(got, "a|b") {
t.Fatalf("escaped pipe must be restored as literal, got %q", got)
}
if !strings.Contains(got, "第2项:") {
t.Fatalf("missing header should fall back to ordinal label, got %q", got)
}
}
+3
View File
@@ -53,6 +53,9 @@ type QARequest struct {
Region string
ResponseLocale string
Query string
AgentIDOverride string
ReasoningExecution string
ReasoningExpired bool
Images []ImageInput
MessageType string
}
+53 -60
View File
@@ -6,36 +6,11 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
// noAnswerPhrases lists explicit "no answer" phrases that the knowledge base
// returns when it cannot find relevant information. These are matched as
// substrings after trimming whitespace.
var noAnswerPhrases = []string{
"未找到",
"没有找到",
"无法确定",
"暂无相关",
"暂无已发布的权威知识",
"知识库中没有",
"产品不存在于资料中",
}
// retrievalGapPhrases are only considered a no-answer signal when the answer
// also directs the user to a human channel. Requiring both signals avoids
// misclassifying valid negative results such as "未查询到异常记录".
var retrievalGapPhrases = []string{
"未查询到",
"未检索到",
"未能查询到",
"未能检索到",
}
var humanEscalationPhrases = []string{
"建议您联系",
"建议联系",
"请联系",
"联系技术支持",
"联系官方销售",
}
// canonicalNoAnswer is the only response that carries the explicit-no-answer
// machine contract. It must be the complete answer, never a substring match:
// an otherwise supported answer may legitimately say that one specific detail
// was not found.
const canonicalNoAnswer = "当前暂无已发布的权威知识,暂不建议操作。"
// Evaluator classifies a successful turn into a coverage verdict.
type Evaluator struct{}
@@ -53,17 +28,20 @@ func (e *Evaluator) Version() string {
// Evaluate classifies a turn. It rejects non-succeeded turns. The evaluation
// rules (in priority order):
// 1. Failed/processing turns → ErrTurnNotSucceeded
// 2. Answer contains an explicit "no answer" phrase → uncovered_explicit_no_answer
// 3. Image turns (image media present) are classified by whether the question
// 2. Has references → covered_has_references (references are the coverage
// evidence; wording in a supported answer never overrides them)
// 3. Complete canonical no-answer response with no references
// → uncovered_explicit_no_answer
// 4. Image turns (image media present) are classified by whether the question
// 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
// 5. Text turns with zero references are classified by whether the question
// actually triggered a knowledge-base search:
// - knowledge searched → uncovered_no_references
// - no knowledge search → covered_non_knowledge_response
//
// v1 never uses a score threshold. TopScore captures the highest reference score
// v5 never uses a score threshold. TopScore captures the highest reference score
// (0 if no references).
func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
if turn.Status != "succeeded" {
@@ -73,9 +51,25 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
// Normalize answer for no-answer phrase detection.
trimmed := strings.TrimSpace(turn.Answer)
// Check for explicit no-answer phrases first — this takes priority
// over reference presence.
if isNoAnswer(trimmed) {
// References are the durable coverage evidence. A supported answer may
// correctly note that one sub-detail is absent, so its wording cannot turn
// the entire turn into a knowledge gap.
if len(turn.References) > 0 {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredHasReferences,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
}, nil
}
// An explicit abstention is a stable machine contract only when it is the
// complete answer. Do not use broad natural-language substring matching.
if isCanonicalNoAnswer(trimmed) {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
@@ -83,8 +77,8 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictUncovered,
ReasonCode: ReasonUncoveredExplicitNoAnswer,
ReferenceCount: len(turn.References),
TopScore: maxScore(turn.References),
ReferenceCount: 0,
TopScore: 0,
}, nil
}
@@ -126,8 +120,22 @@ func (e *Evaluator) Evaluate(turn core.Turn) (Decision, error) {
}, nil
}
// No references → uncovered.
// Text turns without references are only uncovered if they actually
// triggered knowledge search. This prevents greetings and other normal
// non-knowledge responses from generating artificial support cases.
if len(turn.References) == 0 {
if !turn.KnowledgeSearched {
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
Channel: turn.Channel,
EvaluatorVersion: EvaluatorVersion,
Verdict: VerdictCovered,
ReasonCode: ReasonCoveredNonKnowledgeResponse,
ReferenceCount: 0,
TopScore: 0,
}, nil
}
return Decision{
TurnID: turn.ID,
TenantKey: turn.TenantKey,
@@ -163,23 +171,8 @@ func hasImageMedia(media []core.MediaAudit) bool {
return false
}
func isNoAnswer(answer string) bool {
lower := strings.ToLower(answer)
for _, phrase := range noAnswerPhrases {
if strings.Contains(lower, strings.ToLower(phrase)) {
return true
}
}
return containsAny(lower, retrievalGapPhrases) && containsAny(lower, humanEscalationPhrases)
}
func containsAny(value string, phrases []string) bool {
for _, phrase := range phrases {
if strings.Contains(value, strings.ToLower(phrase)) {
return true
}
}
return false
func isCanonicalNoAnswer(answer string) bool {
return strings.TrimSpace(answer) == canonicalNoAnswer
}
func maxScore(refs []core.Reference) float64 {
+27 -7
View File
@@ -66,20 +66,40 @@ func TestEvaluator_ImageWithReferencesIsCovered(t *testing.T) {
}
}
func TestEvaluator_TextTurnNoReferencesStillUncovered(t *testing.T) {
func TestEvaluator_TextTurnSearchedButNoReferencesIsUncovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
ID: "text-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
MessageType: "text",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredNoReferences {
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references (text turn unchanged)", d.Verdict, d.ReasonCode)
t.Fatalf("verdict=%s reason=%s, want uncovered_no_references after knowledge search", d.Verdict, d.ReasonCode)
}
}
func TestEvaluator_TextTurnWithoutKnowledgeSearchIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "smalltalk-turn",
Status: "succeeded",
Answer: "你好,我在。",
MessageType: "text",
KnowledgeSearched: false,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredNonKnowledgeResponse {
t.Fatalf("verdict=%s reason=%s, want covered_non_knowledge_response", d.Verdict, d.ReasonCode)
}
}
+54 -67
View File
@@ -8,8 +8,8 @@ import (
func TestEvaluatorVersion(t *testing.T) {
e := NewEvaluator()
if e.Version() != "v3" {
t.Errorf("Evaluator version = %q, want v3", e.Version())
if e.Version() != "v5" {
t.Errorf("Evaluator version = %q, want v5", e.Version())
}
}
@@ -30,65 +30,51 @@ func TestEvaluator_RejectsProcessingTurn(t *testing.T) {
}
}
func TestEvaluator_UncoveredExplicitNoAnswer(t *testing.T) {
e := NewEvaluator()
noAnswerPhrases := []string{
"未找到相关结果",
"没有找到你需要的信息",
"无法确定该问题的答案",
"暂无相关信息",
"知识库中没有找到相关内容",
"这个问题知识库中没有可用的信息",
}
for _, answer := range noAnswerPhrases {
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: answer,
References: []core.Reference{
{ID: "r1", Score: 0.9},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatalf("unexpected error for answer %q: %v", answer, err)
}
if d.Verdict != VerdictUncovered {
t.Errorf("answer %q: verdict = %s, want uncovered", answer, d.Verdict)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("answer %q: reason_code = %s, want %s", answer, d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
}
}
}
func TestEvaluator_ProductionMissingProductPhraseTrumpsReferences(t *testing.T) {
func TestEvaluator_UncoveredCanonicalNoAnswerWithoutReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "missing-product-turn",
Status: "succeeded",
Answer: "根据提供的检索信息,无法提供对应手册。原因:产品不存在于资料中;检索资料未提及该产品。建议联系技术支持部门。",
References: []core.Reference{
{ID: "unrelated-1", Score: 0.395940},
{ID: "unrelated-2", Score: 0.311595},
},
ID: "test-turn",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictUncovered || d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("verdict=%s reason=%s", d.Verdict, d.ReasonCode)
t.Fatalf("verdict/reason = %s/%s, want %s/%s", d.Verdict, d.ReasonCode, VerdictUncovered, ReasonUncoveredExplicitNoAnswer)
}
}
func TestEvaluator_NovaPartialNotFoundWithSevenReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "nova-partial-turn",
Status: "succeeded",
Answer: "已找到Nova系列的相关资料;未找到VX500 2.5D视觉一体方案的明确说明。",
References: []core.Reference{
{ID: "r1"}, {ID: "r2"}, {ID: "r3"}, {ID: "r4"},
{ID: "r5"}, {ID: "r6"}, {ID: "r7"},
},
}
d, err := e.Evaluate(turn)
if err != nil {
t.Fatal(err)
}
if d.Verdict != VerdictCovered || d.ReasonCode != ReasonCoveredHasReferences || d.ReferenceCount != 7 {
t.Fatalf("verdict/reason/references = %s/%s/%d, want %s/%s/7", d.Verdict, d.ReasonCode, d.ReferenceCount, VerdictCovered, ReasonCoveredHasReferences)
}
}
func TestEvaluator_UncoveredNoReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但没有任何引用",
References: []core.Reference{},
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
@@ -105,9 +91,10 @@ func TestEvaluator_UncoveredNoReferences(t *testing.T) {
func TestEvaluator_UncoveredNilReferences(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
ID: "test-turn",
Status: "succeeded",
Answer: "这是一个正常的回答,但引用为nil",
KnowledgeSearched: true,
}
d, err := e.Evaluate(turn)
if err != nil {
@@ -203,9 +190,9 @@ func TestEvaluator_TopScoreZeroWhenNoReferences(t *testing.T) {
}
}
func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
// When answer contains explicit "no answer" phrase, it should be uncovered
// even if references exist. No-answer detection takes priority.
func TestEvaluator_ReferencesTrumpNaturalLanguageNoAnswer(t *testing.T) {
// References are durable coverage evidence; a natural-language phrase must
// not override them.
e := NewEvaluator()
turn := core.Turn{
ID: "test-turn",
@@ -220,11 +207,11 @@ func TestEvaluator_NoAnswerTrumpsReferences(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Errorf("no-answer with references: verdict = %s, want uncovered", d.Verdict)
if d.Verdict != VerdictCovered {
t.Errorf("answer with references: verdict = %s, want covered", d.Verdict)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Errorf("no-answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Errorf("answer with references: reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
@@ -243,12 +230,12 @@ func TestEvaluator_NoAnswerPhraseMatchesInsideAnswer(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testing.T) {
func TestEvaluator_ProductionUnavailableAuthorityPhraseWithReferencesIsCovered(t *testing.T) {
e := NewEvaluator()
turn := core.Turn{
ID: "cra-price-turn",
@@ -263,11 +250,11 @@ func TestEvaluator_ProductionUnavailableAuthorityPhraseTrumpsReferences(t *testi
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if d.Verdict != VerdictUncovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictUncovered)
if d.Verdict != VerdictCovered {
t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictCovered)
}
if d.ReasonCode != ReasonUncoveredExplicitNoAnswer {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonUncoveredExplicitNoAnswer)
if d.ReasonCode != ReasonCoveredHasReferences {
t.Fatalf("reason_code = %s, want %s", d.ReasonCode, ReasonCoveredHasReferences)
}
}
@@ -280,10 +267,10 @@ func TestEvaluator_RetrievalGapRequiresHumanEscalation(t *testing.T) {
reason string
}{
{
name: "production NOVA price answer",
name: "retrieval wording with evidence remains covered",
answer: "当前暂未查询到Nova系列机器人的公开销售价格信息。\n\n如需获取报价,建议您联系越疆科技官方销售渠道或对应区域的销售工程师咨询具体价格方案。",
verdict: VerdictUncovered,
reason: ReasonUncoveredExplicitNoAnswer,
verdict: VerdictCovered,
reason: ReasonCoveredHasReferences,
},
{
name: "retrieval gap without escalation is a valid negative result",
+6
View File
@@ -10,11 +10,17 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
const (
SupportTriggerCoverageUncovered = "coverage_uncovered"
SupportTriggerFeedbackDown = "feedback_down"
)
// SupportNotification is a safe, minimal view of the data needed to send a
// notification. It must never contain tokens, webhooks, raw API responses,
// raw error text, reference bodies, or group IDs.
type SupportNotification struct {
CaseID string
TriggerType string
Question string
SenderName string
Channel string
+12 -13
View File
@@ -76,11 +76,12 @@ func TestService_EvaluateAndPersist_UncoveredNoReferences(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
ID: "turn-2",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "正常回答但没有引用",
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
@@ -103,14 +104,12 @@ func TestService_EvaluateAndPersist_UncoveredExplicitNoAnswer(t *testing.T) {
s, _ := NewService(repo)
turn := core.Turn{
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: "未找到相关结果",
References: []core.Reference{
{ID: "r1", Score: 0.5},
},
ID: "turn-3",
TenantKey: "t1",
Channel: "dingtalk",
Status: "succeeded",
Answer: canonicalNoAnswer,
KnowledgeSearched: true,
}
d, err := s.EvaluateAndPersist(context.Background(), turn)
+10
View File
@@ -0,0 +1,10 @@
package coverage
import "testing"
func TestSupportNotificationAcceptsAuditableTriggerType(t *testing.T) {
notification := SupportNotification{TriggerType: SupportTriggerFeedbackDown}
if notification.TriggerType != "feedback_down" {
t.Fatalf("trigger=%q", notification.TriggerType)
}
}
+6 -5
View File
@@ -25,14 +25,15 @@ const (
// ReasonCode values.
const (
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredHasReferences = "covered_has_references"
ReasonUncoveredNoReferences = "uncovered_no_references"
ReasonUncoveredExplicitNoAnswer = "uncovered_explicit_no_answer"
ReasonCoveredVisualRecognition = "covered_visual_recognition"
ReasonCoveredNonKnowledgeResponse = "covered_non_knowledge_response"
)
// EvaluatorVersion is the current version of the evaluator.
const EvaluatorVersion = "v3"
const EvaluatorVersion = "v5"
var (
ErrTurnNotSucceeded = errors.New("coverage evaluator: turn status must be succeeded")
+13
View File
@@ -49,6 +49,19 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
return nil
}
// Exists reports whether an object is already stored under key, so identical
// content is never uploaded twice.
func (s *AliyunOSSStore) Exists(ctx context.Context, key string) (bool, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" {
return false, errors.New("outbound image: invalid OSS exists request")
}
exists, err := s.client.IsObjectExist(ctx, s.bucket, key)
if err != nil {
return false, errors.New("outbound image: OSS exists check failed")
}
return exists, nil
}
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
return nil, "", errors.New("outbound image: invalid OSS get request")
+137
View File
@@ -0,0 +1,137 @@
package outboundimage
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/png"
"strings"
)
const (
// compressMaxEdge bounds the longest side of a published screenshot. Manual
// screenshots are captured at 2x-3x, so capping the longest side keeps text
// readable while cutting the upload size by an order of magnitude.
compressMaxEdge = 1600
// compressJPEGQuality is the re-encode quality for compressed screenshots.
compressJPEGQuality = 82
// compressMinBytes skips images that are already small: re-encoding them
// saves little upload time and could only lose detail.
compressMinBytes = 128 << 10
// compressMaxPixels refuses to decode huge images so one oversized
// screenshot cannot exhaust the container's memory.
compressMaxPixels = 24 << 20
)
// CompressImage re-encodes an outbound screenshot as a smaller JPEG. It returns
// the payload to publish together with its content type, and reports whether
// compression applied. Images that are undecodable, already small, animated, or
// not smaller after re-encoding are returned untouched, so publishing always
// has a usable payload.
func CompressImage(data []byte, contentType string) ([]byte, string, bool) {
if !compressibleImageType(contentType) || len(data) < compressMinBytes {
return data, contentType, false
}
decoded, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, contentType, false
}
bounds := decoded.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx()*bounds.Dy() > compressMaxPixels {
return data, contentType, false
}
encoded := encodeCompressedImage(downscaleRGBA(flattenToRGBA(decoded), compressMaxEdge))
if encoded == nil || len(encoded) >= len(data) {
return data, contentType, false
}
return encoded, "image/jpeg", true
}
// compressibleImageType reports whether the content type is a still raster
// format this package can decode and re-encode. GIF and WebP are left alone to
// preserve animation and format fidelity.
func compressibleImageType(contentType string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/png", "image/jpeg", "image/jpg":
return true
default:
return false
}
}
func encodeCompressedImage(source image.Image) []byte {
var encoded bytes.Buffer
if err := jpeg.Encode(&encoded, source, &jpeg.Options{Quality: compressJPEGQuality}); err != nil {
return nil
}
return encoded.Bytes()
}
// flattenToRGBA copies an image onto an opaque white canvas. JPEG has no alpha
// channel, so transparent regions must be composited before encoding or they
// would be rendered black.
func flattenToRGBA(source image.Image) *image.RGBA {
bounds := source.Bounds()
target := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(target, target.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
draw.Draw(target, target.Bounds(), source, bounds.Min, draw.Over)
return target
}
// downscaleRGBA shrinks an image so neither side exceeds maxEdge, averaging the
// source pixels that fall into each destination pixel (box filter) so
// screenshot text stays legible.
func downscaleRGBA(source *image.RGBA, maxEdge int) *image.RGBA {
width, height := source.Rect.Dx(), source.Rect.Dy()
if width <= maxEdge && height <= maxEdge {
return source
}
targetWidth, targetHeight := width, height
if width >= height {
targetWidth = maxEdge
targetHeight = height * maxEdge / width
} else {
targetHeight = maxEdge
targetWidth = width * maxEdge / height
}
if targetWidth < 1 {
targetWidth = 1
}
if targetHeight < 1 {
targetHeight = 1
}
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
startY := y * height / targetHeight
endY := (y + 1) * height / targetHeight
if endY <= startY {
endY = startY + 1
}
for x := 0; x < targetWidth; x++ {
startX := x * width / targetWidth
endX := (x + 1) * width / targetWidth
if endX <= startX {
endX = startX + 1
}
var red, green, blue, count uint32
for row := startY; row < endY; row++ {
line := source.Pix[row*source.Stride:]
for column := startX; column < endX; column++ {
offset := column * 4
red += uint32(line[offset])
green += uint32(line[offset+1])
blue += uint32(line[offset+2])
count++
}
}
offset := y*target.Stride + x*4
target.Pix[offset] = uint8(red / count)
target.Pix[offset+1] = uint8(green / count)
target.Pix[offset+2] = uint8(blue / count)
target.Pix[offset+3] = 0xff
}
}
return target
}
+188
View File
@@ -0,0 +1,188 @@
package outboundimage
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
)
func pngFixture(t *testing.T, width, height int, alpha bool) []byte {
t.Helper()
source := image.NewRGBA(image.Rect(0, 0, width, height))
seed := uint32(12345)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// A pseudo-random pattern keeps the PNG as large as a real manual
// screenshot, so the size thresholds are exercised for real.
seed = seed*1664525 + 1013904223
alphaValue := uint8(0xff)
if alpha && x < width/2 {
alphaValue = 0x00
}
source.SetRGBA(x, y, color.RGBA{
R: uint8(seed >> 24),
G: uint8(seed >> 16),
B: uint8(seed >> 8),
A: alphaValue,
})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, source); err != nil {
t.Fatal(err)
}
return encoded.Bytes()
}
func TestCompressImageDownscalesLargeScreenshotToSmallerJPEG(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
data, contentType, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a large screenshot to be compressed")
}
if contentType != "image/jpeg" {
t.Fatalf("contentType=%q", contentType)
}
if len(data) >= len(original) {
t.Fatalf("compressed size=%d original=%d", len(data), len(original))
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("compressed payload is not a JPEG: %v", err)
}
if decoded.Bounds().Dx() != compressMaxEdge || decoded.Bounds().Dy() != compressMaxEdge*1800/3200 {
t.Fatalf("bounds=%v", decoded.Bounds())
}
}
func TestCompressImageFlattensTransparencyInsteadOfBlack(t *testing.T) {
original := pngFixture(t, 2000, 2000, true)
data, _, applied := CompressImage(original, "image/png")
if !applied {
t.Fatal("expected a transparent screenshot to be compressed")
}
decoded, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
red, green, blue, _ := decoded.At(10, 10).RGBA()
if red < 0xf000 || green < 0xf000 || blue < 0xf000 {
t.Fatalf("transparent area rendered as %v instead of white", []uint32{red, green, blue})
}
}
func TestCompressImageLeavesSmallOrUndecodablePayloadsUntouched(t *testing.T) {
small := pngFixture(t, 64, 64, false)
if len(small) >= compressMinBytes {
t.Fatalf("fixture is not small: %d", len(small))
}
for _, test := range []struct {
name string
data []byte
contentType string
}{
{name: "small", data: small, contentType: "image/png"},
{name: "not an image", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/png"},
{name: "gif stays animated", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/gif"},
{name: "text", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "text/plain"},
} {
t.Run(test.name, func(t *testing.T) {
data, contentType, applied := CompressImage(test.data, test.contentType)
if applied || contentType != test.contentType || !bytes.Equal(data, test.data) {
t.Fatalf("payload changed: applied=%v contentType=%q bytes=%d", applied, contentType, len(data))
}
})
}
}
type capturePublisher struct {
data []byte
contentType string
calls int
}
func (p *capturePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.calls++
p.data = append([]byte(nil), data...)
p.contentType = contentType
return "https://img.example/1.png", nil
}
type pngFetcher struct {
data []byte
}
func (f *pngFetcher) Fetch(_ context.Context, _ string) ([]byte, string, error) {
return f.data, "image/png", nil
}
func TestRewritePublishesCompressedScreenshots(t *testing.T) {
original := pngFixture(t, 3200, 1800, false)
reference := "resource://AbCdEfGhIjKlMnOpQrStUv"
input := "步骤一\n![图1](" + reference + ")\n"
publisher := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, publisher, 5).Rewrite(context.Background(), input)
if publisher.calls != 1 || publisher.contentType != "image/jpeg" {
t.Fatalf("calls=%d contentType=%q", publisher.calls, publisher.contentType)
}
if len(publisher.data) >= len(original) {
t.Fatalf("published bytes=%d original=%d", len(publisher.data), len(original))
}
disabled := &capturePublisher{}
NewRewriter(&pngFetcher{data: original}, disabled, 5, WithImageCompression(false)).Rewrite(context.Background(), input)
if disabled.contentType != "image/png" || !bytes.Equal(disabled.data, original) {
t.Fatalf("compression opt-out changed the payload: contentType=%q bytes=%d", disabled.contentType, len(disabled.data))
}
}
func TestStableHashPublisherReusesExistingObject(t *testing.T) {
store := &existingObjectStore{}
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
if err != nil {
t.Fatal(err)
}
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if store.puts != 1 {
t.Fatalf("puts=%d", store.puts)
}
store.exists = true
second, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("url1=%q url2=%q", first, second)
}
if store.puts != 1 {
t.Fatalf("existing object was uploaded again: puts=%d", store.puts)
}
if !strings.HasPrefix(second, "https://img.example/i/") {
t.Fatalf("url=%q", second)
}
}
type existingObjectStore struct {
exists bool
puts int
}
func (s *existingObjectStore) Put(_ context.Context, _ string, _ []byte, _ string) error {
s.puts++
return nil
}
func (s *existingObjectStore) Exists(_ context.Context, _ string) (bool, error) {
return s.exists, nil
}
+14
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log"
"net/url"
"path"
"regexp"
@@ -18,6 +19,13 @@ type ObjectWriter interface {
Put(ctx context.Context, key string, data []byte, contentType string) error
}
// ObjectStater reports whether an object already exists at key. Stores that
// implement it let the publisher skip re-uploading identical content, which is
// what makes a repeated question instant: object keys are content hashes.
type ObjectStater interface {
Exists(ctx context.Context, key string) (bool, error)
}
type StableHashPublisher struct {
store ObjectWriter
prefix string
@@ -52,6 +60,12 @@ func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentT
idHex := hex.EncodeToString(idBytes)
id := fmt.Sprintf("%s-%s-%s-%s-%s", idHex[:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:])
key := path.Join(p.prefix, id)
if stater, ok := p.store.(ObjectStater); ok {
if exists, err := stater.Exists(ctx, key); err == nil && exists {
log.Printf("outbound image: existing object reused key=%s bytes=%d contentType=%s", key, len(data), contentType)
return p.publicBaseURL + "/" + id, nil
}
}
if err := p.store.Put(ctx, key, data, contentType); err != nil {
return "", errors.New("outbound image: publish object failed")
}
+140 -23
View File
@@ -2,11 +2,15 @@ package outboundimage
import (
"context"
"log"
"net"
"net/url"
"path"
"regexp"
"sort"
"strings"
"sync"
"time"
)
var (
@@ -38,41 +42,56 @@ type Rewriter struct {
fetcher Fetcher
publisher Publisher
maxImages int
compress bool
}
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter {
return &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages}
// RewriterOption customises how one answer's images are prepared for delivery.
type RewriterOption func(*Rewriter)
// WithImageCompression switches the pre-publish JPEG re-encode. It is enabled by
// default because multi-megabyte screenshots dominate the delivery budget.
func WithImageCompression(enabled bool) RewriterOption {
return func(r *Rewriter) { r.compress = enabled }
}
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int, options ...RewriterOption) *Rewriter {
rewriter := &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages, compress: true}
for _, option := range options {
if option != nil {
option(rewriter)
}
}
return rewriter
}
// rewriteConcurrency bounds how many images are fetched and published in
// parallel. Manual screenshots are multi-megabyte objects whose upload time
// dominates the delivery budget, so a serial pass cannot publish a long answer
// before the delivery deadline and silently loses the remaining images.
const rewriteConcurrency = 4
func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
if r == nil || r.fetcher == nil || r.publisher == nil || r.maxImages <= 0 {
markdown = normalizePublicHTMLImages(stripResourceImages(markdown))
markdown = resourceReferenceRE.ReplaceAllString(resourceHTMLUnsafeImageRE.ReplaceAllString(markdown, ""), "")
return sanitizePublicMarkdownImages(markdown)
}
urls := map[string]string{}
attempted := 0
started := time.Now()
urls := r.resolveReferences(ctx, markdown)
resolve := func(reference string) string {
if publishedURL, ok := urls[reference]; ok {
return publishedURL
return urls[reference]
}
if len(urls) > 0 {
published, dropped := 0, 0
for _, publishedURL := range urls {
if publishedURL == "" {
dropped++
continue
}
published++
}
if attempted >= r.maxImages {
urls[reference] = ""
return ""
}
attempted++
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
urls[reference] = ""
return ""
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
urls[reference] = ""
return ""
}
urls[reference] = publishedURL
return publishedURL
log.Printf("outbound image: rewrite finished references=%d published=%d dropped=%d elapsed=%s",
len(urls), published, dropped, time.Since(started).Round(time.Millisecond))
}
markdown = resourceMarkdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
@@ -94,6 +113,104 @@ func (r *Rewriter) Rewrite(ctx context.Context, markdown string) string {
return sanitizePublicMarkdownImages(markdown)
}
// resolveReferences returns reference -> public URL for every image reference
// in content, in document order and deduplicated, capped at maxImages. Each
// image is fetched and published with at most rewriteConcurrency workers; a
// reference that cannot be published maps to "".
func (r *Rewriter) resolveReferences(ctx context.Context, content string) map[string]string {
references := r.collectReferences(content)
if len(references) == 0 {
return map[string]string{}
}
results := make(map[string]string, len(references))
var mutex sync.Mutex
var budgetOnce sync.Once
slots := make(chan struct{}, rewriteConcurrency)
var wait sync.WaitGroup
for _, reference := range references {
wait.Add(1)
go func(reference string) {
defer wait.Done()
slots <- struct{}{}
defer func() { <-slots }()
publishedURL := ""
if err := ctx.Err(); err != nil {
budgetOnce.Do(func() {
log.Printf("outbound image: delivery budget exhausted, dropping remaining images: %v", err)
})
} else {
publishedURL = r.publishReference(ctx, reference)
}
mutex.Lock()
results[reference] = publishedURL
mutex.Unlock()
}(reference)
}
wait.Wait()
return results
}
// collectReferences gathers the resource references that carry a real image,
// in document order, dropping duplicates and anything beyond maxImages. Bare or
// unquoted references are never fetched.
func (r *Rewriter) collectReferences(content string) []string {
type hit struct {
at int
reference string
}
hits := make([]hit, 0, 16)
for _, pattern := range []*regexp.Regexp{resourceMarkdownImageRE, resourceHTMLDoubleImageRE, resourceHTMLSingleImageRE} {
for _, match := range pattern.FindAllStringSubmatchIndex(content, -1) {
if len(match) < 4 || match[2] < 0 || match[3] > len(content) {
continue
}
hits = append(hits, hit{at: match[0], reference: content[match[2]:match[3]]})
}
}
sort.SliceStable(hits, func(i, j int) bool { return hits[i].at < hits[j].at })
references := make([]string, 0, len(hits))
seen := make(map[string]bool, len(hits))
for _, candidate := range hits {
if seen[candidate.reference] {
continue
}
seen[candidate.reference] = true
if len(references) >= r.maxImages {
break
}
references = append(references, candidate.reference)
}
return references
}
// publishReference fetches one image from WeKnora and publishes it to our own
// object storage. Failures are logged with their reason instead of being
// silently dropped from the answer.
func (r *Rewriter) publishReference(ctx context.Context, reference string) string {
started := time.Now()
data, contentType, err := r.fetcher.Fetch(ctx, reference)
if err != nil {
log.Printf("outbound image: fetch failed, image dropped reference=%s error=%v", reference, err)
return ""
}
if r.compress {
if compressed, compressedType, applied := CompressImage(data, contentType); applied {
log.Printf("outbound image: compressed reference=%s bytes=%d contentType=%s compressed_bytes=%d compressed_content_type=%s",
reference, len(data), contentType, len(compressed), compressedType)
data, contentType = compressed, compressedType
}
}
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
log.Printf("outbound image: publish failed, image dropped reference=%s bytes=%d contentType=%s error=%v",
reference, len(data), contentType, err)
return ""
}
log.Printf("outbound image: published reference=%s bytes=%d contentType=%s elapsed=%s",
reference, len(data), contentType, time.Since(started).Round(time.Millisecond))
return publishedURL
}
func sanitizePublicMarkdownImages(markdown string) string {
markdown = markdownImageRE.ReplaceAllStringFunc(markdown, func(image string) string {
match := markdownImageRE.FindStringSubmatch(image)
+47
View File
@@ -4,15 +4,19 @@ import (
"context"
"errors"
"strings"
"sync"
"testing"
)
type fakeFetcher struct {
mutex sync.Mutex
calls map[string]int
fail map[string]bool
}
func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if f.calls == nil {
f.calls = map[string]int{}
}
@@ -24,10 +28,13 @@ func (f *fakeFetcher) Fetch(_ context.Context, ref string) ([]byte, string, erro
}
type fakePublisher struct {
mutex sync.Mutex
calls int
}
func (p *fakePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.calls++
if contentType != "image/png" || len(data) == 0 {
return "", errors.New("bad image")
@@ -375,3 +382,43 @@ func TestDocumentImageDowngradePreservesImageAndLinkContracts(t *testing.T) {
t.Fatalf("unsafe document image became a link: %s", got)
}
}
func TestRewriteDropsImagesWhenDeliveryBudgetIsAlreadyExhausted(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 10)
ctx, cancel := context.WithCancel(context.Background())
cancel()
input := "正文\n![一](resource://FirstRefAbCdEfGhIjKlMnO)\n![二](resource://SecondRefAbCdEfGhIjKlMn)"
got := r.Rewrite(ctx, input)
if strings.Contains(got, "resource://") {
t.Fatalf("exhausted budget leaked a resource reference: %s", got)
}
if !strings.Contains(got, "正文") {
t.Fatalf("text was lost: %s", got)
}
if len(fetcher.calls) != 0 || publisher.calls != 0 {
t.Fatalf("no image may be fetched or published without budget: fetch=%v publish=%d", fetcher.calls, publisher.calls)
}
}
func TestRewritePublishesEveryImageWithinTheLimit(t *testing.T) {
fetcher := &fakeFetcher{}
publisher := &fakePublisher{}
r := NewRewriter(fetcher, publisher, 12)
input := "正文"
for i := 0; i < 12; i++ {
input += "\n![图](resource://Ref" + string(rune('A'+i)) + "bCdEfGhIjKlMnO)"
}
got := r.Rewrite(context.Background(), input)
if strings.Contains(got, "resource://") {
t.Fatalf("resource reference remained: %s", got)
}
if publisher.calls != 12 || len(fetcher.calls) != 12 {
t.Fatalf("calls fetch=%d publish=%d, want 12", len(fetcher.calls), publisher.calls)
}
}
+55
View File
@@ -0,0 +1,55 @@
package outboundtext
import (
"regexp"
)
// DingTalk cards render a small Markdown subset and no math typesetting, so a
// LaTeX formula reaches the user as raw source ($r = \sqrt{X^2 + Y^2}$).
// NormalizeMath rewrites such formulas into plain Unicode text that the card
// can show. It only touches content that actually carries math delimiters or
// well-known math commands, so ordinary prose is left byte-identical.
var (
mathBlockDelimRE = regexp.MustCompile(`\$\$([\s\S]+?)\$\$`)
mathSpanRE = regexp.MustCompile(`(?s)\$\$.+?\$\$|\\\[.+?\\\]|\\\(.+?\\\)|\$[^$\n]{1,400}\$`)
parenDelimRE = regexp.MustCompile(`\\\(([\s\S]+?)\\\)`)
bracketDelimRE = regexp.MustCompile(`\\\[([\s\S]+?)\\\]`)
sqrtRE = regexp.MustCompile(`\\sqrt\s*(?:\[([^\]]{1,20})\])?\s*\{([^{}]{1,200})\}`)
fracRE = regexp.MustCompile(`\\frac\s*\{([^{}]{1,200})\}\s*\{([^{}]{1,200})\}`)
supBracedRE = regexp.MustCompile(`\^\s*\{([^{}]{1,40})\}`)
subBracedRE = regexp.MustCompile(`_\s*\{([^{}]{1,40})\}`)
supCharRE = regexp.MustCompile(`\^([0-9A-Za-z+\-=()])`)
subCharRE = regexp.MustCompile(`_([0-9A-Za-z+\-=()])`)
commandRE = regexp.MustCompile(`\\([A-Za-z]+)`)
braceDelimRE = regexp.MustCompile(`\{([^{}]{0,200})\}`)
blankRunRE = regexp.MustCompile(`[ \t]{2,}`)
)
var mathCommands = map[string]string{
"times": "×", "cdot": "·", "div": "÷", "pm": "±", "mp": "∓",
"leq": "≤", "le": "≤", "geq": "≥", "ge": "≥", "neq": "≠", "ne": "≠",
"approx": "≈", "sim": "∼", "simeq": "≃", "equiv": "≡", "propto": "∝",
"infty": "∞", "partial": "∂", "nabla": "∇",
"alpha": "α", "beta": "β", "gamma": "γ", "delta": "δ", "Delta": "Δ",
"epsilon": "ε", "theta": "θ", "lambda": "λ", "mu": "μ", "rho": "ρ",
"sigma": "σ", "tau": "τ", "phi": "φ", "omega": "ω", "Omega": "Ω",
"eta": "η", "kappa": "κ", "nu": "ν", "xi": "ξ", "zeta": "ζ", "psi": "ψ",
"sum": "Σ", "prod": "Π", "int": "∫", "iint": "∬", "oint": "∮",
"in": "∈", "notin": "∉", "subset": "⊂", "subseteq": "⊆", "cup": "∪", "cap": "∩",
"rightarrow": "→", "to": "→", "longrightarrow": "→", "leftarrow": "←",
"Rightarrow": "⇒", "leftrightarrow": "↔", "mapsto": "↦",
"forall": "∀", "exists": "∃", "angle": "∠", "degree": "°", "circ": "°",
"perp": "⊥", "parallel": "∥", "therefore": "∴", "because": "∵",
"ldots": "…", "cdots": "…", "dots": "…", "prime": "′",
"quad": " ", "qquad": " ", "limits": "", "displaystyle": "",
"left": "", "right": "", "bmod": "mod ", "pmod": "mod ",
"text": "", "mathrm": "", "mathit": "", "operatorname": "",
"overline": "", "underline": "",
}
var escapedPunctuation = map[string]string{
`\\`: "\n", `\%`: "%", `\&`: "&", `\#`: "#", `\_`: "_", `\$`: "$",
`\{`: "{", `\}`: "}", `\,`: " ", `\;`: " ", `\!`: "",
`\(`: "", `\)`: "", `\[`: "", `\]`: "",
}
+128
View File
@@ -0,0 +1,128 @@
package outboundtext
import "strings"
func NormalizeMath(markdown string) string {
if !carriesMath(markdown) {
return markdown
}
content := mathSpanRE.ReplaceAllStringFunc(markdown, convertMathSpan)
// Outside math spans only LaTeX commands are converted. Identifiers and URLs
// (resource://…_pltQ) must never be rewritten: a mangled reference can no
// longer be published, and the outbound sanitizer then drops the whole image.
content = commandRE.ReplaceAllStringFunc(content, replaceCommand)
content = blankRunRE.ReplaceAllString(content, " ")
return content
}
func convertMathSpan(span string) string {
inner := span
switch {
case strings.HasPrefix(inner, "$$"):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$$"), "$$")
return "\n" + convertMath(inner) + "\n"
case strings.HasPrefix(inner, `\[`):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\[`), `\]`)
case strings.HasPrefix(inner, `\(`):
inner = strings.TrimSuffix(strings.TrimPrefix(inner, `\(`), `\)`)
default:
inner = strings.TrimSuffix(strings.TrimPrefix(inner, "$"), "$")
}
return convertMath(inner)
}
func convertMath(inner string) string {
content := inner
for from, to := range escapedPunctuation {
content = strings.ReplaceAll(content, from, to)
}
for i := 0; i < 3; i++ {
next := sqrtRE.ReplaceAllStringFunc(content, expandRoot)
next = fracRE.ReplaceAllString(next, "($1)/($2)")
next = supBracedRE.ReplaceAllStringFunc(next, convertSuperscript)
next = subBracedRE.ReplaceAllStringFunc(next, convertSubscript)
next = supCharRE.ReplaceAllStringFunc(next, convertSuperscript)
next = subCharRE.ReplaceAllStringFunc(next, convertSubscript)
if next == content {
break
}
content = next
}
content = commandRE.ReplaceAllStringFunc(content, replaceCommand)
content = braceDelimRE.ReplaceAllString(content, "$1")
return content
}
func carriesMath(markdown string) bool {
if !strings.Contains(markdown, "$") && !strings.Contains(markdown, `\(`) &&
!strings.Contains(markdown, `\[`) && !strings.Contains(markdown, `\sqrt`) &&
!strings.Contains(markdown, `\frac`) && !strings.Contains(markdown, `\times`) &&
!strings.Contains(markdown, `\times`) {
return false
}
return true
}
func expandRoot(match string) string {
parts := sqrtRE.FindStringSubmatch(match)
degree, radicand := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2])
if degree != "" {
return "√[" + degree + "](" + radicand + ")"
}
return "√(" + radicand + ")"
}
func convertSuperscript(match string) string {
body := strings.TrimPrefix(match, "^")
body = strings.TrimSuffix(strings.TrimPrefix(body, "{"), "}")
if mapped, ok := mapRun(body, superscripts); ok {
return mapped
}
return "^(" + body + ")"
}
func convertSubscript(match string) string {
body := strings.TrimPrefix(match, "_")
body = strings.TrimSuffix(strings.TrimPrefix(body, "{"), "}")
if mapped, ok := mapRun(body, subscripts); ok {
return mapped
}
return "_(" + body + ")"
}
func mapRun(body string, table map[rune]string) (string, bool) {
if body == "" {
return "", false
}
var b strings.Builder
for _, r := range body {
mapped, ok := table[r]
if !ok {
return "", false
}
b.WriteString(mapped)
}
return b.String(), true
}
func replaceCommand(match string) string {
name := strings.TrimPrefix(match, `\`)
if mapped, ok := mathCommands[name]; ok {
return mapped
}
return name
}
var superscripts = map[rune]string{
'0': "⁰", '1': "¹", '2': "²", '3': "³", '4': "⁴", '5': "⁵", '6': "⁶",
'7': "⁷", '8': "⁸", '9': "⁹", '+': "⁺", '-': "⁻", '=': "⁼",
'(': "⁽", ')': "⁾", 'n': "ⁿ", 'i': "ⁱ", 'T': "ᵀ",
}
var subscripts = map[rune]string{
'0': "₀", '1': "₁", '2': "₂", '3': "₃", '4': "₄", '5': "₅", '6': "₆",
'7': "₇", '8': "₈", '9': "₉", '+': "₊", '-': "₋", '=': "₌",
'(': "₍", ')': "₎", 'a': "ₐ", 'e': "ₑ", 'i': "ᵢ", 'j': "ⱼ", 'k': "ₖ",
'n': "ₙ", 'o': "ₒ", 'p': "ₚ", 'r': "ᵣ", 's': "ₛ", 't': "ₜ", 'u': "ᵤ",
'v': "ᵥ", 'x': "ₓ",
}
+36
View File
@@ -0,0 +1,36 @@
package outboundtext
import "testing"
func TestNormalizeMath(t *testing.T) {
cases := []struct{ name, in, want string }{
{"inline sqrt", `$r = \sqrt{X^2 + Y^2}$`, `r = √(X² + Y²)`},
{"inline inside prose", `计算公式为 $r = \sqrt{X^2 + Y^2}$(单位:cm)`, `计算公式为 r = √(X² + Y²)(单位:cm)`},
{"block frac times", `$$\frac{a}{b} \times c$$`, "\n(a)/(b) × c\n"},
{"paren delimiters", `\(x \leq y\)`, `x ≤ y`},
{"bracket delimiters", `\[ \sum_{i=1}^{n} x_i \]`, ` Σᵢ₌₁ⁿ xᵢ `},
{"nth root", `$\sqrt[3]{V}$`, `√[3](V)`},
{"no math untouched", `C:\new\folder 与 100% 覆盖`, `C:\new\folder 与 100% 覆盖`},
{"dollar free prose", `价格是 5 元, 无公式`, `价格是 5 元, 无公式`},
{"resource url untouched", `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 $r = \sqrt{X^2 + Y^2}$`, `![图](resource://efVTXx0gY9Lk2vuZM_pltQ) 公式 r = √(X² + Y²)`},
{"code span untouched", "`x_1` 与 $x_1$", "`x_1` 与 x₁"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := NormalizeMath(tc.in)
if got != tc.want {
t.Fatalf("NormalizeMath(%q) = %q, want %q", tc.in, got, tc.want)
}
if again := NormalizeMath(got); again != got {
t.Fatalf("NormalizeMath is not idempotent: %q -> %q", got, again)
}
})
}
}
func TestNormalizeMathKeepsPlainMarkdown(t *testing.T) {
plain := "### 标题\n\n- 项目一\n- 项目二\n\n普通段落,没有公式。"
if got := NormalizeMath(plain); got != plain {
t.Fatalf("plain markdown changed: %q", got)
}
}
@@ -0,0 +1,75 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
func TestKnowledgeToolEventsMarkTurnAsSearched(t *testing.T) {
knowledgeTools := []string{
"knowledge_search",
"grep_chunks",
"list_knowledge_chunks",
"get_document_info",
"wiki_search",
"wiki_read_page",
"wiki_read_source_doc",
"query_knowledge_graph",
}
for index, toolName := range knowledgeTools {
t.Run(toolName, func(t *testing.T) {
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: toolName},
{ResponseType: "answer", FullAnswer: "answer", Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request", Answer: "answer", References: []core.Reference{{KnowledgeID: "doc"}}},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
messageID := "knowledge-tool-" + string(rune('a'+index))
if _, err = service.AskEvents(context.Background(), streamQARequest(messageID), func(weknora.StreamEvent) {}); err != nil {
t.Fatal(err)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:"+messageID]
repo.mu.Unlock()
if !stored.KnowledgeSearched {
t.Fatalf("tool %s did not mark knowledge searched", toolName)
}
})
}
}
func TestNonKnowledgeToolDoesNotMarkTurnAsSearched(t *testing.T) {
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "tool_call", ToolName: "query_understand"},
{ResponseType: "answer", FullAnswer: "answer", Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request", Answer: "answer"},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
if _, err = service.AskEvents(context.Background(), streamQARequest("non-knowledge-tool"), func(weknora.StreamEvent) {}); err != nil {
t.Fatal(err)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:non-knowledge-tool"]
repo.mu.Unlock()
if stored.KnowledgeSearched {
t.Fatal("non-knowledge tool marked turn as searched")
}
}
-20
View File
@@ -1,20 +0,0 @@
package qa
import "testing"
func TestLocalizedQueryPreservesOriginalWhenAuto(t *testing.T) {
if got := localizedQuery("原始问题", "auto"); got != "原始问题" {
t.Fatalf("got %q", got)
}
}
func TestLocalizedQueryAddsRequestedLanguageWithoutChangingInput(t *testing.T) {
original := "机械臂怎么复位?"
got := localizedQuery(original, "ja")
if got != "日本語で回答してください。\n\n"+original {
t.Fatalf("got %q", got)
}
if original != "机械臂怎么复位?" {
t.Fatal("original query changed")
}
}
+113
View File
@@ -0,0 +1,113 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
)
type fakeReasoningFinalizer struct {
repo *memoryRepository
calls int
input reasoningmode.TurnCompletion
completion reasoningmode.Completion
}
func (f *fakeReasoningFinalizer) Complete(_ context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error) {
f.calls++
f.input = input
f.repo.mu.Lock()
finished := len(f.repo.finishTurns)
f.repo.mu.Unlock()
if finished == 0 {
panic("reasoning completion ran before successful Turn persistence")
}
return f.completion, nil
}
func TestAgentOverrideUsesSameWeKnoraSessionAsDefaultAgent(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
base := core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", SenderName: "张三", Query: "第一问",
}
base.ExternalMessageID = "message-quick"
quick, err := service.Ask(context.Background(), base)
if err != nil {
t.Fatal(err)
}
base.ExternalMessageID = "message-reasoning"
base.Query = "继续深入分析"
base.AgentIDOverride = "agent-reasoning"
reasoning, err := service.Ask(context.Background(), base)
if err != nil {
t.Fatal(err)
}
if quick.WeKnoraSessionID == "" || reasoning.WeKnoraSessionID != quick.WeKnoraSessionID {
t.Fatalf("quick session=%q reasoning session=%q", quick.WeKnoraSessionID, reasoning.WeKnoraSessionID)
}
client.mu.Lock()
defer client.mu.Unlock()
if client.sessions != 1 || len(client.requests) != 2 {
t.Fatalf("sessions=%d requests=%d", client.sessions, len(client.requests))
}
if client.requests[0].AgentID != "agent-1" || client.requests[1].AgentID != "agent-reasoning" {
t.Fatalf("agent requests=%q,%q", client.requests[0].AgentID, client.requests[1].AgentID)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.finishTurns) != 2 || repo.finishTurns[0].AgentID != "agent-1" || repo.finishTurns[1].AgentID != "agent-reasoning" {
t.Fatalf("finished turns=%+v", repo.finishTurns)
}
if repo.finishTurns[0].SessionKey != repo.finishTurns[1].SessionKey {
t.Fatalf("session keys diverged: %q vs %q", repo.finishTurns[0].SessionKey, repo.finishTurns[1].SessionKey)
}
}
func TestReasoningCompletionDecoratesPersistedAndReturnedAnswer(t *testing.T) {
repo := newMemoryRepository()
client := &fakeWeKnora{}
finalizer := &fakeReasoningFinalizer{
repo: repo,
completion: reasoningmode.Completion{
RoundNumber: 8,
ShowHint: true,
},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared, WithReasoningModeFinalizer(finalizer))
if err != nil {
t.Fatal(err)
}
request := core.QARequest{
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
ExternalUserID: "staff-1", ExternalMessageID: "message-mode-completion",
Query: "分析", AgentIDOverride: "agent-reasoning",
ReasoningExecution: string(reasoningmode.ExecutionReasoning),
}
result, err := service.Ask(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if finalizer.calls != 1 || finalizer.input.TurnID == "" || finalizer.input.Answer != "answer" {
t.Fatalf("finalizer calls=%d input=%+v", finalizer.calls, finalizer.input)
}
want := "【智能推理模式】\n\nanswer\n\n💡 推理模式还剩2轮,到期将自动关闭,如需继续可发送 /think on 重置轮次"
if result.Answer != want {
t.Fatalf("result answer=%q want=%q", result.Answer, want)
}
repo.mu.Lock()
defer repo.mu.Unlock()
stored := repo.turns["tenant:dingtalk:message-mode-completion"]
if stored.Answer != result.Answer {
t.Fatalf("stored answer=%q result=%q", stored.Answer, result.Answer)
}
}
+153
View File
@@ -0,0 +1,153 @@
package qa
import (
"context"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
func reasoningAnswerFixture() (string, string, string) {
narration := "我需要先确认这台设备的型号和手册。"
body := "## CR20V 关节电机最大旋转扭矩\n\n| 关节 | 扭矩 |\n| --- | --- |\n| J1 | 98 N·m |"
return narration, body, narration + "\n\n" + body
}
func reasoningEventClient(answer string) *fakeEventStreamingWeKnora {
return &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: "我需要先确认这台设备的型号和手册。"},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "answer", FullAnswer: answer, Done: true},
},
streamResult: weknora.ChatResult{RequestID: "request-reasoning", Answer: answer},
}
}
func TestAskEventsReasoningKeepsOnlyAnswerBody(t *testing.T) {
_, body, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
client := reasoningEventClient(answer)
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-reasoning")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != body {
t.Fatalf("delivered answer must keep the body only, got %q (streamCalls=%d)", result.Answer, client.streamCalls)
}
repo.mu.Lock()
stored := repo.turns["tenant:dingtalk:message-reasoning"]
repo.mu.Unlock()
if stored.Answer != body {
t.Fatalf("stored answer must keep the body only, got %q", stored.Answer)
}
}
func TestAskEventsQuickKeepsFullAnswer(t *testing.T) {
_, _, answer := reasoningAnswerFixture()
repo := newMemoryRepository()
service, err := New(repo, fixedResolver{}, reasoningEventClient(answer), session.GroupShared)
if err != nil {
t.Fatal(err)
}
result, err := service.AskEvents(context.Background(), streamQARequest("message-quick"), func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != answer {
t.Fatalf("quick answer must be untouched, got %q", result.Answer)
}
}
// Production shape (turn efae317e, 给我CRA5的负载图): the agent narrates its
// steps, then publishes the answer — lead-in, image and all — as its own answer
// run. Cutting at the first structural line threw the lead-in and the image
// away; the run boundary keeps them.
func cra5Fixture() (string, string, string) {
narration := "用户想要 CRA5(CR5A)的末端负载曲线图。让我先在知识库中搜索相关的产品手册和 FAQ。"
body := "我已经找到了 CRA5(CR5A)的末端负载曲线图,并确认了负载曲线的读法说明。下面给出完整回答。\n\n" +
"CRA5(即 CR5A)的末端负载曲线如下:\n\n" +
"![CR5A 末端负载曲线](resource://FsVe56UYMz64sm8NsUZNrg)\n\n" +
"---\n\n" +
"## 一、负载曲线怎么读\n\n" +
"负载曲线反映的是:末端负载能力与负载质心偏心距离成负相关。"
return narration, body, narration + body
}
func TestAskEventsReasoningKeepsLeadInAndImageFromAnswerRun(t *testing.T) {
narration, body, answer := cra5Fixture()
// The heuristic on its own drops the lead-in and the image; the observation
// must win over it.
if heuristic := reasoningmode.TrimReasoningNarration(answer); heuristic == body {
t.Fatal("fixture no longer exposes the heuristic defect")
}
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: narration, AnswerRunID: "step-1-answer", AnswerRunStart: 0},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "thinking"},
{ResponseType: "answer", FullAnswer: answer, Done: true, AnswerRunID: "final-answer", AnswerRunStart: len(narration)},
},
streamResult: weknora.ChatResult{RequestID: "request-cra5", Answer: answer},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-cra5")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if result.Answer != body {
t.Fatalf("delivered answer must keep the answer body (lead-in + image), got %q", result.Answer)
}
}
func TestAskEventsReasoningFallsBackWhenFinalRunInterrupted(t *testing.T) {
_, _, answer := reasoningAnswerFixture()
narration := "我需要先确认这台设备的型号和手册。"
repo := newMemoryRepository()
client := &fakeEventStreamingWeKnora{
fakeWeKnora: &fakeWeKnora{},
events: []weknora.StreamEvent{
{ResponseType: "answer", FullAnswer: narration, AnswerRunID: "step-1-answer", AnswerRunStart: 0},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
// The last answer run keeps running across another tool call, so the
// stream cannot prove where the body starts: fall back to the heuristic.
{ResponseType: "answer", FullAnswer: answer, AnswerRunID: "step-2-answer", AnswerRunStart: len(narration)},
{ResponseType: "tool_call", ToolName: "knowledge_search"},
{ResponseType: "tool_result", ToolName: "knowledge_search"},
{ResponseType: "answer", FullAnswer: answer, Done: true, AnswerRunID: "step-2-answer", AnswerRunStart: len(narration)},
},
streamResult: weknora.ChatResult{RequestID: "request-interrupted", Answer: answer},
}
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
if err != nil {
t.Fatal(err)
}
req := streamQARequest("message-interrupted")
req.ReasoningExecution = string(reasoningmode.ExecutionReasoning)
result, err := service.AskEvents(context.Background(), req, func(weknora.StreamEvent) {})
if err != nil {
t.Fatalf("AskEvents: %v", err)
}
if want := reasoningmode.TrimReasoningNarration(answer); result.Answer != want {
t.Fatalf("interrupted run must fall back to the heuristic: want %q, got %q", want, result.Answer)
}
}
+102
View File
@@ -0,0 +1,102 @@
package qa
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
)
// TestReplayRecordedStreamDiagnostics replays a recorded WeKnora SSE stream
// (path in WK_RAW_STREAM) through the real client and prints what the narration
// observation sees. Temporary diagnostic, removed after the investigation.
func TestReplayRecordedStreamDiagnostics(t *testing.T) {
path := os.Getenv("WK_RAW_STREAM")
if path == "" {
t.Skip("WK_RAW_STREAM not set")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read stream: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write(raw)
}))
defer server.Close()
client, err := weknora.NewClient(server.URL, "test-key", 30*time.Second)
if err != nil {
t.Fatalf("client: %v", err)
}
var observation reasoningmode.NarrationObservation
runStarts := []string{}
eventTypes := map[string]int{}
observer := func(e weknora.StreamEvent) {
eventTypes[e.ResponseType]++
switch e.ResponseType {
case "answer":
if e.AnswerRunID != "" {
observation.Observed = true
}
if e.AnswerRunID != "" && e.AnswerRunID != lastRunID(runStarts) {
runStarts = append(runStarts, fmt.Sprintf("%s@%d", e.AnswerRunID, e.AnswerRunStart))
observation.AnswerRuns++
observation.LastRunStart = e.AnswerRunStart
observation.LastRunClean = true
}
case "thinking", "tool_call", "tool_result":
observation.ToolEvents++
observation.LastRunClean = false
}
}
result, err := client.AgentChatEvents(context.Background(), "session", weknora.AgentChatRequest{AgentID: "agent", Query: "q"}, observer)
if err != nil {
t.Fatalf("agent chat events: %v", err)
}
t.Logf("eventTypes=%v", eventTypes)
t.Logf("runs=%v", runStarts)
t.Logf("observation: observed=%v runs=%d tools=%d lastRunStart=%d lastRunClean=%v",
observation.Observed, observation.AnswerRuns, observation.ToolEvents, observation.LastRunStart, observation.LastRunClean)
content := result.Answer
observed := reasoningmode.TrimReasoningNarrationObserved(content, observation)
heuristic := reasoningmode.TrimReasoningNarration(content)
t.Logf("contentLen=%d observedKept=%d heuristicKept=%d", len(content), len(observed), len(heuristic))
if n := len(content) - len(observed); n > 0 {
t.Logf("observedDropPrefix=%q", firstRunes(content[:n], 160))
}
if n := len(content) - len(heuristic); n > 0 {
t.Logf("heuristicDropPrefix=%q", firstRunes(content[:n], 160))
}
t.Logf("observedKeptHead=%q", firstRunes(observed, 160))
}
func lastRunID(runs []string) string {
if len(runs) == 0 {
return ""
}
id := runs[len(runs)-1]
if i := strings.LastIndex(id, "@"); i >= 0 {
return id[:i]
}
return id
}
func firstRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}

Some files were not shown because too many files have changed in this diff Show More