feat(dingtalk): add reasoning mode and support evidence
This commit is contained in:
@@ -6,6 +6,10 @@ DOBOTAI_INGRESS_TOKEN=replace-me
|
||||
DOBOTAI_ADMIN_TOKEN=
|
||||
DOBOTAI_GROUP_SESSION_POLICY=shared
|
||||
DOBOTAI_AGENT_BINDINGS={"dingtalk:*":"replace-with-agent-id"}
|
||||
# Required for /think routing. The base binding above remains the stable
|
||||
# Session identity; these select the execution Agent for each Turn.
|
||||
DOBOTAI_QUICK_AGENT_ID=builtin-quick-answer
|
||||
DOBOTAI_REASONING_AGENT_ID=builtin-smart-reasoning
|
||||
|
||||
# Official WeKnora API only
|
||||
WEKNORA_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -1,115 +1,148 @@
|
||||
# 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.
|
||||
## 钉钉 Stream
|
||||
|
||||
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.
|
||||
使用官方 `dingtalk-stream-sdk-go`,不暴露公网回调入口。
|
||||
|
||||
- `DINGTALK_QUESTION_TABLE_BASE_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_SHEET_ID`
|
||||
- `DINGTALK_QUESTION_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
- 群聊仅在机器人被 `@` 时处理;
|
||||
- 私聊按用户保持长期会话;群聊默认按群共享会话;
|
||||
- 回调 `sessionWebhook` 严格限制为 `https`、`*.dingtalk.com`、443端口且不允许重定向;
|
||||
- 启用 Stream 必须同时设置以下三项;三者均为空时渠道禁用,部分配置会失败关闭:
|
||||
|
||||
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.
|
||||
```text
|
||||
DINGTALK_CLIENT_ID
|
||||
DINGTALK_CLIENT_SECRET
|
||||
DOBOTAI_TENANT_KEY
|
||||
```
|
||||
|
||||
## Uncovered-question FAQ workflow
|
||||
## 推理模式命令
|
||||
|
||||
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.
|
||||
```text
|
||||
/think <问题> 仅当前问题使用智能推理
|
||||
/think on 开启持续智能推理
|
||||
/think off 或 /rag 关闭持续智能推理
|
||||
/setting rounds <1-50> 设置持续推理自动关闭轮数
|
||||
/setting hint <on|off> 设置剩余轮次提示
|
||||
/help、/帮助、/h 查看当前模式和帮助
|
||||
```
|
||||
|
||||
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
|
||||
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
|
||||
|
||||
- `DINGTALK_FAQ_TABLE_BASE_ID`
|
||||
- `DINGTALK_FAQ_TABLE_SHEET_ID`
|
||||
- `DINGTALK_FAQ_TABLE_VIEW_ID`
|
||||
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
|
||||
## 覆盖与人工支持
|
||||
|
||||
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
|
||||
- 有结构化检索引用的回答标记为已覆盖;
|
||||
- 没有引用但已执行检索的回答标记为未覆盖;
|
||||
- 纯图片视觉识别单独记录为 `covered_visual_recognition`,不计入“未覆盖”;
|
||||
- 无权威知识时,产品统一回复:`当前暂无已发布的权威知识,暂不建议操作。`;
|
||||
- 人工通知只带当前问题和同一用户此前最多5个问题,不包含回答、引用或置信度;
|
||||
- 未覆盖与点踩共用幂等 Support Case,避免对同一 Turn 重复通知。
|
||||
|
||||
## 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.
|
||||
复制 `.env.example` 为仅本机可读的运行时环境文件,并在 Git 外注入真实值。不得提交 API Key、数据库密码、钉钉凭据、OSS AccessKey、预签名 URL 或真实用户/群组标识。
|
||||
|
||||
## Run
|
||||
关键配置:
|
||||
|
||||
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:
|
||||
```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
|
||||
```
|
||||
|
||||
迁移前应执行逻辑备份并验证恢复;迁移、二进制部署和钉钉渠道切换是独立步骤,不能把健康检查代替真实渠道验收。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```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`。
|
||||
@@ -22,6 +22,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"
|
||||
@@ -74,6 +75,14 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
reasoningService, err := reasoningmode.NewService(
|
||||
store,
|
||||
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
|
||||
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
groupPolicy := session.GroupPolicy(strings.TrimSpace(os.Getenv("DOBOTAI_GROUP_SESSION_POLICY")))
|
||||
if groupPolicy == "" {
|
||||
groupPolicy = session.GroupShared
|
||||
@@ -93,6 +102,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)
|
||||
@@ -295,6 +307,7 @@ func main() {
|
||||
Directory: directoryResolver,
|
||||
OutboundRewriter: outboundImages.rewriter,
|
||||
SettingsRouter: &dingTalkSettingsRouter{controller: settingsRuntime.controller},
|
||||
ReasoningRouter: &dingTalkReasoningRouter{service: reasoningService},
|
||||
LanguageResolver: settingsRuntime.repository,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
|
||||
@@ -112,6 +113,27 @@ type SettingsRouter interface {
|
||||
Handle(context.Context, SettingsMessage) (handled bool, reply string, err error)
|
||||
}
|
||||
|
||||
type ReasoningMessage struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
StaffID string
|
||||
Text string
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
type ReasoningDecision struct {
|
||||
Handled bool
|
||||
Reply string
|
||||
Query string
|
||||
AgentID string
|
||||
Execution reasoningmode.ExecutionMode
|
||||
Expired bool
|
||||
}
|
||||
|
||||
type ReasoningRouter interface {
|
||||
Prepare(context.Context, ReasoningMessage) (ReasoningDecision, error)
|
||||
}
|
||||
|
||||
type LanguagePreferenceResolver interface {
|
||||
GetPreference(context.Context, string, string, string) (string, error)
|
||||
}
|
||||
@@ -136,6 +158,7 @@ type HandlerOptions struct {
|
||||
Directory UserDirectory
|
||||
OutboundRewriter OutboundContentRewriter
|
||||
SettingsRouter SettingsRouter
|
||||
ReasoningRouter ReasoningRouter
|
||||
LanguageResolver LanguagePreferenceResolver
|
||||
}
|
||||
|
||||
@@ -149,6 +172,7 @@ type Handler struct {
|
||||
directory UserDirectory
|
||||
outboundRewriter OutboundContentRewriter
|
||||
settingsRouter SettingsRouter
|
||||
reasoningRouter ReasoningRouter
|
||||
languageResolver LanguagePreferenceResolver
|
||||
now func() time.Time
|
||||
cardUpdateInterval time.Duration
|
||||
@@ -194,6 +218,7 @@ func NewHandlerWithOptions(cfg TenantConfig, qa QAAsker, reply MarkdownSender, o
|
||||
directory: opts.Directory,
|
||||
outboundRewriter: opts.OutboundRewriter,
|
||||
settingsRouter: opts.SettingsRouter,
|
||||
reasoningRouter: opts.ReasoningRouter,
|
||||
languageResolver: opts.LanguageResolver,
|
||||
now: time.Now,
|
||||
cardUpdateInterval: 500 * time.Millisecond,
|
||||
@@ -334,6 +359,26 @@ func (h *Handler) Handle(ctx context.Context, cb BotCallback) error {
|
||||
return h.sendReply(ctx, webhookURL, unsupportedMessageReply)
|
||||
}
|
||||
|
||||
if h.reasoningRouter != nil {
|
||||
decision, err := h.reasoningRouter.Prepare(ctx, ReasoningMessage{
|
||||
TenantKey: h.cfg.TenantKey, Channel: h.cfg.Channel, StaffID: externalUserID,
|
||||
Text: req.Query, ConversationID: req.ExternalChatID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dingtalk reasoning mode: %w", err)
|
||||
}
|
||||
if decision.Handled {
|
||||
if strings.TrimSpace(decision.Reply) == "" {
|
||||
return nil
|
||||
}
|
||||
return h.sendReply(ctx, webhookURL, decision.Reply)
|
||||
}
|
||||
req.Query = strings.TrimSpace(decision.Query)
|
||||
req.AgentIDOverride = strings.TrimSpace(decision.AgentID)
|
||||
req.ReasoningExecution = string(decision.Execution)
|
||||
req.ReasoningExpired = decision.Expired
|
||||
}
|
||||
|
||||
if eventCard, eventQA := h.eventStreamingCapable(); eventCard != nil && eventQA != nil {
|
||||
return h.handleEventStreaming(ctx, webhookURL, req, eventCard, eventQA)
|
||||
}
|
||||
@@ -449,6 +494,7 @@ func (h *Handler) handleStreaming(ctx context.Context, webhookURL string, req co
|
||||
if strings.TrimSpace(fullContent) == "" {
|
||||
return
|
||||
}
|
||||
fullContent = decorateReasoningPartial(fullContent, req.ReasoningExecution)
|
||||
now := h.now()
|
||||
if lastUpdate.IsZero() || now.Sub(lastUpdate) >= h.cardUpdateInterval {
|
||||
partialContent := h.sanitizeOutboundPartial(fullContent)
|
||||
@@ -509,7 +555,8 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
result, err := eventQA.AskEvents(ctx, req, func(event weknora.StreamEvent) {
|
||||
if event.ResponseType == "answer" && strings.TrimSpace(event.FullAnswer) != "" {
|
||||
answerStarted = true
|
||||
if partialContent := h.sanitizeOutboundPartial(event.FullAnswer); strings.TrimSpace(partialContent) != "" {
|
||||
visibleAnswer := decorateReasoningPartial(event.FullAnswer, req.ReasoningExecution)
|
||||
if partialContent := h.sanitizeOutboundPartial(visibleAnswer); strings.TrimSpace(partialContent) != "" {
|
||||
pump.Offer(partialContent)
|
||||
}
|
||||
return
|
||||
@@ -532,6 +579,16 @@ func (h *Handler) handleEventStreaming(ctx context.Context, webhookURL string, r
|
||||
return h.finalizeCardOrMarkdown(ctx, webhookURL, streamingCard, outTrackID, result, result.Answer, false)
|
||||
}
|
||||
|
||||
func decorateReasoningPartial(content, execution string) string {
|
||||
switch reasoningmode.ExecutionMode(strings.TrimSpace(execution)) {
|
||||
case reasoningmode.ExecutionReasoning, reasoningmode.ExecutionReasoningSingle:
|
||||
if !strings.HasPrefix(content, "【智能推理模式】") {
|
||||
return "【智能推理模式】\n\n" + content
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (h *Handler) finalizeCardOrMarkdown(ctx context.Context, webhookURL string, streamingCard StreamingCardSender, outTrackID string, result core.QAResult, content string, isError bool) error {
|
||||
timeout := h.finalDeliveryTimeout
|
||||
if timeout <= 0 {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
func TestDecorateReasoningPartialMarksReasoningFromFirstChunk(t *testing.T) {
|
||||
got := decorateReasoningPartial("部分答案", string(reasoningmode.ExecutionReasoning))
|
||||
want := "【智能推理模式】\n\n部分答案"
|
||||
if got != want {
|
||||
t.Fatalf("got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateReasoningPartialLeavesQuickAnswerUnchanged(t *testing.T) {
|
||||
if got := decorateReasoningPartial("快速答案", string(reasoningmode.ExecutionQuick)); got != "快速答案" {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
type fakeReasoningRouter struct {
|
||||
called bool
|
||||
message ReasoningMessage
|
||||
decision ReasoningDecision
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeReasoningRouter) Prepare(_ context.Context, message ReasoningMessage) (ReasoningDecision, error) {
|
||||
f.called = true
|
||||
f.message = message
|
||||
return f.decision, f.err
|
||||
}
|
||||
|
||||
func TestReasoningControlCommandBypassesQA(t *testing.T) {
|
||||
qa := &fakeQA{}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeReasoningRouter{decision: ReasoningDecision{Handled: true, Reply: "mode enabled"}}
|
||||
handler, err := NewHandlerWithOptions(
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
qa,
|
||||
reply,
|
||||
HandlerOptions{ReasoningRouter: router},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback := directTextCallback()
|
||||
callback.Text = "/think on"
|
||||
if err = handler.Handle(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !router.called || router.message.StaffID != "staff-123" || router.message.Text != "/think on" {
|
||||
t.Fatalf("router=%+v", router)
|
||||
}
|
||||
if qa.called {
|
||||
t.Fatal("reasoning control command must bypass QA")
|
||||
}
|
||||
if !reply.called || reply.lastText != "mode enabled" {
|
||||
t.Fatalf("reply=%+v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningRouteAppliesQueryAgentAndExecutionToQARequest(t *testing.T) {
|
||||
qa := &fakeQA{result: core.QAResult{TurnID: "turn-1", AnswerMessageID: "answer-1", Answer: "结果"}}
|
||||
reply := &fakeReply{}
|
||||
router := &fakeReasoningRouter{decision: ReasoningDecision{
|
||||
Query: "分析根因", AgentID: "builtin-smart-reasoning",
|
||||
Execution: reasoningmode.ExecutionReasoningSingle, Expired: true,
|
||||
}}
|
||||
handler, err := NewHandlerWithOptions(
|
||||
TenantConfig{TenantKey: "tenant-a", Channel: "dingtalk"},
|
||||
qa,
|
||||
reply,
|
||||
HandlerOptions{ReasoningRouter: router},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback := directTextCallback()
|
||||
callback.Text = "/think 分析根因"
|
||||
if err = handler.Handle(context.Background(), callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !qa.called {
|
||||
t.Fatal("single-turn reasoning must call QA")
|
||||
}
|
||||
if qa.lastReq.Query != "分析根因" || qa.lastReq.AgentIDOverride != "builtin-smart-reasoning" ||
|
||||
qa.lastReq.ReasoningExecution != string(reasoningmode.ExecutionReasoningSingle) || !qa.lastReq.ReasoningExpired {
|
||||
t.Fatalf("QA request=%+v", qa.lastReq)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ type QARequest struct {
|
||||
Region string
|
||||
ResponseLocale string
|
||||
Query string
|
||||
AgentIDOverride string
|
||||
ReasoningExecution string
|
||||
ReasoningExpired bool
|
||||
Images []ImageInput
|
||||
MessageType string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package qa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
||||
)
|
||||
|
||||
type fakeReasoningFinalizer struct {
|
||||
repo *memoryRepository
|
||||
calls int
|
||||
input reasoningmode.TurnCompletion
|
||||
completion reasoningmode.Completion
|
||||
}
|
||||
|
||||
func (f *fakeReasoningFinalizer) Complete(_ context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error) {
|
||||
f.calls++
|
||||
f.input = input
|
||||
f.repo.mu.Lock()
|
||||
finished := len(f.repo.finishTurns)
|
||||
f.repo.mu.Unlock()
|
||||
if finished == 0 {
|
||||
panic("reasoning completion ran before successful Turn persistence")
|
||||
}
|
||||
return f.completion, nil
|
||||
}
|
||||
|
||||
func TestAgentOverrideUsesSameWeKnoraSessionAsDefaultAgent(t *testing.T) {
|
||||
repo := newMemoryRepository()
|
||||
client := &fakeWeKnora{}
|
||||
service, err := New(repo, fixedResolver{}, client, session.GroupShared)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := core.QARequest{
|
||||
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "staff-1", SenderName: "张三", Query: "第一问",
|
||||
}
|
||||
base.ExternalMessageID = "message-quick"
|
||||
quick, err := service.Ask(context.Background(), base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.ExternalMessageID = "message-reasoning"
|
||||
base.Query = "继续深入分析"
|
||||
base.AgentIDOverride = "agent-reasoning"
|
||||
reasoning, err := service.Ask(context.Background(), base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if quick.WeKnoraSessionID == "" || reasoning.WeKnoraSessionID != quick.WeKnoraSessionID {
|
||||
t.Fatalf("quick session=%q reasoning session=%q", quick.WeKnoraSessionID, reasoning.WeKnoraSessionID)
|
||||
}
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
if client.sessions != 1 || len(client.requests) != 2 {
|
||||
t.Fatalf("sessions=%d requests=%d", client.sessions, len(client.requests))
|
||||
}
|
||||
if client.requests[0].AgentID != "agent-1" || client.requests[1].AgentID != "agent-reasoning" {
|
||||
t.Fatalf("agent requests=%q,%q", client.requests[0].AgentID, client.requests[1].AgentID)
|
||||
}
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
if len(repo.finishTurns) != 2 || repo.finishTurns[0].AgentID != "agent-1" || repo.finishTurns[1].AgentID != "agent-reasoning" {
|
||||
t.Fatalf("finished turns=%+v", repo.finishTurns)
|
||||
}
|
||||
if repo.finishTurns[0].SessionKey != repo.finishTurns[1].SessionKey {
|
||||
t.Fatalf("session keys diverged: %q vs %q", repo.finishTurns[0].SessionKey, repo.finishTurns[1].SessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningCompletionDecoratesPersistedAndReturnedAnswer(t *testing.T) {
|
||||
repo := newMemoryRepository()
|
||||
client := &fakeWeKnora{}
|
||||
finalizer := &fakeReasoningFinalizer{
|
||||
repo: repo,
|
||||
completion: reasoningmode.Completion{
|
||||
RoundNumber: 8,
|
||||
ShowHint: true,
|
||||
},
|
||||
}
|
||||
service, err := New(repo, fixedResolver{}, client, session.GroupShared, WithReasoningModeFinalizer(finalizer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := core.QARequest{
|
||||
TenantKey: "tenant", Channel: "dingtalk", ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "staff-1", ExternalMessageID: "message-mode-completion",
|
||||
Query: "分析", AgentIDOverride: "agent-reasoning",
|
||||
ReasoningExecution: string(reasoningmode.ExecutionReasoning),
|
||||
}
|
||||
result, err := service.Ask(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if finalizer.calls != 1 || finalizer.input.TurnID == "" || finalizer.input.Answer != "answer" {
|
||||
t.Fatalf("finalizer calls=%d input=%+v", finalizer.calls, finalizer.input)
|
||||
}
|
||||
want := "【智能推理模式】\n\nanswer\n\n💡 推理模式还剩2轮,到期将自动关闭,如需继续可发送 /think on 重置轮次"
|
||||
if result.Answer != want {
|
||||
t.Fatalf("result answer=%q want=%q", result.Answer, want)
|
||||
}
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
stored := repo.turns["tenant:dingtalk:message-mode-completion"]
|
||||
if stored.Answer != result.Answer {
|
||||
t.Fatalf("stored answer=%q result=%q", stored.Answer, result.Answer)
|
||||
}
|
||||
}
|
||||
+70
-13
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
||||
)
|
||||
@@ -44,6 +45,10 @@ type SucceededTurnObserver interface {
|
||||
ObserveSucceededTurn(ctx context.Context, turn core.Turn) error
|
||||
}
|
||||
|
||||
type ReasoningModeFinalizer interface {
|
||||
Complete(ctx context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error)
|
||||
}
|
||||
|
||||
// ServiceOption configures a Service.
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
@@ -59,14 +64,25 @@ func WithSucceededTurnObserver(observer SucceededTurnObserver, onError func(erro
|
||||
}
|
||||
}
|
||||
|
||||
func WithReasoningModeFinalizer(finalizer ReasoningModeFinalizer, onError ...func(error)) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.reasoningFinalizer = finalizer
|
||||
if len(onError) > 0 {
|
||||
s.reasoningErrorHandler = onError[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository core.Repository
|
||||
agents AgentResolver
|
||||
weknora WeKnora
|
||||
groupPolicy session.GroupPolicy
|
||||
observer SucceededTurnObserver
|
||||
observerErrorHandler func(error)
|
||||
observerConfigured bool
|
||||
repository core.Repository
|
||||
agents AgentResolver
|
||||
weknora WeKnora
|
||||
groupPolicy session.GroupPolicy
|
||||
observer SucceededTurnObserver
|
||||
observerErrorHandler func(error)
|
||||
observerConfigured bool
|
||||
reasoningFinalizer ReasoningModeFinalizer
|
||||
reasoningErrorHandler func(error)
|
||||
}
|
||||
|
||||
func New(repository core.Repository, agents AgentResolver, client WeKnora, groupPolicy session.GroupPolicy, opts ...ServiceOption) (*Service, error) {
|
||||
@@ -115,12 +131,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
if err := validateRequest(request); err != nil {
|
||||
return core.QAResult{}, err
|
||||
}
|
||||
agentID, err := s.agents.Resolve(request.Channel, request.ExternalChatID)
|
||||
baseAgentID, err := s.agents.Resolve(request.Channel, request.ExternalChatID)
|
||||
if err != nil {
|
||||
return core.QAResult{}, err
|
||||
}
|
||||
executionAgentID := baseAgentID
|
||||
if override := strings.TrimSpace(request.AgentIDOverride); override != "" {
|
||||
executionAgentID = override
|
||||
}
|
||||
sessionKey, subjectID, err := session.BuildKey(session.Identity{
|
||||
TenantKey: request.TenantKey, Channel: request.Channel, AgentID: agentID,
|
||||
TenantKey: request.TenantKey, Channel: request.Channel, AgentID: baseAgentID,
|
||||
ConversationType: request.ConversationType, ExternalChatID: request.ExternalChatID,
|
||||
ExternalUserID: request.ExternalUserID, GroupPolicy: s.groupPolicy,
|
||||
})
|
||||
@@ -149,7 +169,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
Channel: request.Channel,
|
||||
ExternalMessageID: request.ExternalMessageID,
|
||||
SessionKey: sessionKey,
|
||||
AgentID: agentID,
|
||||
AgentID: executionAgentID,
|
||||
ConversationType: request.ConversationType,
|
||||
ConversationTitle: request.ConversationTitle,
|
||||
ExternalChatID: request.ExternalChatID,
|
||||
@@ -191,7 +211,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
ConversationTitle: request.ConversationTitle,
|
||||
})
|
||||
mapping := core.SessionMapping{SessionKey: sessionKey, TenantKey: request.TenantKey,
|
||||
Channel: request.Channel, AgentID: agentID, ConversationType: request.ConversationType,
|
||||
Channel: request.Channel, AgentID: baseAgentID, ConversationType: request.ConversationType,
|
||||
ExternalSubjectID: subjectID, DisplayTitle: displayTitle}
|
||||
createSession := func(createCtx context.Context) (string, error) {
|
||||
return s.weknora.CreateSession(createCtx, displayTitle, "Managed by DobotAI; business state remains outside WeKnora")
|
||||
@@ -216,7 +236,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
images = append(images, weknora.Image{Data: image.DataURI})
|
||||
}
|
||||
chatRequest := weknora.AgentChatRequest{
|
||||
Query: query, AgentID: agentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
|
||||
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
|
||||
Images: images,
|
||||
}
|
||||
// Track whether this turn actually triggered a knowledge-base search by
|
||||
@@ -224,7 +244,7 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
// classification (image turns) and is persisted with the turn.
|
||||
var knowledgeSearched bool
|
||||
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
|
||||
if strings.EqualFold(strings.TrimSpace(e.ToolName), "knowledge_search") {
|
||||
if isKnowledgeTool(e.ToolName) {
|
||||
knowledgeSearched = true
|
||||
}
|
||||
if eventObserver != nil {
|
||||
@@ -306,6 +326,33 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
if err := s.repository.FinishTurn(ctx, turn); err != nil {
|
||||
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
|
||||
}
|
||||
if s.reasoningFinalizer != nil && strings.TrimSpace(request.ReasoningExecution) != "" {
|
||||
completion, completionErr := s.reasoningFinalizer.Complete(ctx, reasoningmode.TurnCompletion{
|
||||
Scope: reasoningmode.Scope{
|
||||
TenantKey: request.TenantKey, Channel: request.Channel, ExternalActorID: request.ExternalUserID,
|
||||
},
|
||||
TurnID: turn.ID, Execution: reasoningmode.ExecutionMode(request.ReasoningExecution),
|
||||
Answer: turn.Answer, Locale: request.ResponseLocale, Expired: request.ReasoningExpired,
|
||||
})
|
||||
if completionErr != nil {
|
||||
if s.reasoningErrorHandler != nil {
|
||||
s.reasoningErrorHandler(completionErr)
|
||||
}
|
||||
} else {
|
||||
displayLocale := reasoningmode.ResolveGuideLocale(request.ResponseLocale, request.Query)
|
||||
turn.Answer = reasoningmode.DecorateAnswerForLocale(
|
||||
turn.Answer, reasoningmode.ExecutionMode(request.ReasoningExecution),
|
||||
request.ReasoningExpired, completion, displayLocale,
|
||||
)
|
||||
if err := s.repository.FinishTurn(ctx, turn); err != nil {
|
||||
if s.reasoningErrorHandler != nil {
|
||||
s.reasoningErrorHandler(err)
|
||||
}
|
||||
} else {
|
||||
chat.Answer = turn.Answer
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.observer != nil {
|
||||
if obsErr := s.observer.ObserveSucceededTurn(ctx, turn); obsErr != nil && s.observerErrorHandler != nil {
|
||||
s.observerErrorHandler(obsErr)
|
||||
@@ -316,6 +363,16 @@ func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserve
|
||||
WeKnoraRequestID: chat.RequestID, Answer: chat.Answer, References: chat.References}, nil
|
||||
}
|
||||
|
||||
func isKnowledgeTool(toolName string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
||||
case "knowledge_search", "grep_chunks", "list_knowledge_chunks", "get_document_info",
|
||||
"wiki_search", "wiki_read_page", "wiki_read_source_doc", "query_knowledge_graph":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func localizedQuery(query, locale string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
switch strings.TrimSpace(locale) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func ResolveGuideLocale(locale, query string) string {
|
||||
locale = strings.TrimSpace(locale)
|
||||
if locale != "" && locale != "auto" {
|
||||
return locale
|
||||
}
|
||||
hasLatin := false
|
||||
for _, value := range query {
|
||||
if unicode.In(value, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) {
|
||||
return "zh-CN"
|
||||
}
|
||||
if unicode.In(value, unicode.Latin) {
|
||||
hasLatin = true
|
||||
}
|
||||
}
|
||||
if hasLatin {
|
||||
return "en"
|
||||
}
|
||||
return "zh-CN"
|
||||
}
|
||||
|
||||
func DecorateAnswer(answer string, execution ExecutionMode, expired bool, completion Completion) string {
|
||||
return DecorateAnswerForLocale(answer, execution, expired, completion, "zh-CN")
|
||||
}
|
||||
|
||||
func DecorateAnswerForLocale(answer string, execution ExecutionMode, expired bool, completion Completion, locale string) string {
|
||||
content := strings.TrimSpace(answer)
|
||||
prefixes := make([]string, 0, 2)
|
||||
if expired {
|
||||
prefixes = append(prefixes, "💡 智能推理模式已因10分钟无操作自动关闭,当前已切回【快速问答模式】。")
|
||||
}
|
||||
if execution == ExecutionReasoning || execution == ExecutionReasoningSingle {
|
||||
prefixes = append(prefixes, "【智能推理模式】")
|
||||
}
|
||||
if len(prefixes) > 0 {
|
||||
content = strings.Join(prefixes, "\n\n") + "\n\n" + content
|
||||
}
|
||||
|
||||
suffixes := make([]string, 0, 3)
|
||||
if completion.ShowHint {
|
||||
suffixes = append(suffixes, "💡 推理模式还剩2轮,到期将自动关闭,如需继续可发送 /think on 重置轮次")
|
||||
}
|
||||
if completion.AutoClosed {
|
||||
suffixes = append(suffixes, fmt.Sprintf(
|
||||
"💡 持续推理模式已满%d轮,已自动切回【快速问答模式】\n如需继续深度推理,可再次发送 /think on 开启",
|
||||
completion.RoundNumber,
|
||||
))
|
||||
}
|
||||
if completion.ShowOnboarding {
|
||||
suffixes = append(suffixes, firstUseGuide(locale))
|
||||
}
|
||||
if len(suffixes) > 0 {
|
||||
content += "\n\n" + strings.Join(suffixes, "\n\n")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func firstUseGuide(locale string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(locale), "en") {
|
||||
return "💡 First-time Guide\n" +
|
||||
"✅ Default: Fast Q&A Mode — knowledge-based, fast and cost-effective\n" +
|
||||
"🔹 For deep reasoning & analysis:\n" +
|
||||
"Continuous use: /think on to enable, auto-off after 10 rounds\n" +
|
||||
"Single use: /think + your question, auto switch back after reply\n" +
|
||||
"💬 /help for all commands · /setting for language & preferences"
|
||||
}
|
||||
return "💡 首次使用指南\n" +
|
||||
"✅ 当前默认使用快速问答模式,基于知识库快速作答,成本更低\n" +
|
||||
"🔹 需深度推理分析时:\n" +
|
||||
"连续使用:发送 /think on 开启持续模式,10轮对话后自动关闭\n" +
|
||||
"单次使用:发送 /think + 你的问题,用完自动切回省钱模式\n" +
|
||||
"💬 /help 查看全部指令・/setting 配置语言与偏好"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecorateAnswerMarksReasoningAndRemainingRounds(t *testing.T) {
|
||||
answer := DecorateAnswer("分析结果", ExecutionReasoning, false, Completion{RoundNumber: 8, ShowHint: true})
|
||||
if !strings.HasPrefix(answer, "【智能推理模式】\n\n分析结果") {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
if !strings.Contains(answer, "推理模式还剩2轮") || !strings.Contains(answer, "/think on") {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateAnswerMarksSingleReasoningWithoutRoundHint(t *testing.T) {
|
||||
answer := DecorateAnswer("单次结果", ExecutionReasoningSingle, false, Completion{})
|
||||
if answer != "【智能推理模式】\n\n单次结果" {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateAnswerAppendsAutomaticClose(t *testing.T) {
|
||||
answer := DecorateAnswer("最终结果", ExecutionReasoning, false, Completion{RoundNumber: 10, AutoClosed: true})
|
||||
if !strings.Contains(answer, "持续推理模式已满10轮") || !strings.Contains(answer, "已自动切回【快速问答模式】") {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateAnswerExplainsIdleExpiryOnNextQuickAnswer(t *testing.T) {
|
||||
answer := DecorateAnswer("快速结果", ExecutionQuick, true, Completion{})
|
||||
if !strings.HasPrefix(answer, "💡 智能推理模式已因10分钟无操作自动关闭") || !strings.HasSuffix(answer, "快速结果") {
|
||||
t.Fatalf("answer=%q", answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateAnswerAppendsFirstUseGuideOnce(t *testing.T) {
|
||||
answer := DecorateAnswer("快速结果", ExecutionQuick, false, Completion{ShowOnboarding: true})
|
||||
for _, required := range []string{"首次使用指南", "默认使用快速问答模式", "/think on", "/think + 你的问题", "/help", "/setting"} {
|
||||
if !strings.Contains(answer, required) {
|
||||
t.Fatalf("answer=%q missing %q", answer, required)
|
||||
}
|
||||
}
|
||||
without := DecorateAnswer("快速结果", ExecutionQuick, false, Completion{})
|
||||
if strings.Contains(without, "首次使用指南") {
|
||||
t.Fatalf("onboarding repeated: %q", without)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package reasoningmode
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveGuideLocaleUsesQuestionWhenPreferenceIsAuto(t *testing.T) {
|
||||
if got := ResolveGuideLocale("auto", "How do I configure CRA?"); got != "en" {
|
||||
t.Fatalf("English auto locale=%q", got)
|
||||
}
|
||||
if got := ResolveGuideLocale("auto", "如何配置CRA?"); got != "zh-CN" {
|
||||
t.Fatalf("Chinese auto locale=%q", got)
|
||||
}
|
||||
if got := ResolveGuideLocale("ja", "English text"); got != "ja" {
|
||||
t.Fatalf("explicit locale changed to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package reasoningmode
|
||||
|
||||
import "strings"
|
||||
|
||||
type CommandKind string
|
||||
|
||||
const (
|
||||
CommandEnable CommandKind = "enable"
|
||||
CommandDisable CommandKind = "disable"
|
||||
CommandSingle CommandKind = "single"
|
||||
CommandHelp CommandKind = "help"
|
||||
CommandInvalid CommandKind = "invalid"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Kind CommandKind
|
||||
Query string
|
||||
}
|
||||
|
||||
func ParseCommand(raw string) (Command, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) == 0 {
|
||||
return Command{}, false
|
||||
}
|
||||
|
||||
name := strings.ToLower(fields[0])
|
||||
switch name {
|
||||
case "/help", "/帮助", "/h":
|
||||
if len(fields) != 1 {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandHelp}, true
|
||||
case "/开启推理":
|
||||
if len(fields) != 1 {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandEnable}, true
|
||||
case "/关闭推理", "/rag":
|
||||
if len(fields) != 1 {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandDisable}, true
|
||||
case "/think":
|
||||
default:
|
||||
return Command{}, false
|
||||
}
|
||||
|
||||
if len(fields) == 1 {
|
||||
return Command{Kind: CommandEnable}, true
|
||||
}
|
||||
|
||||
second := strings.ToLower(fields[1])
|
||||
switch second {
|
||||
case "on", "start":
|
||||
if len(fields) != 2 {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandEnable}, true
|
||||
case "off", "end":
|
||||
if len(fields) != 2 {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandDisable}, true
|
||||
default:
|
||||
query := strings.TrimSpace(value[len(fields[0]):])
|
||||
if second == "+" {
|
||||
query = strings.TrimSpace(strings.TrimPrefix(query, "+"))
|
||||
}
|
||||
if query == "" {
|
||||
return Command{Kind: CommandInvalid}, true
|
||||
}
|
||||
return Command{Kind: CommandSingle, Query: query}, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package reasoningmode
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCommandRecognizesPersistentEnableAliases(t *testing.T) {
|
||||
tests := []string{
|
||||
"/think",
|
||||
"/think on",
|
||||
"/think start",
|
||||
"/THINK ON",
|
||||
"/开启推理",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
command, ok := ParseCommand(input)
|
||||
if !ok || command.Kind != CommandEnable || command.Query != "" {
|
||||
t.Fatalf("ParseCommand(%q)=(%+v,%t), want enable", input, command, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandRecognizesDisableAliases(t *testing.T) {
|
||||
tests := []string{
|
||||
"/think off",
|
||||
"/think end",
|
||||
"/THINK OFF",
|
||||
"/关闭推理",
|
||||
"/rag",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
command, ok := ParseCommand(input)
|
||||
if !ok || command.Kind != CommandDisable || command.Query != "" {
|
||||
t.Fatalf("ParseCommand(%q)=(%+v,%t), want disable", input, command, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandRecognizesSingleTurnReasoning(t *testing.T) {
|
||||
command, ok := ParseCommand(" /think 对比 CRA 和 CR 系列 ")
|
||||
if !ok || command.Kind != CommandSingle || command.Query != "对比 CRA 和 CR 系列" {
|
||||
t.Fatalf("command=%+v ok=%t", command, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandAcceptsDocumentedOptionalPlusSeparator(t *testing.T) {
|
||||
command, ok := ParseCommand("/think + 分析根因")
|
||||
if !ok || command.Kind != CommandSingle || command.Query != "分析根因" {
|
||||
t.Fatalf("command=%+v ok=%t", command, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandRecognizesHelpAliases(t *testing.T) {
|
||||
for _, input := range []string{"/help", "/帮助", "/h", "/H"} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
command, ok := ParseCommand(input)
|
||||
if !ok || command.Kind != CommandHelp {
|
||||
t.Fatalf("ParseCommand(%q)=(%+v,%t), want help", input, command, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandRejectsEmbeddedOrSimilarInputs(t *testing.T) {
|
||||
tests := []string{
|
||||
"普通问题",
|
||||
"abc /think on",
|
||||
"/thinking",
|
||||
"//think on",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if command, ok := ParseCommand(input); ok {
|
||||
t.Fatalf("ParseCommand(%q)=(%+v,true), want no command", input, command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandInterceptsMalformedControlArguments(t *testing.T) {
|
||||
tests := []string{
|
||||
"/think on 多余内容",
|
||||
"/think off 多余内容",
|
||||
"/think start 多余内容",
|
||||
"/think end 多余内容",
|
||||
"/rag 多余内容",
|
||||
"/开启推理 多余内容",
|
||||
"/关闭推理 多余内容",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
command, ok := ParseCommand(input)
|
||||
if !ok || command.Kind != CommandInvalid {
|
||||
t.Fatalf("ParseCommand(%q)=(%+v,%t), want invalid command", input, command, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultIdleTimeout = 10 * time.Minute
|
||||
|
||||
type Scope struct {
|
||||
TenantKey string
|
||||
Channel string
|
||||
ExternalActorID string
|
||||
}
|
||||
|
||||
type TurnCompletion struct {
|
||||
Scope Scope
|
||||
TurnID string
|
||||
Execution ExecutionMode
|
||||
Answer string
|
||||
Locale string
|
||||
Expired bool
|
||||
At time.Time
|
||||
Idle time.Duration
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
ResolveState(context.Context, Scope, time.Time) (State, bool, error)
|
||||
Enable(context.Context, Scope, time.Time, time.Duration) (State, error)
|
||||
Disable(context.Context, Scope, time.Time) (State, bool, error)
|
||||
SetMaxRounds(context.Context, Scope, int, time.Time) (State, error)
|
||||
SetHintEnabled(context.Context, Scope, bool, time.Time) (State, error)
|
||||
CompleteTurn(context.Context, TurnCompletion) (Completion, error)
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Handled bool
|
||||
Reply string
|
||||
Query string
|
||||
AgentID string
|
||||
Execution ExecutionMode
|
||||
Expired bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
quickAgentID string
|
||||
reasoningAgentID string
|
||||
now func() time.Time
|
||||
idleTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
|
||||
quickAgentID = strings.TrimSpace(quickAgentID)
|
||||
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
|
||||
if repo == nil {
|
||||
return nil, errors.New("reasoning mode repository is required")
|
||||
}
|
||||
if quickAgentID == "" || reasoningAgentID == "" {
|
||||
return nil, errors.New("quick and reasoning agent IDs are required")
|
||||
}
|
||||
if quickAgentID == reasoningAgentID {
|
||||
return nil, errors.New("quick and reasoning agent IDs must differ")
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
quickAgentID: quickAgentID,
|
||||
reasoningAgentID: reasoningAgentID,
|
||||
now: time.Now,
|
||||
idleTimeout: defaultIdleTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
|
||||
if err := validateScope(scope); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
query := strings.TrimSpace(text)
|
||||
if query == "" {
|
||||
return Decision{}, errors.New("reasoning mode query is required")
|
||||
}
|
||||
|
||||
if command, ok := ParseCommand(query); ok {
|
||||
switch command.Kind {
|
||||
case CommandEnable:
|
||||
state, err := s.repo.Enable(ctx, scope, s.now(), s.idleTimeout)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
return Decision{Handled: true, Reply: fmt.Sprintf(
|
||||
"✅ 已开启【智能推理模式】(计费标准更高)\n持续模式将在%d轮对话后自动关闭,也可随时输入 /think off 手动关闭",
|
||||
state.MaxRounds,
|
||||
)}, nil
|
||||
case CommandDisable:
|
||||
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if !wasReasoning {
|
||||
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
|
||||
}
|
||||
return Decision{Handled: true, Reply: "✅ 已关闭智能推理模式,切回【快速问答模式】"}, nil
|
||||
case CommandSingle:
|
||||
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
|
||||
case CommandHelp:
|
||||
return Decision{Handled: true, Reply: helpReply()}, nil
|
||||
default:
|
||||
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
state, expired, err := s.repo.ResolveState(ctx, scope, s.now())
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if state.Mode == ModeReasoning {
|
||||
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
|
||||
}
|
||||
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
|
||||
}
|
||||
|
||||
func helpReply() string {
|
||||
return "一、模式说明\n" +
|
||||
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
|
||||
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
|
||||
"二、模式切换指令\n" +
|
||||
"/think + 你的问题:仅当前问题使用智能推理\n" +
|
||||
"/think on:开启持续智能推理模式\n" +
|
||||
"/think off 或 /rag:关闭并切回快速问答模式\n\n" +
|
||||
"三、其他常用指令\n" +
|
||||
"/help:查看本帮助说明\n" +
|
||||
"/setting 或 /设置:配置语言、自动关闭轮数和提示开关\n\n" +
|
||||
"只有行首以 / 开头的完整指令会被识别。持续推理模式会在剩余2轮时提醒,到期自动切回快速问答模式。"
|
||||
}
|
||||
|
||||
func (s *Service) Complete(ctx context.Context, input TurnCompletion) (Completion, error) {
|
||||
if input.At.IsZero() {
|
||||
input.At = s.now()
|
||||
}
|
||||
if input.Idle <= 0 {
|
||||
input.Idle = s.idleTimeout
|
||||
}
|
||||
return s.repo.CompleteTurn(ctx, input)
|
||||
}
|
||||
|
||||
func validateScope(scope Scope) error {
|
||||
if strings.TrimSpace(scope.TenantKey) == "" || strings.TrimSpace(scope.Channel) == "" || strings.TrimSpace(scope.ExternalActorID) == "" {
|
||||
return errors.New("tenant, channel and external actor are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
state State
|
||||
expired bool
|
||||
enableCalls int
|
||||
disableCalls int
|
||||
completeCalls int
|
||||
lastCompletion TurnCompletion
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ResolveState(context.Context, Scope, time.Time) (State, bool, error) {
|
||||
return f.state, f.expired, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Enable(context.Context, Scope, time.Time, time.Duration) (State, error) {
|
||||
f.enableCalls++
|
||||
f.state.Mode = ModeReasoning
|
||||
f.state.RoundsUsed = 0
|
||||
return f.state, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Disable(context.Context, Scope, time.Time) (State, bool, error) {
|
||||
f.disableCalls++
|
||||
wasReasoning := f.state.Mode == ModeReasoning
|
||||
f.state.Mode = ModeQuick
|
||||
f.state.RoundsUsed = 0
|
||||
return f.state, wasReasoning, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) SetMaxRounds(context.Context, Scope, int, time.Time) (State, error) {
|
||||
return f.state, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) SetHintEnabled(context.Context, Scope, bool, time.Time) (State, error) {
|
||||
return f.state, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CompleteTurn(_ context.Context, input TurnCompletion) (Completion, error) {
|
||||
f.completeCalls++
|
||||
f.lastCompletion = input
|
||||
return Completion{Next: f.state}, nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, repo Repository) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(repo, "builtin-quick-answer", "builtin-smart-reasoning")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.now = func() time.Time { return time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) }
|
||||
return service
|
||||
}
|
||||
|
||||
func testScope() Scope {
|
||||
return Scope{TenantKey: "tenant", Channel: "dingtalk", ExternalActorID: "staff-1"}
|
||||
}
|
||||
|
||||
func TestPrepareEnablesPersistentReasoningLocally(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think on")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled || decision.Query != "" || repo.enableCalls != 1 {
|
||||
t.Fatalf("decision=%+v enableCalls=%d", decision, repo.enableCalls)
|
||||
}
|
||||
for _, want := range []string{"已开启【智能推理模式】", "计费标准更高", "10轮", "/think off"} {
|
||||
if !strings.Contains(decision.Reply, want) {
|
||||
t.Fatalf("reply=%q missing %q", decision.Reply, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDisablesPersistentReasoningLocally(t *testing.T) {
|
||||
repo := &fakeRepository{state: State{Mode: ModeReasoning, MaxRounds: 10, HintEnabled: true}}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/rag")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled || repo.disableCalls != 1 || !strings.Contains(decision.Reply, "已关闭智能推理模式") {
|
||||
t.Fatalf("decision=%+v disableCalls=%d", decision, repo.disableCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFriendlyReplyWhenReasoningAlreadyOff(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think off")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled || !strings.Contains(decision.Reply, "当前未开启推理模式") {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSingleReasoningDoesNotMutatePersistentState(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/think 分析根因")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Handled || decision.Query != "分析根因" || decision.AgentID != "builtin-smart-reasoning" || decision.Execution != ExecutionReasoningSingle {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
if repo.enableCalls != 0 || repo.disableCalls != 0 {
|
||||
t.Fatalf("unexpected state mutation: %+v", repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRoutesPersistentAndQuickQuestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state State
|
||||
agentID string
|
||||
execution ExecutionMode
|
||||
}{
|
||||
{name: "quick", state: DefaultState(), agentID: "builtin-quick-answer", execution: ExecutionQuick},
|
||||
{name: "reasoning", state: State{Mode: ModeReasoning, MaxRounds: 10, HintEnabled: true}, agentID: "builtin-smart-reasoning", execution: ExecutionReasoning},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
decision, err := newTestService(t, &fakeRepository{state: test.state}).Prepare(context.Background(), testScope(), "普通问题")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Handled || decision.Query != "普通问题" || decision.AgentID != test.agentID || decision.Execution != test.execution {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRoutesExpiredReasoningToQuickWithNotice(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState(), expired: true}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "继续")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.AgentID != "builtin-quick-answer" || decision.Execution != ExecutionQuick || !decision.Expired {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareInterceptsInvalidThinkCommand(t *testing.T) {
|
||||
decision, err := newTestService(t, &fakeRepository{state: DefaultState()}).Prepare(context.Background(), testScope(), "/think on 多余内容")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled || !strings.Contains(decision.Reply, "用法") {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareReturnsLocalHelpWithoutStateMutation(t *testing.T) {
|
||||
repo := &fakeRepository{state: DefaultState()}
|
||||
decision, err := newTestService(t, repo).Prepare(context.Background(), testScope(), "/help")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !decision.Handled {
|
||||
t.Fatalf("decision=%+v", decision)
|
||||
}
|
||||
for _, required := range []string{"快速问答模式", "智能推理模式", "/think + 你的问题", "/think on", "/think off", "/setting"} {
|
||||
if !strings.Contains(decision.Reply, required) {
|
||||
t.Fatalf("help=%q missing %q", decision.Reply, required)
|
||||
}
|
||||
}
|
||||
if repo.enableCalls != 0 || repo.disableCalls != 0 {
|
||||
t.Fatalf("help mutated mode: %+v", repo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Mode string
|
||||
|
||||
type ExecutionMode string
|
||||
|
||||
const (
|
||||
ModeQuick Mode = "quick"
|
||||
ModeReasoning Mode = "reasoning"
|
||||
|
||||
ExecutionQuick ExecutionMode = "quick"
|
||||
ExecutionReasoning ExecutionMode = "reasoning"
|
||||
ExecutionReasoningSingle ExecutionMode = "reasoning_single"
|
||||
)
|
||||
|
||||
var ErrInvalidState = errors.New("invalid reasoning mode state")
|
||||
|
||||
type State struct {
|
||||
Mode Mode
|
||||
MaxRounds int
|
||||
RoundsUsed int
|
||||
HintEnabled bool
|
||||
LastActivityAt time.Time
|
||||
ExpiresAt time.Time
|
||||
OnboardingShown bool
|
||||
OnboardingShownAt time.Time
|
||||
}
|
||||
|
||||
type Completion struct {
|
||||
Next State
|
||||
RoundNumber int
|
||||
ShowHint bool
|
||||
AutoClosed bool
|
||||
ShowOnboarding bool
|
||||
}
|
||||
|
||||
func DefaultState() State {
|
||||
return State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true}
|
||||
}
|
||||
|
||||
func EffectiveState(state State, now time.Time) (State, bool) {
|
||||
if state.Mode != ModeReasoning || state.ExpiresAt.IsZero() || state.ExpiresAt.After(now) {
|
||||
return state, false
|
||||
}
|
||||
state.Mode = ModeQuick
|
||||
state.RoundsUsed = 0
|
||||
state.ExpiresAt = time.Time{}
|
||||
return state, true
|
||||
}
|
||||
|
||||
func ApplySuccessfulTurn(state State, execution ExecutionMode, now time.Time, idleTimeout time.Duration) (Completion, error) {
|
||||
if err := validateState(state); err != nil {
|
||||
return Completion{}, err
|
||||
}
|
||||
if idleTimeout <= 0 {
|
||||
return Completion{}, ErrInvalidState
|
||||
}
|
||||
if execution != ExecutionQuick && execution != ExecutionReasoning && execution != ExecutionReasoningSingle {
|
||||
return Completion{}, ErrInvalidState
|
||||
}
|
||||
|
||||
result := Completion{Next: state}
|
||||
if !state.OnboardingShown {
|
||||
result.ShowOnboarding = true
|
||||
result.Next.OnboardingShown = true
|
||||
result.Next.OnboardingShownAt = now
|
||||
}
|
||||
|
||||
if execution != ExecutionReasoning || state.Mode != ModeReasoning {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.RoundNumber = state.RoundsUsed + 1
|
||||
result.Next.LastActivityAt = now
|
||||
if result.RoundNumber >= state.MaxRounds {
|
||||
result.AutoClosed = true
|
||||
result.Next.Mode = ModeQuick
|
||||
result.Next.RoundsUsed = 0
|
||||
result.Next.ExpiresAt = time.Time{}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Next.RoundsUsed = result.RoundNumber
|
||||
result.Next.ExpiresAt = now.Add(idleTimeout)
|
||||
result.ShowHint = state.HintEnabled && state.MaxRounds-result.RoundNumber == 2
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateState(state State) error {
|
||||
if state.Mode != ModeQuick && state.Mode != ModeReasoning {
|
||||
return ErrInvalidState
|
||||
}
|
||||
if state.MaxRounds < 1 || state.MaxRounds > 50 {
|
||||
return ErrInvalidState
|
||||
}
|
||||
if state.RoundsUsed < 0 || state.RoundsUsed > state.MaxRounds {
|
||||
return ErrInvalidState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package reasoningmode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func defaultTestState() State {
|
||||
return State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnShowsOnboardingOnceInQuickMode(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
result, err := ApplySuccessfulTurn(defaultTestState(), ExecutionQuick, now, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.ShowOnboarding || result.RoundNumber != 0 || result.AutoClosed || result.ShowHint {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if !result.Next.OnboardingShown || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
|
||||
t.Fatalf("next=%+v", result.Next)
|
||||
}
|
||||
|
||||
second, err := ApplySuccessfulTurn(result.Next, ExecutionQuick, now.Add(time.Minute), 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ShowOnboarding {
|
||||
t.Fatal("onboarding must only be shown once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnShowsRemainingTwoHint(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 7, HintEnabled: true, OnboardingShown: true}
|
||||
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, now, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RoundNumber != 8 || !result.ShowHint || result.AutoClosed {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if result.Next.Mode != ModeReasoning || result.Next.RoundsUsed != 8 || !result.Next.ExpiresAt.Equal(now.Add(10*time.Minute)) {
|
||||
t.Fatalf("next=%+v", result.Next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnAutoClosesAtConfiguredLimit(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 9, HintEnabled: true, OnboardingShown: true}
|
||||
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, now, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RoundNumber != 10 || !result.AutoClosed || result.ShowHint {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 || !result.Next.ExpiresAt.IsZero() {
|
||||
t.Fatalf("next=%+v", result.Next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnRespectsDisabledHint(t *testing.T) {
|
||||
state := State{Mode: ModeReasoning, MaxRounds: 10, RoundsUsed: 7, HintEnabled: false, OnboardingShown: true}
|
||||
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, time.Now(), 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.ShowHint {
|
||||
t.Fatal("disabled hint must remain hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnSingleReasoningDoesNotChangePersistentMode(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
state := defaultTestState()
|
||||
result, err := ApplySuccessfulTurn(state, ExecutionReasoningSingle, now, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RoundNumber != 0 || result.AutoClosed || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySuccessfulTurnDoesNotReenableModeClosedWhileAnswerRan(t *testing.T) {
|
||||
state := State{Mode: ModeQuick, MaxRounds: 10, HintEnabled: true, OnboardingShown: true}
|
||||
result, err := ApplySuccessfulTurn(state, ExecutionReasoning, time.Now(), 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RoundNumber != 0 || result.Next.Mode != ModeQuick || result.Next.RoundsUsed != 0 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveStateExpiresReasoningWithoutMutatingPreferences(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
state := State{
|
||||
Mode: ModeReasoning, MaxRounds: 15, RoundsUsed: 4,
|
||||
HintEnabled: false, OnboardingShown: true, ExpiresAt: now,
|
||||
}
|
||||
effective, expired := EffectiveState(state, now)
|
||||
if !expired || effective.Mode != ModeQuick || effective.RoundsUsed != 0 || !effective.ExpiresAt.IsZero() {
|
||||
t.Fatalf("effective=%+v expired=%t", effective, expired)
|
||||
}
|
||||
if effective.MaxRounds != 15 || effective.HintEnabled || !effective.OnboardingShown {
|
||||
t.Fatalf("preferences changed on expiry: %+v", effective)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -28,6 +29,8 @@ type SettingInput struct{ TenantKey, Channel, StaffID, UnionID, DisplayName, Dep
|
||||
type ControllerRepository interface {
|
||||
IsAdministrator(context.Context, string, string) (bool, error)
|
||||
SetPreference(context.Context, string, string, string, string) error
|
||||
SetReasoningMaxRounds(context.Context, string, string, string, int, time.Time) error
|
||||
SetReasoningHintEnabled(context.Context, string, string, string, bool, time.Time) error
|
||||
SaveBinding(context.Context, ResourceBinding) error
|
||||
TestAndActivateSupportGroup(context.Context, ResourceBinding) error
|
||||
DetectBootstrapCandidate(context.Context, BootstrapCandidate) error
|
||||
@@ -109,6 +112,32 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
|
||||
}
|
||||
return "✅ 回答语言已设置为 " + args[1] + "。", nil
|
||||
}
|
||||
if action == "rounds" {
|
||||
if len(args) != 2 {
|
||||
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
|
||||
}
|
||||
rounds, parseErr := strconv.Atoi(args[1])
|
||||
if parseErr != nil || rounds < 1 || rounds > 50 {
|
||||
return "智能推理自动关闭轮数必须是1-50之间的整数。", nil
|
||||
}
|
||||
if err := c.repo.SetReasoningMaxRounds(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, rounds, c.now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("✅ 智能推理将在%d轮对话后自动关闭。", rounds), nil
|
||||
}
|
||||
if action == "hint" {
|
||||
if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
|
||||
return "用法:/setting hint <on|off>。", nil
|
||||
}
|
||||
enabled := args[1] == "on"
|
||||
if err := c.repo.SetReasoningHintEnabled(ctx, in.TenantKey, defaultChannel(in.Channel), in.StaffID, enabled, c.now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if enabled {
|
||||
return "✅ 智能推理剩余轮次提醒已开启。", nil
|
||||
}
|
||||
return "✅ 智能推理剩余轮次提醒已关闭。", nil
|
||||
}
|
||||
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -164,7 +193,10 @@ func settingMenu(admin, _ bool) string {
|
||||
"- auto:跟随当前提问语言(推荐)\n" +
|
||||
"- zh-CN:简体中文\n" +
|
||||
"- en / ja / ko / es:英语 / 日语 / 韩语 / 西班牙语\n" +
|
||||
"例如:/setting language en"
|
||||
"例如:/setting language en\n\n" +
|
||||
"智能推理自动关闭轮数:/setting rounds <1-50>\n" +
|
||||
"例如:/setting rounds 15\n" +
|
||||
"剩余轮次提醒:/setting hint <on|off>"
|
||||
if !admin {
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
type memoryRepo struct {
|
||||
admins map[string]bool
|
||||
prefs map[string]string
|
||||
rounds map[string]int
|
||||
hints map[string]bool
|
||||
bindings map[string]ResourceBinding
|
||||
candidate BootstrapCandidate
|
||||
sessions map[string]SettingSession
|
||||
@@ -18,7 +20,7 @@ type memoryRepo struct {
|
||||
}
|
||||
|
||||
func newMemoryRepo() *memoryRepo {
|
||||
return &memoryRepo{admins: map[string]bool{}, prefs: map[string]string{}, bindings: map[string]ResourceBinding{}, sessions: map[string]SettingSession{}}
|
||||
return &memoryRepo{admins: map[string]bool{}, prefs: map[string]string{}, rounds: map[string]int{}, hints: map[string]bool{}, bindings: map[string]ResourceBinding{}, sessions: map[string]SettingSession{}}
|
||||
}
|
||||
func (r *memoryRepo) IsAdministrator(_ context.Context, tenant, staff string) (bool, error) {
|
||||
return r.admins[tenant+"/"+staff], nil
|
||||
@@ -27,6 +29,14 @@ func (r *memoryRepo) SetPreference(_ context.Context, tenant, channel, staff, lo
|
||||
r.prefs[tenant+"/"+staff] = locale
|
||||
return nil
|
||||
}
|
||||
func (r *memoryRepo) SetReasoningMaxRounds(_ context.Context, tenant, channel, staff string, rounds int, _ time.Time) error {
|
||||
r.rounds[tenant+"/"+channel+"/"+staff] = rounds
|
||||
return nil
|
||||
}
|
||||
func (r *memoryRepo) SetReasoningHintEnabled(_ context.Context, tenant, channel, staff string, enabled bool, _ time.Time) error {
|
||||
r.hints[tenant+"/"+channel+"/"+staff] = enabled
|
||||
return nil
|
||||
}
|
||||
func (r *memoryRepo) SaveBinding(_ context.Context, b ResourceBinding) error {
|
||||
if r.saveErr != nil {
|
||||
return r.saveErr
|
||||
@@ -69,6 +79,43 @@ func TestControllerLanguageForOrdinaryUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerReasoningRoundsForOrdinaryUser(t *testing.T) {
|
||||
r := newMemoryRepo()
|
||||
c := NewController(r)
|
||||
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds 15", ConversationType: "direct"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.rounds["t/dingtalk/u"] != 15 || !strings.Contains(out, "15轮") {
|
||||
t.Fatalf("rounds=%v out=%q", r.rounds, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerRejectsOutOfRangeReasoningRounds(t *testing.T) {
|
||||
for _, value := range []string{"0", "51", "abc"} {
|
||||
r := newMemoryRepo()
|
||||
out, err := NewController(r).Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds " + value, ConversationType: "direct"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.rounds) != 0 || !strings.Contains(out, "1-50") {
|
||||
t.Fatalf("value=%q rounds=%v out=%q", value, r.rounds, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerReasoningHintForOrdinaryUser(t *testing.T) {
|
||||
r := newMemoryRepo()
|
||||
c := NewController(r)
|
||||
out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting hint off", ConversationType: "direct"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.hints["t/dingtalk/u"] || !strings.Contains(out, "已关闭") {
|
||||
t.Fatalf("hints=%v out=%q", r.hints, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerSettingMenuExplainsLanguageWithoutAdminActionsToOrdinaryUser(t *testing.T) {
|
||||
r := newMemoryRepo()
|
||||
c := NewController(r)
|
||||
|
||||
@@ -82,6 +82,11 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
|
||||
if command.RowsAffected() != 1 {
|
||||
return feedback.Result{}, errors.New("insert feedback state affected unexpected rows")
|
||||
}
|
||||
if record.Value == feedback.ValueDown {
|
||||
if err = s.ensureFeedbackSupportCase(ctx, tx, record, record.ID, turnID); err != nil {
|
||||
return feedback.Result{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return feedback.Result{}, fmt.Errorf("commit feedback create: %w", err)
|
||||
}
|
||||
@@ -92,6 +97,11 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
|
||||
}
|
||||
|
||||
if sameFeedbackState(existingValue, existingReason, record.Value, record.ReasonCode) {
|
||||
if record.Value == feedback.ValueDown {
|
||||
if err = s.ensureFeedbackSupportCase(ctx, tx, record, existingID, turnID); err != nil {
|
||||
return feedback.Result{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return feedback.Result{}, fmt.Errorf("commit feedback replay: %w", err)
|
||||
}
|
||||
@@ -108,12 +118,42 @@ func (s *Store) RecordFeedback(ctx context.Context, record feedback.Record) (fee
|
||||
if command.RowsAffected() != 1 {
|
||||
return feedback.Result{}, errors.New("update feedback state affected unexpected rows")
|
||||
}
|
||||
if record.Value == feedback.ValueDown {
|
||||
if err = s.ensureFeedbackSupportCase(ctx, tx, record, existingID, turnID); err != nil {
|
||||
return feedback.Result{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return feedback.Result{}, fmt.Errorf("commit feedback change: %w", err)
|
||||
}
|
||||
return feedback.Result{Changed: true, Value: record.Value, ReasonCode: record.ReasonCode}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureFeedbackSupportCase(ctx context.Context, tx pgx.Tx, record feedback.Record, feedbackID, turnID string) error {
|
||||
var decisionID *string
|
||||
if err := tx.QueryRow(ctx, `SELECT (SELECT id FROM coverage_decisions
|
||||
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3)`,
|
||||
record.TenantKey, record.Channel, turnID,
|
||||
).Scan(&decisionID); err != nil {
|
||||
return fmt.Errorf("find feedback coverage decision: %w", err)
|
||||
}
|
||||
caseID, err := newUUID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate feedback support case ID: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO support_cases
|
||||
(id,tenant_key,channel,turn_id,coverage_decision_id,status,faq_export_status,trigger_type,trigger_feedback_id)
|
||||
VALUES($1,$2,$3,$4,$5,'pending',$6,'feedback_down',$7)
|
||||
ON CONFLICT (tenant_key,channel,turn_id) DO NOTHING`,
|
||||
caseID, record.TenantKey, record.Channel, turnID, decisionID,
|
||||
faqExportStatus(s.faqExportEnabled), feedbackID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert feedback support case: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFeedbackRecord(record feedback.Record) error {
|
||||
if record.ID == "" || record.ActorID == "" || record.TenantKey == "" || record.Channel == "" ||
|
||||
record.ExternalActorID == "" || record.OutTrackID == "" || record.Source != feedback.SourceDingTalkCard {
|
||||
|
||||
@@ -75,3 +75,29 @@ func TestSameFeedbackState(t *testing.T) {
|
||||
t.Fatal("value change must be a state change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedbackDownvoteEnsuresIdempotentSupportCaseInsideTransaction(t *testing.T) {
|
||||
data, err := os.ReadFile("feedback.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"ENSUREFEEDBACKSUPPORTCASE",
|
||||
"SELECT ID FROM COVERAGE_DECISIONS",
|
||||
"TENANT_KEY=$1 AND CHANNEL=$2 AND TURN_ID=$3",
|
||||
"INSERT INTO SUPPORT_CASES",
|
||||
"TRIGGER_TYPE",
|
||||
"TRIGGER_FEEDBACK_ID",
|
||||
"'FEEDBACK_DOWN'",
|
||||
"ON CONFLICT (TENANT_KEY,CHANNEL,TURN_ID) DO NOTHING",
|
||||
"FAQEXPORTSTATUS(S.FAQEXPORTENABLED)",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("feedback support wiring missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Count(source, "ENSUREFEEDBACKSUPPORTCASE") < 4 {
|
||||
t.Fatal("create, replay, and changed-down paths must all ensure the same idempotent support case")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
func openIntegrationStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
url := os.Getenv("DOBOTAI_POSTGRES_INTEGRATION_URL")
|
||||
if url == "" {
|
||||
t.Skip("DOBOTAI_POSTGRES_INTEGRATION_URL is not set")
|
||||
}
|
||||
store, err := Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(store.Close)
|
||||
return store
|
||||
}
|
||||
|
||||
func seedSucceededTurn(t *testing.T, store *Store, suffix string, deliveredCard bool) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tenant, channel := "tenant-"+suffix, "dingtalk"
|
||||
actorID, conversationID := "actor-"+suffix, "conversation-"+suffix
|
||||
requestID, turnID, answerID := "request-"+suffix, "turn-"+suffix, "answer-"+suffix
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO channel_actors(id,tenant_key,channel,external_actor_id,actor_type) VALUES($1,$2,$3,$4,'user')`, actorID, tenant, channel, "staff-"+suffix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO channel_conversations(id,tenant_key,channel,external_conversation_id,conversation_type) VALUES($1,$2,$3,$4,'direct')`, conversationID, tenant, channel, "chat-"+suffix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO messages(id,tenant_key,channel,conversation_id,actor_id,external_message_id,correlation_id,direction,role,message_type,text_content,status) VALUES($1,$2,$3,$4,$5,$6,$7,'inbound','user','text','question','received')`, requestID, tenant, channel, conversationID, actorID, "external-"+suffix, turnID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO turns(id,tenant_key,channel,external_message_id,session_key,question,answer,status,request_message_id,agent_id) VALUES($1,$2,$3,$4,$5,'question','answer','succeeded',$6,'agent')`, turnID, tenant, channel, "external-"+suffix, "session-"+suffix, requestID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messageStatus, method, providerID := "generated", "", ""
|
||||
if deliveredCard {
|
||||
messageStatus, method, providerID = "sent", "card", "track-"+suffix
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO messages(id,tenant_key,channel,conversation_id,external_message_id,correlation_id,direction,role,message_type,text_content,reply_to_message_id,status,delivery_method,provider_message_id) VALUES($1,$2,$3,$4,'',$5,'outbound','assistant','text','answer',$6,$7,$8,$9)`, answerID, tenant, channel, conversationID, turnID, requestID, messageStatus, method, providerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `UPDATE turns SET answer_message_id=$2 WHERE id=$1`, turnID, answerID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `INSERT INTO coverage_decisions(id,tenant_key,channel,turn_id,evaluator_version,verdict,reason_code,reference_count,top_score) VALUES($1,$2,$3,$4,'v3','covered','covered_has_references',1,0)`, "decision-"+suffix, tenant, channel, turnID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationReasoningCompletionIsIdempotent(t *testing.T) {
|
||||
store := openIntegrationStore(t)
|
||||
seedSucceededTurn(t, store, "reasoning", false)
|
||||
ctx := context.Background()
|
||||
scope := reasoningmode.Scope{TenantKey: "tenant-reasoning", Channel: "dingtalk", ExternalActorID: "staff-reasoning"}
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := store.Enable(ctx, scope, now, 10*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.pool.Exec(ctx, `UPDATE reasoning_mode_states SET rounds_used=7 WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := reasoningmode.TurnCompletion{Scope: scope, TurnID: "turn-reasoning", Execution: reasoningmode.ExecutionReasoning, At: now.Add(time.Minute), Idle: 10 * time.Minute}
|
||||
first, err := store.CompleteTurn(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.CompleteTurn(ctx, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.RoundNumber != 8 || !first.ShowHint || second.RoundNumber != 8 || !second.ShowHint {
|
||||
t.Fatalf("first=%+v second=%+v", first, second)
|
||||
}
|
||||
var rounds, ledger int
|
||||
if err := store.pool.QueryRow(ctx, `SELECT rounds_used FROM reasoning_mode_states WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID).Scan(&rounds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM reasoning_mode_turns WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`, scope.TenantKey, scope.Channel, input.TurnID).Scan(&ledger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rounds != 8 || ledger != 1 {
|
||||
t.Fatalf("rounds=%d ledger=%d", rounds, ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDownvoteCreatesOneFeedbackSupportCase(t *testing.T) {
|
||||
store := openIntegrationStore(t)
|
||||
seedSucceededTurn(t, store, "feedback", true)
|
||||
store.SetExternalTableExports(false, true)
|
||||
ctx := context.Background()
|
||||
command := feedback.Record{
|
||||
ID: "feedback-state-1", TenantKey: "tenant-feedback", Channel: "dingtalk", ActorID: "feedback-actor-1",
|
||||
ExternalActorID: "staff-feedback", OutTrackID: "track-feedback", Value: feedback.ValueDown,
|
||||
ReasonCode: feedback.ReasonEmpty, Source: feedback.SourceDingTalkCard,
|
||||
}
|
||||
if _, err := store.RecordFeedback(ctx, command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.ID, command.ActorID = "feedback-state-replay", "feedback-actor-replay"
|
||||
result, err := store.RecordFeedback(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Replayed {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
var count int
|
||||
var triggerType, faqStatus string
|
||||
if err := store.pool.QueryRow(ctx, `SELECT count(*),max(trigger_type),max(faq_export_status) FROM support_cases WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`, "tenant-feedback", "dingtalk", "turn-feedback").Scan(&count, &triggerType, &faqStatus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || triggerType != "feedback_down" || faqStatus != "pending" {
|
||||
t.Fatalf("count=%d trigger=%q faq=%q", count, triggerType, faqStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationDownvoteCreatesCaseWithoutCoverageDecision(t *testing.T) {
|
||||
store := openIntegrationStore(t)
|
||||
seedSucceededTurn(t, store, "feedback-no-decision", true)
|
||||
store.SetExternalTableExports(false, true)
|
||||
ctx := context.Background()
|
||||
if _, err := store.pool.Exec(ctx, `DELETE FROM coverage_decisions
|
||||
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3`,
|
||||
"tenant-feedback-no-decision", "dingtalk", "turn-feedback-no-decision"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := feedback.Record{
|
||||
ID: "feedback-state-no-decision", TenantKey: "tenant-feedback-no-decision", Channel: "dingtalk", ActorID: "feedback-actor-no-decision",
|
||||
ExternalActorID: "staff-feedback-no-decision", OutTrackID: "track-feedback-no-decision", Value: feedback.ValueDown,
|
||||
ReasonCode: feedback.ReasonEmpty, Source: feedback.SourceDingTalkCard,
|
||||
}
|
||||
if _, err := store.RecordFeedback(ctx, command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM support_cases
|
||||
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3 AND coverage_decision_id IS NULL
|
||||
AND trigger_type='feedback_down'`,
|
||||
"tenant-feedback-no-decision", "dingtalk", "turn-feedback-no-decision").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("count=%d", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
)
|
||||
|
||||
func (s *Store) ResolveState(ctx context.Context, scope reasoningmode.Scope, now time.Time) (reasoningmode.State, bool, error) {
|
||||
if err := validateReasoningScope(scope); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, false, fmt.Errorf("begin reasoning state resolve: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err = ensureReasoningState(ctx, tx, scope); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
effective, expired := reasoningmode.EffectiveState(state, now)
|
||||
if expired {
|
||||
if err = updateReasoningState(ctx, tx, scope, effective, now); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.State{}, false, fmt.Errorf("commit reasoning state resolve: %w", err)
|
||||
}
|
||||
return effective, expired, nil
|
||||
}
|
||||
|
||||
func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.Time, idle time.Duration) (reasoningmode.State, error) {
|
||||
if err := validateReasoningScope(scope); err != nil || idle <= 0 {
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
return reasoningmode.State{}, reasoningmode.ErrInvalidState
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("begin reasoning enable: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err = ensureReasoningState(ctx, tx, scope); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state.Mode = reasoningmode.ModeReasoning
|
||||
state.RoundsUsed = 0
|
||||
state.LastActivityAt = now
|
||||
state.ExpiresAt = now.Add(idle)
|
||||
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("commit reasoning enable: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time.Time) (reasoningmode.State, bool, error) {
|
||||
if err := validateReasoningScope(scope); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, false, fmt.Errorf("begin reasoning disable: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err = ensureReasoningState(ctx, tx, scope); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
state, _ = reasoningmode.EffectiveState(state, now)
|
||||
wasReasoning := state.Mode == reasoningmode.ModeReasoning
|
||||
state.Mode = reasoningmode.ModeQuick
|
||||
state.RoundsUsed = 0
|
||||
state.ExpiresAt = time.Time{}
|
||||
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
|
||||
return reasoningmode.State{}, false, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.State{}, false, fmt.Errorf("commit reasoning disable: %w", err)
|
||||
}
|
||||
return state, wasReasoning, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetMaxRounds(ctx context.Context, scope reasoningmode.Scope, maxRounds int, now time.Time) (reasoningmode.State, error) {
|
||||
if err := validateReasoningScope(scope); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
if maxRounds < 1 || maxRounds > 50 {
|
||||
return reasoningmode.State{}, reasoningmode.ErrInvalidState
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("begin reasoning rounds update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err = ensureReasoningState(ctx, tx, scope); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state.MaxRounds = maxRounds
|
||||
if state.RoundsUsed >= maxRounds {
|
||||
state.Mode = reasoningmode.ModeQuick
|
||||
state.RoundsUsed = 0
|
||||
state.ExpiresAt = time.Time{}
|
||||
}
|
||||
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("commit reasoning rounds update: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetHintEnabled(ctx context.Context, scope reasoningmode.Scope, enabled bool, now time.Time) (reasoningmode.State, error) {
|
||||
if err := validateReasoningScope(scope); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("begin reasoning hint update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err = ensureReasoningState(ctx, tx, scope); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
state.HintEnabled = enabled
|
||||
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
|
||||
return reasoningmode.State{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("commit reasoning hint update: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteTurn(ctx context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error) {
|
||||
if err := validateReasoningScope(input.Scope); err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
if strings.TrimSpace(input.TurnID) == "" || input.At.IsZero() || input.Idle <= 0 {
|
||||
return reasoningmode.Completion{}, reasoningmode.ErrInvalidState
|
||||
}
|
||||
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("begin reasoning turn completion: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var existing reasoningmode.Completion
|
||||
err = tx.QueryRow(ctx, `SELECT round_number,showed_hint,auto_closed,showed_onboarding
|
||||
FROM reasoning_mode_turns
|
||||
WHERE tenant_key=$1 AND channel=$2 AND turn_id=$3 AND external_actor_id=$4
|
||||
FOR UPDATE`, input.Scope.TenantKey, input.Scope.Channel, input.TurnID, input.Scope.ExternalActorID).Scan(
|
||||
&existing.RoundNumber, &existing.ShowHint, &existing.AutoClosed, &existing.ShowOnboarding,
|
||||
)
|
||||
if err == nil {
|
||||
if existing.Next, err = selectReasoningState(ctx, tx, input.Scope); err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("commit replayed reasoning completion: %w", err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("find reasoning turn completion: %w", err)
|
||||
}
|
||||
var status string
|
||||
if err = tx.QueryRow(ctx, `SELECT t.status FROM turns t
|
||||
JOIN messages m ON m.id=t.request_message_id
|
||||
JOIN channel_actors a ON a.id=m.actor_id
|
||||
WHERE t.id=$1 AND t.tenant_key=$2 AND t.channel=$3
|
||||
AND a.external_actor_id=$4
|
||||
FOR UPDATE OF t`, input.TurnID, input.Scope.TenantKey, input.Scope.Channel, input.Scope.ExternalActorID).Scan(&status); err != nil {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("lock reasoning completion turn: %w", err)
|
||||
}
|
||||
if status != "succeeded" {
|
||||
return reasoningmode.Completion{}, errors.New("reasoning completion requires a succeeded turn")
|
||||
}
|
||||
|
||||
if err = ensureReasoningState(ctx, tx, input.Scope); err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
state, err := selectReasoningStateForUpdate(ctx, tx, input.Scope)
|
||||
if err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
completion, err := reasoningmode.ApplySuccessfulTurn(state, input.Execution, input.At, input.Idle)
|
||||
if err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
if err = updateReasoningState(ctx, tx, input.Scope, completion.Next, input.At); err != nil {
|
||||
return reasoningmode.Completion{}, err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO reasoning_mode_turns
|
||||
(id,tenant_key,channel,turn_id,external_actor_id,execution_mode,round_number,showed_hint,auto_closed,showed_onboarding)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (tenant_key,channel,turn_id) DO NOTHING`,
|
||||
newSettingsID(), input.Scope.TenantKey, input.Scope.Channel, input.TurnID,
|
||||
input.Scope.ExternalActorID, string(input.Execution), completion.RoundNumber,
|
||||
completion.ShowHint, completion.AutoClosed, completion.ShowOnboarding,
|
||||
)
|
||||
if err != nil {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("insert reasoning turn completion: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return reasoningmode.Completion{}, errors.New("insert reasoning turn completion affected unexpected rows")
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return reasoningmode.Completion{}, fmt.Errorf("commit reasoning turn completion: %w", err)
|
||||
}
|
||||
return completion, nil
|
||||
}
|
||||
|
||||
func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) error {
|
||||
_, err := tx.Exec(ctx, `INSERT INTO reasoning_mode_states
|
||||
(tenant_key,channel,external_actor_id,mode,max_rounds,rounds_used,hint_enabled)
|
||||
VALUES($1,$2,$3,'quick',10,0,TRUE)
|
||||
ON CONFLICT (tenant_key,channel,external_actor_id) DO NOTHING`,
|
||||
scope.TenantKey, scope.Channel, scope.ExternalActorID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure reasoning state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
FROM reasoning_mode_states
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3
|
||||
FOR UPDATE`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
|
||||
}
|
||||
|
||||
func selectReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
|
||||
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
|
||||
FROM reasoning_mode_states
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
|
||||
}
|
||||
|
||||
func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
|
||||
var (
|
||||
state reasoningmode.State
|
||||
mode string
|
||||
last pgtype.Timestamptz
|
||||
expires pgtype.Timestamptz
|
||||
onboarding pgtype.Timestamptz
|
||||
)
|
||||
if err := row.Scan(&mode, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
|
||||
return reasoningmode.State{}, fmt.Errorf("scan reasoning state: %w", err)
|
||||
}
|
||||
state.Mode = reasoningmode.Mode(mode)
|
||||
if last.Valid {
|
||||
state.LastActivityAt = last.Time
|
||||
}
|
||||
if expires.Valid {
|
||||
state.ExpiresAt = expires.Time
|
||||
}
|
||||
if onboarding.Valid {
|
||||
state.OnboardingShown = true
|
||||
state.OnboardingShownAt = onboarding.Time
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func updateReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope, state reasoningmode.State, now time.Time) error {
|
||||
_, err := tx.Exec(ctx, `UPDATE reasoning_mode_states SET
|
||||
mode=$4,max_rounds=$5,rounds_used=$6,hint_enabled=$7,
|
||||
last_activity_at=$8,expires_at=$9,onboarding_shown_at=$10,updated_at=$11
|
||||
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`,
|
||||
scope.TenantKey, scope.Channel, scope.ExternalActorID,
|
||||
string(state.Mode), state.MaxRounds, state.RoundsUsed, state.HintEnabled,
|
||||
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update reasoning state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableTime(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateReasoningScope(scope reasoningmode.Scope) error {
|
||||
if strings.TrimSpace(scope.TenantKey) == "" || strings.TrimSpace(scope.Channel) == "" || strings.TrimSpace(scope.ExternalActorID) == "" {
|
||||
return errors.New("reasoning mode scope is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ reasoningmode.Repository = (*Store)(nil)
|
||||
@@ -0,0 +1,59 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReasoningModeRepositoryUsesScopedAtomicStateTransitions(t *testing.T) {
|
||||
data, err := os.ReadFile("reasoning_mode.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_STATES",
|
||||
"TENANT_KEY=$1",
|
||||
"CHANNEL=$2",
|
||||
"EXTERNAL_ACTOR_ID=$3",
|
||||
"FOR UPDATE",
|
||||
"REASONINGMODE.MODEQUICK",
|
||||
"REASONINGMODE.MODEREASONING",
|
||||
"STRING(STATE.MODE)",
|
||||
"EXPIRES_AT",
|
||||
"MAX_ROUNDS",
|
||||
"HINT_ENABLED",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("reasoning repository missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Count(source, "BEGINTX") < 4 || strings.Count(source, "TX.COMMIT") < 4 {
|
||||
t.Fatal("state transitions must use explicit transactions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningModeRepositoryMakesTurnCompletionIdempotent(t *testing.T) {
|
||||
data, err := os.ReadFile("reasoning_mode.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{
|
||||
"REASONING_MODE_TURNS",
|
||||
"ON CONFLICT (TENANT_KEY,CHANNEL,TURN_ID) DO NOTHING",
|
||||
"ROWSAFFECTED",
|
||||
"APPLYSUCCESSFULTURN",
|
||||
"SELECT ROUND_NUMBER,SHOWED_HINT,AUTO_CLOSED,SHOWED_ONBOARDING",
|
||||
"JOIN MESSAGES M ON M.ID=T.REQUEST_MESSAGE_ID",
|
||||
"JOIN CHANNEL_ACTORS A ON A.ID=M.ACTOR_ID",
|
||||
"T.ID=$1 AND T.TENANT_KEY=$2 AND T.CHANNEL=$3",
|
||||
"A.EXTERNAL_ACTOR_ID=$4",
|
||||
"STATUS != \"SUCCEEDED\"",
|
||||
} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("turn completion repository missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -37,6 +38,14 @@ func (r *SettingsRepository) SetPreference(ctx context.Context, tenant, channel,
|
||||
_, err := r.store.pool.Exec(ctx, `INSERT INTO user_preferences(tenant_key,channel,external_actor_id,language_mode,locale) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,channel,external_actor_id) DO UPDATE SET language_mode=EXCLUDED.language_mode,locale=EXCLUDED.locale,updated_at=NOW()`, tenant, channel, staff, mode, locale)
|
||||
return err
|
||||
}
|
||||
func (r *SettingsRepository) SetReasoningMaxRounds(ctx context.Context, tenant, channel, staff string, rounds int, now time.Time) error {
|
||||
_, err := r.store.SetMaxRounds(ctx, reasoningmode.Scope{TenantKey: tenant, Channel: channel, ExternalActorID: staff}, rounds, now)
|
||||
return err
|
||||
}
|
||||
func (r *SettingsRepository) SetReasoningHintEnabled(ctx context.Context, tenant, channel, staff string, enabled bool, now time.Time) error {
|
||||
_, err := r.store.SetHintEnabled(ctx, reasoningmode.Scope{TenantKey: tenant, Channel: channel, ExternalActorID: staff}, enabled, now)
|
||||
return err
|
||||
}
|
||||
func (r *SettingsRepository) GetPreference(ctx context.Context, tenant, channel, staff string) (string, error) {
|
||||
var locale string
|
||||
err := r.store.pool.QueryRow(ctx, `SELECT locale FROM user_preferences WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, tenant, channel, staff).Scan(&locale)
|
||||
|
||||
@@ -49,7 +49,7 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
|
||||
FROM candidates c
|
||||
WHERE sc.id = c.id
|
||||
RETURNING sc.id, sc.tenant_key, sc.channel, sc.turn_id,
|
||||
sc.notification_attempts, sc.created_at, sc.faq_record_id
|
||||
sc.notification_attempts, sc.created_at, sc.faq_record_id, sc.trigger_type
|
||||
)
|
||||
SELECT
|
||||
cl.id,
|
||||
@@ -62,7 +62,8 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
|
||||
COALESCE(c.conversation_type, 'direct') AS conversation_type,
|
||||
m.created_at AS asked_at,
|
||||
COALESCE(history.previous_questions, ARRAY[]::TEXT[]) AS previous_questions,
|
||||
cl.faq_record_id
|
||||
cl.faq_record_id,
|
||||
cl.trigger_type
|
||||
FROM claimed cl
|
||||
JOIN turns t ON t.id = cl.turn_id
|
||||
AND t.tenant_key = cl.tenant_key
|
||||
@@ -110,16 +111,18 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
|
||||
askedAt time.Time
|
||||
previousQuestions []string
|
||||
faqRecordID string
|
||||
triggerType string
|
||||
)
|
||||
if err := rows.Scan(&id, &tenantKey, &channel, &turnID, &attempts,
|
||||
&question, &senderName, &conversationType, &askedAt,
|
||||
&previousQuestions, &faqRecordID); err != nil {
|
||||
&previousQuestions, &faqRecordID, &triggerType); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("claim scan: %w", err)
|
||||
}
|
||||
cases = append(cases, coverage.PendingSupportCase{
|
||||
SupportNotification: coverage.SupportNotification{
|
||||
CaseID: id,
|
||||
TriggerType: triggerType,
|
||||
Question: question,
|
||||
SenderName: senderName,
|
||||
Channel: channel,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSupportNotificationClaimReadsTriggerType(t *testing.T) {
|
||||
data, err := os.ReadFile("support_notifications.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := strings.ToUpper(string(data))
|
||||
for _, required := range []string{"SC.TRIGGER_TYPE", "CL.TRIGGER_TYPE", "TRIGGERTYPE"} {
|
||||
if !strings.Contains(source, required) {
|
||||
t.Errorf("support claim missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
-4
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -269,12 +270,16 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
|
||||
if event.Content == "" {
|
||||
event.Content = "weknora returned an unspecified error"
|
||||
}
|
||||
// Notify with error type but no content, then return the error
|
||||
// WeKnora also uses response_type=error, done=false for a failed
|
||||
// agent tool that the reasoning loop can recover from. Preserve the
|
||||
// safe event signal, but only abort on an explicitly terminal error.
|
||||
notify(StreamEvent{
|
||||
ResponseType: "error",
|
||||
Done: true,
|
||||
Done: event.Done,
|
||||
})
|
||||
return errors.New(event.Content)
|
||||
if event.Done {
|
||||
return errors.New(event.Content)
|
||||
}
|
||||
case "thinking", "reflection":
|
||||
notify(StreamEvent{
|
||||
ResponseType: event.ResponseType,
|
||||
@@ -282,6 +287,9 @@ func parseSSEWithEventObserver(reader io.Reader, answerObserver AnswerObserver,
|
||||
})
|
||||
case "tool_call", "tool_result":
|
||||
toolName := extractToolName(event.Data)
|
||||
if event.ResponseType == "tool_result" {
|
||||
result.References = appendUniqueReferences(result.References, extractToolResultReferences(event.Data))
|
||||
}
|
||||
notify(StreamEvent{
|
||||
ResponseType: event.ResponseType,
|
||||
Done: event.Done,
|
||||
@@ -344,7 +352,10 @@ func extractToolName(data json.RawMessage) string {
|
||||
}
|
||||
v, ok := m["tool_name"]
|
||||
if !ok {
|
||||
return ""
|
||||
v, ok = m["name"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
@@ -352,3 +363,153 @@ func extractToolName(data json.RawMessage) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func extractToolResultReferences(data json.RawMessage) []core.Reference {
|
||||
var envelope struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Result *struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(data, &envelope) != nil {
|
||||
return nil
|
||||
}
|
||||
success := envelope.Success
|
||||
payloadData := envelope.Data
|
||||
if envelope.Result != nil {
|
||||
success = envelope.Result.Success
|
||||
payloadData = envelope.Result.Data
|
||||
} else if len(payloadData) == 0 || string(payloadData) == "null" {
|
||||
// Live WeKnora SSE flattens sanitized tool result fields into the
|
||||
// event data object alongside tool_name and success. Older fixtures
|
||||
// and SDK variants wrap the result fields in data or result.data.
|
||||
payloadData = data
|
||||
}
|
||||
if !success || len(payloadData) == 0 {
|
||||
return nil
|
||||
}
|
||||
toolName := strings.TrimSpace(envelope.ToolName)
|
||||
if toolName == "" {
|
||||
toolName = strings.TrimSpace(envelope.Name)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
DocumentCount int `json:"document_count"`
|
||||
FetchedChunks int `json:"fetched_chunks"`
|
||||
TotalDocs int `json:"total_docs"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
Results []struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
} `json:"results"`
|
||||
KnowledgeResults []struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
ChunkHitCount int `json:"chunk_hit_count"`
|
||||
} `json:"knowledge_results"`
|
||||
Documents []struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
Title string `json:"title"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
} `json:"documents"`
|
||||
FoundKBs map[string][]string `json:"found_kbs"`
|
||||
}
|
||||
if json.Unmarshal(payloadData, &payload) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
references := make([]core.Reference, 0)
|
||||
switch toolName {
|
||||
case "knowledge_search":
|
||||
if payload.Count <= 0 && len(payload.Results) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, item := range payload.Results {
|
||||
if id := strings.TrimSpace(item.KnowledgeID); id != "" {
|
||||
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.KnowledgeTitle)})
|
||||
}
|
||||
}
|
||||
case "grep_chunks":
|
||||
if payload.DocumentCount <= 0 && len(payload.KnowledgeResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, item := range payload.KnowledgeResults {
|
||||
if id := strings.TrimSpace(item.KnowledgeID); id != "" && item.ChunkHitCount > 0 {
|
||||
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.KnowledgeTitle)})
|
||||
}
|
||||
}
|
||||
case "list_knowledge_chunks":
|
||||
if payload.FetchedChunks > 0 && strings.TrimSpace(payload.KnowledgeID) != "" {
|
||||
references = append(references, core.Reference{
|
||||
KnowledgeID: strings.TrimSpace(payload.KnowledgeID), KnowledgeTitle: strings.TrimSpace(payload.KnowledgeTitle),
|
||||
})
|
||||
}
|
||||
case "get_document_info":
|
||||
if payload.TotalDocs <= 0 && len(payload.Documents) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, item := range payload.Documents {
|
||||
if id := strings.TrimSpace(item.KnowledgeID); id != "" && item.ChunkCount > 0 {
|
||||
references = append(references, core.Reference{KnowledgeID: id, KnowledgeTitle: strings.TrimSpace(item.Title)})
|
||||
}
|
||||
}
|
||||
case "wiki_search", "wiki_read_page":
|
||||
slugs := make([]string, 0, len(payload.FoundKBs))
|
||||
for slug := range payload.FoundKBs {
|
||||
slugs = append(slugs, slug)
|
||||
}
|
||||
sort.Strings(slugs)
|
||||
for _, slug := range slugs {
|
||||
kbIDs := append([]string(nil), payload.FoundKBs[slug]...)
|
||||
sort.Strings(kbIDs)
|
||||
for _, kbID := range kbIDs {
|
||||
if strings.TrimSpace(slug) == "" || strings.TrimSpace(kbID) == "" {
|
||||
continue
|
||||
}
|
||||
references = append(references, core.Reference{
|
||||
ID: "wiki:" + strings.TrimSpace(kbID) + ":" + strings.TrimSpace(slug),
|
||||
KnowledgeTitle: strings.TrimSpace(slug),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
func appendUniqueReferences(current, incoming []core.Reference) []core.Reference {
|
||||
result := append([]core.Reference(nil), current...)
|
||||
seen := make(map[string]struct{}, len(result)+len(incoming))
|
||||
for _, reference := range result {
|
||||
if key := referenceKey(reference); key != "" {
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, reference := range incoming {
|
||||
key := referenceKey(reference)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, reference)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func referenceKey(reference core.Reference) string {
|
||||
if value := strings.TrimSpace(reference.KnowledgeID); value != "" {
|
||||
return "knowledge:" + value
|
||||
}
|
||||
if value := strings.TrimSpace(reference.ID); value != "" {
|
||||
return "id:" + value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -144,6 +144,24 @@ func TestParseSSEErrorDominatesPartialAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSENonTerminalToolErrorContinuesToCompleteAnswer(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"error\",\"content\":\"tool failed\",\"done\":false}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"complete answer\",\"done\":true}\n\n"
|
||||
var observed []StreamEvent
|
||||
result, err := parseSSEWithEventObserver(strings.NewReader(stream), nil, func(event StreamEvent) {
|
||||
observed = append(observed, event)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("non-terminal tool error must not abort stream: %v", err)
|
||||
}
|
||||
if result.Answer != "complete answer" {
|
||||
t.Fatalf("answer=%q, want complete answer", result.Answer)
|
||||
}
|
||||
if len(observed) != 2 || observed[0].ResponseType != "error" || observed[0].Done {
|
||||
t.Fatalf("events=%+v, want a non-terminal error followed by answer", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentChatRequestJSONSerializesImages(t *testing.T) {
|
||||
req := AgentChatRequest{
|
||||
Query: "请描述并分析这张图片。",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package weknora
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLiveDirectToolResultShapeBecomesStructuredReferences(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"get_document_info\",\"success\":true,\"data\":{\"total_docs\":1,\"documents\":[{\"knowledge_id\":\"doc-live\",\"title\":\"现场文档\",\"chunk_count\":8}]}}}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"wiki_search\",\"success\":true,\"data\":{\"found_kbs\":{\"entity/cra\":[\"wiki-kb\"]}}}}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
|
||||
|
||||
result, err := parseSSE(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.References) != 2 {
|
||||
t.Fatalf("references=%+v", result.References)
|
||||
}
|
||||
if result.References[0].KnowledgeID != "doc-live" || result.References[1].ID != "wiki:wiki-kb:entity/cra" {
|
||||
t.Fatalf("references=%+v", result.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFlattenedToolResultShapeBecomesStructuredReferences(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"success\":true,\"count\":1,\"results\":[{\"knowledge_id\":\"doc-flat\",\"knowledge_title\":\"现场文档\"}]}}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
|
||||
|
||||
result, err := parseSSE(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.References) != 1 || result.References[0].KnowledgeID != "doc-flat" {
|
||||
t.Fatalf("references=%+v", result.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalReferencesReplaceSyntheticToolFallbacks(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"success\":true,\"data\":{\"count\":1,\"results\":[{\"knowledge_id\":\"doc-1\",\"knowledge_title\":\"标题\"}]}}}\n\n" +
|
||||
"data: {\"response_type\":\"references\",\"knowledge_references\":[{\"id\":\"chunk-1\",\"knowledge_id\":\"doc-1\",\"content\":\"完整引用一\",\"score\":0.9},{\"id\":\"chunk-2\",\"knowledge_id\":\"doc-1\",\"content\":\"完整引用二\",\"score\":0.8}]}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
|
||||
|
||||
result, err := parseSSE(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.References) != 2 || result.References[0].ID != "chunk-1" || result.References[1].ID != "chunk-2" {
|
||||
t.Fatalf("canonical references were not preserved: %+v", result.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartReasoningToolResultsBecomeStructuredReferences(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"result\":{\"success\":true,\"data\":{\"count\":2,\"results\":[{\"knowledge_id\":\"doc-1\",\"knowledge_title\":\"CRA介绍\"},{\"knowledge_id\":\"doc-2\",\"knowledge_title\":\"CRA手册\"}]}}}}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"grep_chunks\",\"result\":{\"success\":true,\"data\":{\"document_count\":2,\"knowledge_results\":[{\"knowledge_id\":\"doc-2\",\"knowledge_title\":\"CRA手册\",\"chunk_hit_count\":3},{\"knowledge_id\":\"doc-3\",\"knowledge_title\":\"案例视频\",\"chunk_hit_count\":5}]}}}}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"list_knowledge_chunks\",\"result\":{\"success\":true,\"data\":{\"knowledge_id\":\"doc-3\",\"knowledge_title\":\"案例视频\",\"fetched_chunks\":20}}}}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"完整回答\",\"done\":true}\n\n"
|
||||
|
||||
result, err := parseSSE(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.References) != 3 {
|
||||
t.Fatalf("references=%+v", result.References)
|
||||
}
|
||||
for index, want := range []string{"doc-1", "doc-2", "doc-3"} {
|
||||
if result.References[index].KnowledgeID != want {
|
||||
t.Fatalf("references[%d]=%+v want knowledge %s", index, result.References[index], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartReasoningEmptyOrFailedToolResultsRemainUncovered(t *testing.T) {
|
||||
stream := "data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"knowledge_search\",\"result\":{\"success\":true,\"data\":{\"count\":0,\"results\":[]}}}}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"grep_chunks\",\"result\":{\"success\":false,\"data\":{\"document_count\":2,\"knowledge_results\":[{\"knowledge_id\":\"must-not-count\",\"chunk_hit_count\":2}]}}}}\n\n" +
|
||||
"data: {\"response_type\":\"tool_result\",\"data\":{\"tool_name\":\"list_knowledge_chunks\",\"result\":{\"success\":true,\"data\":{\"knowledge_id\":\"also-empty\",\"fetched_chunks\":0}}}}\n\n" +
|
||||
"data: {\"response_type\":\"answer\",\"content\":\"回答\",\"done\":true}\n\n"
|
||||
|
||||
result, err := parseSSE(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.References) != 0 {
|
||||
t.Fatalf("empty/failed tools produced references: %+v", result.References)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user