Compare commits

..

2 Commits

Author SHA1 Message Date
“brainlds dffc716ba0 ci: add huawei branch for vulnerability scanning 2025-09-22 10:52:00 +08:00
“brainlds ad7e88d0dc chore: bump opencv version to 4.8.1.78 2025-09-22 10:45:58 +08:00
5137 changed files with 257089 additions and 610990 deletions
-22
View File
@@ -1,22 +0,0 @@
#!/bin/bash
# PostToolUse hook wrapper for arch-guard.
# Reads the hook JSON from stdin, extracts the edited file path, runs the
# architecture guard, and — if there are violations — surfaces them back to
# the agent via PostToolUse `additionalContext` so it can self-correct.
#
# Why a wrapper (not inline in settings.json):
# - PostToolUse passes data as JSON on stdin (NOT $CLAUDE_FILE — that var
# does not exist). We must parse .tool_input.file_path with jq.
# - Plain stdout is NOT fed back to the agent; only JSON additionalContext is.
#
# Exit 0 always: arch-guard is advisory, it must never block edits.
FILE=$(jq -r '.tool_input.file_path // empty')
[ -z "$FILE" ] && exit 0
OUT=$(bash "$CLAUDE_PROJECT_DIR/scripts/arch-guard.sh" "$FILE" 2>&1)
[ -z "$OUT" ] && exit 0
jq -n --arg ctx "$OUT" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
exit 0
-21
View File
@@ -1,21 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'FILE=$(jq -r \".tool_input.file_path\"); if [ -n \"$FILE\" ] && echo \"$FILE\" | grep -q \"\\.py$\"; then cd $CLAUDE_PROJECT_DIR/src/backend && if [ -f .venv/bin/ruff ]; then .venv/bin/ruff format \"$FILE\" 2>/dev/null; .venv/bin/ruff check --fix \"$FILE\" 2>/dev/null; fi; fi'",
"async": true
},
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/arch-guard-hook.sh",
"async": false
}
]
}
]
}
}
-330
View File
@@ -1,330 +0,0 @@
---
name: approval-module
description: >-
BiSheng 审批模块(审批中心 F025)的架构与代码参考。
覆盖统一审批网关、多场景引擎、多节点流转、outbox 业务执行、站内信通知、异常处理。
迭代审批功能或修复审批相关 Bug 前先读本 skill,可直接定位架构与代码锚点,无需全仓搜索。
TRIGGER when: 用户要改动/修复"审批""审批中心""approval"相关功能(菜单权限申请、频道订阅审批、
知识空间加入审批、审批流程/节点配置、异常处理、outbox/Celery 执行),或排查审批通过后业务未生效、
审批人看不到任务、站内信未发等问题。
---
# 审批模块(审批中心 F025
## ⚠️ 维护契约(修改代码后必读)
**本 skill 是审批模块的唯一权威参考,必须与代码永远一致。**
当你改动以下任意一项时,**同一个改动里必须同步更新本文件对应章节**,否则视为改动未完成:
- 主流程分支逻辑(`ApprovalGate.request_or_pass` 的 pass/flow/exception 分流、`decide_task` / `_advance_after_node_approved` 的节点流转)→ 更新 [§2 架构与主流程](#2-架构与主流程)
- 新增/删除/重命名服务文件或关键方法 → 更新 [§3 代码锚点](#3-代码锚点)
- 新增/删除预置场景或改动其触发入口、Handler → 更新 [§4 预置场景](#4-预置场景)
- 数据库表/状态枚举变化 → 更新 [§5 数据库表](#5-数据库表)
- API 路由增删改 → 更新 [§7 API 列表](#7-api-列表)
- 站内信触发时机/接收人变化 → 更新 [§8 站内信通知矩阵](#8-站内信通知矩阵)
- Celery 队列/路由变化 → 更新 [§6 outbox 与 Celery](#6-outbox-与-celery)
> 自检:改完代码后问自己"本 skill 里有没有哪句话现在变成假的了?"——有就改它。
---
## 1. 概述
审批中心是一套**通用多场景审批引擎**,所有场景共用同一套网关 / 路由 / 流程 / 节点 / 实例 / 任务 / outbox 机制。
**核心原则:审批"通过"与"执行业务"解耦为两步**——通过后只写 `approval_outbox(PENDING)`,由 Celery 异步执行业务 `on_approved()`,成功后实例才置 `EXECUTED`
> ⚠️ **已废弃**:另有一套独立的旧系统——部门知识空间文件上传审批(`approval_request` 表),由 `approval_service.py` + `message_handler.py` 承载,路由在 `/approval/requests/*` 与 `/approval/department-knowledge-space/*`。该功能**已废弃**,仅为兼容存量保留,**不要在其上新增功能**;新需求一律走审批中心引擎。改审批中心时也不要误改它。
---
## 2. 架构与主流程
```
申请人触发业务入口
ApprovalGate.request_or_pass() ← 统一网关,所有场景从这里进入
路由匹配 (approval_route_rule 表,按 sort_order 自上而下)
┌────┴───────────────────────────┐
│ pass 分支 (route_type=pass) │ → instance(APPROVED) + outbox → Celery → on_approved() → EXECUTED
│ flow 分支 (route_type=flow) │ → instance(PENDING) + 首节点 task(PENDING) → 等待审批人
│ 无分支命中 │ → instance(EXCEPTION, route_missing) + 通知管理员
│ 审批人解析为空 │ → instance(EXCEPTION, approver_empty) + 通知管理员
└────────────────────────────────┘
│ (flow 分支被审批人处理)
ApprovalCenterService.decide_task()
通过 → _advance_after_node_approved()
├── 有后续节点(node_order 更大) → 解析下一节点审批人 + 建 tasks + 通知审批人;解析为空 → EXCEPTION(approver_empty)
└── 无后续节点(最后节点) → instance(APPROVED) + outbox → Celery → EXECUTED + 通知申请人
拒绝 → instance(REJECTED) + 通知申请人
撤回 → instance(WITHDRAWN) + 通知有 task 的审批人
```
**多节点 / 会签**`_advance_after_node_approved()` 实现顺序流转。
- OR 节点(`node_mode=or`):任一人通过即把同节点其余 PENDING task 置 SKIPPED 并 advance。
- AND 节点(`node_mode=and`):同节点全部通过才 advance。
- finalize 时若 `handler_key` 未注册,记录 error 后仍照常 APPROVED + 建 outbox(避免卡死)。
**异常实例也留痕**`_create_exception_result()` 在创建异常后会补写 `action='approval.request.submit'` 审计日志(与正常 PENDING/PASS 分支一致)。
---
## 3. 代码锚点
> 路径相对 `src/backend/bisheng/`。这些是定位问题的第一入口。
### 后端服务
| 文件 | 职责 | 关键方法 |
|------|------|---------|
| `approval/domain/services/approval_gate.py` | 统一入口:路由匹配、实例创建、pass/pending/exception 分流 | `request_or_pass()``_create_exception_result()``_notify_admins_of_exception()` |
| `approval/domain/services/approval_center_service.py` | 用户端:任务列表/详情、同意/拒绝、撤回、菜单申请、多节点流转 | `decide_task()``_advance_after_node_approved()``_dispatch_outbox()``_send_approval_notify()` |
| `approval/domain/services/approval_exception_service.py` | 管理端异常处理:重试/指定审批人/跳过节点/取消/标记完成 | `assign_approvers()``_resolve_exception_node()` |
| `approval/domain/services/approval_outbox_service.py` | outbox 执行与重试;成功后置 instance=EXECUTED | `execute_outbox()``retry_outbox()` |
| `approval/domain/services/approval_scenario_admin_service.py` | 管理端:场景/分支/流程/节点配置、异常列表 | — |
| `approval/domain/services/approver_resolver.py` | 解析审批人来源 `direct_user` / `department_admin` / `tenant_admin` | `resolve_approvers_from_sources()` |
| `approval/domain/services/approval_registry.py` | 场景预置目录 + handler 注册表 | `with_default_presets()``register_handler()``get_handler()` |
| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler | `build_runtime_handler(scenario_code)` |
| `approval/domain/services/approval_notification_service.py` | 站内信统一封装 | `notify_user()` / `notify_users()` / `notify_admins()` |
| `approval/domain/services/user_menu_access_service.py` | 菜单授权增删查,含父级菜单依赖自动补全 | `grant_menu_access()``revoke_menu_access()``ensure_application_allowed()` |
| `approval/domain/services/approval_service.py` + `message_handler.py` | **旧系统(已废弃)**:部门知识空间文件上传审批(`approval_request` 表),与审批中心独立,仅兼容存量、勿新增功能 | `ApprovalService.decide_request()` |
| `worker/approval/tasks.py` | Celery 任务(走默认 `celery` 队列) | `execute_approval_outbox``retry_approval_outbox` |
| `worker/config.py` | Celery 路由配置(审批任务**不**配路由,fall through 到默认队列) | `task_routes` |
| `approval/api/endpoints/approval_user.py` | Client 端 API`/api/v1/approval/...` | — |
| `approval/api/endpoints/approval_admin.py` | Platform 管理 API`/api/v1/approval/admin/...` | — |
| `approval/api/endpoints/approval.py` | 旧系统 legacy API`/api/v1/approval/requests/...`),**已废弃** | — |
### 三个场景 Handler
| 文件 | 类 |
|------|----|
| `approval/domain/services/menu_access_handler.py` | `MenuAccessApprovalHandler` |
| `approval/domain/services/channel_subscribe_scenario_handler.py` | `ChannelSubscribeScenarioHandler` |
| `approval/domain/services/knowledge_space_subscribe_scenario_handler.py` | `KnowledgeSpaceSubscribeScenarioHandler` |
### 前端
| 文件 | 职责 |
|------|------|
| `src/frontend/client/src/components/approval/ApprovalCenterDialog.tsx` | 审批中心弹窗(我的审批 + 我的申请 + 时间线) |
| `src/frontend/client/src/api/approval.ts` | 审批 API 封装,含 `ApprovalApiError`(非 200 自动抛出) |
| `src/frontend/client/src/pages/MenuUnavailablePage.tsx` | 无权限占位页 + 申请入口 |
| `src/frontend/client/src/layouts/MenuApprovalPluginGate.tsx` | 菜单审批路由守卫 |
| `src/frontend/platform/src/pages/ApprovalPage/index.tsx` | 管理后台审批页(场景/分支/流程/节点/异常) |
| `src/frontend/platform/src/controllers/API/approval.ts` | Platform 审批 API 封装 |
---
## 4. 预置场景
三个场景由 `ApprovalRegistry.with_default_presets()` 注册(仅是"目录/下拉来源",**不等于已启用**)。每个场景的业务入口在创建 `ApprovalGateRequest` 时**都需要传 `applicant_department_id`**(供 `department_admin` 审批人来源使用,查 `UserDepartmentDao.aget_user_primary_department()`)。
**首次部署自动落库**:4.2 频道订阅审批、4.3 知识空间加入审批由 `common/init_data.py::_init_default_approval_scenarios()`(在 `init_default_data` 内)为默认租户幂等 seed——各建「默认分支(catch-all, route_type=flow) → 默认流程 → 单节点(node_mode=or 或签)」,审批人来源即资源 owner+manager(频道 `channel_owner`/`channel_manager`,知识空间 `knowledge_space_owner`/`knowledge_space_manager`),场景 `enabled=True`。按 `tenant_id+scenario_code` 判存在即跳过,绝不覆盖人工改动。菜单权限申请(4.1)**不**自动 seed。新租户不自动 seed,需管理后台手工配置。
### 4.1 菜单权限申请 (`menu_access_request`)
- **入口**Client `/workspace/menu-unavailable?plugin=xxx``POST /api/v1/approval/menu-access/apply`
- **Handler**`MenuAccessApprovalHandler`
- `on_approved``UserMenuAccessService.grant_menu_access()`,自动补父级依赖(如 `knowledge_space` → 同时授权 `workstation`);`on_revoke``revoke_menu_access()`
- 申请前校验 `ensure_application_allowed()``menu_approval_mode=false` 或已有权限时拒绝)
### 4.2 频道订阅审批 (`channel_subscribe_request`)
- **入口**`channel/domain/services/channel_service.py::subscribe_channel()``REVIEW` 可见性频道)
- **Handler**`ChannelSubscribeScenarioHandler`
- 通过 / pass 路径调 `ChannelService.sync_direct_channel_user_permissions()` 写 ReBAC(OpenFGA) 关系(否则成员不出现在 ReBAC 成员列表)
- `on_approved` 先把申请人的 **PENDING** membership 翻成 ACTIVE 再写 ReBAC(查 membership 注意频道默认只返回 ACTIVE,激活需带非 ACTIVE 状态)
- PENDING 时调 `_send_channel_approval_notification()` 通知审批人
### 4.3 知识空间加入审批 (`knowledge_space_subscribe_request`)
- **入口**`knowledge/domain/services/knowledge_space_service.py::subscribe_space()``auth_type=APPROVAL`
- **Handler**`KnowledgeSpaceSubscribeScenarioHandler`
- 通过 / ACTIVE 路径调 `sync_direct_space_user_permissions()` 写 ReBAC 关系
- PENDING 时调 `_send_space_approval_notification()` 通知审批人
- **不变量:先过网关、再落 membership。** `subscribe_space` 对 APPROVAL 空间必须先 `await gate.request_or_pass()`,按 gate 结果(pass→ACTIVE / pending·exception→PENDING)才通过 `_persist_space_member()``space_channel_member`。**严禁在调网关前预写 PENDING membership**——否则场景未配置/未启用时网关 `raise ApprovalScenarioDisabledError`,但 PENDING 行已落库,下次点"关注"会被 `subscribe_space` 顶部"已 PENDING 直接返回 pending"的早退分支短路,掩盖错误(首次报错、二次假成功)。无场景时每次点击都应一致报错。
---
## 5. 数据库表
| 表名 | 说明 | 关键状态字段 |
|------|------|------------|
| `approval_scenario` | 租户下启用的审批场景 | `enabled` |
| `approval_route_rule` | 场景下条件分支(按 `sort_order` 匹配) | `route_type: pass/flow``enabled` |
| `approval_flow_definition` | 审批流程定义头 | — |
| `approval_flow_version` | 流程版本快照 | `is_active` |
| `approval_node_definition` | 流程版本内顺序节点 | `node_order``node_mode: or/and``approver_config` |
| `approval_instance` | 一次审批申请 | `pending/approved/rejected/withdrawn/executed/execute_failed/exception/cancelled` |
| `approval_task` | 分配给审批人的节点待办 | `pending/approved/rejected/skipped/cancelled` |
| `approval_exception` | 异常记录 | `open/resolved``exception_type: route_missing/approver_empty/execute_failed` |
| `approval_outbox` | 业务执行队列 | `pending/success/failed` |
| `approval_action_log` | 时间线日志 | — |
| `user_menu_access` | 用户级菜单授权(菜单审批专用) | `active/revoked` |
| `approval_request` | **旧系统(已废弃)**:部门知识空间文件上传审批,仅兼容存量 | — |
> 模型定义见 `approval/domain/models/approval_instance.py`、`approval_scenario.py`、`user_menu_access.py`。
> `approval_instance.latest_approver_user_id` 字段已定义但**当前从未赋值**(已知限制,需要时在 `decide_task` 里补)。
---
## 6. outbox 与 Celery
业务执行走 outbox:通过后写 `approval_outbox(PENDING)` → Celery `execute_approval_outbox` 执行 `handler.on_approved()` → 成功 outbox=SUCCESS、instance=EXECUTED;失败 outbox=FAILED、instance=EXECUTE_FAILED 并建 `execute_failed` 异常。
> **原则:业务回调(`on_approved` 等)不得静默失败。** 该执行成功/失败由「是否抛异常」判定:抛异常 → outbox=FAILED + `execute_failed` 异常暴露问题;正常返回 → 一律视为成功并置 instance=EXECUTED。因此前置条件缺失(如找不到要激活的 membership/资源)**必须 raise**,绝不能 `return {'status':'xxx'}` 之类把失败伪装成成功——否则会出现 instance=executed 但业务实际没生效的「假成功」,且无任何告警。
**dispatch 入口(两处,功能相同名字不同):**
- `approval_center_service.py::_dispatch_outbox(outbox_id)``decide_task` 最后节点通过 / skip_node
- `approval_gate.py` PASS 分支 — 调 `execute_approval_outbox.delay(outbox_id)`
**Celery 队列:走默认 `celery` 队列。** `worker/config.py` **不**为 `bisheng.worker.approval.*` 配路由,任务自然 fall through 到默认队列。`workflow_celery` 专供工作流 DAG 执行,审批任务不占用。
> ⚠️ 部署时必须有 worker 消费默认 `celery` 队列(`run_celery.py` 的 `all` / `file` 模式都含),否则审批通过后业务不执行。站内信发送是同步写库,不依赖 Celery。
启动消费默认队列的 worker
```bash
uv run celery -A bisheng.worker.main worker -l info -c 100 -P threads -n default@%h
```
---
## 7. API 列表
> 全局前缀 `/api/v1`。以代码为准(`approval_user.py` / `approval_admin.py` / `approval.py`)。
### 用户端(`/approval`
```
GET /approval/my-tasks # 我的待办(审批人视角)
GET /approval/my-tasks/{task_id} # 任务详情
POST /approval/tasks/{task_id}/decision # 同意/拒绝
GET /approval/my-requests # 我的申请(申请人视角)
GET /approval/instances/{instance_id} # 实例详情(tasks + flow_nodes + action_logs
POST /approval/instances/{instance_id}/withdraw # 撤回
GET /approval/menu-access/pending-check # 菜单申请前置校验
POST /approval/menu-access/apply # 菜单权限申请
POST /approval/menu-access/{instance_id}/revoke-grant # 撤销菜单授权(审批人)
```
### 管理端(`/approval/admin`
```
GET /approval/admin/scenario-presets # 预置场景目录(下拉来源)
GET /approval/admin/scenarios # 场景列表
POST /approval/admin/scenarios # 新增场景
PUT /approval/admin/scenarios/{scenario_id} # 更新场景
DELETE /approval/admin/scenarios/{scenario_id} # 删除场景
GET /approval/admin/scenarios/{scenario_id}/routes # 分支列表
POST /approval/admin/scenarios/{scenario_id}/routes # 新增分支
PUT /approval/admin/routes/{route_rule_id} # 更新分支
DELETE /approval/admin/routes/{route_rule_id} # 删除分支
PATCH /approval/admin/scenarios/{scenario_id}/routes/reorder # 分支排序
GET /approval/admin/scenarios/{scenario_id}/flows # 流程列表
POST /approval/admin/scenarios/{scenario_id}/flows # 新增流程
PUT /approval/admin/flows/{flow_definition_id} # 更新流程
DELETE /approval/admin/flows/{flow_definition_id} # 删除流程
GET /approval/admin/flows/{flow_definition_id}/nodes # 节点配置
PUT /approval/admin/flows/{flow_definition_id}/nodes # 提交节点(全量提交触发新版本)
GET /approval/admin/flows/{flow_definition_id}/versions/{flow_version_id} # 版本预览
GET /approval/admin/exceptions # 异常列表
POST /approval/admin/exceptions/{exception_id}/retry # 重试/指定审批人/跳过节点/标记完成
POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批(必须填原因)
```
### 旧系统 legacy`/approval/requests`、`/approval/department-knowledge-space`)— ⚠️ 已废弃
部门知识空间文件上传审批,独立于审批中心,见 `approval.py`。**已废弃**,仅兼容存量数据,不要在此新增/扩展接口。
---
## 8. 站内信通知矩阵
| 触发时机 | 接收人 | 实现位置 |
|----------|--------|---------|
| 创建审批任务(菜单申请) | 审批人 | `ApprovalCenterService._send_menu_access_approval_messages()` |
| 频道审批创建(PENDING) | 审批人 | `ChannelService._send_channel_approval_notification()` |
| 知识空间审批创建(PENDING) | 审批人 | `KnowledgeSpaceService._send_space_approval_notification()` |
| 中间节点通过、生成下一节点任务 | 下一节点审批人 | `_advance_after_node_approved()``_send_approval_notify('approval_task_pending')` |
| 审批通过(最后节点 finalize) | 申请人 | `_advance_after_node_approved()``_send_approval_notify('approval_instance_approved')` |
| 审批拒绝 | 申请人 | `decide_task()` reject 分支 |
| 申请撤回 | 有 task 的审批人 | `ApprovalCenterService.withdraw_instance()` |
| 异常产生(route_missing/approver_empty | 管理员(AdminRole | `ApprovalGate._notify_admins_of_exception()` / `ApprovalNotificationService.notify_admins()` |
| 异常取消 | 申请人 | `ApprovalExceptionService.cancel_exception_api()` |
> 注:申请人侧"通过"通知是在**最后节点 finalize** 时发的(即审批通过即通知),不等 outbox 业务真正执行完。若要"业务执行成功"的精确通知,需在 `execute_outbox` 成功回调里补。
---
## 9. 审批进度时间轴
`get_instance_detail` 返回三组数据,前端合并展示:
```
action_logs[action=submitted] ← 提交申请
flow_nodes (按 node_order 排序) ← 完整流程骨架(来自 approval_node_definition,含未到达节点)
├── 已有 task → 实际状态
└── 无 task → 灰色"未到达"
action_logs[action!=submitted] ← 撤回/取消等其他日志
```
`flow_nodes` 解决了"tasks 只有已创建节点"的问题,能展示完整流程定义。
---
## 10. 配置要点
条件分支 `match_config` 格式:
```json
{} // 无条件,始终命中(catch-all
{"field": "applicant_role", "value": "dept_admin"} // 申请人是部门管理员
{"field": "menu_key", "value": "knowledge_space"} // 申请特定菜单
{"field": "space_type", "value": "department"} // 知识空间类型
```
`applicant_role` 枚举:`admin`(系统管理员) / `tenant_admin`(租户管理员) / `dept_admin`(部门管理员) / `regular_user`(普通用户, catch-all) / `role_{id}`(特定角色)。
节点 `approver_config.sources` 格式:
```json
[
{"type": "direct_user", "user_ids": [701], "user_names": ["00017"]},
{"type": "department_admin"},
{"type": "tenant_admin"}
]
```
`user_names` 由前端保存时写入,用于节点卡片直接显示用户名,避免二次查库。
---
## 11. 调试指南
### "审批通过但业务没下发"
```sql
SELECT id, status, applicant_user_id FROM approval_instance WHERE id=<N>;
SELECT id, status, error_summary FROM approval_outbox WHERE instance_id=<N>;
```
- outbox 不存在 → `_dispatch_outbox` 没调
- outbox 存在且 `pending` → 没有 worker 消费默认 `celery` 队列
- outbox 存在且 `failed` → 看 `error_summary`,并查 `approval_exception``execute_failed`
手动补偿:
```python
# set_current_tenant_id(tenant_id)
# handler = await build_runtime_handler(outbox.handler_key)
# await handler.on_approved(instance_id, outbox.payload_snapshot)
```
### "审批人看不到任务"
```sql
SELECT id, approver_user_id, status FROM approval_task WHERE instance_id=<N>;
SELECT id, exception_type, status, detail FROM approval_exception WHERE instance_id=<N>;
```
若异常类型是 `approver_empty`:检查 `approval_instance.applicant_department_id` 是否为 NULL,以及节点 `approver_config.sources``department_admin` 是否依赖部门。
### "频道/知识空间审批通过但成员列表看不到"
检查对应 `sync_direct_channel_user_permissions` / `sync_direct_space_user_permissions` 是否在该激活路径被调用(写 ReBAC/OpenFGA 关系)。若 `instance=executed``space_channel_member.status` 仍为 `PENDING`,说明 `on_approved` 没真正激活成员(见 §6 的"业务回调不得静默失败"原则)。
---
## 12. 测试
审批相关测试在 `src/backend/test/approval/``asyncio_mode=auto`)。新测试放到该目录,不放 `test/` 根。
```bash
cd src/backend && uv run pytest test/approval/
```
-154
View File
@@ -1,154 +0,0 @@
# Skill: code-review
## 描述
L2 特性级多维度代码审查。Feature 全部任务完成后执行。
## 触发
```
/code-review --base 2.5.0-PM
```
---
## 审查流程
1. 执行 `git diff 2.5.0-PM...HEAD --stat` 获取变更文件列表
2. 执行 `git diff 2.5.0-PM...HEAD` 获取完整 diff
3. 对照 Feature 的 `spec.md``design.md``tasks.md`
4. 按 7 维度逐一审查
5. 输出审查报告
---
## 7 维度审查框架
### 维度 1:边界条件
| 检查项 | 说明 |
|--------|------|
| null/None 处理 | 外部输入是否校验 None/空字符串 |
| 空集合 | 列表/字典为空时是否正确处理(不抛异常) |
| 数值边界 | 分页 page/size 合法性、ID 为 0/-1 |
| 字符串长度 | 数据库字段长度限制是否在 API 层校验 |
| 超时处理 | 外部调用(LLM/MCP/HTTP)是否设置超时 |
| 分页溢出 | 请求超出总页数时返回空列表而非错误 |
### 维度 2:权限与认证
| 检查项 | 说明 |
|--------|------|
| 认证注入 | 需要认证的端点是否使用 `UserPayload = Depends(UserPayload.get_login_user)` |
| 五级权限链路 | 是否遵循:super_admin → tenant 归属 → tenant admin → ReBAC → RBAC 菜单 |
| PermissionService | 权限检查是否走 `PermissionService.check()` 而非直接查旧表 |
| 资源授权 | 创建资源时是否调用 `PermissionService.authorize()` 写入 owner 元组 |
| tenant_id 隔离 | 跨租户访问是否被阻止(SQLAlchemy event 自动注入) |
| WebSocket 认证 | WS 端点是否使用 `UserPayload.get_login_user_from_ws` |
### 维度 3:并发安全
| 检查项 | 说明 |
|--------|------|
| OpenFGA 双写 | MySQL + OpenFGA 写入是否有失败补偿(failed_tuples 表) |
| 数据库事务 | 多表写入是否在同一事务内 |
| Celery 幂等 | 异步任务是否支持重试不产生副作用 |
| 竞态条件 | 并发创建同名资源是否有唯一约束或乐观锁 |
| 会话状态 | Redis 缓存读写是否考虑过期和并发更新 |
### 维度 4:信息泄漏
| 检查项 | 说明 |
|--------|------|
| 硬编码敏感信息 | 代码中无明文密码/密钥/token |
| 错误信息 | 异常响应不暴露堆栈/SQL/内部路径 |
| 日志脱敏 | logger 输出中敏感字段已脱敏 |
| 前端暴露 | 前端代码不包含后端 IP/密钥/内部 API 路径 |
| tenant_id 泄漏 | API 响应不向前端返回其他租户的 tenant_id |
### 维度 5:测试覆盖
| 检查项 | 说明 |
|--------|------|
| Service 测试 | 核心 Service 方法有单元测试(mock DAO |
| API 测试 | 新端点有集成测试(happy path + 主要 error path |
| AC 覆盖 | spec 中每条 AC 都有对应测试或手动验证 |
| 错误路径 | 权限拒绝、参数校验失败等错误路径有测试 |
| 测试质量 | mock 合理,不 mock 掉核心逻辑 |
> **务实适配**:当前测试基础薄弱,降低阈值但要求核心 Service 方法必须有测试。
> 前端暂用手动验证替代(tasks.md 中有「手动验证」描述即可)。
### 维度 6:代码风格
| 检查项 | 说明 |
|--------|------|
| DDD 分层 | 新代码在正确的层级(domain/services vs api/endpoints |
| 命名一致 | DAO/Service/错误码命名遵循项目约定 |
| 代码重复 | 无复制粘贴式重复逻辑(应提取到 Service 或工具函数) |
| 未使用代码 | 无 dead code、注释掉的代码块、空函数 |
| 格式化 | Python 代码通过 ruff checkhook 自动处理) |
### 维度 7:文档同步(design.md 现状快照)
> **目的**:确保 feature 合入后,新 agent 接手时读 design.md 能 5 分钟建立准确认知 —— 没有过期描述、没有缺失的反直觉坑、没有未声明的对外契约。
| 检查项 | 说明 | 严重度 |
|--------|------|--------|
| design.md 存在 | feature 目录下有 `design.md`(按 `features/_templates/design.md` 起的稿) | HIGH(缺失直接 NEEDS_FIX |
| 决策同步 | diff 中新增/替换的关键技术决策(数据格式、配对/匹配策略、同步 vs 异步、二进制依赖等)在 §3 方案对比有记录,且给出"何时该重新考虑" | HIGH |
| 数据流/契约同步 | diff 触及 API 路径 / 请求响应字段 / 内部 Service 入参出参 / 数据库新表新字段 / 关键文件职责变化 → §4.1 数据流、§4.2 字段约定、§4.3 模块职责对应章节已更新 | HIGH |
| 已知坑同步 | 修了一个"代码里看不出的"反直觉 bug(典型:上游字段格式不符合直觉、运行时值与文档不符、必须的兜底逻辑)→ §5 已知坑 表新增一行(带"如果不知道会怎样"+"在哪处理" | HIGH |
| 契约/依赖同步 | 新增对外 endpoint / 新依赖 chat/knowledge/permission 等模块的隐式契约 / 新增系统二进制依赖 → §6 Outgoing / Incoming 已补 | HIGH |
| 修订历史 | §修订历史 末尾追加了本次 feature 完成的条目(日期 + 改动 + 触发原因) | MEDIUM |
| 与 spec 不冲突 | design.md 对当前实现的描述未与 spec.md AC 矛盾(spec 是不变目标,design 是当前实现,二者口径必须对齐) | HIGH |
**判定要点**
- 若 design.md 与代码现状偏离 → 必须修复(视为 HIGH),不能合入
- 仅本地小修小补(不改变对外行为、不引入新坑、不动决策)→ 本维度全 PASS 即可
- design.md 不存在但已在 SDD 流程要求之内 → HIGH,要求按模板补全后再审
---
## 判定规则
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 无 HIGH 或 MEDIUM | 可合并 |
| **PASS_WITH_WARNINGS** | 仅 MEDIUM 级 | 可合并,记录待改进 |
| **NEEDS_FIX** | 有 HIGH 级 | 修复后重审(最多 2 轮) |
---
## 输出格式
```markdown
# Code Review Report
**Feature**: <feature_name>
**Review scope**: <描述>
**Base branch**: 2.5.0-PM
**Changed files**: <数量>
## Summary
| Dimension | High | Medium | Low | Status |
|-----------|------|--------|-----|--------|
| Boundary Conditions | 0 | 0 | 0 | PASS |
| Permission & Auth | 0 | 0 | 0 | PASS |
| Concurrency Safety | 0 | 0 | 0 | PASS |
| Information Leakage | 0 | 0 | 0 | PASS |
| Test Coverage | 0 | 0 | 0 | PASS |
| Code Style | 0 | 0 | 0 | PASS |
| Docs Sync (design.md) | 0 | 0 | 0 | PASS |
## Findings(如有)
### HIGH
- [Permission] `xxx_endpoint.py:42` — 缺少 PermissionService.check() 调用
### MEDIUM
- [Style] `xxx_service.py:18` — DAO 方法未使用 @classmethod
## Overall: PASS / PASS_WITH_WARNINGS / NEEDS_FIX
```
-251
View File
@@ -1,251 +0,0 @@
---
name: e2e-test
description: >-
为 BiSheng 生成和运行 E2E 测试。两种模式:
(1) SDD 模式 — 基于 feature spec.md 的 AC 生成覆盖;
(2) 自由模式 — 对指定页面/功能写测试。
采用双层策略:API 端到端测试(pytest + httpx)+ 页面手动验证清单。
自动处理认证、多租户隔离、权限检查、Radix UI 交互等常见问题。
用法:/e2e-test [feature_dir] 或 /e2e-test <描述>
当用户说"写 E2E 测试"、"端到端测试"、"E2E coverage"
或使用 /e2e-test 命令时触发。
---
# E2E Test Skill
## 概述
生成并运行 BiSheng 的 E2E 测试,覆盖 API 链路和 UI 交互流程。自动处理 JWT 认证、多租户数据隔离、OpenFGA 权限检查验证、UnifiedResponseModel 响应断言等 BiSheng 特有问题。
## 调用方式
```
/e2e-test <feature_dir> # SDD 模式:基于 spec.md AC 生成
/e2e-test <描述> # 自由模式:对指定页面/功能写测试
```
示例:
```
/e2e-test features/v2.5.0/004-rebac-core
/e2e-test 为租户管理页面写创建流程测试
```
---
## 六步流程
### Step 1:模式识别
解析用户参数:
- **SDD 模式**:参数是 `features/` 开头的路径 → 读取该目录下的 `spec.md`
- **自由模式**:参数是自由文本描述 → 直接进入 Step 3
### Step 2AC 分析(仅 SDD 模式)
读取 `<feature_dir>/spec.md`,从 AC 表格中分类:
**API 行为类**(自动化 pytest 测试):
- CRUD 操作及响应格式
- 权限检查(允许/拒绝)
- 分页、过滤、排序
- 错误码返回(MMMEE
- 跨租户访问拒绝
**UI 交互类**(手动验证清单):
- 表单填写、按钮点击
- 列表展示、搜索过滤
- 弹窗/抽屉交互
- 路由跳转
- 权限控制(按钮隐藏/禁用)
**排除**
- 纯样式/布局调整
- 纯内部状态逻辑
输出分类后的 AC 列表,作为测试用例依据。
### Step 3:基础设施检查
检查共享 helpers 是否存在:
```
src/backend/test/e2e/
├── conftest.py # pytest fixtures(认证、client、cleanup
├── helpers/
│ ├── __init__.py
│ ├── auth.py # JWT 认证 + 用户创建
│ ├── api.py # API 常量 + 通用 CRUD helpers
│ └── cleanup.py # 数据隔离 + 安全 cleanup
└── test_e2e_xxx.py # 各 Feature 的测试文件
```
如果不存在,按照 `references/test-template.md` 创建基础设施。
如果需要新增共享函数,先加到对应的 helpers 文件中。
### Step 4:生成测试
基于 `references/test-template.md` 生成测试文件。
**文件命名**`src/backend/test/e2e/test_e2e_{feature_name}.py`
**强制生成规则(12 条)**
1. **数据隔离(红线)**:测试数据统一 `e2e-{feature}-` 前缀(≥5 字符)。**禁止无条件删除所有资源**——cleanup 必须按前缀过滤,只删本套件创建的数据。E2E 运行前后,非测试数据必须保持不变
2. **双重 cleanup**setup fixture 清理上次残留 + teardown 清理本次数据
3. **测试租户隔离**:使用专用 `test_tenant_id`,不影响正式租户数据。创建测试数据前先确保测试租户存在
4. **认证流程**:通过 helpers 获取 JWT token,注入到请求 headers。测试管理员和普通用户两种角色
5. **响应格式断言**:所有 API 响应必须断言 `UnifiedResponseModel` 格式(`status_code`, `status_message`, `data`
6. **权限测试配对**:每个"允许"操作配对一个"拒绝"测试(不同角色/不同租户)
7. **共享 helpers**:导入 `test/e2e/helpers/` 的函数,**禁止在测试文件内重新定义**通用工具函数
8. **AC 追溯**:每个测试方法的 docstring 标注 `AC-NN: <描述>`
9. **API 验证**:数据变更操作后,通过 GET 请求断言最终状态(不仅依赖创建响应)
10. **错误码精确断言**:业务错误断言具体的 MMMEE 错误码,不仅检查非 200
11. **串行执行**:使用 pytest-ordering 或 class 内方法顺序保证 setup → tests → cleanup
12. **幂等性**:测试可重复运行,不依赖特定的数据库状态(除测试自己创建的数据)
### Step 5:运行与修复
运行生成的测试:
```bash
cd src/backend
.venv/bin/pytest test/e2e/test_e2e_{feature_name}.py -v
```
如果失败,按照 `references/common-pitfalls.md` 的诊断表定位问题。
**最多 3 轮修复**。如果 3 轮后仍有失败,输出剩余问题让用户决定。
**调试技巧**
```bash
# 单个测试
.venv/bin/pytest test/e2e/test_e2e_{feature}.py::TestE2E{Feature}::test_ac01 -v -s
# 显示完整请求/响应
.venv/bin/pytest test/e2e/test_e2e_{feature}.py -v -s --log-cli-level=DEBUG
# 只运行失败的
.venv/bin/pytest test/e2e/test_e2e_{feature}.py --lf -v
```
### Step 6:覆盖报告
输出 AC 覆盖表:
```markdown
# E2E 覆盖报告: <feature_name>
## API 测试结果
| AC-ID | 描述 | 状态 | 测试方法 |
|-------|------|------|---------|
| AC-01 | 创建租户成功 | ✅ 通过 | test_ac01_create_tenant |
| AC-02 | 重复租户名拒绝 | ✅ 通过 | test_ac02_duplicate_name |
| AC-05 | 表单提交创建 | ⏭️ 跳过(UI 交互,见手动清单) | — |
通过: N/M | 跳过: KUI 交互类)| 失败: J
## 手动验证清单
生成位置: `features/v2.5.0/{NNN}-{name}/e2e-checklist.md`
覆盖 AC: AC-05, AC-06, ...
## 整体状态: PASS / PARTIAL / FAIL
```
---
## 手动验证清单格式
当 AC 涉及 UI 交互时,生成结构化验证清单。
**文件位置**`features/v2.5.0/{NNN}-{name}/e2e-checklist.md`
```markdown
# E2E 验证清单: {feature_name}
**测试环境**: http://192.168.106.114:4001 (Platform) / :4001/workspace (Client)
**前置条件**: <描述测试前需要的数据/账号>
## Platform 前端
### AC-05: <描述>
- [ ] 步骤 1: 以管理员登录 Platform (admin/admin123)
- [ ] 步骤 2: 导航到 <页面路径>
- [ ] 步骤 3: 点击 <按钮/元素>
- [ ] 步骤 4: 填写表单: <字段=值>
- [ ] 预期: <具体可观察结果,如 toast 提示、列表刷新>
- [ ] 验证: 刷新页面后数据仍存在
### AC-06: <错误场景描述>
- [ ] 步骤: <触发错误的操作>
- [ ] 预期: <错误提示内容>
## Client 前端(如适用)
### AC-07: <描述>
- [ ] ...
## 回归检查
- [ ] 相关页面(<列出>)正常加载,无 console 错误
- [ ] 既有功能(<列出>)不受影响
- [ ] 不同角色(管理员/普通用户)看到的内容符合权限设定
```
---
## 参考文件
生成测试前**必须阅读**以下参考文件:
| 文件 | 用途 | 何时阅读 |
|------|------|---------|
| `references/test-template.md` | pytest E2E 测试骨架模板 | 生成新测试文件时 |
| `references/common-pitfalls.md` | BiSheng E2E 常见陷阱诊断表 | 测试失败时 |
---
## 已有共享 Helpers 清单
> 首次运行时由 Step 3 自动创建。以下是目标结构。
### `test/e2e/helpers/auth.py`
| 函数 | 签名 | 用途 |
|------|------|------|
| `get_admin_token` | `(client) -> str` | 获取管理员 JWT token |
| `get_user_token` | `(client, username, password) -> str` | 获取指定用户 JWT token |
| `create_test_user` | `(client, admin_token, username, role_id) -> dict` | 创建测试用户 |
| `auth_headers` | `(token) -> dict` | 构建认证请求头 |
### `test/e2e/helpers/api.py`
| 导出 | 用途 |
|------|------|
| `API_BASE` | 后端 API 基础 URL 常量 (`http://localhost:7860/api/v1`) |
| `assert_resp_200(resp)` | 断言 UnifiedResponseModel 成功响应 |
| `assert_resp_error(resp, code)` | 断言 UnifiedResponseModel 错误码 |
| `create_resource(client, path, data, token)` | 通用 POST 创建 |
| `list_resources(client, path, token, params)` | 通用 GET 列表 |
| `delete_resource(client, path, resource_id, token)` | 通用 DELETE |
### `test/e2e/helpers/cleanup.py`
| 函数 | 用途 |
|------|------|
| `cleanup_by_prefix(client, path, prefix, token)` | 安全删除指定前缀的资源。**前缀必须 ≥5 字符**,否则抛错防止误删 |
| `ensure_test_tenant(client, admin_token, tenant_code)` | 确保测试租户存在(不存在则创建) |
---
## 新增 Helper 的规则
当测试需要新的共享函数时:
1. **认证相关** → 加到 `helpers/auth.py`
2. **API 请求/断言** → 加到 `helpers/api.py`
3. **数据管理/fixtures** → 加到 `helpers/cleanup.py`
4. **特定 feature 的 helper** → 留在测试文件内,不提取
提取标准:**2 个以上测试文件使用** → 提取到 helpers。
@@ -1,216 +0,0 @@
# BiSheng E2E 测试常见陷阱与诊断修复
## 陷阱 1:业务错误 HTTP 200
**症状**`assert resp.status_code == 400` 失败,实际收到 200。
**原因**BiSheng 的 `UnifiedResponseModel` 将业务错误包装在 HTTP 200 响应体中,通过 `status_code` 字段区分。
**修复**
```python
# ❌ BiSheng 业务错误也返回 HTTP 200
assert resp.status_code == 400
# ✅ 检查响应体中的 status_code
body = resp.json()
assert body["status_code"] == 10901 # 具体 MMMEE 错误码
assert body["status_message"] != "SUCCESS"
```
---
## 陷阱 2:认证 Token 获取失败
**症状**:登录 API 返回错误,或后续请求 401。
**原因**BiSheng 登录密码需要 RSA 加密。前端从 `/api/v1/user/public_key` 获取公钥后加密。
**修复**
```python
# ✅ 先获取公钥,再加密密码
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
resp = await client.get("/user/public_key")
public_key_pem = resp.json()["data"]["public_key"]
# 加密密码
public_key = serialization.load_pem_public_key(public_key_pem.encode())
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
encrypted_password = base64.b64encode(encrypted).decode()
# 登录
resp = await client.post("/user/login", json={
"user_name": username,
"password": encrypted_password,
})
```
**建议**:将此逻辑封装在 `helpers/auth.py` 中,测试文件直接调用 `get_admin_token()`
---
## 陷阱 3:tenant_id 自动注入导致测试数据不可见
**症状**:创建了数据但 GET 列表查不到。
**原因**SQLAlchemy event 自动注入 `tenant_id` 过滤,测试用户的 tenant_id 与数据不匹配。
**修复**
```python
# ✅ 确保测试用户属于正确的租户
# 1. 创建测试租户
# 2. 将测试用户加入该租户
# 3. 用该用户的 token 创建和查询数据
# ❌ 不要试图绕过 tenant_id(那是安全底线)
```
---
## 陷阱 4OpenFGA 权限未同步
**症状**:创建资源后,同一用户立即查询却被权限拒绝。
**原因**:资源创建时应同步写入 OpenFGA owner 元组,如果 `PermissionService.authorize()` 调用失败或遗漏,用户虽然创建了资源但没有 owner 权限。
**修复**
```python
# ✅ 创建后验证权限元组已写入
resp = await client.post("/resource", json={...}, headers=admin_headers)
data = assert_resp_200(resp)
# 紧接着用同一用户查询,应该能看到
get_resp = await client.get(f"/resource/{data['id']}", headers=admin_headers)
assert_resp_200(get_resp) # 如果失败,说明 OpenFGA 元组没写入
```
---
## 陷阱 5cleanup 顺序错误
**症状**`DELETE /resource/{id}` 返回错误,因为有关联数据未先删除。
**原因**:BiSheng 资源间有关联关系(如知识库→文件、助手→工具/技能/知识库),删除有顺序要求。
**修复**
```python
# ✅ 正确的 cleanup 顺序(依赖关系逆序)
async def cleanup_feature_data(client, token, prefix):
headers = auth_headers(token)
# 1. 先删除依赖方(如关联表、子资源)
# 2. 再删除主资源
# 3. 最后清理 OpenFGA 元组(如有直接操作的话)
# 示例:删除知识库
# 先删知识库文件 → 再删知识库空间
# ❌ 不要假设可以直接删除主资源
```
---
## 陷阱 6:Celery 异步任务未完成就断言
**症状**:创建知识库文件后立即查询,状态还是 `WAITING` 而非 `SUCCESS`
**原因**:文件处理通过 Celery `knowledge_celery` 队列异步执行,创建 API 返回后任务可能还在处理。
**修复**
```python
# ✅ 轮询等待异步任务完成
import asyncio
async def wait_for_status(client, path, token, expected_status, timeout=30):
headers = auth_headers(token)
for _ in range(timeout):
resp = await client.get(path, headers=headers)
data = resp.json()["data"]
if data["status"] == expected_status:
return data
await asyncio.sleep(1)
raise TimeoutError(f"Status not reached: {expected_status}")
# 使用
data = await wait_for_status(
client, f"/knowledge_file/{file_id}",
admin_token, expected_status=2 # SUCCESS
)
```
---
## 陷阱 7:分页参数不一致
**症状**:列表查询返回的数据数量不对。
**原因**BiSheng 不同 API 的分页参数名称可能不同(`page`/`page_num``limit`/`page_size`/`size`)。
**修复**
```python
# ✅ 先查看 API 文档确认参数名
# 常见模式:
resp = await client.get("/resource", params={
"page": 1, # 或 page_num
"limit": 10, # 或 page_size 或 size
}, headers=headers)
# ✅ 响应分页格式(PageData)
data = resp.json()["data"]
items = data["data"] # 列表数据
total = data["total"] # 总数
```
---
## 陷阱 8WebSocket 测试
**症状**:WebSocket 连接失败或消息收不到。
**原因**BiSheng 的 WebSocket 使用特殊的认证方式(`UserPayload.get_login_user_from_ws`),token 通过 query 参数传递。
**修复**
```python
# ✅ WebSocket 认证
import websockets
async with websockets.connect(
f"ws://localhost:7860/api/v1/chat/{flow_id}?t={token}"
) as ws:
# 发送消息
await ws.send(json.dumps({"message": "hello"}))
# 接收响应
response = await ws.recv()
```
---
## 陷阱 9RSA 公钥缓存
**症状**:多个测试用不同用户登录,部分登录失败。
**原因**:公钥可能在短时间内变化,或 RSA 加密使用了错误的 padding。
**修复**
```python
# ✅ 每次登录前重新获取公钥(不缓存)
# helpers/auth.py 中的 get_token() 函数应每次都获取新公钥
```
---
## 快速诊断表
| 错误关键词 | 可能原因 | 首先检查 |
|-----------|---------|---------|
| HTTP 200 但 status_code 非 200 | 业务错误 | 检查 MMMEE 错误码含义 |
| 401 Unauthorized | Token 过期或格式错误 | 重新获取 token,检查 Cookie/Header |
| 查不到刚创建的数据 | tenant_id 不匹配 | 确认用户与资源同租户 |
| 权限拒绝(刚创建的资源) | OpenFGA 元组未写入 | 检查 PermissionService.authorize() |
| DELETE 失败 400/409 | 有关联数据 | 按依赖逆序删除 |
| 异步操作状态不对 | Celery 任务未完成 | 轮询等待 + 增加 timeout |
| 分页数据数量不对 | 参数名不一致 | 查 API 文档确认 page/limit 参数名 |
| 登录失败 | RSA 加密问题 | 检查公钥获取和加密 padding |
| `Connection refused` | 后端未启动 | 确认 localhost:7860 可访问 |
| `Redis connection error` | Redis 未启动 | 确认 Redis 服务运行中 |
@@ -1,226 +0,0 @@
# E2E 测试文件骨架模板
## 完整 pytest 模板
```python
"""
E2E tests for <FEATURE_NAME>
Prerequisites:
- Backend running on localhost:7860
- MySQL/Redis/Milvus/ES/OpenFGA services running
Covers:
- AC-01: <description>
- AC-02: <description>
"""
import pytest
import httpx
from test.e2e.helpers.auth import get_admin_token, get_user_token, auth_headers, create_test_user
from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error
from test.e2e.helpers.cleanup import cleanup_by_prefix, ensure_test_tenant
# Data prefix for test isolation (must be >= 5 chars)
PREFIX = "e2e-<feature>-"
# Test tenant for multi-tenant isolation
TEST_TENANT = "e2e-<feature>-tenant"
class TestE2E<FeatureName>:
"""E2E: <feature_name>"""
# ──────── Fixtures ────────
@pytest.fixture(autouse=True, scope="class")
async def setup_and_teardown(self):
"""双重 cleanup: setup 清理上次残留 + teardown 清理本次"""
async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client:
# Setup: 获取 admin token
admin_token = await get_admin_token(client)
headers = auth_headers(admin_token)
# Setup: 确保测试租户存在
await ensure_test_tenant(client, admin_token, TEST_TENANT)
# Setup: 清理上次残留的测试数据
await cleanup_by_prefix(client, "/resource", PREFIX, admin_token)
yield # 运行测试
# Teardown: 清理本次创建的测试数据
await cleanup_by_prefix(client, "/resource", PREFIX, admin_token)
@pytest.fixture
async def client(self):
"""提供 httpx AsyncClient"""
async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client:
yield client
@pytest.fixture
async def admin_token(self, client):
"""获取管理员 token"""
return await get_admin_token(client)
@pytest.fixture
async def user_token(self, client, admin_token):
"""创建并返回普通用户 token"""
user = await create_test_user(
client, admin_token,
username=f"{PREFIX}user",
role_id=2 # DefaultRole
)
return await get_user_token(client, user["user_name"], "test_password")
# ──────── Happy Path Tests ────────
async def test_ac01_create_success(self, client, admin_token):
"""AC-01: <操作描述> → <预期结果>"""
headers = auth_headers(admin_token)
# 创建资源
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}test-entity"},
headers=headers,
)
# 断言 UnifiedResponseModel 成功格式
data = assert_resp_200(resp)
assert data["name"] == f"{PREFIX}test-entity"
assert "id" in data
# 通过 GET 验证最终状态(不仅依赖创建响应)
get_resp = await client.get(f"/resource/{data['id']}", headers=headers)
get_data = assert_resp_200(get_resp)
assert get_data["name"] == f"{PREFIX}test-entity"
async def test_ac02_list_with_pagination(self, client, admin_token):
"""AC-02: 分页查询资源列表"""
headers = auth_headers(admin_token)
resp = await client.get(
"/resource",
params={"page": 1, "limit": 10},
headers=headers,
)
data = assert_resp_200(resp)
assert "data" in data # PageData format
assert "total" in data
# ──────── Error Path Tests ────────
async def test_ac03_duplicate_name_rejected(self, client, admin_token):
"""AC-03: 重复名称 → 返回 MMMEE 错误码"""
headers = auth_headers(admin_token)
# 创建第一个
await client.post(
"/resource",
json={"name": f"{PREFIX}duplicate"},
headers=headers,
)
# 创建同名第二个
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}duplicate"},
headers=headers,
)
# 断言具体错误码(不仅检查非 200)
assert_resp_error(resp, expected_code=10901) # MMMEE
# ──────── Permission Tests ────────
async def test_ac04_unauthorized_access_denied(self, client, user_token, admin_token):
"""AC-04: 普通用户无权访问管理接口 → 权限拒绝"""
headers = auth_headers(user_token)
resp = await client.get("/admin-only-resource", headers=headers)
assert_resp_error(resp, expected_code=10601) # user permission denied
async def test_ac05_cross_tenant_blocked(self, client, admin_token):
"""AC-05: 跨租户访问 → tenant_id 不匹配拒绝"""
# 创建资源属于 tenant A
headers_a = auth_headers(admin_token) # tenant A
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}tenant-a-only"},
headers=headers_a,
)
resource_id = assert_resp_200(resp)["id"]
# 用 tenant B 的 token 尝试访问
# (需要创建 tenant B 的用户和 token
# headers_b = auth_headers(tenant_b_token)
# resp = await client.get(f"/resource/{resource_id}", headers=headers_b)
# assert resp.status_code == 200
# body = resp.json()
# assert body["status_code"] != 200 # 应该被拒绝
```
## 关键结构规则
1. **class-based 组织** — 每个 Feature 一个 TestClassfixture 管理生命周期
2. **setup_and_teardown 是 class-scoped** — 确保整个类运行前清理 + 运行后清理
3. **每个测试方法 docstring 标注 AC-NN** — 追溯到 spec.md 的 AC 表格
4. **PREFIX 常量** — 所有测试数据以 `e2e-{feature}-` 开头
5. **API 验证** — 数据变更后,通过 GET 断言最终状态
6. **共享 helpers** — 认证/断言/清理使用 `test/e2e/helpers/`,不在文件内重定义
7. **权限配对** — 每个 "允许" 操作配对一个 "拒绝" 测试
## 响应断言模式
```python
# ✅ 正确:断言 UnifiedResponseModel 完整格式
def assert_resp_200(resp):
assert resp.status_code == 200
body = resp.json()
assert body["status_code"] == 200
assert body["status_message"] == "SUCCESS"
return body["data"]
# ✅ 正确:断言具体 MMMEE 错误码
def assert_resp_error(resp, expected_code):
body = resp.json()
assert body["status_code"] == expected_code
# ❌ 错误:只检查 HTTP 状态码
assert resp.status_code == 400 # BiSheng 业务错误也返回 HTTP 200
```
## 认证模式
```python
# ✅ JWT Cookie 认证(BiSheng 主要认证方式)
headers = {"Cookie": f"access_token_cookie={token}"}
# ✅ 或 Header 认证
headers = {"Authorization": f"Bearer {token}"}
# 获取 token
resp = await client.post("/user/login", json={
"user_name": "admin",
"password": "<rsa_encrypted_password>"
})
token = resp.json()["data"]["access_token"]
```
## 多租户测试模式
```python
# ✅ 测试租户隔离
TEST_TENANT_CODE = "e2e-feature-tenant"
# setup: 确保测试租户存在
await ensure_test_tenant(client, admin_token, TEST_TENANT_CODE)
# 创建属于测试租户的数据
# tenant_id 由 SQLAlchemy event 自动注入,不需手动设置)
# 验证:不同租户的用户看不到此数据
```
-13
View File
@@ -1,13 +0,0 @@
---
name: i18n-localizer
description: Internationalize a module by extracting hardcoded Chinese strings, generating translation keys, and updating all three locale files (en, zh-Hans, ja).
---
# i18n Localizer
This skill extracts hardcoded Chinese strings from a React module and replaces them with `useLocalize()` calls, keeping all three locale files in sync.
## Instructions
1. **Read the Workflow**: Read the content of `resources/INSTRUCTIONS.md` for the complete step-by-step process.
2. **Read the Conventions**: Read `resources/CONVENTIONS.md` for key naming rules and usage patterns.
4. **Execute**: Follow the workflow to localize the target module.
@@ -1,133 +0,0 @@
# i18n Conventions for This Project
## Technology Stack
- **Library**: `i18next` (v24+) + `react-i18next` (v15+) + `i18next-browser-languagedetector` (v8+)
- **Supported Languages**: `en` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese)
## File Locations
| File | Purpose |
|------|---------|
| `src/locales/i18n.ts` | i18next initialization and configuration |
| `src/locales/en/translation.json` | English translations |
| `src/locales/zh-Hans/translation.json` | Simplified Chinese translations |
| `src/locales/ja/translation.json` | Japanese translations |
| `src/hooks/useLocalize.ts` | Custom hook wrapping `useTranslation` with Recoil lang state |
## Key Naming Convention
### Domain Namespaces
Keys are organized by domain namespace. Each domain is a top-level object in the JSON:
| Namespace | Scope |
|-----------|-------|
| `com_ui` | General UI elements (buttons, labels, status text) |
| `com_nav` | Navigation, sidebar, top bar, menus |
| `com_auth` | Authentication (login, register, password) |
| `com_endpoint` | LLM endpoint configuration |
| `com_sop` | SOP / task execution features |
| `com_knowledge` | Knowledge base management |
| `com_tools` | Tool panel and tool-related features |
| `com_agent` | Agent-related features |
| `com_app` | App center / agent marketplace |
| `com_invite` | Invitation features |
| `com_linsight` | Linsight-specific features |
| `com_label` | Label / tagging features |
| `com_search` | Search-related features |
| `com_file` | File management |
| `com_message` | Chat message related |
| `com_segment` | Mode segment features |
### Key Naming Rules
1. Use **snake_case** (all lowercase, underscores between words).
2. Keep keys **descriptive but concise** (2-5 words).
3. For similar operations, use consistent suffixes: `_success`, `_error`, `_failed`, `_confirm`, `_placeholder`, `_title`, `_desc`.
4. Do NOT include the translated text in the key name.
## JSON File Format
> [!IMPORTANT]
> **Legacy keys** (flat format like `"com_ui_cancel": "Cancel"`) MUST be left as-is. Do NOT refactor them.
> **New keys** MUST use the nested namespace format described below.
### New Key Format (Nested)
New keys use nested objects grouped by domain namespace:
```json
{
"com_ui_cancel": "Cancel",
"com_ui_delete": "Delete",
"com_knowledge": {
"space_create_success": "Knowledge space created",
"space_deleted": "Space has been dissolved",
"folder_max_depth": "Folder depth limit reached (10 levels)",
"drop_to_upload": "Drop files here to upload"
}
}
```
- Old flat keys like `"com_ui_cancel"` stay untouched at root level.
- New keys go inside their namespace object (e.g. `com_knowledge.space_create_success`).
- Within each namespace object, keys are sorted alphabetically.
- Namespace objects are placed after all legacy flat keys, also sorted alphabetically.
### Interpolation
- Use `{{0}}`, `{{1}}` for positional args; `{{name}}` for named args.
- Use `$t(keyName)` to reference other keys inline.
## Usage in Components
### Import Pattern
```tsx
// Preferred: via the barrel export
import { useLocalize } from "~/hooks";
// Alternative: direct import
import useLocalize from "~/hooks/useLocalize";
```
### Component Usage
```tsx
function MyComponent() {
const localize = useLocalize();
return (
<div>
{/* New nested key — use dot notation */}
<h1>{localize("com_knowledge.title")}</h1>
{/* Legacy flat key — unchanged */}
<button>{localize("com_ui_cancel")}</button>
{/* With interpolation */}
<p>{localize("com_knowledge.files_count", { 0: fileCount })}</p>
</div>
);
}
```
### Toast Messages
```tsx
showToast({
message: localize("com_knowledge.space_create_success"),
severity: NotificationSeverity.SUCCESS
});
```
## Interpolation Examples
| Pattern | Locale Value | Code |
|---------|-------------|------|
| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `localize("key", { 0: selected, 1: total })` |
| Named | `"File: {{name}} exceeds {{size}}MB"` | `localize("key", { name, size })` |
| Nested ref | `"$t(linsight)正在规划..."` | Automatically resolved by i18next |
| Plural (count) | `"剩余任务次数: {{count}}次"` | `localize("key", { count: remaining })` |
@@ -1,124 +0,0 @@
# i18n Conventions for Platform Frontend (src/frontend/platform/)
## Technology Stack
- **Library**: `i18next` (v23+) + `react-i18next` (v15+) + `i18next-http-backend` (v2+)
- **Supported Languages**: `en-US` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese)
## File Locations
| File | Purpose |
|------|---------|
| `src/i18n.js` | i18next initialization (HTTP backend loader) |
| `public/locales/en-US/{ns}.json` | English translations |
| `public/locales/zh-Hans/{ns}.json` | Simplified Chinese translations |
| `public/locales/ja/{ns}.json` | Japanese translations |
## Namespace Files
Platform uses **multiple namespace files** per language (loaded via HTTP backend at runtime):
| Namespace | File | Scope |
|-----------|------|-------|
| `bs` | `bs.json` | General UI, common labels, system messages |
| `flow` | `flow.json` | Flow/workflow builder, nodes, edges |
| `model` | `model.json` | LLM model management, fine-tuning |
| `tool` | `tool.json` | Tool/plugin management |
| `dashboard` | `dashboard.json` | Dashboard, charts, analytics |
| `knowledge` | `knowledge.json` | Knowledge base management |
> When adding keys, choose the namespace that best matches the module the string belongs to. Default to `bs` for cross-cutting or ambiguous strings.
## Key Naming Convention
### Key Naming Rules
1. Use **dot-separated paths** for hierarchy: `knowledge.spaceCreateSuccess`.
2. Use **camelCase** for leaf keys.
3. Keep keys **descriptive but concise** (2-5 words).
4. For similar operations, use consistent suffixes: `Success`, `Error`, `Failed`, `Confirm`, `Placeholder`, `Title`, `Desc`.
### Example Keys
```json
// public/locales/zh-Hans/bs.json
{
"deleteConfirm": "确定要删除吗?",
"saveSuccess": "保存成功",
"cancel": "取消"
}
// public/locales/zh-Hans/knowledge.json
{
"spaceCreateSuccess": "知识空间创建成功",
"dropToUpload": "松手即可上传文件至此处",
"folderMaxDepth": "文件夹层级已达上限(10层)"
}
```
## JSON File Format
- Each namespace is a **flat key-value** JSON object (no nesting).
- Keys are sorted alphabetically.
- Use `{{0}}`, `{{1}}` for positional interpolation, `{{name}}` for named interpolation.
- Do NOT duplicate existing keys — search before adding.
## Usage in Components
### Import Pattern
```tsx
import { useTranslation } from "react-i18next"
```
### Component Usage
```tsx
function MyComponent() {
const { t } = useTranslation()
return (
<div>
{/* Default namespace (bs) */}
<button>{t('cancel')}</button>
{/* Specific namespace */}
<h1>{t('knowledge:spaceCreateSuccess')}</h1>
{/* With interpolation */}
<p>{t('knowledge:filesCount', { 0: fileCount })}</p>
</div>
)
}
```
### Toast Messages
```tsx
import { toast } from "@/components/bs-ui/toast/use-toast"
toast({
title: t('prompt'),
variant: 'success',
description: t('knowledge:spaceCreateSuccess')
})
```
### Specifying Namespace via useTranslation
```tsx
// Load a specific namespace
const { t } = useTranslation('knowledge')
// Now t('spaceCreateSuccess') resolves from knowledge.json
// Load multiple namespaces
const { t } = useTranslation(['bs', 'knowledge'])
```
## Interpolation Examples
| Pattern | Locale Value | Code |
|---------|-------------|------|
| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `t('key', { 0: selected, 1: total })` |
| Named | `"文件: {{name}} 超过 {{size}}MB"` | `t('key', { name, size })` |
| Count | `"剩余任务次数:{{count}}次"` | `t('key', { count: remaining })` |
@@ -1,78 +0,0 @@
# i18n Localization Workflow
## Step 1 — Scan the Module
1. Read all `.tsx` and `.ts` files in the target module directory.
2. Identify every hardcoded user-facing string (Chinese text, toast messages, placeholders, button labels, titles, tooltips, error messages, etc.).
3. Ignore: code comments, CSS class names, variable names, enum values, strings already wrapped in `t()` / `localize()` / `i18n.t()`, and dev-only content (`console.log`).
## Step 2 — Generate Translation Keys
For each extracted string, determine which domain namespace it belongs to (e.g. `com_knowledge`, `com_ui`, `com_sop`), then generate a concise snake_case key name.
Example: `"知识空间创建成功"` → namespace `com_knowledge`, key `space_create_success` → used as `com_knowledge.space_create_success`
Refer to `CONVENTIONS.md` and `SAMPLE_KEYS.json` for naming details.
## Step 3 — Update Locale Files
> **CRITICAL**: Legacy flat keys (like `"com_ui_cancel"`) MUST be left untouched. Only ADD new keys using the nested namespace format.
Add new keys to **all three** translation files using nested structure:
```json
{
"com_ui_cancel": "Cancel",
"com_knowledge": {
"space_create_success": "Knowledge space created",
"drop_to_upload": "Drop files here to upload"
}
}
```
| File | Value |
|------|-------|
| `src/locales/zh-Hans/translation.json` | Original Chinese string |
| `src/locales/en/translation.json` | Professional English translation |
| `src/locales/ja/translation.json` | Professional Japanese translation |
Rules:
- Do NOT modify or restructure existing flat keys.
- New keys go inside their namespace object, sorted alphabetically.
- If the namespace object already exists, append to it. If not, create it.
- Namespace objects are placed after all legacy flat keys, sorted alphabetically.
- Use `{{0}}` for positional interpolation, `{{name}}` for named interpolation.
- Do NOT duplicate existing keys — search before adding.
## Step 4 — Update Component Code
1. Import (if not present): `import { useLocalize } from "~/hooks";`
2. Initialize (if not present): `const localize = useLocalize();`
3. Replace hardcoded strings using **dot notation** for new nested keys:
```tsx
// Before
showToast({ message: "知识空间创建成功" });
// After
showToast({ message: localize("com_knowledge.space_create_success") });
// Before (with dynamic values)
message: `已开始处理 ${files.length} 个文件`
// After
message: localize("com_knowledge.files_processing_started", { 0: files.length })
// Before (JSX)
<p>松手即可上传文件至此处</p>
// After
<p>{localize("com_knowledge.drop_to_upload")}</p>
```
## Step 5 — Verify
1. No hardcoded Chinese remains in modified files (excluding code comments).
2. Every new key exists in all three locale JSON files.
3. No existing flat keys were modified or restructured.
## Output
After completing, provide a summary: number of strings extracted, list of new keys, and files modified.
@@ -1,13 +0,0 @@
---
name: react-component-refactor
description: Refactor large React components by extracting hooks, splitting sub-components, and organizing directory structure following established patterns.
---
# React Component Refactor
This skill provides a systematic approach for refactoring complex React components. Use it when a module has overgrown files, tangled state, or unclear separation of concerns.
## Instructions
1. **Read the Guidelines**: Read `resources/GUIDELINES.md` for the complete refactoring checklist and rules.
2. **Read the Examples**: Read `resources/EXAMPLES.md` for concrete before/after patterns from real refactoring work.
3. **Execute**: Follow the guidelines to refactor the target module.
@@ -1,185 +0,0 @@
# React Component Refactoring — Real Examples
These examples are drawn from the `Subscription` module refactoring and demonstrate each pattern in context.
---
## Example 1: Extract Sub-Component
### Before (in `CreateChannelDrawer.tsx`, ~120 lines inline)
```tsx
// Inline sub-component buried inside the main component
function CreateChannelDrawer({ open, onOpenChange, ... }) {
// ... 18 useState calls ...
// Inline sub-component — hard to find, test, or reuse
function SubChannelBlock({ data, onNameChange, ... }) {
// 120 lines of JSX + local state
}
return ( /* uses SubChannelBlock inline */ );
}
```
### After
```
CreateChannel/
├── CreateChannelDrawer.tsx # imports SubChannelBlock
└── SubChannelBlock.tsx # standalone, with exported Props interface
```
```tsx
// SubChannelBlock.tsx
export interface SubChannelData { id: string; name: string; ... }
interface SubChannelBlockProps {
data: SubChannelData;
onNameChange: (name: string) => void;
onRemove: () => void;
// ...
}
export function SubChannelBlock({ data, onNameChange, ... }: SubChannelBlockProps) {
// self-contained component
}
```
---
## Example 2: Extract Form State Hook
### Before (`CreateChannelDrawer.tsx` — 18 useState + handlers)
```tsx
function CreateChannelDrawer(...) {
const [channelName, setChannelName] = useState("");
const [channelDesc, setChannelDesc] = useState("");
const [visibility, setVisibility] = useState("private");
const [sources, setSources] = useState([]);
// ... 14 more useState calls ...
const resetForm = () => { /* reset all 18 states */ };
const handleAddSubChannel = () => { /* manipulate subChannels state */ };
// ... more handlers ...
return ( /* 400+ lines of JSX using all these states */ );
}
```
### After
```
hooks/
└── useCreateChannelForm.ts # all 18 states + handlers
CreateChannel/
└── CreateChannelDrawer.tsx # clean UI component
```
```tsx
// hooks/useCreateChannelForm.ts
export function useCreateChannelForm() {
const [channelName, setChannelName] = useState("");
// ... all states ...
const resetForm = () => { /* ... */ };
const handleAddSubChannel = () => { /* ... */ };
return { channelName, setChannelName, ..., resetForm, handleAddSubChannel };
}
// CreateChannelDrawer.tsx — now a presentational component
function CreateChannelDrawer(...) {
const form = useCreateChannelForm();
return (
<Input value={form.channelName} onChange={e => form.setChannelName(e.target.value)} />
// ... form.visibility, form.handleAddSubChannel, etc.
);
}
```
---
## Example 3: Extract Data Manager Hook
### Before (`AddSourceDropdown.tsx` — 497 lines with data loading + UI)
```tsx
function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) {
const [wechatSources, setWechatSources] = useState([]);
const [websiteSources, setWebsiteSources] = useState([]);
const [searchKeyword, setSearchKeyword] = useState("");
// Data loading effect
useEffect(() => {
if (!expanded) return;
const load = async () => { /* API call + state mapping */ };
load(currentType);
}, [expanded, activeTab]);
// WeChat auto-detection effect
useEffect(() => { /* 50 lines of async logic */ }, [expanded, viewMode]);
// Filtering logic
const filteredSources = useMemo(() => { /* ... */ }, [...]);
return ( /* 200+ lines of UI */ );
}
```
### After
```
hooks/
└── useSourceManager.ts # API calls, filtering, toggle logic
CreateChannel/
└── AddSourceDropdown.tsx # pure UI (328 lines, down from 497)
```
```tsx
// AddSourceDropdown.tsx — clean separation
function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) {
const mgr = useSourceManager(sources, onSourcesChange, expanded, onExpandChange);
return (
<Input value={mgr.searchKeyword} onChange={e => mgr.setSearchKeyword(e.target.value)} />
// ... mgr.filteredSources, mgr.toggleSource, mgr.handleConfirm, etc.
);
}
```
---
## Example 4: Extract Validation to Utility
### Before (inline in submit handler — 45 lines of validation)
```tsx
onClick={async () => {
if (form.sources.length < 1) { showToast({ message: "..." }); return; }
if (!form.channelName.trim()) { showToast({ message: "..." }); return; }
if (form.contentFilter) {
const err = validateFilterGroups(form.filterGroups);
if (err) { showToast({ message: err }); return; }
}
if (form.createSubChannel) {
for (const sub of form.subChannels) { /* more checks */ }
}
// ... then build data and submit
}}
```
### After
```tsx
// channelUtils.ts — pure validation function
export function validateCreateChannelForm(
data: CreateChannelFormData,
localize: (key: string) => string
): string | null {
if (data.sources.length < 1) return localize("need_one_source") || "至少需添加 1 个信息源";
if (!data.channelName.trim()) return localize("cannot_empty_channel_name");
// ... all checks ...
return null;
}
// CreateChannelDrawer.tsx — clean submit handler
onClick={async () => {
const data = { /* assemble form data */ };
const error = validateCreateChannelForm(data, localize);
if (error) { showToast({ message: error, severity: "warning" }); return; }
// submit
}}
```
@@ -1,159 +0,0 @@
# React Component Refactoring Guidelines
This document defines the standard refactoring methodology for this project. Follow these rules when adding new features or refactoring existing modules to keep code maintainable and consistent.
---
## 1. Directory Structure Rules
### When to create a sub-directory
- When a feature area has **3+ closely related component files**, group them into a named sub-directory.
- The directory name should describe the **feature**, not the component (e.g., `CreateChannel/`, not `CreateChannelDrawerFiles/`).
### Standard layout
```
src/pages/ModuleName/
├── index.tsx # Page entry, layout & routing
├── moduleUtils.ts # Pure utility functions (validation, data transform, payload builders)
├── hooks/ # Custom hooks (one hook per file)
│ ├── useFeatureForm.ts # Form state & handlers
│ └── useDataManager.ts # Data fetching, filtering, CRUD
├── FeatureA/ # Feature sub-directory
│ ├── MainComponent.tsx # Top-level feature component
│ ├── SubComponentA.tsx # Extracted sub-component
│ └── SubComponentB.tsx # Another extracted sub-component
└── FeatureB/
└── ...
```
### Import path conventions
- Components within the same feature directory use relative imports: `./SubComponent`
- Hooks are imported from `../hooks/useXxx`
- Utils are imported from `../moduleUtils`
---
## 2. Component Splitting Rules
### When to extract a sub-component
- An inline function component is **>120 lines**.
- A block of JSX is **self-contained** (has its own props/state concept).
- A component is **reused** or could be tested independently.
### How to extract
1. Create a new file in the same feature directory.
2. Define a clear `Props` interface and export it.
3. Move the component body; keep UI unchanged.
4. Import and use in the parent — the parent JSX should only change the component reference.
### Naming conventions
- Sub-component file name = component name (PascalCase): `SubChannelBlock.tsx`
- Always `export function ComponentName` (named exports, no default).
- Co-export related types/interfaces that are tightly coupled.
---
## 3. Hook Extraction Rules
### When to extract a hook
- A component has **≥8 `useState` calls**.
- There is a block of **`useEffect` + state** that handles data loading or side effects.
- Multiple event handlers share the same state and form a logical unit.
### Naming conventions
- File: `hooks/useFeatureName.ts` (camelCase with `use` prefix)
- Hook function: `useFeatureName`
- Return a flat object: `{ stateA, setStateA, handlerB, ... }`
- The consuming component accesses via `const form = useFeatureName(...)` and references `form.stateA`
### What belongs in a hook
| Belongs in Hook | Stays in Component |
|---|---|
| `useState` declarations | JSX rendering |
| Derived/computed values (`useMemo`) | Layout-specific handlers (e.g., scroll position) |
| Data loading `useEffect`s | Event handlers that only call `showToast` |
| CRUD handlers (add/remove/update) | Direct UI event wiring |
| Form reset logic | |
### What does NOT belong in a hook
- UI library calls (`showToast`, `localize`) — pass as params if needed
- API layer definitions — keep in `~/api/`
- Component-specific render helpers
---
## 4. Utility / Validation Extraction Rules
### When to extract to `moduleUtils.ts`
- **Validation functions** that check form data and return error messages.
- **Payload builders** that transform form data into API payloads.
- **Data transformers** that convert between API types and UI types.
- **Pure functions** that don't depend on React state or hooks.
### Function signature pattern
```typescript
// Validation: returns error message or null
export function validateFormData(
data: FormDataType,
localize: (key: string) => string
): string | null;
// Payload builder: transforms form → API payload
export function buildPayload(data: FormDataType): ApiPayloadType;
```
### Rules
- Keep functions pure — no side effects.
- Accept `localize` as a parameter for i18n error messages.
- The component is responsible for displaying errors (toast/UI).
---
## 5. Refactoring Checklist
When refactoring a module, follow this order:
1. **[ ] Analyze** — Count lines, identify state density, find inline sub-components.
2. **[ ] Restructure directories** — Group files by feature if threshold met.
3. **[ ] Extract sub-components** — Move inline components to separate files.
4. **[ ] Extract hooks** — Pull state management into `hooks/useXxx.ts`.
5. **[ ] Extract utilities** — Move validation and data transforms to `moduleUtils.ts`.
6. **[ ] Clean imports** — Remove unused imports, verify all paths resolve.
7. **[ ] Verify** — Run `yarn start` to ensure compilation succeeds.
### DO NOT change during refactoring
- **UI/JSX structure** — no visual changes.
- **CSS classes** — keep exact same styling.
- **API layer** — do not restructure API files unless explicitly requested.
- **i18n hardcoded strings** — handle separately with the `i18n-localizer` skill.
---
## 6. File Size Guidelines
| File Type | Target Lines | Action if exceeded |
|---|---|---|
| Page component (`index.tsx`) | < 600 | Extract sub-sections |
| Feature component | < 600 | Extract hooks & sub-components |
| Custom hook | < 200 | Split by concern |
| Utility file | < 300 | Split by domain |
| Sub-component | < 150 | Already well-scoped |
---
## 7. Data Flow Conventions
```
API Layer (~/api/)
↕ raw types
Hooks (hooks/useXxx.ts)
↕ processed state + handlers
Component (Feature/Main.tsx)
↕ props
Sub-components (Feature/Sub.tsx)
```
- **Unidirectional**: Parent → Child via props; Child → Parent via callback props.
- **No prop drilling beyond 3 levels** — if deeper, use a hook or context.
- **Hooks own the state**, components own the rendering.
-116
View File
@@ -1,116 +0,0 @@
---
name: sdd-review
description: 对 BiSheng 项目的 SDD 文档执行审查。
- spec:写完 spec.md 后调用,同时检查 PRD gap 和架构合规性,生成报告供用户参考
- design:写完 design.md 后调用,检查"接手测试"四要素(现状/决策/坑/契约),生成报告供用户参考
- tasks:写完 tasks.md 后自动调用,检查 AC 追溯、任务拆解质量和技术债预防
用法:/sdd-review <feature_dir> <doc_type>doc_type 为 spec / design / tasks。
TRIGGER when: 用户完成了 SDD 的 spec.md / design.md / tasks.md 编写,或者用户使用 /sdd-review 命令,或者 Claude 完成了这些文件的编写后需要审查。
---
# SDD Review Skill
## 调用方式
```
/sdd-review <feature_dir> <doc_type>
```
例:
```
/sdd-review features/v2.5.0/004-rebac-core spec
/sdd-review features/v2.5.0/001-multi-tenant tasks
```
## 审查流程
### 第一步:解析参数
从用户输入或调用上下文中提取:
- `feature_dir`:特性目录路径(如 `features/v2.5.0/004-rebac-core`
- `doc_type`:文档类型,必须是 `spec` / `design` / `tasks`
若参数缺失或无效,向用户报告错误后停止。
---
### spec 模式(辅助审查,不自动推进)
spec.md 合并了需求规范和技术设计,因此 spec 审查同时覆盖需求覆盖和架构合规检查。
**第二步(spec):执行合并审查**
读取文件:
- `<feature_dir>/spec.md`(已写的规格文档)
- spec.md 中"关联 PRD"字段指向的文件(若未标注,读取 `docs/PRD/` 下与特性名最相关的文件)
- `features/v2.5.0/release-contract.md`(不变量约束,确认 spec 未越界)
- `docs/architecture/02-backend-modules.md`(后端模块架构)
- `docs/architecture/10-permission-rbac.md`(权限体系)
`references/spec-checklist.md` 中的检查清单执行 14 项检查。
**第三步(spec):展示报告,等待用户确认**
向用户展示分析结果:
- 无 gap / 无问题:告知"审查通过,可继续确认"
- 有 gap / 有问题:展示每个问题(MISSING / FORMAT / CONFLICT / ISSUE),供用户决定是否修改
**等待用户确认**(唯一手动暂停点)。用户确认后,将 `<feature_dir>/tasks.md` 状态表中 spec.md 行更新为 `✅ 已评审`
---
### design 模式(辅助审查,不自动推进)
design.md 是"现状快照 + 关键决策"文档,决定新 agent 接手时能否在不读代码情况下快速建立认知。审查重点是**接手测试**四要素:现状、决策、坑、契约。
**第二步(design):执行审查**
读取文件:
- `<feature_dir>/design.md`(已写的设计文档)
- `<feature_dir>/spec.md`(校验 design 没偏离需求)
- `<feature_dir>/tasks.md`(若存在;校验偏差记录已回写)
- `features/_templates/design.md`(模板结构基线)
- `features/v{X.Y.Z}/release-contract.md`(关键约束、不变量)
`references/design-checklist.md` 中的检查清单执行 24 项检查。
**第三步(design):展示报告,等待用户确认**
向用户展示分析结果:
- 无问题:告知"审查通过,可继续确认"
- 有问题:展示每个问题(ISSUE 含 SEVERITY 和 LOC),供用户决定是否修改
**等待用户确认**(唯一手动暂停点)。用户确认后,将 `<feature_dir>/tasks.md` 状态表中 design.md 行更新为 `✅ 已评审`(若状态表尚无该行,提示用户补一行 `| design.md | 🔲 草稿 | ... |`)。
---
### tasks 模式(自动审查)
**第二步(tasks):执行审查**
读取文件:
- `<feature_dir>/tasks.md`
- `<feature_dir>/spec.md`(验收标准 + 技术方案)
- `features/v2.5.0/release-contract.md`(领域归属 + 不变量)
`references/tasks-checklist.md` 中的检查清单执行 21 项检查。
**第三步(tasks):处理审查结果**
**输出格式**
- 有问题:`ISSUE: <描述> | SEVERITY: high/medium/low | TASK: <T-NN 若适用>`
- 无问题:`LGTM`
**处理逻辑**
- `LGTM` → 更新 `<feature_dir>/tasks.md` 状态表,将 `tasks.md` 行改为 `✅ 已拆解`
-`high`/`medium` ISSUE → 修复后重新审查(最多 2 轮)
- `low` ISSUE → 记录但跳过
- 2 轮后仍有 `high`/`medium` → 停止,向用户报告剩余问题
## 错误处理
- feature_dir 不存在 → 报告路径错误,停止
- doc_type 不是 spec / design / tasks → 报告参数错误,停止
- spec.md 不存在 → 报告"找不到 spec.md,请先完成 spec",停止
- design.md 不存在(design 模式)→ 报告"找不到 design.md,请先按 features/_templates/design.md 完成 design",停止
- tasks.md 不存在(tasks 模式)→ 报告"找不到 tasks.md,请先完成 tasks",停止
@@ -1,76 +0,0 @@
你是 BiSheng 项目的设计文档评审员。请审查 design.md 是否能让一个**完全不熟悉本 feature 的新 agent**在不读代码、不翻 commit log 的情况下,理解"今天系统长什么样、为什么这么做、改的时候要注意什么"。
请自行读取以下文件:
- {feature_dir}/design.md(待审查的设计文档)
- {feature_dir}/spec.mdWhat 和 AC,校验 design 没有偏离需求)
- {feature_dir}/tasks.md(若已存在;校验流水账与 design 现状一致)
- features/_templates/design.md(模板结构基线)
- features/v{X.Y.Z}/release-contract.md(确认关键约束、不变量、依赖契约对齐)
- docs/constitution.md(架构铁律 C1C7,用于 Constitution Check
**核心评判标准 — 接手测试**
> 想象一个新 agent 一个月后接手这个 feature 做扩展。他**只能读 design.md**
> 不能问人、不能猜代码。他能否:
> (a) 5 分钟说出系统现状;(b) 知道哪些决策是"被否决的备选"防止重复尝试;
> (c) 知道有哪些"运行时反直觉"的坑;(d) 知道改了我会破坏谁、我依赖谁。
> 若任何一项不能,对应章节就是不合格的。
---
**§1 目标与非目标**
1. 目标是否 1-3 句话说清,避免长篇大论?
2. 非目标是否明确(防止后人误扩范围)?
**§2 关键约束 + Constitution Check**
3. 是否只写**本功能特有**的约束(性能 / 容量 / 部署形态 / 上游数据格式依赖)?
4. 全局架构铁律(双 DB / 多租户 / 权限 / 分层 / 错误码)是否**只引用 `docs/constitution.md` C1–C7、未重抄**(而非凭空写或抄一遍)?
- **Constitution Check(门禁)**design 的方案是否违反 constitution 任一条 C1C7?违反 → SEVERITY highBLOCKER)。
**§3 方案对比与选定(最高价值章节)**:
5. 是否至少记录 1-3 条**关键设计决策**?(feature 完全无决策极少见)
6. 每条决策是否包含:备选方案(至少 1 个被否决的)/ 选定 / 原因?
7. 每条决策是否给出"何时该重新考虑"的触发条件(否则就是死锚点)?
8. "原因"是否引用了具体证据(性能数字、上游约束、踩过的坑),而非"感觉更好"?
**§4 系统现状(接手必读)**
9. 数据流是否能让人 30 秒理解"输入 → 输出"主线?
10. 关键数据结构 / 字段约定是否写明**对外可见**的契约(字段名、JSON 格式、类型)?
11. 关键模块职责表是否同时写了"做什么 + 不做什么"
12. 是否与 spec.md §5-§7 的实际实现一致(没有过时描述)?
**§5 已知坑 / 反直觉事实(最防失传章节)**:
13. 是否至少列出 1 条**反直觉**事实(代码里看不出、commit message 散落)?
- 若 feature 真的没有任何反直觉点,要在本节明确写"无",而不是留空
14. 每条坑是否带"如果不知道会怎样"(让后人知道严重性)?
15. 每条坑是否给出"在哪处理"(具体文件 / 函数名)?
**§6 对外契约与依赖**
16. Outgoing(我提供给别人的)是否包含 HTTP API + 内部 Python 接口 + 数据格式?
17. Incoming(我依赖别人的)是否包含上游数据契约 + 系统二进制 + 第三方服务?
18. 每条依赖是否标注"风险点"(什么变化会破坏我)?
**§7 测试与可观测**
19. 是否说清楚整体策略(单元 / 集成 / e2e 各覆盖哪一层),而不是重复 tasks.md 的清单?
20. 是否给了"手动验证一遍"的可操作命令 / URL / 账号?
**§8 后续改进**
21. 是否标出已知短板和"不打算做的理由",防止后人重复提议?
**整体一致性**
22. 修订历史是否在 feature 完成时已记初版?
23. design.md 是否与 spec.md 不矛盾?(spec 是不变目标,design 是当前实现,二者口径必须对齐)
24. design.md 是否反映了 tasks.md §实际偏差记录 中的所有"改了系统认知"的偏差?
---
返回格式(必须严格遵守):
有 gap / 问题时,每个问题单独一行:
`ISSUE: <章节> <描述> | SEVERITY: high/medium/low | LOC: <design.md 行号或章节名>`
无问题时:`LGTM`
**SEVERITY 判断**
- **high**:缺失"接手测试"的关键章节(§3/§4/§5/§6),或与 spec 矛盾
- **medium**:章节填了但深度不够(决策缺"原因"或"何时重新考虑"、坑缺"如果不知道会怎样")
- **low**:格式 / 表述 / 修订历史问题
@@ -1,36 +0,0 @@
你是 BiSheng 项目的需求评审员。
**spec.md 是纯 What** —— 用户故事 + 验收标准 + 边界 + out-of-scope。**所有 How(架构决策 / API 契约 / 数据模型 / 分层 / 响应格式 / 文件清单 / 性能指标)都在 design.md,不在 spec。** 请严格按此模型审。
请自行读取以下文件:
- {feature_dir}/spec.md(已写的规格文档)
- {prd_path}(从 spec.md「关联 PRD」字段获取;未标注则读 docs/PRD/ 下与特性名最相关的文件)
- features/v{X.Y.Z}/release-contract.md(不变量约束 + 领域归属,确认 spec 未越界)
- docs/constitution.md(架构铁律 C1C7
**需求覆盖**
1. PRD 的功能点 / 用户场景,spec 是否都有对应 AC?
2. PRD 的边界条件、错误场景,spec 是否覆盖(§3 边界情况)?
3. out-of-scope(本次明确不做)是否写清,防 scope 膨胀?
**AC 质量**
4. AC-ID 是否唯一、格式 `AC-NN`,可被 tasks 的「覆盖 AC: AC-NN」追溯?
5. 是否有 AC 不可测试("友好提示""响应快"这类没法验收的模糊词)?
6. **P0 / 复杂 feature 的 AC 是否用 EARS 句型**`WHEN/IF/WHILE/WHERE … THE SYSTEM SHALL …`),能直接转成测试?小功能用表格式可放行。
7. 错误码是否**仅作「可观测的对外行为」引用**(如「返错误码 12061」),而非在 spec 维护错误码详表(那是 design §6 / 代码的事)?
**边界与合规**
8. 是否越界进入 release-contract 表 1 中归属其他 Feature 的领域?
9. 是否与 release-contract 的 INV 不变量、或 constitution C1C7 冲突?
10. **spec 是否误写了 How**(架构决策 / API 契约 / 数据模型 / 分层 / 响应格式 / 文件清单 / 性能指标)?这些应在 design.md —— spec 里出现即为 gap(违反纯 What 模型)。
返回格式(必须严格遵守):
有 gap / 问题时,每个问题单独一行:
- MISSING: <PRD 中的功能/场景,spec 未覆盖> | SEVERITY: high/medium/low | PRD_REF: <PRD 原文片段或章节>
- ISSUE: <AC 不可测 / 误写 How / 越界 / 缺 EARS> | SEVERITY: high/medium/low | AC: <AC-NN 若适用>
- CONFLICT: <与 INV / constitution 冲突> | SEVERITY: high | REF: <INV-N 或 C-N>
无 gap 且无问题时,只返回一行:LGTM
注意:本报告供参考,是否修改由用户决定。不要建议怎么改,只列出观察到的 gap 和问题。
@@ -1,55 +0,0 @@
你是 BiSheng 项目的任务计划评审员。请审查 {feature_dir}/tasks.md。
请自行读取以下文件:
- {feature_dir}/spec.md(验收标准 + 技术方案)
- features/v2.5.0/release-contract.md(领域归属 + 不变量)
任务规范要求:
- Test-First:后端测试任务必须先于其配对的实现任务
- 每个测试任务必须有"覆盖 AC: AC-NN, AC-NN"标注
- 基础设施任务(ORM 模型、错误码、配置)无测试配对,排在最前面
- 每个任务应在一次 AI 会话内可完成(目标约 30 分钟,最多 1-2 个文件)
- 依赖关系:依赖的任务 ID 必须存在且顺序合理
- 每个任务必须自包含:内联文件路径、逻辑、测试上下文(实现阶段不需要回读 spec.md)
- 任务分 6 类:基础设施 / 后端 Domain / 后端 API / 前端 Platform / 前端 Client / Worker
- 前端任务必须区分 Platformsrc/frontend/platform/)和 Clientsrc/frontend/client/
- Worker 任务须说明 tenant_id 传递方式(Celery headers → ContextVar
- 「测试降级」标注仅在测试成本极高时允许,必须说明理由
审查清单(4 组 17 条 + BiSheng 特有 4 条):
**A. 形式合规**
1. **AC 追溯完整性** — spec.md 中每条 AC 是否都有至少一个测试任务覆盖(带"覆盖 AC:"标注)?
2. **AC 标注完整性** — 是否存在缺少"覆盖 AC:"标注的测试任务?
3. **Test-First 顺序** — 后端测试任务是否先于其配对的实现任务?
4. **依赖关系正确性** — 被依赖的任务 ID 是否存在、顺序是否合理?
5. **原子化** — 每个任务范围是否 ≤ 2 个文件,能在一次会话内完成?
6. **自包含** — 每个任务是否内联了文件路径、逻辑描述、测试上下文?
**B. 任务拆解质量**
7. **粒度合理性** — 单个任务不超过 3 个文件、不跨前后端?
8. **顺序高效性** — 不存在任务 A 的输出被后续任务覆盖/重写的返工情况?
9. **无重复工作** — 不存在多个任务对同一文件同一部分做非增量的重复修改?
10. **spec 覆盖完整性** — spec.md 中定义的每个 API 端点、ORM 模型、Service 方法、前端组件都有对应实现任务?
11. **任务间接口清晰** — 任务描述中明确前驱任务的产出(DAO 方法签名、Service 接口、API 端点路径)?
12. **无过度工程** — 不存在 spec.md 中未提及但 tasks.md 中新增的实现内容?
**C. AC 标注规范**
13. **AC 标注格式** — 必须逐条列举 `AC-01, AC-02`,禁止 `AC-01~AC-05` 范围写法?
14. **测试任务纯净性** — 标注了"覆盖 AC"的测试任务不得混入实现逻辑?
**D. 技术债预防**
15. **无延迟 TODO** — 任务描述中不得有 TODO/FIXME/HACK 将本 Feature 范围内问题推迟?
16. **数据库回滚** — 数据库模型变更任务需包含回滚方案或说明不可逆原因?
17. **跨 Feature 副作用** — 修改其他 Feature 领域对象的写入行为需检查 release-contract.md;修改共享文件需说明影响范围?
**E. BiSheng 特有**
18. **前端分区** — 前端任务是否区分 Platform / Client 两个分区,不混在一起?
19. **Worker tenant_id** — Worker/Celery 任务是否说明 tenant_id 传递方式(headers → ContextVar)?
20. **基础设施优先** — 基础设施任务(ORM/错误码/conftest)是否排在所有业务任务之前?
21. **测试降级理由** — 标注「测试降级」的任务是否说明了充分理由(如需要 Milvus/ES mock)?
返回格式(必须严格遵守):
有问题时,每个问题单独一行:
- ISSUE: <描述> | SEVERITY: high/medium/low | TASK: <T-NN 若适用>
无问题时,只返回一行:LGTM
-108
View File
@@ -1,108 +0,0 @@
---
name: task-review
description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查,
确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。
用法:/task-review <feature_dir> <task_id>
TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。
---
# Task Review SkillL1 任务级审查)
## 调用方式
```
/task-review <feature_dir> <task_id>
```
例:
```
/task-review features/v2.5.0/004-rebac-core T003
/task-review features/v2.5.0/007-resource-permission-ui T007
```
## 审查流程
### Step 1: 解析参数 + 收集变更范围
1. 验证参数:
- `feature_dir` 必须存在且包含 `tasks.md`
- `task_id` 必须匹配 tasks.md 中的某个任务(格式:`T001``T003` 等)
- 若参数缺失或无效,报告错误后停止
2.`<feature_dir>/tasks.md` 中读取指定任务的元数据:
- 任务类型(测试 / 实现 / 基础设施 / Worker
- 目标文件列表
- 前置依赖
- 配对任务(测试↔实现)
- 覆盖 AC 标注(测试任务)
3. 读取任务声明的所有目标文件内容(直接读取文件,不依赖 git diff)
### Step 2: 判断任务类型,选择检查子集
根据任务类型确定适用的检查项(参见 `references/task-checklist.md`):
| 任务类型 | 适用检查项 | 额外检查 |
|---------|-----------|---------|
| **测试任务** | #2 命名 + #5 前端约定 | AC 标注格式(`覆盖 AC: AC-NN` |
| **实现任务** | 完整 #1~#7 | 配对测试任务已完成(tasks.md 中已打勾);design.md 同步检查 |
| **基础设施任务** | #1 架构分层 + #4 数据库约定 + #6 信息泄漏 + #7 设计同步 | 无 |
| **Worker 任务** | #1 架构 + #4 数据库 + #6 信息泄漏 + #7 设计同步 | tenant_id 通过 Celery headers 传递 |
任务类型判断规则:
- 文件路径包含 `test/``__tests__/` → 测试任务
- 文件路径包含 `domain/models/``common/errcode/` 或任务描述含"ORM""迁移""错误码""配置" → 基础设施任务
- 文件路径包含 `worker/` 或任务描述含"Celery""异步任务" → Worker 任务
- 其他 → 实现任务
- 若任务同时包含测试和实现文件,按实现任务处理
### Step 3: 按检查清单执行检查
逐项执行 `references/task-checklist.md` 中适用的检查项。
### Step 4: 元数据交叉验证
- **文件范围**:任务声明的目标文件是否实际存在,是否存在范围蔓延(修改了任务未声明的文件)
- **配对测试**:若为实现任务,检查 tasks.md 中配对的测试任务是否已打勾 ✅
- **前置依赖**:检查任务声明的依赖项是否已完成(tasks.md 中已打勾)
### Step 5: 输出报告
按以下格式输出:
```markdown
## Task Review: <task_id>
**任务**: <任务标题>
**类型**: 测试 / 实现 / 基础设施 / Worker
**文件**: <文件列表>
| # | 检查项 | 结果 | 说明 |
|---|--------|------|------|
| 1 | 架构分层 | PASS / FAIL / N/A | <若 FAIL,具体描述> |
| 2 | 命名规范 | PASS / FAIL / N/A | |
| 3 | 序列化约定 | PASS / FAIL / N/A | |
| 4 | 数据库约定 | PASS / FAIL / N/A | |
| 5 | 前端约定 | PASS / FAIL / N/A | |
| 6 | 信息泄漏 | PASS / FAIL / N/A | |
| 7 | 设计同步 | PASS / FAIL / N/A | <若 FAIL,列出 design.md 哪节应更新> |
**元数据验证**: 文件范围 PASS/FAIL | 配对测试 PASS/FAIL/N/A | 依赖 PASS/FAIL
**结果**: PASS / PASS_WITH_NOTES / NEEDS_FIX
```
### Step 6: 处理结果
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 全部通过 | 告知用户可以打勾 |
| **PASS_WITH_NOTES** | 仅 MEDIUM 级提醒,无 HIGH | 告知用户可以打勾,列出提醒供参考 |
| **NEEDS_FIX** | 任何 HIGH 违规 | 列出需要修复的具体问题,修复后可再次调用 `/task-review` 重审(最多 1 轮重审) |
## 错误处理
- `feature_dir` 不存在 → 报告路径错误,停止
- `tasks.md` 不存在 → 报告"找不到 tasks.md",停止
- `task_id` 不匹配 → 报告"未找到任务 <task_id>",停止
- 目标文件不存在 → 标记为 WARNING(文件可能尚未创建),继续检查其他文件
@@ -1,42 +0,0 @@
# L1 任务审查检查清单
本清单定义了 `/task-review` 在每个任务完成后执行的精简检查项。
L1 聚焦约定合规和架构红线,不检查边界条件、权限、并发、测试覆盖(留给 L2)。
## 检查项
| # | 检查项 | 适用文件 | 严重度 | 检查方法 |
|---|--------|---------|--------|---------|
| 1 | 架构分层 | 后端 `*.py` | HIGH | Endpoint 不直接实例化 DAO 做复杂业务(应通过 Service);Service 不导入 FastAPI 对象(Request/Response/Depends/APIRouter);`domain/models/` 不得 `from bisheng.*.domain.services``common/``core/` 不得导入领域模块;新代码不放 `api/services/`(旧服务层),应放 `{module}/domain/services/`Worker 只导入 Domain Services 不导入 Endpoint |
| 2 | 命名规范 | 全部 | MEDIUM | DAO 方法:同步 `get_xxx`/`create_xxx`/`update_xxx`/`delete_xxx`,异步 `aget_xxx`/`acreate_xxx`/`aupdate_xxx`/`adelete_xxx`DAO 为 `@classmethod`Service 类名 `{Module}{Function}Service`;错误码类名 `{Module}{Error}Error`Code 遵循 MMMEE;前端页面 PascalCasestore 文件 camelCase+StoreAPI 函数 camelCasei18n key 小写+点分隔 |
| 3 | 序列化约定 | 后端 `*.py` | HIGH | ORM 继承 `SQLModelSerializable`API 响应用 `UnifiedResponseModel``resp_200`/`resp_500`/`ErrorClass.return_resp`);分页用 `PageData[T]`(新代码);枚举序列化为 `.value`SSE 用 `to_sse_event()`WS 关闭用 `websocket_close_message()` |
| 4 | 数据库约定 | 后端 models/migration `*.py` | HIGH | 新表必须含 `tenant_id``index=True`);必须含 `create_time`/`update_time`;禁止手动 `WHERE tenant_id=`SQLAlchemy event 自动注入);禁止 Service 层直接写 SQL(用 DAO classmethod);新模块 DAO 放 `{module}/domain/models/` 而非 `database/models/`;使用 `get_sync_db_session()`/`get_async_db_session()` |
| 5 | 前端约定 | `*.tsx`/`*.ts` | MEDIUM | Platform: 全局状态用 Zustand store`src/store/`),API 通过 `controllers/API/` 封装,用户可见文字走 `t('key')` i18n,新路由在 `src/routes/` 注册。Client: API 通过 `src/api/` 封装,store 用 Zustand`src/store/`),路由基础路径 `/workspace` |
| 6 | 信息泄漏 | 全部 | HIGH | 无硬编码密码/密钥/token`password = "xxx"` 等);错误响应不暴露堆栈/SQL(用 BaseErrorCode);日志中敏感字段脱敏;API 不返回 tenant_id 到前端;前端不硬编码后端 IP。排除:测试 fixtures、config.yaml.example |
| 7 | 设计同步 | 全部 | MEDIUM | **触发条件**:本任务修改了 service / endpoint / store / API 契约 / 数据模型 / 渲染器 / 任何 design.md §4.3 "关键模块职责" 表里列出的文件。**检查**(a) design.md 修订历史是否反映了本任务的改动(或本任务后会一并补);(b) 改动是否触及 §3 决策(替换/新增)、§4 数据流或字段约定、§5 已知坑(新增反直觉事实)、§6 对外契约或依赖 —— 若是,必须**同步更新对应章节**;(c) 仅本地小修小补(重构、改文案、调样式、bugfix 不涉及对外行为)→ 标 PASS 即可。**判定**:触发但 design.md 未同步 → FAILMEDIUM);触发且已同步 / 未触发 → PASS |
## 差异化处理规则
### 测试任务
- 仅检查:#2 命名规范 + #5 前端约定中的 i18n + AC 标注格式(`覆盖 AC: AC-NN`
- 跳过:#1 架构分层、#3 序列化、#4 数据库、#7 设计同步
### 实现任务
- 完整执行 #1~#7
- 额外验证:配对的测试任务是否已完成(tasks.md 中已打勾)
### 基础设施任务(ORM 模型、错误码、配置)
- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏、#7 设计同步(新表/新错误码很可能影响 design.md §4.2 字段约定 + §6 契约)
- 跳过:#5 前端约定
### Worker 任务
- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏、#7 设计同步
- 额外检查:tenant_id 是否通过 Celery headers 传递并在 Worker 侧恢复 ContextVar
## 判定规则
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 全部通过 | 打勾,继续下一任务 |
| **PASS_WITH_NOTES** | 仅 MEDIUM 级信息性提醒 | 打勾 + 记录偏差,继续 |
| **NEEDS_FIX** | 任何 HIGH 违规 | 修复 → 重审(最多 1 轮) |
+56 -40
View File
@@ -4,13 +4,13 @@ name: cicd # 定义流水线名称
clone:
disable: true
steps: # 定义流水线执行步骤,这些步骤将顺序执行
- name: clone
image: alpine/git
pull: if-not-exists
environment:
http_proxy:
http_proxy:
from_secret: PROXY
https_proxy:
from_secret: PROXY
@@ -40,10 +40,9 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行
- echo $REPO
- REPO2=$(echo $REPO | sed 's/http:\\/\\///g')
- sed '/apt-get/ s|$| '"$PROXY"'|' Dockerfile
# 删掉 `&& uv cache clean`(连同 && 一起删,避免留下悬空操作符导致 sh 语法错误 exit 2)
- sed -i -E 's/[[:space:]]*&&[[:space:]]*uv cache clean.*$//' Dockerfile
# 在 `RUN uv sync` 行之前插入国内镜像 ENV:用 awk 按内容锚定(不依赖行号,Dockerfile 改动也不会插错位),ENV 在 uv sync 之前生效
- awk '/^RUN uv sync/ && !x {print "ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; x=1} {print}' Dockerfile > Dockerfile.tmp && mv Dockerfile.tmp Dockerfile
- sed -i '6i\RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple' Dockerfile
- sed -i '7i\RUN poetry source add --priority=supplemental foo http://'$NEXUS_PUBLIC':'$NEXUS_PUBLIC_PASSWORD'@'$REPO2'simple' Dockerfile
- sed -i '8i\RUN poetry source add --priority=primary qh https://pypi.tuna.tsinghua.edu.cn/simple' Dockerfile
- cat Dockerfile
- name: build_docker
@@ -57,8 +56,6 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行
path: /var/run/docker.sock
- name: pro-cache
path: /root/.local/share/pypoetry
- name: uv-cache
path: /root/.cache/uv
environment:
http_proxy:
from_secret: PROXY
@@ -126,7 +123,7 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行
image: plugins/webhook
settings:
debug: true
urls:
urls:
from_secret: FEISHU_URL
content_type: application/json
template: |
@@ -196,36 +193,42 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行
- git clone https://github.com/dataelement/bisheng.git .
- git checkout $DRONE_COMMIT
- name: set uv mirrors
- name: set poetry
pull: if-not-exists
image: golang
environment:
NEXUS_PUBLIC:
from_secret: NEXUS_PUBLIC
NEXUS_PUBLIC_PASSWORD:
from_secret: NEXUS_PUBLIC_PASSWORD
REPO:
from_secret: PY_NEXUS
PROXY:
from_secret: APT-GET
volumes:
volumes: # 将容器内目录挂载到宿主机,仓库需要开启Trusted设置
- name: bisheng-cache
path: /app/build/
commands:
- cd ./src/backend
# 删掉 `&& uv cache clean`(连同 && 一起删,避免留下悬空操作符导致 sh 语法错误 exit 2)
- sed -i -E 's/[[:space:]]*&&[[:space:]]*uv cache clean.*$//' Dockerfile
# 在 `RUN uv sync` 行之前插入国内镜像 ENV:用 awk 按内容锚定(不依赖行号,Dockerfile 改动也不会插错位),ENV 在 uv sync 之前生效
- awk '/^RUN uv sync/ && !x {print "ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple"; print "ENV UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple"; x=1} {print}' Dockerfile > Dockerfile.tmp && mv Dockerfile.tmp Dockerfile
- echo $REPO
- REPO2=$(echo $REPO | sed 's/http:\\/\\///g')
- sed '/apt-get/ s|$| '"$PROXY"'|' Dockerfile
- sed -i '6i\RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple' Dockerfile
- sed -i '7i\RUN poetry source add --priority=supplemental foo http://'$NEXUS_PUBLIC':'$NEXUS_PUBLIC_PASSWORD'@'$REPO2'simple' Dockerfile
- sed -i '8i\RUN poetry source add --priority=primary qh https://pypi.tuna.tsinghua.edu.cn/simple' Dockerfile
- cat Dockerfile
- name: build_docker
pull: if-not-exists
image: docker:24.0.6
privileged: true
volumes:
volumes: # 将容器内目录挂载到宿主机,仓库需要开启Trusted设置
- name: apt-cache
path: /var/cache/apt/archives
path: /var/cache/apt/archives # 将应用打包好的Jar和执行脚本挂载出来
- name: socket
path: /var/run/docker.sock
- name: pro-cache
path: /root/.local/share/pypoetry
- name: uv-cache
path: /root/.cache/uv
environment:
http_proxy:
from_secret: PROXY
@@ -279,28 +282,44 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行
- docker build -t $docker_repo:$version .
- docker push $docker_repo:$version
# - name: ssh deploy backend
# image: appleboy/drone-ssh
# pull: if-not-exists
# settings:
# host: 192.168.106.105
# username: root
# password:
# from_secret: sshpwd
# port: 22
# command_timeout: 10m
# script: |
# echo "=======同步 105 backend ======="
# cd /opt/server/dm/bisheng-dm/docker
# docker compose pull
# docker compose up -d
- name: notify-start # notify
pull: if-not-exists
image: plugins/webhook
settings:
debug: true
urls:
from_secret: FEISHU_URL
content_type: application/json
template: |
{
"msg_type": "interactive",
"card": {
"type": "template",
"data": {
"template_id": "AAqkI9bnY5FUs",
"template_variable": {
"repo_name": "{{ repo.name }}",
"build_branch": "{{build.branch}}",
"build_author": "{{ DRONE_COMMIT_AUTHOR }}",
"link": "{{build.link}}",
"commit_msg": "{{ trim build.message }}",
"build_tag":"{{build.tag}}",
"build_start":"{{build.started}}",
"status": "{{ build.status }}"
}
}
}
}
when: # 成功
status:
- success
trigger:
branch:
- feat/**
- add_some_branch_you_need
- huawei
event:
- push
volumes:
- name: bisheng-cache
host:
@@ -314,6 +333,3 @@ volumes:
- name: socket
host:
path: /var/run/docker.sock
- name: uv-cache
host:
path: /opt/drone/data/uv/
-18
View File
@@ -1,18 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+2 -2
View File
@@ -1,5 +1,5 @@
# 默认:自动识别文本,统一用 LF 存库
#* text=auto eol=lf
* text=auto eol=lf
# 明确常见文本文件用 LF
*.py text eol=lf
@@ -31,4 +31,4 @@
*.mp4 binary
*.docx binary
*.xlsx binary
*.pptx binary
*.pptx binary
-20
View File
@@ -1,20 +0,0 @@
## What
简要描述做了什么改动。
## Why
为什么需要这个改动?
## How
实现方式、设计决策(如有)。
## Test
- [ ] 本地测试通过
- [ ] 114 测试服务器验证通过
## Related
- Issue/ticket:
+1 -3
View File
@@ -114,10 +114,8 @@ jobs:
# 获取提交信息
- name: Process git message
id: process_message
env:
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
value=$(echo "$HEAD_COMMIT_MESSAGE" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${{ github.event.head_commit.message }}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${value}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\r/%0A/g')
echo "message=${value}" >> $GITHUB_ENV
shell: bash
+2 -56
View File
@@ -52,20 +52,7 @@ jobs:
id: docker_build_backend
run: |
docker buildx build --file ./src/backend/Dockerfile --platform linux/amd64 --provenance false --tag ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-amd64 --push ./src/backend/
- name: Login to CR
uses: docker/login-action@v1
with:
registry: https://cr.dataelem.com/
username: ${{ secrets.CR_DOCKERHUB_USERNAME }}
password: ${{ secrets.CR_DOCKERHUB_TOKEN }}
- name: Sync backend amd64 to CR
run: |
docker pull ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-amd64
docker tag ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-amd64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-amd64
docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-amd64
build_backend_arm:
runs-on: ubuntu-22.04-arm
steps:
@@ -107,19 +94,6 @@ jobs:
run: |
docker buildx build --file ./src/backend/Dockerfile --platform linux/arm64 --provenance false --tag ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-arm64 --push ./src/backend/
- name: Login to CR
uses: docker/login-action@v1
with:
registry: https://cr.dataelem.com/
username: ${{ secrets.CR_DOCKERHUB_USERNAME }}
password: ${{ secrets.CR_DOCKERHUB_TOKEN }}
- name: Sync backend arm64 to CR
run: |
docker pull ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-arm64
docker tag ${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-arm64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-arm64
docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-backend:${{ steps.get_version.outputs.VERSION }}-arm64
build_bisheng_frontend:
runs-on: ubuntu-latest
steps:
@@ -149,19 +123,6 @@ jobs:
run: |
docker buildx build --file ./src/frontend/Dockerfile --platform linux/amd64 --provenance false --tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64 --push ./src/frontend/
- name: Login to CR
uses: docker/login-action@v1
with:
registry: https://cr.dataelem.com/
username: ${{ secrets.CR_DOCKERHUB_USERNAME }}
password: ${{ secrets.CR_DOCKERHUB_TOKEN }}
- name: Sync frontend amd64 to CR
run: |
docker pull ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64
docker tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64
docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64
build_frontend_arm:
runs-on: ubuntu-22.04-arm
steps:
@@ -197,19 +158,6 @@ jobs:
run: |
docker buildx build --file ./src/frontend/Dockerfile --platform linux/arm64 --provenance false --tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 --push ./src/frontend/
- name: Login to CR
uses: docker/login-action@v1
with:
registry: https://cr.dataelem.com/
username: ${{ secrets.CR_DOCKERHUB_USERNAME }}
password: ${{ secrets.CR_DOCKERHUB_TOKEN }}
- name: Sync frontend arm64 to CR
run: |
docker pull ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64
docker tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64
docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64
notify_feishu:
needs:
- build_bisheng_backend
@@ -225,10 +173,8 @@ jobs:
- name: Process git message
id: process_message
env:
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
value=$(echo "$HEAD_COMMIT_MESSAGE" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${{ github.event.head_commit.message }}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${value}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\r/%0A/g')
echo "message=${value}" >> $GITHUB_ENV
shell: bash
-61
View File
@@ -1,61 +0,0 @@
name: Frontend Quality
# Quality gate for the two frontend apps.
# Policy: legacy violations are frozen in each app's eslint-suppressions.json and
# via @ts-strict-ignore file annotations; suppressed counts may only shrink.
# Any NEW lint violation or strict-mode type error fails this workflow.
on:
pull_request:
paths:
- "src/frontend/**"
- ".github/workflows/frontend-quality.yml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Lint & Typecheck
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: src/frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Shared locale artifacts in sync (packages/locales)
run: pnpm --filter @bisheng/locales check
- name: i18n consistency (key parity + backend error-code coverage)
run: pnpm check-i18n
- name: Lint platform
run: pnpm --filter bisheng lint
- name: Lint client
run: pnpm --filter bishengchat lint
- name: Typecheck platform (strict, non-exempted files)
run: pnpm --filter bisheng typecheck
- name: Typecheck client (strict, non-exempted files)
run: pnpm --filter bishengchat typecheck
+1 -3
View File
@@ -151,10 +151,8 @@ jobs:
- name: Process git message
id: process_message
env:
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
value=$(echo "$HEAD_COMMIT_MESSAGE" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${{ github.event.head_commit.message }}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/%0A/g')
value=$(echo "${value}" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\r/%0A/g')
echo "message=${value}" >> $GITHUB_ENV
shell: bash
+5 -38
View File
@@ -13,15 +13,6 @@ autogen_coding/
# Mac
.DS_Store
# Claude Code local settings
.claude/settings.local.json
# Personal notes (lilu's local reference docs, not for sharing)
.claude/docs/
# Claude Code runtime artifacts
.claude/scheduled_tasks.lock
# VSCode
.vscode
.vscode/settings.json
@@ -132,16 +123,16 @@ __pycache__/
*$py.class
notebooks
# C extensions
*.so
# Distribution / packaging
.Python
build/
./third_party
output/
develop-eggs/
config.dev.yaml
src/backend/bisheng/config.yaml
# Local per-environment configs (contain connection strings / encrypted creds)
src/backend/bisheng/config.*.yaml
src/backend/data/
downloads/
eggs/
.eggs/
@@ -273,28 +264,4 @@ sftp-config.json
# Docker local files
docker/data/*
docker/mysql/data/*
docker/office/bisheng/*.gz
bisheng-sync.sh
/deploy.sh
.omx/
# Local installers / stray root files (do not commit)
*.msi
# Permission migration checkpoint (per-deployment local state)
src/backend/bisheng/permission/migration/migration_f006_checkpoint.json
# MySQL -> DM migration local config (may contain DB passwords; never commit)
src/backend/scripts/migrate_dm.yaml
# Git worktrees (claude code)
.worktrees/
.claude/worktrees/
# UI component refactor working docs (local handoff notes; never commit / ship)
docs-ui-refactor/
# CoAligne local state + sync config (per-developer; the tool rewrites them itself)
.coaligne/
.coaligneignore
docker/office/bisheng/*.gz
+6
View File
@@ -0,0 +1,6 @@
[submodule "src/bisheng-unstructured"]
path = src/bisheng-unstructured
url = https://github.com/dataelement/bisheng-unstructured.git
[submodule "src/bisheng-rt"]
path = src/bisheng-rt
url = https://github.com/dataelement/bisheng-rt.git
+47 -16
View File
@@ -1,26 +1,57 @@
exclude: ^(scripts|docs|docker|requirements|test|experimental)/
exclude: ^scripts|docs|docker|requirements|README.md|test|experimental
repos:
- repo: https://github.com/PyCQA/flake8.git
rev: 3.8.3
hooks:
- id: flake8
args: ["--max-line-length=120"]
- repo: https://github.com/asottile/seed-isort-config
rev: v2.2.0
hooks:
- id: seed-isort-config
- repo: https://github.com/timothycrosley/isort
rev: 4.3.21
hooks:
- id: isort
files: \.(py|pyd)$
args: ["-l 100"]
- repo: https://github.com/pre-commit/mirrors-yapf
rev: v0.32.0
hooks:
- id: yapf
files: \.(py|pyd)$
args: ["--style={column_limit: 120}"]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v3.1.0
hooks:
- id: trailing-whitespace
files: \.(py|pyd|ts|tsx|js|jsx)$
files: \.(py|pyd)$
- id: check-yaml
- id: end-of-file-fixer
files: \.(py|pyd|ts|tsx|js|jsx)$
files: \.(py|pyd)$
- id: requirements-txt-fixer
- id: double-quote-string-fixer
- id: check-merge-conflict
- id: check-added-large-files
args: ["--maxkb=500"]
- id: fix-encoding-pragma
args: ["--remove"]
- id: mixed-line-ending
args: ["--fix=lf"]
files: \.(py|pyd|ts|tsx|js|jsx)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.8
files: \.(py|pyd)$
# - repo: https://github.com/jumanjihouse/pre-commit-hooks
# rev: 2.1.4
# hooks:
# - id: markdownlint
# args: ["-r", "~MD002,~MD013,~MD029,~MD033,~MD034,~MD005"]
# - repo: https://github.com/myint/docformatter
# rev: v1.3.1
# hooks:
# - id: docformatter
# args: ["--in-place", "--wrap-descriptions", "79"]
- repo: local
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types_or: [python, pyi]
- id: ruff-format
types_or: [python, pyi]
- id: clang-format
name: clang-format
description: Format files with ClangFormat
entry: clang-format -i
language: system
files: \.(c|cc|cxx|cpp|cu|h|hpp|hxx|cuh|proto)$
-110
View File
@@ -1,110 +0,0 @@
# AGENTS.md
---
## 1. Project Identity
**BiSheng (毕昇)** — Enterprise LLM application DevOps platform. Monorepo, three sub-projects:
| Path | Project | Stack |
|------|---------|-------|
| `src/backend/` | FastAPI + Celery Workers + Linsight Worker | Python 3.11+, uv, SQLModel, LangGraph |
| `src/frontend/platform/` | Admin / builder UI | Vite 5 + **Zustand** + react-query v3 + bs-ui |
| `src/frontend/client/` | End-user chat UI (`/workspace` base path) | Vite 6 + **Recoil** + react-query v4 (@tanstack) + shadcn/ui |
**Runtime topology** (full picture → `docs/architecture/01-architecture-overview.md`):
- Two SPAs — platform (:3001) and client (:4001, base `/workspace`) — call FastAPI (:7860): `/api/v1` frontend-facing, `/api/v2` open RPC. Commercial edition inserts a Java gateway in front (→ `architecture/11-gateway.md`).
- Async work: Celery workers (knowledge / workflow / default queues) + Beat; the Linsight agent runs as an independent worker process fed by a Redis queue.
- Storage ×6: MySQL|DM8 (dual-DB law C2), Redis, Milvus + ES (RAG dual recall), MinIO, OpenFGA (ReBAC).
- Cross-cutting: tenant isolation auto-injected via ContextVar (C3); every permission check goes through PermissionService → OpenFGA (C4).
---
## 2. Commands
Dev / test / build commands live in each sub-project's `AGENTS.md`: `src/backend/AGENTS.md` · `src/frontend/platform/AGENTS.md` · `src/frontend/client/AGENTS.md`.
Middleware (MySQL / Redis / Milvus / ES / MinIO / OpenFGA): integration tests run in **CI**; per-developer middleware machines are pending.
---
## 3. Backend Rules (P0)
- **Architectural laws** (DDD layering / dual-DB / multi-tenancy / permissions / error codes / security) → [`docs/constitution.md`](docs/constitution.md) (C1C7); enforced by `scripts/arch-guard.sh` + Constitution Check in `/sdd-review design`.
- **Backend coding conventions** (module layout, API/response helpers, pagination, error handling) + subsystem quick map → `src/backend/AGENTS.md` (auto-loads when editing backend files).
---
## 4. Frontend Rules (P0)
Two React apps that **must not be mixed**. Per-app rules auto-load from each sub-project's `AGENTS.md`:
- `src/frontend/platform/AGENTS.md` — Admin/builder UI (Zustand, react-query v3, bs-ui, `@/`)
- `src/frontend/client/AGENTS.md` — End-user chat UI (Recoil, react-query v4, shadcn, `~/`)
**Hard rules (both apps — single source of truth here; per-app files add only app-specific detail):**
- TypeScript only (`.ts` / `.tsx`); functional components only; no class components.
- Single file ≤ 600 lines. Extract sub-components or hooks when exceeded.
- `interface` for Props; `type` for internal types. `handleXxx` internal handlers / `onXxx` props. PascalCase components, camelCase utilities/hooks.
- Named exports for components (`export function`); no default exports. Minimize `any` — if unavoidable, `// eslint-disable-next-line` + a one-line reason.
- **Never** `import axios` directly — use the wrapped request module. (store must not call HTTP = constitution **C7**)
- **Never** introduce new UI or state-management libraries.
- All code comments in English.
- 403 handled automatically by response interceptors — never add 403 branches in business code.
- **No frosted glass by default** — no `backdrop-filter` / `backdrop-blur-*`, including arbitrary values (`backdrop-blur-[4px]`), variant prefixes (`hover:backdrop-blur-sm`) and the arbitrary-property form `[backdrop-filter:blur(…)]`. Every such element gets its own compositing layer and re-snapshots + re-blurs its backdrop each frame; without GPU acceleration — the norm on 信创 machines — that runs on the CPU. A customer on 信保安全浏览器 (Chromium 108) had scrolling *and mouse movement* stall in daily chat; forcing `backdrop-filter: none` fixed it outright. Cost is **per element, not per radius**: a 4px blur on a 24px button costs the same order as a full-screen one, and the per-message action buttons multiplied it by conversation length. Use a translucent background instead (`bg-white/80`, `bg-black/40`) — past ~70% opacity the blur was invisible anyway. **Only exception**, and it must be justified: a full-screen overlay of which at most one exists at a time, verified on a 信创 browser. Never on anything that scales with content (list rows, message bubbles, cards, notification items). Rationale + alternatives: `packages/ui/docs/基础-阴影与圆角规范.mdx` §3.
- **i18n**: no hardcoded Chinese in source (lint-enforced; legacy frozen). New keys ship all three languages (zh-Hans/en/ja) in the same PR. Error-code copy lives ONLY in `src/frontend/packages/locales` (`api_errors` domain — platform addresses `api_errors:<code>`, client `api_errors.<code>`); its generated artifacts (`platform/public/locales/*/api_errors.json`, `client/src/locales/*/api_errors.gen.json`) are never edited by hand (CI-checked). CI also runs `pnpm check-i18n` — key parity across languages + backend error-code coverage; legacy drift is frozen in `scripts/i18n-baseline.json` (shrink-only, `--update-baseline` after healing). Legacy hardcoded Chinese is paid down by whoever touches the file: when editing a file with frozen violations, extract its Chinese strings to i18n (`/i18n-localizer`) in the same change. See `packages/locales/README.md`.
- **Quality gate (CI-enforced, `frontend-quality.yml`)**: `pnpm lint` + `pnpm typecheck` (run from `src/frontend/`) must pass. Legacy violations are frozen — ESLint in each app's `eslint-suppressions.json`, TS strict via `// @ts-strict-ignore` file headers — and may only shrink: never hand-edit the suppressions file, never add `@ts-strict-ignore` to a new file. After fixing violations in a file, run `pnpm lint:prune` (per app) and delete its `@ts-strict-ignore` header if it now passes strict.
---
## 5. Architecture Guard (Auto-enforced)
`scripts/arch-guard.sh` runs after every Write/Edit via a PostToolUse hook (through `.claude/hooks/arch-guard-hook.sh`, which feeds violations back to the agent as `additionalContext` for self-correction).
The 8 RULEs are the machine-enforcement arm of constitution **C1 / C4 / C6 / C7** — the clause↔RULE anchor table lives in [`docs/constitution.md`](docs/constitution.md). **VIOLATION must be fixed immediately.**
---
## 6. SDD Workflow (non-trivial features)
**Full guide — track selection, ★ pause points, deviation re-confirm rule, document roles, constitution gate, harness → [`docs/SDD-Guide.md`](docs/SDD-Guide.md).**
```
0. release-contract.md (features/v{X.Y.Z}/release-contract.md;
version's first feature creates it) + read constitution.md
1. Spec Discovery → ★ user confirms
2. spec.md → /sdd-review <dir> spec → ★ user confirms
3. design.md → /sdd-review <dir> design → ★ user confirms (Constitution Check)
4. tasks.md → /sdd-review <dir> tasks
5. branch feat/<version>/{NNN}-{name} (create early; docs + code on the branch)
6. implement wave-by-wave → /task-review <dir> <id> → check off
7. /e2e-test <dir> (mandatory)
8. /code-review --base <main> (+ CI auto-review)
9. merge
```
Artifacts: `features/v{X.Y.Z}/{NNN}-{name}/{spec,design,tasks}.md`. Templates: `features/_templates/` (incl. `release-contract.md`).
**★ cannot be skipped.** Trivial/hotfix changes use a lighter track — see SDD-Guide §1.
Tests: new backend tests under `test/<module>/` (e.g., `test/approval/`), not `test/` root. `asyncio_mode=auto`.
---
## 7. Common Pitfalls
Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap, Celery Beat × multi-tenant, DB config Redis TTL) → `src/backend/AGENTS.md` §Known Pitfalls. MinIO `sharepoint` image-proxy pitfall → `src/frontend/platform/AGENTS.md` §Known Pitfalls. Commercial edition (`BISHENG_PRO` env, gateway proxy, SSO) → `docs/architecture/11-gateway.md`.
| Pitfall | Reality |
|---------|---------|
| `/api/v1/env` version field | Hardcoded `2.4.0` in source — unreliable. Use route probing instead. |
| Passwords in config.yaml | Fernet-encrypted. Never write plaintext passwords into the YAML. |
| First registered user | Becomes `super_admin` automatically. In multi-tenant mode, create the tenant first. |
---
## 8. Reference
- **Docs index** → `docs/README.md` (navigation hub); onboarding & testing → `docs/architecture/09-development-guide.md`
- **Architecture docs** → `docs/architecture/` (overview, permission, gateway, multi-tenant, data-models, …)
- **Skills**: `/sdd-review`, `/task-review`, `/code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor`
**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, `src/frontend/packages/ui/` (shared component library + design-token SSOT), plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations) and `src/backend/scripts/` (one-off scripts). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
-1
View File
@@ -107,4 +107,3 @@ Welcome to join our discussion group
[![Star History Chart](https://api.star-history.com/svg?repos=dataelement/bisheng&type=Date)](https://star-history.com/#dataelement/bisheng&Date)
-->
+4 -69
View File
@@ -2,39 +2,11 @@
# 密码加密参考 https://dataelem.feishu.cn/wiki/BSCcwKd4Yiot3IkOEC8cxGW7nPc#Gxitd1xEeof1TzxdhINcGS6JnXd
database_url:
"mysql+pymysql://root:gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==@mysql:3306/bisheng?charset=utf8mb4"
# "dm+dmPython://BISHENG:<enc>@192.168.107.9:5236/?schema=BISHENG"
# 数据库连接池配置(可选,省略则用下方默认值)。
# 注意:连接池是“每进程每引擎”一套,总连接数 ≈ 进程数 ×(pool_size + max_overflow)。
# 调优时务必让该总数小于数据库服务端的最大会话数(如达梦 MAX_SESSIONS)。
database_pool:
pool_size: 50 # 每进程常驻连接数
max_overflow: 20 # 高峰允许超出 pool_size 的临时连接数
pool_timeout: 30 # 等待空闲连接的超时秒数
pool_recycle: 3600 # 连接最大存活秒数,超过则回收重建
pool_pre_ping: true # 取连接前先 ping 检测有效性
# MySQL TLS 默认关闭。使用私有 CA 时配置 ca_file;双向 TLS 再同时配置 cert_file 与 key_file。
ssl:
enabled: false
# ca_file: /etc/bisheng/certs/mysql-ca.pem
# cert_file: /etc/bisheng/certs/mysql-client-cert.pem
# key_file: /etc/bisheng/certs/mysql-client-key.pem
# verify_hostname: true
# 缓存配置 redis://[[username]:[password]]@localhost:6379/0
# 如果设置了密码,需要参考MySQL密码的加密逻辑对密码进行加密。eg: redis://root:gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==@redis:6379/0
# 普通模式:
redis_url: "redis://redis:6379/1"
# 与 docker-compose 中 openfga 服务对应(容器内网络)
openfga:
enabled: true
api_url: "http://openfga:8080"
# 网关 HMAC 推送验签(与 bisheng-gateway 的 bisheng.gateway-hmac-secret 一致)
sso_sync:
gateway_hmac_secret: "bisheng-local-hmac-20260422"
signature_header: "X-Signature"
# 集群模式或者哨兵模式(只能选其一):
# redis_url:
# mode: "cluster"
@@ -48,52 +20,15 @@ sso_sync:
# sentinel_password: encrypt(gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==)
# db: 1
# Celery broker Redis 配置
# 单点模式(兼容现有写法):
# celerybroken地址
celery_redis_url: "redis://redis:6379/2"
# 哨兵模式:
# celery_redis_url:
# mode: "sentinel"
# sentinel_hosts:
# - {"host": "redis-sentinel-1", "port": 26379}
# - {"host": "redis-sentinel-2", "port": 26379}
# - {"host": "redis-sentinel-3", "port": 26379}
# sentinel_master: "mymaster"
# sentinel_password: encrypt(gAAAAABlp5vQN7g85IfoeK8nLCb9cfVqpy9ZK9kN7b0qbAXZ4NOZT_Ef7stKJBY6PjL0dngQnCvQMdsavuGu-EE2o6Zlvv6l-Frye08DBXRR4WmL7y7EfK4=)
# password: encrypt(gAAAAABlp5vQN7g85IfoeK8nLCb9cfVqpy9ZK9kN7b0qbAXZ4NOZT_Ef7stKJBY6PjL0dngQnCvQMdsavuGu-EE2o6Zlvv6l-Frye08DBXRR4WmL7y7EfK4=)
# db: 10
# 知识库文件解析调度策略
knowledge_file_worker:
# 是否开启单独的ocr解析队列,防止ocr文件解析阻塞普通的文件解析
ocr_queue_enabled: false
# ocr解析队列celery对应的queue名称,只有在ocr_queue_enabled=true时生效。必须启动worker并指定-Q ocr_celery 才能处理ocr相关的任务,否则ocr相关的任务会一直积压在队列里无法被处理
ocr_queue: "ocr_celery"
# 是否开启公平调度
fair_scheduler_enabled: false
# 公平调度的配置项,只有在fair_scheduler_enabled=true时生效
fair_scheduler:
# 调度任务的分布式锁的超时时间,不能大于调度任务的执行间隔时间,否则可能出现锁还没过期但调度任务已经开始执行了,导致多个调度任务同时执行,无法达到公平调度的效果。建议设置为调度任务执行间隔时间的0.8倍以下
dispatch_lock_ttl_seconds: 24
# 每个队列的全局并发上限 = 文件解析的最大并发数,是唯一的硬上限。应与监听该队列的所有worker进程的 -c(--concurrency) 之和保持一致。配置过大会导致任务在broker中堆积,配置过小则worker空转。
queue_concurrency:
knowledge_celery: 20
ocr_celery: 5
# 用户公平权重(不是在飞上限)。调度时优先把空位分配给"当前在飞数/权重"最小的用户,权重越大稳态在飞份额越高。默认1表示所有用户等额竞争。
per_user_pick_size: 1
# 单独给某些用户配置更大的权重,key是用户id,value是权重。如 {"1": 3} 表示用户1维持约3倍于普通用户的在飞份额。没有单独配置的用户按照per_user_pick_size。
user_overrides: {"1": 3}
# 已经进入队列但还没有被worker执行的文件在队列里的最长时间,超过这个时间后会被认为是异常文件,调度任务会重新调度这个文件。建议设置为worker执行一个文件的最长时间的1.5倍以上
inflight_ttl_seconds: 7200
celery_task:
# 对celery熟悉的用户可以自定义配置任务的路由,启动不同类型的worker处理不同类型的异步任务。注意工作流的执行必须在workflow_celery开头的进程!!!
# 对celery熟悉的用户可以自定义配置任务的路由,启动不同类型的worker处理不同类型的异步任务。注意工作流的执行只能在一个进程!!!
task_routers:
bisheng.worker.knowledge.*: # 知识库文件处理相关任务
queue: knowledge_celery
bisheng.worker.workflow.*: # 工作流相关任务
queue: workflow_celery
# 知识库的milvus和es配置 支持使用 !env ${PATH} 填写环境变量的值, 若环境变量不存在则会报错
vector_stores:
+9 -81
View File
@@ -1,93 +1,21 @@
#!/bin/bash
set -xe
export PYTHONPATH="./"
start_mode=${1:-api}
start_knowledge(){
# 知识库解析的celery worker
celery -A bisheng.worker.main worker -l info -c 50 -P threads -Q knowledge_celery -n knowledge@%h
}
start_knowledge_ocr(){
# 知识库解析的ocr服务的celery worker,如果开启了单独的ocr解析队列(config.yaml里knowledge_file_worker.ocr_queue_enabled=true),则需要启动这个worker来处理ocr相关的任务,否则ocr相关的任务会一直积压在队列里无法被处理
celery -A bisheng.worker.main worker -l info -c 5 -P threads -Q ocr_celery -n knowledge_ocr@%h
}
start_workflow(){
# 工作流相关的celery worker。支持多节点运行,但是需要保证各节点的队列名称不冲突且都以workflow_celery开头
celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q workflow_celery -n workflow@%h
}
start_beat(){
# 定时任务调度
celery -A bisheng.worker.main beat -l info
}
start_linsight(){
# 灵思后台任务worker
python bisheng/linsight/worker.py --worker_num 1 --max_concurrency 5
}
start_default(){
# 默认其他任务的执行worker,目前是定时统计埋点数据
celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q celery -n celery@%h
}
start_min_worker(){
# 最小化worker进程数,减少资源占用
celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q knowledge_celery,ocr_celery,workflow_celery,celery -n min_worker@%h
}
if [ "$start_mode" = "api" ]; then
echo "Running database migrations..."
# Fail fast on migration errors instead of starting the API on a stale,
# half-migrated schema (which surfaces later as confusing "missing
# column/table" 500s). The most common cause is multiple alembic heads: a
# new migration whose down_revision was mounted on an already-applied
# revision instead of the current head. `alembic upgrade head` (singular)
# aborts on that — surface it and stop rather than swallowing the error.
if ! alembic upgrade head; then
echo "FATAL: 'alembic upgrade head' failed; refusing to start the API on a stale schema." >&2
echo " If the cause is multiple heads, inspect with 'alembic heads' and run 'alembic merge heads'." >&2
exit 1
fi
if [ $start_mode = "api" ]; then
echo "Starting API server..."
uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 2
elif [ "$start_mode" = "knowledge" ]; then
echo "Starting Knowledge Celery worker..."
start_knowledge
elif [ "$start_mode" = "knowledge_ocr" ]; then
echo "Starting Knowledge OCR Celery worker..."
start_knowledge_ocr
elif [ "$start_mode" = "workflow" ]; then
echo "Starting Workflow Celery worker..."
start_workflow
elif [ "$start_mode" = "beat" ]; then
echo "Starting Celery beat..."
start_beat
elif [ "$start_mode" = "default" ]; then
echo "Starting default celery worker..."
start_default
elif [ "$start_mode" = "linsight" ]; then
echo "Starting LinSight worker..."
start_linsight
elif [ "$start_mode" = "worker" ]; then
echo "Starting All worker..."
uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 8
elif [ $start_mode = "worker" ]; then
echo "Starting Celery worker..."
# 处理知识库相关任务的worker
# start_knowledge &
# # 处理工作流相关任务的worker
# start_workflow &
# # 处理linsight相关任务的worker
# # 默认其他任务的执行worker,目前是定时统计埋点数据
# start_default &
nohup celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q knowledge_celery -n knowledge@%h &
# 工作流执行worker
nohup celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q workflow_celery -n workflow@%h &
start_min_worker &
start_linsight &
start_beat
echo "All workers started successfully."
python bisheng/linsight/worker.py --worker_num 4 --max_concurrency 5
else
echo "Invalid start mode. Use api、worker、knowledge、knowledge_ocr、workflow、beat、default、linsight."
echo "Invalid start mode. Use 'api' or 'worker'."
exit 1
fi
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env bash
# =============================================================
# BiSheng 运维管理脚本
# 用法: ./bisheng.sh <命令> [参数]
# =============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml"
COMPOSE_CMD="docker compose -f ${COMPOSE_FILE}"
# 容器名常量(与 docker-compose.yml 对应)
BACKEND_CONTAINER="bisheng-backend"
WORKER_CONTAINER="bisheng-backend-worker"
# 所有可管理的 compose service 名称
ALL_SERVICES=(backend backend_worker frontend mysql redis elasticsearch minio milvus etcd)
# ─── 颜色输出 ────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${GREEN}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
header() { echo -e "${CYAN}${BOLD}$*${RESET}"; }
# ─── 帮助 ────────────────────────────────────────────────────
usage() {
header "═══════════════════════════════════════════════"
header " BiSheng 运维管理脚本"
header "═══════════════════════════════════════════════"
echo ""
echo -e "${BOLD}查看日志:${RESET}"
echo " $0 logs backend 实时跟踪 backend 日志(默认最近 200 行)"
echo " $0 logs worker 实时跟踪 backend_worker 日志(默认最近 200 行)"
echo " $0 logs backend -n 0 实时跟踪 backend 所有历史日志"
echo " $0 logs worker -n 500 查看 worker 最近 500 行日志"
echo ""
echo -e "${BOLD}镜像版本管理:${RESET}"
echo " $0 version 查看当前配置的镜像版本"
echo " $0 version v3.0.0 修改 backend、worker、frontend 的版本为 v3.0.0"
echo ""
echo -e "${BOLD}进入容器 Shell${RESET}"
echo " $0 exec backend 进入 backend 容器"
echo " $0 exec worker 进入 backend_worker 容器"
echo ""
echo -e "${BOLD}更新镜像并重启:${RESET}"
echo " $0 update 拉取最新镜像并重启 backend + worker"
echo " $0 update backend 只更新并重启 backend"
echo " $0 update worker 只更新并重启 worker"
echo ""
echo -e "${BOLD}重启容器:${RESET}"
echo " $0 restart 重启 backend + worker"
echo " $0 restart backend 重启 backend"
echo " $0 restart worker 重启 worker"
echo " $0 restart frontend 重启 frontend"
echo " $0 restart <service...> 重启任意多个 service"
echo ""
echo -e "${BOLD}可用 service 名称:${RESET}"
echo " ${ALL_SERVICES[*]}"
echo ""
}
# ─── service 别名解析 ─────────────────────────────────────────
resolve_service() {
case "$1" in
backend) echo "backend" ;;
worker|backend_worker) echo "backend_worker" ;;
frontend) echo "frontend" ;;
mysql) echo "mysql" ;;
redis) echo "redis" ;;
es|elasticsearch) echo "elasticsearch" ;;
minio) echo "minio" ;;
milvus) echo "milvus" ;;
etcd) echo "etcd" ;;
*) echo "$1" ;; # 原样传入,让 docker compose 自行报错
esac
}
# ─── 查看日志 ─────────────────────────────────────────────────
cmd_logs() {
local target="${1:-}"
shift || true
local lines=200
# 解析可选 -n <行数>
while [[ $# -gt 0 ]]; do
case "$1" in
-n)
lines="${2:-200}"
shift 2
;;
*)
shift
;;
esac
done
local service
case "$target" in
backend) service="backend" ;;
worker|backend_worker) service="backend_worker" ;;
*)
error "未知目标 '${target}',请使用 backend 或 worker"
exit 1
;;
esac
if [ "$lines" -eq 0 ]; then
info "实时跟踪 ${service} 所有历史日志(Ctrl+C 退出)..."
${COMPOSE_CMD} logs -f "${service}"
else
info "实时跟踪 ${service} 最近 ${lines} 行日志(Ctrl+C 退出)..."
${COMPOSE_CMD} logs -f --tail="${lines}" "${service}"
fi
}
# ─── 修改版本号 ───────────────────────────────────────────────
cmd_version() {
local new_version="${1:-}"
if [[ -z "$new_version" ]]; then
info "当前 docker-compose.yml 配置的版本:"
grep -E "image:.*dataelement/bisheng-(backend|frontend):" "$COMPOSE_FILE" | awk '{$1=$1};1'
return 0
fi
info "正在将 backend, backend_worker, frontend 版本修改为: ${new_version}"
# 兼容 macOS 和 Linux 的 sed -i 用法
if sed --version 2>/dev/null | grep -q GNU; then
sed -i -E "s|(image: dataelement/bisheng-backend):.*|\1:${new_version}|g" "$COMPOSE_FILE"
sed -i -E "s|(image: dataelement/bisheng-frontend):.*|\1:${new_version}|g" "$COMPOSE_FILE"
else
# macOS/BSD sed
sed -i '' -E "s|(image: dataelement/bisheng-backend):.*|\1:${new_version}|g" "$COMPOSE_FILE"
sed -i '' -E "s|(image: dataelement/bisheng-frontend):.*|\1:${new_version}|g" "$COMPOSE_FILE"
fi
info "✅ 版本修改完成:"
grep -E "image:.*dataelement/bisheng-(backend|frontend):" "$COMPOSE_FILE" | awk '{$1=$1};1'
warn "注意:只是修改了配置文件,若要生效请执行 '$0 update'"
}
# ─── 进入容器 ─────────────────────────────────────────────────
cmd_exec() {
local target="${1:-}"
local container
case "$target" in
backend) container="${BACKEND_CONTAINER}" ;;
worker|backend_worker) container="${WORKER_CONTAINER}" ;;
*)
error "未知目标 '${target}',请使用 backend 或 worker"
exit 1
;;
esac
info "进入容器 ${container} ..."
docker exec -it "${container}" /bin/bash 2>/dev/null \
|| docker exec -it "${container}" /bin/sh
}
# ─── 更新镜像并重启 ───────────────────────────────────────────
cmd_update() {
local targets=()
if [[ $# -eq 0 ]]; then
targets=("backend" "backend_worker" "frontend")
else
for t in "$@"; do
targets+=("$(resolve_service "$t")")
done
fi
info "拉取最新镜像:${targets[*]}"
${COMPOSE_CMD} pull "${targets[@]}"
info "重启服务(不重建依赖):${targets[*]}"
${COMPOSE_CMD} up -d --no-deps "${targets[@]}"
info "✅ 更新完成"
${COMPOSE_CMD} ps "${targets[@]}"
}
# ─── 重启容器 ─────────────────────────────────────────────────
cmd_restart() {
local targets=()
if [[ $# -eq 0 ]]; then
targets=("backend" "backend_worker" "frontend")
else
for t in "$@"; do
targets+=("$(resolve_service "$t")")
done
fi
info "重启服务:${targets[*]}"
${COMPOSE_CMD} restart "${targets[@]}"
info "✅ 重启完成"
${COMPOSE_CMD} ps "${targets[@]}"
}
# ─── 入口 ────────────────────────────────────────────────────
main() {
if [[ $# -eq 0 ]]; then
usage
exit 0
fi
local cmd="$1"; shift
case "$cmd" in
logs) cmd_logs "$@" ;;
version) cmd_version "$@" ;;
exec) cmd_exec "$@" ;;
update) cmd_update "$@" ;;
restart) cmd_restart "$@" ;;
help|-h|--help) usage ;;
*)
error "未知命令: ${cmd}"
usage
exit 1
;;
esac
}
main "$@"
+21
View File
@@ -0,0 +1,21 @@
services:
bisheng-unstructured:
container_name: bisheng-unstructured
image: dataelement/bisheng-unstructured:v0.0.3.14
ports:
- "10001:10001"
environment:
# 填写ocr_sdk或rt服务的根地址
# server_address: bisheng-rt:9001
# 这里填 ocr_sdk 或 rt
# server_type: ocr_sdk
TZ: Asia/Shanghai
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng-uns/config.yaml:/opt/bisheng-unstructured/bisheng_unstructured/config/config.yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:10001/health"]
interval: 30s
timeout: 20s
retries: 3
restart: on-failure
+7 -54
View File
@@ -13,54 +13,13 @@ services:
- ${DOCKER_VOLUME_DIRECTORY:-.}/mysql/conf/my.cnf:/etc/mysql/my.cnf
- ${DOCKER_VOLUME_DIRECTORY:-.}/mysql/data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysql -u root -p$$MYSQL_ROOT_PASSWORD -e 'CREATE DATABASE IF NOT EXISTS openfga;'"]
test: ["CMD-SHELL", "exit | mysql -u root -p$$MYSQL_ROOT_PASSWORD"]
start_period: 30s
interval: 20s
timeout: 10s
retries: 4
restart: on-failure
openfga-migrate:
container_name: bisheng-openfga-migrate
image: openfga/openfga:latest
command: migrate
environment:
OPENFGA_DATASTORE_ENGINE: mysql
OPENFGA_DATASTORE_URI: "root:1234@tcp(mysql:3306)/openfga?parseTime=true"
depends_on:
mysql:
condition: service_healthy
openfga:
container_name: bisheng-openfga
image: openfga/openfga:latest
command: run
environment:
OPENFGA_DATASTORE_METRICS_ENABLED: "true"
OPENFGA_DATASTORE_ENGINE: mysql
OPENFGA_DATASTORE_URI: "root:1234@tcp(mysql:3306)/openfga?parseTime=true"
OPENFGA_LOG_FORMAT: json
OPENFGA_PLAYGROUND_ENABLED: "true"
OPENFGA_CHECK_QUERY_CACHE_ENABLED: "true"
OPENFGA_CHECK_QUERY_CACHE_TTL: 30s
OPENFGA_CHECK_ITERATOR_CACHE_ENABLED: "true"
OPENFGA_CHECK_ITERATOR_CACHE_TTL: 30s
OPENFGA_CHECK_ITERATOR_CACHE_MAX_RESULTS: 10000
OPENFGA_CACHE_CONTROLLER_ENABLED: "true"
OPENFGA_CACHE_CONTROLLER_TTL: 10s
OPENFGA_DATASTORE_MAX_OPEN_CONNS: 80
OPENFGA_DATASTORE_MAX_IDLE_CONNS: 40
ports:
- "8080:8080"
- "8081:8081"
- "2112:2112"
depends_on:
openfga-migrate:
condition: service_completed_successfully
# 不在 compose 里写 HEALTHCHECKopenfga 官方镜像为 distroless,无 /bin/sh、无 wget
# CMD-SHELL 会永久 unhealthy。未定义时 Compose 将 ``service_healthy`` 视为已启动即可。
restart: unless-stopped
redis:
container_name: bisheng-redis
image: redis:7.0.4
@@ -81,12 +40,11 @@ services:
backend:
container_name: bisheng-backend
image: dataelement/bisheng-backend:v2.6.0-fix2
image: dataelement/bisheng-backend:v2.1.1
ports:
- "7860:7860"
environment:
TZ: Asia/Shanghai
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}'
BS_MILVUS_IS_PARTITION: 'true'
BS_MILVUS_PARTITION_SUFFIX: '1'
@@ -117,16 +75,12 @@ services:
condition: service_healthy
redis:
condition: service_healthy
# openfga 官方镜像无 HEALTHCHECKCompose v5 无法用 service_healthy
openfga:
condition: service_started
backend_worker:
container_name: bisheng-backend-worker
image: dataelement/bisheng-backend:v2.6.0-fix2
image: dataelement/bisheng-backend:v2.1.1
environment:
TZ: Asia/Shanghai
BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422"
BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}'
BS_MILVUS_IS_PARTITION: 'true'
BS_MILVUS_PARTITION_SUFFIX: '1'
@@ -151,12 +105,11 @@ services:
condition: service_healthy
redis:
condition: service_healthy
openfga:
condition: service_started
frontend:
container_name: bisheng-frontend
image: dataelement/bisheng-frontend:v2.6.0-fix2
image: dataelement/bisheng-frontend:v2.1.1
ports:
- "3001:3001"
environment:
@@ -170,7 +123,7 @@ services:
elasticsearch:
container_name: bisheng-es
image: docker.io/bitnamilegacy/elasticsearch:8.12.0
image: docker.io/bitnami/elasticsearch:8.12.0
user: root
ports:
- "9200:9200"
-1
View File
@@ -5,7 +5,6 @@ default-character-set=utf8mb4
default-character-set=utf8mb4
[mysqld]
max_connections=1000
init_connect='SET collation_connection = utf8mb4_unicode_ci, NAMES utf8mb4'
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
+4 -20
View File
@@ -6,10 +6,6 @@ map $http_upgrade $connection_upgrade {
}
upstream backend_server {
server backend:7860; # backend api
}
server {
gzip on;
@@ -25,31 +21,19 @@ server {
location / {
root /usr/share/nginx/html/platform;
index index.html index.htm;
# 禁止浏览器缓存 index.html
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires 0 always;
}
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /index.html =404;
add_header X-Frame-Options SAMEORIGIN;
}
location /workspace/ {
alias /usr/share/nginx/html/client/;
index index.html index.htm;
# 禁止浏览器缓存 /workspace/index.html
location = /workspace/index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires 0 always;
}
try_files $uri $uri/ /workspace/index.html;
}
location ~ ^(/workspace)?/api(/|$) {
rewrite ^/workspace(/.*)$ $1 break;
proxy_pass http://backend_server;
proxy_pass http://backend:7860;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -57,7 +41,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 1024m;
client_max_body_size 200m;
add_header Access-Control-Allow-Origin $host;
add_header X-Frame-Options SAMEORIGIN;
}
@@ -66,4 +50,4 @@ server {
rewrite ^/workspace(/.*)$ $1 break;
proxy_pass http://minio:9000;
}
}
}
+2 -2
View File
@@ -24,6 +24,6 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
client_max_body_size 1024m;
client_max_body_size 50m;
}
}
}
@@ -1,277 +0,0 @@
# 2.6 RAG 检索上下文格式优化 PRD
> 版本:v0.1(初稿,待评审)
> 作者:LineWalker
> 创建日期:2026-06-10
> 目标版本:v2.6
> 关联模块:`workflow/nodes/rag`、`workflow/nodes/knowledge_retriever`、`workstation`、`citation`
---
## 1. 背景与动机
### 1.1 现状
工作流 RAG 节点 / Knowledge Retriever 节点、以及 Workstation 知识检索工具,最终交付给 LLM 的检索 chunk 字符串大致形如:
```text
{<file_title>文件名 xx</file_title>
<file_abstract>文件摘要 xx</file_abstract>
<paragraph_content>分段内容正文</paragraph_content>}
citation_key: knowledgesearch_a81ac7fe:109
```
其中:
- `{...}` 包装 + `<file_title>/<file_abstract>/<paragraph_content>`**入库阶段**`KnowledgeUtils.aggregate_chunk_metadata``knowledge/domain/services/knowledge_utils.py:32-38`)写入 `Document.page_content`,并以此字符串做 embedding,已经持久化在 Milvus + ES 中。
- 末尾 `\n\ncitation_key: knowledgesearch_xxx:N`**检索后**`annotate_rag_documents_with_citations``citation/domain/services/citation_prompt_helper.py:108-136`)在 RAG 节点运行时追加的,不入库。
- 多条 chunk 由 LangChain `create_stuff_documents_chain` 默认以 `\n\n` 拼接后作为 `{context}` 注入 prompt。
### 1.2 问题
- **多 chunk 边界混淆**:默认 `\n\n` 分隔符与 chunk 内部 `\n\ncitation_key:` 后缀冲突,LLM 难以区分"上一条 chunk 的引用 key"和"下一条 chunk 的标题块"。
- **citation_key 易被当作正文输出**`citation_key: knowledgesearch_xxx` 紧贴正文末尾,部分模型会把它误抄进回答,或在引用规则未生效时直接以明文 `citation_key:` 形式泄漏到用户。
- **节点间格式不统一**Workstation `_build_knowledge_search_tool._format_chunk``workstation/domain/services/chat_service.py:611-639`)额外塞入 `<knowledge_base_id>` `<knowledge_base_name>` `<chunk_id>`,而 Workflow RAG / Knowledge Retriever 没有;多套上下文格式增加 LLM 学习成本,也让 citation_rules 提示词无法用统一描述覆盖。
- **chunk 引用 ID 与 chunk 正文耦合在同一文本流**:LLM 必须线性读到 chunk 末尾才能"知道"这条引用归属哪个 key,不利于先决定要不要引用、再决定怎么写。
### 1.3 不解决的问题(本期暂缓)
- **入库阶段去除 `{<file_title>...}` 包装、让 embedding 输入更纯净**:方案 1 已讨论过,但需要全量重建知识库才能见效,迁移成本高,本期不做,留到后续版本。
- chunk 切分粒度、abstract 重生成、metadata schema 扩展、混合检索权重调优等更深层 RAG 改造。
- 历史已入库 chunk 的回填 / 重写。
### 1.4 收益预期
- LLM 引用错误率(引用了不相关 chunk、引用 key 拼错、漏引)下降。
- 用户回答中明文出现 `citation_key:` 文本的比例降至 0。
- 三个出口节点上下文格式一致,便于后续统一演进与压测。
---
## 2. 目标与范围
### 2.1 目标
1. 为 LLM 上下文中的 chunk 提供 **明确边界**、**统一外壳**、**结构化引用属性**。
2. 不改动入库流程、不重建知识库、不改动 `Document.page_content` 持久化结构。
3. 兼容现有 citation 注册、前端渲染、`…` 引用输出格式。
### 2.2 范围
| 范围 | 包含 |
|------|------|
| ✅ 在范围 | Workflow RAG 节点 prompt 渲染、Workflow Knowledge Retriever 节点输出字符串、Workstation 知识检索 tool 返回格式、citation_rules 提示词同步、`annotate_rag_documents_with_citations` 改造 |
| ❌ 不在范围 | Ingest 阶段 `aggregate_chunk_metadata` 包装;Milvus / ES 已有数据;KnowledgeUtils.split_chunk_metadata;前端引用气泡渲染;非知识库工具(如 web search)的 citation 渲染 |
### 2.3 非目标
- 不优化 chunk 召回算法本身(rerank、混合检索等)。
- 不改 Workstation 工具 schema`knowledge_base_ids``query``filters` 参数保持不变)。
---
## 3. 设计方案
### 3.1 统一渲染契约(Render Contract
将每条检索到的 chunk 渲染为如下 XML 块,多 chunk 顺序拼接(块之间一个 `\n` 即可,由 `<doc>` 标签提供天然边界):
```text
<doc index="1" citation_key="knowledgesearch_a81ac7fe:109">
{<file_title>文件名 xx</file_title>
<file_abstract>文件摘要 xx</file_abstract>
<paragraph_content>分段内容正文</paragraph_content>}
</doc>
<doc index="2" citation_key="knowledgesearch_a81ac7fe:201">
{<file_title>文件名 yy</file_title>
<paragraph_content>分段内容正文 2</paragraph_content>}
</doc>
```
设计要点:
- **外层 `<doc>` 提供边界**:替代 `create_stuff_documents_chain``\n\n` 隐式分隔,杜绝两条 chunk 之间的内容粘连。
- **`index` 属性**:从 1 开始的位序,对应检索召回顺序,方便 LLM 内部推理时引用"第 N 条"。
- **`citation_key` 属性**:作为属性而非内嵌文本,LLM 在读到 chunk 之前先"看到"引用 key,更利于"先决策再引用"。无 citation_key 的 chunk 渲染为 `<doc index="N">…</doc>`(属性缺省)。
- **内部 chunk 内容原样保留**:仍然是 `{<file_title>...</file_title>\n<file_abstract>...</file_abstract>\n<paragraph_content>...</paragraph_content>}` 的当前形态,不动入库写入的字符串,向量与原文一致。
- **不再向 page_content 追加 `\n\ncitation_key:` 后缀**`citation_key` 仅以属性形式出现,且保留在 `Document.metadata['citation_key']` 中供其他链路使用。
> 渲染契约约束 LLM 可见的 prompt 文本格式,不约束节点内部的 `Document` 对象结构。
### 3.2 核心改动点
#### 3.2.1 `annotate_rag_documents_with_citations``citation/domain/services/citation_prompt_helper.py:108-136`
- 保留:写入 `metadata['citation_key']`
- 删除:第 133 行 `page_content = f'{page_content}\n\ncitation_key: {citation_key}'` 追加逻辑。
- 影响:`_clean_document_for_citation`(同文件 179-185 行)反向剥离逻辑保持不变,`str.replace` 找不到目标字符串时为 no-op,向后兼容历史调用。
- 单测:现有 citation registry 收集与 hash 计算的等价性保持不变。
#### 3.2.2 新增 `render_documents_to_llm_context`(建议放在 `citation/domain/services/citation_prompt_helper.py` 或新建 `workflow/common/rag_render.py`
```python
def render_documents_to_llm_context(documents: List[Document]) -> str:
"""Render retrieved documents into the unified <doc> contract for LLM context."""
```
- 输入:`List[Document]`(已被 `annotate_rag_documents_with_citations` 处理过)。
- 输出:拼接后的字符串,遵循 §3.1 契约。
- 规则:
- `index` 从 1 起编号。
- `citation_key` 取自 `metadata.get('citation_key')`;为空则不渲染该属性。
- XML 属性值需做最小转义(`"``&quot;``<``&lt;`);目前 `citation_key` 格式仅含字母数字 + `:` + `_`,无须额外处理,但工具函数应一并实现,避免后续 schema 演进时被字符越界破坏。
- 内层 `page_content` 原样写入。
- 单测覆盖:空列表 / 单条 / 多条 / 缺失 citation_key / 含特殊字符。
#### 3.2.3 Workflow RAG 节点(`workflow/nodes/rag/rag.py`
-`rag_one_question`(第 85-114 行)中:
- 当前 `qa_chain = create_stuff_documents_chain(llm=self._llm, prompt=self._qa_prompt)` 默认会以 `\n\n` join 每个 `Document.page_content` 渲染 `{context}`
- 改为:调用 `render_documents_to_llm_context(source_documents_with_citations)` 获得契约字符串,直接以 `inputs["context"] = <契约字符串>` 注入;`qa_chain` 简化为 `self._qa_prompt | self._llm`(或保持 `create_stuff_documents_chain` 但传入自定义 `document_prompt` + `document_separator`,二选一)。
- `_log_source_documents` 与 callback 中 `source_documents=...` 传参仍为 `Document` 列表,前端引用气泡与 citation registry 链路 **不受影响**
> 选型建议:直接预渲染为字符串 + Prompt | LLM,逻辑清晰、可单测;保留 stuff_documents_chain 自定义 separator 会引入额外间接层。
#### 3.2.4 Workflow Knowledge Retriever 节点(`workflow/nodes/knowledge_retriever/knowledge_retriever.py`
- 输出结构当前为 `List[{"text", "citation_key", "metadata"}]`(第 40-54 行)。
- 该列表会被下游 LLM 节点通过 `{node_id.retrieved_result}` 变量引用并字符串化注入 prompt。
- 改动:`text` 字段值由原来的 `one.page_content` 改为 `render_documents_to_llm_context([one])` 的单 chunk 渲染结果(即 `<doc index="1" citation_key="...">…</doc>`);保留 `citation_key` 顶层字段与 `metadata` 不变。
- 风险:下游 LLM 节点拿到的是单条 `<doc index="1">…</doc>` 列表(而非多条 doc 的连贯上下文),index 都是 1。可接受,因为下游 LLM 节点的 prompt 模板由用户自定义编排,多条之间的拼接逻辑由用户控制;本节点不知道全局 doc 序列。如果对全局 index 有要求,可在 §6 中作为 follow-up。
#### 3.2.5 Workstation 知识检索 tool`workstation/domain/services/chat_service.py:611-639`
- 当前 `_format_chunk` 返回的字符串结构与 Workflow 不一致(额外含 `<knowledge_base_id>` `<knowledge_base_name>` `<chunk_id>`)。
- 改动:保留这些 Workstation 专属内部字段(它们对 Agent 多 KB 区分有意义),但把它们 **下沉到 `<doc>` 内层**,外层用统一契约包装。最终形如:
```text
<doc index="1" citation_key="knowledgesearch_a81ac7fe:109">
{<knowledge_base_id>...</knowledge_base_id>
<knowledge_base_name>...</knowledge_base_name>
<chunk_id>...</chunk_id>
<file_title>...</file_title>
<file_abstract>...</file_abstract>
<paragraph_content>...</paragraph_content>}
</doc>
```
- 工具返回值由"JSON 序列化的字符串列表"统一调整为"按 `index` 升序拼接后的单一字符串",更贴合 LLM 上下文消费方式;或保留 list 形态,由 `<doc index="N">` 自带 index 即可。**推荐**:直接返回 `\n` 拼接后的整串,减少 LLM 解析 JSON 列表的额外步骤。需在 PRD 评审时确认是否破坏 Workstation Agent 的下游解析(当前看 Agent 只是把 tool 结果作为字符串拼入 history,应可平迁)。
#### 3.2.6 `citation_rules.yaml` 同步更新
当前文本(`core/prompts/yaml/citation.yaml:18-24`)描述 `citation_key` 来自"检索结果中",但未明确从哪里读取。新增 / 修改一条 Required Behavior
> 0. 每条检索结果以 `<doc index="N" citation_key="...">…</doc>` 包裹;引用时必须使用该 `<doc>` 标签上的 `citation_key` 属性值,不得读取或编造 `<doc>` 内部正文中出现的任何 ID 字符串。
并明确 `<doc>` 内部正文 **不会再出现** `citation_key:` 明文行,避免 LLM 被歧义引导。
输出格式(`…` 标记规则)保持不变。
### 3.3 兼容性
| 链路 | 影响 |
|------|------|
| Milvus / ES 中已入库的 chunk | 完全不变,向量与正文均不动 |
| `Document.metadata['citation_key']` | 不变(仍由 `annotate_rag_documents_with_citations` 写入) |
| 前端引用气泡 / source_documents 流向回调 | 不变(callback 传的是 `Document` 列表,不依赖 page_content 后缀) |
| citation registry 收集 / hash | 不变(`_clean_document_for_citation``replace` 自然 no-op |
| `extract_citation_ids_from_text` / `…` 标记解析 | 不变 |
| `KnowledgeUtils.split_chunk_metadata` | 不变(处理的是 page_content 内部,外层 `<doc>` 包装不会进入这条路径) |
| 工作流变量 `{node_id.retrieved_result}``text` 字段 | **变化**:从纯 chunk 字符串变为 `<doc>` 包装字符串。用户若在自定义 LLM 节点的 prompt 中显式做了字符串截取或正则匹配,需通知。文档中加迁移说明。 |
### 3.4 风险与缓解
| 风险 | 缓解 |
|------|------|
| 部分小模型对 XML 属性的 attention 较弱,可能仍把 `citation_key` 当作正文 token | 在 citation_rules 中显式声明"`<doc>` 标签属性不得回写正文";线上 A/B 跑回归集验证 |
| Workstation Agent tool 返回值从 list[str] 改为 str,下游解析行为变化 | PRD 评审时 by code path 走查;必要时通过开关分阶段切换 |
| 用户已在 LLM 节点 prompt 模板中对 `retrieved_result.text` 做字符串处理(罕见但可能) | Release Note 显式公告新格式;Knowledge Retriever 节点配置增加一个 `legacy_text_format` 开关(缺省关,紧急回滚用),保留一个版本后移除 |
| Workflow RAG 节点 `qa_chain``create_stuff_documents_chain` 改为直拼字符串,stop sequence / token 长度行为可能微变 | 单测覆盖 token 数;保留 `_log_user_prompt` / `_log_system_prompt` 用于线上回放 |
---
## 4. 验收标准
### 4.1 功能验收
1. Workflow RAG 节点:执行一条问答后,`_log_user_prompt``{context}` 渲染结果按 §3.1 契约形态出现,且包含 `<doc index="1" citation_key="...">`
2. Workflow Knowledge Retriever 节点:输出列表中每条 `text` 字段以 `<doc index="1" citation_key="...">` 开头、`</doc>` 结尾。
3. Workstation 知识检索 tool:在含 2 个以上召回的 query 下,工具返回串包含 `<doc index="1"``<doc index="2"` 两条且顺序与 `chunk_id` 一致。
4. `Document.page_content``annotate_rag_documents_with_citations` 输出后 **不包含** `\n\ncitation_key:` 子串。
5. 现有 citation registry / `…` 引用样例(取 5 条历史 case 回放)输出格式与改动前一致。
### 4.2 自测项(开发阶段必跑)
- 单测:`render_documents_to_llm_context` 全分支。
- 单测:`annotate_rag_documents_with_citations` 不再写 page_content 后缀,但 metadata 写入正确。
- 集成测:Workflow RAG e2e 一例(构造 2 条召回 → LLM mock 返回带 `…` → 断言 system_prompt + user_prompt 形态、断言 citation_registry 持久化)。
- 集成测:Workstation 知识检索 tool 单次调用 → 断言返回字符串形态。
### 4.3 回归集
-`/e2e-test` 跑 RAG 节点既有覆盖。
- 手测:Workstation 在多 KB 选项下问一个能命中 2-3 条 chunk 的问题,目检引用气泡显示正常、回答末尾不出现明文 `citation_key:`
---
## 5. 落地计划
### 5.1 工作量估算
- 设计与单测:1 人 · 0.5 天
- 编码(含三处节点 + citation helper + citation_rules):1 人 · 1 天
- 集成测 + 回归:1 人 · 0.5 天
- 评审 + 修订 + Release Note1 人 · 0.5 天
- 合计:约 2.5 人天
### 5.2 任务粗拆(细化到 SDD tasks.md 或 issue tracker
1. `render_documents_to_llm_context` 工具函数 + 单测
2. 修改 `annotate_rag_documents_with_citations` + 回归单测
3. 修改 `workflow/nodes/rag/rag.py` `rag_one_question`
4. 修改 `workflow/nodes/knowledge_retriever/knowledge_retriever.py` 输出 `text` 字段
5. 修改 `workstation/domain/services/chat_service.py` `_build_knowledge_search_tool._format_chunk` 与工具返回值
6. 修改 `core/prompts/yaml/citation.yaml` 加入 `<doc>` 属性约束条目
7. 集成测脚本 + Release Note 草稿
### 5.3 上线策略
- 单 PR 提交,挂 v2.6 标签。
- 不需要数据库迁移,不需要重建知识库。
- 灰度方案:Workflow / Workstation 同步上,无需逐租户灰度。
---
## 6. Open Questions(待评审确认)
| # | 问题 | 候选方案 | 建议 |
|---|------|---------|------|
| Q1 | Workstation tool 返回值是否从 `list[str]` 改为 `str` | A: 改为 str(更贴近 LLM context);B: 保留 list,但每个元素都是 `<doc>` 包装 | 倾向 A,待 Workstation Agent 实现复核 |
| Q2 | Knowledge Retriever 节点输出的 `text` 是否随全局 index 改写(需要节点跨条目编号)? | A: 每条 index=1(实现简单);B: 按召回顺序连号 | 倾向 A,由下游使用方按需重排 |
| Q3 | 是否在 `<doc>` 内层补充 `<chunk_index>` `<document_id>` 等已知 metadata? | A: 不补(保持最小变更);B: 补少量利于 LLM 时序判断 | 倾向 A,留到下一期专门做 metadata 暴露 |
| Q4 | `legacy_text_format` 兼容开关是否实装? | A: 不做(直接切换 + Release Note);B: 做一个版本作为回滚口 | 倾向 A,改动面可控,单 PR 可回滚 |
| Q5 | 是否一并把 citation_rules 中 `…` 私有区字符的描述改成 `<citation>...</citation>` 形式? | A: 不动(已上线模型适配良好);B: 一并迁 | 倾向 A,保持本期最小变更 |
---
## 7. 附录
### 7.1 涉及文件清单
- `src/backend/bisheng/citation/domain/services/citation_prompt_helper.py`
- `src/backend/bisheng/workflow/nodes/rag/rag.py`
- `src/backend/bisheng/workflow/nodes/knowledge_retriever/knowledge_retriever.py`
- `src/backend/bisheng/workstation/domain/services/chat_service.py`
- `src/backend/bisheng/core/prompts/yaml/citation.yaml`
- 新增:`src/backend/bisheng/workflow/common/rag_render.py`(或并入 citation helper
- 新增:`src/backend/test/citation/test_rag_render.py`
- 新增:`src/backend/test/workflow/test_rag_node_context.py`
### 7.2 参考
- 入库包装来源:`knowledge/domain/services/knowledge_utils.py:32-38`
- 当前 citation 追加:`citation/domain/services/citation_prompt_helper.py:108-136`
- citation 反向剥离:`citation/domain/services/citation_prompt_helper.py:179-185`
- 现有 citation_rules`core/prompts/yaml/citation.yaml`
- 关联讨论:本仓库 conversation 2026-06-10 RAG system prompt 拆解线索
@@ -1,70 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="2.1 终端用户:从勾选 Skill 到拿到结果">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="U" value="终端用户" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="290" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="P" value="聊天框勾选 1+ 个 Skill" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="160" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Q" value="提交问题" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="280" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="PL" value="deepagents 内核规划任务&lt;br&gt;write_todos &amp;nbsp;|&amp;nbsp; AC-2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="400" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="EX" value="执行步骤:工具 / 知识库 / E2B 沙箱&lt;br&gt;AC-6" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="520" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="C" value="需要补充信息?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="290" y="640" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="H" value="HITL 中断:弹出输入框&lt;br&gt;AC-5" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="640" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="I" value="用户补充信息" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="520" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="R" value="产出结果 / 产物文件" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="760" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D" value="完成 · 全程流式可见&lt;br&gt;todo / 工具 / 产物 &amp;nbsp;|&amp;nbsp; AC-1" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="280" y="880" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="U" target="P">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="P" target="Q">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Q" target="PL">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="PL" target="EX">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="EX" target="C">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="I" target="EX">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" value="否" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="R">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="R" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,49 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="2.2 管理员:Skill 管理与存量迁移">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="MIG" value="系统升级 · 存量 SOP 一次性迁移(US-4" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="160" y="40" width="540" height="280" as="geometry"/>
</mxCell>
<mxCell id="S" value="存量 SOP&lt;br&gt;linsight_sop" style="shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="MIG">
<mxGeometry x="170" y="40" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="K" value="SKILL.md" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="MIG">
<mxGeometry x="170" y="130" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="RP" value="对账报告&lt;br&gt;成功 / 失败 / 跳过 · 异常可人工处理&lt;br&gt;AC-4" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="MIG">
<mxGeometry x="120" y="210" width="300" height="50" as="geometry"/>
</mxCell>
<mxCell id="eSK" value="转换脚本" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="MIG" source="S" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKRP" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="MIG" source="K" target="RP">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="MGMT" value="Skills 管理页" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="120" y="420" width="620" height="270" as="geometry"/>
</mxCell>
<mxCell id="TA" value="租户管理员 · US-3" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="MGMT">
<mxGeometry x="30" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="TS" value="租户自定义 Skill&lt;br&gt;按租户隔离" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="MGMT">
<mxGeometry x="210" y="180" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="SA" value="系统管理员 · US-5" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="MGMT">
<mxGeometry x="400" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="eTATS" value="新建/导入/编辑/删除/启停" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="MGMT" source="TA" target="TS">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eSATS" value="admin-scope 切入该租户后维护" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="MGMT" source="SA" target="TS">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eRPMGMT" value="迁移产物进入管理页" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="RP" target="MGMT">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,121 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.1.7 发起任务用户交互流程图">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="1400" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="Start" value="首页统一输入区" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="360" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="Mode" value="是否进入&lt;br&gt;任务模式" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="360" y="150" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="Normal" value="普通对话模式&lt;br&gt;本节不展开" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="660" y="160" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="InMode" value="显示任务模式 chip" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="350" y="280" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Plus" value="点开「+」菜单" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="180" y="400" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Toolbar" value="输入框工具栏&lt;br&gt;选用预设工具·独立入口&lt;br&gt;同日常模式·不生成 chip" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="640" y="395" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="Skill" value="添加技能&lt;br&gt;勾选启用·不勾不用" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="20" y="520" width="190" height="50" as="geometry"/>
</mxCell>
<mxCell id="KBSpace" value="添加知识空间&lt;br&gt;个人私有" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="230" y="520" width="190" height="50" as="geometry"/>
</mxCell>
<mxCell id="KBOrg" value="添加组织知识库&lt;br&gt;租户共享" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="520" width="190" height="50" as="geometry"/>
</mxCell>
<mxCell id="File" value="添加附件&lt;br&gt;解析见 §4.2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="650" y="520" width="190" height="50" as="geometry"/>
</mxCell>
<mxCell id="Compose" value="输入区生成上下文 chip" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="330" y="640" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Input" value="输入/补全任务描述" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="450" y="760" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Review" value="提交前一览&lt;br&gt;逐项可移除" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="450" y="880" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Submit" value="点击发送" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="460" y="1000" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="Go" value="提交任务&lt;br&gt;进入执行 §4.3" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="700" y="1120" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="Block" value="发送置灰并提示&lt;br&gt;请输入任务描述" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="1120" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Start" target="Mode">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="点击灵思入口/默认" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Mode" target="InMode">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" value="普通对话" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Mode" target="Normal">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="InMode" target="Plus">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="InMode" target="Toolbar">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Plus" target="Skill">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Plus" target="KBSpace">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7b" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Plus" target="KBOrg">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Plus" target="File">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Skill" target="Compose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="KBSpace" target="Compose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10b" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="KBOrg" target="Compose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="File" target="Compose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e12" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Compose" target="Input">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e13" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Toolbar" target="Input">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Input" target="Review">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e15" value="移除某项" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.5;entryX=1;entryY=0.5;" edge="1" parent="1" source="Review" target="Compose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e16" value="确认无误" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Review" target="Submit">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e17" value="可提交" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Submit" target="Go">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e18" value="空内容/仅 chip" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Submit" target="Block">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.5;exitY=0;entryX=0;entryY=0.5;" edge="1" parent="1" source="Block" target="Input">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,133 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.2.1 从上传到被灵思按需使用">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1900" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="A" value="在输入区点击 + 菜单" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="40" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="B" value="选择 附件" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="160" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="C" value="选择上传方式" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="570" y="270" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="D" value="系统文件选择器" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="240" y="420" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D2" value="拖拽热区高亮 松手即上传" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="420" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D3" value="剪贴板内容直接进入上传" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="880" y="420" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="E" value="格式与大小校验" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="570" y="540" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="X1" value="即时拦截&lt;br&gt;提示支持的格式清单" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="120" y="550" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="X2" value="即时拦截&lt;br&gt;提示上限并建议改用知识库" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="980" y="550" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="F" value="出现文件 chip&lt;br&gt;状态: 上传中 spinner" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="660" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="X4" value="自动移除 chip&lt;br&gt;toast 失败原因" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="120" y="660" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="G" value="状态: 解析中 spinner&lt;br&gt;转为结构化资料" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="780" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="X3" value="自动移除 chip&lt;br&gt;toast 失败原因" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="980" y="790" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="H" value="状态: 可用&lt;br&gt;chip 显示文件类型 icon" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="560" y="900" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="I" value="继续添加更多文件 或 撰写指令" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="1020" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="J" value="发送前: 附件出现在&lt;br&gt;本轮上下文一览 可逐项移除" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="550" y="1140" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="K" value="提交任务" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="1260" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="L" value="执行中: 灵思先看文件结构&lt;br&gt;用到才翻相关章节" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="550" y="1380" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="M" value="执行流出现可见步骤&lt;br&gt;正在翻阅《X 文件》" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="550" y="1500" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="N" value="产物中按原文件名标注来源&lt;br&gt;可点击溯源回看" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="550" y="1620" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="eAB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A" target="B">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBC" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="C">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eCD" value="点击选文件" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eCD2" value="拖拽到输入区" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="D2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eCD3" value="粘贴 截图/文件" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="D3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDE" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eD2E" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D2" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eD3E" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D3" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEX1" value="不支持格式" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="X1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEX2" value="超过单文件上限" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="X2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEF" value="通过" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="F">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eFX4" value="上传失败" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F" target="X4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eFG" value="传输完成" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F" target="G">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGH" value="成功" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGX3" value="无法提取文本" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="X3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHI" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eIJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="I" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eJK" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="J" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKL" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="K" target="L">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eLM" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L" target="M">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eMN" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M" target="N">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,49 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.2.2 解析状态可见:用户视角的状态流转">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="start" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#000000;strokeColor=#000000;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="s1" value="上传中" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="265" y="170" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s2" value="解析中" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="265" y="320" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s3" value="可用" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="265" y="470" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="endFail1" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="640" y="180" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="endFail2" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="640" y="330" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="endOk" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="640" y="480" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="e_start_s1" value="选择/拖拽/粘贴文件" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="start" target="s1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_s1_s2" value="文件传输完成" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s1" target="s2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_s1_end" value="上传失败&lt;br&gt;自动移除 + toast 原因" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="s1" target="endFail1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_s2_s3" value="成功提取为结构化资料" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s2" target="s3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_s2_end" value="解析失败&lt;br&gt;自动移除 + toast 原因" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="s2" target="endFail2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e_s3_end" value="随任务提交 或 被用户移除" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="s3" target="endOk">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,105 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.3.1 用户可见状态机">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="sStart" value="" style="ellipse;fillColor=#000000;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="400" y="40" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="s1" value="排队中" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="120" y="160" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s2" value="规划中" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="380" y="280" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s3" value="执行中" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="380" y="430" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s5" value="等待你的补充" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="640" y="430" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s7" value="执行失败" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="120" y="580" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s6" value="已完成" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="380" y="580" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="s4" value="已终止" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="640" y="580" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="sEnd" value="" style="ellipse;fillColor=#000000;strokeColor=#b85450;strokeWidth=4;" vertex="1" parent="1">
<mxGeometry x="495" y="740" width="30" height="30" as="geometry"/>
</mxCell>
<mxCell id="e1" value="提交任务·并发超限" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="sStart" target="s1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="提交任务·有空闲档位" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="sStart" target="s2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" value="轮到本任务调度" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s1" target="s2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" value="你点击取消排队" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s1" target="s4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" value="拆解出任务清单" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s2" target="s3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" value="逐项推进步骤" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.25;entryX=0;entryY=0.75;" edge="1" parent="1" source="s3" target="s3">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="320" y="442"/>
<mxPoint x="320" y="467"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e7" value="灵思发起追问" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.35;entryX=0;entryY=0.35;" edge="1" parent="1" source="s3" target="s5">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" value="你已回答" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.65;entryX=1;entryY=0.65;" edge="1" parent="1" source="s5" target="s3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" value="产物全部就绪" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s3" target="s6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" value="出错且无法继续" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s3" target="s7">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e11" value="无法开始" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.5;entryX=0.5;entryY=0;" edge="1" parent="1" source="s2" target="s7">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="210" y="305"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e12" value="你点击终止" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.85;entryX=0.5;entryY=0;" edge="1" parent="1" source="s3" target="s4">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="600" y="466"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e13" value="你点击终止" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s5" target="s4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e14" value="你基于产物继续追问" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.25;exitY=0;entryX=0.25;entryY=1;" edge="1" parent="1" source="s6" target="s3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e15" value="你点击重试" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.5;exitY=0;entryX=0;entryY=0.5;" edge="1" parent="1" source="s7" target="s2">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="210" y="305"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="e16" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s4" target="sEnd">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e17" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="s6" target="sEnd">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,157 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.3.4 执行过程用户交互流程图">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1700" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="A" value="用户提交任务" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="560" y="40" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="B" value="进入执行视图&lt;br&gt;全局状态:规划中" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="150" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="C" value="思考过程流式滚动&lt;br&gt;生成任务清单" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="260" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D" value="全局状态:执行中&lt;br&gt;清单逐项点亮" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="560" y="370" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="E" value="当前步骤类型" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="570" y="480" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="F" value="步骤卡:输入/输出/状态" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="620" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="G" value="步骤卡:正在翻阅文件第N页" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="620" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="H" value="步骤卡:命中资料条目" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="600" y="620" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="I" value="子任务块:内含子步骤流" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="880" y="620" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="J" value="灵思需要补充?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="470" y="730" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="K" value="全局状态:等待你的补充&lt;br&gt;输入区高亮 见4.4" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="120" y="735" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="L" value="执行结果" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="470" y="870" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="M" value="全局状态:已完成&lt;br&gt;产物区聚合交付物" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="120" y="1010" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="N" value="全局状态:执行失败&lt;br&gt;原因摘要+重试" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="470" y="1010" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="O" value="全局状态:已终止&lt;br&gt;保留已产出结果" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="820" y="1010" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="P" value="预览/单个下载/打包下载" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="1150" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Q" value="基于产物继续追问" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="280" y="1150" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="R" value="重试 或 查看中间结果" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="540" y="1150" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="eAB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A" target="B">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBC" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="C">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eCD" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDE" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEF" value="工具调用" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="F">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEG" value="翻阅附件" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="G">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEH" value="检索知识库" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEI" value="委派子任务" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eFJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eIJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="I" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eJK" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="J" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKD" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="K" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eJD" value="否,继续" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="J" target="D">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="900" y="765"/>
<mxPoint x="900" y="395"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eDL" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="D" target="L">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="430" y="395"/>
<mxPoint x="430" y="905"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eLM" value="成功" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L" target="M">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eLN" value="失败" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L" target="N">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eLO" value="用户终止" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L" target="O">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eMP" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M" target="P">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eMQ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M" target="Q">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eNR" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="N" target="R">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eQD" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="Q" target="D">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="380" y="1230"/>
<mxPoint x="380" y="445"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eRB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="R" target="B">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="640" y="1250"/>
<mxPoint x="800" y="1250"/>
<mxPoint x="800" y="175"/>
</Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,115 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.4.1 人机协同 HITL 用户交互流程图">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="1500" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="A" value="任务执行中" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="B" value="灵思遇到&lt;br&gt;需要用户决策的点?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="450" y="160" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="C" value="继续执行直至产出" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="780" y="170" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D" value="当前任务暂停&lt;br&gt;挂起到追问态" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="300" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="E" value="执行区出现追问卡&lt;br&gt;顶部高亮 等待你的输入" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="420" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="F" value="追问卡展示:问题 + 为什么需要 + 输入控件" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="540" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="G" value="用户如何响应?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="450" y="660" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="H" value="校验回答非空" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="800" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="J" value="二次确认后终止&lt;br&gt;保留已产出的中间结果" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="120" y="660" width="200" height="70" as="geometry"/>
</mxCell>
<mxCell id="K" value="追问态经 checkpointer 持久保存&lt;br&gt;回来仍可回答并续跑" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="760" y="655" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="I" value="追问卡收起为已回答摘要&lt;br&gt;任务从中断点继续执行" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="920" width="200" height="60" as="geometry"/>
</mxCell>
<mxCell id="L" value="是否还有新的&lt;br&gt;追问点?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="450" y="1040" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="M" value="产出最终产物" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="780" y="1190" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="eAB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A" target="B">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBC" value="否,信息充分" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="C">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBD" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDE" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEF" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="F">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eFG" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F" target="G">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGH" value="填写并提交" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHI" value="通过" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHF" value="为空/不合法" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.5;entryX=0;entryY=0.5;" edge="1" parent="1" source="H" target="F">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="380" y="825"/>
<mxPoint x="380" y="565"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eGJ" value="主动终止该任务" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGK" value="离开/隔很久再回来" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKF" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.5;exitY=0;entryX=1;entryY=0.5;" edge="1" parent="1" source="K" target="F">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="870" y="565"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eIL" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="I" target="L">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eLD" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0;exitY=0.5;entryX=0;entryY=0.5;" edge="1" parent="1" source="L" target="D">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="240" y="1075"/>
<mxPoint x="240" y="325"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eLC" value="否" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L" target="C">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="880" y="1075"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="eCM" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="M">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,58 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.5.2 Skill管理页信息架构">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="OP" value="顶部 操作区" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="40" y="60" width="260" height="400" as="geometry"/>
</mxCell>
<mxCell id="A1" value="上传 Skill" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="OP">
<mxGeometry x="30" y="40" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="A2" value="新建 Skill" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="OP">
<mxGeometry x="30" y="110" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="A3" value="编辑" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="OP">
<mxGeometry x="30" y="180" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="A4" value="删除" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="OP">
<mxGeometry x="30" y="250" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="A5" value="启用/停用 开关" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="OP">
<mxGeometry x="30" y="320" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="LS" value="左侧 列表区" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="540" y="60" width="280" height="340" as="geometry"/>
</mxCell>
<mxCell id="L0" value="搜索框" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="LS">
<mxGeometry x="40" y="40" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="L1" value="本租户自定义技能列表&lt;br&gt;名称·描述·启停状态&lt;br&gt;无分组" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="LS">
<mxGeometry x="40" y="180" width="200" height="80" as="geometry"/>
</mxCell>
<mxCell id="DS" value="右侧 详情区" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="1060" y="60" width="280" height="280" as="geometry"/>
</mxCell>
<mxCell id="D1" value="Preview 渲染视图" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="DS">
<mxGeometry x="40" y="50" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="D2" value="Source 原文视图" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="DS">
<mxGeometry x="40" y="170" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="eL0L1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="L0" target="L1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eD1D2" value="切换" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="D1" target="D2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eLSDS" value="选中某 Skill" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="LS" target="DS">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eOPLS" value="作用于选中项" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="OP" target="LS">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,85 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.5.3 上传/新建 Skill 交互流程">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1400" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="Start" value="点 上传 或 新建" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="330" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="Choose" value="选择方式" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="330" y="160" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="Drop" value="选文件 / 拖拽到热区" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="120" y="300" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Form" value="填写 名称/描述/正文 表单" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="520" y="300" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Validate" value="即时校验" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="430" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="ErrHint" value="就地标红 + 提示如何修正&lt;br&gt;保存按钮置灰" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="560" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="FixLoop" value="用户修改" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="690" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Preview" value="预览确认&lt;br&gt;Preview/Source 双视图" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="690" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Save" value="保存" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="350" y="830" width="140" height="50" as="geometry"/>
</mxCell>
<mxCell id="Done" value="列表出现新项&lt;br&gt;默认启用&lt;br&gt;成功提示" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="150" y="970" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="SaveErr" value="保存失败提示 + 可重试&lt;br&gt;不丢失已填内容" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="500" y="970" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Start" target="Choose">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="上传" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Choose" target="Drop">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" value="新建" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Choose" target="Form">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Drop" target="Validate">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Form" target="Validate">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" value="校验失败" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Validate" target="ErrHint">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="ErrHint" target="FixLoop">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="FixLoop" target="Validate">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" value="校验通过" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Validate" target="Preview">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" value="返回修改" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Preview" target="FixLoop">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e11" value="确认保存" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Preview" target="Save">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e12" value="成功" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Save" target="Done">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e13" value="失败" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Save" target="SaveErr">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="SaveErr" target="Preview">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,37 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.5.6 启停增删即时影响">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="M1" value="管理页 新建/启用 自定义技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="V1" value="终端用户输入区&lt;br&gt;该技能 即时出现 可选" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="360" y="40" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="M2" value="管理页 停用 自定义技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="160" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="V2" value="终端用户输入区&lt;br&gt;该技能 即时消失 不可选" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="360" y="160" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="M3" value="管理页 删除 自定义技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="280" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="V3" value="终端用户输入区&lt;br&gt;该技能 移除&lt;br&gt;已勾选者本轮失效并提示" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="360" y="280" width="240" height="70" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M1" target="V1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M2" target="V2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="M3" target="V3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,82 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.6.2 租户管理员首次进入管理页的迁移结果">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="1400" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="A" value="升级完成后 租户管理员&lt;br&gt;首次进入技能管理页" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="40" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="B" value="页面顶部出现&lt;br&gt;迁移结果提示条" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="160" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="C" value="点击 查看迁移对账报告" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="280" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="D" value="对账报告&lt;br&gt;按状态分组" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="460" y="390" width="180" height="80" as="geometry"/>
</mxCell>
<mxCell id="E" value="成功项&lt;br&gt;已转为技能 带 由SOP迁移 标识" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="120" y="540" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="F" value="失败项&lt;br&gt;转换未成功 附原因" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="440" y="540" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="G" value="跳过项&lt;br&gt;超大SOP等 未处理 附原因" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="740" y="540" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="H" value="可直接在管理页&lt;br&gt;查看/编辑/启停" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="130" y="680" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="I" value="人工处理路径:&lt;br&gt;查看原因→修正后重建为技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="430" y="680" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="J" value="人工处理路径:&lt;br&gt;超大SOP拆分后&lt;br&gt;重新上传为技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="740" y="680" width="240" height="70" as="geometry"/>
</mxCell>
<mxCell id="K" value="处理完成项&lt;br&gt;从待办中消除" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="490" y="820" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="L" value="对账报告可重复进入&lt;br&gt;直到待办清零" style="rounded=1;whiteSpace=wrap;html=1;arcSize=40;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="490" y="940" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="eAB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A" target="B">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBC" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="C">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eCD" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="C" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDE" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDF" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="F">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDG" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="G">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eEH" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="E" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eFI" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGJ" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eIK" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="I" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eJK" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="J" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKL" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="K" target="L">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,76 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="4.7.2 用户视角可见性流程图">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="A" value="用户打开灵思 + 菜单 选用技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="360" y="40" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="B" value="技能来源?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="380" y="160" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="D" value="当前角色?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="380" y="295" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="E" value="根本不展示&lt;br&gt;列表里查不到 搜不出" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="680" y="300" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="F" value="可见 可激活选用&lt;br&gt;不可编辑/删除" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="240" y="440" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="G" value="可见 可激活&lt;br&gt;可创建/编辑/删除/启停" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="540" y="440" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="H" value="该技能被停用?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="560" y="560" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="I" value="出现在输入区可选列表" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="400" y="700" width="220" height="50" as="geometry"/>
</mxCell>
<mxCell id="J" value="输入区不再出现&lt;br&gt;已选中的本轮自动失效" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="680" y="695" width="220" height="60" as="geometry"/>
</mxCell>
<mxCell id="A2" value="系统管理员要管某租户技能" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="1080" y="40" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="K" value="先做 admin-scope 切换&lt;br&gt;进入目标租户身份" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="1080" y="160" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="L" value="在该租户视角下&lt;br&gt;按租户管理员权限维护" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="1080" y="295" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="eAB" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A" target="B">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBD" value="本租户自定义" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="D">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eBE" value="他租户自定义" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="B" target="E">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDF" value="终端用户" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="F">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eDG" value="租户管理员" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="D" target="G">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eGH" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="G" target="H">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHI" value="启用" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="I">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eHJ" value="停用" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="H" target="J">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eA2K" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="A2" target="K">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="eKL" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="K" target="L">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,100 +0,0 @@
<mxfile host="app.diagrams.net">
<diagram id="d1" name="用户使用全流程主线图">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="1500" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="Start" value="用户进入灵思" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="360" y="40" width="180" height="50" as="geometry"/>
</mxCell>
<mxCell id="Chip" value="挂上「任务模式」chip&lt;br&gt;进入灵思模式" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="360" y="150" width="180" height="80" as="geometry"/>
</mxCell>
<mxCell id="F2" value="§4.2 附件上传与按需处理&lt;br&gt;offload-first·解析四态·引用溯源" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="660" y="285" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="F1" value="§4.1 发起任务·统一输入区&lt;br&gt;「+」技能 / 知识空间 / 组织知识库 / 附件 + 输入框工具栏选用工具&lt;br&gt;提交前一览·可见可改" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="290" width="260" height="70" as="geometry"/>
</mxCell>
<mxCell id="Submit" value="提交任务" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="360" y="410" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="Queue" value="§4.3 排队中&lt;br&gt;并发超限时排队·可感知" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="330" y="540" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="F3" value="§4.3 执行过程可视化&lt;br&gt;规划→子任务委派→执行&lt;br&gt;含产物交付·长任务后台运行" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="650" width="260" height="70" as="geometry"/>
</mxCell>
<mxCell id="Need" value="灵思需要澄清?" style="rhombus;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="360" y="780" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="F4" value="§4.4 人机协同 HITL&lt;br&gt;执行中追问·用户回答/干预/叫停" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="680" y="775" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="Deliver" value="产物交付&lt;br&gt;下载 / 复制 / 溯源" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="330" y="910" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="Done" value="拿到产物·回看历史任务" style="rounded=1;whiteSpace=wrap;html=1;arcSize=50;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="350" y="1010" width="200" height="50" as="geometry"/>
</mxCell>
<mxCell id="Support" value="横切支撑层" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=0;" vertex="1" parent="1">
<mxGeometry x="120" y="1120" width="860" height="180" as="geometry"/>
</mxCell>
<mxCell id="S5" value="§4.5 Skill 管理页&lt;br&gt;built-in 官方 / 本租户私有" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="Support">
<mxGeometry x="30" y="50" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="S6" value="§4.6 存量 SOP 迁移体验&lt;br&gt;动态 SOP → 静态 Skill" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="Support">
<mxGeometry x="310" y="50" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="S7" value="§4.7 权限与可见性&lt;br&gt;三角色 × 三资源 可见/可改" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="Support">
<mxGeometry x="590" y="50" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="e1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Start" target="Chip">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Chip" target="F1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e3" value="挂入输入区" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="F2" target="F1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F1" target="Submit">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Submit" target="Queue">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Queue" target="F3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="F3" target="Need">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e8" value="是" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Need" target="F4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e9" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;exitX=1;exitY=0.25;entryX=1;entryY=0.25;" edge="1" parent="1" source="F4" target="F3">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e10" value="否" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Need" target="Deliver">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;" edge="1" parent="1" source="Deliver" target="Done">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e12" value="提供可选技能" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="S5" target="F1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e13" value="迁移产出的 Skill" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="S6" target="S5">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e14" value="约束可见范围" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="S7" target="F1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e15" value="约束操作权限" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;dashed=1;" edge="1" parent="1" source="S7" target="S5">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,241 +0,0 @@
# 灵思任务模式 #1 子代理重引入技术方案
> 状态:**5 项决策已对齐(2026-06-17),待进入实现**(先文档后代码)
> 分支:`feat/2.6.0-beta4`
> 基线:deepagents `0.6.8`(已安装版,`src/backend/.venv/.../deepagents`
> 关联:[灵思任务模式全局运行链路与 SOP 必要性研判](./灵思任务模式全局运行链路与%20SOP%20必要性研判.md)、[上下文工程对比调研](./灵思任务模式%20vs%20deepagents%20demo%20上下文工程对比调研.md)
> 本文所有代码引用均以**当前 HEAD 实测代码**为准,非设计期叙述。
---
## 0. 决策记录(2026-06-17 评审对齐)
| # | 决策 | 结论 | 见 |
|---|---|---|---|
| 1 | 禁用默认 general-purpose 机制 | **选项 B:同名覆盖**——提供一个名为 `general-purpose` 的窄定义 spec,纯本地、无全局态、与租户动态 model 无关 | §4.2 |
| 2 | 子代理内部步骤是否外显 | **展示,按 namespace 归组**——保留 `step_type="subagent"`;⚠️ 新增**前端适配**改造点 | §5.2 |
| 3 | MVP 子代理数量 | **1 个 researcher**——先打通整条链路再扩展 | §4.1 |
| 4 | 子代理工具子集构造 | **黑名单**——默认放行用户配置工具;以 DENY 命名常量 + 运行时 HITL 双保险收口 | §4.3 / §5.1 |
| 5 | park/resume 基线验证 | **先验基线**,方式 = **本地起前后端 + 连 test 环境中间件(ES 等),不改 test 环境前后端代码** | §6 |
---
## 1. 背景
### 1.1 子代理(subagent)解决什么问题
deepagents 内核的核心上下文工程手段之一是 **`task` 工具 + 子代理派发**:主图(orchestrator)把一段"独立、重 token、需要深挖"的子任务(如多轮知识库检索 + 文件阅读 + 归纳)委派给一个**拥有独立 context window 的临时子代理**;子代理跑完后**只把蒸馏后的结论**作为一条 `ToolMessage` 回传主图,中间的工具调用、原始检索结果、思考过程都**不进主图上下文**。
这带来三个收益:
- **上下文隔离**:原始检索结果(动辄上万 token)留在子代理里,主图只拿到摘要 → 主图上下文增长从 O(全部原始结果) 降到 O(摘要)。
- **降低主图 recursion 压力**:一次 `task` 调用在主图里只占 1 个 super-step,而它内部可能跑了几十步。当前灵思 `recursion_limit = max_steps(默认 200)``task_exec.py:274/359/647`),主图步数逼近上限是 Tier-1 风险之一;子代理把多步坍缩成一步,直接缓解。
- **可并行 + 专精**:多个独立子任务可并行派发,且每个子代理只带它需要的窄工具集。
deepagents demo 正是这么用的:一个具名 `research-agent` 子代理负责检索/调研,orchestrator 负责规划、澄清、交付物拼装。
### 1.2 为什么当前被一刀切关掉了
`agent_factory.py:129-144` 当前显式剥离了 `task` 工具:
```python
# F035 HITL fix (2026-06-16): strip the deepagents `task` tool (subagent
# delegation). The model was over-delegating (100+ subagents per run) and
# calling ask_user INSIDE subagents, where the HITL interrupt never bubbled up
# to park the task ...
middlewares.append(_ToolExclusionMiddleware(excluded=frozenset({"task"})))
```
两个根因:
- **根因 A(过度派发)**:模型一轮派发 100+ 子代理,token 爆炸、行为发散。
- **根因 B(HITL 冒泡失败)**:子代理内部调用 `ask_user``interrupt()` 在子图里没有正确冒泡到父图驻留(park),导致澄清卡片到不了用户,任务跑成直答兜底。
这是**止血式修复**:它确实消除了 B,但代价是连带砍掉了 1.1 的全部收益——所有规划、工具调用、原始结果全堆进**唯一的主图上下文**,触发 deepagents 内置 `SummarizationMiddleware` 做有损压缩(早期发现可能被丢),且主图步数顶着 `recursion_limit`
---
## 2. 现状(代码实测)
| 事实 | 代码位置 | 说明 |
|---|---|---|
| `task` 工具被剥离 | `agent_factory.py:140` | `_ToolExclusionMiddleware(excluded={"task"})` 加在 `middlewares`,在 `create_deep_agent` 装配后剥掉注入的 `task` |
| 默认 general-purpose 子代理仍被建出但不可达 | `create_deep_agent` graph.py:687-747 | 因为没传 `subagents=` 且未禁用 → 仍自动加 GP → 建出 `SubAgentMiddleware``task` 工具 → 再被上面的排除中间件砍掉。**白建一遍,浪费** |
| **默认 GP 携带 `ask_user`** | graph.py:723-728 (`"tools": _tools or []`) | `create_linsight_agent` 传的是 `tools=[*tools, ask_user]``agent_factory.py:156`),GP 继承全量工具**含 ask_user** → 这正是根因 B 的温床 |
| 流式层早已为子代理预留 | `task_exec.py:280/366/655` `subgraphs=True``stream_event_mapper.py:471-477` | `_infer_step_type(name, ns)`:有 namespace 即 `step_type="subagent"`namespace 写入 `extra_info`mapper:372-374/410-411)供前端归组 |
| ask_user 当前钉在主图 | `agent_factory.py:50-78,156` | `ask_user` 作为普通 tool 注入主图 `tools`,主 prompt`agent_factory.py:43`)强约束"必须主图直接调用、子代理内禁止调用" |
| 主 prompt 已写"子代理"约定但无 task 工具 | `agent_factory.py:43` | prompt 与拓扑不一致:嘴上说 task,手里没 taskTier-3 问题) |
**结论**:现状是"装配了子代理脚手架但禁用了入口",且默认 GP 因继承 `ask_user` 而**不能简单地直接放开**——必须先处理 GP 才能安全重引入。
---
## 3. 关键约束:如何从构造上消解两个根因
重引入的安全性不取决于"子图 interrupt 冒泡是否已修好"(该根因至今**未经运行时验证**),而取决于**让子代理在构造上无法触发 interrupt**。
### 3.1 消解根因 B(HITL 冒泡)——构造性回避,不依赖未验证的修复
- **子代理不持有 `ask_user`**:通过给子代理 spec 显式指定 `tools=[...]`(不含 ask_user)。`create_deep_agent``SubAgent` 的处理是 `raw_subagent_tools = spec.get("tools") if "tools" in spec else tools`graph.py:670)——一旦显式给了 `tools`,子代理**只**拿这些,`ask_user` 留在主图。
- **子代理无其它 interrupt 源**`create_linsight_agent` 不传 `permissions` / `interrupt_on`(默认 None),所以子代理栈里**不会**装 `HumanInTheLoopMiddleware`,文件权限也不会生成 interrupt。子代理因此**根本无法 interrupt**。
- **澄清前置**:主 prompt 已要求"创建任何子任务前一次性 `ask_user` 问完"`agent_factory.py:43`),与上面构造一致。
→ 因此:**唯一的 interrupt 源仍是主图的 `ask_user`park/resume 路径与当前无子代理时完全一致**,F035 现有的主图驻留机制不变、无需先修子图冒泡。这是本方案的安全基石。
### 3.2 消解根因 A(过度派发)
- **禁用 / 改写默认 general-purpose**(见 §4.2):去掉"全量克隆 + 含 ask_user"的催化剂。
- **只暴露 1 个窄职责具名子代理**:明确 description,让派发有目的而非"啥都丢进去"。
- **主 prompt 增加派发预算**:明确"何时派发、不得并发超过 N、最终交付物拼装必须在主图"。
- **改写 `task` 工具描述**(可选):deepagents 默认 `TASK_SYSTEM_PROMPT`/`TASK_TOOL_DESCRIPTION`subagents.py:280-420)极力鼓吹"尽量并行、尽量派发",可用 `HarnessProfile.tool_description_overrides["task"]` 收敛语气(须保留 `{available_agents}` 占位符,否则模型看不到子代理清单——graph.py:604-614 警告)。
### 3.3 残留风险:子代理仍能写工作区
子代理的 `FilesystemMiddleware` 用的是**同一个 WorkspaceBackend**graph.py:620-624 `backend=backend`,而 `_create_agent` 注入的是真实 `WorkspaceBackend(svid=...)``task_exec.py:545`)。所以子代理通过中间件注入的 `write_file` **能写 output/**,可能与主图争抢交付物。
- MVP 缓解:子代理 system_prompt 明确"中间产物只写 `scratch/`,不写 `output/`,结论以最终消息返回"。
- 加固项(后续):给子代理 spec 配 `permissions=[FilesystemPermission(...)]`,从工具层禁止写 `output/`graph.py:104-113/614-624 支持子代理独立权限)。
---
## 4. 方案设计
### 4.1 目标拓扑
```
主图 (orchestrator) —— 模型 = 任务级 model
├─ write_todos 规划(唯一规划源)
├─ ask_user 澄清(唯一 interrupt 源,钉主图)
├─ write_file/edit_file/read_file/ls 交付物拼装(output/
├─ SearchKnowledgeBase + 本地文件工具 主图自身也可直接用
└─ task("general-purpose", ...) 委派隔离调研
└─ 子代理 researcher(独立 contextspec 名义 = "general-purpose"
tools = 主图 tools DENY(黑名单,决策4
= 用户配置工具(MCP/业务) + SearchKnowledgeBase
+ 本地只读文件工具 − {ask_user, 写类文件工具}
+ 中间件注入的 write_todos / 文件工具(共享 WorkspaceBackend
system_prompt: 只调研/归纳;中间产物写 scratch/;
结论作为最终消息回传;不得问用户
```
MVP 只引入**一个**子代理(决策3);并行/多子代理留作后续(§7)。
### 4.2 禁用/改写默认 general-purpose 的机制 —— **决策1:选项 B(同名覆盖)**
默认 GP 必须被处理(§2 已证明它继承 `ask_user`,不安全)。**已定采用选项 B**:
**选项 B(采用)— 用同名 spec 覆盖默认 GP(本地、确定、与模型无关)**
`create_deep_agent` 的自动添加判据是 `not any(spec["name"] == "general-purpose" ...)`graph.py:693)。在 `subagents=` 里提供**一个名为 `general-purpose` 的 spec**,默认 GP 就不再注入,由我们的窄定义取而代之。
- 优点:纯本地、无全局副作用、不受租户动态 model 影响。
- 代价:子代理名义上叫 `general-purpose`,但 description 写实即可("用于隔离的调研/分析,返回蒸馏摘要,不能向用户提问");模型派发决策看 description 而非 name。
**选项 A(未采用)— 注册 HarnessProfile 关闭 GP**`register_harness_profile(key, HarnessProfile(general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False)))`。问题:profile 按 `provider:model`/`provider` 解析(harness_profiles.py:1246-1316),而灵思 model 是**租户动态配置的预构建实例**,key 难稳定命中;且 `register_harness_profile` 是**进程级全局**副作用。
**选项 C(不可行)— 保留默认 GP,仅靠 prompt 收敛**:✗ GP 继承 `ask_user`,根因 B 复活。
### 4.3 工具分区 —— **决策4:黑名单**
子代理工具子集 = 主图 `tools` 参数(**不含 ask_user**,它是 factory 单独注入主图的)按 **DENY 黑名单**过滤。两个前提(§5.1 详述):① 子代理 spec **必须显式指定 `tools`**,绝不能走"继承"deepagents 的继承会拿到 `[*tools, ask_user]`graph.py:670);② DENY 以命名常量维护 + 运行时 HITL 双保险兜底。
| 工具 | 主图 | 子代理(researcher) | 备注 |
|---|---|---|---|
| `write_todos` | ✓ | ✓(中间件强加) | 子代理 todos **必须在 mapper 层过滤**,见 §5.2 |
| `ask_user` | ✓ | ✗ | 不在 `tools` 参数里(factory 单独注入主图);并入 DENY 做冲量防御 |
| `write_file/edit_file` | ✓ output/ | 仅 scratch/(prompt 约束) | FilesystemMiddleware 注入,共享 backend,加固见 §3.3 |
| 本地写类(add_text_to_file/replace_file_lines) | ✓ | ✗ | 列入 DENY,交付物拼装归主图 |
| `read_file/ls/grep/glob` | ✓ | ✓ | FilesystemMiddleware 注入 |
| `SearchKnowledgeBase` + 本地只读文件工具 | ✓ | ✓ | `init_linsight_tools`,不在 DENY |
| **用户配置工具(MCP/业务,`_generate_tools`)** | ✓ | **✓(黑名单默认放行)** | 增强调研能力;**风险:不可控来源**,靠 DENY + 运行时双保险收口 |
### 4.4 主 prompt 调整(`LINSIGHT_SYSTEM_PROMPT_ZH`
在现有约定基础上增加**派发预算**段(要点,非最终文案):
- 何时派发:仅当子任务"独立、需多轮检索/阅读、产出可蒸馏为摘要"时才 `task` 委派调研。
- 不得派发:最终交付物的撰写与拼装必须由你(主图)完成;不得把"问用户"委派给子代理。
- 并发上限:同一时刻并行子代理不超过 N(建议 2~3)。
- 现有"ask_user 必须主图直接调用、子代理内禁止"的措辞保留(现在终于与拓扑一致)。
---
## 5. 集成改造点(代码级)
### 5.1 `agent_factory.py`
1. 删除 `_ToolExclusionMiddleware(excluded={"task"})` 一段(129-144),放开 `task`
2. `create_linsight_agent` 增加构建子代理 spec 并通过 `subagents=` 传入:
- 一个名为 `general-purpose``SubAgent`(决策1),显式 `tools=`(黑名单结果,**必须显式,不能走继承**)、`system_prompt=`(调研/归纳/只写 scratch/不问用户)、`model` 默认继承主 model(省略即继承,graph.py:608)。
- `create_deep_agent(..., subagents=[researcher_spec], ...)``middleware` 可恢复为空(不再需要排除 task)。
3. **子代理工具子集 = 黑名单(决策4),在 factory 内构造**
```python
# factory 内命名常量 —— 维护约定写进注释
# ⚠️ 任何新增的 HITL/interrupt 类工具、写副作用工具,必须登记进 DENY,
# 否则黑名单会默认把它泄漏给子代理。
_SUBAGENT_TOOL_DENY = frozenset({
"ask_user", # 冲量防御(实际它不在 tools 参数里)
"add_text_to_file", # 写类,交付物拼装归主图
"replace_file_lines", # 写类
})
# 运行时双保险:即使有人忘了登记,已知 HITL 工具名也强制剔除
_KNOWN_HITL_TOOL_NAMES = frozenset({"ask_user"})
sub_tools = [
t for t in tools
if t.name not in _SUBAGENT_TOOL_DENY
and t.name not in _KNOWN_HITL_TOOL_NAMES
]
```
- `tools` 是 `create_linsight_agent` 的参数(= `_generate_tools` 用户配置工具 + `init_linsight_tools`**本就不含 ask_user**)。黑名单默认放行用户配置工具以增强调研。
- **不改调用方签名**:在 factory 内过滤,复用已构造实例,三条路径(fresh/continue/resume)零重复。
### 5.2 `stream_event_mapper.py` + 前端(决策2:展示子代理步骤)
**(a) 必须改 —— 否则子代理 todos 污染主计划。** `_handle_updates(chunk, ns)`mapper:194-210)目前**不看 ns** 就把任何 `todos` 喂给 `_diff_todos`,并入主图唯一的 `ctx.todos`。子代理自带 `write_todos`TodoListMiddleware),其 todos 带 namespace → 误并进主计划,产出错乱的 GenerateSubTask/TaskStart/TaskEnd。
- 改法:`_handle_updates` 内 `if ns: return []`,只让主图(ns is None)的 todos 驱动主计划。**注意:这与"展示子代理步骤"不冲突——过滤的是子代理 todos(规划噪声),放行的是子代理 tool 调用步骤(执行轨迹)。**
**(b) 子代理执行步骤照常外显(决策2 = 展示)。** 子代理的 tool 调用 ExecStep 已带 `step_type="subagent"` + `extra_info.namespace`mapper:471-477/372-374),后端**无需额外改动**即可冒泡。
**(c) ⚠️ 新增前端适配改造点。** 前端需消费 `step_type="subagent"` / `extra_info.namespace`,把同一 namespace 的步骤**折叠归组**到父 `task` 步骤下的"可展开调研轨迹"。
- **实现前必须先追前端入口**(按"判断代码是否在用要追前端"原则):定位 LinSight 结果区渲染 ExecStep 的组件,确认当前如何处理 `step_type` / `extra_info`,再决定是新增折叠分组还是复用既有分组逻辑。本项是 MVP 唯一的前端工作量,需纳入回归测试面。
### 5.3 resume / checkpointer 安全性(分析结论:安全,无需改)
- 子代理图由 `SubAgentMiddleware` 内部 `create_agent(...)` 编译,**不带 checkpointer**graph.py:728-743 无 checkpointer 参数),在 `task`/`atask` 工具内以 `await subagent.ainvoke(...)` **同步跑完**再回传 ToolMessagesubagents.py:566-588)。
- 因此**检查点边界上永远不存在"半截在跑的子代理"**:主图 super-step 之间,`task` 调用要么没开始、要么已返回。主图 resume(`Command(resume=...)`thread_id=svid)路径与当前无子代理时一致。
- 且子代理无 interrupt 源(§3.1),不会在子图里 park。→ **resume 机制不受影响**。
### 5.4 recursion_limit(受益,无需改)
主图把多步坍缩成一次 `task` 调用 → 主图 super-step 数下降 → 远离 `max_steps(200)` 上限。子代理有独立递归预算。
---
## 6. 验证计划
### 6.0 验证环境(决策5
**本地起前后端服务 + 连 test 环境中间件(ES / MySQL / Redis / Milvus / MinIO / OpenFGA),不改 test 环境前后端代码。** 代码改动在本地运行(本地 API + 本地 linsight worker + 本地 frontend),中间件指向 test 那套以拿真实数据,避免污染 test 部署。
### 6.1 第 0 步:park/resume 基线验证(决策5 = 先验基线,动 #1 代码之前)
在**当前 HEAD(无 #1 改动)**下,按 6.0 方式本地起服务连 test 中间件,构造一个触发 `ask_user` 的任务,确认:驻留(park)→ 出澄清卡 → `Command(resume)` 续跑均正常。**基线绿了再动 #1 代码**,便于 #1 后归因。
### 6.2 单测(`test/linsight/`
- `test_agent_factory_exposes_task_and_subagent_excludes_hitl`:构图后 `task` 工具存在;子代理工具集**不含 ask_user / 写类 / 已知 HITL 工具**;主图工具含 `ask_user`。
- `test_agent_factory_subagent_blacklist_passes_config_tools`:构造含一个"用户配置工具"的 `tools`,断言它**进入**子代理工具集(黑名单默认放行),而 DENY 内的不进。
- `test_stream_mapper_ignores_subagent_todos`:带 namespace 的 updates(todos) chunk → `normalize` 不产生 GenerateSubTask/TaskStart(主计划不被污染)。
- `test_stream_mapper_emits_subagent_step_type`:带 ns 的 tool_call → `step_type="subagent"` 且 `extra_info.namespace` 存在(决策2 展示链路)。
- 既有 HITL 单测(`test_hitl_worker.py` 等)回归通过——证明 park/resume 路径未受影响。
### 6.3 e2e(按 6.0 本地服务连 test 中间件)
- 构造"多源调研 + 成文"任务,确认:① 主图发生**至少一次** `task` 委派;② 交付物落 `output/`;③ 触发 `ask_user` 时仍能正确驻留并出澄清卡片(根因 B 不复发);④ 主图步数明显低于无子代理基线;⑤ **前端**子代理步骤按 namespace 折叠归组、可展开(决策2 前端适配验收)。
---
## 7. 分期
- **MVP(本方案)**:单子代理(researcher,决策3)、**展示子代理步骤 + 前端 namespace 折叠(决策2)**、黑名单工具子集(决策4)、prompt 收敛、mapper ns todos 过滤。
- **后续**:① 多/并行具名子代理(如 researcher + analyst);② 子代理写权限用 `FilesystemPermission` 硬约束(§3.3);③ `task` 工具描述用 HarnessProfile 收敛;④(独立线)SOP 库改造为检索工具路由,见 SOP 研判文档。
---
## 8. 决策已对齐
5 项决策结论见 **§0 决策记录**。下一步:按 §6.1 先跑 park 基线验证 → 进入实现(先 §5.1 factory + §5.2 mapper,再前端适配)。
@@ -1,128 +0,0 @@
# 灵思任务模式 Skill 能力恢复方案
- **关联 Feature**F035 `035-linsight-task-mode`(本方案是其 Skill 运行时能力的收尾)
- **关联契约**[release-contract](../../../features/v2.6.0/release-contract.md)F035`LinsightSkill` / 110 段 1105011069 / `linsight_skill` 表)
- **关联设计**F035 `design.md §7`(Skill 存储与中间件,原始设计——部分已演进/过时,详见 §1)、`spec.md AC-2/AC-3`
- **文档性质**:历史背景沉淀 + 需求 + 方案思路。**先文档对齐,暂不开发。**
- **一句话**:F035 迁移时把流程知识从动态 SOP 改为可热插拔的静态 Skill,但 **Skill 的运行时注入入口在 2026-06-16 被临时关闭**;本方案厘清「原设计 → 实现演进 → 禁用 → 现状再核查」全脉络,论证恢复无架构级阻塞,并给出落地路径与工作量。
> ✅ **实现状态(2026-06-24,代码已落地 + 单测绿,端到端实测待跑)**:按 **Option 1 / Fork X(复制时过滤,最简)** 实现。
> - 落点:`skill_provisioning.materialize_session_skills`(复制闸门)+ `SkillStore.read_bytes` + schema/`linsight_session_version.skills` 列 + migration `f035_linsight_skills` + `task_exec._create_agent` 触发复制 + `agent_factory.create_linsight_agent(skills_present=...)` 装配 `SkillsMiddleware`(枚举 backend=指向工作区缓存的 `FilesystemBackend`)。
> - **未采用** `active_skills` run-config 穿透与 `TenantSkillsMiddleware` 运行时白名单(语义迁至复制闸门;该子类保留为休眠 fallback)。
> - **§1.5 两条静态推断已校正并单测确认**:① `SkillsMiddleware` 不注册文件工具 → 双 backend 不 shadowdeepagents 0.6.8 源码 + 装配共存确认);② 原生 `skills=` 无法用——`SkillsMiddleware` 靠 `ls` 的 `is_dir` 目录项枚举(`skills.py:674`),而 MinIO 版 `WorkspaceBackend.ls` 只返回文件项 → 改用「复制进工作区 + `FilesystemBackend` 枚举缓存」;`test_skill_provisioning.py::TestEnumerationLoop` 实跑确认复制后被真实 `SkillsMiddleware` 枚举到、且注入路径经 `normalize_workspace_path` 解析回工作区同一物理文件(AC-R2 路径闭环)。
> - **待办**D1 端到端实测(真实 MinIO + 模型 + 交付物落 `output/` 验 shadow 不复现)、D3 DM8/MySQL `alembic upgrade head` 实库回归。
---
## 一、历史背景与演进脉络
### 1.1 Skill 在 F035 中的定位
F035 把灵思自研 ReAct 内核替换为 deepagents,流程知识从**运行时动态生成的 SOP** 迁移为**可热插拔的静态 Skill**progressive disclosure:先看 name/description,命中再 `read_file` 读正文)。Skill 分两类:
- **built-in**`SKILLS_ROOT/built-in/`):内核能力,始终生效、前端不暴露、不经 API。
- **租户自定义**`SKILLS_ROOT/data/skills/{tenant_id}/`):前端唯一可见可管理的一类,可 CRUD + 启停,按租户隔离。
### 1.2 原始设计(design §7.2)——双中间件 + 顺序硬约束
原设计用**两个**中间件:
| 中间件 | 职责 |
|---|---|
| `SkillsMiddleware` | progressive disclosure,加载两类 skill 的 frontmatter 注入 prompt |
| `SkillWhitelistMiddleware` | 按本轮 `active_skills` 过滤**租户自定义** skillbuilt-in 始终放行 |
顺序硬约束:`SkillWhitelistMiddleware → SkillsMiddleware → GenerativeUIMiddleware`
`active_skills` 契约(C3):`["a","b"]`=勾选白名单 / `[]`=全禁租户技能 / `None`=全放行(仅留给非 UI 调用方,产品前端始终下发显式列表)。
### 1.3 实现期演进(deviation D8)——合并为单 subclass
实际实现没有按双中间件落地,而是合并成**一个 subclass** `TenantSkillsMiddleware(SkillsMiddleware)``skill_middleware.py:54-109`),在 `before_agent`/`abefore_agent` 里直接过滤 `skills_metadata`。代码注释明示这是 **deviation D8**:单 subclass 取代 §7.2 的双中间件拆分,消除了对其它中间件的顺序依赖。白名单逻辑(built-in 始终放行、租户技能需 `enabled` + 命中 `active_skills`)写在 `_skill_allowed()` 中,已完整实现。
### 1.4 临时禁用(2026-06-16commit `4ce0c496f`;注释 `285d6c20e`
`agent_factory.create_linsight_agent()``middleware=` 列表**不再注入** `TenantSkillsMiddleware``make_skills_middleware()` 无生产调用方。代码注释(`agent_factory.py:385-392` / `skill_middleware.py:1-32`)记录**两条独立原因**
1. **Workspace filesystem shadow bug**`TenantSkillsMiddleware` 带了**自己独立的** `FilesystemBackend(SKILLS_ROOT, virtual_mode)`。装在工作区 `FilesystemMiddleware` 之后,被认为会**遮蔽** agent 的 `write_file/read_file`,导致交付物落进技能库而非工作区 `output/`,最终工作区空、产不出结果文档。
2. **`active_skills` 白名单从未生效**:per-run 白名单键从未写进 run config(`task_exec.py` 三处 config 只设 `thread_id`),第二道门即使开了也是 no-op。
当时取舍:**技能可选、交付物核心,先保交付物**,Skill 留作 forward-compatible WIP。
### 1.5 现状再核查(基于当前 deepagents 0.6.8,静态代码分析)
对当前安装版本(`deepagents==0.6.8`pin `>=0.6.3`)逐行核查后,**原「shadow 阻塞」的前提已不成立**:
- 0.6.8 的 `SkillsMiddleware` **不注册任何文件工具**——它只在 `before_agent` 用自带 backend 读 SKILL.md 元数据、在 `wrap_model_call` 把技能清单注入 system prompt。文件工具的**唯一来源**是 scaffolding 级的 `FilesystemMiddleware``graph.py:206/213`,不可剔除)。
- deepagents 原生 `skills=` 参数收的是**同一个 backend 下的路径前缀**(如 `/skills/user/`)——官方设计本就让 Skill 与工作区**共用一个 backend**,根本不产生 shadow。
**结论**:当年那条注释的心智模型对应的是更早的版本/自建独立 backend 的组合。在 0.6.8 下,把 `TenantSkillsMiddleware` 加回 `middleware=` 列表**不会**再注册第二套文件工具、**不会** shadow 工作区。原「阻塞性架构问题」需要的是一次廉价的端到端实测复核,而非大改。
**但暴露出一个真实、有界的遗留缺口**(不是 shadow,而是原设计 §7.1 的隐含隐患):技能 bundle 存在独立的 `SKILLS_ROOT`,而模型读正文/附属文件用的是**工作区** backend 的 `read_file`。design §7.1 写「模型按 SKILL.md 中的相对路径 `read_file` 附属文件」、§5 写「Skill 独立走磁盘、两套后端互不混用」——但 progressive disclosure 注入 prompt 的技能路径是 `SKILLS_ROOT` 内的路径,工作区 `read_file` 解析不到。**模型能看到技能 name/description,却读不到技能正文**。这才是恢复真正要解决的「命名空间」问题,且有成熟解法(§3.2)。
> ⚠️ 该断裂为**静态分析结论**,尚未端到端实跑验证;恢复方案 D 块(§3.3)需先实测复核再据此定方案。
---
## 二、需求
### 2.1 恢复目标
**租户自定义 Skill 端到端可用**:终端用户在任务模式勾选已启用技能 → 后端把勾选项作为 `active_skills` 下发 → 中间件按治理 `enabled` + 本轮白名单过滤加载 → 模型能读到技能正文并据其执行 → 交付物仍正确落工作区 `output/`
### 2.2 验收标准(对齐 F035
| # | 验收点 | 来源 |
|---|---|---|
| AC-R1 | 勾选的租户技能在运行时被加载,未勾选/停用的不加载(白名单二元:`[names]` / `[]` | design §7.2 |
| AC-R2 | 模型能 `read_file` 到技能正文与 bundle 附属文件(progressive disclosure 闭环) | 本方案 §1.5 缺口 |
| AC-R3 | 启用技能后,任务交付物仍正确落工作区 `output/`(shadow 不复现) | 本方案 §1.4 原因1 |
| AC-R4 | 跨租户隔离:A 租户技能不被 B 租户加载 | design §6 / §7.6 |
| AC-R5 | 关联 F035 AC-2「Skill 稳定触发、命中率」可在恢复后重新评测 | spec AC-2 |
### 2.3 范围红线(非目标)
-**built-in 内置技能的设计与编写**:中间件对「无技能」优雅处理,built-in 缺省不影响租户技能恢复;属独立增量。
- ❌ 技能热更新策略、管理页↔选择器实时同步(WebSocket)、file-memory 超期兜底等产品增强。
- ❌ 不碰 F035 N1–N7 既有红线(自动挂载 / learning loop / marketplace / Interpreter Skill / 双引擎共存)。
---
## 三、方案思路
### 3.1 现状盘点(哪些已就绪、哪些缺)
| 组件 | 状态 | 锚点 |
|---|---|---|
| 平台端技能管理 UI(列表/新建/上传/启停/详情) | ✅ live | `platform/.../components/LinSight/skill/` |
| 客户端技能选择器 + 提交 payload 带 `skills:[name]` | ✅ live | `client/.../Linsight/Input/SkillSelector.tsx``TaskModeInput.tsx` |
| 后端 CRUD/上传/启停 API10 端点) | ✅ live | `linsight/api/endpoints/skill.py` |
| `linsight_skill` 表 + `SkillStore` 磁盘层 | ✅ live | migration 2026-06-11、`skill_store.py` |
| `TenantSkillsMiddleware`(含白名单过滤) | ⚠️ 已实现未装配 | `skill_middleware.py`DISABLED 注释) |
| 后端接收 `skills` 字段 | ❌ 缺 | 提交端点 schema / `session_version` 模型均无此字段,前端发了被丢 |
| `active_skills` 写入 run config | ❌ 缺 | `task_exec.py` 三处 config 只设 `thread_id`(约 L301/L389/L780 |
| 技能正文对模型可读(跨 backend) | ❌ 缺 | §1.5 缺口 |
| built-in 内置技能文件 | ❌ 仓库无(非恢复必需) | `SKILLS_ROOT/built-in/` 为空 |
要点:**前端 + 管理/存储后端已 100% 就绪**,缺的全在「把已上传的租户技能在运行时喂进 agent」这一段。
### 3.2 关键技术判断:命名空间缺口的本质与三种解法
原设计 §7.1/§5 坚持「Skill 与工作区两套后端互不混用、Skill 独立走磁盘」。在 0.6.8 下这反而让模型读不到技能正文(§1.5)。解法(恢复时三选一):
- **Option 1(推荐,最简、零 deepagents 内核改动)**:任务启动时把「本租户 `enabled` 且本轮 `active_skills` 命中」的技能 bundle 复制进**当前会话工作区**的 `/skills/` 子树,中间件 sources 指向工作区内该子树(或直接用原生 `skills=`)。技能体积小(≤10MB/个),复制开销可忽略;治理过滤仍由 `TenantSkillsMiddleware` 子类完成。← 修正原设计「两套 backend 互不混用」的取向。
- **Option 2**:实现 overlay backend`/skills/**` 路由到 `SKILLS_ROOT`、其余到工作区。更优雅、免复制,但要实现 `BackendProtocol` 包装,多 12 天。
- **Option 3**:让 `SkillsMiddleware` 非渐进地把技能正文直接注入 prompt(放弃 progressive disclosure)。仅适合技能少且小,不推荐。
### 3.3 改动块(全在后端 `linsight`,按依赖排序)
- **A. 技能正文对模型可读**:落 §3.2 的 Option 1(或 2)。关键文件 `agent_factory.py`backend/sources 组装)、`skill_store.py`(复制源路径,`builtin_dir/tenant_dir/skill_dir` 已现成)。
- **B. `skills` 字段穿透到 run config**:① 提交/启动端点 request schema 增加 `skills: list[str]`(接住前端已发字段);② 随会话带到 worker(持久 `session_version` 或随 Redis 队列 payload);③ `task_exec.py` 三处(含 resume 路径)`config.configurable` 注入 `active_skills`(契约同 design §7.2`[]`=禁用全部自定义技能,缺键=不约束仅兜底)。关键文件:`linsight/api/` 提交端点 + `domain/schemas/` + `task_exec.py`
- **C. 装配中间件**`create_linsight_agent()``make_skills_middleware(tenant_id)``session_model` 自带 tenant_id)加进 `middlewares`,恢复 system prompt 对技能的条件化介绍。`make_skills_middleware` 已实现,基本是「解开禁用 + 接好 backend/sources」。
- **D. 实测复核 + 测试**:先实测复核 §1.5 的两个论断(shadow 不复现 / 正文可读断裂是否真实),再据此定 A 块方案;复用 `test/linsight/test_skill_middleware.py`(白名单已覆盖),补 agent 装配级集成测试 + 跨租户隔离用例。
### 3.4 与原设计 §7 的差异修正(须在落地时回链标注)
1. **§7.2 双中间件 → 单 subclass**:已发生(deviation D8),design §7.2 的「两个中间件 + `SkillWhitelistMiddleware → SkillsMiddleware → GenerativeUIMiddleware` 顺序」描述为历史,实际以 `TenantSkillsMiddleware` 单类为准。
2. **§7.1/§5「两套 backend 互不混用」修正**:为闭合 progressive disclosure,恢复时技能需对工作区 `read_file` 可达(Option 1 复制进工作区,或 Option 2 overlay)。
---
## 四、工作量与风险
- **难度**:中等,**无架构级阻塞**。当年的「shadow 阻塞」在 0.6.8 下已消解;真正要做的命名空间缺口有 1 天级成熟解法。
- **核心恢复工作量(A+B+C+D,仅租户自定义技能)**:约 **59 人天**,单人 **12 周** wall-clock。
- **不在范围(勿混入估算)**:built-in 编写、热更新、实时同步——产品增强,非「恢复入口」必需。
- **风险**:① A 块若选 Option 2 overlay 略增成本;② 多节点部署须满足 design §7.1 的 `SKILLS_ROOT` 共享卷约束;③ 多租户隔离 + DM8 双库回归须在 D 块补测;④ §1.5 缺口为静态结论,须先实测复核。
## 五、验证方式(恢复实施后)
1. 本地起前后端 + 连 test 中间件;平台端建/传一个技能并启用。
2. 客户端任务模式勾选该技能发起任务 → 后端日志确认 `active_skills` 进 config、中间件加载到该技能。
3. 让任务产出交付物 → 确认文件落会话工作区 `output/`(不在技能库),即 shadow 不复现(AC-R3)。
4. 让 prompt 命中技能 → 确认模型成功 `read_file` 技能正文并按其指引执行(AC-R2)。
5. 三态白名单 + 跨租户隔离用 `test/linsight/` 集成测试守护(AC-R1/AC-R4)。
@@ -1,342 +0,0 @@
# 灵思任务模式渲染错位技术报告:thinking 碎片化 与 subagent 工具名误标
## 摘要
用户在 114 部署的灵思任务模式运行结果里看到的两大渲染困惑——「大量几字 thinking 行」与「自动委派 · N 个 \<工具名\> 子智能体 / 已调用 N 个工具」——**根因都在后端 `StreamEventMapper`**,前端 `stepUtils.ts`/各 Row 组件均按 fixture 契约忠实渲染、无逻辑错误:(1) thinking 行碎片化源于 `normalize` 末尾「任何非 thinking chunk 都重置 thinking 段 call_id」(`stream_event_mapper.py:157-159`)的切段粒度与 deepagents「思考↔工具」高频交替流不匹配;(2) subagent 错位源于 `_infer_step_type``if ns: return "subagent"``stream_event_mapper.py:492-493`)把**子代理内部每一次带 namespace 的工具调用**无差别标成 subagent 却保留工具名作 `name`,而真正的委派点(主图 `task` 工具调用,ns=None)反被判成普通 tool 行。
> **✅ 2026-06-18 114 实测定论(截图同一次运行,418 帧落库 history)**:真实只有 **3 个子代理**却被渲染成 **22 个**「自动委派 · N 个 \<工具名\>」组;3 个真正的 `task` 委派渲染成不起眼的顶层 "task" 工具行;「已调用 N 个工具」的 N **数的其实是子代理内部 thinking**namespaced thinking 命中 children 分支),并非工具;thinking 共 347 帧 → 347 行、其中 286 行 ≤30 字符。详见下文「114 真实数据实测复盘」。
---
## 背景与问题现象
BiSheng 灵思(Linsight2.6 任务模式已从自研 ReAct 迁移到 deepagents/LangGraph 内核。当前分支 `feat/2.6.0-beta4`HEAD=`8c2ea7797`「re-introduce task subagent delegation (#1)」,**这份 HEAD 代码即产生用户截图的版本,工作树干净,代码即真相**(设计文档/fixture 叙述可能滞后于实现)。
用户反馈两个前端渲染困惑(截图来自 114 部署,该环境用 DeepSeek-R1 模型):
1. **thinking 碎片化**:出现大量 "thinking" 行,展开后每行只有几个字。
2. **subagent 工具名错位**:出现多次「自动委派 · N 个 \<X\> 子智能体」,其中 `<X>` 是**工具名**`write_todos` / `web_search` / `glob` / `write_file`),展开卡片显示「已调用 N 个工具」,语义明显错位。
涉及的 i18n keyzh-Hans):
- `com_linsight_subagent_delegate` = "自动委派 · {{0}} 个 {{1}} 子智能体"`{{0}}`=数量,`{{1}}`=名称)
- `com_linsight_subagent_tools_called` = "已调用 {{0}} 个工具"
- `com_linsight_thinking` = "思考中"
---
## 端到端数据流总览
```
deepagents agent.astream(stream_mode=["updates","messages","values"], subgraphs=True)
task_exec.py:749-754(fresh) / :300-305(resume) / :389-394(continue)
│ 每个 chunksubgraphs=True + 多 mode ⇒ 3 元组 (namespace, mode, data)
│ namespace = langgraph checkpoint_ns.split(NS_SEP);主图为空/None、子图非空
_unpack_stream_chunk → (mode, raw, namespace)
task_exec.py:906-922
│ values+无 ns ⇒ 抽末条文本存 _last_assistant_text 兜底直答(task_exec.py:759-762)
StreamEventMapper.normalize(mode, raw, namespace=<原始元组>)
stream_event_mapper.py:136(_namespace_str 转字符串), 194/338/467 分发
├── updates → _handle_updates : todos diff / __interrupt__
│ (:204-222 diff:220-221 丢弃 namespaced todos:225-244 interrupt)
├── messages → _handle_messages : thinking(:343-345) / tool-start(:348-350) / tool-end(:353-355)
│ 切段闸门 normalize 末尾(:157-159)
└── values → _handle_values : 恒返回 []:467-473
▼ 产出 BaseEventGenerateSubTask / TaskStart / TaskEnd / ExecStep / NeedUserInput
TaskExec._handle_event 分派
task_exec.py:951-966
ExecStep → _handle_exec_step → TASK_EXECUTE_STEP, data=event.model_dump()(:1042-1054)
▼ 落库 history(按 call_id upsertthinking output 拼接 prev+new
state_message_manager.py:441-467thinking 拼接 :456-457MessageEventType 10 取值 :32-56
▼ WS 推送 → 前端
useLinsightWebSocket(versionId) → Recoil linsightMapState
ExecutionFlow.tsx:68 / TaskTurnPanel.tsx:75useLinsightManager.tsx:24,441
│ 伪任务回填 splitSessionPseudoTask(stepUtils.ts:278)
StepListbuildFlowNodes(mergeStepFrames(history))
StepList.tsx:16
├── mergeStepFrames(stepUtils.ts:62-113):按 call_id 合并、output 拼接(:102-107)、
│ namespace 取自 extra_info.namespace(:91)、丢弃 ask_user/ls/call_user_input(:67,:74)
└── buildFlowNodes(stepUtils.ts:120-155)subagent step 按 step.name 分组成 subagent_group、
namespaced 非 subagent step 挂 owner.children
▼ Row 组件渲染
StepList.tsx:24-41 派发 → SubagentRow / ThinkingRow / KnowledgeRow / ToolRow / UiCardRow / IntentRow
```
**关键架构事实**deepagents 的 `task` 工具在 `SubAgentMiddleware` 里于**主图 tools 节点内**直接 `await subagent.ainvoke(...)``deepagents/middleware/subagents.py:563,587`)。子代理是独立编译的 runnable,`subgraphs=True` 时其内部事件作为带 namespace 的子图 chunk 冒泡到父 astream`langchain/agents/_subagent_transformer.py`)。因此:
- **主图 `task` 工具调用**ns=None`task_exec.py:906-922` 对主图 chunk 返回 namespace=None)。
- **子代理内部工具调用**write_todos / web_search / glob / write_file):ns 非空,形如 langgraph 内部 `checkpoint_ns`(见下文「namespace 真实形态」)。
---
## step_type 全量映射表
`ExecStep.step_type` 模型默认 `"tool_call"``event.py:21-22`),但 mapper **从不用默认值**,全部走 `_infer_step_type``stream_event_mapper.py:484-497`)或硬编码。
| step_type | 后端推导规则 (file:line) | 持久化字段 | 前端 Row 组件 | 标题来源 | 展开内容 |
|---|---|---|---|---|---|
| `tool` | `_infer_step_type`ns=None 且 name 不命中知识 hints → `tool`:494-497);`name=tc["name"]`:366,:393 | ExecStep.{name,params,output,step_type,extra_info}`data=model_dump()` | **ToolRow** | `step.name`(裸工具名,ToolRow.tsx:16 | callReason(恒空) + paramsText + outputToolRow.tsx:19-31 |
| `thinking` | 硬编码 `step_type="thinking"``name="thinking"``call_reason=""`:456-457,:460);来源 DeepSeek `reasoning_content` / Anthropic thinking block:499-516 | 同上;同 call_id 的 output 拼接 prev+newstate_message_manager.py:456-457 | **ThinkingRow** | `step.name`(恒="thinking") `\|\|` `com_linsight_thinking`ThinkingRow.tsx:17 | 仅 `step.output` 一段(ThinkingRow.tsx:20 |
| `knowledge` | `_infer_step_type`ns=None 且 name 命中 `_KNOWLEDGE_TOOL_HINTS``knowledge`:494-495 | 同 tool | **KnowledgeRow** | `step.name`KnowledgeRow.tsx:36 | callReason + params.query + 命中文件 + outputKnowledgeRow.tsx:39-51 |
| `subagent` | `_infer_step_type`**ns 非空一律 subagent**:492-493,凌驾 knowledge/tool);`name` 仍是工具名 | 同 tool`extra_info.namespace` 写子图 ns:385-388,:422-424 | **SubagentRow** + SubagentCard | `com_linsight_subagent_delegate(agents.length, group.name)`SubagentRow.tsx:57-60),`group.name`=工具名 | 横排卡片,完成显示 `com_linsight_subagent_tools_called(children.length)`SubagentRow.tsx:18,37 |
| `ui_card` | **当前 mapper 不产出**fixture/前端契约残留,step_types.json:62 | — | UiCardRow | `step.name`UiCardRow.tsx:24 | 注册表为空 → callReason + paramsText + file_name 兜底 |
| `call_user_input` | `NeedUserInput` 默认(event.py:37 / mapper:243),由 `__interrupt__`ask_user)触发(:225-244 | append history(无 call_id,不合并);翻 WAITING_FOR_USER_INPUT | **IntentRow**(已应答态) | `com_linsight_intent_confirmed` | 问答对(IntentRow.tsx:34-54);mergeStepFrames 直接丢弃此类型(stepUtils.ts:67),由 TaskStepRow 单独喂 IntentRow |
> **矛盾标注(以代码为准)**fixture `step_types.json` 的 `_meta` 列出 `ui_card`,且大量 step 带有意义的 `call_reason`(如 :11「运行 Python 计算同比增长率」);但 HEAD 实现中 **mapper 不产出 ui_card**,且 `call_reason` 被显式置空(`stream_event_mapper.py:370-372`)。因此 ToolRow/KnowledgeRow/UiCardRow 的 callReason 段在真实运行中永不渲染,标题全部退化为裸 `step.name`。
---
## 子代理(subagent)委派的真实语义 vs 当前渲染
### 设计意图(fixture + 设计文档)
设计文档与 fixture 一致描述「**一个 task 委派 = 一个 subagent 行 + 其内部工具调用作为 children**」:
- 技术方案 §1.1 / §5.2(c) / 决策2`task` 一次委派在主图只占 1 个 super-step,子代理内部工具调用「都不进主图上下文」,对用户外显时是「可展开调研轨迹」,按同一 namespace **折叠归组**到父 task 步骤下。
- fixture `step_types.json` 明确区分两类 namespaced step`:38-58``_meta`:4 写死契约「subagent 及其子步骤带非空 namespace,前端按层级缩进渲染」):
- `call_sub_01``name="research_subagent"``step_type="subagent"` —— **task 委派行**
- `call_sub_inner_01``name="search_knowledge_base"``step_type="knowledge"`、**同** namespace —— 子代理**内部**工具调用,保留其本来类型。
- 前端单测 `stepUtils.test.ts:42-71` 把这个意图钉成规格:`subagentChild` 明确 `step_type='knowledge'`:35),断言 `buildFlowNodes` 产出**一个** subagent_group、`agents` 长度 1、`agents[0].children` 长度 1。
按此意图,header 应渲染「自动委派 · 1 个 **general-purpose** 子智能体」,卡片显示「已调用 1 个工具」。
### 当前实现(HEAD=8c2ea7797
`_infer_step_type` 把**所有** namespaced step 一律打成 subagent,凌驾于 knowledge/tool 判断之上:
```python
# stream_event_mapper.py:484-497
def _infer_step_type(self, name: str, ns: str | None) -> str:
if ns:
return "subagent" # ← :492-493 无条件,不看工具是什么
lowered = (name or "").lower()
if any(hint in lowered for hint in _KNOWLEDGE_TOOL_HINTS):
return "knowledge"
return "tool"
```
`name` 始终取工具名(`_handle_tool_starts``name = tc.get("name","")`:366,写入 ExecStep :393)。于是:
- **主图 `task` 调用**ns=Nonename="task")→ 走 :494-497`task` 不命中 knowledge hints → `step_type="tool"`。**真正的委派行只是一个普通 ToolRow "task",从未以 subagent 形态出现。**
- **子代理内部每个工具调用**ns 非空,name=write_todos/web_search/glob/write_file)→ :492-493 → `step_type="subagent"`、name=工具名。
子代理 `general-purpose` 通过它自己的 middleware stack 拿到这些内置工具:`_subagent_tools` 黑名单只剥离 HITL + 写副作用工具(`agent_factory.py:123-139`),TodoListMiddleware/FilesystemMiddleware 给子代理注入 write_todos/glob/write_file`agent_factory.py:136-137` 注释明示),业务 MCP 工具(含 web_searchdefault-allow 通过。
### "N 个 \<tool\> 子智能体" 的精确成因
前端 `buildFlowNodes` 见到一串 `step_type="subagent"` 的 step,按 `step.name` 分组、相邻同名合并(`stepUtils.ts:129-133`):
```ts
const last = nodes[nodes.length - 1];
if (last && last.kind === 'subagent_group' && last.name === step.name) {
last.agents.push(agent); // 相邻同名 → 同组,{{0}} 计数 +1
} else {
nodes.push({ kind: 'subagent_group', name: step.name, agents: [agent] }); // group.name = 工具名
}
```
`SubagentRow.tsx:57-60``1: group.name` 填进 `com_linsight_subagent_delegate``{{1}}`,于是渲染「自动委派 · N 个 write_todos 子智能体」等。由于各异工具名 `write_todos ≠ web_search ≠ glob``last.name === step.name` 多数不成立 → 多为「1 个 \<工具名\>」的孤立组。
### "已调用 N 个工具" 与 children.length 之谜的最终结论(✅ 114 实测已定论)
`calledCount = children.length``SubagentRow.tsx:18`),而 `children` **只在** `buildFlowNodes` 的「namespaced 且 stepType≠subagent」分支 push`stepUtils.ts:138-147`)。静态推断曾以为「所有 namespaced step 都是 subagent → children 恒为 0」,**这个推断错了**——它漏看了 thinking。
**2026-06-18 在 114 抓取截图同一次运行的真实落库 step history`linsight_execute_task.history`session=`10660c9eaef14f279256883050cdfc47`、task=`73cf8b4c`、418 帧)定论**
- **children 装的根本不是工具,而是子代理内部的 thinking**。thinking step 的 `step_type` 是硬编码 `"thinking"`**绕过** `_infer_step_type`,不受 `if ns: return "subagent"` 影响),但 `_build_thinking_step` 在 ns 非空时**照样把 `extra_info.namespace` 写上**`stream_event_mapper.py:439-441`)。于是子代理内部的 thinking 帧 =「namespaced 且 step_type≠subagent」→ 正好命中分支 B → 被 push 进 owner agent 的 children。
- 实测分布:418 帧 = thinking 347 + subagent 67 + tool 4;其中 **step_type≠subagent 且 namespace≠null 的帧 = 345,全部是 thinking**。把前端逻辑原样在这 418 帧上重放,22 个 subagent_group 的 children **100% 是 thinking**(如 web_search 卡片 children=`[0,135]`、write_file 卡片 children=`[7,14]`),与截图「已调用 2 个工具」逐一吻合。
- 所以「已调用 N 个工具」这句文案**双重错误**:N 既不是工具数、计的也不是工具,而是「该子代理这一小段里穿插了几次 thinking」。owner 归属由 `agentByNamespace[ns]` 的**最后一次覆盖**决定(`stepUtils.ts:128` 无 has 守卫),thinking 落到它之前最近注册的那个 tool-命名 subagent 上,所以同一 thinking 计数会被切散到不同卡片。
### namespace 真实形态(✅ 实测证实之前的预测)
langgraph 内部(亲读 venv):NS_SEP=`|`、NS_END=`:``_internal/_constants.py:87,89`);冒泡 namespace 元组 = `checkpoint_ns.split(NS_SEP)[:-1]``_messages.py:142-144`);`task_id` 是 xxhash UUID 串(`_algo.py:1404-1409`),**不是 `0`**。
**114 实测**:该 task 全程只有 **3 个 distinct namespace**,形如 `tools:c4770e2c-aa75-2150-a961-62ff912cad87`(即 `tools:<uuid>`,全平面、无多层 `|`)——对应模型「launch three subagents to research each framework」派生的 **3 个 research 子代理**
> **矛盾标注(以代码为准,实测确认)**fixture `step_types.json:44,55` 与后端单测 `test_subagent_reintroduction.py:184` 用理想值 `"research_subagent:0"`,前端单测 `stepUtils.test.ts:28` 用 `"general-purpose:0"`。**真实运行中 namespace 是 `tools:<uuid>`,既不是 `research_subagent:0` 也不是 `general-purpose:0`,也不是多层 ns**(之前假设的「多层前缀匹配」不成立——children 之谜的真因是 namespaced thinking,而非前缀匹配)。
### ✅ 114 真实数据实测复盘(截图同一次运行)
| 维度 | 实测值 |
|---|---|
| task | session=`10660c9e…`、task=`73cf8b4c``history` 418 帧 |
| step_type 分布 | thinking **347** / subagent **67** / tool **4** |
| 真正的委派点(ns=None tool 帧) | `task`×**3** + `write_todos`×1 —— 3 个 `task` 就是父→3 个 research 子代理的真实委派,却渲染成普通顶层 ToolRow "task" |
| 真实 subagent 数 | **3**3 个 `tools:<uuid>` namespace |
| subagent 帧按 name | web_search 46 / write_todos 13 / ls 4(前端按 name=ls 丢弃) / write_file 3 / glob 1 |
| 前端重放渲染树 | 顶层 thinking **13** 行 + 顶层 tool 行 4(task×3+write_todos) + subagent_group **22** 组(截图所见,与真实 3 个子代理完全不符) |
| thinking 碎片化 | 347 帧 → **347 个 distinct call_id=347 行)**;每行最终长度 **125 行 ≤10 字符、286 行 ≤30 字符**,最长仅 598;其中 334 行作为 children 藏进委派卡片、13 行顶层 |
**一句话**:UI 上 22 个「自动委派 · N 个 \<工具名\>」其实是 **3 个真实子代理**被打散的内部工具调用;"已调用 N 个工具" 数的是子代理内部 thinking;3 个真正的委派(`task`)反而是 3 个不起眼的 "task" 工具行。
---
## thinking 渲染机制与"碎片化"成因
### 段共用 call_id 的设计意图
thinking 无工具 call_id`messages` 模式逐 token 流式推送(DeepSeek 走 `additional_kwargs["reasoning_content"]`:502-504;增量 delta,非累计全量——单测 `test_stream_event_mapper.py:224-236` 正面断言、注释 :443-444 明文)。mapper 用「一段连续 thinking 复用一个合成 id」策略(`stream_event_mapper.py:449-452`):
```python
if self.ctx.current_thinking_call_id is None:
self.ctx.thinking_seq += 1
self.ctx.current_thinking_call_id = f"thinking:{task_id}:{self.ctx.thinking_seq}"
call_id = self.ctx.current_thinking_call_id
```
**设计意图明确:一段连续思考 = 一行。** 落库(state_message_manager.py:456-457 `output = prev + new`)与前端(stepUtils.ts:102-107 拼接 + `!==` 去重护栏 :104)都按 delta 拼接,正确无重复/截断。
### 切段闸门(碎片化根因)
`normalize` 收尾是切段的唯一闸门:
```python
# stream_event_mapper.py:157-159
if not self._is_thinking_continuation(result):
self.ctx.current_thinking_call_id = None
# :161-163 _is_thinking_continuation:仅当「恰好 1 个 ExecStep 且 step_type=='thinking'」为真
```
**即任何非 thinking 的 chunktool-call start / tool result end / write_todos diff / 空结果 `[]` / 普通 answer 文本)都会把 `current_thinking_call_id` 重置为 None → 下一段 thinking 重新铸 id → 前端新起一行。**
deepagents/DeepSeek-R1 的典型轨迹是「reasoning 几句 → 调工具 → 工具结果 → 再 reasoning 几句」的高频交替;叠加子代理内部密集穿插 write_todos/web_search/glob/write_file(每个都冒泡成 step),每次穿插都命中切段闸门:
```
reasoning("先对齐口径") → 段A(thinking:..:1)
tool_call(write_todos) → 切断段A
reasoning("接下来查财报") → 段B(thinking:..:2) ← 新行
tool_call(web_search) → 切断段B
...
```
一段连续推理被切成大量短行,每行只承载两次穿插之间那几字 reasoning。**该行为被单测 `test_stream_event_mapper.py:238-251``test_thinking_segments_split_by_intervening_step`)正面断言 `id_a != id_b` 钉为预期** —— 即「工具调用切断思考段」是 by-design 行为,但其设计假设(思考与工具低频交替)与 deepagents 运行真相(高频交替)背离。
### 前端次因:标题恒英文、折叠态零信息
`ThinkingRow.tsx:17` 标题 = `step.name || localize('com_linsight_thinking')`。后端 thinking 的 `name` 写死 `"thinking"`:456),`step.name` 永远真值 → **i18n 兜底「思考中」永不触发,屏幕显示英文字面 "thinking"**。展开仅 `step.output` 单段(ThinkingRow.tsx:20),无摘要。完成态默认折叠(StepRow.tsx:67-69),于是历史 thinking 全部折叠成一排标题完全相同的 "thinking" 行,用户无法区分/预览,主观放大「碎、空」观感。
---
## 两大困惑的根因定位
### 困惑1:大量几字 thinking 行
- **现象**:大量 "thinking" 行,展开后每行只有几个字,且标题全是英文 "thinking",无法区分。
- **根因**:主根因是后端切段规则「任何非 thinking chunk 即重置段 call_id」(`stream_event_mapper.py:157-159` + `:161-163`)粒度过细,与 deepagents「思考↔工具」高频交替流不匹配;次根因是前端 `ThinkingRow.tsx:17` 标题恒英文 `name="thinking"`、折叠态零摘要。
- **代码证据**:切段 `stream_event_mapper.py:157-159,161-163`;段 id 分配/写死 name :449-452,:456-457delta 语义单测 `test_stream_event_mapper.py:224-236`;切段被钉为规格的单测 `:238-251`;前端拼接 `stepUtils.ts:102-107`;标题失效 `ThinkingRow.tsx:17`
- **判定**:**设计缺陷为主**(切段粒度规则与运行真相背离,被单测固化为绿)+ **前端实现小瑕疵**(标题恒英文)。非模型「只想了几个字」,非数据层 bugreasoning_content 是 delta、拼接正确,确定度高)。
- **影响面**:所有 deepagents 任务模式运行结果,尤其 reasoning 模型(DeepSeek-R1+ 子代理委派场景;114 部署用 DeepSeek,命中最重。实时流与历史回看同源(落库 history 与实时流一致),均受影响。严重度中(无数据错乱/功能失效,但严重损害可读性 + 暴露英文 "thinking" 本地化破窗)。
- **✅ 114 实测**:截图那次运行 thinking 347 帧 → 347 个 distinct call_id347 行),125 行 ≤10 字符、286 行 ≤30 字符——碎片化得到定量证实,且子代理内部 thinking 也同样逐次被切(见上文实测复盘表)。
### 困惑2:自动委派 · N 个 \<工具名\> 子智能体
- **现象**:出现「自动委派 · N 个 write_todos/web_search/glob/write_file 子智能体」,展开卡片「已调用 N 个工具」,语义错位。
- **根因**:单一根因在后端 `_infer_step_type``if ns: return "subagent"``stream_event_mapper.py:492-493`):它把子代理内部每一次带 namespace 的工具调用无差别标成 subagent 却保留工具名作 `name`;真正的委派点(主图 `task` 调用,ns=None)反被判成普通 tool 行。前端 `buildFlowNodes` 忠实按 `step.name` 分组成委派行(`stepUtils.ts:126-135`),`{{1}}`=工具名。
- **代码证据**`_infer_step_type` `stream_event_mapper.py:484-497`(关键 :492-493);name=工具名 :366,:393call_reason 置空 :370-372;主图 task ns=None / 子图工具 ns 非空 `task_exec.py:906-922`;前端分组 `stepUtils.ts:126-135`:130 分组键、:133 group.name=step.name);header `SubagentRow.tsx:57-60`calledCount `SubagentRow.tsx:18,37`;子代理工具来源 `agent_factory.py:123-139`
- **判定**:**bug,且被后端单测固化成绿**。fixture `step_types.json:_meta:4` + `:40-56` 是正确契约(委派行 vs 内部 knowledge/tool 子步骤两类分明),前端实现与前端单测 `stepUtils.test.ts:42-71` 均符合契约;后端单测 `test_subagent_reintroduction.py:191` 却把「namespaced ⇒ subagent」正面断言为规格,与 fixture/前端单测三方对撞,且**无任何用例覆盖主图 task 委派行**(覆盖缺口),bug 因此逃逸。
- **影响面**:所有使用 subagent 委派的任务模式运行结果。委派语义被错位到子代理内部工具调用上,真正的委派关系(task → research 子代理)在 UI 上完全丢失。严重度中高(核心语义错位,但无数据损坏)。
- **✅ 114 实测修正**:截图那次运行真实只有 **3 个子代理**3 个 `tools:<uuid>` namespace)、3 个真正的 `task` 委派帧(ns=None,渲染成顶层普通 ToolRow "task"),却被打散成 **22 个**「自动委派 · N 个 \<工具名\>」组;「已调用 N 个工具」的 N 经实测**数的是子代理内部 thinking**(卡片 children 100% 是 thinking,如 `[0,135]`/`[7,14]`),并非工具——见上文「children.length 之谜的最终结论」。
---
## 修复建议
### 后端 mapper 路线(推荐 ★★★★★,根因所在)
**核心思路**:委派行应只在主图 `task` 工具调用这一真正委派点产生;子代理内部工具调用应保留其真实 step_typetool/knowledge)并带 namespace 作为 children。
| # | 改动点 (file:line) | 做法 | 风险 |
|---|---|---|---|
| B1 | `stream_event_mapper.py:484-497` | 删除 `if ns: return "subagent"`。namespace 只影响归组(已写 extra_info.namespace),不改写 step_typestep_type 仍按 name 推断 tool/knowledge | 低-中 |
| B2 | `stream_event_mapper.py:359-401` | 主图 task 调用识别为委派行:`if ns is None and name == "task": step_type="subagent"`,并把 `name``tc.args` 取子代理名(subagent_type/description)改写成 `general-purpose` | 低 |
| B3 | `stream_event_mapper.py` StreamContext + `_unpack_stream_chunk(task_exec.py:906-922)` | **真正难点**:建立委派行(ns=None)↔ 子代理内部 step(子图哈希 ns)的关联。主图 task step 与子图 step 无共享 ns 字符串,需在 mapper 侧维护 `task call_id ↔ 子图 ns 前缀` 映射,或在解包阶段把子图 ns 的父前缀回填给委派行,前端方能把子步骤折叠进委派行 children | 中-高 |
| B4 | `stream_event_mapper.py:157-159` | thinking 切段改为:仅当真正换 deepagents 节点/换轮才切段,工具调用穿插**不切段**,让一个 task 内的连续 reasoning 复用同一 call_id(落库 :457 拼接天然兼容) | 中(改契约,需确认 thinking 与 tool 行时序渲染合理) |
| B5 | `stream_event_mapper.py:456` | thinking ExecStep 的 `name` 改用 output 首句/前 N 字(替代写死 "thinking"),让前端折叠态可区分 | 低(纯展示增强) |
**对 fixtures/单测影响**
- fixture `step_types.json` 本就是正确契约,**无需改**(仅 namespace 字面名 `research_subagent:0` 是小瑕疵,可对齐为 langgraph 哈希形态或保持平面示例)。
- 后端单测 `test_subagent_reintroduction.py:169-199``test_stream_mapper_emits_subagent_step_type`)**必须重写**:当前把 bug 断言成规格(:191 断言 namespaced `search_knowledge_base` 是 subagent),改造后应断言「namespaced 工具保留 knowledge、主图 task 调用才是 subagent 且 name=general-purpose」。需补主图 task 委派行用例(覆盖缺口)。
- 单测 `test_stream_event_mapper.py:238-251` 若实施 B4,需同步删除/改写 `id_a != id_b` 断言。
### 前端 stepUtils/Row 路线(推荐 ★★☆☆☆,仅兜底/配合)
| # | 改动点 (file:line) | 做法 | 风险/说明 |
|---|---|---|---|
| F1 | `ThinkingRow.tsx:17` | 标题改为 `step.output` 首句/前 N 字(截断),fallback 到 `localize('com_linsight_thinking')`,修掉「英文 thinking」破窗 + 给折叠态信息量 | 低,纯展示,需处理空 output。**推荐与 B 路线并行** |
| F2 | `stepUtils.ts:120` buildFlowNodes | build 阶段把相邻 `stepType==='thinking'` 的 MergedStep 合并成一个节点(output 分隔符拼) | 中,与 B4 重复,二选一优先后端 |
| F3 | `SubagentRow.tsx:18` calledCount | **配合 B 路线必做**`calledCount` 改为只数 tool/knowledge 类 children`children.filter(c => c.stepType !== 'thinking').length`),否则「已调用 N 个工具」仍把子代理内部 thinking 计进去(实测正是此 bug);thinking-children 可在卡片内单列「思考 M 次」或不计数 | 低,纯展示,**实测根因点** |
| — | `stepUtils.ts:126-134` | 若完全不改后端,硬编码把 write_todos/glob 等内置工具名的 subagent step 降级成普通 tool 行 | **不推荐**:治标且脆弱,无法反推真实子代理名/委派关系 |
> **若后端按 B 路线改造,前端 `buildFlowNodes` 几乎不用改**:分支 A 收委派行(name=子代理名)、分支 B 收同 ns 子步骤进 children,正是其当前设计;`SubagentRow.tsx:57-60` 的 `{{1}}=group.name` 自然变成 `general-purpose`。**但 `calledCount` 必须配 F3**:实测 children 当前 100% 是 thinkingB 路线修好后 children 会同时含「子代理内部工具」与「子代理内部 thinking」,"已调用 N 个工具" 要排除 thinking 才准确。
**推荐组合**:困惑2 走 **B1+B2+B3**(根因,前端无辜);困惑1 走 **B4(治本,行太多)+ F1(折叠首句摘要 + 修 i18n 破窗)**。若暂不动后端契约,困惑1 退而求其次用 **F1+F2**。两条路线正交,风险可控。
---
## 附录:关键代码位置索引 + 数据真相指引
### 后端根因
- **`_infer_step_type`(困惑2 根因)**`stream_event_mapper.py:484-497`,关键 `:492-493` `if ns: return "subagent"`
- **thinking 切段(困惑1 根因)**`stream_event_mapper.py:157-159`(重置闸门)、`:161-163``_is_thinking_continuation`)、`:449-452`(段 id 分配)
- **thinking name 写死 / call_reason 置空**`stream_event_mapper.py:456-457``:370-372`
- **tool start/endname=工具名**`stream_event_mapper.py:359-401`:366,:393)、`:409-435`
- **namespace 写进 extra_info(非顶层)**`stream_event_mapper.py:385-388,:422-424,:439-441`;字符串化 `_namespace_str` `:136,:477-482`(多段 `|` 连接)
- **thinking 抽取(delta**`stream_event_mapper.py:499-516`DeepSeek reasoning_content :502-504 / Anthropic thinking block :506-515
- **丢弃 namespaced todos**`stream_event_mapper.py:220-221`
- **astream + subgraphs=True**`task_exec.py:749-754`(fresh)/`:300-305`(resume)/`:389-394`(continue)
- **`_unpack_stream_chunk`(主图 ns=None / 子图 ns 非空)**`task_exec.py:906-922`
- **`_handle_event` 分派 / ExecStep→TASK_EXECUTE_STEP**`task_exec.py:951-966,:1042-1054`
- **落库 upsert / thinking 拼接 / MessageEventType 10 取值**`state_message_manager.py:441-467`(:456-457)、`:32-56`
- **ExecStep 无顶层 namespace 字段**`bisheng_langchain/linsight/event.py:14-25`(:21-22 默认 tool_call)
### agent 构建
- **middlewares=[] / 禁用 Skills+自研压缩**`agent_factory.py:225-231`
- **create_deep_agent(tools=[*tools, ask_user], subagents=[...])**`agent_factory.py:239-248`
- **子代理 specname="general-purpose",无 model/permissions/interrupt_on**`agent_factory.py:142-170`
- **子代理工具黑名单**`agent_factory.py:123-139`:136-137 注释 write_todos/glob 等内置工具)
- **ask_user(仅主图,interrupt**`agent_factory.py:92-120`
### deepagents/langgraph 内核(venv
- **内置工具清单 / 中间件栈 / 同名抑制默认 GP**:`deepagents/graph.py:262-266,:750-781,:693`
- **task 用 ainvoke 调子图**`deepagents/middleware/subagents.py:563,587`
- **子图事件冒泡机制**`langchain/agents/_subagent_transformer.py`;主图 tools 为普通节点 `langchain/agents/factory.py:1390-1394`
- **NS_SEP=`|` / NS_END=`:`**`langgraph/_internal/_constants.py:87,89`
- **namespace = `checkpoint_ns.split(NS_SEP)[:-1]`**`langgraph/pregel/_messages.py:142-144`
- **checkpoint_ns / task_ns / task_id(xxhash UUID) 构造**`langgraph/pregel/_algo.py:615,833,987 / 624,843,1002 / 1404-1409`
### 前端(忠实呈现,无逻辑错误)
- **WS 挂载**`ExecutionFlow.tsx:68``TaskTurnPanel.tsx:75`
- **状态存储 / task.history**`useLinsightManager.tsx:24,441`
- **伪任务回填**`stepUtils.ts:278-288`,调用点 `ExecutionFlow.tsx:73-76``TaskTurnPanel.tsx:105-108`
- **merge/build 唯一入口 + 派发**`StepList.tsx:16,24-41`
- **mergeStepFrames**`stepUtils.ts:62-113`(按 call_id 合并 :75-97、output 拼接 :102-107、namespace 取 extra_info :91、丢弃 ask_user/ls/call_user_input :67,:74
- **buildFlowNodes**`stepUtils.ts:120-155`subagent 分支 :126-136、ns 注册覆盖 :128、相邻同名分组 :129-133、children push 仅分支 B :138-147、前缀匹配 :141-143
- **SubagentRow**`SubagentRow.tsx:18`(calledCount=children.length)、`:37`(已调用 N 个工具)、`:54`(PeopleRound 图标)、`:57-60`(委派标题 {{1}}=group.name)
- **ThinkingRow**`ThinkingRow.tsx:17`(标题 name||思考中)、`:20`(展开仅 output)
- **stepTypeIcon 正则**`StepRow.tsx:17-26`web_search→Earth :24、write→Write :25、glob→default Wrench :26subagent 走 SubagentRow 不经此)
- **折叠/展开规则**`StepRow.tsx:67-69`
### 数据真相 fixtures 指引
- **subagent/thinking 理想契约**`src/backend/test/linsight/fixtures/ws_events/step_types.json:30-58`thinking name="thinking" :31、subagent 委派行 `call_sub_01` name="research_subagent" :40-46、内部 knowledge 子步骤 `call_sub_inner_01` step_type="knowledge"+同 ns :51-55、`_meta`:4 契约说明)
- **task_id==svid 伪任务说明**`event_samples.json:6`
- **后端单测对撞点(bug 被写绿)**:`test_subagent_reintroduction.py:169-199`:191 断言 namespaced=subagent,与 fixture knowledge 契约对撞;无 task 委派行用例);`test_stream_event_mapper.py:224-236`delta 共用 call_id)、`:238-251`(切段被钉为预期)
- **前端单测(符合契约)**`stepUtils.test.ts:28`(ns="general-purpose:0")、`:31-40`(subagentChild step_type='knowledge'+ns)、`:42-71`(折叠为一个 group)
> **统一矛盾标注**fixture `step_types.json` 与前端单测 `stepUtils.test.ts` 代表**正确设计契约**HEAD 的后端 mapper 实现**背离**该契约,后端单测 `test_subagent_reintroduction.py` 把背离固化为绿。所有渲染错位的真正源头是后端 `_infer_step_type`(困惑2)与切段闸门(困惑1)。**「已调用 N 个工具」N≥2 之谜已于 2026-06-18 在 114 实测定论**N 数的是子代理内部 namespaced thinking`_build_thinking_step` 在 ns 非空时也写 `extra_info.namespace``stream_event_mapper.py:439-441`),并非工具,亦非之前假设的多层 ns 前缀匹配。
### 114 实测数据来源(可复现)
- 环境:`ssh root@192.168.106.114`MySQL `bisheng/123456`,库 `bisheng`
- 数据:`SELECT history FROM linsight_execute_task WHERE id LIKE '73cf8b4c%'`session_version `10660c9eaef14f279256883050cdfc47`session_id=`502cf65ee91146f78e69afcfe6093aff`,即截图那次运行)。`history` 是 JSON ARRAY,每帧 = `ExecStep.model_dump()`(与前端 `ExecStepEventData` 同构)。
- 复现:把 `mergeStepFrames`+`buildFlowNodes` 移植到脚本在该 418 帧上重放,得到的 22 个 subagent_group 顺序与各卡片 children 计数与截图逐一吻合。**只读未改库、未重启 worker**(抓取时 `free -m` available ~18.5G)。
@@ -1,196 +0,0 @@
# 灵思任务模式执行流增量优化方案(活动摘要 · 子代理拆平 · 自然语言旁白)
> 上游文档:《灵思任务模式后端执行与前端渲染映射技术报告.md》(根因 + 114 实测)、《灵思任务模式执行流渲染优化方案.md》(22→3 与聚合层已落地)。
> 本文是在「聚合层 + 22→3 委派修正」**已上线**之后,对照 Claude 交互范式的**第二轮增量优化**。
> 状态:**待评审(先文档后代码)**。
> 修订:并入「执行流归属机制重构(隐式游标 → write_todos 段流)」——这是 R1/R3 的**新底座**,本轮的根本决策。
---
## 0. 一句话结论
> 把任务模式执行流从「**按 todo 归属的嵌套进度树**」**降维**为「**以 write_todos 调用为界的单条段流**」:每段自带「干了什么」的活动摘要标题、段间穿插一句自然语言旁白、子代理独立成段——对齐 Claude 的执行流交互范式。
**本轮的根本决策**:放弃「把每个步骤归属到某个 todo」,改为「以 write_todos 调用切段、把段内步骤聚合」。一句话原因:
> **deepagents 的流里根本不携带「这一步属于哪个 todo」的信号——归属做不到、只能近似且会错;而 write_todos 是流里的确定性事件——切分永远准。** 把问题从「步骤属于谁(attribution)」降维成「段的边界在哪(segmentation)」,模型并行标记 todo、不守 todo 纪律等一切 adherence 问题随之蒸发。
三项目标(R1/R3/R2)都建立在这个新底座之上,按依赖与价值排序:
| 编号 | 目标 | 形态 | 改动面 | 风险 |
|---|---|---|---|---|
| **底座** | 归属机制不可靠 | 隐式游标归属 → **write_todos 段流(后端单桶 B2** | 后端 mapper + 前端渲染 | 中(multi-turn 需先验证) |
| **R1** | 一级标题信息量不足 | 「已深度思考(用时 N 秒)」→ **活动摘要 +(用时 N 秒)** | 纯前端 | 低 |
| **R3** | 运行过程层级过多 | 子代理「团队外壳→子代理→已深度思考→工具」**四层** → **每段一级 → 工具二级** | 纯前端 | 中 |
| **R2** | 摘要之间缺自然语言 | 段间穿插一句模型**自然语言旁白** | 后端 mapper + prompt + 前端 | 中(可行性先验证) |
---
## 1. 目标与需求
### 1.1 北极星
Claude 的执行流(本文参考图)做对了三件事,正是本轮要对齐的:
1. **每个折叠块的标题就是「干了什么」的摘要**——`Used 6 tools, ran 5 commands, read 3 files`,而非「已深度思考」这种动作无关的元信息。
2. **折叠块之间穿插一句自然语言**——`Materials read — I have the full picture now. Moving to writing.`,把"机器在干活"翻译成"同事在汇报"。
3. **层级克制**——过程是一条可扫读的浅栈,而不是需要逐层下钻的深树。
### 1.2 现状与差距(MECE
四条差距:前三条是 UI 表层(对应 R1/R2/R3),第四条是**底层机制**——它是前三条的共同地基,本轮才认清。
- **差距一 · 一级标题是元信息而非动作**:一级组统一显示「已深度思考(用时 382.0 秒)」。用时有了,但**"这 382 秒里搜了几次、读了几个文件、写了什么"全无**。
-**需求 R1**:一级标题改为**活动摘要**。
- **差距二 · 摘要之间没有自然语言**:纯摘要行堆叠,缺少把过程"翻译成人话"的旁白。
-**需求 R2**:段之间穿插一句**自然语言旁白**。
- **差距三 · 运行过程层级过多**:子代理被包了三层(`已派出 N 个子智能体调研``子智能体 N``已深度思考``工具调用`)。
-**需求 R3**:去外壳、每个子代理升一级、内部铺平。
- **差距四 · 归属机制本身不可靠(本轮新认知,是前三者的地基)**:执行流当前把每个步骤按 `task_id` 归属到某个 todo(靠「隐式游标」),再按 todo 分桶渲染成多条时间线。但**框架不提供 todo 关联信号、且模型并行标记多个 in_progress 时步骤会错位**(详见 §2)。R1/R3 若继续建在这个会错的归属之上,是在歪地基上盖楼。
-**底座决策**:归属机制换底座为 **write_todos 段流**(§3)。
### 1.3 决策基线(已与产品对齐)
| 项 | 已拍板决策 |
|---|---|
| **底座** | **段流·甩开 todo 归属**:执行流降为单条「段流」,以 write_todos 调用为段边界(**前端靠 `name==='write_todos'` 切段**,复用现有丢弃判断点 `mergeStepFrames:279`,零后端新增契约);不再按 task_id 把步骤分到各 todo 容器。落地走 **B2(后端单桶)**:后端把主图步骤统一落 `id==svid` 会话伪任务桶、删隐式游标。**多轮零额外后端工作**(2026-06-19 勘察修正,原"per-round 会话桶"前提作废):reload 今天本就不分轮——`history[]` 仅由 live `continueConversation` 客户端快照填充(`useLinsightManager.tsx:75`),reload 入口 `switchAndUpdateLinsight` 从不重建 → 所有轮已合并成一条;B2 把主图步骤全落 `sessionSteps`,正中现有快照分轮机制 → **live 分轮不变、reload 维持单条合并=今天行为**,无需新建 per-round 桶(那是一个独立新功能而非回归修复)。底部 TaskPanel 保留作独立进度展示、与执行流脱钩。**接受代价**:执行流与 todo 清单解耦——但该关联本就因 multi-in_progress 而不可靠。 |
| **prompt** | **删去** `agent_factory:108`「同一时刻只允许一个 in_progress」一句——段流后已不依赖单 in_progress,删它使 system prompt 与 write_todos 工具说明书不再矛盾、顺应 deepagents 原生并行语义;无需定制 tool_description、无需告警日志。模型并行标记时 TaskPanel 如实亮多个 running(反映并行意图、自洽)。**保留**「只翻转 status 不改文案」(TaskPanel todo 标识稳定)与「子代理并行委派 ≤2~3」。 |
| **R1** | **活动摘要为主 +「(用时 N 秒)」后缀**;新建/编辑文件**合并为一档**「编辑 N 个文件」(不拆 created/edited);**纯推理、段内无可计动作时,回退「已深度思考(用时 N 秒)」**。段头**纯活动摘要、不带 todo 名**。 |
| **R3** | **完全拆平**:每个子代理(namespace 硬边界)独立成一段、升为一级;内部去「已深度思考」二级、工具升二级。**接受代价**:弱化"并行"分组信号。 |
| **R2** | **本轮一起做**(不推后),但**实现前置一个可行性 Spike**:抓真实 DeepSeek 流确认工具批次间 `content` 产出率;后端 mapper 接通被丢弃的 `AIMessage.content` 通道 + prompt 引导。产出稀疏则降级(prompt 强制 / thinking 末句派生)。 |
### 1.4 非目标
- 不重做后端 thinking 切段(碎片化)逻辑——渲染层已吸收,作为技术债保留。
- 不追求「步骤 ↔ todo」的精确绑定——已确认框架无此信号,本轮正是**放弃**这个目标。
- 不动日常 `/c` 深度思考组(北极星本体)。
---
## 2. 现状逻辑与三个根本缺陷
### 2.1 现状:两层切分 + 隐式游标归属
**第一层 · 按归属切成多条时间线**`ExecutionFlow.tsx:193/200`):`sessionSteps`(规划伪任务)一条时间线,每个 todo 各一条(在各自 `TaskStepRow` 内)。
**归属怎么算(隐式游标)**deepagents 的 `write_todos` 推全量 todo 快照;后端 `_diff_todos` 做 3 级对齐、给每个 todo 算稳定 `task_id``_stable_task_id`=md5)。`_refresh_in_progress` 扫快照,把**当前 `in_progress` 的 todo** 记为游标 `current_in_progress_task_id`。此后每个步骤(thinking/工具/interrupt)一律按 `task_id = current_in_progress_task_id or svid` 盖戳——**有 in_progress 的 todo 就归它,没有(规划/收尾)就归会话伪任务 svid**。
**第二层 · 每条时间线内 `buildTimelineGroups` 聚合**`stepUtils.ts:482`):把连续顶层步骤包进一个 `deep_step_group`(=一个「已深度思考」),唯一切断点是子代理组。
> 本质:归属是**时间游标式**的——某步属于哪个 todo,纯看它发生那一刻哪个 todo 恰好 in_progress**与这步的内容无关**。
### 2.2 三个根本缺陷(为什么必须换底座,而非修游标)
1. **框架无 todo 关联信号**deepagents 的 streammessages 模式 metadata)只有 `langgraph_node`/namespace**没有任何字段能把一条 message/工具关联到 todo**。隐式游标是在硬凑一个**不存在**的信息——怎么修都是近似。
2. **multi-in_progress → 空壳 todo**langchain `TodoListMiddleware` 的工具说明书**鼓励**模型并行标多个 in_progress"mark your first task **(or tasks)** as in_progress"),与灵思 system prompt「只允许一个」**自相矛盾**。一旦模型并行标记:`_status_transition` 对每个都发 `TaskStart`(前端 N 个 todo 亮 running),但 `_refresh_in_progress``break` **只认列表第一个**做游标 → 所有步骤堆到第一个、其余是**亮着 running 却没有任何过程的空壳 todo**。这不是 bug 可补,是「时间游标」在并行下的**本质局限**(主图是单 agent 顺序流,todo 是备忘录、不是执行隔离边界;真正的硬隔离只有子代理 namespace)。
3. **无可靠全局序**`BaseEvent.timestamp` 只有**秒级 int**,无全局自增序。这堵死了"前端把多个 todo 桶合并重排成单条流"的退路(同秒必乱、且子代理步骤时间戳早于主图委派帧)——倒逼 B2(后端单桶天然有序),见 §3.2。
> 三者叠加的结论:**归属做不到、修不好;唯一稳健的出路是不再归属,改以 write_todos 切段。**
### 2.3 现成基础设施(活动摘要 + 旁白通道)
- **活动摘要(R1 几乎零新建)**:`summarizeActivity``stepUtils.ts:199`+ `classifyActivity` 已实现 8 类计数(已排除 thinking/ls/write_todos/ask_user);`ACTIVITY_I18N` 三语文案已就绪。8 类:联网搜索 / 检索知识库 / 读取文件 / **编辑文件(新建+覆盖+追加合并一档)** / 导出文档 / 运行代码 / 查找文件 / 执行操作。R1 本质只是把它接到段标题上 + 无动作回退。
- **旁白通道现在是断的(R2 关键)**:`stream_event_mapper._handle_messages`:386)对模型**纯自然语言正文 `AIMessage.content`** 一律 `return []` 丢弃(最终答案另走 values → `ResultSection`)。Claude 那种"工具批次间一句旁白" = 这条被丢弃的 content。R2 必须先在后端接通它——这是 R2 风险高于 R1/R3 的根因。
---
## 3. 方案:write_todos 段流重构
### 3.1 核心:从「归属」降维到「切分」
| | 隐式游标(现状) | write_todos 段流(本方案) |
|---|---|---|
| 在回答什么问题 | 这步**属于哪个 todo**attribution | 段的**边界在哪**segmentation |
| 依赖什么 | "当前 in_progress 是谁"(模型纪律) | "write_todos 出现在哪"(确定性事件) |
| multi-in_progress | 错位 → 空壳 todo | **不读 in_progress 集合,问题蒸发** |
| 框架支持 | ❌ 无 todo 关联信号 | ✅ write_todos 是流里的实锚点 |
**段流定义**:执行流 = 单条按 write_todos 边界切出的「段」序列。两次 write_todos 之间的步骤聚成一个一级段;子代理(namespace 硬边界)天然独立成段;首个 write_todos 前为规划段、末个 completed 后为收尾段。段标题 = 活动摘要 +(用时)(R1);段间穿插旁白(R2);子代理段内部铺平(R3)。
> **利好**write_todos 调用在后端**本来就产出一个可见 ExecStep**messages 模式经 `_handle_tool_starts`,无过滤);前端目前在 `mergeStepFrames` 把它当噪声丢弃。本方案只需把"丢弃"改成"留作不可见的段边界 marker"——锚点是现成的。
### 3.2 落地决策:B2(后端单桶),B1 否决
- **B1(前端重组多桶)已否决**:要把 `sessionSteps` + N×`tasks[].history` 合并成单条流,唯一排序键是秒级 timestamp(§2.2 缺陷三)→ 同秒乱序、子代理步骤错排。`mergeStepFrames` 又只信数组序、从不重排。退路堵死。
- **B2(后端单桶)采纳**:后端把所有主图 ExecStep 的 `task_id` 统一改成 `svid`,它们全部 append 进 `id==svid` 的会话桶 history**append 顺序 = 真实执行顺序**,是可靠全局单调序、无需 timestamp 排序。前端直接渲染这一条流。
**支撑 B2 的已核验事实**
- 子代理识别**纯靠 `extra_info.namespace`**(与 task_id 无关)→ 改 task_id 不影响子代理段(R3 不回归)。
- `TaskPanel` 只读 `tasks[].status``_diff_todos`/`TaskStart`/`TaskEnd`/`GenerateSubTask` 全保留即可驱动勾选,**与段流脱钩、零回归**。
- `id==svid` 会话桶行已由 `_ensure_session_pseudo_task` 建好、`add_execution_task_step` 已支持 upsert,能承载整条流。
### 3.3 后端改动(`stream_event_mapper.py` + `agent_factory.py`
> 注:原计划的 `task_exec.py` per-round 会话桶已**砍掉**2026-06-19 勘察:reload 不分轮、B2 落 `svid` 桶即零回归,见 §1.3 底座行)。后端只剩 mapper + prompt 两处改动。
| 改动 | 位置 | 内容 |
|---|---|---|
| task_id 统一 svid | `_handle_tool_starts:390``_build_thinking_step:494``_handle_interrupt:265` | `current_in_progress_task_id or svid`**`self.ctx.svid`**(落现有 `id==svid` 会话伪任务桶;tool-end `:478` 继承 open_call,自动跟随) |
| 删隐式游标 | `_refresh_in_progress`、其在 `_diff_todos:319` 的调用、`StreamContext.current_in_progress_task_id:126` | 整段删除,三处盖戳改 svid 后无消费者(已核验) |
| **prompt 收口** | `agent_factory:108` | 删「同一时刻只允许一个 in_progress」一句(顺应框架并行、消矛盾);保留「只翻转 status 不改文案」「子代理并行 ≤2~3」 |
| **保留** | `_diff_todos`3 级对齐 / `_stable_task_id`)、`_status_transition``GenerateSubTask` | TaskPanel 唯一数据源,**绝不动** |
| write_todos 帧 | `_handle_tool_starts`(现状即产可见 ExecStep | **保持产出**——落当轮桶后顺序即真实 write_todos 时刻,作前端段边界锚点 |
### 3.4 前端段流(`stepUtils.ts` 为主,含 R1/R3/R2 接线)
| 改动 | 位置 | 内容 |
|---|---|---|
| write_todos 转段 marker | `mergeStepFrames:279` | 从丢弃集移除 write_todosask_user/ls 仍丢弃),改为打标保留(`extraInfo.segmentBoundary`),仅作切段信号、不 inline 渲染。`classifyActivity` 已对它返 null,**不污染 R1 摘要计数** |
| **段边界切断点** | `buildTimelineGroups:482` | `node.kind==='step'` 分支判 `isSegmentBoundary(step)` → flush 当前 episode 且不 push marker;子代理组仍 flush+passthrough。**改动局限单函数** |
| **R1 段标题** | `DeepStepGroup.tsx:105-123` `label` | 接 `summarizeActivity(group.steps)` + `ACTIVITY_I18N` 拼活动摘要 +(用时 N 秒);摘要空(纯 thinking)回退「深度思考」;`compact`(子代理内)仍去时间子句 |
| **R2 段间旁白** | `DeepStepGroup.tsx` 段头下方 | 接已实现的 `narrationFromSteps`(取段内 thinking 末句);其上游依赖 §2.3 后端接通 content**Spike 先行**(见 §4 |
| **R3 子代理拆平(完全拆平·已实现)** | 渲染层 `ExecutionTimeline` + 新增 `explodeSubagentGroup``stepUtils` | 每子代理升为独立一级段:`explodeSubagentGroup``subagent_group` 爆成「每子代理一段」,复用 `DeepStepGroup`(新增 `subagent` 头:goal·活动摘要 + 子代理图标)。`buildTimelineGroups` 与其测试**不变**(爆破在渲染层)。**删** `SubagentTeamGroup`/`SubagentTrack`(+其 test,已无生产引用)。代价:丢「N 个并行」分组信号(§1.3 已接受)。 |
| 渲染塌缩(已简化·零改 carrier) | —(**无需**改 `ExecutionFlow`/`ConversationRound`/`TaskTurnPanel` | B2 后新数据 `task.history` 皆空 → `TaskStepRow` 命中 `!hasSteps` **自返 null**,既有 `<ExecutionTimeline history={sessionSteps}/>` 自动成段流。**比删 TaskStepRow 更安全**:老 reload 数据仍经 TaskStepRow 渲染(免费向后兼容)。 |
| 退役 | `TaskStepRow.tsx` **保留** | 不删——它对空 history 自失效,且承载老 reload 数据的向后兼容。 |
**渲染示例(目标态)**
```
执行流(单条 · 按 write_todos 切段)
✦ 检索知识库 3 次 · 读 2 文件(18 秒) ← 规划/首段
「先摸清已有材料。」 ← R2 旁白
✦ 联网搜索 5 次 · 读 3 文件(42 秒) ← 段2
✦ 调研框架A · 执行 5 工具(38 秒) ← 子代理段(namespace 硬边界)
✦ 撰写交付物 · 写 1 文件(26 秒) ← 收尾段
────────────────────────────────
底部 TaskPanel:☑任务1 ☑任务2 ☐任务3 ← 计划清单(独立、与段流脱钩)
```
> `stepUtils.ts` 当前 ~710 行,本轮后复核;若逼近 client 的 600 行上限,把 clarify 解析段抽到 `clarifyUtils.ts`。
---
## 4. 实施次序
| Wave | 内容 | 特征 |
|---|---|---|
| ~~**Wave 0**~~ | ~~per-round 会话桶~~ **已砍**2026-06-19 | 勘察证明 reload 不分轮、B2 落 `svid` 桶即零回归;per-round 桶是独立新功能而非前置。后端直接从 Wave A 起。 |
| **Wave A** | **后端单桶(svid+ 删游标 + prompt 收口** | 3 处 task_id→`self.ctx.svid`、删隐式游标(`_refresh_in_progress` + 其调用 + 字段)、删 `agent_factory:108` 单 in_progress 要求;单 PR。 |
| **Wave B** | **前端段流 + R1 + R3**(纯前端,紧随 A) | 与 A 有契约耦合(write_todos `name` 作段锚点),建议同分支、分 commit。 |
| **Wave C** | **R2 旁白**(本轮内、最后做:Spike → 后端 content + prompt + 前端) | 风险最高、放最后但仍在本轮。先抓真实 DeepSeek 流验证工具批次间 content 产出率;产出稀疏则降级(prompt 强制 / thinking 末句派生)。 |
> 原则:先落底座(A/B,最小可见收益尽早),R2 的不确定性靠其内部 Spike 闸门隔离、不阻塞 R1/R3。完成后同步更新本文 §2.1。
### 实现进度(2026-06-19
- **Wave A 后端**:✅ mapper 三处 task_id→`self.ctx.svid` + 删隐式游标(方法/调用/字段);✅ prompt 收口已落(删 `agent_factory:108` 单 in_progress,按采访决策#4;中途被外部还原过一次、经用户确认再删)。`test/linsight/` 全绿(327 passed)。
- **Wave B 前端**:✅ 段流(`mergeStepFrames` 保留 write_todos 作边界 + `buildTimelineGroups` flush);R1 段标题(活动摘要 + 用时,纯推理回退「深度思考」,新增 i18n `com_linsight_act_summary`×3 语);R3 **完全拆平**`explodeSubagentGroup` + `DeepStepGroup.subagent` 头,删团队组两件套)。`Execution/*.test` 39 passed、touched 文件 tsc 零新增错误。
- **Wave C R2**:✅ 降级旁白已落(`DeepStepGroup``narrationFromSteps`,取段内 thinking 末句,仅折叠态渲染、空则不渲染;按用户选)。⏸ **「好」路径仍欠**:后端接通 `AIMessage.content` + prompt 引导,需对真实 DeepSeek 流做 Spike(本地↔116 隧道不稳),环境就绪后再做。
- **待办**:① 对抗式 review;② E2E 目检(段切分 / R1 标题 / R3 扁平 / R2 旁白 / TaskPanel 勾选;live≈reload;需 114/116 真机);③ R2「好」路径 Spike。
---
## 5. 风险与回归清单
- **multi-turn(已澄清,无需后端改动)**:reload 今天即单条合并(`history[]` 仅 live 快照填充、reload 不重建),B2 落 `svid` 桶后 live 仍靠现有客户端快照分轮、reload 仍单条合并=今天行为。回归只需确认「B2 后 live 多轮分轮不变 + reload 不更糟」,**不引入** per-round 桶/迁移。「reload 按轮分开展示」是另一条独立增强、不在本轮。
- **R3 单文件 ≤600 行**`stepUtils.ts` 复核,必要时拆 `clarifyUtils.ts`
- **R2 可行性**DeepSeek 中途 content 产出率是 Spike 唯一判据;adherence 风险须正视,留降级路径。需确保最终答案不被同时渲成 narration + ResultSection(仅在 tool-call 前 flush、流末丢弃)。
- **prompt 矛盾(本轮删除单 in_progress 要求解决)**:删 system prompt「单 in_progress」一句、顺应框架并行后矛盾消失;模型并行标记时 TaskPanel 如实亮多个 running(反映并行意图、自洽,非 bug)。
- **全路径回归**liveWS 落 `svid`→sessionSteps/ reload`svid` 伪任务 history → splitSessionPseudoTask/ 多轮 ConversationRoundlive 快照分轮,reload 单条合并=今天)/ 分享只读 / clarify HITLask_user 仍走 ClarifyCard,不进段流)/ 规划段 / 收尾段 / TaskPanel 勾选(status 驱动,与段流脱钩)。
- **验证环境**:本地起前后端 + 连 116 test 中间件(mock 流补 write_todos×2 + 子代理);114 真实 DeepSeek E2E 目检——无空壳 todo、段标题摘要、子代理铺平、reload≈live、**多轮 reload 分轮正确**。
---
## 附:测试落点
- **后端** `test/linsight/test_stream_event_mapper.py`:删 `current_in_progress_task_id` 断言;新增 ①全步骤 `task_id==svid` ②write_todos 仍产可见 ExecStep(段边界)③`_diff_todos` 仍驱动 TaskPanel ④子代理步骤 `task_id==svid` 且 namespace 保留。
- **前端** `stepUtils.test.ts`:现有 write_todos「丢弃」用例(`:203/:339`)语义反转为「切段」——`[tool, write_todos, tool]`→2 段、`[tool, write_todos, subagent, write_todos, tool]`→3 段;write_todos 仍不作可见 step`summarizeActivity` 排除 write_todos 用例保留。
@@ -1,353 +0,0 @@
# 灵思任务模式执行流渲染优化方案
> 配套文档:《灵思任务模式后端执行与前端渲染映射技术报告.md》(根因定位 + 114 实测)。本文是其**优化设计篇**,聚焦"前端整体交互流畅 / 美观 / 易于理解"。
> 产出方式:ultracode 多 agent 工作流(6 视角体验审计 → 3 方案设计 → 3 评审团 + 3 可行性硬校验 → 首席综合),16 agent / 186 万 token。
> 状态:**待评审(先文档后代码)**。
---
## 0. 一句话方案
> **把任务模式执行流收敛到日常聊天「深度思考组」的同一套设计语言**:用「最小但正确的后端契约纠正(B1+B2)」把数据修对,再用「前端聚合层(buildTimelineGroups+ 与 `DeepThinkingGroup` 同构的渲染原语」把 ~39 个等权裸节点收敛成 5–8 个有标题、有用时、可下钻的聚合组——**22 个伪委派组 → 1 个『已派出 3 个子智能体调研』组;347 个几字 thinking 行 → 收进组内一段;"已调用 N 个工具"双重错误 → 真实『N 工具 · M 思考』**。
| 维度 | 现状(114 实测同一次运行) | 目标 |
|---|---|---|
| 同级裸节点 | ~39 个等权灰行(13 thinking + 4 tool + 22 委派组) | 5–8 个有标题/有用时的聚合组 |
| 子代理表达 | 22 个「自动委派 · N 个 \<工具名\>」卡片墙 | **1** 个「已派出 **3** 个子智能体调研(用时 M 秒)」组,3 路并行可下钻 |
| thinking | 347 帧 → 347 行,标题恒英文 "thinking" | 收进「深度思考(用时 N 秒)」组,整段拼接,折叠头带首句指纹 |
| 委派点 | 3 个不起眼的顶层 ToolRow "task" | 升格为显式委派组头(携带委派目标) |
| 子代理卡片摘要 | "已调用 N 个工具"N 其实数 thinking,双重错误) | "✓ N 个工具 · M 次思考"N 排除 thinking |
| 实时流 | 每个 step 收尾整行塌陷,347 次连环抖动 | 组级稳定折叠,内部只 append,不抖 |
| 跨模式一致性 | /linsight 与 /c 各画各的 | 共享 `TimelineRail`/`CollapsibleTimelineItem`/`useElapsedTicker`,同构零学习成本 |
---
## 1. 设计北极星:日常聊天的「深度思考组」
本次优化**不新造设计系统**——同一代码库的日常聊天模式早已解决了同类问题,它就是北极星:
| 北极星组件 | 它做对了什么 | 对应解决任务模式的什么病 |
|---|---|---|
| `Chat/Messages/DeepThinkingGroup.tsx` | 把**一段连续的 thinking + tool_call** 聚合成**一个**可折叠组,组头「已深度思考(用时 N 秒)」/「正在深度思考(已用 N 秒)...」 | 缺聚合层(SNR-01 / MM-05 / DC-01)、缺用时(DC-02 |
| `Chat/Messages/ThinkingContent.tsx` | 内部 thinking 用 `\n\n` 拼成**一整段**「思考内容」,左轨 timeline16px 图标 + 1px `#E0E0E0` 连接线) | thinking 碎片化、跨段不合并(DC-06) |
任务模式的 `StepList`/`ThinkingRow`/`SubagentRow` 家族是一套**更原始、未聚合**的平行渲染。优化的本质是**让任务模式与日常模式同构(而非"相似")**——抽出共享原语,工程上根除复制粘贴漂移。
---
## 2. 体验审计:54 条发现,51 条是已知两困惑之外的新问题
6 个交互设计师视角并行审计,确认《技术报告》的两大困惑(thinking 碎片化、subagent 工具名错标)只是冰山一角。下表只列**高优(🔴)**与代表性中优(🟡)发现:
### 2.1 信息层级与信噪比
- 🔴 **SNR-01** 执行流是一条无层级平铺时间线,~39 个同级节点争夺同等视觉权重,没有主线。
- 🔴 **SNR-02** 唯一的主线进展信号(TaskPanel 任务 N/M 清单)被默认折叠、放逐到输入框上方,与执行流脱节——**信息层级被倒置**(最该看见的藏起、最该藏起的铺满)。
- 🔴 **SNR-03** 22 个横排卡片墙占用远超信息价值的版面,把"真实 3 个子代理在调研"这个高价值信号 22 倍稀释。
- 🟡 SNR-04 最终产出与几十行过程噪声同处一条滚动流末尾,缺终态强调(违反峰终定律)。
- 🟡 SNR-06 折叠态零信息:完成后一排标题全等的英文 "thinking" 无法扫读定位。
- 🟡 SNR-07 `call_reason` 全程为空 → 所有过程行退化为裸工具名,丧失"为什么做这一步"的语义。
### 2.2 心智模型对齐
- 🔴 **MM-01** 缺失「委派树」结构:扁平列表抹掉了父→子代理层级,用户建立的是"单 agent 顺序执行 N 步"而非"一个规划者 + 三个并行调研员"。
- 🔴 **MM-02** 3 个子代理是**并行**派发的,纵向时间线却制造"串行"假象。
- 🔴 **MM-03** "自动委派 · N 个 X 子智能体"把**委派动作发起者数量**与**被委派者**混为一谈,破坏施受关系。
- 🔴 **MM-04** `task` 委派点退化为裸 ToolRow,委派这一关键认知锚点对用户隐形。
- 🔴 **MM-05** 缺日常已有的 episode 聚合层,心智模型从"一段推理"退化为"几十个零件"。
- 🟡 MM-09 全程无时间维度,用户无法判断系统在努力工作还是卡住了。
### 2.3 设计一致性(对标北极星)
- 🔴 **DC-01** 缺"外层聚合容器"这一最高层结构单元——根本性设计语言断层。
- 🔴 **DC-02** `ExecStep.timestamp`(秒级,已落库 ✅已核实 `event.py:9-10`)全程被前端丢弃,没有"用时 N 秒"。
- 🔴 **DC-03** 折叠头文案是"裸名词",日常是"动词短语(已使用/已检索/已联网搜索 X)"——可读性 + 本地化双重退化。
- 🟡 DC-04 左轨 token 系统性漂移(任务模式折叠色 `#8C8C8C` vs 日常 `#999999`、间距 `gap-2/gap-1` vs `gap-1.5/gap-0.5`)。
- 🟡 DC-05 SubagentRow 横排卡片(lucide `Recycle/Check` + 蓝/绿)是异质设计语言,破坏左轨统一。
- 🟡 DC-09 两套组件零复用,趋同只能靠人工对齐而必然漂移。
### 2.4 渐进披露与折叠策略
- 🔴 **PD-2【新】** 实时流中步骤完成即自动折叠——**用户正在读的行会"当着面合上"**。
- 🔴 **PD-3【新】** 折叠摘要只服务"运行中",完成态丢弃所有摘要能力——历史回看与实时流的披露策略本末倒置。
- 🔴 **PD-7** "已调用 N 个工具"在折叠摘要位给出错误计数,是摘要"可信度污染"的典型。
- 🟡 PD-4/PD-5/PD-6【新】 多层折叠下钻成本高于价值;手动折叠 per-row 非持久(刷新/切会话即丢);展开无高度上限,长文撑爆视口。
### 2.5 实时流体验与动效
- 🔴 **RT-1** 逐 delta 帧整树重渲染 + `StepList` 无 memo 全量重算 → 347+ 帧流式必然掉帧。
- 🔴 **RT-2** running→done 瞬间整行从展开塌陷为折叠 → 347 次连环抖动。
- 🔴 **RT-3** smooth 自动滚动 + 250px 阈值与高频帧竞速 → 滚动失控、用户上滑被反复拽回。
- 🔴 **RT-6** 最终报告生成阶段(status=Running 无 step 事件)两个承载面反馈不一致,ExecutionFlow 全页缺"正在生成结果"。
- 🟡 RT-4 新行硬闪入,且北极星的 `animate-thinking-appear` 在 client app **实为失效空类**`tailwind.config.cjs` 未定义该 keyframe)——破窗。
- 🟡 RT-5 running 反馈源过载:spinner + 两种呼吸点(`animate-pulse`/`pulse-scale`+ 多桥接行可能同屏。
### 2.6 边界异常态与双承载面
- 🔴 **B1【死路径】** 子代理内部调研轨迹(children)完全不渲染——"可展开调研轨迹"是死路径(叠加 B9:子代理内部 thinking 既被错计成工具、又因 children 不渲染而彻底消失)。
- 🟡 B2 双承载面结构不一致(TaskTurnPanel 缺 ConversationRound、breathing 逻辑分叉);B3 内嵌 80% 气泡宽度未差异化,横排卡片必然换行;B4 terminated/error 横幅在历史轮(ConversationRound)丢失;B6 ls/ask_user 被丢弃 → 子代理卡"已调用 0 个工具"空态;B8 `ui_card` 整条路径是 dead code。
---
## 3. 方案对比与评审结论
设计了 3 个不同哲学的方案,3 个评审团视角对比打分(清晰度 / 美学 / 工程可行性各最高权重一次):
| 方案 | 哲学 | 用户清晰度团 | 视觉美学团 | 工程可行团 |
|---|---|:--:|:--:|:--:|
| **①深度思考组同构移植** | 前端主导聚合,对标北极星,零后端难点 | **8.5** ★ | **8.8** ★ | 7.0 |
| ②语义任务树(B1+B2+**B3**) | 后端把委派树修正确,前端按真实层级渲染 | 7.5 | 7.4 | 6.1 |
| ③务实最小改(B1/B2/B5+F1/F2/F3 | 最低风险快速止血 | 7.2 | 6.9 | **8.2** ★ |
**3 份可行性硬校验一致确认的关键约束**(决定融合形态):
1. **B1+B2 是不可降级的硬前提**(不是可选项):纯前端方案①若不动后端,子代理内部真实工具(web_search/write_file)会永远被 `if ns: return "subagent"` 打成 subagent"✓N 工具"恒错;且 `task` 委派帧 ns=None 永不入 `agentByNamespace`,渲染树根本搭不起来。而 B1/B2 **只依赖 ns 是否为空、不依赖 ns 的具体形态**,不受 `tools:<uuid>` 平面形态影响——经硬校验确证成立、风险可控。
2. **报告里的 B3(委派帧 ↔ 子图哈希 ns 精确 1:1 关联)在真实数据上不可行**:主图 taskns=None)与子图 step 是两套独立 ID、无共享字符串,burst 并行派发场景时序错配。三份校验一致建议**放弃强关联**,改用方案②的洞察「**ns 即子代理身份**」——按 `extra_info.namespace` group-bydistinct ns 数天然 = 真实子代理数(22→3)。
3. **后端 B5thinking name 取首句)应改由前端做**thinking 是逐 token delta 流,per-chunk 首帧只有几字,后端写 name 会是残句且每帧抖动;改由前端对**合并后的完整 output** 取首句(F4),0 后端改动、更可靠。
### 最终选择:融合方案
> **以方案①(同构移植)为主干**,嫁接 **方案②**的「ns 即子代理身份 + 委派目标透传 + side-by-side 并行表达 + 共享原语」,辅以 **方案③**的「分波止血 + B1/B2 后端纠正 + 前端 firstLine 兜底」。
>
> 形态 = **前端聚合层(buildTimelineGroups+ 最小但正确的后端契约纠正(B1+B2,不做 B3+ 与 DeepThinkingGroup 同构的渲染语言**。
兼得三者所长:用户易懂(真实委派树浮现 + 跨模式零学习)、视觉美观一致(共享 token 根除漂移)、流畅(组级稳定折叠 + useMemo + 入场动画),且后端改动收敛为"删 1 行 + 改 2 处 + 重写 2 个测试",单 PR 可控、分波可独立交付。
---
## 4. 目标设计
### 4.1 目标心智模型
> 「父代理先规划(它自己深度思考了 N 秒,写了任务清单),然后在『调研』这一步**同时派出 3 个子代理**分头去查 3 个框架——每个子代理点开能看到它**搜了什么、写了什么、想了什么**;最后父代理把三路结果汇总成这份报告。」
用户看到的是一棵**能下钻、关系清晰、并行可辨、过程收纳、产出凸显、带时间感**的委派树,而非几十个等权重的灰色裸行。
### 4.2 渲染树:现状 → 目标
**现状(病象)**
```
ExecutionFlow (overflow-y-auto, max-w-800)
├─ 用户问题气泡
├─ IntentRow「已明确意图」
├─ StepList(sessionSteps) ← 裸 map,无聚合容器
│ ├─ ThinkingRow「thinking」×13 (标题恒英文, ≤30 字符, mb-6 孤岛)
│ └─ ToolRow「task」×3 (委派点退化为不起眼裸工具行, ns=None)
├─ TaskStepRow「调研三大框架对比」(完成默认折叠)
│ └─ StepList(task.history)
│ ├─ SubagentRow「自动委派 · N 个 web_search 子智能体」┐
│ ├─ SubagentRow「自动委派 · N 个 glob 子智能体」 │ 22 个横排白卡墙
│ ├─ SubagentRow「自动委派 · N 个 write_file 子智能体」 │ lucide 异质视觉
│ └─ … (共 22 组, name=工具名错标) │ "已调用 N 工具"(N 数 thinking)
│ └─ SubagentCard (children 死路径, 展开后无下文) ┘
├─ PlanningRow「正在执行任务」(报告生成期文案错位, 全页缺 generating)
└─ ResultSection (mt-4, 与 39 个过程节点同层, 无终态强调)
```
**目标(同构聚合)**
```
ExecutionFlow (scrollRef, max-w-800)
├─ [历史轮] ConversationRound × N (共用下方同一聚合 + 终态横幅, 稳定 key)
├─ 用户问题气泡
└─ <ExecutionTimeline tasks sessionSteps status> ★唯一聚合入口 (useMemo)
│ buildTimelineGroups: 扁平 MergedStep[] → 有序 TimelineGroup[]
├─ IntentRow「已明确意图」
├─ ┌ DeepStepGroup「已深度思考(用时 8 秒)」 ← 规划/会话级聚合 (复用北极星壳)
│ │ ├ ThinkingContent「思考内容」(相邻 thinking \n\n 合并为一段)
│ │ └ ToolRowLite「已写入任务清单」(write_todos, TimelineRail 语言)
│ └ (组级单一折叠, 运行中固定展开不抖)
├─ TaskStepRow「任务 1:调研三大框架对比」✓ (内部递归同一聚合)
│ └ <ExecutionTimeline history=task.history>
│ ├ DeepStepGroup「父代理思考:拆成 3 路并行」
│ └ ┌ SubagentTeamGroup「已派出 3 个子智能体调研(用时 42 秒)」 ★ 22→1
│ │ 并行: 宽屏 side-by-side 3 列 / 窄面纵向 track
│ │ ┌SubagentTrack─┐┌SubagentTrack─┐┌SubagentTrack─┐
│ │ │目标:调研框架A ││目标:调研框架B ││⟳目标:框架C │
│ │ │✓5工具·8思考 ││✓4工具·6思考 ││已联网搜索… │
│ │ └─[展开▾]───────┘└──────────────┘└──────────────┘
│ │ ↓ 展开 → 内嵌该子代理的 DeepStepGroup (真实 children!)
│ │ └ ThinkingContent + ToolRowLite「已联网搜索(3)」「已写入文件」
│ └ (一个 task 委派点 = 一个 team 组, distinct ns = track)
├─ DeepStepGroup「正在深度思考(已用 3 秒)...」 (活跃组, live tick)
├─ <BreathingRow state=planning|researching|generating> (单一呼吸, 两面共用)
└─ <ResultPanel> (顶部分隔 + ✓任务完成头) → ResultSection
```
### 4.3 设计原则
1. **结构正确先于美观**:UI 是用户推断系统模型的唯一证据(Norman system image)。先用最小后端纠正(B1/B2)把数据修对,前端才能渲染真相。
2. **同构而非相似**:抽共享 `TimelineRail` 原语,日常组与任务组共用同一套 token,工程上根除漂移。
3. **组块化优于平铺(Miller 7±2)**:~39 个等权裸节点先聚合成少数有标题、有用时的 episode 组,再允许逐层下钻。
4. **组级折叠取代行级折叠**:折叠绑定**语义阶段边界**而非单 step 的 running 生命周期 → 根治 347 次连环塌陷抖动。
5. **每层下钻都给正比于成本的信息回报**:折叠头携带内容指纹(用时 N 秒 / 3 个子智能体 / N 工具·M 思考 / 首句摘要)。
6. **并行用并置、串行用时间线**:宽屏 side-by-side 多列卡片,窄面降级纵向 track。
7. **结果是峰值**:最终产出移出同质过程流,包进独立 `ResultPanel`(峰终定律)。
8. **以用户语言而非系统术语呈现**:工具行复用动词短语映射,替换裸 `write_todos/glob`
9. **单一活动指示语言**:全树只用一套呼吸语言(组头 `animate-pulse` + 用时 tick)。
10. **双承载面同构**:四处承载面共用同一聚合入口与 `BreathingRow`
---
## 5. 详细规格
### 5.1 后端改动(最小但正确)
| # | 文件 | 改动 | 风险 | 测试影响 |
|---|---|---|---|---|
| **B1** | `stream_event_mapper.py` `_infer_step_type`(:492-493) | **删除** `if ns: return "subagent"`。namespace 仅用于归组(已写 `extra_info.namespace`),不再改写 step_type;子代理内部工具恢复按 name 推断 tool/knowledge,子代理内部 thinking 保持 thinking。 | 中。删后全链路再无 `step_type==='subagent'`(除 B2 新增的主图 task)→ 前端 `buildFlowNodes` 必须同步改为按 namespace 建组(F2a),**后端前端必须同 PR**。 | 反转 `test_subagent_reintroduction.py:169-199`namespaced `search_knowledge_base` 断言改回 `knowledge`);改 `test_stream_event_mapper.py` `TestSubagentNamespace`(仍带 ns 但 step_type 回归真实类型)。 |
| **B2** | `stream_event_mapper.py` `_handle_tool_starts`(:359-401) | 识别真正委派点 `if ns is None and name == "task"`step_type=`subagent``ExecStep.name``tc.args.subagent_type`(默认 `general-purpose`);`call_reason``extra_info.delegate_goal` 回填 `tc.args.description/instruction`(**仅 task 委派行回填**,普通工具行 call_reason 仍空)。 | 低-中。有 fixture 锚点;`delegate_goal` ↔ 具体 ns **不做精确 1:1**(burst 不可靠),仅作本轮委派目标在 team 组头展示。 | 新增用例:ns=None + name=task → step_type=subagent、name=general-purpose、call_reason=description、extra_info.delegate_goal 存在。fixture `step_types.json` 委派行 name 改 `general-purpose`、子代理内部帧改真实 step_type + ns 对齐 `tools:<uuid>`。 |
**明确不做(已论证)**
-**B3**(委派帧↔子图哈希 ns 精确关联)——真实数据无共享 ID、burst 时序错配,改用「ns 即子代理身份」替代。
-**B4**(后端 thinking 切段改造)——与前端聚合正交,先在渲染层合并;落库仍是碎片,作为技术债在本文记录(见 §7 开放决策 1)。
-**B5**(后端 thinking name 取首句)——delta 流首帧残句且抖动,改由前端 F4 对完整 output 取首句。
### 5.2 前端改动
| # | 文件 | 改动 | 风险 |
|---|---|---|---|
| **F1** | `tailwind.config.cjs` | keyframes 增 `thinking-appear`opacity 0→1 + translateY 4px→0),animation 增 `thinking-appear: 0.25s ease-out`。**一处修复同时救活日常 `ThinkingContent:35` / Artifacts / SearchWebUrls 的失效空类**(破窗)。 | 极低,纯新增。 |
| **F2** | `stepUtils.ts`(聚合若超量拆 `timelineGroups.ts` | (a) `buildFlowNodes` 改为按 `step.namespace`(含 `tools:<uuid>` 平面形态)group-by 建隐式 agent 组,不再依赖 `step.stepType==='subagent'`B2 标 subagent 的 task 委派点(ns=None)作 team 锚点,其后 distinct ns 子代理就近挂入(启发式,非 B3 精确关联,挂不上则各 ns 自成 track 仍归当前 in_progress task)。(b) 新增 `buildTimelineGroups`:相邻 thinking + tool/knowledge 聚成 `thinking_group`(相邻**同 namespace** thinking 用 `\n\n` 合并,**跨 ns 不合并**避免污染);task 委派点 + distinct ns 聚成 `subagent_team`distinct ns 数=3)。(c) `mergeStepFrames` 消费 `frame.timestamp`(秒级 int ✅已落库)→ `MergedStep``startedAt/endedAt`,组级用时近似到秒。(d) `calledCount = children.filter(c => c.stepType !== 'thinking').length``thinkingCount` 单列。(e) `firstLine` 工具函数(取 output 首句/前 ~24 字符去换行)。 | 中。聚合较重需大量单测;ns 分组对 burst 交错流是启发式,需 orphan 兜底(所有 step_type 的 orphan 统一 inline 降级)。 |
| **F3** | `stepUtils.ts` / `TaskStepRow.tsx` | `mergeStepFrames + buildTimelineGroups` 调用点全部 **useMemo 化**(按 history 引用 + `[history.length, lastFrame.status, lastFrame.call_id]` 复合 key 兜底 WS 原地 mutate);`ExecutionTimeline``activeFlowNode` 共享同一次 build,消除每帧双重 O(n) 重算与折叠标题逐帧跳。 | 低-中。需验证 WS 增量是否产生新 history 数组引用。 |
| **F4** | `ThinkingRow.tsx` | 标题改 `firstLine(step.output) || localize('com_linsight_thinking')`~24 字符截断 + fallback,修恒英文破窗。多数场景该行已被 thinking_group 吸收,此为兜底。 | 低。 |
| **F5** | `ExecutionFlow / TaskTurnPanel / ConversationRound / TaskStepRow` | 四处承载面把裸 `<StepList>` 换成 `<ExecutionTimeline>`(统一聚合入口);planning/executing/generating 三处合并为 `<BreathingRow state=...>`(两面共用,ExecutionFlow 补 generating);`ResultSection` 包进 `<ResultPanel>`stopped/error 终态在 ConversationRound 历史轮也渲染;ConversationRound key 从 index 改稳定 idsession_version_id)。 | 中。改动面广,需回归 live/refresh/历史多轮/分享只读/clarify/splitSessionPseudoTask 各路径。 |
| **F6** | `useAutoScroll.ts` + `TaskTurnPanel.tsx` | 流式期 `behavior 'smooth'→'auto'(instant)`、阈值 250→64px、用户上滑即脱离 autoscroll 直到回底才恢复;给 TaskTurnPanel **补接入** useAutoScroll(当前缺失)。 | 低。 |
| **F7** | `store/`(新增 `linsightCollapseState` atom) | 组级折叠态持久化:key=组稳定 id(`planning`/taskId/namespace),存轻量 Recoil **sessionStorage** atom,切会话/刷新不丢。纯前端状态,不发 HTTP(**C7 合规**)。 | 低。 |
| **F8** | `stepUtils.ts` + `StepList.tsx` | 移除 `ui_card` dead casemapper 从不产出、注册表恒空)或显式标 TODO;ls/ask_user 被丢弃后留下的空 children 子代理卡折叠或给替代文案(修 0 工具空态)。 | 极低。清理死路径。 |
### 5.3 新增组件(11 个,含共享原语)
**共享原语(先抽后切,保护北极星本体)**
- **`TimelineRail.tsx`** — 抽出的共享左轨(16px 图标 + `w-px #E0E0E0` 连接线 + 统一 token `gap-1.5/gap-0.5/pt-[3px]`),日常 `ThinkingContent/ToolCallDisplay` 与任务模式各 Row 共用,消灭复制粘贴漂移。**先抽后切、保日常侧 props/className 不变、加视觉回归快照**,防动到北极星本体。
- **`CollapsibleTimelineItem.tsx`** — 共享可折叠时间线项:折叠头(TimelineRail + 标题 + chevron + 摘要槽)+ `grid 0fr→1fr` 动画 + 受控 open。`DeepStepGroup`/`SubagentTrack`/任务行同源构建。
- **`useElapsedTicker.ts`** — 从 `DeepThinkingGroup` 抽出的组级耗时 hookstart/end + 100ms tick + `formatSeconds`),日常组与任务组共用同一计时器。
**任务模式渲染组件**
- **`ExecutionTimeline.tsx`** — 唯一聚合渲染入口,替代 `StepList` 裸 map;遍历 `buildTimelineGroups` 派发,四处承载面共用,根除双承载面漂移。
- **`DeepStepGroup.tsx`** — 任务模式版深度思考组:聚合相邻 thinking+tool,组头「已深度思考(用时 N 秒)」/「正在深度思考(已用 N 秒)...」,内部 thinking `\n\n` 拼一段渲染 `ThinkingContent` + 工具行。
- **`SubagentTeamGroup.tsx`** — 委派组壳(取代 `SubagentRow`):一个 task 委派点 + 真实 N 个 distinct ns 子代理聚成「已派出 N 个子智能体调研(用时 M 秒)」,宽屏 side-by-side 多列、窄面纵向降级。
- **`SubagentTrack.tsx`** — 单个子代理可折叠轨迹:标题=委派目标 `delegate_goal` 或「子智能体 N」,摘要「✓N 工具·M 思考」;展开后**真实 map children** 渲染该子代理的 `DeepStepGroup` + 工具行——兑现"可展开调研轨迹"、堵 children 死路径。
- **`ToolRowLite.tsx`** — 任务模式工具/知识行:套 `TimelineRail` 外观,标题复用 `ToolCallDisplay``BUILTIN_TOOL_I18N` 动词短语(已联网搜索/已检索知识/已写入文件),保留现有 input/output 分段;**不写 MergedStep→AgentToolCall 适配器**(避免 output 裸字符串喂 parseResults 退化为单 chip)。
- **`BreathingRow.tsx`** — 统一呼吸行:`state='planning'|'researching'|'generating'`,单一 `animate-pulse` 标题 + live tick,替代分散的 PlanningRow×2 + generating row,两承载面共用。
- **`ResultPanel.tsx`** — 终态产出容器:顶部 `border-t` 分隔 + ✓ 任务完成头,把 `ResultSection` 从过程流拔高(峰终定律)。
### 5.4 交互规格
- **折叠默认态——组级取代行级**`DeepStepGroup`/`SubagentTeamGroup` 默认展开(`useState(true)`)且**不绑定 running**——组创建即稳定展开,内部新增只 append,组壳本身不开合;废除单步 end 帧触发整组折叠。
- **历史回看**:完成组默认折叠为一行「已深度思考(用时 N 秒)」/「已派出 3 个子智能体调研(用时 M 秒)」**带信息摘要**,支持扫读定位。
- **渐进披露三层**:L1 主线(规划组/任务行 N/M/委派组/结果)常驻;L2 展开组看 `ThinkingContent` 一段 + 工具行 + 子代理 track;L3 子代理 track 内再展开看其 `DeepStepGroup`。每层折叠头都带摘要。
- **SubagentTrack 摘要**:完成态「✓N 工具·M 思考」(N=children 非 thinkingM=thinking),运行态「正在调研…」或当前工具动词;标题优先 `delegate_goal`,无则「子智能体 N」。
- **手动折叠持久化**:组展开态 key=组稳定 id 存 Recoil sessionStorage,切会话/刷新不丢;组创建默认 = 持久化值 `?? true`
- **running 反馈统一**:活跃组头 `animate-pulse` + 用时 100ms tick;移除 spinner + pulse 蓝点 + pulse-scale 灰点三套并存;`BreathingRow` 三态合一两面共用。
- **自动滚动**:流式期 instant、阈值 64px、用户上滑即脱离直到回底恢复;TaskTurnPanel 补接入。
- **并行表达**`SubagentTeamGroup` 内宽屏(≥560pxside-by-side flex 等宽多列(`flex-1 min-w-0`),窄面(TaskTurnPanel 80% 气泡 <560px)降级纵向堆叠;卡片间无连接线。
### 5.5 动效规格
- 组展开/折叠:`grid gridTemplateRows 0fr→1fr` + `transition-all duration-300 ease-out`(复用日常同款)。
- chevron`Outlined.Down transform-gpu transition-transform duration-200`,组头展开 `rotate-180`、行折叠 `-rotate-90`
- 活跃组头:`animate-pulse`(单一活动指示语言)。
- 新行入场:`animate-thinking-appear`0.25s ease-out,需先补 tailwind keyframe,顺带修日常失效类)。
- 耗时 tick100ms `setInterval`,仅 streaming 时启动、结束清理。
- **禁止**:单步 end 帧触发整行/整组高度塌陷动画(根除 347 次连环抖动);DeepStepGroup 内部 thinking 增量只更新文本不重排。
### 5.6 视觉规格(全量复用北极星 token)
| 元素 | token |
|---|---|
| 组头标题 | `#212121 font-medium text-sm leading-[22px]` |
| 节点/触发器标题 | `#999999`hover→`#212121`)(任务模式 `#8C8C8C` **统一为 `#999999`** |
| 细节正文 | `#818181 text-xs leading-5` |
| 连接线/分隔 | `#E0E0E0 w-px` |
| 完成态 rail 图标 | `Outlined.CheckCircle #C9CDD4` / 其余 `Outlined` `#333` |
| 间距 | 组壳 `gap-3`(取代 mb-6 孤岛)、rail `gap-0.5`、图标-标题 `gap-1.5``pt-[3px]`、展开内容 `mt-2` |
| 图标(统一 bisheng-icons Outlined**废弃 lucide Recycle/Check 异质卡片** | 思考 `Bulb`、thinking rail `CheckCircle/#C9CDD4`、web `Earth`、knowledge `BookOpenText`、写 `Write`、子代理组 `PeopleRound`、规划/任务 `ListSuccess/DoubleCheck`、running `Loading animate-spin`(唯一 spinner |
| 工具行标题 | 复用 `BUILTIN_TOOL_I18N` 动词短语「已联网搜索(N)/已检索知识(N)/已写入文件/已使用 X」 |
| 用时文案 | 复用 `formatSeconds(ms)`<10s 一位小数、≥10s 取整;0 秒隐藏用时沿用 `showDuration=false` |
### 5.7 i18n 变更(三语 zh-Hans / en / ja
**废弃/替换**
- `com_linsight_subagent_delegate`("自动委派 · N 个 X 子智能体")→ 句式有施受/计数歧义,由 `com_linsight_subagent_team_*` 取代。
- `com_linsight_subagent_tools_called`"已调用 N 个工具")→ 由 `com_linsight_subagent_summary`「N 个工具 · M 次思考」取代,根治双重错误。
**新增**
| key | zh-Hans |
|---|---|
| `com_linsight_subagent_team_done` | 已派出 {{0}} 个子智能体调研(用时 {{1}} 秒) |
| `com_linsight_subagent_team_running` | 正在派出 {{0}} 个子智能体调研(已用 {{1}} 秒)... |
| `com_linsight_subagent_summary` | {{0}} 个工具 · {{1}} 次思考 |
| `com_linsight_subagent_track` | 子智能体 {{0}} |
| `com_linsight_deep_thinking_done` | 已深度思考(用时 {{0}} 秒) |
| `com_linsight_deep_thinking_running` | 正在深度思考(已用 {{0}} 秒)... |
| `com_linsight_task_completed` | 任务完成 |
---
## 6. 分波实施计划
> 原则:**最小可见收益尽早交付**;后端契约纠正与其前端依赖必须同 PR。
### Wave 0 — 前端纯止血(零后端、零契约风险,可立即独立合入)
- F1 tailwind 补 `thinking-appear` keyframe(修破窗 + 救日常失效类)
- F4 ThinkingRow 标题取 `firstLine`(修恒英文破窗)
- stepUtils 新增 `firstLine` 工具函数
- F3 `mergeStepFrames/buildFlowNodes` useMemo 化(消每帧重算)
- SubagentRow `calledCount` 排除 thinking(先消计数污染残影)
- i18n 新增 deep_thinking / task_completed 等 key(三语)
**Gate**`npm run build` 通过;stepUtils.test.ts 补 firstLine/useMemo 用例绿;114/116 连 test 中间件目检——英文 thinking 消失、滚动后期不再明显卡顿、N 计数不再数 thinking。
### Wave 1 — 后端契约纠正 + 前端分组改造(**必须同 PR**,结构正确硬前提)
- B1 删 `if ns: return "subagent"`
- B2 主图 task 标 subagent + name=general-purpose + call_reason/delegate_goal 回填
- 重写 `test_subagent_reintroduction.py`(反转 namespaced=knowledge + 新增 task 委派用例)
-`test_stream_event_mapper.py TestSubagentNamespace` 断言
- 更新 fixture `step_types.json`(委派行 name + 子代理内部帧真实 step_type + ns 对齐 `tools:<uuid>`
- F2(a) `buildFlowNodes` 改按 namespace 建组(22→3
- stepUtils.test.ts 重写:3 个不同 `tools:<uuid>` → 3 组、跨 ns thinking 不误并、orphan 兜底
**Gate**`uv run pytest test/linsight/` 全绿;前端用 114 实测 418 帧形态构造 fixture 重放断言 **3 组而非 22**、✓N 工具 N 正确;114 E2E 跑真实 DeepSeek 任务确认 22→3、子代理卡可展开见真实 web_search/write_file。
### Wave 2 — 核心聚合 + 同构渲染
- F2(b)(c)(d)(e) `buildTimelineGroups`thinking_group / subagent_team / 用时 / 拆计数)
-`TimelineRail` + `CollapsibleTimelineItem` + `useElapsedTicker` 共享原语(**先抽后切、日常侧加视觉回归快照、保 props/className 不变**
- 新增 `ExecutionTimeline / DeepStepGroup / SubagentTeamGroup / SubagentTrack / ToolRowLite`
- F5 四处承载面 `StepList → ExecutionTimeline`
- `SubagentTrack` 真实 map children 兑现可下钻
**Gate**:日常 `/c` 深度思考组视觉回归快照**像素级不变**(保护北极星);`/linsight``/c` 同构目检;347 行收进组内一段;组级折叠运行中不抖;单文件 ≤600 行(聚合算法超量则拆 `timelineGroups.ts`)。
### Wave 3 — 交互打磨 + 双承载面收口
- F5 `BreathingRow` 三态合一 + 两面共用 + ExecutionFlow 补 generating
- F5 `ResultPanel` 终态容器 + ConversationRound 历史轮终态横幅 + key 改稳定 id
- F6 `useAutoScroll` 调参 + TaskTurnPanel 接入
- F7 折叠态 sessionStorage 持久化
- F8 移除 ui_card dead case + 空 children 兜底
- 并行 side-by-side(宽屏)+ 窄面纵向降级
- TaskPanel 默认展开(若拍板采纳)
**Gate**:双承载面结构与反馈一致目检;refresh/切会话折叠态保留;历史轮终态不蒸发;窄气泡 side-by-side 正确降级不溢出;E2E 回归 live/refresh/分享只读/clarify/多轮全路径。
---
## 7. 待拍板的开放决策
| # | 决策 | 建议 |
|---|---|---|
| 1 | 是否接受落库 history 仍是 347 帧碎片(**不做后端 B4**,仅前端渲染层合并)? | **建议先仅前端 F2 合并**(与 B4 正交、可后续叠加),在本文显式记录技术债:导出/二次分析/未来其他 UI 消费方仍见碎片。 |
| 2 | 委派目标 `delegate_goal` ↔ 具体子代理 track 的精确 1:1 绑定做不做? | **建议不做**。burst 场景时序无法可靠对应;track 标题退化为「子智能体 N」,团队组头展示委派目标列表。坚持"失败只缺文案不影响结构"。 |
| 3 | 组级用时只能到秒级精度(thinking 无独立 start 帧、call_id upsert 只剩 END 帧时间戳),是否接受? | **建议接受秒级近似** + 0 秒隐藏用时(沿用日常 `showDuration=false`,0 后端改动)。任务多为分钟级长任务,秒级足够。 |
| 4 | TaskPanel(任务 N/M 主线锚点)是否从默认折叠改为默认展开、并从 footer 移到滚动区顶部 sticky | **建议先做默认展开仍在 footer**(低风险)。移到顶部 sticky 作为独立后续项,避免与执行流重构耦合。 |
| 5 | `com_linsight_subagent_delegate` 等旧 key 是否随组件下线直接删除? | **建议先 grep 确认** const/event/utils 是否被 gpts 复用;仅删确认无其他引用方的 key,存疑保留。 |
---
## 8. 本方案额外解决的问题(已知两困惑之外)
性能(RT-1 useMemo + 共享 build,渲染节点 ~400 行→~10 组)、高频抖动(RT-2/RT-3 组级折叠 + 滚动调参)、破窗(RT-4 补 keyframe)、反馈过载(RT-5 BreathingRow 合一)、折叠态零信息 + 不持久(PD-4/PD-5 指纹 + sessionStorage)、children 死路径(B1/B9 真实下钻)、空态 / dead code(B6/B8)、双承载面漂移 + 历史轮终态蒸发(B2/B4/EX 共用入口 + 稳定 key)、结果信号被拉平(SNR-04 ResultPanel)、token 系统性漂移(DC-04/DC-09 抽原语单一可信源)、裸工具名噪声(DC-03/SNR-07 动词短语)、无时间维度 + 串行假象(MM-02/MM-09 用时 + side-by-side)。
---
## 9. 风险与回归清单
- **后端前端耦合**:B1 删除后全链路再无 `step_type==='subagent'`(除 task),F2(a) 必须同 PR,否则渲染树搭不起来。
- **保护北极星**:抽 `TimelineRail` 必须"先抽后切"、日常侧加视觉回归快照、props/className 不变——严禁回归 `/c` 体验。
- **ns 分组是启发式**:burst 交错流边界可能关联不准,必须有 orphan 兜底(统一 inline 降级),并在 Wave 1 用 114 实测 418 帧形态做重放单测。
- **WS 原地 mutate**F3 useMemo 依赖需复合 key 兜底(WS 增量可能不产生新数组引用)。
- **全路径回归**live / refresh / 历史多轮 / 分享只读 / clarify HITL / `splitSessionPseudoTask` 伪任务回填 / 窄气泡降级。
- **验证环境**:本地起前后端 + 连 test 中间件(116),不改 test 部署代码;114 跑真实 DeepSeek E2E 确认 22→3。
@@ -1,309 +0,0 @@
# 灵思任务模式运行链路与 deepagents 上下文工程对比
> 一句话结论:当前灵思任务模式 = **deepagents 内核 + 毕昇企业级"外骨骼"**——单图规划/执行被 deepagents 的 `create_deep_agent` 接管(`write_todos` 现场拆解、内置 SummarizationMiddleware 压缩、WorkspaceBackend 真文件系统),外层包了分布式 Worker 队列、Redis checkpointer 跨进程 park/resume、StreamEventMapper 防腐层与 WebSocket 回传;而 deepagents demo 是同一框架的**最小默认形态**(单进程单图、useStream SSE 直连、子代理并行委派、Skills progressive disclosure 真生效)。毕昇为修 HITL 正确性,**主动砍掉了 demo 引以为核心的 `task` 子代理委派与 Skills 热插拔**——这正是两者最尖锐的分歧。
>
> 本文定位:取代旧两篇调研(《灵思任务模式 vs deepagents demo 上下文工程对比调研.md》《灵思任务模式全局运行链路与 SOP 必要性研判.md》),基于本地最新代码(HEAD=`77beaa6f0`,分支 `feat/2.6.0-beta4`)的真实调用链重写。**2026-06-17 一连串重构(SOP 残留代码整体物理删除、知识检索收窄到知识库/知识空间并改精确注入、SearchKnowledgeBase 改条件注入)已全部折叠进正文为当前真相**,所有毕昇侧锚点为本次实读的 CURRENT 行号;deepagents demo 侧凡未在本仓库复验的论断均显式标注可信度。
---
## 1. 全局运行链路
### 1.1 时序泳道速览
```
用户(client UI) HTTP/SSE/WS 网关 Redis 队列 Linsight Worker(多进程) deepagents agent
───────────── ──────────────── ────────── ────────────────────── ─────────────────
点击发送 ──submit(SSE)──▶ submit_user_question
├ linsight_workbench_submit (落 session_version)
└ linsight_workbench_title_generate
◀────────────────────┘
直接 start-execute ─POST▶ queue.put(裸 svid) ─RPUSH▶ [svid]
│ │ BLPOP
│ process_one_item ─acquire 槽─▶ async_run
├─连 WS task-message-stream ◀─BLPOP 消费事件队列─ push_message(RPUSH) ◀─ StreamEventMapper ◀─ astream(updates/messages/values)
│ │ ├ write_todos(现场规划)
│ │ ├ 工具调用 / read_file / search_kb
│ │ └ ask_user → interrupt()
│ 收到 call_user_input 卡片 │ park: astream 自然结束 → done_callback → semaphore.release(归还槽)
└─答复 user-input ─POST▶ queue.put_head(resume=True) ─LPUSH 头插▶ [resume]
│ BLPOP(优先)
process_one_item ─acquire▶ async_resume(Command(resume=...) 同 thread_id)
│ └ 续跑 → write_file 产物 → FINAL_RESULT
◀────────── FINAL_RESULT 事件(经事件队列→WS)──────────┘ COMPLETED
```
三种通道并存:**submit 走 SSE**(两事件),**start-execute / user-input / continue 走普通 POST****执行结果回传走 WebSocket**。
### 1.2 线性叙事(编号即时间序)
**链路一·首次提交新任务**
1. 用户在 landing`/linsight/new`)输入框敲回车。真实入口是 `TaskModeChatInput``components/Sop/index.tsx:45` 守卫 `versionId==='new' && !sopId``:55` 渲染),复用日常 `AiChatInput` + `taskMode:true``TaskModeInput.tsx` 是另一套 shell,只在执行页底部输入框出现(`ExecutionFlow.tsx:221`),landing 不用它。
2. `handleSend``TaskModeChatInput.tsx:78`)构造 submission,调 `setLinsightSubmission('new', {...})``useLinsightManager.tsx:175`)写入 Recoil。
3. `useLinsightSubmit``useLinsightManager.tsx:204`)固定 watch `'new'` 键,监听到后打开 SSE 连 `/api/v1/linsight/workbench/submit``useLinsightManager.tsx:257`),payload 经 `convertTools``:393`)把伪工具映射成后端标志位。
4. 后端 `submit_linsight_workbench``linsight.py:158`):校验邀请码,`submit_user_question` 落库 message_session + session_version 并发 `linsight_workbench_submit`;随后 `task_title_generate` + `insert_one`,发 `linsight_workbench_title_generate`。两事件之间**无任何 SOP 生成调用**(SOP 生成代码已物理删除)。`submit_user_question` 构造 `LinsightSessionVersion` 时**不传 `sop` 字段**,该字段默认 None`linsight_session_version.py:84`,仅留存兼容)。
5. 前端 `linsight_workbench_title_generate` 回调里**直接** `startLinsight(versionId)``useLinsightManager.tsx:338`POST `/workbench/start-execute`)——无 SOP 步骤,注释 `useLinsightManager.tsx:335` 明写 "no SOP generation step anymore"。
6. 后端 `start_execute``linsight.py:215`):校验 owner + 非终态,`queue.put(data=linsight_session_version_id)``linsight.py:251`)传**裸 svid**。注意 wire 层并非裸字符串——`arpush``redis_conn.py:425`)先 `pickle.dumps` 再 RPUSH;消费端 `ablpop``redis_conn.py:392``pickle.loads` 还原回原始 `str`,故 Worker 在 Python 层确实拿到裸 str。**端点已无任何 SOP 写入逻辑**(随 SOP 残留代码删除),只做入队。
7. 同时 `ExecutionFlow.tsx:68` 通过 `useLinsightWebSocket(versionId)` 连 WS `/workbench/task-message-stream`。后端 WS 端点(`linsight.py:534`)只做 `MessageStreamHandle.connect()` 纯订阅推送,**不参与调度/入队**。
**链路二·澄清(ask_user / clarify)回填**
8. Worker 执行中触发 `interrupt()`ask_user),astream 在 `__interrupt__` chunk 后自然结束,任务 park 为 `WAITING_FOR_USER_INPUT`。前端 WS 收到 `call_user_input` step`ExecutionFlow` 选最新未完成项渲染 `ClarifyCard``PlanningRow`(由 `planning` 布尔门控 `{planning && <PlanningRow/>}``ExecutionFlow.tsx:161`)是"规划中呼吸点",与 SOP 无关,**在用**。
9. 用户答完,`ClarifyCard.finishAndSubmit` 合并多问成一段文本 → `handleClarifySubmit``ExecutionFlow.tsx:112`)→ `sendInput(...)` POST `/workbench/user-input`
10. 后端 `user_input``linsight.py:321`)的关键 **session-level 分支**`session_level = linsight_execute_task_id == session_version_id``linsight.py:358`)。session-leveltodo 尚未生成时的 ask_usertask_id 等于伪 session 任务、无 `LinsightExecuteTask` 行——注释 `linsight.py:353-357`)硬置 `already_completed=False``:361`)并**跳过 `set_user_input`**(否则 task 找不到会抛 500not-found 的 `ValueError``state_message_manager.py:265`,经 except `:292` 包成 `:294` `ServerError.http_exception()`);非 session-level 才进 `else` 分支查 `USER_INPUT_COMPLETED` 幂等(`:363-368`)并 `set_user_input``:371`)。
11.`not already_completed``:380` 门控),`queue.put_head(encode_queue_item(svid, resume=True, ...))``linsight.py:385`**LPUSH 头插**,让 park 任务插队优先恢复。Worker 取到 `resume=True``async_resume`
**链路三·已完成会话再追一轮(continue / 多轮)**
12. 执行页底部 `TaskModeInput.onFollowUp``ExecutionFlow.tsx:235`)→ `continueConversation(versionId, question)``useLinsightManager.tsx:73`):前端先把当前轮快照进 `history`、清空顶层字段开新轮,再 POST `/workbench/continue`
13. 后端 `continue_conversation``linsight.py:264`):要求会话 COMPLETED/FAILED 终态,**先翻回 IN_PROGRESS**`:296-299`,否则 Worker 非终态守卫会丢弃),再 `queue.put(encode_queue_item(svid, continue_question=question))``:307`)尾插。Worker 取到 `continue_question != None``async_continue`(喂同一 thread 保留上下文)。
### 1.3 队列与 Worker 调度(`worker.py`,本轮未改动)
- **LinsightQueue = RPUSH/BLPOP FIFO + LPUSH 头插队**`put``arpush`=RPUSHpickle),`get_wait``ablpop`=BLPOPunpickle),`put_head``alpush`=LPUSH`alpush` 自身不 pickle,故 `put_head``worker.py:159` 手动 `pickle.dumps` 保持一致)。
- **`parse_queue_item`**`worker.py:64-78`):dict 直接用;裸 str 落 legacy 分支 `{session_version_id, resume:False, user_input:None, continue_question:None}``:78`)。`index``worker.py:173`)算排队位时跳过 resume 项,避免插队项虚增他人等待位。
- **多进程模型**`start_schedule_center_process``worker.py:371`)起 `worker_num`(默认 4)个 `ScheduleCenterProcess`daemon Process`spawn` 启动),每进程独立 event loop + 独立 `asyncio.Semaphore(max_concurrency)`(默认 32)。
- **三分派**`worker.py:299-310`,守卫顺序):`continue_question is not None``async_continue``:304`);`elif resume``async_resume``:307`);`else``async_run``:310`)。派发前有 **pre-flight 非终态守卫** `_session_is_terminal`,终态/缺失则丢弃并立即释放槽(防 park 期间被终止的任务被陈旧 resume 复活)。
- **park 不占并发槽**`task.add_done_callback(handle_task_result)``worker.py:316`)。`interrupt()` 让 astream 自然结束、协程 returndone_callback 的 `finally` 无条件 `semaphore.release()``worker.py:236`)。**park 瞬间即归还并发槽,漫长等待期不占槽**(注释 `worker.py:312-315`),长时间等用户输入不会饿死 Worker。
### 1.4 direct-answer fallback 与步骤持久化
- **direct-answer fallback**astream 跑完、**未 park** 且 `_final_result` 仍为 Noneplanner 没产 TaskEnd,即没有 todo 状态迁移到终态)→ 走 `_handle_direct_answer_completion`。典型是问候/纯问答被 deepagents planner 直接作答、不产 todo。effect:用 `_last_assistant_text` 兜底答复并置 COMPLETED,避免前端卡"规划中";若 todo 已生成却无产物,仍 `get_final_result_file` + `build_fallback_report_file` 兜底合成报告。
- **流式/孤儿步骤刷新不丢**:规划/收尾/direct-answer 这类路由到 `task_id=svid`(无独立 `LinsightExecuteTask` 行)的步骤,过去刷新即丢;现 `add_execution_task_step``call_id` upsertthinking delta 累积成一条、tool start/end 合并成一帧)并补了一个 session-level 伪任务行承载它们,`get_execute_task_detail` 在其为空时再剔除——强化"刷新不丢"。
---
## 2. 主 agent 的上下文是怎么装配的
主 agent 真实看到的上下文有**两条通道**,只看 system_prompt 会严重漏判。
### 2.1 静态通道:system_prompt
`create_linsight_agent``agent_factory.py:81`)在 `create_deep_agent(...)``:156` 区)传入 `system_prompt=LINSIGHT_SYSTEM_PROMPT_ZH``agent_factory.py:37`,开篇即"你是深度研究任务智能体…")——一段静态中文"拆解待办清单 + ask_user 澄清纪律"指令,**不含 SOP / 知识 / 文件**。这只是 deepagents 拼接的最外层:框架还会在其后追加 `BASE_AGENT_PROMPT`,各内置中间件(TodoList/Filesystem/Summarization)再注入各自工具行为段。真实任务上下文不在这条里。
### 2.2 动态通道:运行期首条 user message
`_build_agent_input``task_exec.py:801`)把首条 user message 的 content 用 `"\n".join(parts)` 拼成,`parts` 顺序:
1. **`history_summary`**(拼接 `task_exec.py:817-818`,调用点 `:716`= `build_prior_conversation_summary(chat_id)``utils.py:411`):按 chat_id 从统一会话流 `ChatMessage` 重建跨轮"用户/助手"配对,确定性 head+tail 截断(永远保留第一轮原始诉求 + 最近若干轮,`max_chars=8000`,省略中间轮加标记)。**这是 per-session Redis checkpointer 看不到的更早 daily/task 轮次前情**。
2. **`session_model.question`**`task_exec.py:819-820`)。
3. **文件指针块(条件)**`prepare_file_list``workbench_impl.py:534`)返回**零正文**的 `<uploaded_files>` 指针块(path/name/lines/imagesoffload-first),拼到 `\n# 可用文件\n{block}``task_exec.py:827`)。模型按需 `read_file` 取正文。
4. **可用知识库块(条件)**`_resolve_knowledge_block``task_exec.py:861`)经 `_resolve_user_knowledge_bases``:835`)拉取**用户在日常选择器里精确勾选的那几个** org KB + 知识空间 id`session_version.organization_knowledge_ids` + `knowledge_space_ids``linsight_session_version.py:72/75`),**不再用 `org/personal_knowledge_enabled` 两个布尔拉"该类型全部 KB"**(旧逻辑勾 1 个会喂 20+ 个;布尔列仅留存兼容);经 `prepare_knowledge_list``workbench_impl.py:572`)渲成 `- 名称 (knowledge_id: {id})` 行,拼到 `\n# 可用知识库...``task_exec.py:828-831`)。**这是 `search_knowledge_base` 拿到真实 `knowledge_id` 的唯一来源**;且工具白名单 `_resolve_allowed_knowledge_ids``:874`)取**同一组 id**"prompt advertise 的 id"与"工具实际放行的 id"严格一致(C4 隔离)。
> **SOP 注入分支已删除**:原先这里有第 3 项 `if session_model.sop: parts.append("# 执行规范(SOP)...")`,随 2026-06-17 SOP 残留代码清理**整段移除**`_build_agent_input` 不再有 SOP 段(详见 §4.1)。
若 parts 全空则退化为 `session_model.question or ""`
---
## 3. 工具与中间件拓扑
### 3.1 deepagents 默认装配 vs 毕昇调整
| 维度 | deepagents 框架默认 | 毕昇 `create_linsight_agent` |
|---|---|---|
| `write_todos`TodoListMiddleware | 默认装 | **保留**(规划核心) |
| 文件工具 read/write/edit/ls/grepFilesystemMiddleware | 默认装,挂 StateBackend | **保留但覆盖 backend** → 真 `WorkspaceBackend`MinIO)。注:旧自研 local_file 工具集已退役,文件能力统一走框架中间件 |
| `task` 子代理委派(SubAgentMiddleware | 默认装,且不传 `subagents=` 时自动注入 1 个 general-purpose 子代理 | **剥离**`_ToolExclusionMiddleware(excluded={"task"})``agent_factory.py:140`)从模型可见工具集过滤掉 `task` |
| SummarizationMiddleware | 默认装 | **保留内置**(自研 `tool_buffer` 压缩退役,避免与内置重复触发 langchain "duplicate middleware" 断言) |
| SkillsSkillsMiddleware | demo 真生效 | **整体 DISABLED**(运行时零注入,见 §4.2 |
| web_search / think_tool | 框架不强制,由应用层选择 | **不注入**(全仓 grep 零命中) |
| `ask_user`HITL interrupt | 框架无内置 ask-human 工具 | **新增硬注入**`agent_factory.py:156` `tools=[*tools, ask_user]` |
### 3.2 默认工具集真相
agent 最终工具集 = **用户日常勾选 tools** + **`ask_user`(恒硬注入)** + **`search_knowledge_base`(条件注入)**
- **用户日常勾选 tools**`_generate_tools``task_exec.py:551`)在 `session_model.tools` 为空时返回 `[]`,否则转调 `init_linsight_config_tools``workbench_impl.py:904`)。task 模式**不再用 per-app 灵思白名单**,而直接复用 DAILY chat 配置 / 用户日常勾选:`tool_ids = _extract_tool_ids(session_version.tools)``:925`)是真正绑定源,`config_tool_ids`(来自日常配置,`:934`**仅供 code-interpreter 分支使用**。
- **`search_knowledge_base`(条件注入,非恒注入)**`init_linsight_tools``tool/domain/services/tool.py:588`,工具组"知识库和知识空间检索" `:617`)注入 `SearchKnowledgeBase`(类在 `tool/domain/langchain/linsight_knowledge.py:21`),在 fresh/resume/continue 三条路径都 extend。两点关键变化(2026-06-17):
- **只检索知识库/知识空间**,已删除 `search_linsight_file`(milvus 文件语义检索)分支——上传文件改由 agent 直接 `read_file` 读工作区里的解析 markdown`_init_file_directory` 已把解析 markdown 下载进 workspace),白名单也只剩 KB/space id(不含上传文件 id)。
- **条件注入**:当白名单 `allowed_knowledge_ids` 为**空 set**(未选 KB 且无可检索目标)时 `init_linsight_tools` 直接 `return []``tool.py:608-609`),**不注入该工具**——否则模型会看到它并带缺失 `knowledge_id` 误调,报 "Field required"`None`back-compat)才保留工具且不设门控。
- **`ask_user`**`agent_factory.py:156`):调 langgraph `interrupt()` 实现 park-and-resume**唯一恒硬注入**的内置工具。
**code_interpreter 非无条件注入**:需双条件——`need_upload and file_dir``workbench_impl.py:937`,有上传文件)**且** `bisheng_code_tool.id in config_tool_ids``workbench_impl.py:869`,日常配置勾了代码解释器)。
### 3.3 `task` 子代理为何被剥离
注释固化于 `agent_factory.py:129-136`:模型过度委派(单 run 100+ 子代理)且在子代理内调 `ask_user`HITL interrupt 在子图里**冒泡不上来**,澄清触达不了用户、任务跑成 direct-answer fallback。剥离 `task` 强制所有工作(含 ask_user)回到主图,`interrupt()` 在主图才能正确 park。`_ToolExclusionMiddleware` 加在 SubAgentMiddleware 之后,在模型看到前过滤掉 `task` 工具(隐藏入口,不拆基础设施)。
> ⚠️ **勘误(2026-06-17`0924be451`**:禁用方式升级为**双保险**——新增 `_disable_subagent_delegation(model)``agent_factory.py:114` 调用 / `:169` 定义),用 deepagents **官方 harness-profile API** 在 profile 级关掉自动注入的 general-purpose 子代理;`_ToolExclusionMiddleware(excluded={"task"})``:144`**仍保留**作辅。注释(`:176`)明示:单靠 `_ToolExclusionMiddleware` **不可靠**(模型仍会 over-delegate 生出调 0 个工具的 `ls`/`glob` "子代理"、并可能在子代理内调 `ask_user` 致 interrupt 不冒泡),故 profile 级禁用才是"唯一可靠"的关法。即由"中间件过滤模型可见工具"单层,改为"profile 级禁用(主)+ 中间件过滤(辅)"双层。
### 3.4 文件系统:WorkspaceBackendoffload-first
`WorkspaceBackend(FilesystemBackend)``workspace_backend.py:123`),**MinIO 为唯一真理源**key 前缀 `workspace/{svid}/`),local `file_dir` 是 write-through cache。`task_exec._create_agent``task_exec.py:560`backend 实例化在 `:576`)显式注入真 backend,覆盖工厂默认的 `FakeWorkspaceBackend` 测试桩。write/edit/upload 先写 cache 再立即落 MinIOread 走 `_materialize`(先 cache 后 MinIO 回填);ls 以 MinIO 为权威。大文件不进窗口——`prepare_file_list` 只给指针,body 按需 `read_file`
### 3.5 checkpointer 与防腐层
- **`PlainRedisCheckpointer`**`checkpointer.py:59`):纯标准 Redis 命令避开 RediSearchthread_id 即 svid,跨进程 park/resume 用 MULTI/EXEC 原子写 + ZSET 时间索引,TTL 默认 7 天。`make_checkpointer()``:362`)三处注入(fresh/resume/continue)。这是 park/resume 能跨 Worker 进程定位 interrupt checkpoint 的前提。
- **`StreamEventMapper`**`stream_event_mapper.py:111`)= 纯翻译器(硬规则不碰 Redis/MySQL):把 `astream(stream_mode=["updates","messages","values"], subgraphs=True)` 原始 chunk 翻成 `BaseEvent` 家族(`ExecStep/TaskStart/TaskEnd/GenerateSubTask/NeedUserInput`),`__interrupt__``NeedUserInput``:198`),`write_todos` 快照三级 diff→`GenerateSubTask`/`TaskStart`/`TaskEnd`
- **副作用层** `LinsightStateMessageManager` 把事件映射到 10 个冻结 `MessageEventType``push_message`→RPUSH 进事件队列;`MessageStreamHandle.connect``message_stream_handle.py:46`)循环 BLPOP 消费并 `send_json` 推 WebSocket。这是 Worker(生产)↔ WS 网关(消费)的跨进程解耦队列。
---
## 4. SOP / Skills 现状矩阵
### 4.1 SOP——已物理删除,仅余库表
2026-06-17 一连串 commit(移除 `/integrated-execute` + SOP-gen 级联、移除 workbench SOP/feedback 端点、删 `sop_manage.py` 整文件)把 SOP **执行侧代码全部物理删除**。合并后 `generate_sop` / `modify_sop` / `integrated_execute` / `_generate_sop_content` / `feedback_regenerate_sop_task` / `search_sop` 在生产代码均 **0 处定义**`linsight.py``workbench_impl.py` 各净删数百行;`sop_manage.py` 不复存在)。
| 维度 | 标准 task-mode 链路结论 | 锚点 |
|---|---|---|
| 生成(主 agent 主动产 SOP | ❌ 不生成,走 deepagents `write_todos` 现场规划 | `task_exec.py:426`legacy generate_task precall removed 注释) |
| 注入(SOP 反哺主 agent) | ❌ 注入分支**已删除**(原 `if session_model.sop:` 整段移除) | `_build_agent_input``task_exec.py:801`)已无 SOP 段 |
| 编排(SOP 驱动任务拆解) | ❌ 编排改由 deepagents kernelwrite_todos → StreamEventMapper → GenerateSubTask)承担 | `task_exec.py:390` `_execute_workflow` |
| 生成/反馈端点 | ❌ `/workbench/generate-sop``/integrated-execute`、workbench SOP/feedback 端点**均已删除** | 全仓零定义 |
| 库静态资产 | ✅ 仅余 `linsight_sop` / `linsight_sop_record` 库表 + `LinsightSOPDao` + 库 CRUD/评分/showcase 端点;执行链零触点 | `linsight_sop.py`、showcase 端点 `linsight.py:698` |
**结论:SOP 从旧文档的"架空(库静态资产 + 死分支 + 死端点)"进一步演进为"已删除"。** 现仅剩 `linsight_sop` 库表 + 一组库管理/案例 showcase 端点(add/update/list/record/sync/showcase),**运行时执行链对 SOP 彻底无触点**。`session_version.sop` 列虽保留(back-compat)但永不写入、永不读取。若未来要复用 SOP 库内容当 deepagents skill/system-prompt 喂料,需重新接线,且会卡在 Skills 因 workspace filesystem shadow bug 被禁用上——**SOP 复用 = Skills 复活,二者绑定**。
### 4.2 Skills(运行时零注入,DISABLED
- `agent_factory.py:127 middlewares: list = []`,唯一 append 的是 `_ToolExclusionMiddleware`(剥离 `task`)。`TenantSkillsMiddleware` / `make_skills_middleware` 在生产代码**无任何活跃 import / 调用方**(仅自身定义 + 一条 DISABLED 注释 + test 引用)。
- 禁用根因(注释 `agent_factory.py:119-126`):`TenantSkillsMiddleware` 自带 `FilesystemBackend`SKILLS_ROOT, virtual_mode),加在 workspace FilesystemMiddleware 之后会 **shadow** agent 的 write_file/read_file,导致交付物落进 skills store、workspace 为空、产不出结果文档。
- `SkillService` / `SkillStore` 仍活跃,但只服务技能库管理端点(CRUD / from_github / set_status),**不参与 agent 执行链路**。前端 submit payload 虽带 `skills` 字段,但 submit schema 明确"does not consume this field yet",属 forward-compatible 占位,运行时无效。
**结论:Skills 在标准 task-mode 运行时彻底零注入;只剩一套"可管理但不被 agent 消费"的库 + 管理端点。**
---
## 5. deepagents demo 的运行逻辑与上下文工程
> **可信度声明**:`[框架真值-已验证]` = 直读 `.venv/.../deepagents/`v0.6.8)包源码、可在本仓复核;`[demo专属-未在本仓库复验]` = 据旧调研记录的 demo 仓库实现,demo 源码不在本仓库。关键核查:`ORCHESTRATOR_PROMPT` / `Silent Defaults` / `request_clarification` / `emit_research_card` / `research-agent` 在框架包内 **grep 全无匹配**——它们是 demo 在框架之上自写的应用层 prompt / 工具 / UI,不是框架默认能力。
### 5.1 整体运行逻辑
- **单进程单图**demo 用 `create_deep_agent(...)` 装一张 LangGraph 图,agent 图与 HTTP server 同进程,规划+执行在同一次 `astream` 连续完成,无独立 Worker、无 Redis 队列、无两段式。`[demo专属-未在本仓库复验]` 框架侧佐证:`create_deep_agent` 末尾即 `create_agent(...).with_config({recursion_limit: 9999})` 返回可直接 invoke/astream 的编译图(`graph.py:840-862`),框架本身不含进程/队列基础设施。`[框架真值-已验证]`
- **SSE useStream 直连**:前端用 `useStream` 直接订阅 graph 原生 SSE 流,**无后端事件翻译层/防腐层**。`[demo专属-未在本仓库复验]`
- **interrupt + resume 同进程**HITL 在进程内 thread 上靠 `interrupt()` 挂起,`useStream` 同进程 resume`Command(resume=...)`),无毕昇的分布式 park-release / 释放 Worker 槽 / 跨进程 resume。`[demo专属-未在本仓库复验]``interrupt()` 是 LangGraph 原生机制 `[框架真值-已验证]`
### 5.2 上下文工程要点
- **Prompt 工程**`ORCHESTRATOR_PROMPT` 把 6 步流水线写死(Step0/Step1 互斥、Silent Defaults 五字段缺省表、最多 1 轮澄清),使规划跨 run 可复现。`[demo专属-未在本仓库复验]`,框架包内不存在。框架默认会把 `BASE_AGENT_PROMPT`Understand→Act→Verify)拼在调用方 system_prompt 之后(`graph.py:69-92/832-838`),demo 的 6 步是叠加其上的应用层约束。`[框架真值-已验证]`
- **task 子代理并行委派 + 子上下文隔离(demo 核心)**:demo 把 `task` 并行委派当工作流核心,`subagents=[research-agent]` fan-out 并行调研、各子代理隔离上下文里深挖、主图只收综合后单条结果。`[demo专属-未在本仓库复验]``research-agent` 框架包内不存在)。框架真值底座 `[框架真值-已验证]``task` 调子代理时构造全新隔离 state,从父 state 剔除 `messages/todos/structured_response/...``subagents.py:240-264`),子代理 messages 重置为仅一条 `HumanMessage(description)``:538-540`);跑完只把最后一条 AIMessage 文本包成单条 ToolMessage 回传父图(`:494-532`)——这就是子上下文隔离。即便不传 `subagents=`,框架也自动注入 1 个 general-purpose 子代理(`graph.py:687-747`)。
- **文件系统**demo 文件全在 state 内虚拟 FS、随 checkpoint 持久化,不落 MinIO。`[demo专属-未在本仓库复验]`。框架默认 backend 就是 `StateBackend()``filesystem.py:734-735`),文件存于 state `files` 字段(DeltaChannel 增量持久化,`filesystem.py:307-310`)。框架也支持换真实/远程 backend——毕昇正是显式覆盖默认。`[框架真值-已验证]`
- **记忆/压缩**demo 单 turn deep-research,直接吃框架内置 `SummarizationMiddleware` 默认阈值,无自研调参;单 turn 无跨轮记忆。`[demo专属-未在本仓库复验]`。主 agent 栈固定插入 `create_summarization_middleware``graph.py:776-781``[框架真值-已验证]`——这点毕昇与 demo 同源同默认。
- **HITL**demo 有两类——询问型 active`request_clarification` Step0 规划前澄清,框架包内不存在,demo 自写)+ 审批型 dormant`interrupt_on` 工具执行前审批,demo 休眠未用)。`[demo专属-未在本仓库复验]``interrupt_on` 是框架一等参数,接到 `HumanInTheLoopMiddleware``graph.py:808-813``[框架真值-已验证]`
- **Skills 真生效(progressive disclosure**demo 的 SkillsMiddleware 真注入,每次 model call 把 skills 渲染进 system message(仅注入 frontmatter 的 name/description,正文靠模型自己 read_file),受架构不变量测试守护。`[demo专属-未在本仓库复验]`。框架代码实证 `skills.py:883-949` `[框架真值-已验证]`。这与毕昇"Skills 整体 DISABLED"形成对比。
- **generative UI**demo 用 `emit_research_card` 推 title/summary 卡片到前端。`[demo专属-未在本仓库复验]`(框架包内不存在);框架允许自定义 `state_schema`(须 `DeepAgentState` 子类)`[框架真值-已验证]`
### 5.3 一句话基线
deepagents demo = **框架默认形态的最小可运行参考**:单进程单图、useStream SSE 直连、同进程 interrupt/resume;上下文工程上**采用框架默认**(state 内虚拟 FS、内置 Summarization、task 子上下文隔离、Skills progressive disclosure 真生效、interrupt_on 审批现成但 dormant),并叠加应用层 prompt/工具(6 步 ORCHESTRATOR_PROMPT + Silent Defaults + request_clarification 澄清 + research-agent 并行委派 + emit_research_card UI)。其最强项是**上下文纯净度与经济性**,代价是不解决排队/并发/断线/规划可审。
---
## 6. 差异对比(context engineering,从效果角度)
### 6.1 主对照表
| 维度 | deepagents demo | 毕昇灵思任务模式 | 效果差异 |
|---|---|---|---|
| 执行框架 | 单进程单图,规划+执行一次 astream | 分布式多进程 Worker + Redis 队列 + 两段式(async_run/resume/continue | 毕昇解决排队/并发/断线/跨进程恢复 |
| Prompt 工程 | 6 步 ORCHESTRATOR_PROMPT 写死(应用层)`[demo专属]` | 静态 `LINSIGHT_SYSTEM_PROMPT_ZH` + 运行期动态首条 user message(前情/问题/文件指针/知识库 id) | demo 规划确定性强;毕昇上下文按会话动态装配 |
| 工具供给 | task 委派 + demo 自定义 web 工具 `[demo专属]` | 用户日常勾选 + ask_user(恒) + search_knowledge_base(条件,仅 KB/space);无 web_search/think_tool | 毕昇按租户/用户资源约束,无内置搜索 |
| 子代理 / 子上下文隔离 | `task` fan-out 并行 + 隔离 state(核心)| `task` **剥离**,全工作回主图单上下文 | 毕昇牺牲并行/隔离换 HITL 在主图正确 park |
| HITL | request_clarification(询问型 active+ interrupt_on(审批型 dormant`[demo专属]` | `ask_user``interrupt()` 主图 park + 跨进程 resume`Command(resume)` 同 thread_id | 毕昇 HITL 工业化(park 不占槽、LPUSH 头插优先恢复) |
| 文件系统 / offload | state 内虚拟 FS 随 checkpoint(框架默认) | `WorkspaceBackend`MinIO 真理源)+ offload-first 指针块 | 毕昇交付物落对 workspace,每写打 MinIO |
| 记忆 / 压缩 | 内置 Summarization(默认阈值),单 turn 无跨轮 | 内置 Summarization + `build_prior_conversation_summary` 跨轮截断前情 | 毕昇额外有跨轮记忆 |
| 流式 / 防腐 | useStream 直订 graph SSE,无翻译层 | astream→StreamEventMapper→Redis 事件队列→WebSocket 三跳 | 毕昇防腐层隔离模型 chunk 与前端协议,可落库重放 |
| Skills | progressive disclosure 真生效 | 整体 DISABLED(运行时零注入) | 毕昇牺牲热插拔领域知识换 workspace 不被 shadow |
### 6.2 分维度要点
- **prompt 工程**demo 把规划逻辑前置进静态 prompt(跨 run 可复现);毕昇把规划交给 deepagents kernel,但用**运行期动态 user message** 注入会话级上下文(跨轮前情 + 文件指针 + 真实 knowledge_id),上下文随每次提交装配而非写死。
- **工具供给**:demo 倾向丰富内置/自定义工具;毕昇收口到"用户已有权限的日常勾选 + 知识检索(仅 KB/知识空间)+ HITL",刻意不给 web_search/think_tool;连知识检索工具也改成"无可检索目标就不注入",把工具表面积压到最小。
- **subagent 与子上下文隔离**:这是最尖锐对立。demo 靠 `task` 隔离子上下文换纯净度与并行;毕昇为保 `ask_user` interrupt 一定在主图 park**主动剥离 `task`**,放弃并行与子上下文隔离。
- **HITL**demo 的 active HITL 是规划前一次性澄清(`request_clarification`);毕昇是执行中任意点可 park 的 `ask_user`,且 park 后通过 LPUSH 头插 + 跨进程 resume 工业化恢复,park 期间不占并发槽。
- **文件系统与 offload**demo 文件随 checkpointpark/resume 无损但撑大 checkpoint);毕昇 offload-first——大文件不进窗口、只给 `<uploaded_files>` 指针,正文按需 `read_file`,交付物落 MinIO 真理源。
- **记忆与压缩**:turn 内压缩两边同源(内置 SummarizationMiddleware);跨轮上下文是毕昇独有(确定性 head+tail 截断回顾)。
- **流式与防腐**demo 前端直订 graph SSE(少一跳但耦合模型 chunk 格式);毕昇插 StreamEventMapper 防腐层,把模型 chunk 翻成 10 个冻结事件类型再经 Redis 队列 → WebSocket,可落库、可断线重连、前端协议与模型解耦。
- **执行框架**:demo 单进程单图(最小部署);毕昇分布式多进程 + 信号量并发控制 + Redis checkpointer,把"一个会话的规划→执行→park→resume→多轮 continue"做成可在 Worker 集群上调度的工业链路。
---
## 7. 效果影响与取舍:"削能力换正确性"
毕昇相对 deepagents demo 的几处"减法"都是**用框架能力换交付正确性/工程稳定性**:
- **剥离 `task` 子代理**:牺牲并行 fan-out 与子上下文隔离(纯净度/经济性),换 `ask_user``interrupt()` 一定在主图 park 稳定触达用户——否则子图内 interrupt 冒泡不上来,澄清丢失、任务跑成 direct-answer fallback。
- **禁用 Skills**:牺牲热插拔领域知识(progressive disclosure),换交付物正确落进 workspace——否则 skills 自带的 FilesystemBackend 会 shadow agent 的 write_file/read_file,产物落进 skills store、workspace 为空、产不出结果文档。
- **删除 SOP 子系统**:牺牲"历史最佳实践前置编排",换链路简单与一致性——SOP 编排已被 deepagents `write_todos` 现场规划完全取代,残留代码反成维护负担,故 2026-06-17 整体物理删除,仅留库表待未来以 skill 形式重新接回。
- **知识检索收窄 + 条件注入**:牺牲"文件也能语义检索"与"工具恒在",换正确性——文件改 `read_file` 直读避免双路径歧义,无目标就不注入避免模型带空 id 误调。
- **offload-firstWorkspaceBackend + 指针块)**:牺牲"每写都打 MinIO"的 I/O 成本与"文件不随 checkpoint",换上下文窗口不被文件正文撑爆、大文件按需读、交付物有持久真理源。
- **防腐层 + 分布式队列**:牺牲单进程的简洁与"少一跳",换排队/并发/断线/跨进程恢复/可审计落库的工业能力。
净效果:灵思任务模式不是"deepagents 的功能超集",而是**为企业级交付正确性做了定向取舍的框架特化**——demo 强在上下文纯净度与规划确定性,灵思强在 HITL 工业化、交付物落地与分布式可调度。
---
## 8. 附录
### 8.1 关键代码锚点表(CURRENTHEAD=`77beaa6f0`
| 主题 | 锚点(file:line |
|---|---|
| 前端入口守卫/渲染 | `components/Sop/index.tsx:45`(守卫)/ `:55`TaskModeChatInput 渲染) |
| 前端 submit SSE 入口 | `hooks/useLinsightManager.tsx:257`convertTools `:393` |
| 前端 title_generate→startLinsight(无 SOP 步骤) | `useLinsightManager.tsx:335-338` |
| 前端 generate-sop 已移除墓碑注释 | `useLinsightManager.tsx:373-374` |
| 前端 ExecutionFlowWS/澄清/续问) | `ExecutionFlow.tsx:68`useLinsightWebSocket/ `:112`handleClarifySubmit/ `:161`PlanningRow/ `:221`TaskModeInput/ `:235`onFollowUp |
| 后端 submit 端点 | `linsight.py:158` |
| sop 字段默认 None(仅留存兼容) | `linsight_session_version.py:84` |
| 精确 KB id 列 | `linsight_session_version.py:72`organization_knowledge_ids/ `:75`knowledge_space_ids |
| start-execute 入队裸 svid(已无 SOP 写入) | `linsight.py:215`(端点)/ `:251`queue.put |
| user-input session-level 分支 | `linsight.py:321`(端点)/ `:353-357`(伪任务注释)/ `:358`session_level/ `:361`already_completed/ `:363-368`(幂等)/ `:371`set_user_input/ `:380,385`put_head 门控/入队) |
| set_user_input 抛 500 | `state_message_manager.py:265`not-found ValueError/ `:294`(包成 ServerError |
| continue 端点 | `linsight.py:264`(端点)/ `:296-299`(翻 IN_PROGRESS/ `:307`queue.put |
| WS 订阅端点 | `linsight.py:534` |
| SOP showcase 端点(库静态资产唯一残存执行入口) | `linsight.py:698` |
| 队列 RPUSH/BLPOP/LPUSH | `redis_conn.py:425/392/378` |
| Worker 三分派 / park 归还槽 | `worker.py:299-310`done_callback `:316`release `:236` |
| 执行器 async_run / _execute_workflow | `task_exec.py:130` / `:390` |
| generate_task 移除(write_todos 现场规划) | `task_exec.py:426` |
| _build_agent_input(上下文装配,无 SOP 段) | `task_exec.py:801`history_summary 拼接 `:817-818`(调用点 `:716`);question `:819-820`;文件块 `:827`;知识库块 `:828-831` |
| 精确 KB 解析 / 白名单 | `task_exec.py:835`_resolve_user_knowledge_bases/ `:861`_resolve_knowledge_block/ `:874`_resolve_allowed_knowledge_ids |
| _generate_tools | `task_exec.py:551` |
| _create_agent 注入真 WorkspaceBackend | `task_exec.py:560`def/ `:576`backend 注入) |
| create_linsight_agent / tools=[*tools, ask_user] | `agent_factory.py:81`def/ `:156`create_deep_agent tools= |
| system_prompt / ask_user 工具体 | `agent_factory.py:37` / `:51` |
| Skills DISABLED 注释 / middlewares=[] / task 剥离 | `agent_factory.py:119-126` / `:127` / `:140` |
| 工具装配 init_linsight_config_tools | `workbench_impl.py:904`tool_ids 绑定源 `:925`config_tool_ids `:934` |
| code_interpreter 双条件 | `workbench_impl.py:937`need_upload and file_dir+ `:869`id in config_tool_ids |
| search_knowledge_base 注入(条件 + KB/space | `tool/domain/services/tool.py:588`init/ `:608-609`(空白名单 return []/ `:617`(工具组);类 `tool/domain/langchain/linsight_knowledge.py:21` |
| prepare_file_list / prepare_knowledge_list | `workbench_impl.py:534` / `:572` |
| WorkspaceBackend | `workspace_backend.py:123` |
| PlainRedisCheckpointer / make_checkpointer | `checkpointer.py:59` / `:362` |
| StreamEventMapperinterrupt→NeedUserInput | `stream_event_mapper.py:111` / `:198` |
| MessageStreamHandle.connect | `message_stream_handle.py:46` |
### 8.2 legacy 模块 liveness 矩阵(`bisheng_langchain/linsight/`
> SOP 子系统 2026-06-17 物理删除后,`workbench_impl` 已不再 import `bisheng_langchain.linsight``LinsightAgent` / `ExecConfig` 引用全消失)。整条 `LinsightAgent → manage → react_task` 链**失去最后一条活跃 import 边,彻底死亡**。现仅余 `event.py` 数据类 + `const.TaskStatus` 作为防腐层基石仍在用。
| 模块/符号 | 状态 | 证据 |
|---|---|---|
| `event.py`BaseEvent/ExecStep/GenerateSubTask/NeedUserInput/TaskStart/TaskEnd | **在用(核心)** | `task_exec.py:42` / `stream_event_mapper.py:32` / `state_message_manager.py:19` / `linsight_schema.py:3` 防腐层基石 |
| `const.TaskStatus` | **在用** | `task_exec.py:41` |
| `const.ExecConfig` | **死**SOP 删除后 workbench_impl 不再 import | 全仓无活跃 import |
| `agent.py LinsightAgent`(含 generate_sop/feedback_sop/ainvoke/continue_task/generate_task 全部方法) | **死** | 原唯一活跃入口(workbench_impl SOP 旁路)已随 SOP 删除一并移除,仅 agent_test / POC 脚本引用 |
| `manage.py TaskManage` | **死** | 仅 `agent.py:14` 顶层 import;执行入口运行期不触达 |
| `task.py Task / BaseTask` | **死** | 仅 manage/react_task 内部互引 |
| `react_task.py ReactTask` / `react_prompt.py` | **死** | 承载它的传递性 import 边(经 LinsightAgent)随 SOP 删除断裂,无任何活跃调用方 |
| `prompt.py` SOP 三件套(SopPrompt/FeedBackSopPrompt/GenerateTaskPrompt | **死** | 原仅 SOP 旁路用,SOP 删除后无活跃引用 |
| `utils.format_size` | **在用(散点)** | `local_file.py:9`(与 SOP 链无关) |
> 关键修正:上一版文档(基于 SOP 删除前的 HEAD)把 `LinsightAgent`/`ExecConfig`/`prompt.py` 标为"SOP 旁路在用"。SOP 子系统物理删除后,这些已全部转为 **死代码**——`bisheng_langchain/linsight/` 现在只有 `event.py` + `const.TaskStatus` + `utils.format_size` 三处还有活跃引用。
### 8.3 相比旧两篇调研文档纠正的关键误判
- **system_prompt ≠ 主 agent 全部上下文**:真实任务上下文在运行期首条 user messagehistory_summary + question + 文件指针 + 知识库 id),只读 `LINSIGHT_SYSTEM_PROMPT_ZH` 会严重漏判。
- **SOP 已物理删除(不止"架空"**submit→start-execute 全程无 SOP 生成;`_build_agent_input` 的 SOP 注入分支、`generate_sop`/`search_sop`/`sop_manage.py`、generate-sop/integrated-execute 端点**全部已删**,仅余 `linsight_sop` 库表 + 管理/showcase 端点作静态资产,执行链零触点。
- **知识检索收窄 + 精确注入 + 条件注入**:`search_knowledge_base` 现**只检索知识库/知识空间**(删了文件检索,文件改 `read_file` 直读);KB 注入改成**用户精确勾选的那几个 id**(不再按类型全量);白名单为空时**不注入该工具**——故"唯一恒硬注入"实为 `ask_user``search_knowledge_base` 是条件注入。
- **Skills 运行时零注入**`agent_factory.py:127 middlewares=[]` 仅含剥离 task 的中间件,`TenantSkillsMiddleware` 生产代码零 import,因 workspace filesystem shadow bug 整体 DISABLED——旧文档若称 Skills 在运行链路生效为误。
- **`task` 子代理已剥离**`_ToolExclusionMiddleware(excluded={"task"})` 从模型可见工具集过滤掉 `task`,毕昇放弃了 demo 引以为核心的子代理并行委派与子上下文隔离。
- **默认无 web_search / think_tool**:全仓 grep 零命中;旧自研 local_file 工具集也已退役(文件能力统一走 deepagents FilesystemMiddleware)。
- **自研 tool_buffer 压缩已退役**:改用 deepagents 内置 SummarizationMiddleware(避免 langchain "duplicate middleware" 断言),这点毕昇与 demo 同源同默认。
---
> **维护提示**:本文锚点基于 HEAD=`77beaa6f0`。task-mode 子系统近期改动频繁(2026-06-17 当天即多次重构),若 `task_exec.py` / `linsight.py` / `workbench_impl.py` 再有提交,行号会整体漂移——**以函数名/符号定位为准,行号为辅**。判断任何逻辑"是否在用"务必 grep 调用方 + 追条件守卫,勿据旧行号或历史叙述直接采信。
@@ -1,141 +0,0 @@
# 灵思任务模式 write_file 死循环(recursion_limit=200)根因分析与优化方案
> 技术方案(先文档后代码的对齐产物)。范围:核心三层 L2+L3+L4(不改 system prompt、不动 max_tokens)。
## Context / 背景
客户环境(COFCO,模型 Qwen3.6-35B-A3BMoE 3B 激活)任务:"挖掘二级市场投资机会,形成报告"。
- 澄清问答 + 10~11 次知识库检索(~92s)正常,拿到大量券商研报。
- 进入写报告环节:模型调用 `write_file` 时参数缺 `content` → 报错 `content: Field required` → 反复重试(在"空参 `{}`" 和 "只有 `file_path`" 之间横跳)**62 次** → 撞 LangGraph `recursion_limit=200` → 任务失败,前端只显示通用"任务执行失败"卡片。耗时 **1133s~19 分钟)**
现场初判:①模型能力弱(填不进大文本参数)②框架缺同工具连续失败熔断。**核实后修正为:根因是"content 是巨大的工具参数被截断/解析丢弃",模型能力只是诱因之一,框架侧有三个叠加缺口。**
---
## 根因分析(已核实到源码行)
### A. 现象的机理链("为什么报的是 `content: Field required`"
1. 灵思用 `agent.astream(..., stream_mode=["updates","messages","values"])` 驱动图(`linsight/domain/task_exec.py:804`)。`stream_mode="messages"` 挂上 `StreamMessagesHandler``langgraph/pregel/_messages.py:49`,是 `_StreamingCallbackHandler`)→ agent 节点里的 `model.ainvoke` **内部走流式**,tool-call 参数逐块拼接后用 **`parse_partial_json`(容错 JSON 解析)** 解出(`langchain_core/messages/ai.py:541`)。
2. `parse_partial_json` 对**被截断的参数串**有决定性行为(`langchain_core/utils/json.py:113-131`):
- 截断落在 **content 值开始之前**`{"file_path":"x.md"` / `{"file_path":"x.md",` / `{"file_path":"x.md","content"`)→ 回溯**丢弃**残缺的 content 键 → 解出 `{"file_path":"x.md"}` 甚至 `{}`**content 键整个消失**
- 截断落在 **content 值已吐出部分字节**`{...,"content":"半截`)→ 自动**补全**成 `"半截"` → 校验通过,写出**截断短文件**(不报此错)。
3. content 缺失的 args dict 被当作**合法 tool_call**(不是 invalid_tool_call)派发给 ToolNode,撞 `WriteFileSchema``deepagents/middleware/filesystem.py:334-338``content` 必填、无默认)→ 抛 `content: Field required``langgraph/prebuilt/tool_node.py:956-966`),包成 error `ToolMessage` 回喂模型 → 模型重试 → 循环。
> **∴ `content: Field required` 是"截断落在任何 content 字节到达之前"的正向证据**——不是"模型不知道要传 content",而是"content 还没来得及吐 / 残缺 JSON 被解析器丢了"。
### B. 为什么偏偏这个大参数出事(三个放大因素,均已核实)
- **无 max_tokens 兜底**:灵思链路从不传 `max_tokens`,只有管理员在模型 DB `config` JSON 手填才有;无代码级默认(`llm.py:281-282` 是唯一入口;`agent_factory.py:748` 只传 temperature)。输出预算实际由推理服务端 context 窗口(`max_model_len`)兜底。
- **上下文被检索结果挤占**:11 次券商研报检索灌进上下文,留给"吐一篇大报告正文"的输出空间被大幅压缩。
- **reasoning 再吃预算**Qwen3 是 reasoning 模型,正式吐 tool call 前先吐大段 `reasoning_content``chat_openai_reasoning.py:70-95` 仅塞进 `additional_kwargs`),进一步逼近 `finish_reason=length`
### C. 三个框架缺口(把一次截断放大成 19 分钟空转)
1. **无 `finish_reason=="length"` 检测**:截断响应是正常 HTTP 200、不抛异常;容错中间件 `resilience_middleware.py` 只分类**异常**,看不到它 → 直接掉进 pydantic 校验循环。
2. **无同工具连续失败熔断**`linsight/` `tool/` 全无 consecutive/circuit/retry-cap 守卫;唯一兜底是 `recursion_limit=max_steps=200` → 62 次 ×~18s ≈ 1133s。
3. **失败态是通用卡片**`GraphRecursionError` 无专门捕获,`classify_for_event``ErrorType.UNKNOWN` → 前端渲染通用"任务执行失败"。
### 结论
根子在 **content 是一个巨大的工具参数**,在被检索结果 + reasoning 挤占的上下文里被截断/解析丢弃;叠加"无 length 检测 + 无熔断 + 通用失败卡片"三个框架缺口,放大成 ~19 分钟空转。**不是"模型能力弱到数不清 2 个参数"**。现象里偶发的 `{}`(完全空参)略偏"模型畸形调用"一支,故最可能是**两支混合、以 content 过大/截断为主**——两支的修法一致。
### 待客户日志坐实项(区分主因、用于调参,非阻塞)
1. 失败调用的 `finish_reason` 是否 `length`
2. 模型吐出的**原始 `arguments` 串**(是 `{"file_path":"...",` 被切断,还是干净的 `{}`);
3. 当时 **prompt token 数 vs 模型 context 窗口**
4. 服务端 `max_tokens` / `max_model_len` 配置。
---
## 优化方案(三层,按对本根因的针对性排序)
> 设计取向沿用灵思既有容错风格:厂商无关、复用现有中间件装配点(每图一实例:主图 + researcher 子代理各一)、软约束缓解 + 硬兜底保底。
### Layer 2 · 截断即时检测与纠偏(model-call 层,最佳靶向)
`LinsightModelResilienceMiddleware.awrap_model_call` 拿到模型返回后,检测 `finish_reason == "length"` 且带**参数缺失/被截断的 tool_call**
- 注入针对性纠偏("上次输出因过长被截断,请把文档拆成多个较小部分分次写入;或缩短单次 content")→ **有界重试(默认 ≤2 次)**
- 重试仍截断 → 落 L3/L4 兜底(走抢救路径)。
- 唯一在**故障发生的那一刻**就拦住的修法,避免掉进 pydantic 循环。行为分桶仍纯用 `finish_reason`/消息形状,不碰厂商码。
### Layer 3 · 同工具连续失败熔断(tool-call 层,兜底)· 优雅终止 + 抢救中间产物
新增 `LinsightToolLoopBreakerMiddleware``AgentMiddleware`),复用 `agent_factory.py:459 _empty_retry_count` 的消息遍历范式:
- **计数**:走 `state["messages"]` 尾部,统计"连续、同一工具名、`status=="error"`"的 ToolMessage 段(一次成功/新 human 轮重置)。
- **soft 阈值**(默认 3):`awrap_tool_call` 给返回的 error ToolMessage **追加强化纠偏提示**(write_file:内容可能过长被截断→分段写;务必把完整文本放进 content)。
- **hard 阈值**(默认 8):**优雅终止 + 抢救**:
1.`state["messages"]` 抢救:模型**分析结论**AIMessage 文本)+ **检索到的知识**`search_knowledge_base` 结果 ToolMessage,精简)。
2. 组装 = 道歉前言 + 抢救内容。
3. 作为**任务结果返回给用户**(COMPLETED,非红色报错)。
- propagation`aafter_model` 命中 hard 抛携带 `partial_result``LinsightToolLoopError`(内置 `ToolCallLimitMiddleware` 的成熟范式,能干净冒泡出 astream)。外层 `task_exec._handle_task_partial``partial_result` 渲染为可读结果(镜像 `_handle_direct_answer_completion`)。
- 每图一实例,主图 + researcher 子代理都挂。
- 效果:62 次/1133s → hard 阈值内(数秒~1~2 分钟),且用户拿到有意义的中间产出。
### Layer 4 · 超限/终止的有意义收尾(友好态 + 抢救)
- `GraphRecursionError`(其它原因触顶)与 L3 终止异常 → 同样走 `_handle_task_partial`GraphRecursionError 无 `partial_result`,退化用 `_last_assistant_text`)。
- 确无可抢救内容 → 友好分类失败(`llm_error_classifier``ErrorType.TASK_ABORTED` + 友好中文 error_message),不再是裸 recursion 文本 / UNKNOWN 通用卡片。
---
## 涉及文件与复用点
| 层 | 文件 | 动作 |
|----|------|------|
| L3 | `linsight/domain/services/tool_loop_middleware.py`(新增) | `LinsightToolLoopBreakerMiddleware` + `build_*` + `LinsightToolLoopError(partial_result=...)` + 抢救组装;复用 `agent_factory.py:459` 遍历范式 |
| L3 | `linsight/domain/services/agent_factory.py` | 主图(~640)+ 子代理(~702)装配新中间件 |
| L3/L4 | `linsight/domain/task_exec.py` | `__init__` 加抢救字段;`_execute_agent_tasks` 捕获 `LinsightToolLoopError`/`GraphRecursionError``_handle_task_completion` 加分支;新增 `_handle_task_partial` |
| L4 | `common/services/llm_error_classifier.py` | `ErrorType.TASK_ABORTED` + `label_error` 识别 `GraphRecursionError`/`LinsightToolLoopError` |
| L2 | `linsight/domain/services/resilience_middleware.py` | `awrap_model_call``finish_reason=length`+缺参 tool_call 检测 → 纠偏 + 有界重试 |
| 配置 | `core/config/settings.py:386 LinsightConf` + `initdb_config.yaml` | `tool_failure_soft_limit=3` / `tool_failure_hard_limit=8` / `truncation_retry_limit=2`(可配,复用 `get_linsight_conf` |
| 测试 | `test/linsight/test_tool_loop_middleware.py`(新增) | soft 追加提示 / hard 抛携带 partial_result 的终止 / 成功重置 / 非错误与 interrupt 透传 / 抢救组装 |
---
## 决策记录
- **范围**:核心三层 L2+L3+L4,不改 prompt、不动 max_tokens。
- **L2 截断动作**:纠偏 + 有界重试(≤2),仍截断落 L3/L4。
- **L3/L4 收尾**:优雅终止 + 抢救中间产物(道歉前言 + 分析结论 + 检索知识精简);正常结果渲染(非红色卡片);无可抢救则友好分类失败。
- **阈值**L2 重试 ≤2、L3 soft=3 / hard=8,均可配 `LinsightConf`
---
## 实施顺序(编码波次)
1. **Wave 1 · L3 熔断 + 抢救 + L4 收尾**(止血核心)。
2. **Wave 2 · L2 截断即时检测**(源头减少触发)。
3. **Wave 3 · 验证**:单测 → 本地端到端 → 客户日志坐实项回填、微调阈值。
> 波次可独立上线:Wave 1 先止血,Wave 2 再从源头减少触发。
---
## 验证方法
1. **单测**`test/linsight/``asyncio_mode=auto`):
- L3:连续 `write_file` error ToolMessage 序列 → soft 追加提示、hard 抛终止(携带 partial_result)、一次成功后重置、非错误 ToolMessage 与 `ask_user` interrupt 透传不受影响;抢救组装。
- L2mock `finish_reason=length` + 缺参 tool_call → 命中截断分支并按动作处理。
- L4`GraphRecursionError`/`LinsightToolLoopError` 映射到 `TASK_ABORTED` 而非 UNKNOWN。
2. **端到端**(本地起前后端 + 连 test 环境中间件):确定性逼出(工具必然缺参失败 / mock 截断)→ 任务在 hard 阈值内数秒终止、**抢救内容作为正常结果渲染**(道歉前言、非红色报错),而非 200 步/1133s。
3. **客户侧确认**:按"待坐实项"捞日志,验证 finish_reason/arguments 与假设一致,据此微调阈值。
---
## 实现与验证记录(2026-07-04
已落地(Wave 1 + Wave 2):
- **L3** `tool_loop_middleware.py`(新增):`awrap_tool_call` soft 提示 + `aafter_model` hard 抛 `LinsightToolLoopError(partial_result=...)`;两个防误杀守卫(模型改出纯文本 / 切换工具则不熔断);抢救组装(分析结论 + 检索知识精简)。装配到主图 + researcher 子代理。
- **L2** `resilience_middleware.awrap_model_call/wrap_model_call` 重构为 while 双预算循环(异常重试 vs 截断重试互不挤占):`finish_reason∈{length,max_tokens,...}` + 带 tool_call → 注入"分段写"纠偏并有界重试(默认 ≤2),仍截断则交 L3/L4。
- **L4** `llm_error_classifier``ErrorType.TASK_ABORTED` + `_is_task_aborted`(识别 `GraphRecursionError``LinsightToolLoopError`,后者按类名匹配避免 common→linsight 反向依赖)。
- **抢救渲染** `task_exec._handle_task_partial`(镜像 `_handle_direct_answer_completion`):COMPLETED + 道歉前言 + 抢救正文 + 合成/收集产物文件 + `FINAL_RESULT` 事件(正常渲染,`output_result.partial=True` 标记)。无可抢救时降级 `_handle_task_failure`TASK_ABORTED 友好卡片)。
- **三条驱动路径统一**`_stash_partial_abort` 助手被 fresh(`_execute_agent_tasks`)/resume(`_drive_resume`)/continue(`_drive_continue`) 三处 astream 循环共用 → 任一路径的 tool 循环/超限都走抢救。
- **配置** `LinsightConf``tool_failure_soft_limit=3` / `tool_failure_hard_limit=8` / `truncation_retry_limit=2`(可配)。
验证:
- 单测 **72 通过**L3 15 + L2 9 + 既有 resilience 8 + classifier 39 + 1 集成);生产文件 ruff 干净。
- **集成测试**`create_agent` + 假模型循环调用缺参工具)证实 `aafter_model` 抛异常能干净冒泡出真实 `ainvoke`、在 recursion_limit 之前熔断——本设计的承重假设成立。
- 待做:连 test 环境的完整前后端 E2E(真实弱模型/大报告,观察抢救卡片渲染与降频)+ 客户日志坐实项回填。
实现教训:`ruff --fix`PostToolUse hook)会删「当下未被引用」的 import——先加 import 后加用法会被静默删掉,导致运行期 NameError(本次踩了 4 次:GraphRecursionError×2、两个中间件 import)。规则:**先写用法、再补 import**。
-29
View File
@@ -1,29 +0,0 @@
# BiSheng 文档导航
> 找文档从这里开始。原则:**规范看根目录、架构看 `architecture/`、功能文档看 `../features/`**。
## 规范(开发前必读)
| 文档 | 内容 |
|------|------|
| [`constitution.md`](constitution.md) | 架构铁律 C1–C7(双 DB / 多租户 / 权限 / 分层 / 错误码 / 安全)——不可违反 |
| [`SDD-Guide.md`](SDD-Guide.md) | 开发流程总纲:流程分级、★ 暂停点、偏差处理、测试分层、harness 现状 |
| `../AGENTS.md` | 全局 agent 规则入口(各子项目规则按目录自动加载) |
## 架构(子系统深度)
[`architecture/`](architecture/) — 13 篇:总览 / 后端模块 / 工作流引擎 / RAG / Linsight / 双前端 / 数据模型 / 部署 / 开发指南 / 权限 ReBAC / 商业网关 / 多租户 / 游标分页 + 数据库表结构。子系统架构的唯一正文。
## 功能文档(SDD 产出)
[`../features/`](../features/) — 按 `v{X.Y.Z}/{NNN}-{name}/` 组织,每个功能含 `spec.md` / `design.md` / `tasks.md`(及按需 `testcases.md`,如 F025)。模板见 [`../features/_templates/`](../features/_templates/)。
## 接口 · 测试 · 部署 · 科普
| 目录 | 内容 |
|------|------|
| [`api/`](api/) | 接口文档(filelib 纯检索、知识空间/知识库接口) |
| [`PRD/`](PRD/) | 现行迭代的产品 PRD 与技术方案(按 `{版本} {主题} PRD/` 组织) |
| [`observability/`](observability/) | BS_METRIC 指标日志契约(监控团队解析依据) |
| [`私有化部署/`](私有化部署/) | 部署文档 |
| [`blog/`](blog/) | 设计科普(如"企业权限体系的前世今生") |
-143
View File
@@ -1,143 +0,0 @@
# SDD Guide — Spec-Driven Development Workflow
> The full spec for how features get built in BiSheng. Root `AGENTS.md §6` is the quick-reference flow; **this file is the authoritative detail and the single source for the workflow**.
> Architecture laws every feature must obey → `docs/constitution.md`. Document templates → `features/_templates/`.
>
> **Status convention**: items marked **✅** are live today; **🚧** are planned and **must not be assumed active**. Full status list → §8.
---
## 0. Background & rationale
This workflow replaced an earlier "scattered / duplicated / mixed" doc setup. Three moves:
1. **One source per fact** — laws in `constitution.md`, architecture in `docs/architecture/`, coding conventions in each `AGENTS.md`. No fact has two homes.
2. **Load on demand** — sub-project rules auto-load by directory (editing backend loads `src/backend/AGENTS.md`, etc.), so context isn't flooded.
3. **A harness** — so the agent runs long stretches unattended (self-checks, self-fixes), and the human stays on key decisions + final judgement.
**Key lesson (why the ✅/🚧 split exists):** an "architecture guard" everyone assumed was running had in fact been **silently dead for months** (non-existent hook variable + a hardcoded path to someone else's machine). It was caught only by deliberately triggering a violation and checking. → **Never trust "should be running"; verify "is running."** Every harness piece below is therefore marked live or planned, and planned ones are not to be relied on.
---
## 1. Pick the track (流程分级) ✅
Not every change runs the full pipeline. Match track to scope:
| Track | When | Steps |
|---|---|---|
| **Hotfix / trivial** | bug fix, copy/text change, dep bump, ≲ 1 file of logic, **no contract change** | branch → fix → arch-guard + smoke pass → `/code-review` → merge. No spec/design/tasks. |
| **Small feature** | single module, no cross-feature contract, low uncertainty | lightweight `spec.md` (AC list) → implement → `/e2e-test` → review. Decisions/gotchas: record in the PR, or a short `design.md` if non-trivial. |
| **Full SDD** | new capability, cross-module, new/changed contract, or touches a constitution clause | full pipeline (§2) |
**Rule of thumb**: if you'd change a **contract others depend on**, or **revisit a constitution law (C1C7)**, it's Full SDD. When unsure, ask the user which track.
---
## 2. Full pipeline
```
0. (version's first feature only) release-contract.md + read docs/constitution.md
1. Spec Discovery (agent explores code, asks one key decision at a time) → ★ user confirms
2. spec.md (What only; no tech stack; [待澄清] markers; EARS-style testable AC 🚧)
→ /sdd-review <dir> spec → ★ user confirms
3. design.md (only this feature's How; global laws → reference constitution)
→ /sdd-review <dir> design (Constitution Check gate, §5 🚧) → ★ user confirms
4. tasks.md (vertical slices; dependency waves 🚧) → /sdd-review <dir> tasks
5. branch: feat/<version>/{NNN}-{name} (create EARLY — docs + code live on the branch)
6. implement → /task-review <dir> <id> → check off
— harness auto-catches violations & runs smoke (§6); deviations handled per §4
7. /e2e-test <dir> (mandatory; frontend = Playwright 🚧)
8. /code-review --base <main> (+ CI auto-review 🚧)
9. merge
```
---
## 3. Pause points (★) — human-in-the-loop ✅
★ = a **mandatory stop; the agent cannot skip it**. Key decisions can't be made by the machine alone.
Three fixed ★: after Spec Discovery, after `spec.md`, after `design.md`.
**Fourth, dynamic ★ — deviation re-confirmation:** during implementation, if a deviation **overturns something the user already signed off on** (a spec AC, or a design decision), **stop and re-confirm** — don't silently rewrite an agreed decision.
- Overturns a signed-off decision → **stop, re-confirm** (humanAI alignment is broken; re-align).
- Minor implementation detail only → just update `design.md` + note it, no stop.
> Note: the fourth ★ currently relies on agent/human discipline — there's no tooling enforcing it yet (🚧).
(`tasks.md` has no ★ — it's an execution checklist; `/sdd-review tasks` is enough.)
---
## 4. Document roles & the design philosophy ✅
| Document | Answers | Update rule |
|----------|---------|-------------|
| `spec.md` | What & acceptance criteria | Only when requirements change |
| `design.md` | Why this How + today's-state snapshot | **Overwrite in place** — always reflects today |
| `tasks.md` | What was done, in what order | Append — running log |
**The design.md philosophy (important):**
`design.md` keeps **only today's state** — overwrite it, never keep old-design snapshots. The next person reads the latest and nothing older. **BUT** for every key decision it must record:
- **why this option, not the rejected alternatives** (and *when to reconsider*), and
- **known gotchas** — "tried A, breaks on X, so B" (the §5「已知坑」 section).
This is **not** history-keeping — it's a **guardrail so the next person doesn't re-walk a proven dead end.**
> Example (F028): PDF engine switched libreoffice → chromium. If design kept only "uses chromium", someone would later ask "why not the simpler libreoffice?" and revert — back into the table-layout breakage. The decision record + gotcha blocks that. **Keep the reason, drop the old snapshot.**
**Deviation log (`tasks.md §实际偏差记录`) — lightweight:** the reasoning lives in `design.md`; the tasks log needs only a one-line pointer ("T7 deviated → updated design decision 6"), or rely on the PR. **Never duplicate design's argument in two places.**
---
## 5. Constitution gate 🚧
**Target**: `/sdd-review design` runs a **Constitution Check** — does the design violate any law C1C7 in `docs/constitution.md`? A violation is a **BLOCKER**. To be folded into the existing `design-checklist.md` (not a separate gate). **Not yet implemented.**
---
## 6. Harness — what's automatic during implementation
The harness lets the agent run a long stretch without the human relaying results:
-**arch-guard hook** feeds rule violations back as `additionalContext` → agent self-corrects (`.claude/hooks/arch-guard-hook.sh`; verified end-to-end).
- 🚧 **Stop hook** runs the no-dependency fast tests (seconds: backend unit + frontend component) → agent fixes until green. Heavier tiers go to central regression (see Test tiers below).
- 🚧 **Adversarial review subagent** — checks the diff in an independent context (incl. "does the diff contradict design.md?").
- 🚧 **Frontend Playwright** interaction tests assert behavior; humans only judge look / feel / UX.
-**Circuit breaker** — any hook can be disabled fast (env flag / settings comment) if it misfires.
**Test tiers** (the split is "external dependency or not"):
- **PR gate** (every PR, fast, no external deps): backend unit + frontend component (Vitest) + ruff + arch-guard. → can be wired now without middleware; 🚧 CI job not yet added.
- **Central regression** (pre-release / periodic, needs full env): DM8 full + middleware integration + API e2e + Playwright. → **not in per-feature gates by design** (constitution C2). 🚧
---
## 7. Doc-drift prevention (incl. hotfix path) 🚧
Biggest drift risk: **hotfixes that skip SDD** — code changes, nobody updates design. Planned mitigation:
- A change hitting a file/symbol covered by a design decision/contract/gotcha → review subagent flags "diff vs design mismatch".
- A hotfix that reveals the original design was wrong → update the design decision **and add a known-gotcha** ("why the old approach fails"). Highest-value gotcha source — learned by getting burned.
---
## 8. Status & Roadmap
**✅ Live now:**
- Doc layering: `constitution.md`, slimmed root + per-subproject `AGENTS.md`, this guide, `docs/architecture/` ownership.
- **All three feature templates on the new model**: spec = What-only, P0/complex AC in EARS form (small features use table form); design = decisions+gotchas referencing constitution + deviation tiering; tasks = wave organization + one-line deviation log.
- arch-guard hook feeding violations back to the agent (verified end-to-end).
- Track selection (§1); three fixed pause points (§3); circuit breaker; `/sdd-review` · `/task-review` · `/e2e-test`.
**🚧 Planned (not active — do not assume):**
- Constitution Check folded into `design-checklist.md` (§5). `/sdd-review spec` does not yet enforce EARS on P0 specs (convention only for now).
- Stop hook self-fix; adversarial review subagent; Playwright frontend tests; CI auto-review; doc-drift subagent (§6, §7).
- **CI today only builds/pushes images — no test/lint/arch-guard gate yet.** The PR fast-test gate (backend unit + frontend component + ruff + arch-guard) can be wired now without middleware; DM8 / integration / e2e wait for the central-regression env (see constitution C2).
- Deviation re-confirm / drift enforcement is tooling-only-planned — templates already prompt it, nothing enforces it yet.
**Open decisions:**
- ~~EARS enforcement scope~~ — **decided: EARS for P0/complex features, table form for small ones.**
- tasks dependency format (markdown waves vs JSON) — leaning markdown.
- Visual-regression testing — deferred.
- CI must bring up the full middleware stack (+ DM8) before the CI/Stop-hook items can land.
> Each 🚧 item should land with a minimal eval (does it actually reduce human intervention?) before being treated as ✅ — see §0 lesson.
-351
View File
@@ -1,351 +0,0 @@
# 知识库纯检索接口 (Filelib Retrieve)
`POST /api/v2/filelib/retrieve`
跨一个或多个知识库返回 top-k chunks,**不**调用 LLM 生成回答。面向外部检索集成场景(自带 LLM 的 agent、Deep Research 工作流、第三方 RAG 编排器),是 BiSheng 工作台「日常模式 + 知识库检索」中检索阶段的 HTTP 化暴露。
---
## 1. 适用场景
| 场景 | 是否合适 |
|---|---|
| 外部 agent 用自己的 LLM 生成答案,需要 BiSheng 的检索能力 | ✅ |
| Deep Research / DeepAgents 类多源检索拼接 | ✅ |
| 自建 RAG 流水线接 BiSheng 知识库当 retriever | ✅ |
| 普通用户聊天问答 | ❌ 用 `POST /api/v1/knowledge/space/{space_id}/chat/folder`SSE 流式 RAG |
---
## 2. 认证
沿用 BiSheng OpenAPI`/api/v2/*`)的统一模式:服务账号身份。
- 不接收用户 JWT,也不读取 `access_token_cookie`
- 后端以「默认操作员」身份发起调用(DB 配置项 `default_operator.user`)。
- 调用方需在网络层负责访问控制(VPN / 内网 / 反向代理鉴权)。
> 部署前置条件:DB `initdb_config` 已配置 `default_operator.user`,且该用户对要检索的知识库具有访问权限(推荐配置 super_admin 以避免权限边界问题)。
---
## 3. 请求
### 3.1 Header
```
Content-Type: application/json
```
### 3.2 Body
```json
{
"query": "string",
"knowledge_base_ids": [1, 2],
"filters": {
"knowledge_base_filters": [
{
"knowledge_base_id": 1,
"tags": ["tag-name-1", "tag-name-2"],
"tag_match_mode": "ANY"
}
]
},
"top_k": 10,
"max_content": 15000
}
```
### 3.3 字段说明
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `query` | string | ✅ | — | 用户问题。最小长度 1。 |
| `knowledge_base_ids` | int[] | ✅ | — | 要检索的知识库 ID 列表(即 BiSheng 内部的 knowledge space id),至少 1 个。 |
| `filters` | object | ❌ | null | 检索过滤器,目前仅支持按知识库分别配置 tag 过滤。 |
| `filters.knowledge_base_filters` | array | ❌ | `[]` | 每个 entry 配置一个 KB 的过滤条件。 |
| `filters.knowledge_base_filters[].knowledge_base_id` | int | ✅ | — | 必须出现在 `knowledge_base_ids` 中,否则 400。 |
| `filters.knowledge_base_filters[].tags` | string[] | ✅ | — | 标签名(**不是** tag id)。标签作用域是单个 KBbusiness_type=knowledge_space, business_id=该 KB 的 id)。 |
| `filters.knowledge_base_filters[].tag_match_mode` | enum | ❌ | `"ANY"` | 枚举:`"ANY"``"ALL"`。**目前只支持 ANY**,传 `"ALL"` 返回 400。 |
| `top_k` | int | ❌ | 10 | 最终返回的 chunk 数量上限,跨所有 KB 合并后再截断。范围 `[1, 200]`。 |
| `max_content` | int | ❌ | 15000 | 单个 KB 内合并文本的字符上限(影响检索阶段返回的最大 chunk 数),传递给底层 retriever。范围 `>=1`。 |
### 3.4 字段语义补充
#### `knowledge_base_ids` vs `filters.knowledge_base_filters`
- `knowledge_base_ids` 定义检索范围(哪些 KB 参与)。
- `filters.knowledge_base_filters` 定义筛选条件(参与的 KB 各自怎么筛)。
- **没在 `knowledge_base_filters` 中出现的 KB,按整库检索**(不施加 tag 过滤)。
-`knowledge_base_filters` 中出现但 `tags` 解析后无匹配文件,该 KB 直接返回 0 chunks,不影响其他 KB。
#### `tag_match_mode`
- `"ANY"`(默认):文件命中**任意一个**标签即纳入。
- `"ALL"`:文件必须**同时**带上全部标签——**暂未实现,传此值返回 400**。后续版本会补齐。
#### `max_content` 的工作机制
由底层 `KnowledgeRetrieverTool` 在 RRF 合并后按字符总长度截断。这是**单 KB 内**的限制,多 KB 调用每个库独立应用此上限。最终再用 `top_k` 全局截断。
---
## 4. 响应
### 4.1 成功响应(HTTP 200
```json
{
"status_code": 200,
"status_message": "success",
"data": {
"chunks": [
{
"content": "...chunk 文本内容...",
"knowledge_id": 1,
"document_id": 123,
"document_name": "产品手册.pdf",
"chunk_index": 5
},
{
"content": "...",
"knowledge_id": 2,
"document_id": 456,
"document_name": "API 说明.md",
"chunk_index": 0
}
],
"total": 2
}
}
```
### 4.2 chunk 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `content` | string | chunk 原文。可能含 markdown 图片标记(`![](path/IMAGE_X.png)`),渲染时建议保留。 |
| `knowledge_id` | int | 该 chunk 来源的知识库 ID。多 KB 调用下用于区分来源。 |
| `document_id` | int | 文档(文件)ID,在 BiSheng 内对应 `KnowledgeFile.id`。 |
| `document_name` | string | 文档名称(含扩展名)。 |
| `chunk_index` | int | chunk 在文档内的顺序号,从 0 开始。 |
### 4.3 顶层包装字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.chunks` | array | 检索到的 chunks,按 KB 顺序拼接后取 `top_k` 截断。**单库内**按 RRF 相关度排序;**跨库间**按 `knowledge_base_ids` 入参的顺序串接。 |
| `data.total` | int | 实际返回的 chunks 数量(≤ `top_k`)。 |
| `status_code` | int | 200 表示成功;非 200 见错误码表。 |
| `status_message` | string | 状态描述。 |
> 注意:当前版本不返回相关度 `score`。如下游需要重排/二次过滤,建议依赖返回顺序(同一 KB 内已按 RRF 排序)。
---
## 5. 错误响应
### 5.1 HTTP 400 Bad Request
请求语义错误,常见原因:
| 触发条件 | `detail` 字段示例 |
|---|---|
| `knowledge_base_ids` 为空 | `"knowledge_base_ids must not be empty"` |
| 过滤器引用了不在 `knowledge_base_ids` 中的 KB | `"filter references kb_id 99 not present in knowledge_base_ids"` |
| `tag_match_mode` 传了 `"ALL"` | `"tag_match_mode=ALL is not yet supported"` |
| 字段类型校验失败(FastAPI 自动校验) | `"validation error"` 嵌套结构 |
```json
{
"detail": "tag_match_mode=ALL is not yet supported"
}
```
### 5.2 业务错误(HTTP 200 + 错误 status_code
BiSheng 沿用了「HTTP 200 + body 内 status_code」的统一响应模型用于业务错误:
| `status_code` | 含义 | 何时触发 |
|---|---|---|
| 404 | 知识库不存在 | `knowledge_base_ids` 中某个 ID 在数据库中查不到 |
| 403 | 默认操作员对该 KB 无访问权限 | 多租户 / ReBAC 权限检查不通过 |
| 500 | 服务器内部错误 | 向量库 / ES 不可用、embedding 服务异常等 |
```json
{
"status_code": 404,
"status_message": "Knowledge base 99 not found",
"data": {
"exception": "..."
}
}
```
> ⚠️ 调用方应同时检查 HTTP status 和 body 内的 `status_code` 字段。
---
## 6. 调用示例
### 6.1 单 KB,整库检索
```bash
curl -X POST 'http://bisheng-host:7860/api/v2/filelib/retrieve' \
-H 'Content-Type: application/json' \
-d '{
"query": "如何配置 SSO 登录?",
"knowledge_base_ids": [1],
"top_k": 5
}'
```
### 6.2 多 KB,部分库带 tag 过滤
```bash
curl -X POST 'http://bisheng-host:7860/api/v2/filelib/retrieve' \
-H 'Content-Type: application/json' \
-d '{
"query": "审批流程的常见问题",
"knowledge_base_ids": [1, 2, 3],
"filters": {
"knowledge_base_filters": [
{
"knowledge_base_id": 1,
"tags": ["approval", "faq"],
"tag_match_mode": "ANY"
}
]
},
"top_k": 20,
"max_content": 12000
}'
```
上例语义:
- KB 1 限定带 `approval``faq` 标签的文件。
- KB 2、KB 3 整库检索(未在 filters 中出现)。
- 三库各自检索后合并,取前 20 个 chunks。
### 6.3 Python (httpx)
```python
import httpx
resp = httpx.post(
"http://bisheng-host:7860/api/v2/filelib/retrieve",
json={
"query": "How do I configure tenant isolation?",
"knowledge_base_ids": [1, 2],
"top_k": 8,
},
timeout=30.0,
)
body = resp.json()
assert body["status_code"] == 200
for chunk in body["data"]["chunks"]:
print(f"[KB={chunk['knowledge_id']} doc={chunk['document_name']}] {chunk['content'][:80]}")
```
### 6.4 LangChain Tool 包装(参考)
```python
from langchain_core.tools import tool
import httpx
@tool
def bisheng_retrieve(query: str, knowledge_base_ids: list[int], top_k: int = 10) -> list[dict]:
"""Retrieve top-k chunks from BiSheng knowledge bases without LLM generation."""
resp = httpx.post(
"http://bisheng-host:7860/api/v2/filelib/retrieve",
json={"query": query, "knowledge_base_ids": knowledge_base_ids, "top_k": top_k},
timeout=30.0,
)
body = resp.json()
if body.get("status_code") != 200:
raise RuntimeError(f"BiSheng retrieve failed: {body.get('status_message')}")
return body["data"]["chunks"]
```
---
## 7. 行为细节与边界
### 7.1 检索流程(内部实现)
每个 KB 独立执行以下流程,最终结果合并:
1. 权限校验:default operator 是否对该 KB 有 view 权限。
2. 解析过滤:tag 名(如有)→ tag id → file id 列表。无对应文件时该 KB 返回空。
3. 构建过滤器:file id 列表 + 主版本过滤(排除已废弃的文档版本)→ Milvus expr + ES filter。
4. 双路召回:Milvus 向量检索 + Elasticsearch 全文检索,各取 top 100。
5. RRF 合并:两路结果按 Reciprocal Rank Fusion 融合排序。
6. `max_content` 截断:从前往后累加 chunk 文本长度,超过阈值截断。
多 KB 间:使用 `asyncio.gather` 并发执行,按 `knowledge_base_ids` 顺序合并,最后 `top_k` 截断。
### 7.2 性能注意
- 单 KB 检索延迟约等于一次 Milvus query + 一次 ES query 的串行最大值。
- 多 KB 是并发的,所以延迟不显著叠加。
- `max_content` 不影响检索阶段的 candidate 数量(始终是 top 100),只决定最终保留的 chunk 数。
- `top_k` 大时建议同步调大 `max_content`,否则可能在 `max_content` 截断后就已经少于 `top_k` 个 chunk。
### 7.3 多租户
- 接口本身不携带租户参数;租户从 `default_operator` 的关联租户自动注入。
- 跨租户检索**不支持**——`default_operator` 看不到其他租户的知识库。
### 7.4 文档版本
- 自动只检索「主版本」(`is_primary=true`) 的文档;废弃版本不会出现在结果里。
- 这与 `/api/v1/.../chat/folder` 行为一致。
### 7.5 标签作用域
- BiSheng 的标签按 `(business_type, business_id)` 划分作用域。
- 本接口的 tag 在 `business_type=knowledge_space, business_id=<knowledge_base_id>` 的空间下查找。
- 同名标签在不同 KB 下是不同记录——传 tag 名时不会跨 KB 混淆。
### 7.6 空结果
下列情况都返回 HTTP 200 + 空 `chunks` 数组(不算错误):
- 知识库为空、检索词无任何召回。
- tag 过滤后命中 0 个文件。
- 所有候选文件都是非主版本(被版本过滤剔除)。
---
## 8. 与现有接口的关系
| 接口 | 路径 | 区别 |
|---|---|---|
| 本接口 | `POST /api/v2/filelib/retrieve` | 纯检索,JSON 同步响应,服务账号身份 |
| 工作台 RAG | `POST /api/v1/knowledge/space/{id}/chat/folder` | 检索 + LLM 生成 + 会话落库,SSE 流式,用户 JWT |
| 知识库管理 | `POST /api/v2/filelib/...` 其他端点 | 同 namespace 下的上传/QA 管理 |
---
## 9. 版本与兼容性
- **当前状态**:v2.6.0 引入。后续版本会补齐 `tag_match_mode=ALL`、可选的 `score` 字段、`metadata` 透传开关。
- **向后兼容承诺**:现有字段(`query``knowledge_base_ids``filters``top_k``max_content`)的语义保持稳定;新增字段会以可选项加入。
- **响应包装**沿用 BiSheng 全局响应模型,`data` 结构在 minor 版本内保持兼容。
---
## 10. FAQ
**Q:能跨多个知识库返回结果时按全局相关度排序吗?**
A:当前实现按 KB 顺序串接,未做跨库 score 归一化。如下游对全局排序有强需求,可在调用前把同一类知识库分组、或在自己侧再做一次重排。
**Qtop_k 设置成 50 但只返回了 10 条,是 bug 吗?**
A:很可能是 `max_content` 提前截断了。调大 `max_content`,或检查知识库实际命中量。
**Q:能传 user_id 让接口以某个特定用户身份检索吗?**
A:当前不支持。如需多用户身份语义,需在调用层做。后续版本可能加 `as_user_id` 可选参数。
**Q:如何拿到 chunk 的 page、bbox、source 等元信息?**
A:当前响应不透传 metadata。后续版本会加可选的 `include_metadata` 开关。
-609
View File
@@ -1,609 +0,0 @@
2. ### 知识空间/知识库接口文档
1. #### 知识空间层级
1. ##### 获取知识资源列表【改动】
**接口地址**
**GET**`/api/v2/filelib/`
**接口说明**
按类型、名称和分页查询当前调用身份可见的知识资源。知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。
**入参**
| | | | | |
| ----------- | ------- | --- | ---- | --------------------------------------------- |
| 参数 | 类型 | 必填 | 默认值 | 业务说明 |
| `type` | integer | 否 | 0 | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 |
| `name` | string | 否 | 无 | 按名称搜索,知识库名称和知识空间名称都查这个字段。 |
| `sort_by` | string | 否 | `update_time` | 排序字段:`update_time` / `create_time` / `name`。 |
| `page_size` | integer | 否 | `10` | 每页数量。 |
| `cursor` | string | 否 | 无 | 游标分页 token,取上一页响应的 `next_cursor`;首页不传。与 v1 知识库列表一致(F027 INV-6)。 |
| `user_id` | integer | 否 | 无 | 人员 ID。传入后,只返回该人员有权限访问范围内的知识资源。不传则按默认操作人身份过滤。 |
**出参**
| | | |
| ---------------- | ------- | --------------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data.data` | array | 当前页的知识资源列表。 |
| `data.page_size` | integer | 每页数量。 |
| `data.has_more` | boolean | 是否还有下一页。 |
| `data.next_cursor` | string | 下一页游标;无更多数据时为 `null`。下次请求把它传入 `cursor`。 |
| `id` | integer | 知识资源 ID。 |
| `name` | string | 知识资源名称。 |
| `type` | integer | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 |
| `description` | string | 资源描述。 |
| `model` | string | Embedding 模型 ID。 |
| `state` | integer | 资源状态。 |
| `auth_type` | string | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 |
| `is_released` | boolean | 是否发布到知识广场。 |
| `user_id` | integer | 创建人 ID。 |
| `user_name` | string | 创建人名称。 |
| `tenant_id` | integer | 所属租户 ID。 |
| `create_time` | string | 创建时间。 |
| `update_time` | string | 更新时间。 |
| `permission_ids` | array | 当前调用身份拥有的权限点。 |
2. ##### 创建知识资源【改动】
**接口地址**
**POST**`/api/v2/filelib/`
**接口说明**
创建一个知识资源。知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。 通过 `type` 区分创建哪一类资源。
**入参**
| | | | | |
| ------------- | ------- | --- | -------- | --------------------------------------------- |
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| `name` | string | 是 | 无 | 知识资源名称。 |
| `type` | integer | 否 | `0` | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 |
| `description` | string | 否 | 无 | 知识资源描述。 |
| `model` | string | 是 | 无 | Embedding 模型 ID,用于后续文档向量化检索。 |
| `auth_type` | string | 否 | `public` | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 |
| `is_released` | boolean | 否 | `false` | 是否发布到知识广场。主要用于知识空间。 |
**出参**
| | | |
| ---------------- | ------- | --------------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | object | 创建成功后的知识资源信息。 |
| `id` | integer | 知识资源 ID。 |
| `name` | string | 知识资源名称。 |
| `type` | integer | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 |
| `description` | string | 知识资源描述。 |
| `model` | string | Embedding 模型 ID。 |
| `state` | integer | 资源状态。 |
| `auth_type` | string | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 |
| `is_released` | boolean | 是否发布到知识广场。 |
| `user_id` | integer | 创建人 ID。 |
| `user_name` | string | 创建人名称。 |
| `tenant_id` | integer | 所属租户 ID。 |
| `create_time` | string | 创建时间。 |
| `update_time` | string | 更新时间。 |
| `permission_ids` | array | 当前调用身份拥有的权限点。 |
3. ##### 删除知识资源【已有】
**接口地址**
**DEL**`/api/v2/filelib/{knowledge_id}`
**接口说明**
删除指定知识资源。知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。 调用方只需要传资源 ID,不需要区分它是知识库还是知识空间。
**入参**
| | | | |
| -------------- | ------- | --- | ------------------------------ |
| 参数 | 类型 | 必填 | 说明 |
| `knowledge_id` | integer | 是 | 知识资源 ID。可以是知识库 ID,也可以是知识空间 ID。 |
**出参**
| | | |
| ---------------- | ------- | ------------------------------------------------ |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,删除成功时返回 `knowledge deleted successfully`。 |
| `data` | null | 删除接口无业务数据返回。 |
4. ##### 更新知识资源【改动】
**接口地址**
**PUT**`/api/v2/filelib/`
**接口说明**
更新指定知识资源的基础信息。知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。当前接口只支持更新名称和描述
**入参**
| | | | | |
| -------------- | ------- | --- | --------- | ---------------------------------- |
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 要更新的知识资源 ID。可以是知识库 ID,也可以是知识空间 ID。 |
| `name` | string | 否 | 不传则不修改 | 新的知识资源名称。 |
| `description` | string | 否 | 不传则将描述变为空 | 新的知识资源描述。 |
**出参**
| | | |
| ---------------- | ------- | --------------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | object | 更新后的知识资源信息。 |
| `id` | integer | 知识资源 ID。 |
| `name` | string | 知识资源名称。 |
| `type` | integer | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 |
| `description` | string | 知识资源描述。 |
| `model` | string | Embedding 模型 ID。 |
| `state` | integer | 资源状态。 |
| `auth_type` | string | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 |
| `is_released` | boolean | 是否发布到知识广场。 |
| `user_id` | integer | 创建人 ID。 |
| `user_name` | string | 创建人名称。 |
| `tenant_id` | integer | 所属租户 ID。 |
| `create_time` | string | 创建时间。 |
| `update_time` | string | 更新时间。 |
| `permission_ids` | array | 当前调用身份拥有的权限点。 |
5. ##### 清空知识资源内容【已有】
**接口地址**
**DEL**`/api/v2/filelib/clear/{knowledge_id}`
**接口说明**
清空指定知识资源下的文件内容和检索索引,但保留知识资源本身。知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。简单理解就是:资源还在,名称、描述、ID 还在,但里面的文件和向量检索内容会被清掉。
**入参**
| | | | |
| -------------- | ------- | --- | ------------------------------ |
| 参数 | 类型 | 必填 | 说明 |
| `knowledge_id` | integer | 是 | 知识资源 ID。可以是知识库 ID,也可以是知识空间 ID。 |
**出参**
| | | |
| ---------------- | ------- | ---------------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,清空成功时返回 `knowledge clear successfully`。 |
| `data` | null | 清空接口无业务数据返回。 |
6. ##### 检索知识资源分段【改动】
**接口地址**
**POST**`/api/v2/filelib/retrieve`
**接口说明**
对指定知识资源执行搜索查询,返回最相关的文本分段。 知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。 可按知识资源 ID、人员权限和标签进行过滤。
**入参**
| | | | | |
| -------------------- | ------- | --- | ------- | ---------------------------------------- |
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| `query` | string | 是 | 无 | 检索问题或搜索关键词。 |
| `knowledge_base_ids` | array | 是 | 无 | 要检索的知识资源 ID 列表。可以是知识库 ID,也可以是知识空间 ID。 |
| `user_id` | integer | 否 | 无 | 人员 ID。传入后,只在该人员有权限访问的知识资源范围内检索。如果不传则是全部。 |
| `tags` | array | 否 | 无 | 标签名称列表。传入后,只检索命中这些标签的文件或分段。 |
| `top_k` | integer | 否 | `10` | 返回分段数量,最大 `200`。 |
| `max_content` | integer | 否 | `15000` | 最大检索内容长度,可理解为本次检索最多合并多少文本内容。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data.chunks` | array | 检索命中的分段列表。 |
| `data.total` | integer | 本次返回的分段数量。 |
| `content` | string | 分段文本内容。 |
| `knowledge_id` | integer | 分段所属的知识资源 ID。 |
| `document_id` | integer | 分段所属的文件 ID。 |
| `document_name` | string | 分段所属的文件名称。 |
| `chunk_index` | integer | 分段在文件中的序号。 |
2. #### 文档层级
1. ##### 上传文件到知识资源【改动】
**接口地址**
**POST**`/api/v2/filelib/file/{knowledge_id}`
**接口说明**
向指定知识资源上传文件,并触发解析、切分和入库。 知识资源包括:文档知识库、QA 知识库、个人知识库、知识空间。 文件可以通过本地上传,也可以通过 `file_url` 远程拉取。
**入参**
| | | | |
| ----------------------- | ------- | --- | --------------------------------------------- |
| 参数 | 类型 | 必填 | 说明 |
| `knowledge_id` | integer | 是 | 知识资源 ID。可以是知识库 ID,也可以是知识空间 ID。 |
| `parent_id`<br><br><br> | integer | 否 | 目标文件夹 ID。仅知识空间上传时使用;不传表示上传到知识空间根目录。普通知识库不需要传。 |
**出参**
| | | |
| ---------------- | ------- | ------------------------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | object | 上传后创建的文件记录。 |
| `id` | integer | 文件 ID。 |
| `knowledge_id` | integer | 文件所属知识资源 ID。 |
| `file_name` | string | 文件名称。 |
| `file_type` | integer | 文件类型:`0` 文件夹,`1` 文件。 |
| `file_source` | string | 文件来源,上传接口通常为 `upload`。 |
| `file_size` | integer | 文件大小,单位字节。 |
| `status` | integer | 文件处理状态:`1` 处理中,`2` 成功,`3` 失败,`5` 排队中,`6` 超时,`7` 内容安全违规。 |
| `remark` | string | 处理备注或失败原因。 |
| `object_name` | string | 文件在对象存储中的内部地址。 |
| `create_time` | string | 创建时间。 |
| `update_time` | string | 更新时间。 |
2. ##### 获取文件列表【改动】
**接口地址**
**GET**`GET /api/v2/filelib/file/list`
**接口说明**
按知识资源 ID 获取文件列表。知识资源可以是知识库,也可以是知识空间。
**入参**
| | | | | |
| -------------- | --------- | --- | ---- | --------------------------------------------------------------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识资源 ID。可以是知识库 ID,也可以是知识空间 ID。 |
| `parent_id` | integer | 否 | 无 | 目标文件夹 ID。仅知识空间使用;不传表示列出根目录。普通知识库忽略此参数。 |
| `keyword` | string | 否 | 无 | 文件名或标签关键词。 |
| `status` | integer[] | 否 | 无 | 文件处理状态:`1` 处理中,`2` 成功,`3` 失败,`4` 重建中,`5` 排队中,`6` 超时,`7` 内容安全违规。 |
| `page_size` | integer | 否 | `10` | 每页数量。 |
| `cursor` | string | 否 | 无 | 游标分页 token,取上一页响应的 `next_cursor`;首页不传。与 v1 文件列表一致(F027 INV-6)。 |
| `user_id` | integer | 否 | 无 | 人员 ID。传入后,只返回该人员有权限访问范围内的文件。不传则按默认操作人身份过滤。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data.data` | array | 当前页文件列表。 |
| `data.page_size` | integer | 每页数量。 |
| `data.has_more` | boolean | 是否还有下一页。 |
| `data.next_cursor` | string | 下一页游标;无更多数据时为 `null`。 |
| `data.writeable` | boolean | 当前身份是否有写入权限(库=对库可写;空间=对该目录/文件可编辑)。 |
| `id` | integer | 文件 ID。 |
| `knowledge_id` | integer | 文件所属知识资源 ID。 |
| `file_name` | string | 文件名称。 |
| `file_type` | integer | 文件类型:`0` 文件夹,`1` 文件。 |
| `file_source` | string | 文件来源,例如 `upload`。 |
| `file_size` | integer | 文件大小,单位字节。 |
| `status` | integer | 文件处理状态。 |
| `remark` | string | 处理备注或失败原因。 |
| `title` | string | 文件摘要或标题。 |
| `tags` | array | 文件标签列表。 |
| `create_time` | string | 创建时间。 |
| `update_time` | string | 更新时间。 |
3. ##### 删除文件【已有】
**接口地址**
**DEL**`/api/v2/filelib/file/{file_id}`
**接口说明**
删除指定文件。文件可以属于知识库,也可以属于知识空间。
**入参**
| | | | | |
| --------- | ------- | --- | --- | ---------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `file_id` | integer | 是 | 无 | 要删除的文件 ID。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | null | 删除成功后无业务数据返回。 |
4. ##### 批量删除文件【已有】
**接口地址**
**POST**`/api/v2/filelib/delete_file`
**接口说明**
批量删除指定文件。文件可以属于知识库,也可以属于知识空间。
**入参**
| | | | | |
| ------ | --------- | --- | --- | ------------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `body` | integer[] | 是 | 无 | 要删除的文件 ID 列表。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | null | 删除成功后无业务数据返回。 |
3. #### 元数据层级
1. ##### 获取元数据字段【已有】
**接口地址**
**GET**`api/v2/knowledge/get_metadata_fields/{knowledge_id}`
**接口说明**
获取指定知识库已配置的元数据字段。
**入参**
| | | | | |
| -------------- | ------- | --- | --- | ------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
**出参**
| | | |
| ---------------------- | ------- | --------------------------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data.knowledge_id` | integer | 知识库 ID。 |
| `data.metadata_fields` | array | 元数据字段列表。 |
| `field_name` | string | 字段名。 |
| `field_type` | string | 字段类型:`string` 文本,`number` 数值,`time` 时间。 |
| `updated_at` | integer | 字段更新时间,Unix 时间戳。 |
2. ##### 添加元数据字段【已有】
**接口地址**
**POST**`/api/v2/knowledge/add_metadata_fields`
**接口说明**
给指定知识库添加元数据字段。
**入参**
| | | | | |
| ------------------------------ | ------- | --- | --- | --------------------------------------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `metadata_fields` | array | 是 | 无 | 要添加的元数据字段列表。 |
| `metadata_fields[].field_name` | string | 是 | 无 | 字段名,只能使用小写字母、数字、下划线,且必须以小写字母开头。 |
| `metadata_fields[].field_type` | string | 是 | 无 | 字段类型:`string` 文本,`number` 数值,`time` 时间。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否添加成功。 |
3. ##### 修改元数据字段【已有】
**接口地址**
**PUT**`/api/v2/knowledge/modify_metadata_fields`
**接口说明**
修改指定知识库的元数据字段名称
**入参**
| | | | | |
| ---------------------------------- | ------- | --- | --- | -------------------------------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `metadata_fields` | array | 是 | 无 | 要修改的字段列表。 |
| `metadata_fields[].old_field_name` | string | 是 | 无 | 原字段名。 |
| `metadata_fields[].new_field_name` | string | 是 | 无 | 新字段名,只能使用小写字母、数字、下划线,且必须以小写字母开头。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否修改成功。 |
4. ##### 删除元数据字段【已有】
**接口地址**
**DEL**`/api/v2/knowledge/delete_metadata_fields`
**接口说明**
删除指定知识库中的元数据字段。
**入参**
| | | | | |
| -------------- | -------- | --- | --- | ---------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `field_names` | string[] | 是 | 无 | 要删除的字段名列表。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否删除成功。 |
5. ##### 批量查询文件用户元数据【已有】
**接口地址**
**POST**`/api/v2/knowledge/file/list_user_metadata`
**接口说明**
查询指定文件已填写的用户元数据。
**入参**
| | | | | |
| -------------------- | --------- | --- | --- | --------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `knowledge_file_ids` | integer[] | 是 | 无 | 文件 ID 列表。 |
**出参**
| | | |
| ---------------- | ------------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | object | 文件用户元数据,key 为文件 ID。 |
| `field_value` | string/number | 字段值。 |
| `updated_at` | integer | 更新时间,Unix 时间戳。 |
| `field_type` | string | 字段类型。 |
6. ##### 添加文件用户元数据【已有】
**接口地址**
**POST**`/api/v2/knowledge/file/add_user_metadata`
**接口说明**
给指定文件添加用户元数据。
**入参**
| | | | | |
| ---------------------------------------- | ------------- | --- | ---- | ---------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `add_metadata_list` | array | 是 | 无 | 文件元数据添加列表。 |
| `add_metadata_list[].knowledge_file_id` | integer | 是 | 无 | 文件 ID。 |
| `add_metadata_list[].user_metadata_list` | array | 是 | 无 | 要添加的元数据列表。 |
| `field_name` | string | 是 | 无 | 元数据字段名。 |
| `field_value` | string/number | 否 | null | 元数据字段值。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否添加成功。 |
7. ##### 修改文件用户元数据【已有】
**接口地址**
**PUT**`/api/v2/knowledge/file/modify_user_metadata`
**接口说明**
修改指定文件已存在的用户元数据。
**入参**
| | | | | |
| ------------------------------------------- | ------------- | --- | ---- | ---------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `modify_metadata_list` | array | 是 | 无 | 文件元数据修改列表。 |
| `modify_metadata_list[].knowledge_file_id` | integer | 是 | 无 | 文件 ID。 |
| `modify_metadata_list[].user_metadata_list` | array | 是 | 无 | 要修改的元数据列表。 |
| `field_name` | string | 是 | 无 | 元数据字段名。 |
| `field_value` | string/number | 否 | null | 新的元数据字段值。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否修改成功。 |
8. ##### 删除文件用户元数据【已有】
**接口地址**
**DEL**`/api/v2/knowledge/file/delete_user_metadata`
**接口说明**
删除指定文件上的用户元数据。
**入参**
| | | | | |
| ------------------------------------------- | -------- | --- | --- | ---------- |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 |
| `delete_user_metadatas` | array | 是 | 无 | 文件元数据删除列表。 |
| `delete_user_metadatas[].knowledge_file_id` | integer | 是 | 无 | 文件 ID。 |
| `delete_user_metadatas[].field_names` | string[] | 是 | 无 | 要删除的字段名列表。 |
**出参**
| | | |
| ---------------- | ------- | ----------------------- |
| 字段 | 类型 | 说明 |
| `status_code` | integer | 业务状态码,成功固定为 `200`。 |
| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 |
| `data` | boolean | 是否删除成功。 |
@@ -1,353 +0,0 @@
# 系统架构总览
BiSheng 是一个面向企业的 LLM 应用 DevOps 平台,采用**前后端分离 + 异步任务处理**的分层架构。系统由 7 个运行时进程组和 5 个基础设施服务构成,通过 Nginx 反向代理统一入口,FastAPI 后端承载核心业务逻辑,Celery Worker 集群处理耗时任务(知识库解析、工作流执行、遥测统计),Linsight Worker 独立运行智能体推理。后端代码遵循领域驱动设计(DDD)模式,每个业务模块按 `api/ -> domain/` 分层组织。
## 系统组件图
```mermaid
flowchart TB
subgraph Client["客户端"]
Browser["浏览器"]
end
subgraph Proxy["反向代理层"]
Nginx["Nginx :8860"]
end
subgraph Frontend["前端层"]
Platform["Platform 前端<br/>React + Vite :3001<br/>src/frontend/platform/"]
ClientFE["Client 前端<br/>React + Vite :4001<br/>src/frontend/client/"]
end
subgraph Backend["后端应用层"]
API["FastAPI 后端<br/>uvicorn :7860<br/>src/backend/bisheng/main.py"]
KnowledgeWorker["Celery Worker<br/>knowledge_celery 队列"]
WorkflowWorker["Celery Worker<br/>workflow_celery 队列"]
DefaultWorker["Celery Worker<br/>celery 默认队列"]
Beat["Celery Beat<br/>定时任务调度"]
LinsightWorker["Linsight Worker<br/>智能体推理进程<br/>bisheng/linsight/worker.py"]
end
subgraph Storage["基础设施存储层"]
MySQL["MySQL 8.0 :3306<br/>关系型数据"]
Redis["Redis 7.0 :6379<br/>缓存 / Broker / 会话"]
Milvus["Milvus 2.5 :19530<br/>向量存储"]
ES["Elasticsearch 8.12 :9200<br/>全文检索"]
MinIO["MinIO :9000<br/>对象存储 (S3)"]
end
Browser --> Nginx
Nginx --> Platform
Nginx --> ClientFE
Platform -- "/api/ 代理" --> API
ClientFE -- "/api/ 代理" --> API
Platform -- "/bisheng, /tmp-dir" --> MinIO
API --> MySQL
API --> Redis
API --> Milvus
API --> ES
API --> MinIO
API -- "任务分发" --> Redis
Redis -- "消息队列" --> KnowledgeWorker
Redis -- "消息队列" --> WorkflowWorker
Redis -- "消息队列" --> DefaultWorker
Beat -- "定时触发" --> Redis
KnowledgeWorker --> MySQL
KnowledgeWorker --> Milvus
KnowledgeWorker --> ES
KnowledgeWorker --> MinIO
WorkflowWorker --> MySQL
WorkflowWorker --> Milvus
LinsightWorker --> Redis
LinsightWorker --> MySQL
```
## 请求数据流
一个典型的用户请求从浏览器到数据存储层,经历以下路径:
```
浏览器
|
v
Nginx (:8860) -- 反向代理,路由分发
|
v
Vite Dev Server (:3001) -- 前端静态资源 + 开发热更新
| proxy /api/ /health
v
FastAPI (:7860) -- 后端 API,JWT 认证,路由分派
|
+---> Router (api/v1/ 或 api/v2/)
| |
| v
| Service 层 (业务逻辑)
| |
| +---> DAO 层 (database/models/) ---> MySQL
| |
| +---> Redis (缓存/会话)
| |
| +---> Celery Task ---> Redis Broker
| |
| +---> knowledge_celery Worker
| | +---> Milvus (向量写入)
| | +---> ES (全文索引)
| | +---> MinIO (文件存储)
| |
| +---> workflow_celery Worker
| +---> LangGraph 执行引擎
|
+---> WebSocket
|
v
ChatManager ---> 回调流式输出 ---> 队列缓冲 ---> 消息持久化 (MySQL)
```
**同步请求**:浏览器 -> Nginx -> Vite -> FastAPI -> Service -> DAO -> MySQL,原路返回 JSON 响应。
**异步任务**:FastAPI 将耗时操作(文档解析、工作流执行、遥测统计)投递到 Redis 消息队列,由对应的 Celery Worker 异步消费处理。
**WebSocket 通信**:聊天场景通过 WebSocket 连接建立长连接,ChatManager 管理消息订阅,通过回调机制实现流式输出。
## API 路由架构
后端提供两套 API 路由,定义在 `src/backend/bisheng/api/router.py`
### v1 路由 (`/api/v1`) -- 面向前端
v1 路由面向前端应用,提供完整的 CRUD 操作界面。共注册 29 个子路由:
| 路由模块 | 来源 | 职责 |
|---------|------|------|
| `chat_router` | `api/v1/` | 对话管理 |
| `knowledge_router` | `knowledge/api/` | 知识库 CRUD |
| `knowledge_space_router` | `knowledge/api/` | 知识空间管理 |
| `qa_router` | `knowledge/api/` | 问答检索 |
| `workflow_router` | `api/v1/` | 工作流管理 |
| `assistant_router` | `api/v1/` | AI 助手管理 |
| `llm_router` | `llm/api/` | 模型供应商管理 |
| `user_router` | `api/v1/` | 用户认证与管理 |
| `group_router` | `api/v1/` | 用户组/RBAC |
| `tool_router` | `api/v1/` | 工具集成 |
| `evaluation_router` | `api/v1/` | 模型评测 |
| `finetune_router` | `finetune/api/` | 模型微调 |
| `server_router` | `finetune/api/` | 微调服务器 |
| `linsight_router` | `linsight/api/` | 灵思智能体 |
| `session_router` | `chat_session/api/` | 会话管理 |
| `channel_router` | `channel/api/` | 渠道集成 |
| `message_router` | `message/api/` | 消息收件箱 |
| `share_link_router` | `share_link/api/` | 公开分享链接 |
| `telemetry_search_router` | `telemetry_search/api/` | 遥测数据检索 |
| `flows_router` | `api/v1/` | Flow 管理 |
| `workstation_router` | `api/v1/` | 工作台 |
| `skillcenter_router` | `api/v1/` | 技能中心 |
| `endpoints_router` | `api/v1/` | 通用端点 |
| `variable_router` | `api/v1/` | 变量管理 |
| `report_router` | `api/v1/` | 报告生成 |
| `audit_router` | `api/v1/` | 审计日志 |
| `tag_router` | `api/v1/` | 标签管理 |
| `mark_router` | `api/v1/` | 数据标注 |
| `invite_code_router` | `api/v1/` | 邀请码 |
### v2 路由 (`/api/v2`) -- 面向外部集成
v2 路由采用 RPC 风格,面向外部系统集成,提供简化的编程接口。共 6 个子路由:
| 路由模块 | 来源 | 职责 |
|---------|------|------|
| `knowledge_router_rpc` | `open_endpoints/api/` | 知识库操作 |
| `filelib_router_rpc` | `open_endpoints/api/` | 文件库操作 |
| `chat_router_rpc` | `open_endpoints/api/` | 对话接口 |
| `assistant_router_rpc` | `open_endpoints/api/` | 助手调用 |
| `workflow_router_rpc` | `open_endpoints/api/` | 工作流触发 |
| `llm_router_rpc` | `open_endpoints/api/` | LLM 调用(含 OpenAI 兼容接口) |
## 应用启动生命周期
FastAPI 应用的启动和关闭由 `lifespan` 上下文管理器编排(`src/backend/bisheng/main.py` 第 51-58 行):
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
await initialize_app_context(config=settings) # 阶段 1:初始化基础设施
await init_default_data() # 阶段 2:初始化默认数据
yield # 应用运行中
thread_pool.tear_down() # 阶段 3:关闭线程池
await close_app_context() # 阶段 4:逆序关闭基础设施
```
### 阶段 1:基础设施初始化
`ApplicationContextManager``src/backend/bisheng/core/context/manager.py`)按依赖顺序逐一初始化 7 个基础设施上下文:
```
序号 上下文管理器 依赖资源 职责
─────────────────────────────────────────────────────────────────
1 DatabaseManager database_url MySQL 连接池与会话工厂
2 RedisManager redis_url Redis 连接池
3 MinioManager minio config MinIO S3 客户端
4 EsConnManager elasticsearch_url Elasticsearch 主实例(业务检索)
5 EsConnManager elasticsearch_url Elasticsearch 统计实例(遥测)
6 HttpClientManager (无) HTTP 异步客户端
7 PromptManager (无) 提示词模板加载
```
所有上下文管理器继承 `BaseContextManager[T]` 基类,提供线程安全的延迟加载、健康检查和生命周期管理。关闭时按注册的逆序执行,确保依赖关系正确处理。
### 阶段 2:默认数据初始化
`init_default_data()``src/backend/bisheng/common/init_data.py`)负责:
- 数据库 Schema 迁移
- 创建管理员账号(首个注册用户自动获得管理员权限)
- 初始化默认角色和用户组
- 加载内置模板
### 中间件栈
请求进入路由处理前,依次经过三层中间件(`main.py` 第 78-87 行):
```
请求入站
|
v
CORSMiddleware -- 跨域资源共享,允许所有来源
|
v
CustomMiddleware -- 请求日志记录 + X-Trace-ID 链路追踪
| (src/backend/bisheng/utils/http_middleware.py)
v
WebSocketLoggingMiddleware -- WebSocket 连接日志 + TraceID 注入
|
v
路由处理
```
## DDD 模块模式
后端业务模块遵循领域驱动设计的分层约定。以 `knowledge` 模块为例:
```
src/backend/bisheng/knowledge/
├── api/ # API 层:路由定义与请求处理
│ ├── router.py # 路由注册
│ ├── dependencies.py # 依赖注入(认证、权限)
│ └── endpoints/ # 端点实现
│ ├── knowledge.py # 知识库 CRUD
│ ├── knowledge_space.py # 知识空间管理
│ └── qa.py # 问答检索
└── domain/ # 领域层:核心业务逻辑
├── models/ # 领域模型
│ ├── knowledge.py
│ ├── knowledge_file.py
│ └── knowledge_space_file.py
├── schemas/ # Pydantic 数据传输对象
│ ├── knowledge_schema.py
│ ├── knowledge_file_schema.py
│ ├── knowledge_rag_schema.py
│ └── knowledge_space_schema.py
├── services/ # 领域服务(业务逻辑)
│ ├── knowledge_service.py
│ ├── knowledge_file_service.py
│ ├── knowledge_space_service.py
│ ├── knowledge_permission_service.py
│ └── ...
├── repositories/ # 仓储层(数据访问抽象)
│ ├── interfaces/ # 仓储接口定义
│ │ ├── knowledge_repository.py
│ │ └── knowledge_file_repository.py
│ └── implementations/ # 仓储实现
│ ├── knowledge_repository_impl.py
│ └── knowledge_file_repository_impl.py
└── utils.py # 领域工具函数
```
**调用链路**`Router -> Endpoint -> Service -> Repository -> database/models/ (ORM)`
各业务模块均遵循此模式,但不强制所有层级都必须存在。较简单的模块(如 `tag``message`)可能省略 `repositories/` 层,直接在 Service 中调用 DAO。
## Celery 异步任务架构
Celery 应用定义在 `src/backend/bisheng/worker/main.py`,使用 Redis 作为消息 Broker。
### 任务队列
| 队列 | Worker 启动命令 | 职责 |
|------|----------------|------|
| `knowledge_celery` | `celery -A bisheng.worker.main worker -Q knowledge_celery` | 文档解析、Embedding 生成、向量写入 |
| `workflow_celery` | `celery -A bisheng.worker.main worker -Q workflow_celery` | 工作流 DAG 执行 |
| `celery` (默认) | `celery -A bisheng.worker.main worker` | 遥测统计收集 |
任务路由配置(`src/backend/bisheng/core/config/settings.py`):
```python
task_routers = {
"bisheng.worker.knowledge.*": {"queue": "knowledge_celery"},
"bisheng.worker.workflow.*": {"queue": "workflow_celery"},
}
```
### Beat 定时任务
| 任务 | 执行时间 | 职责 |
|------|---------|------|
| `sync_mid_user_increment` | 每日 00:30 | 同步用户增量遥测 |
| `sync_mid_knowledge_increment` | 每日 00:30 | 同步知识库增量遥测 |
| `sync_mid_app_increment` | 每日 00:30 | 同步应用增量遥测 |
| `sync_mid_user_interact_dtl` | 每日 00:30 | 同步用户交互明细 |
| `sync_information_article` | 每日 05:30 | 同步情报中心文章 |
## 技术栈
### 后端
| 技术 | 版本 | 用途 |
|------|-----|------|
| Python | 3.11+ | 运行时(pyproject `requires-python >=3.11` |
| FastAPI | -- | Web 框架 |
| uvicorn | -- | ASGI 服务器 |
| SQLModel / SQLAlchemy | -- | ORM |
| Celery | 5.x | 异步任务队列 |
| LangGraph | 0.3.x | 工作流执行引擎 |
| LangChain | 0.3.x | LLM 编排框架 |
| Pydantic | v2 | 数据校验与序列化 |
| Loguru | -- | 结构化日志 |
| uv | -- | 依赖管理(替代 Poetry |
### 前端
| 技术 | 版本 | 用途 |
|------|-----|------|
| React | 18.x | UI 框架 |
| TypeScript | -- | 类型安全 |
| Vite (SWC) | -- | 构建工具 |
| Zustand | -- | 状态管理 |
| Radix UI | -- | 无障碍组件库 |
| Tailwind CSS | -- | 原子化样式 |
| @xyflow/react | -- | 工作流画布 |
| Axios | -- | HTTP 客户端 |
| i18next | -- | 国际化(中/英/日) |
| recharts | -- | 图表可视化 |
### 基础设施
| 服务 | 版本 | 端口 | 用途 |
|------|-----|------|------|
| MySQL | 8.0 | 3306 | 关系型数据存储(用户、会话、模型配置等 24+ 张表) |
| Redis | 7.0 | 6379 | 缓存、Celery Broker、会话存储、Linsight 状态持久化 |
| Milvus | 2.5.10 | 19530 | 向量存储(RAG 稠密向量检索) |
| Elasticsearch | 8.12 | 9200 | 全文检索(BM25 关键词检索)、遥测数据存储 |
| MinIO | -- | 9000 | S3 兼容对象存储(文档文件、图片、模型文件) |
## 相关文档
- 工作流引擎设计 -- `docs/architecture/02-workflow-engine.md`
- 知识库/RAG 流水线 -- `docs/architecture/03-knowledge-rag-pipeline.md`
- 认证与权限体系 -- `docs/architecture/04-auth-rbac.md`
- 配置管理机制 -- `docs/architecture/05-configuration.md`
- 部署运维指南 -- `docs/architecture/06-deployment.md`
-322
View File
@@ -1,322 +0,0 @@
# 后端领域模块总览
BiSheng 后端采用 DDD(领域驱动设计)架构,将业务逻辑拆分为 15+ 自治领域模块,每个模块拥有独立的 API 层、领域服务层和数据访问层。模块间通过明确的接口交互,基础设施层(`core/`)提供数据库、缓存、对象存储、向量库等共享能力。
所有后端代码位于 `src/backend/bisheng/` 目录下。
## 模块清单
### 业务领域模块
| 模块 | 路径 | 职责 | 内部结构 |
|------|------|------|----------|
| knowledge | `knowledge/` | 知识库管理、RAG 文档处理管道 | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` + `rag/` |
| workflow | `workflow/` | 工作流 DAG 执行引擎(LangGraph | `graph/` + `nodes/` + `edges/` + `callback/` + `common/` |
| linsight | `linsight/` | Linsight Agent 自主任务框架 | `api/` + `domain/services/` + `domain/models/` + `worker.py` |
| llm | `llm/` | LLM 供应商管理、模型注册与配置 | `api/` + `domain/services/` + `domain/llm/` + `domain/models/` |
| chat_session | `chat_session/` | 聊天会话管理、消息持久化 | `api/` + `domain/services/` |
| tool | `tool/` | 工具/插件管理 | `api/` + `domain/services/` + `domain/models/` + `domain/langchain/` |
| channel | `channel/` | 多渠道通信、情报中心 | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` + `domain/es/` |
| message | `message/` | 消息收件箱 | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` |
| user | `user/` | 用户管理、认证、RBAC | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` |
| mcp_manage | `mcp_manage/` | MCP 协议集成(SSE/STDIO/Streamable | `clients/` + `langchain/` + `manager.py` |
| finetune | `finetune/` | 模型微调流水线 | `api/` + `domain/services/` + `domain/models/` |
| share_link | `share_link/` | 公开分享链接管理 | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` |
| telemetry_search | `telemetry_search/` | 遥测数据检索与可视化 | `api/` + `domain/services/` + `domain/repositories/` + `domain/models/` |
| workstation | `workstation/` | 工作台后端 | `api/` + `domain/services/` + `domain/schemas/` |
| open_endpoints | `open_endpoints/` | v2 RPC 接口,面向外部系统集成 | `api/` + `domain/schemas/` |
### 非独立模块
以下模块没有独立的顶级目录,其路由定义在 `api/v1/` 中,数据模型在 `database/models/` 中:
| 路由 | 来源 | 职责 |
|------|------|------|
| assistant | `api/v1/assistant.py` | AI 助手生命周期管理 |
| evaluation | `api/v1/evaluation.py` | 模型评测 |
| audit | `api/v1/audit.py` | 审计日志 |
| group | `api/v1/group.py` | 用户组管理 |
| tag | `api/v1/tag.py` | 标签管理 |
| mark | `api/v1/mark.py` | 数据标注 |
| flows | `api/v1/flows.py` | 应用流程管理 |
| skillcenter | `api/v1/skillcenter.py` | 技能中心 |
| variable | `api/v1/variable.py` | 变量管理 |
| report | `api/v1/report.py` | 报表生成 |
| invite_code | `api/v1/invite_code.py` | 邀请码管理 |
## API 路由注册
路由注册在 `api/router.py` 中完成,分为两个版本:
**v1 路由**`/api/v1`,共 29 个路由),面向前端应用:
```
chat, endpoints, knowledge, knowledge_space, server, user, qa, variable,
report, finetune, assistant, group, audit, evaluation, tag, llm, workflow,
mark, workstation, skillcenter, flows, linsight, tool, invite_code,
session, share_link, telemetry_search, channel, message
```
**v2 RPC 路由**`/api/v2`,共 6 个路由),面向外部系统集成:
```
knowledge_rpc, filelib_rpc, chat_rpc, assistant_rpc, workflow_rpc, llm_rpc
```
v2 路由的实现位于 `open_endpoints/api/` 目录下,提供 RPC 风格的接口供第三方系统调用。
## DDD 分层约定
采用 DDD 分层的模块遵循以下目录结构约定:
```
module_name/
api/ # API 层:路由定义、请求/响应处理
router.py # FastAPI Router 注册
endpoints/ # 按功能拆分的路由文件
domain/ # 领域层:业务逻辑核心
services/ # 领域服务,封装业务规则
models/ # 领域模型(非 ORM,业务实体)
schemas/ # Pydantic 数据传输对象(DTO
repositories/ # 数据访问抽象
interfaces/ # 仓储接口定义
implementations/ # 仓储实现(访问 database/models/
```
请求处理的调用链路:
```
FastAPI Router (api/)
--> 领域服务 (domain/services/)
--> 仓储实现 (domain/repositories/implementations/)
--> ORM 模型 DAO (database/models/)
--> MySQL / Redis / Milvus / ES
```
部分模块(如 `workflow/``mcp_manage/`)由于业务特殊性,未采用标准 DDD 分层,而是按功能组件组织:`graph/``nodes/``edges/``callback/` 等。
## 核心基础设施(core/
`core/` 目录提供所有领域模块共享的基础设施能力。
### 上下文管理(context/
应用生命周期管理框架,负责基础设施资源的初始化和销毁。
- **`BaseContextManager[T]`** -- 通用基类,提供线程安全的延迟加载、自动缓存和健康检查
- **`ApplicationContextManager`** -- 编排所有上下文管理器,按依赖顺序初始化
- **`ContextRegistry`** -- 全局注册表,管理上下文实例的注册和查找
初始化顺序:`DatabaseManager` --> `RedisManager` --> `MinioManager` --> `EsConnManager` --> `HttpClientManager` --> `PromptManager`。关闭时逆序清理。
### 配置管理(config/
`settings.py` 基于 Pydantic Settings`Settings` 类包含约 40 个配置字段。配置加载优先级:
```
YAML 文件 (config.yaml) --> 环境变量 (bisheng_*) --> 数据库配置合并 --> Redis 缓存 (100s TTL)
```
支持 `!env ${VAR}` 语法从环境变量注入配置值。
### 数据库(database/
SQLAlchemy 引擎工厂,支持同步和异步双模式会话。连接池默认 `pool_size=100`。ORM 模型定义在 `database/models/` 目录下,共 24 个模型文件。
每个模型文件包含:
- **Base 模型** -- SQLModel ORM 定义(表结构)
- **Read/Create/Update Schema** -- Pydantic 数据校验模型
- **DAO 类** -- 数据访问对象,提供同步 `get_xxx()` 和异步 `aget_xxx()` 方法
现有模型:flow, flow_version, assistant, knowledge (via domain), session, message, user_link, user_group, role, role_access, group, group_resource, tag, evaluation, dataset, mark_task, mark_record, mark_app_user, audit_log, report, template, variable_value, recall_chunk, invite_code。
### 缓存(cache/
`RedisManager` 继承 `BaseContextManager`,提供 Redis 客户端的线程安全单例。支持同步和异步访问模式。
### 对象存储(storage/minio/
`MinioManager` 管理与 MinIO/S3 的连接。`BaseStorage` 抽象接口定义文件上传、下载、删除等操作,方便替换存储后端。
### 搜索引擎(search/elasticsearch/
`EsConnManager` 管理 Elasticsearch 连接,维护两个实例:主实例(文档检索)和统计实例(遥测数据)。
### 向量存储(vectorstore/
向量库集成层,支持 Milvus 稠密向量检索 + Elasticsearch BM25 关键词检索的混合检索模式。
### AI 服务(ai/
统一的 AI 模型服务封装层,按模型类型分子模块:
| 子模块 | 职责 |
|--------|------|
| `llm/` | 大语言模型调用封装 |
| `embeddings/` | 文本向量化服务 |
| `asr/` | 语音识别(Automatic Speech Recognition |
| `tts/` | 语音合成(Text-to-Speech |
| `rerank/` | 重排序模型 |
### 提示词管理(prompts/
`PromptManager` 管理系统级提示词模板,YAML 格式存储,按场景加载。
### 外部服务(external/
HTTP 客户端管理和外部服务集成:
- `http_client/` -- `HttpClientManager`,统一的 HTTP 客户端(连接池、超时、重试)
- `bisheng_information_client/` -- 情报中心服务客户端
## bisheng_langchain 扩展包
独立的 LangChain 扩展包,位于 `src/backend/bisheng_langchain/`,作为单独的 Python 包安装,被主应用 `bisheng` 导入使用。
包含以下子模块:
| 子模块 | 职责 |
|--------|------|
| `chains/` | 自定义 Chain 实现(QA、检索、路由等) |
| `chat_models/` | 聊天模型适配器 |
| `document_loaders/` | 文档加载器扩展 |
| `embeddings/` | Embedding 模型适配器 |
| `vectorstores/` | 向量存储适配器 |
| `rag/` | RAG 检索增强管道(评分、重排序、检索器初始化) |
| `agents/` | Agent 实现(LLM Functions Agent、ChatGLM Functions Agent |
| `gpts/` | GPTs 工具集(Web 搜索、代码解释器、SQL Agent、DALL-E 等) |
| `linsight/` | Linsight Agent 核心逻辑 |
| `memory/` | 会话记忆管理 |
| `sql/` | SQL 执行相关 |
| `retrievers/` | 自定义检索器 |
| `input_output/` | 输入输出处理 |
| `utils/` | 工具函数 |
## 错误码体系
错误码基类 `BaseErrorCode` 定义在 `common/errcode/base.py` 中,采用 5 位数编码方案:
```
错误码格式: MMMEE
MMM = 模块编码(前 3 位)
EE = 模块内错误序号(后 2 位)
```
### 模块编码分配
| 模块编码 | 模块 | 错误码范围 | 定义文件 |
|----------|------|-----------|----------|
| 100 | server(基础服务) | 10000-10099 | `server.py` |
| 101 | finetune(微调) | 10100-10199 | `finetune.py` |
| 103 | component(组件) | 10300-10399 | `component.py` |
| 104 | assistant(助手) | 10400-10499 | `assistant.py` |
| 105 | flow(应用/工作流) | 10500-10599 | `flow.py` |
| 106 | user(用户) | 10600-10699 | `user.py` |
| 108 | llm(模型管理) | 10800-10899 | `llm.py` |
| 109 | knowledge(知识库) | 10900-10999 | `knowledge.py` |
| 110 | linsight | 11000-11099 | `linsight.py` |
| 120 | workstation(工作台) | 12000-12099 | `workstation.py` |
| 130 | chat/channel | 13000-13099 | `chat.py`, `channel.py` |
| 140 | message(消息) | 14000-14099 | `message.py` |
| 150 | tool(工具) | 15000-15099 | `tool.py` |
| 160 | dataset(数据集) | 16000-16099 | `dataset.py` |
| 170 | telemetry(遥测) | 17000-17099 | `telemetry.py` |
| 180 | knowledge_space(知识空间) | 18000-18099 | `knowledge_space.py` |
### 错误返回格式
`BaseErrorCode` 支持三种错误返回格式,适配不同通信协议:
| 方法 | 用途 | 输出格式 |
|------|------|----------|
| `return_resp()` | HTTP 响应 | `UnifiedResponseModel` JSON |
| `to_sse_event()` | SSE 事件流 | `event: error\ndata: {...}\n\n` |
| `websocket_close_message()` | WebSocket 关闭 | JSON 消息 + 连接关闭 |
## 统一响应模型
所有 API 响应遵循 `UnifiedResponseModel` 结构,定义在 `common/schemas/api.py` 中:
```python
class UnifiedResponseModel(BaseModel, Generic[DataT]):
status_code: int # 状态码,200 表示成功,其他为错误码
status_message: str # 状态描述
data: DataT = None # 响应数据
```
辅助函数:
| 函数 | 用途 |
|------|------|
| `resp_200(data, message)` | 成功响应 |
| `resp_500(code, data, message)` | 业务错误响应 |
分页数据模型:
| 模型 | 字段 | 说明 |
|------|------|------|
| `PageList[T]` | `list: List[T]`, `total: int` | 旧版分页(兼容保留) |
| `PageData[T]` | `data: List[T]`, `total: int` | 新版分页(推荐使用) |
SSE 流式响应使用 `SSEResponse` 模型,输出格式为 `event: {event}\ndata: {data}\n\n`
## Worker / Celery 任务
异步任务处理基于 Celery,代码位于 `worker/` 目录。
### 任务队列架构
```
Celery Broker (Redis)
|
+--> knowledge_celery 队列
| file_worker.py -- 文件解析、知识库文件处理
| qa.py -- QA 问答对生成
| rebuild_knowledge_worker.py -- 知识库重建、分块重建
|
+--> workflow_celery 队列
| tasks.py -- execute_workflow, continue_workflow, stop_workflow
|
+--> celery 默认队列
article.py -- sync_information_article(情报中心文章同步)
mid_table.py -- 遥测统计中间表同步
```
### 已注册任务
| 任务函数 | 队列 | 职责 |
|----------|------|------|
| `parse_knowledge_file_celery` | knowledge_celery | 解析知识库文件、生成向量 |
| `file_copy_celery` | knowledge_celery | 文件复制 |
| `retry_knowledge_file_celery` | knowledge_celery | 失败文件重试 |
| `rebuild_knowledge_celery` | knowledge_celery | 知识库整体重建 |
| `rebuild_knowledge_file_chunk` | knowledge_celery | 单文件分块重建 |
| `execute_workflow` | workflow_celery | 执行工作流 |
| `continue_workflow` | workflow_celery | 恢复暂停的工作流(用户输入后继续) |
| `stop_workflow` | workflow_celery | 停止运行中的工作流 |
| `sync_information_article` | celery | 同步情报中心文章 |
| `sync_mid_user_increment` | celery | 同步用户增量统计 |
| `sync_mid_knowledge_increment` | celery | 同步知识库增量统计 |
| `sync_mid_app_increment` | celery | 同步应用增量统计 |
| `sync_mid_user_interact_dtl` | celery | 同步用户交互明细 |
### Beat 定时任务
Celery Beat 定时调度器配置在 `Settings.celery_task.beat_schedule` 中,典型的定时任务包括:
- 每日凌晨同步遥测统计中间表(用户/知识库/应用增量)
- 每日同步情报中心文章
### Worker 心跳机制
Worker 启动后,通过后台线程每 5 秒向 Redis 写入心跳时间戳(`celery_worker_alive_queues` 哈希键),用于监控 Worker 存活状态。
## 相关文档
| 文档 | 说明 |
|------|------|
| [系统架构全景图](./01-architecture-overview.md) | 运行时组件、请求数据流、技术栈 |
| [工作流引擎](./03-workflow-engine.md) | 工作流 DAG 执行引擎详解 |
| [知识库与 RAG 管道](./04-knowledge-rag.md) | 文档处理流水线、双向量存储 |
| [Linsight Agent 与 MCP](./05-linsight-agent.md) | Agent 框架与 MCP 协议集成 |
| [数据模型与存储层](./07-data-models.md) | ORM 模型定义、DAO 模式 |
-555
View File
@@ -1,555 +0,0 @@
# 工作流引擎架构
BiSheng 工作流引擎是一个基于 LangGraph 的 DAG(有向无环图)执行引擎,负责将用户在前端画布上编排的工作流 JSON 编译为 LangGraph 状态机并驱动执行。引擎支持 14 种可执行节点类型(另有 1 种仅用于画布标注的 NOTE 节点),内置中断/恢复机制以支持人机交互场景,并通过 Celery 异步任务队列实现后台执行与横向扩展。
## 架构总览
```mermaid
graph TB
subgraph 前端
Canvas[工作流画布<br>@xyflow/react]
end
subgraph API层
API[FastAPI Router<br>api/v1/workflow]
Service[WorkFlowService<br>api/services/workflow]
end
subgraph Celery Worker
Task[execute_workflow / continue_workflow<br>worker/workflow/tasks.py]
RC[RedisCallback<br>worker/workflow/redis_callback.py]
end
subgraph 工作流引擎核心
WF[Workflow<br>graph/workflow.py]
GE[GraphEngine<br>graph/graph_engine.py]
GS[GraphState<br>graph/graph_state.py]
EM[EdgeManage<br>edges/edges.py]
NF[NodeFactory<br>nodes/node_manage.py]
SG[LangGraph StateGraph<br>+ MemorySaver]
end
subgraph 节点实现
START[StartNode]
LLM[LLMNode]
INPUT[InputNode]
OUTPUT[OutputNode]
COND[ConditionNode]
OTHER[其他节点...]
end
Canvas -->|工作流 JSON| API
API --> Service
Service -->|发送 Celery 任务| Task
Task --> RC
Task --> WF
WF --> GE
GE --> EM
GE --> NF
GE --> GS
GE --> SG
NF --> START
NF --> LLM
NF --> INPUT
NF --> OUTPUT
NF --> COND
NF --> OTHER
RC -.->|事件流| API
API -.->|SSE/WebSocket| Canvas
```
## 节点类型注册表
所有节点类型定义在 `workflow/common/node.py``NodeType` 枚举中,节点类注册在 `workflow/nodes/node_manage.py``NODE_CLASS_MAP` 中。
| NodeType | 枚举值 | 节点类 | 源文件 | 说明 |
|----------|--------|--------|--------|------|
| START | `start` | `StartNode` | `nodes/start/start.py` | 工作流入口节点,每个工作流有且仅有一个 |
| END | `end` | `EndNode` | `nodes/end/end.py` | 工作流终止节点,可有多个 |
| INPUT | `input` | `InputNode` | `nodes/input/input.py` | 用户输入节点,暂停执行等待用户提交数据 |
| OUTPUT | `output` | `OutputNode` | `nodes/output/output.py` | 输出展示节点,支持选择性交互(输入/选择) |
| FAKE_OUTPUT | `fake_output` | `OutputFakeNode` | `nodes/output/output_fake.py` | OUTPUT 的辅助节点,处理中断判定逻辑(不在注册表中,由引擎自动创建) |
| LLM | `llm` | `LLMNode` | `nodes/llm/llm.py` | 大语言模型调用节点,支持流式输出 |
| AGENT | `agent` | `AgentNode` | `nodes/agent/agent.py` | 智能体节点,可调用工具完成复杂任务 |
| CODE | `code` | `CodeNode` | `nodes/code/code.py` | 自定义代码执行节点 |
| CONDITION | `condition` | `ConditionNode` | `nodes/condition/condition.py` | 条件分支节点,根据条件路由到不同下游 |
| TOOL | `tool` | `ToolNode` | `nodes/tool/tool.py` | 工具调用节点 |
| RAG | `rag` | `RagNode` | `nodes/rag/rag.py` | RAG 检索增强生成节点 |
| KNOWLEDGE_RETRIEVER | `knowledge_retriever` | `KnowledgeRetriever` | `nodes/knowledge_retriever/knowledge_retriever.py` | 知识库检索节点 |
| QA_RETRIEVER | `qa_retriever` | `QARetrieverNode` | `nodes/qa_retriever/qa_retriever.py` | QA 问答对检索节点 |
| REPORT | `report` | `ReportNode` | `nodes/report/report.py` | 报告生成节点 |
| NOTE | `note` | -- | -- | 画布标注节点,仅用于显示注释,不参与执行 |
所有路径前缀为 `src/backend/bisheng/workflow/`
## 图引擎核心流程
`GraphEngine``workflow/graph/graph_engine.py`)是工作流执行的核心类,负责将工作流 JSON 数据编译为 LangGraph 状态机并驱动执行。
### 构建阶段
构造函数中按顺序完成以下步骤:
```
GraphEngine.__init__()
|
+-- build_edges() # 1. 解析边数据,构建 EdgeManage
|
+-- build_nodes() # 2. 构建节点与图结构
|
+-- init_nodes() # 2a. 实例化所有节点
| +-- NodeFactory # 通过工厂创建节点实例
| +-- add_node to graph # 注册到 LangGraph StateGraph
| +-- 识别特殊节点 # START / END / INPUT / OUTPUT
|
+-- build_node_level() # 2b. 计算节点层级(BFS 最长路径)
|
+-- add_node_edge() # 2c. 为每个节点添加边
| +-- 普通节点 → add_edge
| +-- CONDITION → add_conditional_edges
| +-- OUTPUT → 插入 FakeNode + conditional_edges
|
+-- build_more_fan_in_node() # 2d. 处理多入边汇聚节点
|
+-- compile() # 2e. 编译 LangGraph 图
+-- MemorySaver # 启用检查点
+-- interrupt_before # 设置中断节点列表
```
### 关键内部状态
| 属性 | 类型 | 说明 |
|------|------|------|
| `nodes_map` | `Dict[str, BaseNode]` | 节点 ID 到节点实例的映射 |
| `nodes_fan_in` | `Dict[str, List[str]]` | 记录每个节点有哪些前驱节点 |
| `nodes_next_nodes` | `Dict[str, Set[str]]` | 记录每个节点的所有后续节点 |
| `node_level` | `Dict[str, int]` | 从 START 到每个节点的最长路径层级 |
| `condition_nodes` | `List[str]` | 所有互斥分支节点(CONDITION 和选择性交互的 OUTPUT |
| `graph_state` | `GraphState` | 全局变量池 |
| `graph` | `CompiledStateGraph` | 编译后的 LangGraph 图 |
### 执行阶段
```python
# 首次执行
graph_engine.run()
-> _run({'flag': True})
-> graph.stream(input_data, config=graph_config) # LangGraph 驱动
# 中断后恢复
graph_engine.continue_run(data={node_id: {key: value}})
-> nodes_map[node_id].handle_input(node_params) # 注入用户输入
-> _run(None) # 从检查点恢复
-> graph.stream(None, config=graph_config)
```
执行配置:
```python
graph_config = {
'configurable': {'thread_id': '1'},
'recursion_limit': (节点数 - END节点数 - 1) * max_steps + END节点数 + 1
}
```
`recursion_limit` 根据节点数量和最大步数动态计算,确保循环工作流不会无限执行。
## GraphState 变量池
`GraphState``workflow/graph/graph_state.py`)是节点间数据传递的核心机制,每个 `GraphEngine` 实例持有一个 `GraphState` 对象。
### 变量存取
```
变量池结构: {node_id: {variable_key: value}}
写入: node._run() 返回 dict → GraphEngine 调用 graph_state.set_variable(node_id, key, value)
读取: 下游节点调用 graph_state.get_variable(node_id, key) 获取上游输出
```
### 变量引用格式
节点参数中通过字符串引用其他节点的输出变量,格式为:
```
{node_id}.{variable_key} # 基础引用
{node_id}.{variable_key}#{index} # 数组索引或字典键引用
```
`get_variable_by_str(contact_key)` 方法解析上述格式,支持从列表(按整数索引)或字典(按字符串键)中提取子元素。
### 对话历史
`GraphState` 还管理多轮对话历史,通过内置的 `ConversationBufferWindowMemory` 实现:
- `save_context(content, msg_sender)` -- 保存用户或 AI 的消息
- `get_history_memory(count)` -- 获取最近 N 条对话历史
- `get_history_list(count)` -- 获取消息对象列表
特殊变量键 `chat_history` 会自动路由到对话历史接口。
## 边管理与条件路由
`EdgeManage``workflow/edges/edges.py`)管理工作流中节点之间的连接关系。
### 边数据模型
```python
class EdgeBase:
id: str # 边唯一标识
source: str # 源节点 ID
sourceHandle: str # 源节点输出句柄
target: str # 目标节点 ID
targetHandle: str # 目标节点输入句柄
```
### 三种连边方式
引擎在 `add_node_edge()` 中根据节点类型使用不同的 LangGraph 连边策略:
1. **普通边** -- 用于大多数节点,直接调用 `graph_builder.add_edge(source, target)`
2. **条件边** -- 用于 `CONDITION` 节点,调用 `add_conditional_edges(node_id, route_node, mapping)`,由节点的 `route_node()` 方法动态决定下游
3. **OUTPUT 节点的特殊处理** -- OUTPUT 节点后自动插入一个 `OutputFakeNode`,通过 `add_edge` 连接 OUTPUT 到 FakeNode,再通过 `add_conditional_edges` 从 FakeNode 路由到实际下游
### 多入边汇聚(Fan-in)处理
当一个节点有多个前驱节点时(fan-in),引擎通过 `build_more_fan_in_node()``parse_fan_in_node()` 判断是否需要等待所有前驱完成:
- **互斥分支汇聚** -- 如果前驱节点来自 CONDITION 的不同互斥分支,则不需要等待(任一分支到达即可执行)
- **并行汇聚** -- 如果前驱节点是并行路径,则需要等待所有前驱完成后再执行(LangGraph 的 fan-in 语义)
判定算法通过 `get_all_edges_nodes()` 遍历所有从条件节点到目标节点的路径,检查是否存在两条完全不相交的路径来确定互斥关系。
## 回调系统
回调系统定义在 `workflow/callback/` 目录下,负责将引擎执行过程中的事件传递给外部消费者。
### BaseCallback 接口
`BaseCallback``workflow/callback/base_callback.py`)定义了以下回调方法:
| 回调方法 | 事件数据类 | 触发时机 |
|----------|-----------|----------|
| `on_node_start` | `NodeStartData` | 节点开始执行 |
| `on_node_end` | `NodeEndData` | 节点执行完成(含错误原因和日志数据) |
| `on_user_input` | `UserInputData` | INPUT 节点请求用户输入 |
| `on_guide_word` | `GuideWordData` | 引导语输出 |
| `on_guide_question` | `GuideQuestionData` | 推荐问题输出 |
| `on_stream_msg` | `StreamMsgData` | LLM 流式 token 输出 |
| `on_stream_over` | `StreamMsgOverData` | LLM 流式输出结束 |
| `on_output_msg` | `OutputMsgData` | OUTPUT 节点完整消息输出 |
| `on_output_choose` | `OutputMsgChooseData` | OUTPUT 节点选择交互 |
| `on_output_input` | `OutputMsgInputData` | OUTPUT 节点输入交互 |
### 事件数据结构
所有事件数据类定义在 `workflow/callback/event.py`,关键字段:
- `unique_id` -- 本次节点执行的唯一标识(UUID hex)
- `node_id` -- 节点 ID
- `name` -- 节点名称
- `msg` -- 消息内容
- `output_key` -- 输出变量键名
- `reasoning_content` -- LLM 推理过程内容(思维链)
- `source_documents` -- 溯源文档
### LLM 流式回调
`LLMNodeCallbackHandler``workflow/callback/llm_callback.py`)实现了 LangChain 的 `BaseCallbackHandler`,桥接 LLM 的流式输出到工作流回调系统:
- `on_llm_new_token` -- 每个 token 生成时触发 `on_stream_msg`
- `on_llm_end` -- 生成结束时触发 `on_stream_over`(流式)或 `on_output_msg`(命中缓存/非流式)
- 支持工具调用事件跟踪(`on_tool_start` / `on_tool_end` / `on_tool_error`
## 中断与恢复机制
工作流引擎支持在执行过程中暂停等待用户输入,然后从暂停点恢复执行。这一机制基于 LangGraph 的 `interrupt_before` 功能实现。
### 中断触发
两种节点可以触发中断:
1. **INPUT 节点** -- 始终触发中断,等待用户输入表单数据或对话消息
2. **OUTPUT 节点**(通过 FakeNode-- 当 OUTPUT 配置了交互式输入或选择时触发
引擎在编译时将所有 INPUT 节点和 FakeNode 加入 `interrupt_before` 列表。当 LangGraph 执行到这些节点前,会自动暂停并返回控制权。
### 状态判定
`judge_status()` 方法在每次 `graph.stream()` 返回后检查状态:
```
graph.stream() 返回
|
+-- snapshot = graph.get_state(config)
+-- next_nodes = snapshot.next
|
+-- next_nodes 为空 → WorkflowStatus.SUCCESS
+-- next_nodes 包含 INPUT → WorkflowStatus.INPUT
| +-- 触发 on_user_input 回调
+-- next_nodes 包含 FAKE_OUTPUT → WorkflowStatus.INPUT
```
### 恢复流程
```
用户提交输入
|
+-- RedisCallback.set_user_input(data) # 写入 Redis
+-- continue_workflow.delay(...) # 触发 Celery 任务
|
+-- Workflow.run(input_data)
+-- graph_engine.continue_run(data)
+-- nodes_map[node_id].handle_input(params) # 注入输入
+-- _run(None) # 从检查点恢复
+-- graph.stream(None, config)
```
### 状态机
```mermaid
stateDiagram-v2
[*] --> WAITING : Celery 任务入队
WAITING --> RUNNING : Worker 开始执行
RUNNING --> SUCCESS : 所有节点执行完毕
RUNNING --> FAILED : 执行异常
RUNNING --> INPUT : 遇到 INPUT/OUTPUT 中断节点
INPUT --> INPUT_OVER : 用户提交输入
INPUT_OVER --> RUNNING : continue_workflow 恢复执行
RUNNING --> FAILED : 用户手动停止
```
## Celery 异步执行
工作流通过 Celery 在独立的 Worker 进程中异步执行,避免阻塞 API 服务器。相关代码在 `worker/workflow/` 目录下。
### 任务定义
文件 `worker/workflow/tasks.py` 定义了三个 Celery 任务:
| 任务 | 函数 | 队列 | 说明 |
|------|------|------|------|
| 执行工作流 | `execute_workflow` | `workflow_celery` | 首次启动工作流执行 |
| 恢复工作流 | `continue_workflow` | `workflow_celery` | 用户输入后恢复暂停的工作流 |
| 停止工作流 | `stop_workflow` | `workflow_celery` | 强制停止运行中的工作流 |
### 执行流程
```
API 请求
|
+-- RedisCallback.set_workflow_data(data) # 工作流数据写入 Redis
+-- RedisCallback.set_workflow_status(WAITING) # 状态设为等待
+-- execute_workflow.delay(unique_id, ...) # 投递 Celery 任务
|
+-- API 返回 SSE 流
+-- RedisCallback.get_response_until_break() # 轮询事件队列
```
### RedisCallback
`RedisCallback``worker/workflow/redis_callback.py`)继承 `BaseCallback`,是异步执行场景下的核心回调实现。它承担以下职责:
1. **事件桥接** -- 将引擎回调事件序列化为 `ChatResponse`,写入 Redis 列表(`workflow:{unique_id}:event`
2. **状态管理** -- 在 Redis 中维护工作流状态(`workflow:{unique_id}:status`
3. **数据传递** -- 通过 Redis 存储工作流 JSON 数据和用户输入
4. **消息持久化** -- 将输出消息保存到数据库(ChatMessage),管理会话生命周期
5. **停止信号** -- 通过 Redis key 传递停止信号(`workflow:{unique_id}:stop`
Redis 键命名规范:
| Redis Key | 用途 | 过期时间 |
|-----------|------|----------|
| `workflow:{unique_id}:data` | 工作流 JSON 数据 | timeout + 60s |
| `workflow:{unique_id}:status` | 执行状态与失败原因 | 7 天 |
| `workflow:{unique_id}:event` | 事件消息队列 | timeout + 60s |
| `workflow:{unique_id}:input` | 用户输入数据 | timeout + 60s |
| `workflow:{unique_id}:stop` | 停止信号 | 24 小时 |
### 工作流对象缓存
Worker 进程内维护全局字典 `_global_workflow` 缓存暂停中的 `Workflow` 对象。当工作流进入 INPUT 状态时,对象保留在内存中以便恢复时跳过重新构建。执行完成或失败时从缓存中清除。
`StatefulWorker` 机制确保同一工作流的 `execute_workflow``continue_workflow` 任务被路由到同一个 Worker 节点,保证内存中的 `Workflow` 对象可被正确访问。
## BaseNode 基类
所有可执行节点继承自 `BaseNode``workflow/nodes/base.py`),它定义了节点的标准生命周期。
### 节点执行生命周期
```python
def run(self, state: dict) -> Any:
# 1. 检查停止标志
if self.stop_flag:
raise IgnoreException('stop by user')
# 2. 检查最大执行次数
if self.current_step >= self.max_steps:
raise IgnoreException(f'{self.name} -- has run more than the maximum number of times.')
# 3. 生成执行唯一 ID,触发 on_node_start 回调
exec_id = uuid.uuid4().hex
self.callback_manager.on_node_start(...)
# 4. 调用子类实现的 _run(exec_id)
result = self._run(exec_id)
# 5. 解析执行日志
log_data = self.parse_log(exec_id, result)
# 6. 将返回结果存入 GraphState 全局变量池
for key, value in result.items():
self.graph_state.set_variable(self.id, key, value)
# 7. 步数计数器递增
self.current_step += 1
# 8. 触发 on_node_end 回调(OUTPUT 节点例外,由 FakeNode 触发)
self.callback_manager.on_node_end(...)
```
### 子类需要实现的方法
| 方法 | 是否必须 | 说明 |
|------|----------|------|
| `_run(unique_id)` | 必须 | 节点核心逻辑,返回 `Dict[str, Any]` 作为输出变量 |
| `parse_log(unique_id, result)` | 可选 | 返回节点执行日志,默认返回空列表 |
| `route_node(state)` | 条件节点必须 | 条件路由方法,返回目标节点 ID |
| `get_input_schema()` | 交互节点可选 | 返回用户输入表单描述 |
| `handle_input(user_input)` | 交互节点可选 | 处理用户输入数据 |
### Workflow 包装类
`Workflow``workflow/graph/workflow.py`)封装了 `GraphEngine`,增加超时管理和对话历史功能:
- 自动将用户输入保存到 `GraphState` 的对话历史中
-`run()` 中循环调用 `continue_run()` 直到状态不再是 RUNNING(处理连续中断场景)
- 提供 `stop()` 方法向所有节点传播停止信号
## 配置
工作流相关配置在 `core/config/settings.py``WorkflowConf` 类中定义:
```python
class WorkflowConf(BaseModel):
max_steps: int = 50 # 单个节点最大执行次数
timeout: int = 720 # 超时时间(分钟)
```
- `max_steps` 限制单个节点在一次工作流执行中的最大运行次数,防止无限循环
- `timeout` 控制 Redis 中工作流数据的过期时间,以及等待用户输入的超时时间
## 扩展指南:添加新节点类型
以下是添加一个新节点类型的完整步骤:
### 1. 注册枚举值
`workflow/common/node.py``NodeType` 枚举中添加:
```python
class NodeType(Enum):
# ... 已有类型
MY_NODE = "my_node"
```
### 2. 实现节点类
`workflow/nodes/` 下创建目录和实现文件:
```
workflow/nodes/my_node/
__init__.py
my_node.py
```
节点类需继承 `BaseNode` 并实现 `_run` 方法:
```python
from bisheng.workflow.nodes.base import BaseNode
class MyNode(BaseNode):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 从 self.node_params 中读取节点配置参数
def _run(self, unique_id: str) -> dict:
# 1. 通过 self.get_other_node_variable() 获取上游输出
# 2. 执行节点逻辑
# 3. 返回输出变量字典
return {"output_key": "output_value"}
def parse_log(self, unique_id: str, result: dict):
# 可选:返回执行日志
return [[{"key": "结果", "value": result.get("output_key"), "type": "variable"}]]
```
### 3. 注册到工厂
`workflow/nodes/node_manage.py` 中添加映射:
```python
from bisheng.workflow.nodes.my_node.my_node import MyNode
NODE_CLASS_MAP = {
# ... 已有映射
NodeType.MY_NODE.value: MyNode,
}
```
### 4. 条件节点特殊处理
如果新节点需要条件路由能力(类似 CONDITION),还需要:
- 重写 `is_condition_node()` 返回 `True`
- 实现 `route_node(state)` 方法返回目标节点 ID
## 相关文档
- 项目总体架构: `docs/architecture/README.md`
- 知识库/RAG 架构: 参见 `src/backend/bisheng/knowledge/`
- Celery Worker 配置: `src/backend/bisheng/worker/`
- 前端工作流画布: `src/frontend/platform/src/pages/BuildPage/flow/`
- 工作流 API 接口: `src/backend/bisheng/api/v1/workflow.py`
## 源码目录结构
```
src/backend/bisheng/workflow/
common/
node.py # NodeType 枚举、BaseNodeData、NodeParams 数据模型
workflow.py # WorkflowStatus 枚举
graph/
graph_engine.py # GraphEngine 核心引擎
graph_state.py # GraphState 变量池
workflow.py # Workflow 包装类
edges/
edges.py # EdgeManage 边管理
nodes/
base.py # BaseNode 抽象基类
node_manage.py # NodeFactory 工厂 + NODE_CLASS_MAP 注册表
prompt_template.py # 模板变量解析
start/ # StartNode
end/ # EndNode
input/ # InputNode
output/ # OutputNode + OutputFakeNode
llm/ # LLMNode
agent/ # AgentNode
code/ # CodeNode
condition/ # ConditionNode
tool/ # ToolNode
rag/ # RagNode
knowledge_retriever/ # KnowledgeRetriever
qa_retriever/ # QARetrieverNode
report/ # ReportNode
callback/
base_callback.py # BaseCallback 抽象接口
event.py # 事件数据结构
llm_callback.py # LLM 流式回调适配器
src/backend/bisheng/worker/workflow/
tasks.py # Celery 任务定义
redis_callback.py # RedisCallback 异步回调实现
```
-417
View File
@@ -1,417 +0,0 @@
# 知识库与 RAG 管道
知识库模块是毕昇平台的核心业务领域之一,负责文档的上传、解析、向量化与检索。整个处理流程采用三阶段管道架构(Load、Transform、Ingest),文档内容同时写入 Milvus 稠密向量库和 Elasticsearch 稀疏索引,实现语义检索与关键词检索的双路召回。异步文件处理由 Celery Worker 驱动,通过 `knowledge_celery` 队列实现任务解耦。
## 1. 领域模型
### 1.1 Knowledge
`Knowledge` 实体(`knowledge/domain/models/knowledge.py`)是知识库的核心聚合根,主要字段如下:
| 字段 | 类型 | 说明 |
|------|------|------|
| `user_id` | int | 创建者用户 ID |
| `name` | str | 知识库名称(1-200 字符) |
| `type` | int | 知识库类型,取值见 `KnowledgeTypeEnum` |
| `description` | str | 描述 |
| `model` | str | Embedding 模型 ID |
| `collection_name` | str | Milvus Collection 名称 |
| `index_name` | str | Elasticsearch 索引名称 |
| `state` | int | 知识库状态,取值见 `KnowledgeState` |
| `auth_type` | AuthTypeEnum | 权限类型(PUBLIC / PRIVATE / APPROVAL |
| `metadata_fields` | List[Dict] | 用户自定义元数据字段配置 |
| `is_released` | bool | 是否发布到知识广场 |
### 1.2 知识库类型(KnowledgeTypeEnum
| 枚举值 | 数值 | 说明 |
|--------|------|------|
| NORMAL | 0 | 文档知识库 |
| QA | 1 | 问答知识库 |
| PRIVATE | 2 | 工作台个人知识库 |
| SPACE | 3 | 知识空间 |
### 1.3 知识库状态(KnowledgeState
| 枚举值 | 数值 | 说明 |
|--------|------|------|
| UNPUBLISHED | 0 | 未发布 |
| PUBLISHED | 1 | 已发布(正常状态) |
| COPYING | 2 | 复制中 |
| REBUILDING | 3 | 重建中(切换 Embedding 模型时触发) |
| FAILED | 4 | 重建失败 |
### 1.4 KnowledgeFile
`KnowledgeFile` 实体(`knowledge/domain/models/knowledge_file.py`)记录知识库中每个文件的处理状态,主要字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `knowledge_id` | int | 所属知识库 ID |
| `file_name` | str | 文件名(最大 200 字符) |
| `file_type` | int | 0 = 目录,1 = 文件 |
| `file_source` | str | 来源(upload / channel / space_upload |
| `md5` | str | 文件 MD5,用于去重 |
| `parse_type` | str | 解析方式(etl4lm / mineru / paddle_ocr / local |
| `split_rule` | str | 分块规则 JSON |
| `status` | int | 处理状态,取值见 `KnowledgeFileStatus` |
| `object_name` | str | MinIO 中的源文件对象名 |
| `preview_file_object_name` | str | 预览文件对象名 |
| `bbox_object_name` | str | bbox 文件对象名(用于文档定位) |
| `user_metadata` | Dict | 用户自定义元数据值 |
| `remark` | str | 错误信息或备注 |
### 1.5 文件处理状态(KnowledgeFileStatus
| 枚举值 | 数值 | 说明 |
|--------|------|------|
| PROCESSING | 1 | 解析中 |
| SUCCESS | 2 | 解析成功 |
| FAILED | 3 | 解析失败 |
| REBUILDING | 4 | 重建中 |
| WAITING | 5 | 排队等待 |
| TIMEOUT | 6 | 解析超时(超过 24 小时) |
### 1.6 QAKnowledge
`QAKnowledge` 实体用于问答型知识库,存储问答对:
- `questions`:问题列表(JSON 数组)
- `answers`:答案文本
- `source`:来源标识(0=未知,1=手动,2=审核,3=API,4=批量导入)
- `status`:QA 状态(0=禁用,1=启用,2=处理中,3=插入失败)
### 1.7 元数据字段(MetadataFieldType
知识库支持为文件定义自定义元数据字段,字段类型包括:`STRING``NUMBER``TIME`。元数据值存储在 `KnowledgeFile.user_metadata` 中,同时同步到 Milvus 和 Elasticsearch 中,支持基于元数据的过滤检索。
## 2. DDD 分层结构
知识库模块采用领域驱动设计(DDD),目录结构如下:
```
knowledge/
api/
router.py # 路由导出:knowledge_router, qa_router, knowledge_space_router
endpoints/
knowledge.py # 文档知识库 API
knowledge_space.py # 知识空间 API
qa.py # 问答知识库 API
domain/
models/
knowledge.py # Knowledge 实体 + KnowledgeDao
knowledge_file.py # KnowledgeFile / QAKnowledge 实体 + DAO
schemas/ # 请求/响应 Pydantic Schema
services/
knowledge_service.py # 核心业务逻辑
knowledge_file_service.py # 文件级操作(元数据修改等)
knowledge_space_service.py # 知识空间业务逻辑
knowledge_permission_service.py # 权限校验
knowledge_audit_telemetry_service.py # 审计与遥测
knowledge_metadata_service.py # 元数据字段管理
repositories/
interfaces/
knowledge_repository.py # 知识库仓储接口
knowledge_file_repository.py # 文件仓储接口
knowledge_rag.py # KnowledgeRag:向量存储初始化工具类
rag/ # RAG 管道实现
base_file_pipeline.py # 文件管道基类
knowledge_file_pipeline.py # 知识库文件管道
preview_file_pipeline.py # 预览文件管道
milvus_factory.py # Milvus 实例工厂
elasticsearch_factory.py # Elasticsearch 实例工厂
pipeline/
base.py # BasePipeline / NormalPipeline
types.py # PipelineStage / PipelineConfig / PipelineResult
loader/ # 文档加载器
transformer/ # 文档转换器
```
## 3. RAG 管道架构
### 3.1 整体流程
```mermaid
flowchart LR
A[文件上传] --> B[MinIO 存储]
B --> C[Celery Task]
C --> D[Load 阶段]
D --> E[Transform 阶段]
E --> F[Ingest 阶段]
subgraph "Load"
D --> D1[选择 Loader]
D1 --> D2[解析为 Document]
end
subgraph "Transform"
E --> E1[摘要提取]
E1 --> E2[附件/图片处理]
E2 --> E3[缩略图生成]
E3 --> E4[文本分块]
E4 --> E5[预览缓存]
end
subgraph "Ingest"
F --> F1[Milvus 稠密向量]
F --> F2[Elasticsearch 稀疏索引]
end
```
### 3.2 管道阶段定义
管道通过 `PipelineStage` 枚举控制执行深度,支持在任意阶段提前终止:
| 阶段 | 枚举值 | 说明 |
|------|--------|------|
| LOAD | 1 | 仅加载源文档,不做任何转换 |
| TRANSFORMER | 2 | 完成所有转换,但不写入向量库 |
| INGEST | 10 | 完整管道,包括写入向量存储(默认) |
### 3.3 管道执行流程
`NormalPipeline``knowledge/rag/pipeline/base.py`)是标准执行器,同时支持同步和异步模式:
1. 调用 `loader.load()` 将源文件解析为 `List[Document]`
2. 依次执行各 `transformer.transform_documents(docs)` 进行文档转换
3. 遍历所有 `vectorstore`,调用 `add_documents(docs)` 写入向量存储
4. 返回 `PipelineResult`,包含阶段标记、文档列表和耗时
`BaseFilePipeline``knowledge/rag/base_file_pipeline.py`)封装了文件类型到加载器/转换器的映射关系。`run()` 方法创建临时目录,根据文件扩展名查找 `FileExtensionMap`,动态初始化对应的 Loader 和 Transformer 链,然后交由 `NormalPipeline` 执行。
`KnowledgeFilePipeline``knowledge/rag/knowledge_file_pipeline.py`)继承 `BaseFilePipeline`,绑定 `KnowledgeFile` 数据库记录,负责从 MinIO 下载源文件并构造文件元数据(文档 ID、知识库 ID、上传者、更新者、时间戳、用户自定义元数据等)。
## 4. 文档加载器
### 4.1 文件类型映射
`FileExtensionMap` 定义了每种文件扩展名对应的加载器和转换器初始化方法:
| 文件扩展名 | 加载器 | 说明 |
|-----------|--------|------|
| pdf | `_init_pdf_loader` | 根据配置选择解析引擎(见下文) |
| doc, docx | `BishengWordLoader` | Word 文档加载 |
| ppt, pptx | `BishengPptLoader` | PowerPoint 加载 |
| txt, md | `BishengTextLoader` | 纯文本 / Markdown |
| html, htm | `BishengHtmlLoader` | HTML 页面 |
| xlsx, xls, csv | `ExcelLoader` | 表格文件(使用独立的 Excel 转换链) |
| png, jpg, jpeg, bmp | `_init_image_loader` | 图片(复用 PDF 解析引擎做 OCR) |
所有加载器继承 `BaseBishengLoader``knowledge/rag/pipeline/loader/base.py`),统一接收 `file_path``file_metadata``file_extension``tmp_dir` 四个参数,输出 LangChain `Document` 列表。加载器还维护 `bbox_list`(文本区域坐标信息)和 `local_image_dir`(提取的图片目录)供后续转换器使用。
### 4.2 PDF 解析引擎
PDF 文件的加载器通过 `KnowledgeConf.loader_provider` 配置项动态选择,支持以下引擎:
| 引擎 | 配置值 | 类 | 说明 |
|------|--------|-----|------|
| ETL4LM | `etl4lm` | `Etl4lmLoader` | 默认引擎,外部文档解析服务,支持版面分析和公式识别,超时 600 秒 |
| MineRU | `mineru` | `MineruLoader` | 替代解析服务,超时 60 秒,支持自定义 Headers |
| PaddleOCR | `paddle_ocr` | `PaddleOcrLoader` | 基于 OCR 的解析,适合扫描件,支持认证 Token |
| 本地解析 | 无配置 | `LocalPdfLoader` | 兜底方案,使用本地 PDF 库直接解析 |
解析引擎的选择逻辑位于 `BaseFilePipeline._init_pdf_loader()`:按配置的 `loader_provider` 值匹配,若对应引擎的 URL 为空则回退到本地解析。图片文件(png/jpg/jpeg/bmp)复用 PDF 解析引擎进行 OCR,如果回退到本地解析器则抛出不支持异常。
### 4.3 解析引擎配置
配置位于 `core/config/settings.py`,通过 `config.yaml``knowledge` 段加载:
```yaml
knowledge:
loader_provider: "etl4lm" # 可选: etl4lm, mineru, paddle_ocr
etl4lm:
url: "http://..."
timeout: 600
ocr_sdk_url: "http://..."
mineru:
url: "http://..."
timeout: 60
headers: {}
request_kwargs: {}
paddle_ocr:
url: "http://..."
timeout: 60
auth_token: ""
```
## 5. 文档转换器
转换器位于 `knowledge/rag/pipeline/transformer/`,按顺序组成处理链,每个转换器实现 LangChain 的 `BaseDocumentTransformer` 接口:
| 转换器 | 类 | 说明 |
|--------|-----|------|
| 摘要提取 | `AbstractTransformer` | 利用 LLM 为文档生成摘要,可通过 `no_summary` 参数跳过 |
| 附件/图片处理 | `ExtraFileTransformer` | 处理文档中的内嵌图片,上传到 MinIO,可通过 `retain_images` 控制是否保留 |
| 缩略图生成 | `ThumbnailTransformer` | 为文档生成缩略图,可通过 `need_thumbnail` 参数控制 |
| 文本分块 | `SplitterTransformer` | 使用 `ElemCharacterTextSplitter` 进行文本切分 |
| 预览缓存 | `PreviewCacheTransformer` | 将解析结果写入 Redis 缓存,供前端预览使用 |
### 5.1 文本分块
`SplitterTransformer` 是管道中的关键转换步骤,核心参数:
- `separator`:自定义分隔符列表
- `separator_rule`:分隔符规则
- `chunk_size`:分块大小(默认 1000 字符)
- `chunk_overlap`:分块重叠(默认 100 字符)
分块完成后,每个 chunk 会附带 `chunk_index`(序号)、`bbox`(区域坐标 JSON)、`page`(所在页码)等元数据。单个 chunk 的文本长度上限为 10000 字符,超出则抛出异常。
### 5.2 Excel 专用转换链
Excel 类文件(xlsx/xls/csv)使用独立的转换链,跳过附件处理、缩略图和文本分块步骤,仅执行摘要提取和预览缓存。这是因为表格数据的分块逻辑由 `ExcelLoader` 在加载阶段直接完成,按行切片处理。
## 6. 向量存储策略
知识库采用双向量存储架构,文档同时写入 Milvus 和 Elasticsearch,实现混合检索:
### 6.1 Milvus(稠密向量)
- 用途:语义检索,基于 Embedding 相似度搜索
- 每个知识库对应一个 Collection(`Knowledge.collection_name`
- 向量由知识库绑定的 Embedding 模型生成(`Knowledge.model` 字段指定模型 ID
- 文档元数据字段包括:`document_id``knowledge_id``chunk_index``page``bbox``user_metadata`
- 初始化入口:`KnowledgeRag.init_knowledge_milvus_vectorstore()` / `MilvusFactory`
### 6.2 Elasticsearch(稀疏索引)
- 用途:关键词检索 / BM25 检索
- 每个知识库对应一个 Index`Knowledge.index_name`
- 存储文本原文和元数据(不含向量),通过全文索引实现关键词匹配
- 初始化入口:`KnowledgeRag.init_knowledge_es_vectorstore()` / `ElasticsearchFactory`
### 6.3 KnowledgeRag 工具类
`KnowledgeRag``knowledge/domain/knowledge_rag.py`)封装了向量存储的初始化逻辑,提供以下核心方法:
| 方法 | 说明 |
|------|------|
| `init_knowledge_milvus_vectorstore` | 初始化单个知识库的 Milvus 向量存储(异步) |
| `init_knowledge_es_vectorstore` | 初始化单个知识库的 ES 存储(异步) |
| `get_multi_knowledge_vectorstore_sync` | 批量初始化多个知识库的向量存储,用于跨知识库检索 |
以上方法均支持同步(`_sync` 后缀)和异步两种调用方式。Embedding 模型通过 `LLMService` 根据 `Knowledge.model` 字段动态加载。
## 7. 检索组件
### 7.1 bisheng_langchain 检索器
`bisheng_langchain` 扩展包(`src/backend/bisheng_langchain/rag/`)提供了多种检索器实现,用于 RAG 管道的检索阶段:
| 检索器 | 类 | 说明 |
|--------|-----|------|
| 关键词检索 | `KeywordRetriever` | 基于 Elasticsearch 的关键词匹配检索 |
| 基线向量检索 | `BaselineVectorRetriever` | 基于 Milvus 的标准向量相似度检索 |
| 混合检索 | `MixRetriever` | 结合向量检索和关键词检索 |
| 小块检索 | `SmallerChunksVectorRetriever` | 使用更小的分块进行精细检索,返回时映射回原始大块 |
| 集成检索 | `EnsembleRetriever` | 将多个检索器的结果进行融合排序 |
### 7.2 BishengRagPipeline
`BishengRagPipeline``bisheng_langchain/rag/bisheng_rag_pipeline.py`)是完整的 RAG 管道编排类,通过 YAML 配置文件驱动,整合以下组件:
- Embedding 模型初始化
- LLM 模型初始化
- Milvus 向量存储连接
- Elasticsearch 关键词存储连接
- 多检索器组合(通过 `EnsembleRetriever` 融合)
- QA Chain 问答生成
### 7.3 评分与评估
`bisheng_langchain/rag/scoring/` 提供了 RAG 质量评估工具:
- `RagScore``ragas_score.py`):基于 RAGAS 框架的自动化评估
- `llama_index_score.py`:基于 LlamaIndex 的评估方法
### 7.4 Rerank
`bisheng_langchain/rag/rerank/` 提供检索结果重排序功能,在初步召回后对候选文档进行精排,提升检索精度。
## 8. 异步任务处理
知识库的文件处理全部由 Celery Worker 异步执行,任务定义位于 `worker/knowledge/`
### 8.1 Worker 队列
知识库任务使用 `knowledge_celery` 队列,启动命令:
```bash
celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q knowledge_celery -n knowledge@%h
```
### 8.2 Celery 任务
| 任务函数 | 文件 | 说明 |
|---------|------|------|
| `parse_knowledge_file_celery` | `file_worker.py` | 解析上传的文件,构建向量索引 |
| `retry_knowledge_file_celery` | `file_worker.py` | 重试失败的文件解析(先删除旧向量再重新解析) |
| `delete_knowledge_file_celery` | `file_worker.py` | 删除文件及其向量数据 |
| `file_copy_celery` | `file_worker.py` | 复制知识库(含文件、向量、ES 索引的完整复制) |
| `rebuild_knowledge_celery` | `rebuild_knowledge_worker.py` | 重建知识库索引(切换 Embedding 模型时使用) |
| QA 相关任务 | `qa.py` | 问答对的处理和向量化 |
### 8.3 文件解析流程
`parse_knowledge_file_celery` 的执行流程:
1. 从数据库查询 `KnowledgeFile` 记录,校验状态为 WAITING 或 PROCESSING
2. 将状态更新为 PROCESSING
3. 解析分块规则 `FileProcessBase`
4. 调用 `process_file_task()` 执行完整的 Load -> Transform -> Ingest 管道
5. 任务完成后检查文件记录是否仍存在(可能在解析期间被用户删除),若不存在则清理向量数据
### 8.4 知识库重建
`rebuild_knowledge_celery` 用于在切换 Embedding 模型后重建向量索引:
1. 查询所有 SUCCESS 和 REBUILDING 状态的文件
2. 删除 Milvus 中的旧向量数据(保留 ES 数据)
3. 将文件状态更新为 REBUILDING
4. 从 ES 中读取已有的 chunk 文本,使用新模型重新生成 Embedding 并写入 Milvus
5. 更新文件状态为 SUCCESS 或 FAILED
6. 更新知识库状态
### 8.5 知识库复制
`file_copy_celery` 实现知识库的完整复制:
1. 分页遍历源知识库的所有文件,通过 MD5 跳过已存在的文件
2. 复制 MinIO 中的源文件、PDF 预览文件、bbox 文件
3. 复制 Milvus 向量数据(按批次 1000 条插入)
4. 复制 ES 索引数据
5. 更新知识库状态为 PUBLISHED
## 9. 权限模型
### 9.1 知识库权限类型(AuthTypeEnum
| 类型 | 说明 |
|------|------|
| PUBLIC | 公开,所有用户可访问 |
| PRIVATE | 私有,仅创建者和被授权者可访问 |
| APPROVAL | 审批,需申请后由管理者审批 |
### 9.2 权限校验
`KnowledgePermissionService``knowledge/domain/services/knowledge_permission_service.py`)提供集中式权限校验:
- `ensure_knowledge_read_async`:校验知识库读权限
- `ensure_knowledge_write_async`:校验知识库写权限
底层通过 `UserPayload.async_access_check()` 实现,基于 RBAC 模型:用户 -> 角色 -> 资源访问控制(`RoleAccessDao`)。管理员角色(role_id=1)拥有所有知识库的完整权限。
### 9.3 审计与遥测
`KnowledgeAuditTelemetryService``knowledge/domain/services/knowledge_audit_telemetry_service.py`)负责记录知识库操作的审计日志和遥测事件:
- 创建 / 删除知识库时记录审计日志
- 通过遥测服务上报知识库创建、删除等关键事件
## 10. 相关文档
- `docs/architecture/01-architecture-overview.md` -- 系统整体架构概述
- `src/backend/bisheng/core/config/settings.py` -- KnowledgeConf 配置定义
- `src/backend/bisheng/knowledge/` -- 知识库模块完整源码
- `src/backend/bisheng_langchain/rag/` -- RAG 检索器与评估工具
- `src/backend/bisheng/worker/knowledge/` -- Celery 异步任务定义
-393
View File
@@ -1,393 +0,0 @@
# Linsight Agent 框架与 MCP 协议集成
Linsight(灵思)是 BiSheng 平台内置的自主任务执行框架,面向需要多步骤推理、工具调用和人机交互的复杂任务场景。它通过 SOP(标准操作流程)将用户需求拆解为结构化的任务树,由 Agent 自主执行每个步骤,并在必要时暂停等待用户输入。MCPModel Context Protocol)协议集成为 Linsight 和工作流引擎提供了统一的外部工具接入能力,支持 SSE、Standard I/O 和 Streamable HTTP 三种传输方式。
## 整体架构
Linsight 采用独立 Worker 进程架构,通过 Redis 队列与主 API 服务解耦。任务提交后进入 Redis FIFO 队列,由 Worker 进程消费并驱动 Agent 执行。
```mermaid
graph TB
subgraph API 服务
A[用户提交任务] --> B[生成 SOP]
B --> C[创建 SessionVersion]
C --> D[推入 Redis 队列]
end
subgraph Redis
D --> E[LinsightQueue<br/>linsight:queue]
F[心跳键<br/>linsight:node:heartbeat:*]
G[任务归属<br/>linsight:task:owner:*]
H[状态数据<br/>linsight_tasks:*]
end
subgraph Worker 进程
E --> I[ScheduleCenterProcess]
I --> J[NodeManager<br/>心跳 + 任务归属]
I --> K[LinsightWorkflowTask<br/>任务执行器]
K --> L[LinsightAgent<br/>推理引擎]
L --> M[工具调用]
L --> N[用户交互]
L --> O[子任务生成]
end
subgraph 工具层
M --> P[内置工具<br/>代码解释器等]
M --> Q[MCP 工具<br/>外部服务]
M --> R[知识库检索]
end
subgraph 消息推送
K --> S[StateMessageManager]
S --> H
S --> T[WebSocket 推送<br/>MessageStreamHandle]
T --> U[前端实时展示]
end
```
## 任务生命周期状态机
任务执行涉及两层状态:**会话版本状态**(SessionVersion)控制整体流程,**执行任务状态**(ExecuteTask)控制单个步骤。
### 会话版本状态(SessionVersionStatusEnum
```mermaid
stateDiagram-v2
[*] --> NOT_STARTED
NOT_STARTED --> IN_PROGRESS : 开始执行
IN_PROGRESS --> COMPLETED : 所有任务成功
IN_PROGRESS --> FAILED : 执行异常
IN_PROGRESS --> TERMINATED : 用户主动终止
NOT_STARTED --> SOP_GENERATION_FAILED : SOP 生成失败
COMPLETED --> [*]
FAILED --> [*]
TERMINATED --> [*]
SOP_GENERATION_FAILED --> [*]
```
### 执行任务状态(ExecuteTaskStatusEnum
```mermaid
stateDiagram-v2
[*] --> NOT_STARTED
NOT_STARTED --> IN_PROGRESS : TaskStart 事件
IN_PROGRESS --> SUCCESS : 任务完成
IN_PROGRESS --> FAILED : 执行失败
IN_PROGRESS --> WAITING_FOR_USER_INPUT : NeedUserInput 事件
WAITING_FOR_USER_INPUT --> USER_INPUT_COMPLETED : 用户提交输入
USER_INPUT_COMPLETED --> IN_PROGRESS : 恢复执行
IN_PROGRESS --> TERMINATED : 用户终止
NOT_STARTED --> TERMINATED : 前置任务失败
```
## 事件系统
事件是 Agent 执行过程中向上层传递信息的核心机制。所有事件继承自 `BaseEvent`,包含 `task_id``timestamp` 两个基础字段。事件定义位于 `src/backend/bisheng_langchain/linsight/event.py`
| 事件类型 | 类名 | 触发时机 | 核心字段 |
|---------|------|---------|---------|
| 任务开始 | `TaskStart` | Agent 开始处理某个任务 | `name` |
| 任务结束 | `TaskEnd` | Agent 完成某个任务 | `name`, `status`, `answer`, `data` |
| 执行步骤 | `ExecStep` | 工具调用开始或结束 | `call_id`, `call_reason`, `name`, `params`, `output`, `step_type`, `status` |
| 需要用户输入 | `NeedUserInput` | Agent 判断需要人工介入 | `call_reason`, `params`, `step_type="call_user_input"` |
| 生成子任务 | `GenerateSubTask` | 循环任务拆分出子步骤 | `subtask` (子任务列表) |
事件流转路径:Agent 产生事件 -> `TaskManage.aqueue` 异步队列 -> `LinsightWorkflowTask._handle_event()` 分发 -> `LinsightStateMessageManager` 持久化到 Redis 和数据库 -> `MessageStreamHandle` 通过 WebSocket 推送到前端。
`ExecStep``step_type` 区分不同类型的步骤:`tool_call` 表示工具调用,`react_step` 表示推理步骤或固定回答,`call_user_input` 表示用户输入请求。
## Worker 架构
Worker 以独立进程运行,与 API 服务完全解耦。启动命令:
```bash
.venv/bin/python bisheng/linsight/worker.py --worker_num 4 --max_concurrency 5
```
源码位于 `src/backend/bisheng/linsight/worker.py`
### 核心组件
**LinsightQueue** — 基于 Redis List 的 FIFO 任务队列,键名为 `linsight:queue`。提供阻塞式消费(`get_wait`)和非阻塞消费(`get_nowait`),支持查询任务在队列中的位置(`index`)和删除指定任务(`remove`)。
**NodeManager** — 节点健康管理,单例模式。每个 Worker 进程持有唯一的 `node_id`(格式:`hostname-随机8位hex`),通过 Redis 心跳键(`linsight:node:heartbeat:{node_id}`)维持存活状态,心跳间隔 5 秒,TTL 15 秒。同时管理任务归属(`linsight:task:owner:{session_version_id}`),确保一个任务只在一个节点上执行。
**ScheduleCenterProcess** — 调度中心进程,继承自 `multiprocessing.Process`。每个 Worker 进程内部通过 `asyncio.Semaphore` 控制并发任务数。主循环:获取信号量 -> 从队列阻塞消费 -> 注册任务归属 -> 创建 `LinsightWorkflowTask` 异步任务 -> 任务完成后释放信号量。
### 进程模型
```
主进程 (worker.py __main__)
├── 检查并终止未完成任务
└── 启动 N 个 ScheduleCenterProcess (spawn 模式)
├── Process 1
│ ├── NodeManager 心跳协程
│ ├── Semaphore(max_concurrency)
│ └── 主循环: 消费队列 → 创建异步任务
├── Process 2
│ └── ...
└── Process N
└── ...
```
## 任务执行器(LinsightWorkflowTask
`LinsightWorkflowTask`(位于 `src/backend/bisheng/linsight/domain/task_exec.py`)是单次任务执行的核心控制器,负责完整的执行生命周期。
### 执行流程
1. **资源初始化**:通过 `_managed_execution()` 上下文管理器,创建 `LinsightStateMessageManager`、校验会话状态(防止重复执行)、启动终止监控协程、初始化文件目录并下载用户上传的文件到本地临时目录。
2. **组件准备**:获取 LLM 实例(使用工作台配置的任务模型)、构建工具列表(用户配置的工具 + 内置 Linsight 工具如代码解释器)、创建 `LinsightAgent` 实例。
3. **任务生成**:调用 `agent.generate_task(sop)` 由 LLM 根据 SOP 拆解出结构化的任务步骤列表,保存到数据库和 Redis。
4. **任务执行**:启动两个并发协程——Agent 执行协程和终止监控协程。Agent 按顺序执行每个任务,产生的事件通过 `_handle_event()` 方法分发到对应的处理器。
5. **用户交互**:当 Agent 产生 `NeedUserInput` 事件时,任务暂停,等待用户通过 API 提交输入,支持文件上传。用户输入后调用 `agent.continue_task()` 恢复执行。
6. **资源清理**:无论成功或失败,清理终止监控协程和临时文件目录。
### 终止机制
用户可以主动终止正在执行的任务。终止监控协程每 2 秒检查 Redis 中会话状态是否被设置为 `TERMINATED`,一旦检测到终止信号,通过 `UserTerminationError` 异常中断 Agent 执行,并将所有未完成的子任务标记为 `TERMINATED`
## bisheng_langchain 运行时
Agent 的推理和执行逻辑实现在 `src/backend/bisheng_langchain/linsight/` 包中,作为独立的 LangChain 扩展被主服务导入。
### LinsightAgent
`LinsightAgent``agent.py`)是面向上层的统一接口,核心职责:
- **SOP 生成**`generate_sop`):根据用户问题、可用工具和上传文件,调用 LLM 生成标准操作流程。支持流式输出,解析 `<Thought_END>` 标签分离思考过程和 SOP 内容。
- **SOP 反馈修改**`feedback_sop`):用户对生成的 SOP 提出修改意见后,结合历史摘要重新生成。
- **任务拆解**`generate_task`):将 SOP 文本交给 LLM 拆解为结构化的步骤列表(JSON),包含 `step_id``target``input` 依赖、`workflow` 等字段。
- **任务执行**`ainvoke`):创建 `TaskManage` 并依次执行所有任务,以异步迭代器方式产出事件。
- **恢复执行**`continue_task`):接收用户输入后恢复暂停的任务。
### TaskManage
`TaskManage``manage.py`)是任务调度器,管理任务树和工具集。关键设计:
- **任务树构建**`rebuild_tasks`):将 LLM 生成的任务 JSON 实例化为 `Task`Function Calling 模式)或 `ReactTask`(ReAct 模式),处理父子关系和执行顺序。
- **工具 Schema 增强**:所有工具的参数 Schema 中自动注入 `call_reason` 必填字段,要求 LLM 在调用工具时说明原因。同时添加内置的 `call_user_input` 伪工具,用于触发用户输入事件。
- **执行调度**`ainvoke_task`):串行执行根任务,通过内部异步队列(`aqueue`)收集事件并向上层传递。
- **历史管理**:当工具调用历史超过 `tool_buffer` 配置的 token 数时,自动调用 LLM 进行摘要压缩。
### 执行模式
通过 `TaskMode` 枚举选择:
| 模式 | 类 | 特点 |
|------|-----|------|
| `func_call` | `Task` | 使用 LLM 的 Function Calling 能力,由模型直接选择工具和参数 |
| `react` | `ReactTask` | 使用 ReActReasoning + Acting)模式,LLM 在文本中输出思考过程和工具调用指令 |
两种模式共享 `BaseTask` 基类,包含任务的完整上下文(查询、SOP、文件目录、历史记录、执行配置等),并实现相同的事件产出接口。
## 状态管理
状态管理由 `LinsightStateMessageManager`(位于 `src/backend/bisheng/linsight/domain/services/state_message_manager.py`)统一负责,采用 Redis 缓存 + MySQL 持久化的双写策略。
### Redis 键结构
```
linsight_tasks:{session_version_id}:session_version_info # 会话版本信息(pickle 序列化)
linsight_tasks:{session_version_id}:messages # 消息队列(Redis List
linsight_tasks:{session_version_id}:execution_tasks:{task_id} # 各任务执行状态
linsight:queue # 全局任务队列
linsight:node:heartbeat:{node_id} # 节点心跳
linsight:task:owner:{session_version_id} # 任务归属
```
所有状态数据的 Redis 过期时间为 3600 秒(1 小时)。核心操作均带有重试机制(3 次重试,间隔 1 秒)。
### 消息事件类型(MessageEventType
| 事件类型 | 含义 |
|---------|------|
| `TASK_GENERATE` | 任务列表生成完毕 |
| `TASK_START` | 单个任务开始执行 |
| `TASK_EXECUTE_STEP` | 任务执行步骤(工具调用等) |
| `TASK_END` | 单个任务执行完毕 |
| `USER_INPUT` | 需要用户输入 |
| `USER_INPUT_COMPLETED` | 用户输入完成 |
| `FINAL_RESULT` | 整体任务最终结果 |
| `ERROR_MESSAGE` | 执行错误 |
| `TASK_TERMINATED` | 任务被终止 |
消息通过 `MessageStreamHandle`(WebSocket 连接)实时推送到前端。当收到 `ERROR_MESSAGE``TASK_TERMINATED``FINAL_RESULT` 事件时,WebSocket 连接关闭。
## 数据模型
### LinsightSessionVersion
会话版本模型,记录一次完整的任务执行过程。位于 `src/backend/bisheng/linsight/domain/models/linsight_session_version.py`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | CHAR(36) | 主键,UUID |
| `session_id` | CHAR(36) | 关联的聊天会话 ID |
| `user_id` | int | 用户 ID |
| `question` | Text | 用户问题 |
| `tools` | JSON | 可用工具列表 |
| `files` | JSON | 上传文件列表 |
| `sop` | Text | SOP 内容 |
| `output_result` | JSON | 输出结果(包含 answer、final_files 等) |
| `status` | Enum | 会话版本状态 |
| `score` | int | 评分(1-5 |
| `has_reexecute` | bool | 是否重新执行过 |
### LinsightExecuteTask
执行任务模型,记录单个步骤的执行状态和历史。位于 `src/backend/bisheng/linsight/domain/models/linsight_execute_task.py`
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | CHAR(36) | 主键,UUID |
| `session_version_id` | CHAR(36) | 关联的会话版本 ID |
| `parent_task_id` | CHAR(36) | 父任务 ID(子任务时有值) |
| `previous_task_id` | CHAR(36) | 前一个任务 ID |
| `next_task_id` | CHAR(36) | 下一个任务 ID |
| `task_type` | Enum | SINGLE(单任务)/ COMPOSITE(含子任务) |
| `task_data` | JSON | 任务数据(LLM 生成的原始信息) |
| `history` | JSON | 执行步骤记录列表 |
| `status` | Enum | 任务状态 |
| `result` | JSON | 任务结果 |
### LinsightSOP
SOP 模型,存储标准操作流程定义。位于 `src/backend/bisheng/linsight/domain/models/linsight_sop.py`。SOP 内容同时存储在 MySQL(全文)和向量库(前 10000 字符的向量索引 + Elasticsearch 关键词索引),支持语义检索和关键词混合检索。
## SOP 管理
SOP 管理服务(`SOPManageService`,位于 `src/backend/bisheng/linsight/domain/services/sop_manage.py`)提供 SOP 的完整生命周期管理:
- **创建**:将 SOP 内容写入 MySQL,同时在 Milvuscollection: `col_linsight_sop`)和 Elasticsearch 中建立索引。
- **检索**:使用 `EnsembleRetriever` 混合向量检索和关键词检索,权重各 50%。
- **批量导入**:支持从 Excel 文件批量导入 SOP,处理重名冲突(覆盖/另存/提示)。
- **SOP 记录**:任务执行后自动生成 `LinsightSOPRecord`,记录执行效果和评分。
- **向量库重建**:提供 `rebuild_sop_vector_store_task` 方法,支持在更换 Embedding 模型后重建全部向量索引。
## MCP 协议集成
MCPModel Context Protocol)集成模块位于 `src/backend/bisheng/mcp_manage/`,为系统提供统一的外部工具接入能力。
### ClientManager 工厂
`ClientManager``manager.py`)是 MCP 客户端的工厂类,根据配置 JSON 自动识别传输类型并创建对应的客户端实例。
配置解析规则(`parse_mcp_client_type`):
1. 若配置中包含 `type` 字段,直接使用其值作为传输类型。
2. 若配置中包含 `command` 字段,识别为 STDIO 类型。
3. 其他情况默认为 SSE 类型。
配置格式示例:
```json
{
"mcpServers": {
"my-tool-server": {
"url": "http://localhost:8080/sse"
}
}
}
```
```json
{
"mcpServers": {
"local-tool": {
"command": "python",
"args": ["tool_server.py"]
}
}
}
```
```json
{
"mcpServers": {
"streamable-server": {
"type": "streamable",
"url": "http://localhost:8080/mcp"
}
}
}
```
### 传输层实现
所有客户端继承自 `BaseMcpClient` 抽象基类,统一实现 `get_transport()` 方法返回读写流,由基类的 `initialize()` 方法建立 `ClientSession`
| 客户端 | 文件 | 传输协议 | 核心参数 |
|--------|------|---------|---------|
| `SseClient` | `clients/sse.py` | Server-Sent Events | `url` |
| `StdioClient` | `clients/stdio.py` | 标准输入/输出 | `command`, `args` |
| `StreamableClient` | `clients/streamable.py` | Streamable HTTP | `url` |
`BaseMcpClient` 提供两个核心方法:
- `list_tools()`:列出 MCP 服务器提供的所有工具。
- `call_tool(name, arguments)`:调用指定工具并返回 JSON 结果。
每次工具调用都会重新建立连接(`initialize()` 上下文管理器),适用于无状态的短连接场景。
### MCP 到 LangChain 适配器
`McpTool`(位于 `src/backend/bisheng/mcp_manage/langchain/tool.py`)负责将 MCP 工具桥接为 LangChain 的 `StructuredTool`,使其可被工作流节点(TOOL/AGENT)和 Linsight Agent 直接调用。
适配流程:
```
MCP 服务器
↓ list_tools()
工具元信息 (name, description, inputSchema)
↓ McpTool.get_mcp_tool()
LangChain StructuredTool
├── func = McpTool.run() (同步,内部创建事件循环)
└── coroutine = McpTool.arun() (异步,直接调用 mcp_client.call_tool)
```
`McpTool` 在调用前会通过 `parse_kwargs_schema()` 根据工具的参数 Schema 对输入值进行类型转换(如将字符串 "123" 转换为整数 123),确保与 MCP 服务器的类型约定一致。
### 使用流程
1. 用户在系统中配置 MCP 服务器的 JSON 配置(包含 `mcpServers` 字段)。
2. `ClientManager.parse_mcp_client_type()` 解析传输类型和连接参数。
3. `ClientManager.sync_connect_mcp()` 创建对应的客户端实例。
4. 调用 `list_tools()` 获取可用工具列表。
5. 通过 `McpTool.get_mcp_tool()` 将每个 MCP 工具包装为 `StructuredTool`
6. 包装后的工具可在工作流 TOOL/AGENT 节点和 Linsight Agent 中使用。
## 配置参考
### LinsightConf
位于 `src/backend/bisheng/core/config/settings.py`,通过 `config.yaml``linsight_conf` 字段配置。
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `debug` | `false` | 调试模式,开启后记录 LLM 输入输出 |
| `tool_buffer` | `100000` | 工具执行历史的最大 token 数,超过后自动摘要 |
| `max_steps` | `200` | 单个任务最大执行步骤数,防止死循环 |
| `retry_num` | `3` | 模型调用失败重试次数 |
| `retry_sleep` | `5` | 重试间隔(秒) |
| `max_file_num` | `5` | SOP 生成时 prompt 中放入的用户文件数量 |
| `max_knowledge_num` | `20` | SOP 生成时 prompt 中放入的知识库数量 |
| `default_temperature` | `0` | 默认模型温度 |
| `retry_temperature` | `1` | ReAct 模式 JSON 解析失败后重试时的模型温度 |
| `file_content_length` | `5000` | 拆分子任务时读取文件内容的字符数上限 |
| `max_file_content_num` | `3` | 拆分子任务时读取的中间过程文件数量 |
### McpConf
位于同一配置文件,通过 `config.yaml``mcp` 字段配置。
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `enable_stdio` | `true` | 是否启用 STDIO 类型的 MCP 客户端 |
## 相关文档
- [01-architecture-overview.md](01-architecture-overview.md) — 系统整体架构
@@ -1,304 +0,0 @@
# 双前端架构
BiSheng 前端由两个独立的 React 应用构成:**Platform**(管理端)和 **Client**(用户端)。Platform 面向管理员和应用构建者,提供知识库管理、工作流编排、模型配置、系统设置等完整的 DevOps 操作界面;Client 面向终端用户,提供轻量化的对话交互、智能体调用和知识库浏览体验,并支持 PWA 离线访问。两个应用共享同一套后端 API(`/api/v1``/api/v2`),通过 Vite 代理分别转发到 FastAPI 后端。
## 应用对比
| 维度 | Platform | Client |
|------|----------|--------|
| 位置 | `src/frontend/platform/` | `src/frontend/client/` |
| 包名 | bisheng | bishengchat |
| 版本 | 2.4.0_beta1 | 2.4.0 |
| React | 18.3.1 | 18.2.0 |
| Vite | 5.3.1 (SWC 编译) | 6.3.6 |
| 开发端口 | 3001 | 4001 |
| 基础路径 | `/`(可配置) | `/workspace` |
| 构建输出 | `build/` | `build/` |
| 定位 | 管理员/构建者界面 | 终端用户对话界面 |
| PWA | 不支持 | 支持(Service Worker 自动更新) |
| 状态管理 | Zustand + React Context | Zustand18+ slices |
| 代码编辑器 | react-ace | CodeMirror + Monaco Editor |
## Platform 架构
### 路由体系
Platform 使用 `react-router-dom` 6.x,所有页面组件均通过 `lazy()` 懒加载,路由定义在 `src/frontend/platform/src/routes/index.tsx`
#### 主路由组(MainLayout 内)
| 路径 | 组件 | 权限 | 说明 |
|------|------|------|------|
| `/filelib` | KnowledgePage | knowledge | 知识库列表 |
| `/filelib/:id` | FilesPage | knowledge | 知识库文件详情 |
| `/filelib/upload/:id` | FilesUpload | knowledge | 文件上传 |
| `/filelib/adjust/:fileId` | AdjustFilesUpload | knowledge | 文件调整上传 |
| `/filelib/qalib/:id` | QasPage | knowledge | QA 问答库 |
| `/build/apps` | Apps | build | 应用列表 |
| `/build/tools` | SkillToolsPage | build | 工具管理 |
| `/build/client` | WorkBenchPage | build | 工作台 |
| `/build/skill` | L2Edit | build | 技能编辑器 |
| `/build/skill/:id/:vid` | L2Edit | build | 技能版本编辑 |
| `/build/temps/:type` | Templates | build | 应用模板 |
| `/model/management` | Management | -- | 模型管理 |
| `/model/finetune` | Finetune | -- | 模型微调 |
| `/sys` | SystemPage | sys | 系统设置 |
| `/log` | LogPage | -- | 应用日志 |
| `/evaluation` | EvaluatingPage | -- | 模型评测 |
| `/dataset` | DataSetPage | -- | 数据集管理 |
| `/label` | LabelPage | -- | 数据标注任务 |
| `/dashboard` | Dashboard | -- | 数据仪表盘 |
#### 独立页面(MainLayout 外)
| 路径 | 说明 |
|------|------|
| `/flow/:id` | 工作流编辑器(全屏) |
| `/skill/:id` | 技能编辑器(全屏) |
| `/assistant/:id` | 助手编辑器(全屏) |
| `/dashboard/:id` | 仪表盘编辑器 |
| `/dashboard/share/:boardId` | 仪表盘分享页 |
| `/chat/assistant/auth/:id` | 助手认证对话 |
| `/chat/flow/auth/:id` | 工作流认证对话 |
| `/chat/:id` | 对话分享页 |
| `/diff/:id/:vid/:cid` | 工作流版本对比 |
| `/report/:id` | 报告查看 |
#### 权限过滤
路由定义中通过 `permission` 字段标记权限要求。`getPrivateRouter()` 根据当前用户的权限列表过滤路由——无权限的路由不会注册到 Router 中,用户无法通过 URL 直接访问。管理员使用 `getAdminRouter()` 获取全部路由,不做过滤。
未登录用户使用 `publicRouter`,仅暴露登录页、密码重置页和对话分享页。
### 状态管理
Platform 采用 **Zustand + React Context** 双层状态管理。
#### Zustand Stores`src/frontend/platform/src/store/`
| Store | 职责 |
|-------|------|
| `dashboardStore` | 仪表盘编辑器:撤销/重做栈、图表刷新、布局状态 |
| `editFlowStore` | 工作流编辑器:节点/边操作、画布状态、变量管理 |
| `diffFlowStore` | 工作流版本对比:版本选择、差异高亮 |
| `assistantStore` | AI 助手配置:模型参数、工具绑定、提示词 |
#### React Context`src/frontend/platform/src/contexts/`
Context Provider 按固定顺序嵌套(外层到内层),定义在 `contexts/index.tsx`
```
TooltipProvider -- Radix UI 工具提示
ReactFlowProvider -- @xyflow/react 画布上下文
DarkProvider -- 暗色模式切换
TypesProvider -- 节点类型注册表
LocationProvider -- 导航状态追踪
AlertProvider -- 全局提示消息
SSEProvider -- 服务端推送事件
TabsProvider -- 标签页管理
UndoRedoProvider -- 撤销/重做
UserProvider -- 用户认证信息
PopUpProvider -- 弹窗状态
```
Zustand 用于跨页面的复杂业务状态(如编辑器),Context 用于全局基础设施(认证、主题、画布)。两者各司其职,避免单一方案的复杂度膨胀。
## Client 架构
### 路由体系
Client 路由定义在 `src/frontend/client/src/routes/index.tsx`,基础路径为 `/workspace`。整体结构围绕对话体验设计。
#### 主要路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/` | 重定向到 `/c/new` | 默认进入新对话 |
| `/c/:conversationId?` | ChatRoute | 核心对话界面 |
| `/linsight/:conversationId?` | Sop | Linsight 智能体交互 |
| `/linsight/case/:sopId` | Sop | 智能体案例执行 |
| `/app/:conversationId/:fid/:type` | AppChat | 应用对话(Flow/Assistant |
| `/apps` | AgentCenter | 智能体/应用中心 |
| `/apps/explore` | ExplorePlaza | 应用探索广场 |
| `/channel` | Subscription | 渠道订阅 |
| `/knowledge` | Knowledge | 知识库浏览 |
| `/knowledge/space/:spaceId` | Knowledge | 知识空间详情 |
| `/knowledge/file/:fileId` | FilePreviewPage | 文件预览 |
| `/share/:token/:vid?` | Share | 分享链接入口 |
Client 保留了旧路由 `/chat/:conversationId/:fid/:type` 的兼容重定向,自动跳转到新路径 `/app/...`
### PWA 支持
Client 通过 `vite-plugin-pwa` 集成 Progressive Web App 能力:
- **注册方式**`injectRegister: 'auto'`,自动注入 Service Worker 注册代码
- **更新策略**`registerType: 'autoUpdate'`,新版本自动激活,无需用户确认
- **缓存范围**:JS、CSS、HTML 文件及应用图标,单文件最大 4MB
- **排除项**`images/` 目录、Source Map、`index.html`(由网络优先策略处理)
- **导航回退**:排除 `/oauth` 路径,确保 OAuth 回调不被 Service Worker 拦截
- **开发模式**:禁用 Service Worker,避免热更新冲突
### 状态管理
Client 采用纯 Zustand 方案,通过 18 个 slice 组织状态(`src/frontend/client/src/store/`):
| Slice | 职责 |
|-------|------|
| `families` | 对话/消息家族树 |
| `endpoints` | API 端点配置 |
| `settings` | 用户设置偏好 |
| `language` | 语言切换 |
| `linsight` | Linsight 智能体状态 |
| `prompts` | 提示词模板 |
| `modeltype` | 模型类型定义 |
| `search` | 搜索状态 |
| `submission` | 消息提交队列 |
| `text` | 文本输入缓冲 |
| `toast` | 提示通知 |
| `user` | 用户认证 |
| `artifacts` | 制品/附件管理 |
| `preset` | 预设配置 |
| `temporary` | 临时状态 |
| `misc` | 杂项 |
所有 slice 通过 `store/index.ts` 统一导出,组件按需导入对应的 selector。
## API 通信层
### 请求拦截(Platform
Platform 的 HTTP 通信基于 Axios 封装(`src/frontend/platform/src/controllers/request.ts`):
**请求拦截器**
-`localStorage.ws_token` 读取 JWT token
- 注入 `Authorization: Bearer {token}` 请求头
- 跳过 MinIO 文件请求(`/bisheng` 前缀)的认证头注入
**响应拦截器**
- 正常响应:解包 `{ status_code, data, status_message }` 格式,`status_code === 200` 时返回 `data`
- Blob 响应:直接返回(用于文件下载)
- 业务错误:将 `status_code` 映射到 i18n 错误消息(`errors.{code}`),找不到翻译则使用原始 `status_message`
- 401:登录过期,清除本地用户信息并刷新页面
- 403/404:GET 请求跳转到对应错误页
- 10599/17005:应用无编辑权限,跳转到应用列表
- 10604:异地登录,触发远程登录回调
**登录认证**
- RSA 加密密码传输,公钥从 `/api/v1/user/public_key` 获取
- 使用 `jsencrypt` 库进行客户端加密
### API 模块
Platform 的 API 调用函数按功能模块组织在 `src/frontend/platform/src/controllers/API/` 下:
| 模块 | 说明 |
|------|------|
| `index.ts` | 核心 API(知识库、用户、配置、助手、模型等) |
| `workflow.ts` | 工作流 CRUD、节点配置、执行调试 |
| `flow.ts` | Flow 应用管理 |
| `dashboard.ts` | 仪表盘数据查询 |
| `user.ts` | 用户管理、角色、权限 |
| `log.ts` | 日志查询 |
| `finetune.ts` | 模型微调任务 |
| `label.ts` | 数据标注 |
| `evaluate.ts` | 模型评测 |
| `tools.ts` | 工具集成 |
| `linsight.ts` | Linsight 智能体 |
| `assistant.ts` | 助手配置 |
| `workbench.ts` | 工作台 |
| `pro.ts` | 高级功能 |
每个模块导出异步函数,内部调用封装的 Axios 实例。函数命名遵循 `动词 + 名词` 惯例(如 `getKnowledgeList``createWorkflow``deleteAssistant`)。
## 组件库
### bs-ui 组件体系
Platform 基于 Radix UI 原语 + Tailwind CSS 封装了一套业务组件库,位于 `src/frontend/platform/src/components/bs-ui/`,包含 38 类组件:
| 分类 | 组件 |
|------|------|
| 表单输入 | input, select, checkBox, radio, radio-group, slider, switch, toggle, toggle-group, multiSelect, calendar, upload, voice |
| 数据展示 | table, badge, progress, skeleton, card, separator, label, editLabel |
| 反馈通知 | alert, alertDialog, dialog, toast, tooltip, popover, sheet |
| 导航布局 | accordion, tabs, pagination, step, dropdownMenu |
| 操作按钮 | button, command |
所有组件遵循 Radix UI 的无障碍规范(WAI-ARIA),通过 `class-variance-authority`CVA)管理样式变体。
### 图标库
`src/frontend/platform/src/components/bs-icons/` 提供统一的 SVG 图标组件,支持大小和颜色自定义。
### 工作流画布
工作流编辑器基于 `@xyflow/react`(v12.8.4)构建,提供节点拖拽、连线、缩放、小地图等交互能力。画布状态通过 `editFlowStore`Zustand)管理,`ReactFlowProvider`Context)提供实例访问。
## 国际化
两个前端均使用 `i18next` + `react-i18next` 实现多语言支持。
**支持语言**
- `zh-Hans` -- 简体中文
- `en-US` -- 英文
- `ja` -- 日文
**命名空间**Platform):
- `bs` -- 通用业务文本
- `flow` -- 工作流编辑器专用文本
**动态品牌配置**:通过 `window.BRAND_CONFIG` 注入品牌名称、Logo 等可定制元素,支持不同部署环境的品牌适配。
**翻译加载**:采用 `i18next-http-backend` 按需加载翻译文件,避免首屏加载全部语言包。
## 构建与代理
### Vite 代理配置
开发模式下,两个前端通过 Vite 内置代理将 API 请求和文件服务请求转发到后端。
#### Platform 代理(端口 3001
| 路径匹配 | 目标 | 说明 |
|----------|------|------|
| `/api/` | `http://127.0.0.1:7860` | FastAPI 后端 API |
| `/health` | `http://127.0.0.1:7860` | 健康检查 |
| `/bisheng` | `http://localhost:9000` | MinIO 对象存储(文件/图片) |
| `/tmp-dir` | `http://localhost:9000` | MinIO 临时文件目录 |
#### Client 代理(端口 4001
| 路径匹配 | 目标 | 路径重写 | 说明 |
|----------|------|---------|------|
| `/workspace/api` | `http://127.0.0.1:7860` | 去除 `/workspace` 前缀 | 后端 API |
| `/workspace/bisheng` | `http://localhost:9000` | 去除 `/workspace` 前缀 | MinIO 文件 |
| `/workspace/tmp-dir` | `http://localhost:9000` | 去除 `/workspace` 前缀 | MinIO 临时文件 |
Client 的所有代理规则都需要重写路径,去除 `/workspace` 基础路径前缀后再转发。
### 构建分包策略
#### Platform 分包
Platform 按依赖类型拆分为 5 个 vendor chunk,避免单一巨型包:
| Chunk | 包含内容 |
|-------|---------|
| `vendor-pdf` | pdfjs-distPDF 渲染) |
| `vendor-xlsx` | xlsx, mammoth 及其传递依赖(文档处理) |
| `vendor-editor` | react-ace, ace-builds, react-syntax-highlighter, vditor(代码/文本编辑) |
| `vendor-markdown` | react-markdown, rehype/remark 插件, MathJax, DOMPurifyMarkdown 渲染) |
| `vendor` | 其余所有 node_modulesReact, Radix, recharts, xyflow, i18n 等) |
业务代码不做手动分包,依赖 Rollup 根据 `lazy()` 动态导入自动生成 code splitting。
#### Client 分包
Client 采用更细粒度的分包策略,将 30+ 种依赖分别拆分为独立 chunksandpack, virtualization, i18n, codemirror 系列, markdown, radix-ui, framer-motion 等),剩余依赖归入通用 `vendor` chunk。业务代码中的 `src/locales/` 翻译文件单独拆分为 `locales` chunk。
## 相关文档
- 系统架构总览 -- `docs/architecture/01-architecture-overview.md`
- 后端领域模块总览 -- `docs/architecture/02-backend-modules.md`
- 部署架构与配置 -- `docs/architecture/08-deployment.md`
-335
View File
@@ -1,335 +0,0 @@
# 数据模型与存储层
BiSheng 的持久化层由 MySQL 中的 24 个 SQLModel ORM 模型和 5 种异构存储引擎协同组成。关系型数据(用户、应用、会话、权限等)存储在 MySQL 中,通过统一的 DAO 模式提供同步/异步访问;向量数据存入 Milvus,关键词索引交给 Elasticsearch,文件对象托管于 MinIO,会话状态和缓存则由 Redis 承载。所有存储引擎的连接生命周期由 `core/context/` 下的上下文管理系统统一编排。
## 核心模型清单
以下 24 个模型文件位于 `src/backend/bisheng/database/models/` 目录下:
| 模型类 | 文件 | 用途 |
|--------|------|------|
| `Flow` | `flow.py` | 应用/工作流/助手的统一定义,包含名称、JSON 画布数据、状态、类型 |
| `FlowVersion` | `flow_version.py` | 应用版本控制,每个版本独立保存画布数据快照 |
| `Assistant` | `assistant.py` | AI 助手配置,包含系统提示词、模型参数、温度等 |
| `AssistantLink` | `assistant.py` | 助手关联表,连接助手与工具、技能、知识库 |
| `Template` | `template.py` | 应用模板,预置的工作流/助手模板 |
| `ChatMessage` | `message.py` | 聊天消息记录,支持 LONGTEXT 消息体、点赞、敏感词状态 |
| `MessageSession` | `session.py` | 会话记录,关联应用与用户,汇总互动统计 |
| `Role` | `role.py` | 角色定义,内置管理员角色(ID=1)和默认角色(ID=2) |
| `RoleAccess` | `role_access.py` | 角色权限映射,定义角色对各类资源的读写权限 |
| `Group` | `group.py` | 用户组,默认组 ID=2 |
| `GroupResource` | `group_resource.py` | 用户组资源共享映射 |
| `UserGroup` | `user_group.py` | 用户-组关联表,含组管理员标识 |
| `UserLink` | `user_link.py` | 用户关联信息(如常用应用等) |
| `Tag` | `tag.py` | 标签定义,区分知识库标签和应用标签 |
| `TagLink` | `tag.py` | 标签-资源关联表,支持多资源类型绑定 |
| `Dataset` | `dataset.py` | 微调数据集元数据 |
| `Evaluation` | `evaluation.py` | 评测任务,记录执行状态、评分结果、结果文件路径 |
| `Report` | `report.py` | 报告模板与生成记录 |
| `VariableValue` | `variable_value.py` | 工作流节点变量值持久化 |
| `RecallChunk` | `recall_chunk.py` | RAG 召回追踪,记录每次检索的关键词与命中分块 |
| `InviteCode` | `invite_code.py` | 邀请码管理,支持批次、用量限制 |
| `AuditLog` | `audit_log.py` | 审计日志,记录用户操作行为与 IP 地址 |
| `MarkTask` | `mark_task.py` | 数据标注任务定义 |
| `MarkRecord` | `mark_record.py` | 标注记录,关联任务与会话 |
| `MarkAppUser` | `mark_app_user.py` | 标注任务的应用-用户分配 |
## 模型关系图
```mermaid
erDiagram
Flow ||--o{ FlowVersion : "版本管理"
Flow ||--o{ MessageSession : "产生会话"
Flow ||--o{ Template : "生成模板"
Flow ||--o{ VariableValue : "节点变量"
Assistant ||--o{ AssistantLink : "关联资源"
AssistantLink }o--|| Flow : "关联技能"
MessageSession ||--o{ ChatMessage : "包含消息"
ChatMessage ||--o{ RecallChunk : "召回追踪"
Role ||--o{ RoleAccess : "权限定义"
Group ||--o{ GroupResource : "资源共享"
Group ||--o{ UserGroup : "成员关系"
Tag ||--o{ TagLink : "标签绑定"
MarkTask ||--o{ MarkRecord : "标注记录"
MarkTask ||--o{ MarkAppUser : "用户分配"
Evaluation }o--|| Flow : "评测对象"
```
## 模型分类详解
### 应用层模型
**Flow** 是系统中最核心的模型,通过 `flow_type` 枚举统一承载六种应用类型:
| 枚举值 | FlowType | 说明 |
|--------|----------|------|
| 5 | `ASSISTANT` | AI 助手 |
| 10 | `WORKFLOW` | 工作流 |
| 15 | `WORKSTATION` | 工作台 |
| 20 | `LINSIGHT` | 灵思模式 |
| 25 | `CHANNEL_ARTICLE` | 频道文章助手 |
| 30 | `KNOLEDGE_SPACE` | 知识空间 |
`Flow.data` 字段以 JSON 格式存储完整的画布定义(包含 `nodes``edges`),创建时会自动校验 JSON 结构。`FlowVersion` 为每个应用维护独立的版本链,`is_current` 标记当前生效版本。
`Flow.status` 控制应用上下线状态:`OFFLINE(1)` 表示离线编辑中,`ONLINE(2)` 表示已上线可用。
**Assistant** 独立存储助手的 LLM 配置(model_name、temperature、max_token、system_prompt),通过 **AssistantLink** 关联表将助手与工具(tool_id)、技能(flow_id)、知识库(knowledge_id)三类资源建立多对多关系。
### 会话与消息模型
**MessageSession**`chat_id` 为主键,记录用户与应用的一次完整对话会话。包含会话统计字段(like、dislike、copied)和敏感词审核状态。`group_ids` 以 JSON 数组存储所属用户组,支持按组过滤会话。
**ChatMessage** 存储具体的消息内容,`message` 字段使用 MySQL LONGTEXT 类型以支持超长回复。关键字段包括:
- `is_bot` -- 区分用户消息与 AI 回复
- `type` / `category` -- 消息类型分类(如 question、answer
- `liked` / `solved` -- 用户反馈(0 未评/1 赞/2 踩)
- `intermediate_steps` -- 推理过程日志(Text 类型)
- `files` -- 关联的上传文件
- `sensitive_status` -- 敏感词检测结果(1 通过/2 违规)
表级别设置 `utf8mb4` 字符集以支持 emoji 等特殊字符。
### RBAC 权限模型
权限体系采用 **用户 - 用户组 - 角色 - 权限** 四层结构:
```
User ──┬── UserGroup ──── Group
│ │
└── UserRole ───── Role ──── RoleAccess
GroupResource ┘
```
**RoleAccess** 通过 `AccessType` 枚举定义 13 种权限类型:
| 编码 | AccessType | 说明 |
|------|-----------|------|
| 1 | `KNOWLEDGE` | 知识库读权限 |
| 3 | `KNOWLEDGE_WRITE` | 知识库写权限 |
| 5 | `ASSISTANT_READ` | 助手读权限 |
| 6 | `ASSISTANT_WRITE` | 助手写权限 |
| 7 | `GPTS_TOOL_READ` | 工具读权限 |
| 8 | `GPTS_TOOL_WRITE` | 工具写权限 |
| 9 | `WORKFLOW` | 工作流读权限 |
| 10 | `WORKFLOW_WRITE` | 工作流写权限 |
| 11 | `DASHBOARD` | 看板读权限 |
| 12 | `DASHBOARD_WRITE` | 看板写权限 |
| 99 | `WEB_MENU` | 前端菜单栏权限 |
**GroupResource** 通过 `ResourceTypeEnum` 定义资源类型(KNOWLEDGE=1, ASSISTANT=3, GPTS_TOOL=4, WORK_FLOW=5, DASHBOARD=6, WORKSTATION=7, SPACE_FILE=8),将资源共享到指定用户组。
**UserGroup** 是用户与组的多对多关联表,`is_group_admin` 标识该用户是否为组管理员。
### 业务支撑模型
- **Evaluation** -- 评测任务模型,通过 `exec_type`flow/assistant/workflow)和 `unique_id` 关联被评测的应用,`status` 追踪执行状态(1 运行中/2 失败/3 成功),`result_score` 以 JSON 存储评分结果
- **Dataset** -- 微调数据集,`object_name` 指向 MinIO 中的数据文件
- **Report** -- 报告模板与生成记录,`object_name` 指向 MinIO 中的模板文件
- **Tag / TagLink** -- 标签系统,`business_type` 区分知识库标签(`knowledge_space`)与应用标签(`application`),TagLink 通过唯一约束(resource_id + resource_type + tag_id)防止重复绑定
- **VariableValue** -- 工作流节点变量持久化,记录 flow_id、version_id、node_id 和变量值,`value_type` 区分文本(1)、列表(2)、文件(3)
- **RecallChunk** -- RAG 召回追踪,关联 message_id 和 chat_id,记录检索关键词(keywords)和命中的文档分块(chunk)及元数据
- **InviteCode** -- 邀请码管理,支持批次(batch_id/batch_name)、用量限制(limit/used)和用户绑定
### 标注模型
标注系统由三个模型协作:
- **MarkTask** -- 标注任务定义,包含创建者、关联应用 ID、标注人员列表(process_users),状态枚举为 DEFAULT(1)/DONE(2)/ING(3)
- **MarkRecord** -- 标注记录,关联 task_id 和 session_id,追踪每条会话的标注状态
- **MarkAppUser** -- 标注任务中的应用-用户分配关系
### 审计模型
**AuditLog** 记录系统中的关键操作行为。`system_id` 标识操作所属模块(chat/build/knowledge/system/dashboard 等),`event_type` 记录具体行为(如 create_chat、delete_knowledge、user_login),`object_type` 标识操作对象类型(work_flow/assistant/knowledge 等),并记录操作者 IP 地址。主键使用 UUID 格式。
## DAO 模式
所有模型文件遵循统一的三层结构:**Base Schema -> Model(table=True) -> Dao 类**。
### 基类
所有模型继承自 `SQLModelSerializable`(定义在 `common/models/base.py`),它扩展了 SQLModel 并默认以 JSON 模式序列化:
```python
class SQLModelSerializable(SQLModel):
model_config = ConfigDict(from_attributes=True)
def model_dump(self, **kwargs) -> Dict[str, Any]:
if 'mode' not in kwargs:
kwargs['mode'] = 'json'
return super().model_dump(**kwargs)
```
### 三层结构
以 Flow 为例说明典型的模型文件组织方式:
```python
# 1. Base Schema -- 定义字段、校验逻辑,不映射数据库表
class FlowBase(SQLModelSerializable):
name: str = Field(index=True)
user_id: Optional[int] = Field(default=None, index=True)
data: Optional[Dict] = Field(default=None)
status: Optional[int] = Field(default=1)
flow_type: Optional[int] = Field(default=FlowType.WORKFLOW.value)
create_time: Optional[datetime] = Field(...)
update_time: Optional[datetime] = Field(...)
# 2. Model -- 映射数据库表,声明主键和特殊列类型
class Flow(FlowBase, table=True):
id: str = Field(default_factory=generate_uuid, primary_key=True, unique=True)
data: Optional[Dict] = Field(default=None, sa_column=Column(JSON))
# 3. Read/Create/Update Schema -- API 层的请求/响应模型
class FlowRead(FlowBase):
id: str
user_name: Optional[str] = None
class FlowCreate(FlowBase):
flow_id: Optional[str] = None
class FlowUpdate(SQLModelSerializable):
name: Optional[str] = None
description: Optional[str] = None
# 4. Dao 类 -- 数据访问对象,封装 CRUD 操作
class FlowDao(FlowBase):
@classmethod
def create_flow(cls, flow_info: Flow, flow_type: Optional[int]) -> Flow:
with get_sync_db_session() as session:
session.add(flow_info)
session.commit()
session.refresh(flow_info)
return flow_info
@classmethod
async def aget_flow_by_id(cls, flow_id: str) -> Flow:
async with get_async_db_session() as session:
statement = select(Flow).where(Flow.id == flow_id)
result = await session.exec(statement)
return result.first()
```
### Dao 方法命名约定
| 前缀 | 含义 | 示例 |
|------|------|------|
| `get_` | 同步查询 | `get_one_assistant()` |
| `aget_` | 异步查询 | `aget_one_assistant()` |
| `create_` | 同步创建 | `create_flow()` |
| `update_` | 同步更新 | `update_version()` |
| `delete_` | 同步删除 | `delete_assistant()` |
| `filter_` | 条件过滤查询 | `filter_dataset_by_ids()` |
所有 Dao 方法均为 `@classmethod``@staticmethod`,通过 `get_sync_db_session()` / `get_async_db_session()` 获取数据库会话,无需实例化 Dao 对象。
## 存储引擎职责
BiSheng 采用 5 种存储引擎,各司其职:
| 存储引擎 | 基础设施位置 | 存储内容 | 访问方式 |
|----------|------------|---------|---------|
| **MySQL 8.0** | `core/database/` | 全部 ORM 模型数据:应用定义、用户、权限、会话、消息、评测、审计等 | SQLModel/SQLAlchemy,同步引擎(`pymysql`) + 异步引擎(`aiomysql`) |
| **Redis 7.0** | `core/cache/redis_manager.py` | 配置缓存(100s TTL)、Celery 消息代理、Linsight 会话状态(1h 过期)、分布式锁 | RedisManager 上下文管理器 |
| **Milvus** | `core/vectorstore/` | 稠密向量索引,知识库文档的 Embedding 向量,用于语义相似度检索 | Collection 抽象,支持 Milvus/Qdrant/Chroma 多后端 |
| **Elasticsearch** | `core/search/elasticsearch/` | 稀疏/关键词索引(BM25 检索),遥测统计数据 | EsConnManager 上下文管理器,双实例(业务 + 统计) |
| **MinIO** | `core/storage/minio/` | 文件对象:上传文档、数据集文件、报告模板、应用 Logo、知识库原始文件 | MinioManager 上下文管理器,S3 兼容 API |
### MySQL 连接管理
`DatabaseConnectionManager``core/database/connection.py`)负责管理数据库引擎的创建和连接池配置:
- 自动将同步 URLpymysql)转换为异步 URLaiomysql
- 连接池默认配置:`pool_size=100``max_overflow=20``pool_timeout=30``pool_recycle=3600`1 小时回收)、`pool_pre_ping=True`(连接健康检查)
- 通过 `get_sync_db_session()``get_async_db_session()` 两个上下文管理器向 Dao 层提供会话
### 向量存储双通道
知识库 RAG 采用稠密向量(Milvus+ 稀疏检索(Elasticsearch)双通道架构:
- **Milvus** -- 存储文档分块的 Embedding 向量,支持 ANN(近似最近邻)语义检索
- **Elasticsearch** -- 存储文档分块的原文,提供 BM25 关键词检索能力
两个通道的检索结果经过融合排序后返回,兼顾语义理解与精确匹配。
### Elasticsearch 双实例
系统注册两个 `EsConnManager` 实例:
1. **业务实例** -- 处理知识库文档的关键词索引与检索
2. **统计实例**`statistics_es_name`)-- 存储遥测统计数据,支持用户行为分析和系统运营指标查询
## 上下文管理系统
所有存储引擎的连接生命周期由 `core/context/` 下的上下文管理系统统一编排。
### BaseContextManager 生命周期
`BaseContextManager[T]``core/context/base.py`)是所有基础设施管理器的抽象基类,提供线程安全的延迟加载、缓存和生命周期管理:
```
UNINITIALIZED ──> INITIALIZING ──> READY
│ │
v v
ERROR CLOSING ──> CLOSED
```
核心特性:
- **双锁机制** -- 同步锁(`threading.Lock`)和异步锁(`asyncio.Lock`)分别保护对应的初始化路径
- **双检查模式** -- `async_get_instance()` / `sync_get_instance()` 在获取锁前后各检查一次状态,避免重复初始化
- **重试机制** -- 默认 3 次重试,指数退避(2^attempt 秒),超时时间默认 30 秒
- **等待事件** -- `threading.Event``asyncio.Event` 让后续请求等待首次初始化完成,而非重复触发
### ApplicationContextManager 编排
`ApplicationContextManager``core/context/manager.py`)作为顶层编排器,按依赖顺序注册并初始化所有基础设施上下文管理器:
```
DatabaseManager -- MySQL 连接
|
RedisManager -- Redis 缓存
|
MinioManager -- MinIO 对象存储
|
EsConnManager (业务) -- Elasticsearch 业务实例
EsConnManager (统计) -- Elasticsearch 统计实例
|
HttpClientManager -- HTTP 客户端
|
PromptManager -- 提示词管理
```
初始化在 FastAPI lifespan 中触发,关闭时按注册的逆序清理资源。
### 子类实现
每个具体的 Manager 继承 `BaseContextManager[T]` 并实现四个抽象方法:
| 方法 | 用途 |
|------|------|
| `_async_initialize() -> T` | 异步创建连接/客户端实例 |
| `_sync_initialize() -> T` | 同步创建连接/客户端实例 |
| `_async_cleanup()` | 异步释放资源(关闭连接池等) |
| `_sync_cleanup()` | 同步释放资源 |
业务代码通过 `manager.async_get_instance()``manager.sync_get_instance()` 获取已初始化的连接实例,首次调用时自动触发延迟初始化。
## 相关文档
| 文档 | 说明 |
|------|------|
| [系统架构全景图](./01-architecture-overview.md) | 运行时组件与请求数据流 |
| [后端领域模块总览](./02-backend-modules.md) | 15+ DDD 模块清单与分层约定 |
| [知识库与 RAG 管道](./04-knowledge-rag.md) | 三阶段文档处理管道,向量存储细节 |
| [部署架构与配置](./08-deployment.md) | 存储服务部署与配置系统 |
-235
View File
@@ -1,235 +0,0 @@
# 部署与运维
BiSheng 采用 Docker Compose 编排全部基础设施和应用服务。生产环境通过 `docker/docker-compose.yml` 一键拉起 9 个容器,覆盖数据库、缓存、向量存储、全文检索、对象存储、后端 API、异步 Worker 和前端。本地开发时可选择混合部署模式:存储层保留 Docker 容器运行,后端和前端从源码启动,避免每次代码变更都要重建镜像。配置系统支持多层合并(YAML 文件、环境变量、数据库配置、Redis 缓存),密码字段通过 Fernet 加密保护。
## 容器清单
`docker/docker-compose.yml` 定义了以下 9 个服务:
| 容器名 | 镜像 | 端口映射 | 职责 |
|--------|------|---------|------|
| `bisheng-mysql` | `mysql:8.0` | 3306:3306 | 关系型数据存储,默认密码 `1234`,数据库 `bisheng` |
| `bisheng-redis` | `redis:7.0.4` | 6379:6379 | 缓存、Celery Broker、会话存储 |
| `bisheng-backend` | `dataelement/bisheng-backend:v2.4.0` | 7860:7860 | FastAPI 后端 API,通过 `entrypoint.sh api` 启动 |
| `bisheng-backend-worker` | `dataelement/bisheng-backend:v2.4.0` | (无) | Celery 全部 Worker + Beat,通过 `entrypoint.sh worker` 启动 |
| `bisheng-frontend` | `dataelement/bisheng-frontend:v2.4.0` | 3001:3001 | Nginx 托管前端静态资源 |
| `bisheng-milvus-etcd` | `quay.io/coreos/etcd:v3.5.5` | (无) | Milvus 元数据存储(ETCD |
| `bisheng-milvus-minio` | `minio/minio:RELEASE.2023-03-20T20-16-18Z` | 9100:9000, 9101:9001 | Milvus 数据存储(MinIO),同时作为业务对象存储 |
| `bisheng-milvus-standalone` | `milvusdb/milvus:v2.5.10` | 19530:19530, 9091:9091 | 向量数据库,依赖 ETCD 和 MinIO |
| `bisheng-es` | `bitnamilegacy/elasticsearch:8.12.0` | 9200:9200, 9300:9300 | 全文检索引擎 |
### 服务依赖关系
```
bisheng-backend
├── depends_on: mysql (healthy)
└── depends_on: redis (healthy)
bisheng-backend-worker
├── depends_on: mysql (healthy)
└── depends_on: redis (healthy)
bisheng-frontend
└── depends_on: backend
bisheng-milvus-standalone
├── depends_on: etcd
└── depends_on: minio
```
### 附加 Compose 文件
除主编排文件外,项目还提供三个可选编排文件,按需独立启动:
| 文件 | 容器名 | 镜像 | 端口 | 用途 |
|------|--------|------|------|------|
| `docker-compose-ft.yml` | `bisheng-ft-server` | `dataelement/bisheng-ft:v0.5.0` | 8000 | 模型微调服务,需要 GPUnvidia driver |
| `docker-compose-uns.yml` | `bisheng-unstructured` | `dataelement/bisheng-unstructured:v0.0.3.14` | 10001 | 非结构化文档解析服务 |
| `docker-compose-office.yml` | `bisheng-office` | `onlyoffice/documentserver:7.1.1` | 8701:80 | OnlyOffice 文档预览与编辑 |
## 配置系统
配置的加载与合并遵循多层优先级机制,最终生成运行时 `Settings` 对象。
```
优先级(高→低)
─────────────
┌─────────────────┐
│ config.yaml │ ← 基础配置文件(文件路径由环境变量 config 指定,默认 config.yaml
│ │ 支持 !env ${VAR} 语法从环境变量注入值
└────────┬────────┘
│ 加载
v
┌─────────────────┐
│ BS_* 环境变量 │ ← Docker 环境变量覆盖,如 BS_MILVUS_CONNECTION_ARGS、BS_MINIO_ENDPOINT 等
│ │ 在 docker-compose.yml 的 environment 中设置
└────────┬────────┘
│ 合并
v
┌─────────────────┐
│ 数据库配置 │ ← MySQL 中 initdb_config 记录,通过 Web 界面修改的运行时配置
│ (initdb_config)│ 首次启动时从 initdb_config.yaml 初始化写入数据库
└────────┬────────┘
│ 合并
v
┌─────────────────┐
│ Redis 缓存 │ ← 配置读取结果缓存在 Redis 中,TTL 100 秒
│ (100s TTL) │ 避免每次请求都查询数据库
└─────────────────┘
```
### 密码加密
`config.yaml` 中的 `database_url``redis_url` 密码字段使用 Fernet 对称加密。加密密钥硬编码在 `src/backend/bisheng/core/config/settings.py``secret_key` 变量中。Settings 类在加载配置时自动解密:
- `database_url`:正则匹配 `:password@` 中的密码部分,调用 `decrypt_token()` 解密
- `redis_url`:支持字符串 URL 和字典两种格式,字典格式使用 `encrypt(...)` 包装标记加密值
### Settings 类主要字段
`Settings` 类定义在 `src/backend/bisheng/core/config/settings.py`,主要配置分组:
| 字段 | 类型 | 说明 |
|------|------|------|
| `database_url` | `str` | MySQL 连接字符串(密码加密) |
| `redis_url` | `str/dict` | Redis 连接配置 |
| `celery_redis_url` | `str/dict` | Celery Broker Redis 连接 |
| `vector_stores` | `VectorStores` | Milvus + Elasticsearch 配置 |
| `object_storage` | `ObjectStore` | MinIO 对象存储配置 |
| `celery_task` | `CeleryConf` | Celery 任务路由和定时任务 |
| `workflow_conf` | `WorkflowConf` | 工作流执行参数(max_steps=50, timeout=720min |
| `linsight_conf` | `LinsightConf` | 灵思 Agent 参数(max_steps=200, retry_num=3 |
| `logger_conf` | `LoggerConf` | 日志级别与处理器 |
| `password_conf` | `PasswordConf` | 密码策略(有效期、错误锁定) |
| `cookie_conf` | `CookieConf` | JWT Cookie 配置(默认过期 86400s |
| `system_login_method` | `SystemLoginMethod` | 登录方式(商业版标识、多端登录) |
| `mcp` | `McpConf` | MCP 协议配置 |
| `information_conf` | `IntelligenceCenterConf` | 情报中心配置 |
## 本地混合开发部署
混合部署模式下,存储服务运行在 Docker 中,后端和前端从本地源码启动。这种模式下需要先停止 Docker 中的后端和前端容器以释放端口。
```
本地源码运行 Docker 容器运行
────────────── ──────────────
FastAPI 后端 :7860 MySQL 8.0 :3306
Celery Workers (无独立端口) Redis 7.0 :6379
Vite Dev Server :3001 Milvus 2.5 :19530
Elasticsearch :9200
MinIO :9100 (映射到容器内 9000)
```
### 启动步骤
```bash
# 1. 启动全部 Docker 容器
cd docker && docker compose -p bisheng up -d
# 2. 停止与本地服务冲突的容器
docker stop bisheng-backend bisheng-backend-worker bisheng-frontend
# 3. 启动本地后端
cd src/backend
.venv/bin/uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log
# 4. 启动 Celery Workers(各开一个终端)
.venv/bin/celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q knowledge_celery -n knowledge@%h
.venv/bin/celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q workflow_celery -n workflow@%h
.venv/bin/celery -A bisheng.worker.main beat -l info
# 5. 启动前端开发服务器
cd src/frontend/platform
npm start -- --host 0.0.0.0 # 端口 3001API 代理到 localhost:7860
```
## 远程开发工作流
项目支持本地编辑、远程运行的开发模式。代码在本地 Mac 编辑,通过 rsync 同步到远程服务器执行。
### 同步脚本
项目根目录的 `bisheng-sync.sh` 提供三种同步模式:
| 命令 | 说明 |
|------|------|
| `./bisheng-sync.sh up` | 本地代码推送到远程服务器 |
| `./bisheng-sync.sh down` | 远程代码拉取到本地 |
| `./bisheng-sync.sh watch` | 监听本地文件变化,自动推送到远程 |
### 日常流程
1. 终端 A 常驻 `./bisheng-sync.sh watch`,监听文件变化自动同步
2. 本地 IDEClaude Code / Cursor 等)编辑 `/Users/lilu/Projects/bisheng` 下的代码
3. 改动自动推送到远程服务器,远程进程加载新代码
4. 浏览器通过 `http://192.168.106.114:8860` 访问(Nginx 反向代理)
注意:如果未开启 watch 模式,修改代码后需手动执行 `./bisheng-sync.sh up` 推送到远程。
## Celery Worker 启动模式
`docker/bisheng/entrypoint.sh` 通过第一个参数控制启动模式,支持 7 种运行方式:
| 模式 | 命令 | 队列 | 并发数 | 说明 |
|------|------|------|--------|------|
| `api` | `uvicorn bisheng.main:app` | -- | 8 workers | FastAPI 服务器(默认模式) |
| `knowledge` | `celery ... worker -Q knowledge_celery` | `knowledge_celery` | 20 线程 | 知识库文档解析、Embedding 生成 |
| `workflow` | `celery ... worker -Q workflow_celery` | `workflow_celery` | 100 线程 | 工作流 DAG 执行 |
| `beat` | `celery ... beat` | -- | -- | 定时任务调度器 |
| `default` | `celery ... worker -Q celery` | `celery` | 100 线程 | 遥测统计等默认任务 |
| `linsight` | `python bisheng/linsight/worker.py` | -- | 4 worker / 5 并发 | 灵思 Agent 独立进程 |
| `worker` | 以上全部(除 api) | 全部 | -- | 一次性启动全部 Worker + Beat |
### 定时任务(Beat Schedule
`CeleryConf``src/backend/bisheng/core/config/settings.py`)中定义的默认定时任务:
| 任务标识 | Celery Task 路径 | 调度时间 | 说明 |
|---------|-----------------|---------|------|
| `telemetry_mid_user_increment` | `bisheng.worker.telemetry.mid_table.sync_mid_user_increment` | 每日 00:30 | 用户增量遥测统计 |
| `telemetry_mid_knowledge_increment` | `bisheng.worker.telemetry.mid_table.sync_mid_knowledge_increment` | 每日 00:30 | 知识库增量遥测统计 |
| `telemetry_sync_mid_app_increment` | `bisheng.worker.telemetry.mid_table.sync_mid_app_increment` | 每日 00:30 | 应用增量遥测统计 |
| `telemetry_sync_mid_user_interact_dtl` | `bisheng.worker.telemetry.mid_table.sync_mid_user_interact_dtl` | 每日 00:30 | 用户交互明细统计 |
| `sync_information_article` | `bisheng.worker.information.article.sync_information_article` | 每日 05:30 | 同步情报中心文章 |
## 运维管理脚本
`docker/deploy.sh` 提供常用运维操作的快捷命令:
| 命令 | 用法 | 说明 |
|------|------|------|
| `logs` | `./deploy.sh logs backend [-n 200]` | 实时跟踪容器日志 |
| `version` | `./deploy.sh version [v3.0.0]` | 查看或修改镜像版本号 |
| `exec` | `./deploy.sh exec backend` | 进入容器 Shell |
| `update` | `./deploy.sh update [backend]` | 拉取最新镜像并重启 |
| `restart` | `./deploy.sh restart [backend worker]` | 重启指定服务 |
支持的 service 别名:`backend``worker`backend_worker)、`frontend``mysql``redis``es`elasticsearch)、`minio``milvus``etcd`
## 升级 checklist
> 跨版本升级时,除 `alembic upgrade head`DDL)外,部分版本还引入了**一次性数据迁移 / backfill 脚本**。这类数据操作按约定不放进 Alembic(见 `src/backend/CLAUDE.md`「Migration vs. script」),需在升级后**手动按序执行**。脚本均**默认 dry-run、可重复执行(幂等)**,建议先空跑看摘要再加 `--apply`。脚本详细说明见 `src/backend/scripts/README.md`。
### v2.6 · 灵思任务模式迁移(F035)
从 < v2.6 升级到 v2.6(自研 ReAct → deepagents)涉及 4 件事。其中**步骤 2/3 在服务首次启动时自动执行**(`main.lifespan` 里的幂等 backfill,对齐 F034 先例:失败只记日志、绝不阻塞启动,下次启动自愈),运维**无需手动跑**——对应脚本仅在需要时供手动补跑 / dry-run 核对。**步骤 1 由部署流程执行、步骤 4 需手动执行。**
| # | 步骤 | 触发方式 | 命令(手动补跑 / 核对,从 `src/backend/` | 不执行的后果 |
|---|------|---------|------------------------------------------|-------------|
| 1 | **建表**(DDL,先决条件) | 🔧 部署流程 | `uv run alembic upgrade head` | 后续 backfill / 脚本所依赖的 `linsight_skill` 等表不存在 |
| 2 | **灵思执行模型收敛**Track E | ✅ 启动自动 | `python scripts/migrate_linsight_task_model_to_default.py``--apply` | `task_model` / `linsight_executor_mode` 残留,新内核取不到 `linsight_default_model_id`,灵思无可用执行模型 |
| 3 | **任务模式菜单权限**WEB_MENU | ✅ 启动自动 | `python scripts/backfill_linsight_task_mode_web_menu.py``--apply` | 存量角色丢失 `linsight_task_mode` 菜单,路由守卫拦截,看不到任务模式入口 |
| 4 | **存量 SOP → Skill**Track G | ✋ 手动 | `bash scripts/migrate_sop_to_skill.sh``bash scripts/migrate_sop_to_skill.sh apply` | 存量 `linsight_sop` 不会转为可用 Skill,管理页 / 技能选择器为空 |
要点:
- **为什么 2/3 自动、4 手动**:2/3 是纯 DB、幂等、轻量的 backfill,失败只影响菜单 / 模型配置且可自愈,适合放进启动 lifespan;步骤 4 要写对象存储(MinIO 上的 `SKILL.md`)、数据量可能大、需人工核对迁移摘要,副作用重,故保持手动运维脚本(详见 `src/backend/CLAUDE.md`「Migration vs. script」与 PRD 决策)。
- **步骤 4 说明**:需要完整 app context(写 MinIO 的 `SKILL.md`)。**不调用 LLM**——技能描述取 SOP 原描述,缺失时用 SOP 名称兜底(技能描述为必填,不会留空)。产出 JSON 迁移摘要(成功/跳过/失败,**运维产物,无管理页报告界面**),失败 / 超大 SOP 项需人工处理(拆分后经管理页重建)。`linsight_sop` 原表保留归档、不删。
- **幂等**:四步均可安全重跑。步骤 2/3 重复启动是 no-op;步骤 4 借 `metadata.sop-id` 识别已迁移项并覆盖自身 bundle,不会重复产生带后缀的技能。
- 步骤 4 单租户灰度可加 `--tenant-id <id>`
## 相关文档
- 系统架构总览 -- `docs/architecture/01-architecture-overview.md`
- 配置系统详解 -- Settings 类定义在 `src/backend/bisheng/core/config/settings.py`
- 开发指南 -- `docs/architecture/09-development-guide.md`
-354
View File
@@ -1,354 +0,0 @@
# 开发指南
本文档面向 BiSheng 项目的开发者,涵盖环境搭建、服务启动、新模块开发约定、工作流节点扩展、API 端点添加、测试和代码风格规范。后端使用 Python 3.11pyproject `requires-python >=3.11`)+ uv 管理依赖(2.4.0 版本已从 Poetry 迁移到 uv),前端使用 React + TypeScript + Vite。
## 环境搭建
### 后端环境
```bash
# 1. 创建 Python 3.11 虚拟环境(pyproject 要求 requires-python >=3.11
conda create --name BiShengVENV python==3.11
conda activate BiShengVENV
# 2. 安装后端依赖(使用 uvlockfile 为 uv.lock
cd src/backend
uv sync --frozen --python $(which python)
```
`uv sync` 会在 `src/backend/.venv/` 下创建虚拟环境并安装全部依赖。后续启动服务均通过 `.venv/bin/` 下的可执行文件调用。
### 前端环境
```bash
# 前端为 pnpm workspace(platform + client + packages/ui),在 workspace 根一次安装。
# 已禁用 npm(only-allow pnpm);pnpm 通过 corepack 提供:corepack enable
cd src/frontend
pnpm install
```
### 存储服务
存储服务通过 Docker Compose 启动,然后停止与本地开发冲突的容器:
```bash
cd docker && docker compose -p bisheng up -d
docker stop bisheng-backend bisheng-backend-worker bisheng-frontend
```
## 服务启动
### 后端 API 服务
```bash
cd src/backend
.venv/bin/uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log
```
本地开发建议使用 `--workers 1` 以便调试。生产环境的 Docker 容器默认使用 `--workers 8`
### Celery Workers
每个 Worker 需要独立的终端窗口:
```bash
# 知识库任务 Worker(文档解析、Embedding 生成、向量写入)
.venv/bin/celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q knowledge_celery -n knowledge@%h
# 工作流任务 Worker(工作流 DAG 执行)
.venv/bin/celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q workflow_celery -n workflow@%h
# 定时任务调度器(遥测统计、情报同步)
.venv/bin/celery -A bisheng.worker.main beat -l info
```
### Linsight Worker(可选)
灵思 Agent 框架使用独立的 Python 进程,不走 Celery 队列:
```bash
.venv/bin/python bisheng/linsight/worker.py --worker_num 4 --max_concurrency 5
```
### 前端开发服务器
```bash
cd src/frontend/platform
pnpm start -- --host 0.0.0.0
```
Vite 开发服务器运行在 3001 端口,自动将 `/api/``/health` 请求代理到后端 `localhost:7860`。文件服务路由(`/bisheng``/tmp-dir`)代理到 MinIO。
## 新模块开发约定
后端遵循领域驱动设计(DDD)模式。新增业务模块时,按以下目录结构组织代码。
### 目录结构
```
src/backend/bisheng/<module_name>/
├── api/ # API 层
│ ├── router.py # 路由注册(创建 APIRouter
│ ├── dependencies.py # 依赖注入(可选)
│ └── endpoints/ # 端点实现
│ └── <module_name>.py # CRUD 端点函数
└── domain/ # 领域层
├── models/ # 领域模型(ORM 实体)
├── schemas/ # Pydantic 数据传输对象
├── services/ # 领域服务(核心业务逻辑)
└── repositories/ # 仓储层(可选)
├── interfaces/ # 仓储接口定义
└── implementations/ # 仓储实现
```
### 步骤
1. **创建模块目录**:在 `src/backend/bisheng/` 下创建模块目录,包含 `api/``domain/` 子目录。
2. **定义路由**:在 `api/router.py` 中创建 `APIRouter`,设置路由前缀和标签:
```python
from fastapi import APIRouter
from bisheng.<module_name>.api.endpoints.<module_name> import router as module_router
router = APIRouter(prefix='/<module_name>', tags=['<ModuleName>'])
router.include_router(module_router)
```
3. **注册到全局路由**:在 `src/backend/bisheng/api/router.py` 中导入并注册路由:
```python
from bisheng.<module_name>.api.router import router as module_router
router.include_router(module_router) # 注册到 v1 路由
```
4. **实现业务逻辑**:遵循调用链路 `Router -> Endpoint -> Service -> Repository -> ORM`。较简单的模块可省略 Repository 层,在 Service 中直接调用 DAO。
### 调用链路
```
api/endpoints/<module_name>.py ← 接收请求,校验参数,调用 Service
|
v
domain/services/<service>.py ← 业务逻辑编排,事务控制
|
v
domain/repositories/impl/<repo>.py ← 数据访问(或直接调用 database/models/ 中的 DAO
|
v
database/models/<model>.py ← SQLModel ORMDAO 方法(sync get_xxx / async aget_xxx
```
## 新工作流节点开发
工作流引擎基于 LangGraph,支持 14 种节点类型。扩展新节点需要修改三个位置。
### 步骤
1. **创建节点目录**:在 `src/backend/bisheng/workflow/nodes/` 下创建节点子目录:
```
src/backend/bisheng/workflow/nodes/my_node/
├── __init__.py
└── my_node.py
```
2. **实现节点类**:继承 `BaseNode``src/backend/bisheng/workflow/nodes/base.py`),实现 `_run` 抽象方法:
```python
from bisheng.workflow.nodes.base import BaseNode
class MyNode(BaseNode):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 从 self.node_data 中提取节点配置参数
# 将处理后的参数存入 self.node_params
def _run(self, unique_id: str):
"""
节点执行逻辑。
参数:
unique_id: 本次执行的唯一标识
行为:
- 通过 self.graph_state.get_variable() 读取上游节点变量
- 执行业务逻辑
- 通过 self.graph_state.set_variable() 写入输出变量
- 通过 self.callback_manager 发送事件(on_node_start, on_node_end 等)
"""
pass
```
`BaseNode` 构造函数接收以下关键参数:
- `node_data: BaseNodeData` -- 节点配置数据(类型、参数、描述)
- `workflow_id: str` -- 所属工作流 ID
- `user_id: int` -- 执行用户 ID
- `graph_state: GraphState` -- 全局变量池,管理节点间数据流
- `target_edges: List[EdgeBase]` -- 出边列表
- `max_steps: int` -- 最大执行步数(默认 50
- `callback: BaseCallback` -- 回调管理器,支持流式输出
3. **注册节点类型枚举**:在 `src/backend/bisheng/workflow/common/node.py``NodeType` 枚举中添加新类型:
```python
class NodeType(Enum):
# ... 现有类型
MY_NODE = "my_node"
```
4. **注册节点工厂映射**:在 `src/backend/bisheng/workflow/nodes/node_manage.py``NODE_CLASS_MAP` 中添加映射:
```python
from bisheng.workflow.nodes.my_node.my_node import MyNode
NODE_CLASS_MAP = {
# ... 现有映射
NodeType.MY_NODE.value: MyNode,
}
```
### 现有节点类型参考
| 类型 | 枚举值 | 说明 |
|------|--------|------|
| `START` | `start` | 工作流起始节点 |
| `END` | `end` | 工作流终止节点 |
| `INPUT` | `input` | 用户输入节点 |
| `OUTPUT` | `output` | 结果输出节点 |
| `FAKE_OUTPUT` | `fake_output` | 伪输出节点 |
| `LLM` | `llm` | 大语言模型调用 |
| `CODE` | `code` | 代码执行节点 |
| `CONDITION` | `condition` | 条件分支判断 |
| `KNOWLEDGE_RETRIEVER` | `knowledge_retriever` | 知识库向量检索 |
| `QA_RETRIEVER` | `qa_retriever` | 问答检索 |
| `RAG` | `rag` | 检索增强生成 |
| `TOOL` | `tool` | 工具调用 |
| `AGENT` | `agent` | Agent 智能体 |
| `REPORT` | `report` | 报告生成 |
## 新 API 端点开发
### 步骤
1. **创建端点文件**:在对应模块的 `api/endpoints/` 目录下创建文件,定义路由和处理函数:
```python
from fastapi import APIRouter, Depends
from bisheng.common.dependencies.user_deps import UserPayload
from bisheng.common.schemas.api import UnifiedResponseModel, resp_200
router = APIRouter(prefix='/my-resource', tags=['MyResource'])
@router.get('/', response_model=UnifiedResponseModel)
async def list_resources(login_user: UserPayload = Depends(UserPayload.get_login_user)):
"""获取资源列表。"""
# login_user 包含: user_id, user_name, user_role
# login_user.is_admin() 判断是否管理员
# login_user.access_check(owner_id, target_id, access_type) 检查资源权限
data = []
return resp_200(data=data)
```
2. **认证依赖注入**:通过 `UserPayload = Depends(UserPayload.get_login_user)` 获取当前登录用户。`UserPayload` 从 JWT Cookie 中解析用户身份,提供以下属性和方法:
| 属性/方法 | 类型 | 说明 |
|----------|------|------|
| `user_id` | `int` | 用户 ID |
| `user_name` | `str` | 用户名 |
| `user_role` | `List[int]` | 用户角色 ID 列表 |
| `is_admin()` | `bool` | 是否管理员 |
| `access_check(owner_id, target_id, access_type)` | `bool` | 资源权限检查 |
WebSocket 端点使用 `UserPayload.get_login_user_from_ws` 变体。
3. **统一响应格式**:所有 API 返回 `UnifiedResponseModel`,通过辅助函数构造:
```python
from bisheng.common.schemas.api import resp_200, resp_500
# 成功响应
return resp_200(data={"id": 1, "name": "test"})
# 返回: {"status_code": 200, "status_message": "SUCCESS", "data": {...}}
# 错误响应
return resp_500(code=500, message="操作失败")
# 返回: {"status_code": 500, "status_message": "操作失败", "data": null}
```
4. **注册路由**:在模块的 `api/router.py` 中包含端点路由,然后在 `src/backend/bisheng/api/router.py` 全局路由中注册。
### 错误码规范
错误码体系定义在 `src/backend/bisheng/common/errcode/``src/backend/bisheng/api/errcode/` 中。错误码为 5 位整数,前 3 位标识模块,后 2 位标识具体错误。继承 `BaseErrorCode` 可定义模块专属错误码,支持三种输出格式:
- `return_resp()` -- HTTP JSON 响应
- `to_sse_event()` -- SSE 事件流
- `websocket_close_message()` -- WebSocket 关闭消息
## 测试
### 运行测试
```bash
cd src/backend
# 运行全部测试
.venv/bin/pytest test/
# 运行单个测试文件
.venv/bin/pytest test/test_knowledge.py
# 运行单个测试用例
.venv/bin/pytest test/test_knowledge.py::test_fn
# 按关键字筛选测试
.venv/bin/pytest test/ -k "keyword"
```
### 测试文件位置
测试代码位于 `src/backend/test/` 目录。测试文件命名遵循 `test_<module>.py` 约定。
## 代码风格
### 后端
使用 Black 格式化和 Ruff 代码检查:
```bash
cd src/backend
# 代码格式化
.venv/bin/black .
# 代码检查与自动修复
.venv/bin/ruff check . --fix
```
### 后端编码约定
- **ORM 模型**:定义在 `database/models/` 中,每个文件包含 Base/Read/Create/Update schema 和 DAO 类。DAO 提供同步方法(`get_xxx`)和异步方法(`aget_xxx`)两套接口。
- **配置读取**:运行时可变配置从数据库读取(通过 `ConfigService.get_all_config()`),静态配置从 `config.yaml` 加载。
- **日志**:使用 Loguru,通过 `from loguru import logger` 导入。中间件自动注入 `trace_id` 用于链路追踪。
- **异步任务**:耗时操作投递到 Celery 队列。知识库任务路由到 `knowledge_celery` 队列,工作流任务路由到 `workflow_celery` 队列。
### 前端
- TypeScript 严格模式
- 组件使用函数式组件 + Hooks
- 状态管理优先使用 Zustand store,其次 React Context
- 国际化文本通过 `useTranslation()` 获取,支持中文、英文、日文
## 相关文档
- 系统架构总览 -- `docs/architecture/01-architecture-overview.md`
- 工作流引擎设计 -- `docs/architecture/02-workflow-engine.md`
- 知识库/RAG 流水线 -- `docs/architecture/03-knowledge-rag-pipeline.md`
- 数据模型定义 -- `docs/architecture/07-data-models.md`
- 部署与运维 -- `docs/architecture/08-deployment.md`
-725
View File
@@ -1,725 +0,0 @@
# 用户与权限体系深度解析
BiSheng 采用**三层权限模型**:用户认证(JWT)→ 角色权限(RBAC)→ 资源级授权(Owner/Role/Member),覆盖菜单可见性、资源读写、协作空间三个维度。当前体系以"角色-资源"绑定为核心,辅以"知识空间成员制"实现协作场景,但细粒度授权能力(字段级、操作级、跨模块联动)尚有扩展空间。
---
## 1. 权限架构总览
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户请求 │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 第一层:认证 (Authentication) │
│ JWT Token → Cookie / Header / WebSocket │
│ AuthJwt.get_subject() → {user_id, user_name} │
│ 文件: user/domain/services/auth.py:24-93 │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 第二层:身份加载 (Identity Loading) │
│ LoginUser.init_login_user() │
│ → 加载 user_role[] (UserRoleDao) │
│ → 判断 is_admin() (role_id == 1) │
│ → 注入为 FastAPI Depends(UserPayload.get_login_user) │
│ 文件: user/domain/services/auth.py:95-338 │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 第三层:授权 (Authorization) — 三种检查模式 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ 菜单权限 │ │ 资源权限 │ │ 协作空间成员权限 │ │
│ │ WEB_MENU(99) │ │ RBAC 读/写 │ │ SpaceChannelMember │ │
│ │ 控制前端导航 │ │ Owner/Role │ │ Creator/Admin/ │ │
│ │ │ │ 判定 │ │ Member │ │
│ └──────────────┘ └──────────────┘ └────────────────────┘ │
│ 文件: database/models/role_access.py │
│ common/models/space_channel_member.py │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. 数据模型关系
```mermaid
erDiagram
User ||--o{ UserRole : "拥有"
User ||--o{ UserGroup : "属于"
User ||--o{ UserLink : "外部账号"
Role ||--o{ UserRole : "分配给"
Role ||--o{ RoleAccess : "授予"
Role }o--|| Group : "归属"
Group ||--o{ UserGroup : "包含"
Group ||--o{ GroupResource : "管理"
SpaceChannelMember }o--|| User : "成员"
User {
int user_id PK
string user_name UK
string password
datetime password_update_time
int delete
}
UserRole {
int user_id PK_FK
int role_id PK_FK
}
Role {
int id PK
string role_name
int group_id FK
int knowledge_space_file_limit
}
RoleAccess {
int id PK
int role_id FK
string third_id "资源ID"
int type "AccessType枚举"
}
Group {
int id PK
string group_name UK
int create_user
}
UserGroup {
int user_id PK_FK
int group_id PK_FK
bool is_group_admin
}
GroupResource {
int id PK
string group_id FK
string third_id "资源ID"
int type "ResourceTypeEnum"
}
SpaceChannelMember {
int id PK
string business_id "空间/频道ID"
string business_type "space/channel"
int user_id FK
string user_role "creator/admin/member"
string status "ACTIVE/PENDING/REJECTED"
bool is_pinned
}
UserLink {
int id PK
int user_id FK
string type "OAuth/SSO类型"
string type_detail
}
```
### 核心表说明
| 表 | 文件 | 记录数量级 | 用途 |
|---|---|---|---|
| `user` | `user/domain/models/user.py` | 百~万 | 用户主表 |
| `userrole` | `user/domain/models/user_role.py` | 与用户 1:N | 用户-角色关联(多角色,旧文档常写 `user_role` |
| `role` | `database/models/role.py` | 十~百 | 角色定义,按 Group 隔离 |
| `roleaccess` | `database/models/role_access.py` | 百~万 | 角色-资源授权记录(旧文档常写 `role_access` |
| `group` | `database/models/group.py` | 十~百 | 用户组 |
| `usergroup` | `database/models/user_group.py` | 与用户 1:N | 用户-组关联,含组管理员标记(旧文档常写 `user_group` |
| `groupresource` | `database/models/group_resource.py` | 百~万 | 组-资源归属关系(旧文档常写 `group_resource` |
| `space_channel_member` | `common/models/space_channel_member.py` | 百~万 | 知识空间/频道成员(协作模型) |
> 注意:这里涉及的当前 SQL 表名分别是 `userrole`、`roleaccess`、`usergroup`、`groupresource``user_role`、`role_access`、`user_group`、`group_resource` 在本仓里更多是代码文件名、历史逻辑名或字段名,不是当前实际表名。
---
## 3. 认证机制
### 3.1 JWT Token 生命周期
```
注册/登录 → 密码校验(MD5) → 生成 JWT → 写入 Cookie
payload = {user_id, user_name}
algorithm = HS256
secret = settings.jwt_secret
exp = now + jwt_token_expire_time (默认 86400s = 1天)
iss = settings.cookie_conf.jwt_iss (默认 "bisheng")
```
**源码位置**: `user/domain/services/auth.py:34-44`
```python
class AuthJwt:
def create_access_token(self, subject: dict) -> str:
payload = {
'sub': json.dumps(subject), # {user_id, user_name}
'exp': int(datetime.now(timezone.utc).timestamp()) + self.cookie_conf.jwt_token_expire_time,
'iss': self.cookie_conf.jwt_iss
}
return jwt.encode(payload, self.jwt_secret, algorithm="HS256")
```
### 3.2 Token 提取方式(三种来源)
| 来源 | 场景 | 提取方式 |
|------|------|----------|
| Cookie | 浏览器请求 | `request.cookies.get("access_token_cookie")` |
| Header | API 调用 | `request.headers["Authorization"].split(" ")[-1]` |
| WebSocket | 实时通信 | `websocket.cookies.get("access_token_cookie")` 或查询参数 `t` |
**源码位置**: `auth.py:67-83`
### 3.3 Cookie 配置
```python
class CookieConf(BaseModel): # core/config/settings.py:204-214
max_age: Optional[int] = None # Cookie 最大存活秒数
path: str = '/'
domain: Optional[str] = None
secure: bool = False # 是否仅 HTTPS
httponly: bool = True # 禁止 JS 访问
samesite: str = None # 'lax'/'strict'/'none'
jwt_token_expire_time: int = 86400 # Token 有效期(秒)
jwt_iss: str = 'bisheng' # JWT 签发者
```
### 3.4 登录密码安全
- **传输加密**: 前端 RSA 加密密码,后端解密后 MD5 存储
- **密码策略** (`PasswordConf`):
- `password_valid_period`: 密码有效期(天),过期强制修改
- `login_error_time_window`: 错误窗口期(分钟)
- `max_error_times`: 最大错误次数,超过后锁定用户
- **多设备登录**: `allow_multi_login` 控制是否允许多端同时登录
---
## 4. 身份与角色体系
### 4.1 用户身份层级
```
系统管理员 (AdminRole, id=1)
↓ 拥有全部权限,跳过所有检查
用户组管理员 (UserGroup.is_group_admin=True)
↓ 管理组内用户和资源
普通用户 (DefaultRole, id=2 + 自定义角色)
↓ 按角色授权访问资源
```
### 4.2 LoginUser — 权限判定核心类
**文件**: `user/domain/services/auth.py:95-338`
这是整个权限系统的运行时核心。每个请求通过 FastAPI 依赖注入获得一个 `LoginUser` 实例:
```python
class LoginUser(BaseModel):
user_id: int
user_name: str
user_role: List[int] # 用户拥有的角色 ID 列表
group_cache: Dict[int, Any] # 组信息缓存,减少 DB 查询
```
**依赖注入入口** (`auth.py:291-293`):
```python
@classmethod
async def get_login_user(cls, auth_jwt: AuthJwt = Depends()) -> Self:
subject = auth_jwt.get_subject() # 解码 JWT
return await cls.init_login_user( # 加载角色
user_id=subject['user_id'],
user_name=subject['user_name']
)
```
`init_login_user` 会从 `UserRoleDao` 查询用户的所有 `role_id`,缓存在 `user_role` 列表中。
### 4.3 Admin 绕过机制
```python
@cached_property
def _check_admin(self): # auth.py:113-119
return any(role_id == AdminRole for role_id in self.user_role)
@staticmethod
def wrapper_access_check(func): # auth.py:124-137
"""装饰器:admin 用户直接返回 True,跳过后续检查"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
if args[0].is_admin():
return True
return func(*args, **kwargs)
return wrapper
```
所有权限检查方法(`access_check`, `check_group_admin`, `copiable_check` 等)都使用此装饰器,admin 一律放行。
### 4.4 系统初始化默认权限
**文件**: `common/init_data.py:25-58`
首次启动时自动创建:
| 实体 | 值 | 说明 |
|------|-----|------|
| Role id=1 | System Admin | 系统最高权限 |
| Role id=2 | Regular users | 普通用户默认角色 |
| Group id=2 | Default user group | 所有用户自动加入 |
| RoleAccess × 6 | DefaultRole + WEB_MENU | 普通用户的默认菜单权限 |
普通用户默认获得的菜单权限:
```python
BUILD, KNOWLEDGE, MODEL, BACKEND, FRONTEND, KNOWLEDGE_SPACE
```
未默认授予的菜单:`EVALUATION`, `BOARD`, `SUBSCRIPTION`, `CREATE_DASHBOARD`
---
## 5. 资源授权模型(RBAC
### 5.1 AccessType — 资源权限类型
**文件**: `database/models/role_access.py:52-66`
```python
class AccessType(Enum):
KNOWLEDGE = 1 # 知识库读权限
KNOWLEDGE_WRITE = 3 # 知识库写权限
ASSISTANT_READ = 5 # 助手读权限
ASSISTANT_WRITE = 6 # 助手写权限
GPTS_TOOL_READ = 7 # 工具读权限
GPTS_TOOL_WRITE = 8 # 工具写权限
WORKFLOW = 9 # 工作流读权限
WORKFLOW_WRITE = 10 # 工作流写权限
DASHBOARD = 11 # 看板读权限
DASHBOARD_WRITE = 12 # 看板写权限
WEB_MENU = 99 # 前端菜单可见性
```
**设计特点**:
- 读写分离:每类资源有独立的读(奇数/小值)和写(偶数/大值)权限
- 编号不连续:2、4 未使用,为历史预留
- WEB_MENU 是特殊类型,控制前端导航菜单可见性而非资源访问
### 5.2 ResourceTypeEnum — 组资源类型
**文件**: `database/models/group_resource.py:12-19`
```python
class ResourceTypeEnum(Enum):
KNOWLEDGE = 1 # 知识库
ASSISTANT = 3 # 助手
GPTS_TOOL = 4 # 工具
WORK_FLOW = 5 # 工作流
DASHBOARD = 6 # 看板
WORKSTATION = 7 # 工作台
SPACE_FILE = 8 # 知识空间文件
```
### 5.3 WebMenuResource — 前端菜单项
**文件**: `database/models/role_access.py:35-49`
```python
class WebMenuResource(Enum):
BUILD = 'build' # 应用构建
KNOWLEDGE = 'knowledge' # 知识库管理
MODEL = 'model' # 模型管理
EVALUATION = 'evaluation' # 模型评测
BOARD = 'board' # 数据看板
KNOWLEDGE_SPACE = 'knowledge_space' # 知识空间
SUBSCRIPTION = 'subscription' # 订阅管理
FRONTEND = 'frontend' # 前端权限
BACKEND = 'backend' # 后端权限
CREATE_DASHBOARD = 'create_dashboard' # 创建看板
```
前端路由通过 `permission` 属性关联菜单项:
```typescript
// src/frontend/platform/src/routes/index.tsx
{ path: "filelib", element: <KnowledgePage />, permission: 'knowledge' }
{ path: "build/apps", element: <Apps />, permission: 'build' }
{ path: "sys", element: <SystemPage />, permission: 'sys' }
```
### 5.4 核心权限判定流程
**access_check** (`auth.py:154-165`) — 单资源访问检查:
```
access_check(owner_user_id, target_id, access_type)
├── is_admin()? ──→ True (装饰器短路)
├── user_id == owner_user_id? ──→ True (资源所有者)
├── RoleAccessDao.judge_role_access(
│ user_role[], # 用户的所有角色 ID
│ target_id, # 资源 ID
│ access_type # 权限类型
│ )? ──→ True (角色授权)
└── False (无权限)
```
**SQL 查询**:
```sql
SELECT * FROM role_access
WHERE role_id IN ()
AND type =
AND third_id = ID
LIMIT 1
```
### 5.5 资源列表过滤流程
**文件**: `api/services/workflow.py` (WorkFlowService.get_all_flows)
```python
if user.is_admin():
# 管理员:查看全部资源
data, total = FlowDao.get_all_apps(...)
else:
# 普通用户:自己的 + 角色授权的
access_list = [AccessType.WORKFLOW, AccessType.ASSISTANT_READ]
flow_id_extra = user.get_user_access_resource_ids(access_list)
data, total = FlowDao.get_all_apps(
user_id=user.user_id, # 过滤自己创建的
id_extra=flow_id_extra # 合并角色授权的资源 ID
)
```
**FlowDao 中的 SQL 逻辑**:
```sql
WHERE (user_id = OR id IN (ID列表))
```
### 5.6 写权限检查的 API 示例
**文件**: `api/v1/workflow.py:35-58`
```python
@router.get("/write/auth")
async def check_app_write_auth(login_user: UserPayload = Depends(...), ...):
flow_info = await FlowDao.aget_flow_by_id(flow_id)
if flow_type == FlowType.ASSISTANT.value:
check_type = AccessType.ASSISTANT_WRITE
else:
check_type = AccessType.WORKFLOW_WRITE
if await login_user.async_access_check(flow_info.user_id, flow_id, check_type):
return resp_200()
return AppWriteAuthError.return_resp()
```
---
## 6. 用户组与资源归属
### 6.1 组的层级
```
DefaultGroup (id=2) ← 所有用户自动加入
├── User A (普通成员)
├── User B (组管理员, is_group_admin=True)
└── 关联资源 (GroupResource)
├── Knowledge #1
├── Workflow #2
└── Assistant #3
Custom Group (id=N) ← 管理员创建
├── User C (组管理员)
├── User D (普通成员)
└── 关联资源 (GroupResource)
```
### 6.2 组管理员权限
组管理员(`UserGroup.is_group_admin=True`)的能力:
| 操作 | 系统管理员 | 组管理员 | 普通用户 |
|------|-----------|---------|---------|
| 创建组 | 可以 | 可以 | 不可以 |
| 删除组 | 可以 | 仅自己管理的组 | 不可以 |
| 管理组内用户 | 可以 | 仅自己管理的组 | 不可以 |
| 分配资源到组 | 可以 | 仅自己管理的组 | 不可以 |
| 修改管理员用户的组 | 不可以 | 不可以 | 不可以 |
**关键约束** (`api/services/role_group_service.py`):
- 管理员用户(AdminRole)的组归属不可被修改(`AdminUserUpdateForbiddenError`
- DefaultGroupid=2)不可删除(`UserGroupNotDeleteError`
- 组删除时,组内资源会迁移到 DefaultGroup(如果该资源不属于其他组)
### 6.3 GroupResource — 资源归属
`GroupResource` 表记录"哪个资源属于哪个组",是间接授权的桥梁:
```
用户 → UserGroup → Group → GroupResource → 资源
```
**注意**: GroupResource 本身**不直接用于权限判定**。当前权限判定走的是 `RoleAccess` 路径。GroupResource 更多用于资源管理和组织视图(如"查看本组的所有知识库")。这是一个潜在的扩展点——未来可以将 GroupResource 与权限判定结合,实现"组内资源自动对组成员可见"。
---
## 7. 协作空间成员制(Knowledge Space
### 7.1 架构概述
知识空间采用独立于 RBAC 的**成员制**模型,是当前系统中最接近"细粒度协作授权"的实现。
**文件**: `common/models/space_channel_member.py`
```python
class SpaceChannelMember(SQLModelSerializable, table=True):
business_id: str # 空间/频道 ID
business_type: str # 'space' 或 'channel'
user_id: int # 成员用户 ID
user_role: UserRoleEnum # creator / admin / member
status: MembershipStatusEnum # ACTIVE / PENDING / REJECTED
is_pinned: bool # 是否置顶
```
### 7.2 成员角色与权限矩阵
| 操作 | Creator | Admin | Member | 非成员(Public空间) | 非成员(Private空间) |
|------|---------|-------|--------|-------------------|-------------------|
| 查看空间 | 可以 | 可以 | 可以 | 可以 | 不可以 |
| 上传文件 | 可以 | 可以 | 不可以 | 不可以 | 不可以 |
| 删除文件 | 可以 | 可以 | 不可以 | 不可以 | 不可以 |
| 管理成员 | 可以 | 可以 | 不可以 | 不可以 | 不可以 |
| 修改空间设置 | 可以 | 可以 | 不可以 | 不可以 | 不可以 |
| 删除空间 | 可以 | 不可以 | 不可以 | 不可以 | 不可以 |
| 订阅/取消订阅 | -- | 可以 | 可以 | 可以申请 | 可以申请 |
### 7.3 权限检查实现
**写权限** (`knowledge_space_service.py:117-127`):
```python
async def _require_write_permission(self, space_id: int) -> UserRoleEnum:
role = await SpaceChannelMemberDao.async_get_active_member_role(
space_id, self.login_user.user_id
)
if role not in {UserRoleEnum.CREATOR, UserRoleEnum.ADMIN}:
raise SpacePermissionDeniedError()
return role
```
**读权限** (`knowledge_space_service.py:129-140`):
```python
async def _require_read_permission(self, space_id: int) -> Knowledge:
space = await KnowledgeDao.aquery_by_id(space_id)
if space.auth_type == AuthTypeEnum.PUBLIC: # Public 空间所有人可读
return space
role = await SpaceChannelMemberDao.async_get_active_member_role(...)
if not role: # Private 空间必须是活跃成员
raise SpacePermissionDeniedError()
return space
```
### 7.4 订阅审批流程
```
用户申请订阅 → SpaceChannelMember(status=PENDING)
→ 空间管理员审批
├── 批准 → status=ACTIVE → 发送通知
└── 拒绝 → status=REJECTED → 24小时内可见拒绝状态
```
**文件**: `knowledge/domain/services/subscribe_handler.py`
### 7.5 空间限制
```python
_MAX_SPACE_PER_USER = 30 # 每个用户最多创建 30 个空间
_MAX_SUBSCRIBE_PER_USER = 50 # 每个用户最多订阅 50 个空间(非创建者)
```
### 7.6 与 RBAC 的关系
知识空间的成员制模型**独立于** RBAC 体系运作:
- RBAC 路径:`User → UserRole → Role → RoleAccess → Knowledge (type=KNOWLEDGE)`
- 空间路径:`User → SpaceChannelMember → Knowledge (type=SPACE)`
普通知识库(`KnowledgeTypeEnum.NORMAL`)走 RBAC,知识空间(`KnowledgeTypeEnum.SPACE`)走成员制。两套机制并行,代码中通过 `knowledge.type` 区分。
---
## 8. 频道权限模型
频道(Channel)复用了 `SpaceChannelMember` 表(`business_type='channel'`),但有自己的约束:
```python
MAX_USER_CHANNEL_COUNT = 10 # 用户最多创建 10 个频道
MAX_ADMIN_COUNT = 5 # 频道最多 5 个管理员
MAX_USER_SUBSCRIBE_COUNT = 20 # 用户最多订阅 20 个频道
```
频道可见性(`ChannelVisibilityEnum`):PUBLIC / PRIVATE / INTERNAL
---
## 9. 前端路由权限控制
### 9.1 菜单动态过滤
**后端** (`auth.py:315-337`):
```python
@classmethod
async def get_roles_web_menu(cls, user: User) -> (str | List[int], List[str]):
if role == 'admin':
web_menu = [one.value for one in WebMenuResource] # 管理员获得全部菜单
else:
web_menu = await RoleAccessDao.aget_role_access(role_ids, AccessType.WEB_MENU)
web_menu = list(set([one.third_id for one in web_menu]))
return role, web_menu
```
**前端**: 路由表中的 `permission` 属性与 `web_menu` 列表匹配,不在列表中的路由不渲染。
### 9.2 角色类型返回
登录后返回给前端的角色类型:
| 返回值 | 含义 | 菜单范围 |
|--------|------|----------|
| `'admin'` | 系统管理员 | 全部菜单 |
| `'group_admin'` | 组管理员 | 按角色查询 |
| `[role_id, ...]` | 普通用户 | 按角色查询 |
---
## 10. 权限流转全景图
```mermaid
flowchart TD
A[用户请求] --> B{JWT 有效?}
B -->|No| C[401 未认证]
B -->|Yes| D[加载 LoginUser]
D --> E{is_admin?}
E -->|Yes| F[放行全部操作]
E -->|No| G{操作类型}
G -->|菜单访问| H[查询 RoleAccess<br/>type=WEB_MENU]
H --> I{匹配?}
I -->|Yes| J[允许]
I -->|No| K[隐藏菜单项]
G -->|资源读写| L{是资源所有者?}
L -->|Yes| M[允许]
L -->|No| N[查询 RoleAccess<br/>type=对应AccessType]
N --> O{角色有授权?}
O -->|Yes| P[允许]
O -->|No| Q[403 无权限]
G -->|知识空间操作| R{空间类型}
R -->|Public| S[读: 允许<br/>写: 查成员角色]
R -->|Private| T[查 SpaceChannelMember]
T --> U{是活跃成员?}
U -->|Yes| V{Creator/Admin?}
V -->|Yes| W[允许读写]
V -->|No Member| X[仅允许读]
U -->|No| Y[403 无权限]
G -->|组管理操作| Z[查询 UserGroup<br/>is_group_admin]
Z --> AA{是组管理员?}
AA -->|Yes| AB[允许组内操作]
AA -->|No| AC[403 无权限]
```
---
## 11. 当前体系的架构特征与局限性
### 11.1 设计特征
| 特征 | 说明 |
|------|------|
| **Admin 全权绕过** | 系统管理员跳过所有检查,简化实现但不适合大型组织 |
| **扁平角色模型** | 角色不支持继承,无层级关系 |
| **资源粒度绑定** | 每条 RoleAccess 绑定到一个具体资源 ID,无通配/批量授权 |
| **读写二元分离** | 每种资源仅有读/写两种权限,无更细粒度操作区分 |
| **双轨并行** | 普通知识库走 RBAC,知识空间走成员制,逻辑独立 |
| **GroupResource 弱关联** | 组资源关系用于管理视图,不直接参与权限判定 |
### 11.2 企业场景潜在需求 vs 现状
| 企业需求 | 现状 | 差距 |
|----------|------|------|
| **部门层级授权** | Group 是扁平的,无父子关系 | 需要树形组织结构 |
| **角色继承** | 角色间无继承关系 | 需要角色层级(如:部门经理继承普通员工权限) |
| **操作级权限** | 仅读/写两种 | 需要更细操作:分享、导出、评论、审批等 |
| **字段级权限** | 无 | 如:普通成员只能看到知识库摘要,不能看源文件 |
| **数据行级隔离** | 无 | 如:销售只能看自己负责区域的数据 |
| **临时授权** | 无过期机制 | 需要限时共享、到期自动回收 |
| **审批工作流** | 仅知识空间订阅有审批 | 需要通用审批流(如:工作流上线审批) |
| **审计追溯** | AuditLog 有审计表 | 需要更完整的操作日志和权限变更记录 |
| **外部身份源** | UserLink 支持 OAuth | 需要 LDAP/AD/SCIM 对接,自动同步组织结构 |
| **跨模块授权联动** | 各模块独立判权 | 如:工作流引用的知识库,是否自动授予执行者访问权 |
### 11.3 扩展建议方向
**短期可行**(当前架构内可实现):
-`AccessType` 中增加更多操作类型(如 EXPORT、SHARE、COMMENT
-`RoleAccess` 增加 `expire_time` 字段实现临时授权
-`GroupResource` 参与权限判定(组成员自动获得组资源的读权限)
**中期演进**(需要模型扩展):
- Group 增加 `parent_id` 支持树形组织
- Role 增加继承关系(`parent_role_id`
- 统一知识空间成员制和 RBAC 为同一套模型
**长期规划**(需要架构升级):
- 引入 ABAC(基于属性的访问控制)框架
- 引入通用审批引擎
- 支持 SCIM/LDAP 外部身份源自动同步
---
## 12. 关键源码索引
| 功能 | 文件路径 | 关键行 |
|------|----------|--------|
| JWT 认证 | `user/domain/services/auth.py` | 24-93 |
| LoginUser 权限核心 | `user/domain/services/auth.py` | 95-338 |
| UserPayload 依赖注入 | `common/dependencies/user_deps.py` | 全文件 |
| 角色定义 | `database/models/role.py` | 全文件 |
| 权限类型枚举 | `database/models/role_access.py` | 35-66 |
| 权限判定查询 | `database/models/role_access.py` | 113-131 |
| 用户组 | `database/models/user_group.py` | 全文件 |
| 组资源关系 | `database/models/group_resource.py` | 全文件 |
| 空间成员制 | `common/models/space_channel_member.py` | 全文件 |
| 知识空间权限 | `knowledge/domain/services/knowledge_space_service.py` | 72-140 |
| 知识库权限服务 | `knowledge/domain/services/knowledge_permission_service.py` | 全文件 |
| 组管理服务 | `api/services/role_group_service.py` | 全文件 |
| 初始化默认权限 | `common/init_data.py` | 25-58 |
| 资源列表过滤 | `api/services/workflow.py` | 115-124 |
| 前端路由权限 | `src/frontend/platform/src/routes/index.tsx` | 63-100 |
| 用户角色常量 | `database/constants.py` | AdminRole=1, DefaultRole=2 |
---
## 相关文档
- [数据模型与存储层](./07-data-models.md) — ORM 模型完整清单
- [后端领域模块总览](./02-backend-modules.md) — 模块间的权限检查调用关系
- [双前端架构](./06-frontend-architecture.md) — 前端路由权限过滤机制
- [部署架构与配置](./08-deployment.md) — JWT/Cookie 配置项
-544
View File
@@ -1,544 +0,0 @@
# 商业 API 网关 (bisheng-gateway)
bisheng-gateway 是 BiSheng 的**商业拓展套件**,作为 Java 网关层部署在前端与后端之间,提供 SSO/OAuth 统一认证、内容安全审查(敏感词过滤)、流量控制(限流/在线计数)和 API 反向代理能力。它是一个独立的 Java 项目(私有仓库 `dataelement/bisheng-gateway`),拥有独立的数据库表和配置,通过 HTTP 与 bisheng 后端通信。
## 1. 系统架构
### 1.1 请求流转路径
```mermaid
flowchart LR
Browser["浏览器"] --> Nginx["Nginx :8860"]
Nginx --> Vite["Vite Dev :3001"]
Vite --> Gateway["Gateway :8180"]
Gateway --> Backend["Backend :7860"]
subgraph Gateway 内部处理
direction TB
GF1["SensitiveWordsFilter<br/>敏感词过滤"]
GF2["PathRateGlobalFilter<br/>HTTP 限流"]
GF3["SelfWebsocketRoutingFilter<br/>WebSocket 路由 + 在线计数"]
GF4["CustomResponseFilter<br/>响应敏感词过滤"]
end
```
开启网关模式后,前端 Vite 的 `VITE_PROXY_TARGET``http://localhost:7860` 切换为 `http://localhost:8180`,所有 `/api/**` 请求先经过 Gateway 再代理到 Backend。
### 1.2 路由分工
| 路径 | 处理方 | 说明 |
|------|--------|------|
| `/api/oauth2/*` | Gateway 自处理 | SSO/OAuth 登录入口与回调 |
| `/api/sso/callback`, `/api/wx/callback`, `/api/wxweb/callback` | Gateway 自处理 | 各 SSO Provider 回调 |
| `/api/sensitive/*` | Gateway 自处理 | 敏感词管理 CRUD |
| `/api/group/*` (gateway 侧) | Gateway 自处理 | 用户组/资源组管理 |
| `/api/getkey` | Gateway 自处理 | RSA 公钥获取(前端密码加密用) |
| `/api/v1/**` | Gateway 代理 → Backend | v1 API 全部代理 |
| `/api/v2/**` | Gateway 代理 → Backend | v2 RPC API 全部代理 |
| `/api/v1/chat/**`, `/api/v2/chat/**` | Gateway WebSocket 代理 | WebSocket 连接代理(自定义 Filter |
---
## 2. 技术栈
| 维度 | 技术 | 版本 | 说明 |
|------|------|------|------|
| 框架 | Spring Boot + Spring Cloud Gateway | 3.2.6 / 2023.0.1 | Reactive/WebFlux 响应式网关 |
| 构建 | Maven | -- | `pom.xml`artifact: `gateway-0.0.1-SNAPSHOT` |
| Java | JDK | 17 | 编译目标 17 |
| ORM | MyBatis-Plus | 3.5.6 | 4 张业务表(`gt_*` 前缀) |
| OAuth | JustAuth | 1.16.6 | 多平台 OAuth2 登录库 |
| 认证 | Sa-Token | 1.38.0 | Reactor 响应式集成 |
| 企业微信 | weixin-java-cp | 4.7.0 | 企业微信通讯录同步 + OAuth |
| HTTP 客户端 | Spring 6 @HttpExchange | -- | `BsClient` 声明式调用 bisheng 后端 |
| 限流 | Guava RateLimiter | 33.2.0-jre | 基于令牌桶的 HTTP 限流 |
| 工具 | Hutool / Lombok / MapStruct | 5.8.28 / -- / 1.5.5 | 加解密、Bean 映射等 |
---
## 3. 项目结构
```
com.dataelem.gateway/
├── GatewayApplication.java # Spring Boot 启动入口
├── config/
│ ├── BishengConfig.java # bisheng 集成配置(license, API URL, filter URL 等)
│ ├── CustomerConfig.java # SSO/微信/LDAP 配置 + 预置敏感词加载
│ ├── LicenseLoader.java # License 校验(ApplicationRunner, order=1
│ ├── LimitRuleLoader.java # 限流规则加载器(启动时从 DB 加载,运行时刷新)
│ ├── RedisTopicListener.java # Redis Pub/Sub 监听(delete_group 频道)
│ ├── SpringCustomConfig.java # BsClient WebClient 工厂配置
│ └── ... # MyBatis, Netty WebSocket, CORS 等配置类
├── controller/
│ ├── OauthController.java # SSO/OAuth 登录 + 回调(5 种登录方式)
│ ├── SensitiveWordsController.java # 敏感词管理 CRUD
│ ├── UserGroupController.java # 用户组管理
│ ├── GroupResourceController.java # 组-资源关联管理
│ └── BlockRecordController.java # 拦截记录查询
├── filter/
│ ├── SensitiveWordsFilter.java # 请求敏感词过滤(GlobalFilter, order=HIGHEST+1001
│ ├── CustomResponseFilter.java # 响应敏感词过滤(GlobalFilter, order=-2
│ ├── PathRateGlobalFilter.java # HTTP 限流(GlobalFilter, order=LOWEST-90
│ └── SelfWebsocketRoutingFilter.java # WebSocket 路由 + 在线会话计数
├── entity/
│ ├── UserGroup.java # gt_user_group 用户组
│ ├── GroupResource.java # gt_group_resource 组资源关联
│ ├── SensitiveWords.java # gt_sensitive_words 敏感词配置
│ └── BlockRecord.java # gt_block_record 拦截记录
├── sso/
│ ├── AuthCustomSSORequest.java # 自定义 SSO Provider(继承 JustAuth AuthDefaultRequest
│ ├── AuthCustomSource.java # 自定义 OAuth Source 枚举
│ └── CustomSsoConfig.java # SSO 配置 POJOURL、clientId、clientSecret 等)
├── sdk/
│ └── BsClient.java # 声明式 HTTP 客户端(@HttpExchange,调用 bisheng 后端)
├── schedule/
│ └── WechatScheduled.java # 企业微信通讯录定时同步
├── service/impl/ # Service 实现层
├── mapper/xml/ # MyBatis-Plus Mapper XML
├── dto/ # 数据传输对象
├── params/ # 请求参数对象
├── exception/ # 异常处理 + ResultData 统一响应
└── utils/
└── ac/ # Aho-Corasick 自动机(敏感词高效匹配)
├── AC.java # AC 自动机基类(KMP + Trie Tree
└── ACPlus.java # 支持包含词处理的增强版
```
---
## 4. 数据模型
Gateway 使用独立的 4 张表(`gt_*` 前缀),与 bisheng 主库的表互不干扰:
| 表名 | 实体 | 核心字段 | 用途 |
|------|------|----------|------|
| `gt_user_group` | `UserGroup` | id, group_name, admin_user, group_limit | 用户组定义,id 与 bisheng 主库保持一致 |
| `gt_group_resource` | `GroupResource` | group_id, resource_id, resource_limit, resource_type | 组-资源关联,含每资源限流阈值 |
| `gt_sensitive_words` | `SensitiveWords` | resource_id, resource_type, words, auto_words, words_types, auto_reply, is_check | 按资源配置的敏感词(预置词表 + 自定义词表) |
| `gt_block_record` | `BlockRecord` | resource_id, user_input, block_words, system_out | 敏感词命中记录 |
```mermaid
erDiagram
UserGroup ||--o{ GroupResource : "拥有"
GroupResource }o--|| SensitiveWords : "resource_id 关联"
SensitiveWords ||--o{ BlockRecord : "触发"
UserGroup {
int id "与 bisheng 组 ID 一致"
string group_name
string admin_user
int group_limit "0=无限制"
}
GroupResource {
int id PK
int group_id FK
string resource_id "应用/助手 ID"
int resource_limit "0=无限制"
int resource_type "助手/技能"
}
SensitiveWords {
int id PK
string resource_id "应用/助手 ID"
int resource_type
string auto_words "预置词表"
string words "自定义词表"
string words_types "1=预置 2=自定义"
string auto_reply "命中后自动回复"
boolean is_check "是否启用"
}
BlockRecord {
int id PK
string resource_id
string user_input "用户原文"
string block_words "命中词"
string system_out "替换回复"
}
```
---
## 5. SSO/OAuth 认证流程
### 5.1 整体时序
```mermaid
sequenceDiagram
participant Browser as 浏览器
participant Gateway as Gateway :8180
participant SSO as SSO Provider
participant Backend as Backend :7860
Browser->>Gateway: GET /api/oauth2/list
Gateway-->>Browser: {sso: "/api/oauth2/sso", wx: "/api/oauth2/wx", ldap: "/api/oauth2/ldap"}
Browser->>Gateway: GET /api/oauth2/sso
Gateway-->>Browser: 302 Redirect → SSO authorize URL
Browser->>SSO: 用户认证(输入账号密码)
SSO-->>Browser: 302 Redirect → /api/sso/callback?code=xxx
Browser->>Gateway: GET /api/sso/callback?code=xxx
Gateway->>SSO: POST accessTokenUrl (code → access_token)
SSO-->>Gateway: {access_token, refresh_token}
Gateway->>SSO: POST userInfoUrl (Bearer access_token)
SSO-->>Gateway: {user_name, ...}
Gateway->>Backend: POST /api/v1/internal/sso/login-sync {external_user_id, ...}
Backend-->>Gateway: {access_token, refresh_token}
Gateway-->>Browser: Set-Cookie: access_token_cookie=xxx + 302 → home
```
**核心流程要点**:
1. **前端获取登录方式**: `GET /api/oauth2/list` 根据 `CustomerConfig` 中配置的 SSO/微信/LDAP 返回可用登录入口
2. **重定向到 SSO**: Gateway 构造 OAuth2 authorize URL(包含 client_id、redirect_uri、state),302 重定向浏览器
3. **SSO 回调处理**: JustAuth 框架自动完成 code → access_token → user_info 的 OAuth2 标准流程
4. **注册/登录 bisheng**: Gateway 调用新的 HMAC SSO 同步链路 → `POST /api/v1/internal/sso/login-sync`,将第三方稳定用户标识、部门信息与用户属性同步给后端,后端完成用户补齐/绑定并返回 JWT
5. **写入 Cookie**: Gateway 将后端返回的 `access_token``refresh_token` 写入浏览器 Cookie,302 跳转首页
### 5.2 三种 SSO Provider
| Provider | 入口 | 回调 | JustAuth 实现 | 配置 |
|----------|------|------|---------------|------|
| 自定义 SSO | `/api/oauth2/sso` | `/api/sso/callback` | `AuthCustomSSORequest` (自定义) | `custom.ssoconfig.*` |
| 企业微信扫码 | `/api/oauth2/wx` | `/api/wx/callback` | `AuthWeChatEnterpriseQrcodeRequest` | `custom.wxoauth.*` |
| 企业微信网页 | `/api/oauth2/wxweb` | `/api/wxweb/callback` | `AuthWeChatEnterpriseWebRequest` | `custom.wxoauth.*` |
| LDAP | `/api/oauth2/ldap` (POST) | 无回调(直接校验) | `LdapUtil.checkUserExists()` | `custom.ldap.*` |
**自定义 SSO** (`AuthCustomSSORequest`): 继承 JustAuth 的 `AuthDefaultRequest`,可配置 4 个端点 URLauthorizeUrl, accessTokenUrl, userInfoUrl, refreshUrl),适配任意标准 OAuth2 Provider。通过 `CustomSsoConfig.userName` 字段指定从 userInfo 响应中提取用户名的 JSON 路径。
**LDAP**: 不走 OAuth 流程,前端 POST 用户名和 RSA 加密密码,Gateway 解密后直接调用 `LdapUtil` 进行 LDAP bind 校验,成功后走同样的 `login-sync` 登录同步流程。
### 5.3 密码加密
Gateway 启动时生成 RSA 密钥对。前端通过 `GET /api/getkey` 获取公钥,用公钥加密密码后传输,Gateway 用私钥解密。此机制用于 LDAP 密码保护。
---
## 6. 内容安全审查
### 6.1 架构概览
内容安全由 4 个组件协作实现:
```
请求入站 响应出站
| |
v v
SensitiveWordsFilter (order=HIGHEST+1001) CustomResponseFilter (order=-2)
| 拦截请求中的用户输入 | 拦截响应中的模型输出
| 命中 → 直接返回 auto_reply | 命中 → 替换响应内容
| 未命中 → 放行到后端 | 支持 SSE 流式响应
v v
PathRateGlobalFilter → Backend → 响应 响应 → 返回浏览器
```
### 6.2 请求侧过滤 (SensitiveWordsFilter)
**拦截路径**: `/api/v2/assistant/chat/completions``/api/v1/process`
**工作流程**:
1. 从请求 body 提取用户输入(助手类型取 `messages[-1].content`,技能类型取 `inputs`
2. 根据 `resource_id` + `resource_type` 查询 `gt_sensitive_words` 配置
3. 构建 Aho-Corasick 自动机(懒加载 + 缓存),合并预置词表和自定义词表
4. 对用户输入执行多模式匹配
5. 命中 → 记录到 `gt_block_record`,直接返回 `auto_reply`,不转发到后端
6. 未命中 → 正常代理到后端
**Aho-Corasick 自动机** (`utils/ac/`): AC 自动机(KMP + Trie Tree)实现高效多模式字符串匹配,时间复杂度 O(n + m),n 为文本长度,m 为匹配结果数。每个资源的自动机实例缓存在 `HashMap<String, AC>` 中,通过 `remove_cache()` 方法支持配置变更后重建。
### 6.3 响应侧过滤 (CustomResponseFilter)
**拦截路径**: 同上
**工作流程**:
1. 装饰 `ServerHttpResponse`,拦截后端返回的内容
2. 区分非流式(JSON)和流式(SSE `text/event-stream`)两种响应
3. 非流式:解析完整 JSON,提取模型回复内容,执行敏感词匹配,命中则替换
4. 流式:按 SSE 事件边界(`\n\n`)分割,逐事件解析 `data: {json}` 中的内容进行过滤
5. 同样区分助手类型(`choices[0].delta.content`)和技能类型(`data.result.answer`
### 6.4 敏感词配置
`gt_sensitive_words` 支持两种词表来源:
- **预置词表** (`auto_words`): 从 `words.txt` 文件加载的内置敏感词库,`words_types` 包含 `"1"` 时启用
- **自定义词表** (`words`): 用户通过管理界面配置,以 `|` 分隔,`words_types` 包含 `"2"` 时启用
每条配置绑定到一个具体资源(`resource_id` + `resource_type`),支持按应用/助手粒度独立配置。
---
## 7. 流量控制
### 7.1 HTTP 限流 (PathRateGlobalFilter)
**触发条件**: 请求路径匹配 `bisheng.filter-url` 配置列表中的任一项
**限流机制**:
- `LimitRuleLoader` 启动时从 `gt_group_resource` 加载限流规则
- 每个 `group_id + resource_id` 组合对应一个 Guava `RateLimiter` 实例(令牌桶算法)
- 未配置规则的资源使用默认 RateLimiter10 QPS
- 限流命中时返回 HTTP 200 + `{status_code: 429, status_message: "系统正忙,请稍候"}`
### 7.2 WebSocket 在线计数 (SelfWebsocketRoutingFilter + LimitRuleLoader)
**在线会话管理**:
- `LimitRuleLoader` 维护两级在线计数:`GROUP_ONLINE`(用户组级)和 `FLOW_ONLINE`(用户组+资源级)
- WebSocket 连接建立时 `increment()`,断开时 `decrement()`
- `SESSION_ONLINE` 记录每个 WebSocket Session 关联的计数 key,确保断连时正确回收
- `isOverage()` 检查当前在线数是否超过组级(`group_limit`)或资源级(`resource_limit`)阈值
### 7.3 规则刷新
- 启动时:`LimitRuleLoader` 作为 `ApplicationRunner` 从 DB 加载全部规则
- 运行时:通过 Redis Pub/Sub 频道 `delete_group` 触发缓存失效(`RedisTopicListener`),`LimitRuleLoader.load()` 重新加载
---
## 8. 与 bisheng 后端集成
### 8.1 BsClient 声明式 HTTP 客户端
`BsClient` 使用 Spring 6 的 `@HttpExchange` 声明式 HTTP 客户端,baseUrl 由 `bisheng.bisheng-api-url` 配置:
```java
@HttpExchange(url = "/api", accept = "application/json", contentType = "application/json")
public interface BsClient {
@PostExchange("/v1/internal/sso/login-sync") // SSO 用户同步登录
Flux<String> loginSync(@RequestBody LoginSyncRequest body);
@GetExchange("/v1/user/info") // 获取用户信息
Flux<String> info(@RequestHeader("Cookie") String cookie);
@GetExchange("/v1/group/list") // 获取用户组列表
Mono<String> userGroupList(...);
@GetExchange("/v1/group/get_group_resources") // 获取组资源
Mono<String> resourceList(...);
@PostExchange("/v1/internal/sso/gateway-wecom-org-sync") // 企微部门+成员整批同步
Flux<String> syncUsers(@RequestBody GatewayWecomOrgSyncRequest body);
}
```
### 8.2 关键集成点
| 场景 | Gateway 调用 | Backend 端点 | 说明 |
|------|-------------|-------------|------|
| SSO 登录 | `BsClient.loginSync(LoginSyncRequest)` | `POST /api/v1/internal/sso/login-sync` | 同步用户/部门信息后登录,返回 JWT |
| 用户信息查询 | `BsClient.info(cookie)` | `GET /api/v1/user/info` | 验证用户身份 |
| 组列表同步 | `BsClient.userGroupList()` | `GET /api/v1/group/list` | Gateway 管理界面展示 |
| 企业微信同步 | `BsClient.syncUsers(GatewayWecomOrgSyncRequest)` | `POST /api/v1/internal/sso/gateway-wecom-org-sync` | 部门+成员整批同步 |
### 8.3 缓存同步
- **Redis Pub/Sub**: bisheng 后端删除用户组时,通过 Redis `delete_group` 频道通知 GatewayGateway 的 `RedisTopicListener` 接收消息后删除对应的本地缓存(用户组 + 限流规则 + 敏感词 AC 自动机)
- **前置条件**: Gateway 和 Backend 必须共享同一个 Redis 实例(配置 `spring.data.redis`
### 8.4 企业微信通讯录同步
`WechatScheduled` 定时任务(cron 表达式:`0 0 ${bisheng.time:0} * * ?`,默认每天 0 点):
1. 通过 `weixin-java-cp` SDK 拉取企业微信完整部门树 + 用户列表
2. 构建 `Department` 树形结构(parentId 关联)
3. 调用 `BsClient.syncUsers()` 将组织结构同步到 bisheng 后端
4. 可通过 `bisheng.enable_sync=true` 开启,`bisheng.time` 配置执行小时
---
## 9. License 机制
### 9.1 校验流程
`LicenseLoader` 作为 `ApplicationRunner`order=1),在 Spring Boot 启动阶段执行 License 校验:
```
启动 → 读取 bisheng.license 配置
→ RSA 私钥解密 → 得到 JSON: {name, version, expireDay, finger}
→ 检查 version 字段:
"trial" → 比较 expireDay 与当前日期,过期则 System.exit(0)
"pro" → 永久有效,直接放行
→ 解密失败 → 打印错误日志并 System.exit(0)
```
### 9.2 License 格式
License 是 RSA 加密的 Base64 字符串,解密后为 JSON
```json
{
"name": "pro",
"version": "pro", // "trial" 或 "pro"
"expireDay": "2025-05-25", // trial 模式的过期日期
"finger": "63f3b3e2..." // 机器指纹(当前未在运行时校验)
}
```
**注意**: `finger` 字段虽然包含在 License JSON 中,但当前运行时**未进行指纹校验**,仅检查 `version``expireDay`
---
## 10. 配置说明
主配置文件为 `src/main/resources/application.yml`,关键配置分组:
### 10.1 Gateway 路由
```yaml
spring.cloud.gateway.routes:
- id: bisheng-http # HTTP API 代理
uri: http://backend:7860
predicates:
- Path=/api/v1/**,/api/v2/**
filters:
- name: CacheRequestBody # 缓存请求体供 Filter 读取
args: { bodyClass: java.lang.String }
- id: bisheng-ws # WebSocket 代理
uri: ws://backend:7860
predicates:
- Path=/api/v1/chat/**,/api/v2/chat/**,/api/v1/workflow/chat/**
```
**注意**: 默认 WebSocket Routing Filter 已禁用(`global-filter.websocket-routing.enabled: false`),由自定义的 `SelfWebsocketRoutingFilter` 接管,实现在线计数和限流检查。
### 10.2 bisheng 集成
```yaml
bisheng:
license: <RSA 加密的 License 字符串>
filter-url: # 需要限流/敏感词过滤的 URL 模式
- api/v1/assistant/chat/
- api/v1/process/
- api/v2/assistant/chat
home-url: http://host:port # SSO 回调后的跳转首页
web-home-url: http://host:port/chatpro/flow_id # 企业微信内嵌跳转
bisheng-api-url: http://backend:7860 # bisheng 后端 API 地址
enable_sync: false # 是否启用企业微信通讯录同步
time: 0 # 同步执行小时(0~23
```
### 10.3 SSO Provider 配置
```yaml
custom:
ssoconfig: # 自定义 SSOOAuth2 标准流程)
authorizeUrl: https://sso.example.com/oauth2/authorize
accessTokenUrl: https://sso.example.com/oauth2/token
userInfoUrl: https://sso.example.com/oauth2/userinfo
refreshUrl: https://sso.example.com/oauth2/refresh
clientId: xxx
clientSecret: xxx
redirectUri: http://gateway-host/api/sso/callback
userName: userName # userInfo 响应中用户名的 JSON 路径
wxoauth: # 企业微信
clientId: <corpId>
clientSecret: <corpSecret>
redirectUri: http://gateway-host/api/wx/callback
redirectWebUri: https://domain/oauth/api/wxweb/callback?flowId=flow_id
agentId: <agentId>
userName: name # name 或 userid
ldap: # LDAP 直连认证
ldapUrl: ldap://ldap-host:389/dc=example,dc=com
```
### 10.4 数据源
```yaml
spring:
datasource:
url: jdbc:mysql://host:3306/bisheng_gateway # Gateway 独立数据库
username: root
password: xxx
data:
redis:
host: redis-host # 必须与 bisheng 后端共享同一 Redis
port: 6379
database: 3 # 使用独立的 Redis DB
```
---
## 11. 开发环境搭建
### 11.1 本地开发
> 前提:bisheng 后端启动前必须 `export BISHENG_PRO=true`,否则商业版接口(如 `/api/v1/user/sso`)不会注册。
```bash
# 1. 克隆项目
git clone https://github.com/dataelement/bisheng-gateway.git
# 2. 构建
cd bisheng-gateway
mvn clean package -DskipTests
# 3. 启动(端口 8180,避免与 OpenFGA 8080 冲突)
java -jar target/gateway-0.0.1-SNAPSHOT.jar \
--spring.profiles.active=dev \
--server.port=8180
# 4. 前端切换为 Gateway 模式
cd ~/Projects/bisheng/src/frontend/platform
# 修改 vite.config.mts 中 VITE_PROXY_TARGET 为 http://localhost:8180
npm start
```
### 11.2 远程开发
```bash
# 远程服务器路径
# /opt/bisheng-gateway/ on 192.168.106.114
# 同步脚本(项目根目录 gateway-sync.sh
./gateway-sync.sh up # 本地 → 远程
./gateway-sync.sh down # 远程 → 本地
./gateway-sync.sh watch # 监听变化,自动推送
```
### 11.3 数据库
Gateway 的 4 张表(`gt_*`)部署在独立数据库 `bisheng_gateway` 中(也可与 bisheng 主库同实例不同 schema)。表结构通过 MyBatis-Plus 的 `@TableName` 注解映射,无自动迁移机制,需手动建表。
---
## 12. GlobalFilter 执行顺序
Gateway 中注册了 4 个 GlobalFilter,按 order 值从小到大执行:
```
请求入站方向(order 从小到大):
1. SensitiveWordsFilter (HIGHEST_PRECEDENCE + 1001) → 请求敏感词过滤
2. CustomResponseFilter (order = -2) → 装饰响应(但实际过滤在出站方向)
3. SelfWebsocketRoutingFilter → WebSocket 路由 + 在线计数
4. PathRateGlobalFilter (LOWEST_PRECEDENCE - 90) → HTTP 限流
响应出站方向(反序):
CustomResponseFilter 的 ServerHttpResponseDecorator 拦截响应内容进行敏感词过滤
```
---
## 13. v2.5 改造方向(规划中)
| 改造项 | 当前状态 | 目标状态 |
|--------|---------|---------|
| 多租户 OAuth | SSO/微信/LDAP 配置硬编码在 `application.yml` | 新增 `gt_oauth_config` 表,按租户维护 Provider 配置 |
| JWT 租户上下文 | Gateway 不感知 tenant_id | 从 bisheng JWT 中提取 tenant_id,注入到代理请求头 |
| 租户级内容安全 | 敏感词按 resource_id 粒度 | 增加 tenant_id 维度,支持租户级全局敏感词 |
| 租户级限流 | 限流按 group+resource 粒度 | 增加 tenant_id 维度,支持租户级配额 |
| 权限集成 | Gateway 独立管理用户组/资源组 | 与 bisheng 的 OpenFGA ReBAC 体系对齐,复用部门/用户组模型 |
---
## 相关文档
- [系统架构总览](./01-architecture-overview.md) -- 整体请求数据流和组件关系
- [用户与权限体系](./10-permission-rbac.md) -- bisheng 后端的认证与授权机制
- [部署架构与配置](./08-deployment.md) -- Docker Compose 编排和配置系统
- [v2.5 权限管理体系改造 PRD](../archive/2.5%20权限管理体系改造%20PRD/2.5%20权限管理体系改造%20PRD.md) -- ReBAC 权限体系设计
-638
View File
@@ -1,638 +0,0 @@
# 多租户架构
BiSheng v2.5.0 引入了基于**逻辑隔离**的多租户架构,在同一数据库实例内通过 `tenant_id` 列实现数据隔离,覆盖 44 张业务表和 5 种存储引擎(MySQL、Redis、Milvus、Elasticsearch、MinIO)。系统支持两种运行模式:**单租户模式**(`enabled=false`,向后兼容 v2.4.x)和**多租户模式**(`enabled=true`,强制租户上下文)。租户上下文通过 Python `ContextVar` 在请求作用域内传播,SQLAlchemy 事件钩子自动完成 SELECT 过滤和 INSERT 填充,开发者编写普通 ORM 代码即可获得租户隔离。
---
## 1. 架构总览
### 设计原则
- **逻辑隔离**:所有租户共享同一数据库实例,通过 `tenant_id` 列区分数据。选择逻辑隔离而非物理隔离(独立库/独立实例)的原因:降低运维复杂度、支持跨租户聚合查询、简化部署
- **向后兼容**:默认租户(id=1)在所有外部存储中不添加前缀,与 v2.4.x 的数据路径完全一致
- **透明接入**:业务代码无需手动添加 `WHERE tenant_id=X`SQLAlchemy 事件钩子自动注入
### 租户隔离全链路
```mermaid
flowchart TB
subgraph 客户端
Browser["浏览器"]
end
subgraph 中间件层
HTTP["CustomMiddleware<br/>JWT Cookie → tenant_id"]
WS["WebSocketMiddleware<br/>ASGI Cookie → tenant_id"]
end
subgraph 上下文
CV["ContextVar<br/>current_tenant_id"]
end
subgraph 数据层
SQLFilter["SQLAlchemy Events<br/>tenant_filter.py<br/>SELECT → WHERE tenant_id=X<br/>INSERT → 自动填充 tenant_id"]
StoragePrefix["Storage Prefix<br/>tenant_storage.py"]
end
subgraph 存储引擎
MySQL["MySQL<br/>WHERE tenant_id=X"]
MinIO["MinIO<br/>tenant_{code}/"]
Milvus["Milvus<br/>t{id}_collection"]
ES["ES<br/>t{id}_index"]
Redis["Redis<br/>t:{id}:key"]
end
subgraph Celery异步任务
Publish["before_task_publish<br/>headers.tenant_id=X"]
PreRun["task_prerun<br/>set_current_tenant_id(X)"]
PostRun["task_postrun<br/>reset to None"]
end
Browser --> HTTP
Browser --> WS
HTTP --> CV
WS --> CV
CV --> SQLFilter
CV --> StoragePrefix
CV --> Publish
SQLFilter --> MySQL
StoragePrefix --> MinIO
StoragePrefix --> Milvus
StoragePrefix --> ES
StoragePrefix --> Redis
Publish --> PreRun --> PostRun
```
---
## 2. 配置与运行模式
### 配置模型
```python
# src/backend/bisheng/core/config/multi_tenant.py
class MultiTenantConf(BaseModel):
enabled: bool = Field(default=False) # 是否启用多租户
default_tenant_code: str = Field(default='default') # 默认租户编码
```
### config.yaml
```yaml
multi_tenant:
enabled: false
default_tenant_code: "default"
```
### 两种运行模式
| 维度 | 单租户模式 (`enabled=false`) | 多租户模式 (`enabled=true`) |
|------|---------------------------|--------------------------|
| 默认行为 | 所有查询自动使用 `DEFAULT_TENANT_ID=1` | 必须在请求上下文中设置 `tenant_id` |
| 无上下文时 | 回退到默认租户,不报错 | 抛出 `NoTenantContextError`20004 |
| 登录流程 | 直接分配 `tenant_id=1` | 查询用户关联租户,可能需要选择 |
| 存储前缀 | 永远为空(原路径) | 默认租户为空,新租户加前缀 |
| 适用场景 | v2.4.x 升级后的默认模式 | 企业集团多组织部署 |
---
## 3. 数据模型
### ER 关系
```mermaid
erDiagram
Tenant ||--o{ UserTenant : "包含用户"
User ||--o{ UserTenant : "属于租户"
Tenant ||--|| Department : "root_dept_id 关联根部门"
Tenant {
int id PK "自增主键"
string tenant_code UK "租户编码(唯一)"
string tenant_name "租户名称"
string logo "Logo URL"
int root_dept_id FK "根部门 ID"
string status "active / disabled / archived"
string contact_name "联系人"
string contact_phone "联系电话"
string contact_email "联系邮箱"
json quota_config "资源配额配置"
json storage_config "存储配置覆盖"
int create_user "创建人 ID"
datetime create_time "创建时间"
datetime update_time "更新时间"
}
UserTenant {
int id PK "自增主键"
int user_id FK "用户 ID"
int tenant_id FK "租户 ID"
int is_default "是否默认租户"
string status "active / disabled"
datetime last_access_time "最后访问时间"
datetime join_time "加入时间"
}
```
### 租户感知的业务表(44 张)
以下表均包含 `tenant_id` 列(`INT NOT NULL DEFAULT 1`),由 Alembic 迁移 `v2_5_0_f001_multi_tenant.py` 统一添加:
| 模块 | 表名 |
|------|------|
| **核心应用** | `flow`, `flowversion`, `assistant`, `assistantlink`, `template` |
| **标签/分组** | `tag`, `taglink`, `group`, `groupresource`, `usergroup` |
| **角色/权限** | `role`, `roleaccess`, `userrole` |
| **会话/消息** | `chatmessage`, `message_session`, `t_report`, `t_variable_value` |
| **知识库** | `knowledge`, `knowledgefile`, `qaknowledge` |
| **工具** | `t_gpts_tools`, `t_gpts_tools_type` |
| **渠道** | `channel`, `channel_info_source`, `channel_article_read` |
| **分享** | `share_link` |
| **消息收件箱** | `inbox_message`, `inbox_message_read` |
| **微调/部署** | `finetune`, `presettrain`, `modeldeploy`, `server`, `sftmodel` |
| **Linsight** | `linsight_sop`, `linsight_sop_record`, `linsight_session_version`, `linsight_execute_task` |
| **LLM** | `llm_server`, `llm_model` |
| **评测/标注** | `evaluation`, `dataset`, `marktask`, `markrecord`, `markappuser` |
| **审计/邀请** | `auditlog`, `invitecode` |
**不包含 `tenant_id` 的表**`user`(用户全局)、`user_tenant`(关联表,tenant_id 为 FK)、`tenant`(租户主表)、`config`(系统配置)、`recallchunk`(检索中间表)、`failed_tuple`(补偿表)
---
## 4. 租户上下文传播
### ContextVar 机制
租户隔离基于 Python 的 `contextvars` 模块,天然支持线程安全和异步安全。
```python
# src/backend/bisheng/core/context/tenant.py
DEFAULT_TENANT_ID: int = 1
# 请求作用域内的租户 ID
current_tenant_id: ContextVar[Optional[int]] = ContextVar('current_tenant_id', default=None)
# 是否绕过租户过滤(系统管理员跨租户查询)
_bypass_tenant_filter: ContextVar[bool] = ContextVar('_bypass_tenant_filter', default=False)
```
### API 函数
| 函数 | 用途 |
|------|------|
| `get_current_tenant_id()` | 获取当前上下文的租户 ID,未设置返回 `None` |
| `set_current_tenant_id(tid)` | 设置当前上下文的租户 ID |
| `bypass_tenant_filter()` | 上下文管理器,临时禁用租户过滤 |
| `is_tenant_filter_bypassed()` | 检查当前是否已绕过过滤 |
### 上下文设置时机
| 场景 | 设置方 | 文件 |
|------|--------|------|
| HTTP 请求 | `CustomMiddleware` 从 JWT Cookie 解码 | `utils/http_middleware.py` |
| WebSocket | `WebSocketLoggingMiddleware` 从 ASGI Cookie 解码 | `utils/http_middleware.py` |
| Celery 任务 | `task_prerun` 信号从 headers 恢复 | `worker/tenant_context.py` |
| 系统初始化 | 显式调用 `set_current_tenant_id()``bypass_tenant_filter()` | `common/init_data.py` |
---
## 5. 自动租户过滤(SQLAlchemy 事件钩子)
核心文件:`src/backend/bisheng/core/database/tenant_filter.py`
### 注册时机
`DatabaseManager._register_tenant_filter()` 在数据库连接管理器初始化时调用 `register_tenant_filter_events()`,注册全局 Session 事件。该函数幂等,多次调用只注册一次。
### 自动发现机制
`_discover_tenant_aware_tables()` 扫描 SQLModel 的 metadata,自动发现所有包含 `tenant_id` 列的表。新增 ORM 模型只要声明了 `tenant_id` 字段即可自动参与过滤,无需额外注册。
排除列表 `_EXCLUDED_TABLES = {'user_tenant'}``user_tenant``tenant_id` 是外键关联字段,非隔离字段。
### SELECT 拦截流程
```mermaid
flowchart TD
A["ORM SELECT 执行"] --> B{"bypass_tenant_filter?"}
B -->|Yes| C["跳过,原样执行"]
B -->|No| D{"is_select?"}
D -->|No| C
D -->|Yes| E["提取 statement 中的租户感知表"]
E --> F{"找到租户感知表?"}
F -->|No| C
F -->|Yes| G["_resolve_tenant_id()"]
G --> H{"ContextVar 有值?"}
H -->|Yes| I["使用 ContextVar 值"]
H -->|No| J{"multi_tenant.enabled?"}
J -->|No| K["使用 DEFAULT_TENANT_ID = 1"]
J -->|Yes| L["抛出 NoTenantContextError"]
I --> M["注入 WHERE tenant_id = X"]
K --> M
```
`do_orm_execute` 事件通过两种方式提取查询中的表:
1. `column_descriptions` — 适用于 `select(Model)` 模式
2. `froms` 回退 — 适用于 joins 和 subquery
### INSERT 自动填充
`before_flush` 事件在 `session.new` 中遍历待插入对象:
- 若对象所属表在 `_tenant_aware_tables` 中,且 `tenant_id``None``0`,自动填充为当前上下文的租户 ID
- 多租户模式下若无上下文,跳过填充(由后续 SELECT 时的 `_resolve_tenant_id()` 捕获异常)
### 已知限制
`text()` 构造的原生 SQL **不触发** ORM 事件。使用原生 SQL 时必须手动添加 `WHERE tenant_id = X`
---
## 6. HTTP / WebSocket 中间件
核心文件:`src/backend/bisheng/utils/http_middleware.py`
### 请求处理流程
```mermaid
sequenceDiagram
participant B as 浏览器
participant M as CustomMiddleware
participant JWT as JWT 解码
participant Redis as Redis
participant CV as ContextVar
participant API as 业务路由
B->>M: HTTP 请求 + Cookie(access_token_cookie)
M->>JWT: decode_jwt_token(token)
JWT-->>M: {user_id, user_name, tenant_id}
M->>CV: set_current_tenant_id(tenant_id)
alt 豁免路径
M->>API: 直接放行
else tenant_id == 0(待选择)
M-->>B: 403 Missing tenant context
else 正常路径
M->>Redis: GET disabled_tenant:{tenant_id}
alt 租户已禁用
M-->>B: 403 Tenant is disabled
else 租户正常
M->>API: 放行
end
end
```
### 豁免路径
以下路径不进行租户状态检查(登录前或系统级接口):
```
/api/v1/user/login
/api/v1/user/register
/api/v1/user/sso # legacy compatibility only; new third-party login uses /api/v1/internal/sso/login-sync
/api/v1/user/ldap
/api/v1/user/public_key
/api/v1/user/switch-tenant
/api/v1/user/tenants
/api/v1/env
/health
/docs
/openapi.json
/redoc
```
### WebSocket 中间件
`WebSocketLoggingMiddleware` 从 ASGI scope 的 headers 中解析 Cookie,调用相同的 `_set_tenant_context()` 函数设置租户上下文。
---
## 7. 存储隔离策略
核心文件:`src/backend/bisheng/core/storage/tenant_storage.py`
### 前缀规则
默认租户(id=1)不添加前缀,保持与 v2.4.x 完全兼容。新租户使用各存储引擎对应的前缀:
| 存储引擎 | 默认租户 (id=1) | 新租户 (id=2, code="acme") | 函数 |
|----------|:---------------:|:-------------------------:|------|
| MinIO | `""` (原路径) | `tenant_acme/` | `get_minio_prefix(tenant_id, tenant_code)` |
| Milvus | `""` (原集合名) | `t2_` | `get_milvus_collection_prefix(tenant_id)` |
| Elasticsearch | `""` (原索引名) | `t2_` | `get_es_index_prefix(tenant_id)` |
| Redis | `""` (原 key) | `t:2:` | `get_redis_key_prefix(tenant_id)` |
### 使用方式
这些函数定义的是前缀约定。实际的存储调用点在各业务模块中拼接前缀后再访问存储引擎:
- **MinIO**:文件上传路径拼接 `{prefix}{original_path}`
- **Milvus**:知识库创建时 collection 名为 `{prefix}{collection_name}`
- **ES**:索引创建时名为 `{prefix}{index_name}`
- **Redis**:缓存 key 拼接 `{prefix}{original_key}`
---
## 8. Celery 任务租户上下文传递
核心文件:`src/backend/bisheng/worker/tenant_context.py`
### 信号流程
```mermaid
sequenceDiagram
participant API as FastAPI 进程
participant Broker as Redis Broker
participant Worker as Celery Worker
Note over API: ContextVar tenant_id = 2
API->>Broker: publish task<br/>headers = {tenant_id: 2}
Note over API: before_task_publish 信号
Broker->>Worker: deliver task
Note over Worker: task_prerun 信号
Worker->>Worker: set_current_tenant_id(2)
Worker->>Worker: 执行任务<br/>(ORM 自动过滤 tenant_id=2)
Note over Worker: task_postrun 信号
Worker->>Worker: current_tenant_id.set(None)
```
### 三个 Celery 信号
| 信号 | 时机 | 作用 |
|------|------|------|
| `before_task_publish` | API 进程发布任务时 | 将当前 `tenant_id` 写入任务 `headers` |
| `task_prerun` | Worker 执行任务前 | 从 `headers` 恢复 `ContextVar`,无值时回退到 `DEFAULT_TENANT_ID` |
| `task_postrun` | Worker 执行任务后 | 重置 `ContextVar``None`,防止线程池复用时泄露 |
信号注册方式:`worker/main.py``import bisheng.worker.tenant_context` 触发模块级信号绑定。
---
## 9. 登录与租户选择流程
### 完整流程
```mermaid
flowchart TD
A["POST /api/v1/user/login"] --> B["验证用户名密码"]
B --> C{"multi_tenant.enabled?"}
C -->|No| D["tenant_id = DEFAULT_TENANT_ID"]
D --> E["签发 JWT,设置 Cookie"]
C -->|Yes| F["查询 UserTenant 列表"]
F --> G{"关联几个活跃租户?"}
G -->|0 个| H["返回 NoTenantsAvailableError"]
G -->|1 个| I["tenant_id = 该租户 ID"]
I --> E
G -->|多个| J["tenant_id = 0(待选择)"]
J --> K["返回 requires_tenant_selection=true<br/>+ tenants 列表"]
K --> L["前端跳转 TenantSelect 页面"]
L --> M["用户选择租户"]
M --> N["POST /api/v1/user/switch-tenant"]
N --> O["验证 UserTenant 存在且 active"]
O --> P["验证 Tenant 状态为 active"]
P --> Q["签发新 JWTtenant_id=目标ID"]
Q --> R["Set-Cookie + 跳转应用首页"]
```
### JWT Payload 结构
```json
{
"user_id": 1,
"user_name": "admin",
"tenant_id": 2
}
```
`tenant_id=0` 是一个临时状态,表示用户已认证但尚未选择租户。中间件对此状态的非豁免路径返回 403。
---
## 10. 租户管理 API
### 端点清单
**管理员端点**(需要系统管理员权限):
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/tenants/` | POST | 创建租户(含根部门 + UserTenant + OpenFGA 元组) |
| `/api/v1/tenants/` | GET | 租户列表(分页 + 关键词搜索 + 状态筛选) |
| `/api/v1/tenants/{id}` | GET | 租户详情(含管理员用户列表) |
| `/api/v1/tenants/{id}` | PUT | 更新租户信息(名称 / logo / 联系人) |
| `/api/v1/tenants/{id}` | DELETE | 删除租户(须无活跃用户) |
| `/api/v1/tenants/{id}/status` | PUT | 状态管理(active / disabled / archived |
| `/api/v1/tenants/{id}/quota` | GET/PUT | 配额查看 / 设置 |
| `/api/v1/tenants/{id}/users` | GET/POST/DELETE | 租户用户管理 |
**用户端点**(已登录用户):
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/user/tenants` | GET | 获取我的可用租户列表 |
| `/api/v1/user/switch-tenant` | POST | 切换租户(签发新 JWT |
### 保护规则
- 默认租户(id=1)不可删除,不可修改 `tenant_code`
- 删除租户前须确认无活跃用户(`TenantHasUsersError`
- 禁用租户时向 Redis 写入黑名单 key(`disabled_tenant:{id}`),中间件实时生效
- 不能移除租户最后一个管理员(`TenantAdminRequiredError`
### 创建租户的原子流程
```
1. 创建 Tenant 记录(检查 tenant_code 唯一性)
2. 创建根部门(DepartmentService.acreate_root_department
3. 回写 Tenant.root_dept_id
4. 为管理员用户创建 UserTenant 记录
5. 写入 OpenFGA 权限元组(admin + member
```
---
## 11. 数据库迁移与初始化
### Alembic 迁移
迁移文件:`src/backend/bisheng/core/database/alembic/versions/v2_5_0_f001_multi_tenant.py`
**upgrade 流程**
1. 创建 `tenant`
2. 创建 `user_tenant` 表(含 `uk_user_tenant` 唯一约束)
3. 为 44 张业务表添加 `tenant_id` 列(`INT NOT NULL DEFAULT 1`+ 索引
4. 种子数据:插入默认租户 (id=1, code='default', name='Default Tenant')
5. 回填:为所有现有用户创建 `user_tenant` 记录(关联默认租户)
**downgrade**:逆序删除 `tenant_id` 列和表。注意 tenant_id > 1 的数据上下文会丢失。
### 应用初始化
`src/backend/bisheng/common/init_data.py` 中的相关函数:
| 函数 | Feature | 作用 |
|------|---------|------|
| `_init_default_tenant()` | F001 | 确保默认租户存在,回填 `user_tenant` |
| `_init_default_root_department()` | F002 | 为默认租户创建根部门 |
| `_migrate_rbac_to_rebac_if_needed()` | F006 | 一次性 RBAC → ReBAC 迁移(Redis SETNX 锁保证幂等) |
这些函数在应用启动时(lifespan)调用,使用 `bypass_tenant_filter()` 绕过租户过滤。
---
## 12. 前端集成
### 租户选择页
`src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx`
- 在登录后若用户有多个可用租户,前端跳转到租户选择页
- 使用 `sessionStorage` 缓存待选租户列表(来自登录 API 响应)
- 用户点击租户后调用 `switchTenantApi(tenantId)` 获取新 JWT
- 成功后跳转到应用首页
### 租户管理页
`src/frontend/platform/src/pages/TenantPage/`
- 系统管理员可见的租户管理面板
- 租户列表(表格 + 搜索 + 状态筛选)
- 子组件:CreateTenantDialog(创建/编辑)、TenantUserDialog(用户管理)、TenantQuotaDialog(配额设置)
- 删除租户需输入 `tenant_code` 二次确认
### API 层
`src/frontend/platform/src/controllers/API/tenant.ts` 封装了所有租户相关 API 调用。
---
## 13. 错误码
模块编码:`200`5 位编码 `200XX`
| 错误码 | 类名 | 说明 |
|--------|------|------|
| 20000 | `TenantNotFoundError` | 租户不存在 |
| 20001 | `TenantDisabledError` | 租户已禁用 |
| 20002 | `UserNotInTenantError` | 用户不属于该租户 |
| 20003 | `TenantCodeDuplicateError` | 租户编码重复 |
| 20004 | `NoTenantContextError` | 多租户模式下缺少租户上下文 |
| 20005 | `TenantHasUsersError` | 删除租户时仍有活跃用户 |
| 20006 | `TenantAdminRequiredError` | 不能移除租户最后一个管理员 |
| 20007 | `TenantSwitchForbiddenError` | 用户不属于目标租户 |
| 20008 | `TenantCreationFailedError` | 租户创建失败 |
| 20009 | `NoTenantsAvailableError` | 用户无可用租户 |
---
## 14. 开发者注意事项
### 原生 SQL 的租户过滤
`text()` 构造的原生 SQL 绕过 ORM 事件,不会自动注入 `WHERE tenant_id=X`。使用原生 SQL 时必须手动添加租户过滤:
```python
# 错误 — 无租户过滤
session.execute(text("SELECT * FROM flow WHERE status = 'active'"))
# 正确 — 手动添加 tenant_id
tid = get_current_tenant_id()
session.execute(text("SELECT * FROM flow WHERE status = 'active' AND tenant_id = :tid"), {"tid": tid})
```
### bypass_tenant_filter 使用场景
`bypass_tenant_filter()` 是一个上下文管理器,临时禁用 SELECT 过滤和 INSERT 自动填充。仅在以下场景使用:
- 系统初始化(`init_data.py`
- 系统管理员跨租户管理查询(`TenantDao` 内部)
- 登录/注册流程(用户尚无租户上下文)
- 数据迁移脚本
```python
from bisheng.core.context.tenant import bypass_tenant_filter
with bypass_tenant_filter():
# 此上下文内的查询不带 WHERE tenant_id=X
all_tenants = TenantDao.get_all()
# 退出后自动恢复过滤
```
### 新增 ORM 模型
只要在模型中声明 `tenant_id` 列,`_discover_tenant_aware_tables()` 会在应用启动时自动发现,无需额外注册:
```python
class MyNewModel(SQLModel, table=True):
id: int = Field(primary_key=True)
tenant_id: int = Field(default=0, index=True) # 声明即参与自动过滤
name: str
```
### Celery 任务
任务函数内的 ORM 操作自动享受租户隔离(通过 `task_prerun` 信号恢复上下文)。但若通过其他方式直接操作存储(如 Redis `SET/GET`、MinIO 上传),需手动拼接前缀。
### 测试
测试代码中需显式设置租户上下文:
```python
from bisheng.core.context.tenant import set_current_tenant_id, bypass_tenant_filter
def test_something():
set_current_tenant_id(1)
# ... 测试代码
def test_cross_tenant():
with bypass_tenant_filter():
# ... 跨租户查询
```
### 默认租户保护
id=1 的默认租户具有特殊保护:不可删除、不可修改状态、存储前缀为空。对默认租户的操作应格外谨慎。
---
## 15. 关键源码索引
所有路径相对于 `src/backend/bisheng/`,除非另有标注。
| 功能 | 文件路径 | 关键内容 |
|------|----------|----------|
| 租户上下文 ContextVar | `core/context/tenant.py` | `current_tenant_id`, `bypass_tenant_filter()` |
| SQLAlchemy 自动过滤 | `core/database/tenant_filter.py` | `do_orm_execute`, `before_flush` 事件钩子 |
| 过滤事件注册 | `core/database/manager.py:58-65` | `DatabaseManager._register_tenant_filter()` |
| 多租户配置 | `core/config/multi_tenant.py` | `MultiTenantConf` |
| 存储隔离前缀 | `core/storage/tenant_storage.py` | 4 种前缀函数 |
| HTTP 中间件 | `utils/http_middleware.py` | `CustomMiddleware`, `WebSocketLoggingMiddleware` |
| Celery 信号 | `worker/tenant_context.py` | 3 个 Celery signals |
| Worker 信号注册 | `worker/main.py` | `import bisheng.worker.tenant_context` |
| Tenant ORM 模型 | `database/models/tenant.py` | `Tenant`, `UserTenant`, DAO 类 |
| 租户管理服务 | `tenant/domain/services/tenant_service.py` | `TenantService` 业务逻辑 |
| 租户 CRUD API | `tenant/api/endpoints/tenant_crud.py` | 管理员 CRUD 端点 |
| 用户租户 API | `tenant/api/endpoints/user_tenant.py` | `switch-tenant`, `tenants` |
| 租户 DTO | `tenant/domain/schemas/tenant_schema.py` | 请求/响应 Pydantic 模型 |
| 错误码 | `common/errcode/tenant.py` | 20000-20009 |
| Alembic 迁移 | `core/database/alembic/versions/v2_5_0_f001_multi_tenant.py` | 表创建 + 44 表加列 |
| 初始化数据 | `common/init_data.py` | `_init_default_tenant()`, `_init_default_root_department()` |
| 前端租户选择 | `src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx` | 租户选择页面 |
| 前端租户 API | `src/frontend/platform/src/controllers/API/tenant.ts` | API 调用封装 |
| 前端租户管理 | `src/frontend/platform/src/pages/TenantPage/` | 管理页面 |
---
## 相关文档
- [系统架构总览](./01-architecture-overview.md) — 运行时组件和数据流全景
- [数据模型与存储层](./07-data-models.md) — ORM 模型完整清单
- [部署架构与配置](./08-deployment.md) — 配置系统和环境变量
- [用户与权限体系](./10-permission-rbac.md) — RBAC/ReBAC 权限模型
- [商业版 API 网关](./11-gateway.md) — Gateway 与多租户的集成方向
-327
View File
@@ -1,327 +0,0 @@
# 列表 Cursor 翻页与无限滚动 (F027)
BiSheng 高频列表(知识库 / 应用 / 知识空间文件)统一从 `page_num + COUNT(*)` 偏移翻页改造为 **cursor (keyset) 翻页 + 前端无限滚动**。改造的核心驱动力不是 UI 体验,而是**根治翻深页时 OpenFGA 细权限请求量随页号线性增长**:OFFSET 模式下,翻第 50 页要为前 49 页全部资源跑一次 ReBAC 过滤;cursor 模式下,每次只对当前 keyset 窗口跑一次。本文档描述这套模式的协议、后端实现套路、前端模式、以及 DM8 兼容上的关键坑。
---
## 1. 总览
```
┌──────────────────────────────────────────────────────────────────┐
│ 前端 │
│ ┌──────────────┐ ┌───────────────────────┐ │
│ │ useInfinite- │ │ LoadMore sentinel │ │
│ │ CursorTable │ │ (IntersectionObserver)│ │
│ └──────┬───────┘ └───────────┬───────────┘ │
│ │ cursor=next_cursor │ 滚到底自动触发 │
│ ▼ ▼ │
│ GET /api/v1/<list>?cursor=<token>&page_size=20 │
└──────────────────────────┬────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 后端 Service │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ decode_cursor(token, expected_context, expected_key_len) │ │
│ │ 失败 → 抛业务错误码 (10550 / 10991 / 18070) │ │
│ └────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ fetch-until-enough scan loop (若细权限过滤可能减少) │ │
│ │ 每轮: DAO.aget_xxx(cursor=batch_cursor, limit=batch) │ │
│ │ 过滤: ApplicationPermissionService.get_app_permission │ │
│ │ 累积: 到 page_size + 1 (探 has_more) 或 DB 拉空 │ │
│ │ 推进: batch_cursor = last DB row (非 last visible) │ │
│ └────────────────────────┬───────────────────────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ encode_cursor((sort_key_tuple), context) → next_cursor │ │
│ └────────────────────────┬───────────────────────────────────┘ │
└──────────────────────────┬────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ DAO + DB (MySQL / DM8) │
│ SELECT ... WHERE <keyset predicate> │
│ ORDER BY <sort_cols> LIMIT <batch+1> │
│ 注: DM8 不支持 row-value tuple 比较,统一走展开 OR ladder │
└──────────────────────────────────────────────────────────────────┘
```
**改造范围**:
| 接口 | 改造内容 |
|------|---------|
| `GET /api/v1/knowledge` | OFFSET → cursor;砍 COUNT;`sort_by=name` 用复合索引 `(name, id)` |
| `GET /api/v1/workflow/list` | OFFSET → cursor;砍 COUNT;workflow + assistant UNION;**fetch-until-enough 循环** |
| `GET /api/v1/knowledge/space/{id}/children` | OFFSET → cursor;**scan loop 凑够即停**;ext_rank 用 CASE WHEN |
| `GET /api/v1/departments/tree` | 删 `member_count` 字段及对应 `COUNT(*) GROUP BY` |
| `GET /api/v1/tool` | 后端不动,只清前端「共 X 个」文案 |
---
## 2. 协议层:cursor envelope
所有 cursor 接口返回统一 envelope:
```python
# common/schemas/api.py
class PageInfiniteCursorData(BaseModel, Generic[T]):
data: List[T]
page_size: int
has_more: bool
next_cursor: Optional[str]
```
**与旧 `PageData[T]` 的区别**:无 `total` 字段(砍 COUNT 是性能优化的核心),用 `has_more` 替代「是否最后一页」。`next_cursor` 为 None 时表示已到末页,前端 LoadMore sentinel 应停止触发。
请求侧约定:`cursor` 是 query 参数,空值或省略 = 第一页;`page_size` 用户可控,典型 20。
### 2.1 Cursor token 编解码
`common/cursor.py`:
```python
def encode_cursor(values: Sequence, *, context: str) -> str
def decode_cursor(token: str, *, expected_key_len: int, expected_context: str) -> List
```
token 结构(简化):`base64(json({"c": context, "v": [...sort_key_values]}))``context` 字符串是「这个 cursor 是哪个查询发的」的标签(例如 `"flow|sort=update_time"``"knowledge_space_children|sort=file_type_asc"`),用来防御「用户拿 A 接口的 cursor 喂 B 接口」。
`decode_cursor` 失败(token 篡改 / context 不匹配 / key 长度对不上)抛 `CursorDecodeError`,Service 层翻译成业务错误码:
| 错误码 | 模块 | 含义 |
|--------|------|------|
| `10550` | flow (105) | `AppInvalidCursorError` — workflow/app 列表 cursor 解码失败 |
| `10991` | knowledge (109) | `KnowledgeInvalidCursorError` — 知识库列表 cursor 解码失败 |
| `18070` | knowledge_space (180) | `KnowledgeSpaceInvalidCursorError` — 空间文件列表 cursor 解码失败 |
前端拿到这些错误码后必须 `reset cursor=null` 重新从第一页拉。
---
## 3. Keyset WHERE 子句:DM8 兼容的展开式
`database/utils/keyset.py``build_keyset_where()` 是所有 cursor DAO 的统一 WHERE 子句生成器。SQL-92 标准是 **row-value tuple 比较**:
```sql
WHERE (update_time, id) < (?, ?)
```
MySQL / Postgres / SQLite 都支持。但 **DM8 v8 不支持**(`[CODE:-2007] line N, column M, nearby [?] has error: Syntax error`),即使 T001 的 dialect-stub smoke test 用 `DefaultDialect` 编译能过。
**`_USE_EXPANDED_FALLBACK = True` 必须始终开启**。helper 自动展开成 OR ladder:
```sql
WHERE
update_time > ?
OR (update_time = ? AND id > ?)
```
语义等价,索引使用一样(复合索引 `(update_time, id)` 同样能 seek)。修改这个开关前必须在真实 DM8 环境验证。
### 3.1 混合方向 ASC/DESC
space_children 的 keyset 是 `file_type ASC, ext_rank ASC, update_time DESC, id DESC` — 混合方向用 tuple 表达不了,必须用展开 OR。helper 接受 `descending: Sequence[bool]` 参数,自动按列方向生成 `>``<`:
```python
build_keyset_where(
sort_cols=(t.c.file_type, t.c.update_time, t.c.id),
cursor_values=(0, dt0, 100),
descending=(False, True, True),
)
```
### 3.2 CASE 表达式作 sort_col
`knowledge_file``ext_rank`(扩展名优先级:pdf=1 / docx=2 / ...)是 15-WHEN CASE 表达式。helper 接受任意 `ColumnElement` 包括 `case()`,所以 cursor 排序键可以是计算值;**注意:Python 侧需要有对应的 `_compute_ext_rank_python()` 函数**,用来在收到 DAO 一批数据后给最后一行算 `ext_rank` 推进 `batch_cursor`。这个「双函数对」(SQL CASE + Python 等价 fn)的一致性必须维护,Python 侧错位会导致下一批漏行或重复。
---
## 4. Fetch-until-enough scan loop
OFFSET 翻页时代,「细权限把当前页过滤剩 7 条」的「页缺数」问题不存在(下一页是第 N+1 行起步)。但 cursor 模式下,如果 service 在 DAO 之后做 ReBAC 细过滤,page_size=20 拉来可能剩 7 条,直接返给前端就是「列表突然短」。
**解决套路**:在 service 层加循环,DAO 拉一批 → 过滤 → 累积到 `page_size + 1` 探到 has_more 或 DB 拉空才返。两个地方用了:
| 接口 | 实现 | batch_size 常量 |
|------|------|-----------------|
| `workflow/list` | `WorkFlowService._scan_visible_flows_cursor` | `_FLOW_PERMISSION_SCAN_BATCH_SIZE = 50` |
| `knowledge_space/children` | `KnowledgeSpaceService._scan_visible_child_items` | `_CHILD_PERMISSION_SCAN_BATCH_SIZE = 100` |
骨架(伪代码):
```python
visible: List[Dict] = []
batch_cursor = decoded_cursor # 从前端 cursor 解出来,或 None 表示第一页
while True:
batch, db_has_more = await DAO.fetch(cursor=batch_cursor, limit=BATCH_SIZE)
if not batch:
return visible[:page_size], False
kept = filter_by_fine_grained_permission(batch)
for item in kept:
visible.append(item)
if len(visible) > page_size:
return visible[:page_size], True # has_more=True
if not db_has_more:
return visible[:page_size], False
# 关键: cursor 推进用 last DB row,不是 last visible
batch_cursor = encode_sort_key_from(batch[-1])
```
**最容易写错的一行**:`batch_cursor = batch[-1]` 必须用 DAO 返回的最后一行,**不能用过滤后的最后一行**。如果用 last visible,被过滤掉的中间行会在下一批被 DAO 重新返出来(因为 keyset 的「严格大于」边界没跨过它们),最终重复出现在累积 visible 里。
### 4.1 不同接口的过滤位置差异
三条 cursor 线的 OpenFGA 过滤策略不同:
| 接口 | 过滤位置 | 是否需要 scan loop |
|------|---------|---------------------|
| `knowledge` | **DB 之前**:`PermissionService.list_accessible_ids()` 一次拉出可见 id 集,作为 DAO `WHERE id IN (...)` 条件 | 否,DB 拉多少 = 返多少 |
| `workflow/list` | **DB 前粗筛 + DB 后细筛**:粗筛只看类型维度(view_app/edit_app),DAO 后对结果再跑 `get_app_permission_map_async` | 是,因为细筛可能缩水 |
| `knowledge_space/children` | **DB 后逐批过滤**:`_build_child_permission_context` + per-item check | 是,且过滤率可能 > 50% |
knowledge 走「先算清楚再查」,代价是首次进来要并发跑全集 ReBAC,但走 Redis 缓存基本毫秒级。workflow / space_children 走「先查再过滤」,所以必须 fetch-until-enough。
---
## 5. 前端模式
### 5.1 Platform (`src/frontend/platform/`)
复用 hook `src/frontend/platform/src/util/hook.ts → useInfiniteCursorTable`:
```typescript
const { data, hasMore, loading, reload, loadMore } = useInfiniteCursorTable({
queryFn: ({ cursor }) => getKnowledgeList({ cursor, page_size: 20, ...filters }),
deps: [searchText, sortBy], // 这些变化时自动 reload(reset cursor=null)
})
```
hook 内部维护 `nextCursor` / `accumulated data`;调用方只暴露 `data``hasMore``loadMore()`
### 5.2 Client (`src/frontend/client/`)
Client 没有通用 hook(`useFileManager.ts` 是 SpaceDetail 专用)。`useFileManager` 把「page 1 替换、page>1 append」「默认路径用 nextCursor、搜索路径用 nextSearchPage 拼接」合在 `loadFiles(page)` 一个方法里,外部用 `onPageChange(currentPage + 1)` 触发下一批。
### 5.3 LoadMore sentinel
`src/frontend/platform/src/components/bs-comp/loadMore/index.tsx``src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx` 是同一模式的两个版本。核心实现:
```typescript
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = findScrollableAncestor(sentinelRef.current)
// ↑ 必须传 root,否则容器内滚动不触发
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) onLoadRef.current?.()
}, { root, threshold: 0.1 })
observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [])
```
**两个最坑的陷阱**:
1. **IntersectionObserver `root: null` 默认走 viewport**。BiSheng 大部分列表是「列表区在固定高度容器里 overflow:scroll」,容器内滚动不改变 sentinel 跟 viewport 的关系 → observer 只在 mount 时触发一次,之后永远不再触发。必须用 `findScrollableAncestor()` 走 DOM 找最近 `overflow-y: auto / scroll / overlay` 祖先作 root。
2. **`onLoad` 闭包冻结 stale `nextCursor`**。observer 是 mount 时创建的(`[]` deps),callback 里用的 `onLoad` 是首次渲染时的版本。必须用 `useRef` 同步:`onLoadRef.current = onLoad` 每次 render 都更新,observer callback 调 `onLoadRef.current?.()` 拿最新版本。
不解决这两个,代码看起来对、第一页加载也对,然后下拉就再也不触发,且没报错。
### 5.4 短列表「mount 即触发」副作用
如果首屏数据不足以撑满 scroll container,sentinel mount 时就跟 viewport 相交 → 立刻触发一次 LoadMore。如果第二页数据还不满,继续触发 → 直到 `hasMore=false`。这是正确行为(数据够少就该一次全拉),但 UX 上「没滚就在加载」可能让用户疑惑。需要时可加 500ms mount 缓冲期。
### 5.5 5s 状态轮询不能动 cursor 链
`useFileManager` 在有「处理中文件」时每 5s 轮询刷状态。append 模式下,**不能再用 `loadFiles(currentPage)`** — 那会把累积 files 替换成最新一批,前面累积的尾部全丢,且 cursor 会前进。
正确做法(`refreshLoadedStatuses()`):
- 调一次 `cursor=null, page_size=files.length`,拿前 N 条最新数据
-`id` merge:已加载行用回包覆盖 status / progress 字段;回包里有但本地没有(新上传)append 到头部;本地有但回包没有的不删
- `nextCursor / hasMore` **不动**
搜索状态下不轮询(搜索结果是「截图」,实时刷状态意义不大且接口语义不同)。
---
## 6. 关键陷阱速查
| 现象 | 根因 | 修法 |
|------|------|------|
| DM8 报 `[CODE:-2007] line N nearby [?] Syntax error`,SQL 含 `(col_a, col_b) < (?, ?)` | DM8 不支持 row-value tuple compare | `_USE_EXPANDED_FALLBACK = True`(已是默认) |
| Workflow/space_children 列表「页缺数」(每页返 7 条) | 细权限过滤后没补 | scan loop,batch_cursor 推进用 last DB row |
| LoadMore mount 后只触发一次,滚动再不触发 | IntersectionObserver `root: null` 默认 viewport,但 sentinel 在 overflow 容器里 | `findScrollableAncestor()` 找最近 scroll 祖先作 root |
| LoadMore 触发但 `onLoad` 用的是首次 render 的 cursor | `[]` deps 的 useEffect 闭包冻结了 onLoad | `useRef` 同步:`onLoadRef.current = onLoad` 每 render |
| Client SpaceDetail 跳到第 5 页拿到第 2 页数据 | `cursor: page > 1 ? nextCursor : null` 中 nextCursor 只是「下一页」的 cursor,跨页跳无中间历史 | 不允许跳页:UI 改成 LoadMore append 即可 |
| 5s 轮询把无限滚动列表「截短」回首页 | 轮询调 `loadFiles(currentPage)` 替换了累积数据 | 改成 `refreshLoadedStatuses()`,只 merge status,不动 cursor 链 |
| `int(last['id'])` 抛 ValueError | workflow/list UNION:flow id 是 int,assistant id 是 UUID 字符串 | `encode_cursor` 不强转类型,JSON 保留原类型 |
| `datetime is not JSON serializable` | `update_time` 是 datetime,cursor 编码崩 | `encode_cursor` 加 datetime → ISO 字符串 fallback |
| 部署 backend 镜像时拉不到 `dataelement/bisheng-backend:base.v8` | base image 在 docker.io 上 403,cr.dataelem.com 上没有 | 写 `Dockerfile.beta3`:`FROM cr.dataelem.com/dataelement/bisheng-backend:feat_2.6.0-beta2` + `COPY ./ ./` 增量构建 |
---
## 7. 关键文件路径
```
docs (本文档) → docs/architecture/13-cursor-pagination.md
spec / tasks (本地) → features/v2.6.0/027-rebac-list-perf-optim/{spec,tasks}.md (features/ 在 gitignore)
release-contract → features/v2.6.0/release-contract.md (F027 entry + INV-6)
cursor 编解码 → src/backend/bisheng/common/cursor.py
keyset WHERE → src/backend/bisheng/database/utils/keyset.py (_USE_EXPANDED_FALLBACK = True)
envelope → src/backend/bisheng/common/schemas/api.py (PageInfiniteCursorData)
errcodes → src/backend/bisheng/common/errcode/{knowledge,flow,knowledge_space}.py
10550 / 10991 / 18070
后端 cursor 实现
- knowledge → bisheng/knowledge/domain/services/knowledge_service.py
- workflow → bisheng/api/services/workflow.py
_scan_visible_flows_cursor (fetch-until-enough)
get_all_flows_envelope
- space_children → bisheng/knowledge/domain/services/knowledge_space_service.py
_scan_visible_child_items (fetch-until-enough)
list_space_children
_compute_ext_rank_python (SQL CASE 的 Python 等价)
- departments tree → bisheng/department/domain/services/department_service.py
(member_count 已移除)
前端 platform
- hook → src/frontend/platform/src/util/hook.ts → useInfiniteCursorTable
- LoadMore → src/frontend/platform/src/components/bs-comp/loadMore/index.tsx
- 入口 → pages/BuildPage/apps.tsx
pages/KnowledgePage/KnowledgeFile.tsx (兼 /build/knowledge 和 ?type=1 QA 库)
前端 client
- hook → src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts
- LoadMore → src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx
- 入口 → src/frontend/client/src/pages/knowledge/SpaceDetail/index.tsx
测试
- cursor 编解码 → src/backend/test/common/test_cursor.py
- keyset DAO → src/backend/test/database/test_keyset.py
- knowledge cursor → src/backend/test/knowledge/test_knowledge_list_cursor.py
- workflow cursor → src/backend/test/api/test_workflow_list_cursor.py
- space children → src/backend/test/knowledge/test_knowledge_space_children_cursor.py
- 部门树 → src/backend/test/department/test_department_tree_no_member_count.py
- client SpaceDetail → src/frontend/client/src/pages/knowledge/hooks/useFileManager.test.ts
```
---
## 8. 给「下一个改这块的人」的清单
要新加一个「列表 X」走 cursor + 无限滚动:
1. **DAO 层**:把现有 `query_xxx(page, page_size)` 改成 `query_xxx(cursor, limit)`,WHERE 加 `build_keyset_where(sort_cols, cursor)`(`cursor is None` 时跳过),`fetch_limit = limit + 1` 探 has_more。返 `(data, has_more)`,**不返 total**。
2. **Service 层**:加 `xxx_envelope()`:`decode_cursor → fetch-until-enough (如果有细权限过滤) → encode_cursor(last visible) → PageInfiniteCursorData`
3. **Endpoint**:`cursor: Optional[str] = Query(None)` + `page_size: int = Query(20)`,return envelope。
4. **errcode**:在所属模块 `errcode/<module>.py` 加一个 `<XxxInvalidCursorError>`(5 位 MMMEE),context 字符串配套(例如 `"xxx|sort=update_time"`)。
5. **索引**:看是否需要新加复合索引 `(sort_col_1, ..., id)`,DM8 + MySQL 双方言验证(`alembic` migration 注意 `dialect_helpers`)。
6. **前端**:platform 用 `useInfiniteCursorTable` 一行接;client 仿照 `useFileManager.ts` 写 hook + `<LoadMore>` sentinel。
7. **测试**:单元测 envelope 路径(mock DAO 测 cursor 解码 / encode / has_more);单元 / 静态测覆盖 fetch-until-enough 循环存在。
实施前先读 spec(`features/v2.6.0/027-rebac-list-perf-optim/spec.md`)的 AD 节,里面是 F027 期间踩坑沉淀下来的 architectural decisions,包括为什么选 keyset(`update_time, id`)而不是其他 + 为什么 file_type 排序要用 ext_rank 复合 cursor 等。
-46
View File
@@ -1,46 +0,0 @@
# BiSheng 架构文档
BiSheng v2.5.0 是面向企业的开源 LLM 应用 DevOps 平台,基于 FastAPI + React + LangGraph 构建,采用 DDD 架构组织 15+ 领域模块,支持工作流编排、知识库/RAG、多 Agent 协作(Linsight)、MCP 集成、模型评测与微调、多租户隔离。
## 仓库结构
```
bisheng/
├── src/backend/bisheng/ # FastAPI 后端主应用
├── src/backend/bisheng_langchain/ # LangChain 扩展包
├── src/frontend/platform/ # 管理端前端 (React)
├── src/frontend/client/ # 用户端前端 (React)
├── docker/ # Docker Compose 部署
└── docs/ # 文档
```
## 文档导航
| 文档 | 说明 |
|------|------|
| [系统架构全景图](./01-architecture-overview.md) | 运行时组件、请求数据流、技术栈、启动生命周期 |
| [后端领域模块总览](./02-backend-modules.md) | 15+ DDD 模块清单、分层约定、基础设施详解 |
| [工作流引擎](./03-workflow-engine.md) | LangGraph DAG 执行引擎、14 种节点类型、回调机制 |
| [知识库与 RAG 管道](./04-knowledge-rag.md) | 三阶段管道、双向量存储、文档处理流水线 |
| [Linsight Agent 与 MCP](./05-linsight-agent.md) | 自主任务框架、事件驱动、MCP 协议集成 |
| [双前端架构](./06-frontend-architecture.md) | Platform + Client 双应用、状态管理、组件库 |
| [数据模型与存储层](./07-data-models.md) | 24 个 ORM 模型、5 种存储引擎、DAO 模式 |
| [部署架构与配置](./08-deployment.md) | Docker Compose 编排、配置系统、开发环境 |
| [用户与权限体系](./10-permission-rbac.md) | 三层权限模型、RBAC、协作空间成员制、扩展分析 |
| [开发指南](./09-development-guide.md) | 环境搭建、模块约定、扩展点、测试 |
| [商业版 API 网关](./11-gateway.md) | Gateway 架构、SSO/OAuth 流程、内容安全、流控、开发环境 |
| [多租户架构](./12-multi-tenant.md) | 逻辑隔离、租户上下文传播、自动过滤、存储隔离、Celery 传递 |
| [列表 Cursor 翻页与无限滚动](./13-cursor-pagination.md) | F027 — cursor envelope、keyset DM8 兼容、fetch-until-enough、前端 LoadMore 模式 |
## 快速导航
- 想了解系统整体如何运行、各服务之间如何通信,请看 [系统架构全景图](./01-architecture-overview.md)
- 想了解后端代码的组织方式和各模块职责,请看 [后端领域模块总览](./02-backend-modules.md)
- 想了解工作流如何定义和执行、如何扩展自定义节点,请看 [工作流引擎](./03-workflow-engine.md)
- 想了解文档解析、向量化、检索的完整流程,请看 [知识库与 RAG 管道](./04-knowledge-rag.md)
- 想了解如何部署项目或搭建本地开发环境,请看 [部署架构与配置](./08-deployment.md)
- 想了解用户认证、角色权限、资源授权的完整机制,请看 [用户与权限体系](./10-permission-rbac.md)
- 想了解开发规范、如何新增模块或编写测试,请看 [开发指南](./09-development-guide.md)
- 想了解商业版网关(SSO/OAuth、内容安全、流控)的架构和开发方式,请看 [商业版 API 网关](./11-gateway.md)
- 想了解多租户隔离机制、租户上下文传播和开发注意事项,请看 [多租户架构](./12-multi-tenant.md)
- 想新加一个走 cursor 翻页 + 无限滚动的列表接口,请看 [列表 Cursor 翻页与无限滚动](./13-cursor-pagination.md)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
# BiSheng Architecture Constitution
> **The single source of truth for BiSheng's architectural laws — invariant across all features, never to be violated.**
>
> - `AGENTS.md` and every feature's `design.md` **reference this file; they never copy it.** Changing an implementation never requires editing this file (they point here).
> - `scripts/arch-guard.sh` is the **machine-enforcement arm** of this document: each RULE maps to a clause below (see the anchor table).
> - Violations are reported as **BLOCKER** during `/sdd-review design`.
> - **Change governance**: editing this file requires PR review (a law change affects every feature). If a RULE is involved, sync the "→ Cx" note in `arch-guard.sh`.
> - Last revised: 2026-06-05.
## Anchor Table (clause ↔ arch-guard RULE)
| Clause | Law | arch-guard RULE | Severity |
|---|---|---|---|
| **C1** | DDD layered call chain | RULE-1 / 2 / 3 / 4 / 5 | VIOLATION (RULE-3 is WARNING during migration) |
| **C2** | Dual-DB compatibility (MySQL + DM8) | — (review + CI) | — |
| **C3** | Multi-tenancy auto-injection | — (review) | — |
| **C4** | Permission unified entry point | RULE-8 | VIOLATION |
| **C5** | Error-code convention | — (review) | — |
| **C6** | No hardcoded secrets | RULE-7 | WARNING |
| **C7** | Frontend store must not call HTTP directly | RULE-6 | WARNING |
---
## C1. DDD Layered Call Chain
Call chain — **never skip layers**: `Router → Endpoint → Service → Repository → DB`
- **Never** `import bisheng.database.models.*` in endpoints — go through a Domain Service/DAO (RULE-3, WARNING during migration).
- **Never** write ORM queries in Service; **never** add new DAO entry points for new features.
- `common/`, `core/` must not import `domain/`, `api/` (RULE-1).
- `database/models/` must not import `domain/` (RULE-2).
- `domain/models/` must not import `domain/services/` (RULE-4).
- The API layer must not cross-import between modules (RULE-5).
## C2. Dual-DB Compatibility (MySQL + DM8) ⚠️
Every new feature must work on both dialects. **DM8 is not optional.**
| ✅ Use | ❌ Never use |
|--------|-------------|
| `dialect_helpers.JsonType` | `sqlalchemy.JSON`, `mysql.JSON` |
| `dialect_helpers.LargeText` | `LONGTEXT`, `MEDIUMTEXT` |
| `dialect_helpers.UPDATE_TIME_SERVER_DEFAULT` | `ON UPDATE CURRENT_TIMESTAMP` |
| `SQLAlchemy inspect()` | `information_schema`, `DATABASE()` |
| Explicit relational columns | `JSON_EXTRACT` / `JSON_CONTAINS` / `JSON_SEARCH` |
macOS: the DM8 driver (`dmPython`/`dmAsync`) is not installed (`sys_platform != 'darwin'`).
**DM8 is a development hard-requirement** — always use `dialect_helpers`, never MySQL-only syntax. But **DM8 compatibility is *verified* by a central regression run** (pre-release / periodic, on Linux), **not by per-feature CI gates** — day-to-day it's held by this law + review, not by a per-PR DM8 test.
## C3. Multi-Tenancy — Auto-Injected, Never Manual
**Never write `WHERE tenant_id = X` manually.** SQLAlchemy events handle it automatically for 23+ tables.
`multi_tenant.enabled=false` behaves identically to single-tenant (default `tenant_id=1`).
## C4. Permissions — Unified Entry Point
```python
from bisheng.permission.domain.services.permission_service import PermissionService
await PermissionService.check(...) # check access
await PermissionService.authorize(...) # write OpenFGA owner tuple on resource creation (required)
```
- **Never** query `role_access` directly for resource authorization (RULE-8 / historical invariant **INV-T19**, VIOLATION).
- Resource creation **must** call `PermissionService.authorize()`; failures go to the `failed_tuples` retry table.
- Five-level short-circuit: `super_admin` → tenant mismatch deny → tenant admin → ReBAC (OpenFGA) → RBAC menu.
## C5. Error-Code Convention
- 5-digit `MMMEE` (3-digit module + 2-digit error), defined in `common/errcode/`.
- Module numbers: 100=server, 104=assistant, 105=flow, 106=user, 108=llm, 109=knowledge, 110=linsight, 120=workstation, 130=chat, 140=message, 150=tool, 180=knowledge_space.
## C6. No Hardcoded Secrets (RULE-7)
No `password` / `secret_key` / `api_key` / `access_token` literals in code. Use config + Fernet encryption (passwords in `config.yaml` are Fernet-encrypted; never write plaintext).
## C7. Frontend Store Must Not Call HTTP Directly (RULE-6)
A frontend store must not call HTTP directly — go through `controllers/API/` (platform) or `api/` (client).
All other frontend conventions (state library, UI library, path aliases, i18n, Toast, etc.) live in `.claude/rules/platform-frontend.md` and `.claude/rules/client-frontend.md` (see also `AGENTS.md §4`).
-97
View File
@@ -1,97 +0,0 @@
# LangChain 1.x 升级 — 影响范围与测试重点
> 分支 `feat/langchain-1x`。后端从 LangChain 0.3 升级到 1.x,并清理了依赖该生态的两个第三方包(autogen / ragas)。本文给出影响范围、关键改动、已知问题与测试重点,供回归测试与评审使用。
## 1. 版本落点
| 包 | 升级前 | 升级后 | 说明 |
|----|--------|--------|------|
| langchain | 0.3.27 | **1.3.2** | 主包瘦身,旧模块迁出 |
| langchain-core | 0.3.79 | **1.4.1** | |
| langgraph | 0.3.x | **1.2.2** | workflow 引擎依赖 |
| **langchain-classic** | — | **1.0.7(新增)** | 承接被移出主包的旧模块(chains/agents/schema/…) |
| langchain-openai | 0.3.x | **1.2.2** | 强制 openai≥2.26 |
| langchain-community | 0.3.x | 0.4.2 | 部分 chat_models 符号被移除 |
| langchain-milvus | 0.2.1 | 0.3.3 | 强制 pymilvus≥2.6 |
| **pymilvus** | 2.5.x | **2.6.15** | 连带强升(向量库) |
| **openai** | 1.x | **2.41.0** | 连带强升(SDK 大版本) |
| **httpx** | 0.27.1 | **0.28.1** | 连带强升,移除 `proxies=` |
| rsa | (传递依赖) | **4.9(显式)** | 新依赖图不再带入,需显式声明 |
| bisheng_pyautogen / bisheng-ragas / datasets | 有 | **已移除** | 见 §4 |
**核心库未变**pydantic 2.12.x、sqlalchemy 2.0.44、fastapi 0.121、sqlmodel 0.0.27 —— 升级未触及这些,回归风险显著降低。
## 2. 影响范围总览
升级触及三层,约 110+ 文件改动:
- **依赖层**langchain 全家桶 1.x + 3 个连带强升(pymilvus / openai / httpx)。
- **import 层**:被移出主包的 `langchain.X``langchain_classic.X`(当前仍有 **65 个文件**使用 `langchain_classic`)。
- **运行时语义层**openai 2.x / pymilvus 2.6 / langgraph 1.x / httpx 0.28 行为变化(import 测不出,需实跑)。
## 3. 关键代码改动(按类别)
| 类别 | 改动 | 触及 |
|------|------|------|
| import 迁移 | `langchain.{chains,agents,schema,memory,docstore,callbacks,chat_models,llms,embeddings,tools,prompts,…}``langchain_classic.*` | 主应用 + `bisheng_langchain` fork |
| httpx 0.28 | `httpx.Client/AsyncClient(proxies=)``proxy=``requests` 库的 `proxies=` 保持不变) | bisheng_langchain ~10 文件 |
| langchain-core 移除符号 | `format_tool_to_openai_tool` 改从 `langchain_classic.tools.render` 导入 | assistant_agent.py |
| langgraph 1.x | `langgraph.graph.graph.CompiledGraph``langgraph.graph.state.CompiledStateGraph` | gpts/sql_agent |
| pydantic v2 严格化 | 非注解类属性 `pattern = re.compile(...)``pattern: ClassVar[...]` | chatglm output_parser |
| **LLM 流式(重点)** | `BishengLLM._generate/_agenerate`inner 流式时改用 `generate_from_stream(inner._stream)` 聚合,保留 `on_llm_new_token` 回调 | `bisheng/llm/domain/llm/llm.py` |
| **Milvus ORM 连接(重点)** | langchain-milvus 0.3.x 用 MilvusClient 连接,但 `col` 属性仍走 ORM `Collection(using=alias)`pymilvus 2.6 的 `MilvusClient._using='cm-<id>'` 不注册 ORM 连接 → `ConnectionNotExistException`。新增 `Milvus` 子类,首次访问 `col` 时按 (uri,db) 注册稳定可复用的 ORM 连接并重定向 `self.alias` | `bisheng/core/vectorstore/milvus.py` |
| **Elasticsearch 构造参数** | langchain-elasticsearch 1.0 把 `ElasticsearchStore(es_connection=...)` 改名为 `client=...`(同步/异步均是) | `bisheng/knowledge/rag/elasticsearch_factory.py` |
### 3.1 BishengLLM 流式聚合修复(务必理解)
langchain-openai 1.x 在 `ChatOpenAI._generate` 内**不再聚合流式响应**(直接返回原始 `Stream`,改由 `_generate_with_cache` 分派到 `_stream`)。而 `BishengLLM` 直接委托 inner 的 `_generate`,导致 `streaming=True` 的 inner 返回无法解析的 `Stream``'Stream' object has no attribute 'model_dump'`)。
修复:inner 流式时在 `_generate/_agenerate` 内用 `generate_from_stream/agenerate_from_stream` 聚合 inner 的 `_stream/_astream`。这恢复了 0.3 时代行为,**并保持 `on_llm_new_token` 逐 token 回调**workflow 大模型节点、对话 UI 的流式输出依赖它)。
> ⚠️ 经验教训:曾错误地用 `kwargs['stream']=False` 抑制流式,导致 workflow 模型节点变成整段返回。**任何"非流式化"的简化都会破坏 UI 流式**,因为非流式分派路径(`_generate/_agenerate`)仍需 inner 内部流式来触发回调。
## 4. autogen / ragas 移除(独立子工作)
- **bisheng_pyautogenautogen**:仅被遗留的动态 chain 加载引用,无 flow/模板/DB 使用 → 整体删除(`autogen_role/``chains/autogen/`、相关 loader 与配置)。
- **bisheng-ragas**:评测功能唯一用到的是单个指标 `AnswerCorrectnessBisheng`prompt → LLM → 解析 JSON → P/R/F1)。已用纯 langchain 重写为 `bisheng/evaluation/domain/services/answer_correctness.py`(**prompt 与评分公式字节级一致**prompt 已用 `json-repair` 解析),并将评测重构为独立 DDD 模块 `bisheng/evaluation/`
- **datasets**HF):仅评测与已删除的 benchmark 脚本使用 → 一并移除;删除依赖 ragas 的死脚本(`rag/scoring/ragas_score.py``qa_generator.py``run_qa_gen_web.py``bisheng_rag_pipeline*.py``run_rag_evaluate_web.py`)。
- **langchain_compat.py shim**:曾用 sys.modules 别名桥接上述两个第三方包对已删除 `langchain.*` 路径的引用;两包移除后**整体删除**。
- 评测表 `Evaluation` 迁入 `bisheng/evaluation/domain/models/`(表名不变,**无 DB 迁移**);多租户务必同步:`core/database/tenant_filter.py::_TENANT_AWARE_MODEL_MODULES` 已指向新路径(漏改会静默关闭该表租户隔离)。
## 5. 已知问题 / 待跟进
| 严重度 | 问题 | 位置 | 状态 |
|--------|------|------|------|
| 🟠 | `LLMUsageCallbackHandler` 未实现 `on_chat_model_start`langchain 1.x 新调用点)→ 回调告警 | workflow 回调 | 待修,疑似 1.x 连带 |
| 🟠 | Celery worker 内 token 计费/调用日志的异步 DB 写抛 `Event loop is closed` / `Future attached to a different loop` | `token_tracker.py:71``call_logger.py:70` | 待修,异步上下文问题 |
| 🟡 | `BishengLLM.moonshot_generate/agenerate` 仍直接调用 inner `_generate`,对 streaming moonshot 模型存在同类 `Stream` 崩溃 | `bisheng/llm/domain/llm/llm.py` | 未改,moonshot 小众路径 |
| 🟡 | `bisheng_langchain/rag/config/*.yaml` 残留 `type: 'bisheng-ragas'`,已无消费方 | — | 死配置,无害 |
| 🟡 | openai 2.x / pymilvus 2.6 运行时语义未全量实测 | 全局 | 需 P0 手测 |
## 6. 测试重点(按优先级)
### P0 — 必须实跑(运行时语义,import/单测覆盖不到)
1. **Workflow 大模型节点流式输出** —— 确认逐 token 流式恢复(不是整段返回);覆盖 reasoning_content、工具调用。
2. **知识库文件入库全链路** —— pymilvus 2.6 写 Milvus + ES 双写、检索召回。
3. **Assistant Agent 对话** —— `create_react_agent`、工具调用、引用标注、流式。
4. **各 LLM provider 调用** —— openai 2.x 下 ChatOpenAI/Azure/通义/深求/Ollama 等的 `.invoke()``.astream()`,响应解析、token 用量记录。
5. **Workflow 中断/恢复** —— INPUT/OUTPUT 中断后 Celery 续跑(langgraph 1.x)。
### P1 — 重点回归
6. **评测功能**(重写后)—— 实跑一次评测任务,核对 9 个字段/分数与历史口径一致;确认 `AnswerCorrectnessBisheng` 行为对齐。
7. **RAG 节点问答** —— `create_stuff_documents_chain`(走 langchain_classic)。
8. **httpx 代理为空** 场景下各 LLM client 构造(0.28 空 proxy 会报错)。
9. **token 计费 / 调用日志** —— 验证 §5 的异步 DB 写问题是否影响计费准确性。
### P2 — 边界 / 环境
10. **DM8 + dmPython** 依赖可安装与运行(仅 CI/Linux,本地 macOS 跳过)。
11. xinference rerank、ASR/TTSopenai 2.x client)。
### 自动化基线
- 全量 `uv run pytest test/ -m "not e2e"`:升级前后基线对比 **2219→2302 passed**errors 148→93),差异由移除 datasets/flaml/ragas 解除了部分测试模块的收集错误所致,**无升级引入的回归**。失败项均为本地无中间件(MySQL/Redis/Milvus/ES/OpenFGA)的既有 infra 依赖。
- 必须起中间件:`bash docker/local-dev/start-middleware.sh` 后手测 P0/P1。
## 7. 回滚要点
- 依赖与代码都在 `feat/langchain-1x` 分支;回滚即切回基线分支。
- 无 DB schema 变更(评测表名不变),无需 alembic 回滚。
- 若仅需回退 autogen/ragas 移除而保留 langchain 1.x,则需恢复 `langchain_compat.py` shim(见 git 历史)。
-101
View File
@@ -1,101 +0,0 @@
# BiSheng 指标日志契约(BS_METRIC
> 给**监控团队**的解析依据。后端各进程(web / Celery / Linsight worker)向标准日志输出结构化行,marker 为 `BS_METRIC`,由日志管线(ELK / Loki / ES)采集并聚合成指标。后端**只打原始测量**;P95 / QPS / 成功率等聚合全部在监控层完成。
>
> 设计与埋点位置见 `features/v2.6.0/042-metric-log-observability/design.md`F042)。
## 行格式
统一 marker + `domain` + logfmt `key=value`
```
BS_METRIC domain=<域> key=value key=value ...
```
- 数值字段 `*_ms` 为毫秒;`bool` 渲染为 `1`/`0`;含空格/引号的字符串值加双引号并转义。
- `None` 字段省略不打(解析时按缺省处理)。
- 采集正则建议锚 `BS_METRIC domain=`;用 `domain=` 的**精确 token**(后接空格或行尾)分流,避免 `db_query` 误匹配 `db_query_agg`
## 字段字典
| domain | 触发时机 | 字段 |
|---|---|---|
| `db_query` | 单条 SQL 且 `elapsed_ms >= db_slow_query_ms`(慢查询明细);失败查询也打 | `op`(SELECT/INSERT/UPDATE/DELETE/OTHER) `elapsed_ms` `status`(ok/error) |
| `db_query_agg` | 每进程每 `db_agg_window_s` 秒 | `window_s` `count` `sum_ms` `le_5 le_10 le_25 le_50 le_100 le_250 le_500 le_1000 le_inf`(**累积**桶计数,单位 ms) |
| `db_pool` | 周期采样(由查询驱动);池等待超时时 | `engine`(sync/async) `checked_out` `idle` `size` `capacity` `at_capacity`(0/1) `result`(可选=wait_timeout) |
| `obj_storage` | 每次上传/下载完成 | `op`(put/get) `result`(ok/error/excluded) `http_status` `err_code` `elapsed_ms` |
| `model_invoke` | 每次模型调用结束 | `model_id` `status`(success/failed) `is_stream`(0/1) `ttft_ms` `total_ms` |
| `eplus_notify` | E+ 每次真实调用(ok/error);forwarder 过白名单后的收件人 skipskipped | `result`(ok/error/skipped) `http_status` `biz_code` `err_code` `elapsed_ms` `action` `reason`(skipped 时) |
### 示例行
```
BS_METRIC domain=db_query op=SELECT elapsed_ms=253.1 status=ok
BS_METRIC domain=db_query_agg window_s=10 count=8421 sum_ms=41230 le_5=6100 le_10=7300 le_25=8000 le_50=8250 le_100=8360 le_250=8408 le_500=8418 le_1000=8420 le_inf=8421
BS_METRIC domain=db_pool engine=async checked_out=37 idle=63 size=100 capacity=120 at_capacity=0
BS_METRIC domain=obj_storage op=put result=ok http_status=200 elapsed_ms=45.2
BS_METRIC domain=obj_storage op=get result=error http_status=500 err_code=InternalError elapsed_ms=1203.4
BS_METRIC domain=model_invoke model_id=123 status=success is_stream=1 ttft_ms=340.0 total_ms=5200.0
BS_METRIC domain=eplus_notify result=ok http_status=200 biz_code=0 elapsed_ms=88 action=request_channel
```
> **`le_*` 是累积桶**Prometheus histogram 语义):`le_10` 含 `le_5``le_inf == count`。累积桶跨进程、跨窗口可直接相加,聚合后用 `histogram_quantile` 得真实全局 P95。
## 指标算法口径
设采集周期内某窗口跨所有进程汇总。
- **DB QPS** = `sum(db_query_agg.count) / 时间跨度秒`
- **DB 查询耗时 P95** = 对 `db_query_agg``le_*` 桶**按进程/窗口求和**后 `histogram_quantile(0.95, buckets)`。P50/P99 同理。
- **DB 慢查询 TopN / 错误率** = `db_query` 明细行按 `op` 分组;错误率 = `count(status=error) / count(*)`
- **连接池饱和度** = `db_pool.checked_out / capacity`**排队告警** = `at_capacity==1` 持续 N 个采样,或出现 `db_pool result=wait_timeout`(池已耗尽、等待超时)。
- **存储成功率** = `count(result=ok) / (count(result=ok) + count(result=error))`,按 `op`(put/get) 分。**`result=excluded` 不进分母**(401/403 签证过期)。
- **存储耗时** = 对 `obj_storage.elapsed_ms` 原始样本按 `op` 分组算分位。
- **模型 TTFT P95** = 对 `model_invoke.ttft_ms` 原始样本算分位;**调用成功率** = `count(status=success) / count(*)`
- **E+ 调用事件** = `eplus_notify``result`(ok/error/skipped) 分组计数;`elapsed_ms` 分位;错误细分看 `biz_code` / `err_code`
## 查询示例
### Loki (LogQL)
DB QPS5 分钟速率):
```logql
sum(rate({app="bisheng"} |= "BS_METRIC domain=db_query_agg" | logfmt | unwrap count [5m]))
```
存储成功率(put,排除 excluded):
```logql
sum(count_over_time({app="bisheng"} |= "BS_METRIC domain=obj_storage" | logfmt | op="put" | result="ok" [5m]))
/
sum(count_over_time({app="bisheng"} |= "BS_METRIC domain=obj_storage" | logfmt | op="put" | result=~"ok|error" [5m]))
```
模型 TTFT P95
```logql
quantile_over_time(0.95, {app="bisheng"} |= "BS_METRIC domain=model_invoke" | logfmt | unwrap ttft_ms [5m])
```
> DB 查询 P95 需对 `le_*` 桶跨进程求和再做 histogram_quantile;若管线支持,把 `db_query_agg` 的桶转成 Prometheus histogram 系列(每个 `le_x` 一条)后用 `histogram_quantile(0.95, sum by (le)(...))`。
### ES(聚合思路)
- 用 ingest/grok 把 `BS_METRIC domain=... k=v` 解析成结构化字段(`domain``op``result``elapsed_ms`…)。
- 成功率:`filter result:ok / (result:ok OR result:error)`;耗时分位:`percentiles` agg on `elapsed_ms`
- DB P95:对 `db_query_agg``le_*``sum` agg 后在可视化层做 histogram_quantile(或转 Prometheus remote-write)。
## 后端开关(运维)
`config.yaml``metric_log` 段(默认全开):
| 键 | 默认 | 说明 |
|---|---|---|
| `enabled` | true | 总开关,关闭后不打任何 `BS_METRIC` |
| `db` / `obj_storage` / `model_invoke` / `eplus` | true | 各域独立开关 |
| `db_slow_query_ms` | 200 | 慢查询明细阈值(ms);调小可看到更多 `db_query` 明细 |
| `db_agg_window_s` | 10 | `db_query_agg` 汇总 + `db_pool` 采样窗口(秒) |
## 契约变更约束
- **新增字段**:向后兼容,可直接加。
- **重命名 / 删除字段、改 `domain`、改 marker `BS_METRIC`**:破坏性变更,**必须通知监控团队**同步解析/告警规则。
- 字段单位固定:`*_ms` 恒为毫秒;`le_*` 桶恒为累积计数。
-120
View File
@@ -1,120 +0,0 @@
# SDD (Spec-Driven Development) — BiSheng 适配版
> **完整方法论指南**: [`docs/SDD-Guide.md`](../docs/SDD-Guide.md)
>
> 本目录存放 SDD 产物——版本契约、Feature 规格和任务清单。
---
## 工作流(9 步)
```
0. release-contract.md 版本开始时,一次性
1. Spec Discovery 架构师提问,识别 PRD 不确定性
↓ ★ 手动暂停点:用户确认
2. 编写 spec.md 合并需求规范 + 技术设计
3. /sdd-review <dir> spec 审查 spec11 项检查)
↓ ★ 手动暂停点:用户确认
4. 编写 tasks.md 拆解为原子任务
5. /sdd-review <dir> tasks 审查 tasks17 项,自动推进)
6. 创建 Feature 分支 feat/v2.5.0/{NNN}-{name},基于 2.5.0-PM
7. 逐任务执行 实现 → 测试 → /task-review → 打勾
7.5. /e2e-test <dir> E2E 测试(强制)
8. /code-review --base 2.5.0-PM 多维度代码审查(自动)
9. 合并回 2.5.0-PM
```
**核心约束**:
- 每步只产出该步骤的文件,不提前执行后续步骤
- 两个 ★ 手动暂停点必须等待用户确认
- 实现偏差必须记录在 tasks.md §实际偏差记录
---
## 目录结构
```
features/
├── README.md # 本文件
├── _templates/ # 可复用模板
│ ├── release-contract.md # 版本契约模板
│ ├── spec.md # 规格文档模板(BiSheng 适配版)
│ └── tasks.md # 任务清单模板(BiSheng 适配版)
└── v2.5.0/ # v2.5.0 版本产物
├── release-contract.md # 版本契约(预填)
├── README.md # Feature 索引
├── 001-feature-name/
│ ├── spec.md
│ └── tasks.md
└── ...
```
---
## 命名规范
### Feature 目录
```
{NNN}-{kebab-case-name}
```
- `NNN` — 零补齐三位数字(000, 001, 002, ...
- Name — 小写、连字符分隔、描述性名称
- 示例:`000-test-infrastructure``001-multi-tenant``004-rebac-core`
### Feature 分支
```
feat/v2.5.0/{NNN}-{short-name}
```
- 基于 `2.5.0-PM` 拉出
- 合并回 `2.5.0-PM``git merge --no-ff`
- 示例:`feat/v2.5.0/004-rebac-core`
---
## 审查命令
| 命令 | 时机 | 说明 |
|------|------|------|
| `/sdd-review <dir> spec` | spec.md 编写后 | 11 项需求+架构检查 |
| `/sdd-review <dir> tasks` | tasks.md 编写后 | 17 项拆解质量检查(自动) |
| `/task-review <dir> <task_id>` | 每个任务完成后 | L1 约定合规(6 项) |
| `/code-review --base 2.5.0-PM` | Feature 全部完成后 | L2 多维度深度审查 |
| `/e2e-test <dir>` | 全部任务完成后 | 生成并运行 E2E 测试 |
---
## 快速开始
### 新建 Feature
```bash
# 1. 复制模板
cp features/_templates/spec.md features/v2.5.0/NNN-feature-name/spec.md
cp features/_templates/tasks.md features/v2.5.0/NNN-feature-name/tasks.md
# 2. 按工作流执行:Discovery → spec → review → tasks → review → 实现
```
### 新建版本
```bash
# 1. 创建版本目录
mkdir features/vX.Y.Z
# 2. 复制版本契约模板
cp features/_templates/release-contract.md features/vX.Y.Z/release-contract.md
# 3. 填写领域对象归属、不变量、依赖图
```
-137
View File
@@ -1,137 +0,0 @@
# Design: <特性名称>
> **本文档定位 — 现状快照(Why this How**
>
> - `spec.md` 回答 **做什么**(目标、AC、边界)
> - `design.md`(本文)回答 **为什么这么实现**:关键决策、运行时不直观的事实、对外契约
> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动
>
> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):
> - 实现变化 → **覆盖更新本文档**,只留"今天的状态"、不留旧快照;但每个决策保留"为什么 + 被否方案"和坑(护栏,见 §3/§5)
> - **偏差分级**:推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design,不必停
> - `tasks.md`「实际偏差记录」只留一行指针(如"T7 偏离 → 更新 design 决策3"),论证在本文档,不重复
**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)
**版本**: v<X.Y.Z>
**最后更新**: YYYY-MM-DD(同步实现变更时一并更新)
---
## 1. 目标与非目标
- **目标**1-3 句话讲清这个 feature 在系统里扮演什么角色
- **非目标**:明确**不**做什么(防止后人误扩范围)
---
## 2. 关键约束
本功能**特有**的硬性约束(决定下面方案对比的取舍空间)。
- **全局架构铁律(双 DB / 多租户 / 权限 / 分层 / 错误码)不在此重抄** —— 一句"遵循 `docs/constitution.md` C1–C7"带过;本节只写本功能特有的:性能 / 容量 / 部署形态 / 上游数据格式依赖 等。
- 引用上游:`docs/constitution.md`(铁律)、`release-contract.md`(版本契约 / 模块编码)。
---
## 3. 方案对比与选定
> **核心设计决策**逐条记录。每条 3 段:备选 / 选定 / 原因。
> 这一节是 design.md 的**最高价值部分** —— agent 接手时,先读这里就知道有哪些"想当然会走但被否决"的路。
### 决策 1<决策主题>
- **备选**
- A. <方案 A> — 优点 / 缺点
- B. <方案 B> — 优点 / 缺点
- **选定**A
- **原因**<关键约束 / trade-off / 已知证据>
- **何时该重新考虑**:<触发条件,例:QPS > X、双 DB 兼容性松动、上游数据格式变更>
### 决策 2:…
---
## 4. 系统现状(接手必读)
> **这里写"今天代码长什么样"**,让接手 agent 不用从 950 行 service 里反推。
> 不写实现细节代码,写**业务话的流程 + 关键文件名 + 关键函数名**。
### 4.1 数据流
`<入口> → <处理 A> → <处理 B> → <出口>`
每一步一句话:做什么 + 主要文件:行号或函数名。
### 4.2 关键数据结构 / 字段约定
> 写**对外可见**的契约(API request/response、消息字段、文件命名规则、ID 规则)。
> 内部数据结构不用写(看代码即可)。
| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 |
|---|---|---|---|
| `xxx.query` | JSON envelope `{"query": str, "files": [...]}` | 用户输入消息 | 导出 / 历史回放 |
### 4.3 关键模块职责
| 模块 / 文件 | 职责 | 不做什么 |
|---|---|---|
| `xxx_service.py` | 业务编排 | 不直接写 ORM |
| `xxx_renderer.py` | 输出格式化 | 不取数据 |
---
## 5. 已知坑 / 反直觉事实
> **最防失传的内容**。代码里看不出、commit message 里散落、踩过才知道的东西。
> 每条要带"如果不知道会怎样",让后人知道严重性。
| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
|---|---|---|---|
| 1 | 运行时 `parentMessageId` 一直是空串,不能用来配对消息 | 配对全错,导出空白 | `useMessageSelection.ts:buildPairGroup` 改用数组位置 |
| 2 | 答案文本在 v2.5 agent-native 格式下只在 `events[]` 里,`msg` 可能为空 | 导出空答复 + RAG 角标残留 | `_extract_answer_text` 兜底从 events 取 + 重跑 strip |
---
## 6. 对外契约与依赖
> 让"改了我会破坏谁"和"我依赖谁"显式化,跨 feature 重构时能搜到。
### 6.1 我提供给别人的(Outgoing
| 契约 | 形式 | 谁在用 |
|---|---|---|
| `/api/v1/xxx/yyy` POST | HTTP API | platform 前端、external script |
| `XxxService.do_yyy()` | 内部 Python API | <其他 service 名> |
### 6.2 我依赖别人的(Incoming
| 依赖 | 形式 | 风险点 |
|---|---|---|
| chat 消息 `query` 字段为 JSON envelope | 隐式数据契约 | chat 模块若改格式会静默坏掉本 feature |
| `libreoffice` 二进制 docx→pdf | 系统依赖 | Docker 镜像必须装 |
---
## 7. 测试与可观测
> 不重复 tasks.md 的测试清单。只写**整体策略**和**怎么手动验证**。
- 单元 / 集成 / e2e 各覆盖哪些层
- 真实环境怎么手动跑一遍(命令、URL、账号)
- 关键日志 / 指标 / 报警在哪
---
## 8. 后续改进 / 不打算做的事
- 已知短板 + 暂时不投入的理由
- 重写或拆分的触发条件
---
## 修订历史
| 日期 | 改动 | 触发原因 |
|---|---|---|
| YYYY-MM-DD | 初版 | feature 完成 |
| YYYY-MM-DD | §5 增坑 X | 用户报障 / e2e 暴露 |
-61
View File
@@ -1,61 +0,0 @@
# Release Contract — vX.X.X
> 本文件是版本级领域归属与全局约束的权威来源。
> **所有 spec.md 在动笔前必须先阅读本文件。**
> 每次 spec 评审时,必须对照本文件检查一致性。
---
## 表 1:领域对象归属
每个领域对象只能有一个 Owner Feature,负责定义该对象的写入行为
(创建、更新、删除)。其他 Feature 只能"读取"或"引用"该对象。
| 领域对象 | Owner Feature | 说明 |
|---------|--------------|------|
| _(在此填写领域对象)_ | F{NNN}-{name} | 包括创建、更新、删除 |
**规则**
- 非 Owner Feature 的 AC 中不得出现其他对象的"创建/修改/删除"行为,只能"读取"或"调用" Owner 的 Service
- 新增领域对象时必须先更新本表
---
## 表 2:跨 Feature 不变量(INV-N
全局业务约束,任何 spec 的 AC **不得与之矛盾**
| ID | 不变量描述 | 涉及领域对象 | 来源 spec |
|----|-----------|------------|---------|
| INV-1 | _(在此填写不变量)_ | _对象_ | F{NNN} |
**规则**
- 新增不变量:先在此表追加,再写 AC
- 修改不变量:必须列出 Impacted Specs 清单,逐一回写并重新评审
- 冲突检测:若 AC 与不变量矛盾,spec 评审不通过
---
## 表 3Feature 依赖图
| Feature | 依赖(必须先完成) | 说明 |
|---------|-----------------|------|
| F{NNN}-{name} | F{NNN}-{name} | 原因:需要某领域对象/API |
---
## 已分配模块编码(MMMEE
> 新 Feature 分配错误码时,必须检查此表避免冲突。
| 模块编码 (MMM) | 模块 | Owner Feature |
|----------------|------|---------------|
| _(从 common/errcode/ 中同步已有编码,新增时在此追加)_ | — | — |
---
## 变更历史
| 日期 | 变更内容 | 影响范围 |
|------|---------|---------|
| YYYY-MM-DD | 初始版本 | — |
-110
View File
@@ -1,110 +0,0 @@
# Feature: <名称>
> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
>
> spec 只回答 **做什么 / 验收标准 / 不做什么**。
> **所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里。**
> How 的唯一真相在 [design.md](./design.md)(接手必读)与 [tasks.md](./tasks.md)(执行流水)。
>
> 为什么这么切:How 写进 spec,实现一变 spec 就过时,而 spec 通常没人回头改 → 接手人被旧方案误导。
> spec 只承载需求口径,才能长期稳定、可被反复引用。
>
> **前置步骤**:本文档编写前必须已完成 Spec Discovery(架构师提问),PRD 中的不确定性已与用户对齐。
**关联 PRD**: [<PRD 文件路径 / wiki 链接> §章节名]
**优先级**: P0 / P1 / P2
**所属版本**: v<X.Y.Z>
**依赖**: <前置 Feature,如 F004 / F008;无则写「无」>
> **范围边界**(文章经验:写清「明确不做什么」比写「做什么」更能防 scope 膨胀)
> - **本次纳入**
> - <能力 1>
> - <能力 2>
> - **本次明确排除**
> - <明确不做的功能>(延后到 vX.X.X / 另起 feature
> - <容易被误以为要做、但本期不做的事,逐条写清并附一句原因>
---
## 1. 用户故事
> 只保留**业务价值视角**的故事。
> 「为什么这么实现」(如:统一某状态管理、共用某 Service)属于**设计意图**,写进 design.md §3 / §4.3,不在 spec 重复。
作为 **<角色>**
我希望 **<目标>**
以便 **<价值>**。
(多角色场景按 A / B / C 分列,每条都是「角色 + 目标 + 价值」三段式。)
---
## 2. 验收标准
> **spec 的核心资产。** AC-ID 在本特性内唯一,格式 `AC-NN`。
> tasks.md 中的测试任务必须通过 `覆盖 AC: AC-NN` 追溯到此表。
> 错误码号在此表中**仅作为「可观测的对外行为」引用**(如「返错误码 12061」);
> 错误码的定义、归属、编码规则是实现细节,写在 design.md §6 / 代码 `common/errcode/`,不在 spec 维护错误码详表。
**写法分级**
- **P0 / 复杂 feature** → 用 **EARS 句型**(无歧义、可直接转测试;杜绝"友好提示""响应快"这类没法验收的话)
- **小功能 / hotfix** → 下面的表格式即可
**EARS 句型**(每条 AC 选一种填空):
| 句型 | 用于 |
|---|---|
| `THE SYSTEM SHALL <要求>` | 始终成立的普遍要求 |
| `WHEN <事件>, THE SYSTEM SHALL <响应>` | 某事件发生时 |
| `WHILE <状态>, THE SYSTEM SHALL <响应>` | 处于某状态时(如"流式生成中" |
| `IF <异常/不期望>, THEN THE SYSTEM SHALL <响应>` | 异常 / 错误路径 |
| `WHERE <特性开启>, THE SYSTEM SHALL <响应>` | 某可选特性开启时 |
EARS 示例:
- **AC-01** — WHEN 用户提交正确凭证, THE SYSTEM SHALL 返回 token 并初始化其权限上下文。
- **AC-02** — IF 密码连续错误 5 次, THEN THE SYSTEM SHALL 锁定账号 15 分钟并返回错误码 10605。
- **AC-03** — WHERE 多租户开启, THE SYSTEM SHALL 把查询限定到当前 tenant_id。
表格式(小功能 / hotfix 用):
| ID | 角色 | 操作 | 预期结果 |
|----|------|------|---------|
| AC-01 | <角色> | <执行什么操作> | <系统返回 / 显示什么> |
| AC-02 | <角色> | <边界 / 错误场景操作> | <错误码 / HTTP 状态码 / 提示文案> |
(AC 多时按能力分小节 2.1 / 2.2 / …。)
---
## 3. 边界情况
- 当 <异常场景> 时,系统应 <预期行为>
- <数据形态多样 / 并发 race / 缺字段兜底 等> —— 只写**期望的对外行为**;
具体兜底策略、运行时坑的处理细节指向 design.md §5(不在 spec 展开实现)。
---
## 4. 设计与实现(指针,不复制)
> **本节刻意不写内容。** spec 不承载 How,避免与代码一起漂移。
> 需要了解实现时,按下表跳转 —— 这些文档才是各自 How 的唯一真相:
| 你想知道 | 去哪看 |
|---|---|
| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 |
| 关键约束(双 DB / 多租户 / 权限 / 运行时依赖 / 错误码段) | design.md §2 |
| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 系统现状 |
| 代码里看不出的坑、反直觉事实 | design.md §5 |
| 对外契约、改了会破坏谁、依赖谁 | design.md §6 |
| 性能 / 安全 / 可观测非功能指标 | design.md §2 + §7 |
| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md |
---
## 相关文档
- 设计真相: [design.md](./design.md)(接手第一入口)
- 执行与落档: [tasks.md](./tasks.md)
- 版本契约: [features/v<X.Y.Z>/release-contract.md](../release-contract.md)(写 spec 前必须先阅读)
- 架构文档: `docs/architecture/`
- PRD: <PRD 路径 / wiki 链接>
-125
View File
@@ -1,125 +0,0 @@
# Tasks: <特性名称>
**关联规格**: [spec.md](./spec.md)
**版本**: v<X.Y.Z>
---
## 状态
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | 🔲 草稿 | 用户确认后改为 ✅ 已评审 |
| design.md | 🔲 草稿 | 用户确认后改为 ✅ 已评审;接手时的第一入口 |
| tasks.md | 🔲 草稿 | 拆解完成后改为 ✅ 已拆解 |
| 实现 | 🔲 未开始 | 0 / N 完成。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4 |
---
## 开发模式
**按 Wave 组织任务**:按依赖分组——无依赖的归 Wave 1(可并行),依赖前序的归后续 Wave。每个任务标 `依赖:`,让 agent 能自行判断下一步、把无依赖的并行跑。
**后端 Test-First(务实版)**
- 理想流程:先写测试(红),再写实现(绿)
- 测试基础薄弱时,首个测试任务一并搭 pytest 基础设施(conftest.py、db fixture、mock helpers
- 测试编写成本极高(如需完整 Milvus/ES mock)可标注 `**测试降级**: 手动验证 + TODO`,并在「实际偏差记录」说明
- 中间件 / DM8 / e2e 测试在 **CI** 跑,不依赖本地(见 `docs/SDD-Guide.md` §6
**前端**:当前用「手动验证」(每个任务附验证步骤)。目标是 Playwright 交互测试(`docs/SDD-Guide.md` §6,🚧 规划中)—— 落地后前端任务改写自动化断言。
**自包含任务**:每个任务内联文件、逻辑、AC 覆盖,实现期不必回读 spec。但**设计论证(为什么这么做)指向 design §X,不复制**(避免第三处漂移)。
---
## Tasks
### 基础设施(无测试配对)
- [ ] **T001**: 数据库 ORM 模型 + DAO
**文件**: `src/backend/bisheng/{module}/domain/models/{entity}.py`
**逻辑**: 定义 SQLModel 表(继承 SQLModelSerializable,含 tenant_id/create_time/update_time),
编写 DAO classmethodget_xxx/aget_xxx/create_xxx/update_xxx/delete_xxx
**依赖**: 无
- [ ] **T002**: 错误码定义
**文件**: `src/backend/bisheng/common/errcode/{module}.py`
**逻辑**: 定义 MMMEE 错误码类,继承 BaseErrorCode,在 release-contract.md 注册模块编码
**依赖**: 无
### 后端 Domain ServiceTest-First 配对)
- [ ] **T003**: {Module}Service 单元测试
**文件**: `src/backend/test/test_{module}_service.py`
**逻辑**: 测试核心方法,mock DAO 层
**测试**: `test_create_success` → AC-01, `test_permission_denied` → AC-02
**覆盖 AC**: AC-01, AC-02
**基础设施**: 如 conftest.py 不存在,本任务一并创建基础 fixture
**依赖**: T001
- [ ] **T004**: {Module}Service 实现
**文件**: `src/backend/bisheng/{module}/domain/services/{service}.py`
**逻辑**: 业务逻辑,调用 DAO + PermissionService
**测试**: T003 全部通过
**覆盖 AC**: AC-01, AC-02
**依赖**: T001, T003
### 后端 API 层(Test-First 配对)
- [ ] **T005**: API 端点集成测试
**文件**: `src/backend/test/test_{module}_api.py`
**逻辑**: TestClient 测试 HTTP 端点,覆盖 happy path + 主要 error path
**覆盖 AC**: AC-01, AC-02
**依赖**: T004
- [ ] **T006**: API 端点 + Router 注册
**文件**: `src/backend/bisheng/{module}/api/endpoints/{endpoint}.py`,
`src/backend/bisheng/{module}/api/router.py`
**逻辑**: FastAPI endpoint 定义,UserPayload 认证注入,委托 Service 处理,
UnifiedResponseModel 响应包装。在 api/router.py 注册(如新模块)
**测试**: T005 全部通过
**覆盖 AC**: AC-01, AC-02
**依赖**: T004, T005
### 前端 Platform(手动验证)
- [ ] **T007**: Platform 页面实现
**文件**: `src/frontend/platform/src/pages/{Page}/index.tsx`,
`src/frontend/platform/src/controllers/API/{module}.ts`
**逻辑**: 组件结构、API 集成、状态管理(Zustand)、i18n
**覆盖 AC**: AC-01, AC-02
**手动验证**:
- 打开 http://192.168.106.114:4001/xxx
- 执行 <操作>,验证 <预期结果>
- 检查错误场景:<描述>
**依赖**: T006
### 前端 Client(手动验证,如适用)
- [ ] **T008**: Client 页面实现
**文件**: `src/frontend/client/src/pages/{page}.tsx`,
`src/frontend/client/src/api/{module}.ts`
**逻辑**: 组件结构、API 集成、状态管理(Zustand
**覆盖 AC**: AC-03
**手动验证**:
- 打开 http://192.168.106.114:4001/workspace/xxx
- 执行 <操作>,验证 <预期结果>
**依赖**: T006
### Worker 异步任务(如适用)
- [ ] **T009**: Celery 任务定义
**文件**: `src/backend/bisheng/worker/{module}/tasks.py`
**逻辑**: 异步任务实现,调用 Domain Service
**约束**: 任务参数中包含 tenant_idWorker 执行前恢复 current_tenant_id ContextVar
**覆盖 AC**: AC-04
**依赖**: T004
---
## 实际偏差记录
> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。
> 推翻已 ★ 确认的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。
- T<NN> 偏离 → 更新 design 决策 <X> / 新增坑 <Y>(一句话原因)
@@ -1,228 +0,0 @@
# Feature: 测试基础设施
> **前置步骤**:本文档编写前已完成 Spec Discovery(架构师提问),
> PRD 中的不确定性已与用户对齐。
**关联 PRD**: 无(基础设施 Feature
**优先级**: P0
**所属版本**: v2.5.0
---
## 范围界定
**IN**:
- 后端 pytest 配置(`pyproject.toml` `[tool.pytest.ini_options]` + test markers + 测试依赖声明)
- 共享 conftest.pyDB engine/session fixture、tenant context fixture、TestClient fixture
- SQLite 兼容 DDL 集中定义(`table_definitions.py`,覆盖 tenant/user_tenant + F002-F008 常用表)
- Import chain 预 mock 集中化(从 F001 各文件的 `sys.modules` 模式提取合并到共享模块)
- 外部服务 mock fixtureRedis → fakeredis、MinIO → MagicMock、OpenFGA → InMemoryOpenFGAClient
- Test data 工厂函数(`create_tenant()``create_user_tenant()``create_test_user()` 等)
- 基础设施自身的 smoke test~10 个用例,验证 fixture 正确工作)
- 前端 Platform Vitest 基础配置 + test-utils + smoke test
**OUT**:
- 具体业务测试用例(各 Feature 自写)
- 浏览器 E2E 测试框架(Playwright/Cypress 等)
- F001 现有测试的迁移/重构(F001 测试自包含且稳定,保持原样)
- Client 前端测试框架(已有 Jest 配置,不在 F000 范围)
- CI/CD 测试步骤(后续独立处理)
**关联不变量**: 无直接关联(F000 提供的 fixture 间接支撑所有 INV 的测试覆盖)
---
## 1. 概述与用户故事
F000 是 v2.5.0 所有 Feature 的测试地基。它提供开箱即用的 pytest fixture、mock 基础和前端测试框架配置,使 F001~F010 的开发者无需重复搭建测试环境。
F001 已内联搭建了一套最小测试基础(SQLite in-memory + conftest.py + 预 mock 模式),但每个测试文件各自维护独立的 engine/session/pre-mock,不可复用。F000 将这些模式正式化为共享基础设施,并为后续 Feature(特别是 F004-rebac-core 所需的 OpenFGA mock)提前准备。
**用户故事 1**:
作为 **BiSheng 后端开发者**
我希望 **在新 Feature 的测试文件中直接使用 `db_session`、`mock_redis`、`mock_openfga` 等 fixture**
以便 **不必每次从零搭建 SQLite engine、手动管理 import chain pre-mock、编写事务回滚逻辑**
**用户故事 2**:
作为 **BiSheng 后端开发者**
我希望 **通过 `create_tenant(session)`、`create_test_user(session)` 等工厂函数快速构造测试数据**
以便 **专注于业务逻辑测试而非测试数据准备**
**用户故事 3**:
作为 **BiSheng 前端开发者**
我希望 **在 Platform 项目中运行 `npm test` 即可执行 Vitest 测试**
以便 **F007(资源权限 UI)等前端 Feature 有自动化测试基础可用**
---
## 2. 验收标准
| ID | 角色 | 操作 | 预期结果 |
|----|------|------|---------|
| AC-01 | 开发者 | 在 `src/backend` 下执行 `pytest --collect-only` | 无 import 错误,正确收集所有测试文件(含 F001 现有测试) |
| AC-02 | 开发者 | 在测试函数签名中声明 `db_session` 参数 | 获得一个已建表的 SQLite in-memory Session,测试结束后自动 ROLLBACK(每个测试独立) |
| AC-03 | 开发者 | 在测试函数签名中声明 `async_db_session` 参数 | 获得一个异步 SQLite Session,支持测试异步 DAO 方法(`aget_xxx` |
| AC-04 | 开发者 | 在测试函数签名中声明 `mock_redis` 参数 | 获得一个 fakeredis 实例,支持 `set`/`get`/`delete` 等基本命令,无需真实 Redis |
| AC-05 | 开发者 | 在测试函数签名中声明 `mock_minio` 参数 | 获得一个 MagicMock 对象,提供 `put_object`/`get_object`/`remove_object` 方法 |
| AC-06 | 开发者 | 在测试函数签名中声明 `mock_openfga` 参数 | 获得一个 `InMemoryOpenFGAClient` 实例,支持 `write_tuples`/`check`/`list_objects`/`list_users` + 测试断言辅助方法 |
| AC-07 | 开发者 | 在测试函数签名中声明 `test_client` 参数 | 获得一个绑定 FastAPI app 的 TestClient,可直接发送 HTTP 请求到 API 端点,DB/Redis/MinIO 已被 mock |
| AC-08 | 开发者 | 调用 `create_tenant(session, code="test")` | 在测试 DB 中创建一条 tenant 记录并返回 Tenant 对象 |
| AC-09 | 开发者 | 执行 `pytest test/test_infrastructure_smoke.py -v` | ~10 个 smoke test 全部 PASSED,验证所有 fixture 正确工作 |
| AC-10 | 开发者 | 执行 `pytest test/test_tenant_*.py -v` | F001 现有的 43+ 测试全部 PASSED(回归无损) |
| AC-11 | 前端开发者 | 在 `src/frontend/platform` 下执行 `npm test` | Vitest smoke test PASSEDtest-utils 中的 `render` 函数正常工作 |
---
## 3. 边界情况
-**F001 现有测试文件自带 engine/session/pre-mock** 时,F000 的共享 conftest 不得干扰其独立运行。conftest 中的 session-scoped autouse fixture 必须兼容 F001 的自包含模式
-**新 Feature 只需少量表**(如只需 tenant + department)时,开发者可以使用 `create_tables(engine, 'tenant', 'department')` 选择性建表,无需加载全部 DDL
-**import chain 引发新的循环依赖**(后续 Feature 引入新模块)时,开发者需在 `mock_services.py``PREMOCK_MODULES` 列表中追加模块路径
-**OpenFGA client 接口发生变化**F004 定义真实 client 后)时,`InMemoryOpenFGAClient` 需同步更新以匹配接口签名
- **不支持**: 真实 MySQL 测试(需要 MySQL 特有行为的测试使用 E2E 方式)
- **不支持**: 异步 TestClient(当前只提供同步 TestClient,异步 API 测试通过异步 DB session 间接覆盖)
---
## 4. 架构决策
| ID | 决策 | 选项 | 结论 | 理由 |
|----|------|------|------|------|
| AD-01 | 测试数据库引擎 | A: SQLite in-memory / B: test MySQL 实例 | 选 A | F001 已验证 43+ 测试稳定通过;零外部依赖;亚毫秒级速度;MySQL 特有行为由 E2E 覆盖。SQLite 不兼容的 DDL(如 `ON UPDATE CURRENT_TIMESTAMP`)通过 `table_definitions.py` 集中转写 |
| AD-02 | OpenFGA mock 方式 | A: 纯 Python 内存 mock / B: Docker 测试容器 / C: 留给 F004 | 选 A | OpenFGA client`core/openfga/`)尚未实现(F004 负责),但 F000 可预定义 mock 接口让后续 Feature 有 fixture 可用。内存 mock 零延迟零依赖,F004 可按需增强。不实现完整 Zanzibar 算法,仅支持直接元组匹配 |
| AD-03 | 前端测试框架 | A: VitestPlatform/ B: Jest(统一) | 选 A | Platform 使用 Vite 构建,Vitest 天然共享 `vite.config.mts`(路径别名、插件等)。Client 已有 Jest 配置但不在 F000 范围 |
| AD-04 | F001 测试回迁 | A: 不回迁 / B: 统一迁移到共享 fixture | 选 A | F001 测试稳定且自包含,迁移有回归风险无业务价值。新 Feature 使用共享 fixtureF001 保持原样 |
---
## 5. 数据库 & Domain 模型
N/A — F000 不创建 ORM 模型。`table_definitions.py` 提供 SQLite 兼容 DDL 用于测试建表,但不是生产代码。
### SQLite 兼容 DDL 定义(test-only
`table_definitions.py` 为每张需要测试的表提供 SQLite 兼容的 `CREATE TABLE` SQL。这些定义从 F001 测试文件提取并扩展,覆盖:
**已有**(从 F001 提取):`tenant``user_tenant`
**新增**(为 F002-F008 准备):`user``department``user_department``user_group``role``role_access``flow``knowledge`
**设计规则**
- 每张表 DDL 是独立 SQL 字符串常量
- `create_all_tables(engine)` 建全部表,`create_tables(engine, *names)` 选择性建表
- 不含 MySQL 特有语法(`AUTO_INCREMENT``AUTOINCREMENT``ON UPDATE CURRENT_TIMESTAMP` → 省略)
- 保持与生产 ORM 定义的字段/类型/约束一致(除上述兼容项)
---
## 6. API 契约
N/A — F000 不新增 API 端点。
---
## 7. Service 层逻辑
N/A — F000 是纯测试基础设施 Feature。核心交付物是 pytest fixture 和配置,不含业务逻辑。
### 核心组件
| 组件 | 文件 | 职责 |
|------|------|------|
| pytest 配置 | `pyproject.toml` `[tool.pytest.ini_options]` | testpaths、markerse2e/slow)、asyncio_mode、filterwarnings |
| 预 mock 注册 | `test/fixtures/mock_services.py` | 集中 `sys.modules` 预 mock,解决 import chain 循环依赖 |
| 表定义 | `test/fixtures/table_definitions.py` | SQLite 兼容 DDL,支持全量/选择性建表 |
| DB fixture | `test/conftest.py` | `db_engine`session-scoped)、`db_session`function-scoped, ROLLBACK 隔离) |
| 异步 DB fixture | `test/conftest.py` | `async_db_engine``async_db_session` |
| Redis mock | `test/conftest.py` | `mock_redis` → fakeredis 实例 |
| MinIO mock | `test/conftest.py` | `mock_minio` → MagicMock |
| OpenFGA mock | `test/fixtures/mock_openfga.py` | `InMemoryOpenFGAClient`dict 存储元组,支持 write/check/list + 断言辅助 |
| TestClient | `test/conftest.py` | `test_client` → FastAPI TestClient,依赖已 override |
| 工厂函数 | `test/fixtures/factories.py` | `create_tenant()``create_user_tenant()``create_test_user()` |
| Smoke test | `test/test_infrastructure_smoke.py` | 验证所有 fixture 正确工作 |
### InMemoryOpenFGAClient 接口
```python
class InMemoryOpenFGAClient:
"""纯内存 OpenFGA mock。存储元组为 (object, relation, user) 三元组。
不实现 userset 展开(transitive resolution)。
F004 引入真实 client 后可增强此 mock 以匹配接口。
"""
async def write_tuples(self, writes: list[dict]) -> None: ...
async def delete_tuples(self, deletes: list[dict]) -> None: ...
async def check(self, user: str, relation: str, object: str) -> bool: ...
async def list_objects(self, user: str, relation: str, type: str) -> list[str]: ...
async def list_users(self, relation: str, object: str, user_type: str) -> list[str]: ...
# 测试辅助
def assert_tuple_exists(self, user: str, relation: str, object: str) -> None: ...
def assert_tuple_count(self, expected: int) -> None: ...
def reset(self) -> None: ...
```
---
## 8. 前端设计
### 8.1 Platform 前端
> 路径:`src/frontend/platform/`
F000 不创建业务页面,仅搭建 Vitest 测试框架:
**配置**`vitest.config.ts` 通过 `mergeConfig` 复用 `vite.config.mts`(继承路径别名 `@/``src/`、react-swc 插件等)
**测试工具**`src/test/test-utils.tsx` 提供自定义 `render` 函数,包裹 `BrowserRouter` 等公共 Provider
**Setup**`src/test/setup.ts` 配置 `@testing-library/jest-dom` matchers + i18n mock
**Smoke test**`src/test/smoke.test.ts` 验证 Vitest 基础设施可运行
### 8.2 Client 前端
N/A — Client 已有 Jest 配置,不在 F000 范围。
---
## 9. 文件清单
### 新建
| 文件 | 说明 |
|------|------|
| `src/backend/test/fixtures/__init__.py` | fixture 子包入口 |
| `src/backend/test/fixtures/table_definitions.py` | SQLite 兼容 DDL 集中定义(10 张表) |
| `src/backend/test/fixtures/mock_services.py` | import chain 预 mock 集中化 + 服务 mock 工厂函数 |
| `src/backend/test/fixtures/mock_openfga.py` | InMemoryOpenFGAClient 内存 mock |
| `src/backend/test/fixtures/factories.py` | test data 工厂函数 |
| `src/backend/test/test_infrastructure_smoke.py` | 基础设施 smoke test~10 用例) |
| `src/frontend/platform/vitest.config.ts` | Vitest 配置(mergeConfig from vite.config.mts |
| `src/frontend/platform/src/test/setup.ts` | 测试 setupjest-dom matchers + i18n mock |
| `src/frontend/platform/src/test/test-utils.tsx` | 自定义 render + Provider 包裹 |
| `src/frontend/platform/src/test/smoke.test.ts` | 前端 smoke test |
### 修改
| 文件 | 变更内容 |
|------|---------|
| `src/backend/pyproject.toml` | 添加 `[tool.pytest.ini_options]` + `[project.optional-dependencies].test` |
| `src/backend/test/conftest.py` | 扩展为完整共享 fixture(保留现有 `mock_settings`,新增 `db_engine`/`db_session`/`mock_redis`/`mock_minio`/`mock_openfga`/`test_client`/`tenant_context`/`bypass_tenant` 等) |
| `src/frontend/platform/package.json` | 添加 vitest + @testing-library devDependencies + test scripts |
---
## 10. 非功能要求
- **性能**: 全量非 E2E 测试套件(含 F001 43+ 现有测试 + F000 smoke test)应在 30 秒内完成
- **零外部依赖**: 执行 `pytest test/ -m "not e2e"` 不需要 MySQL、Redis、MinIO、Milvus、ES、OpenFGA 等外部服务
- **兼容性**: F001 现有 6 个测试文件(test_tenant_*.py)全部保持 PASSED,不因共享 conftest 引入回归
- **可扩展性**: 后续 Feature 可在 `table_definitions.py` 追加表、在 `mock_services.py` 追加预 mock 模块、在 `factories.py` 追加工厂函数,无需修改 conftest.py
---
## 相关文档
- 版本契约: [features/v2.5.0/release-contract.md](../release-contract.md)
@@ -1,270 +0,0 @@
# Tasks: 测试基础设施
**关联规格**: [spec.md](./spec.md)
**版本**: v2.5.0
---
## 状态
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | ✅ 已评审 | 14 项检查通过(6 PASS + 8 N/A |
| tasks.md | ✅ 已拆解 | 21 项检查通过(1 low 跳过),9 个任务 |
| 实现 | ✅ 已完成 | 9 / 9 完成,50/50 后端测试通过,2/2 前端测试通过 |
---
## 开发模式
**特殊模式(测试基础设施自身)**
- F000 本身就是测试基础设施,不适用标准 Test-First 模式
- 验证方式:每个任务完成后运行 `pytest --collect-only` 确认无 import 错误 + 最终 T008 的 smoke test 验证全部 fixture
- F001 回归保护:每个修改 conftest.py 的任务必须确认 `pytest test/test_tenant_*.py` 全部通过
**前端 Test-Alongside**
- T009 搭建 Vitest 框架 + smoke test,一步到位
**自包含任务**:每个任务内联文件、逻辑、验证方式,实现阶段不需要回读 spec.md。
---
## 依赖图
```
T001 (pytest 配置 + 依赖)
├── T002 (import chain 预 mock)
│ │
│ ├── T004 (DB fixtures) ─── T006 (TestClient)
│ │ │
│ └── T005 (Redis/MinIO/FGA mock)──┘
└── T003 (SQLite DDL 定义)
├── T004 (DB fixtures)
└── T007 (工厂函数)
T004 + T005 + T006 + T007 ──→ T008 (smoke test)
T009 (前端 Vitest) ── 独立,可与后端并行
```
---
## Tasks
### 基础设施配置
- [x] **T001**: pytest 配置 + 测试依赖声明
**文件(修改)**:
- `src/backend/pyproject.toml` — 添加 `[tool.pytest.ini_options]` + `[project.optional-dependencies].test`
**逻辑**:
- `[tool.pytest.ini_options]`:
- `testpaths = ["test"]`
- `python_files = ["test_*.py"]`
- `asyncio_mode = "auto"`(支持异步 DAO 测试)
- `markers`: `e2e`(需要运行中的后端)、`slow`>5s 的测试)
- `filterwarnings`: ignore SQLAlchemy DeprecationWarning
- `[project.optional-dependencies].test`:
- `pytest>=8.0`, `pytest-asyncio>=0.23`, `pytest-cov>=5.0`
- `fakeredis[lua]>=2.21`Redis mock
- `httpx>=0.27`(已在 main deps 中,此处确认可用)
- 安装验证:`uv sync --extra test`
**验证**: `cd src/backend && pytest --collect-only` 无报错,收集到 F001 现有测试
**覆盖 AC**: AC-01(部分,配置层面)
**依赖**: 无
---
### 后端 fixture 搭建
- [x] **T002**: Import chain 预 mock 集中化
**文件(新建)**:
- `src/backend/test/fixtures/__init__.py` — fixture 子包入口(空文件)
- `src/backend/test/fixtures/mock_services.py` — 集中化预 mock 模块
**逻辑**:
- 从 F001 的 `test_tenant_filter.py``test_tenant_auth.py` 顶部提取 `sys.modules` 预 mock 列表,合并去重
- 定义 `PREMOCK_MODULES` 常量列表(包含已知会引发循环依赖的模块路径):
- `bisheng.common.services` 及子模块(config_service, telemetry
- `bisheng.database.models.*`user_group, role_access, group, role 等)
- `bisheng.database.constants`
- `bisheng.user.domain.models.*`user_role, user
- `bisheng.common.errcode.http_error`
- `bisheng.common.exceptions.auth`
- 提供 `premock_import_chain()` 函数:遍历列表,对未在 `sys.modules` 中的模块注入 MagicMock
- 提供 `create_mock_settings(multi_tenant_enabled=False, ...)` 工厂函数:返回配置好的 MagicMock
- **关键约束**: 不使用 autouse fixture 调用 premock(会干扰 F001 已有的自包含 pre-mock),而是在 conftest.py 顶部以模块级代码调用
**验证**: `pytest test/test_tenant_*.py -v` 全部 PASSEDF001 回归)
**覆盖 AC**: AC-01import chain 兼容)
**依赖**: T001
- [x] **T003**: SQLite 表定义集中管理
**文件(新建)**:
- `src/backend/test/fixtures/table_definitions.py` — SQLite 兼容 DDL 定义
**逻辑**:
- 为每张表定义一个 SQL 字符串常量(`TABLE_TENANT`, `TABLE_USER_TENANT`, `TABLE_USER`, ...
- 已有表(从 F001 `test_tenant_dao.py:24-53` 提取):`tenant`, `user_tenant`
- 新增表(为 F002-F008 准备,参照生产 ORM 定义转写为 SQLite 兼容语法):
- `user` — id, user_name, password, role_id, tenant_id, ...
- `department` — id, tenant_id, name, parent_id, path, level, ...
- `user_department` — id, user_id, department_id, is_primary, ...
- `user_group`(即 `group` 表)— id, tenant_id, group_name, ...
- `role` — id, role_name, role_type, tenant_id, ...
- `role_access` — id, role_id, access_type, access_id, ...
- `flow` — id, tenant_id, name, flow_type, status, ...
- `knowledge` — id, tenant_id, name, model, ...
- DDL 转写规则:`AUTO_INCREMENT``AUTOINCREMENT``ON UPDATE CURRENT_TIMESTAMP` → 省略,`ENUM``VARCHAR``INT UNSIGNED``INTEGER`
- 提供 `create_all_tables(engine)` 建全部表
- 提供 `create_tables(engine, *table_names)` 选择性建表(`table_names` 为字符串参数如 `'tenant'`, `'user'`
- 表名到 DDL 的映射用 `TABLE_DEFINITIONS: dict[str, str]`
**验证**: 在 Python REPL 中 `create_all_tables(engine)` 无报错,`create_tables(engine, 'tenant', 'department')` 只建 2 张表
**覆盖 AC**: AC-02(部分,DDL 层面)
**依赖**: T001
- [x] **T004**: DB fixtures(同步 + 异步)
**文件(修改)**:
- `src/backend/test/conftest.py` — 扩展,新增 DB 相关 fixture
**逻辑**:
- 在文件顶部(fixture 定义之前)调用 `premock_import_chain()`(模块级代码,非 fixture
- 保留现有 `mock_settings` fixture 不变
- 新增 fixture
- `db_engine`scope=session):`create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)``create_all_tables(engine)` → yield → `engine.dispose()`
- `db_session`scope=function):从 `db_engine` 创建 connection → `begin()``Session(bind=connection)` → yield → `rollback()``close()`
- `async_db_engine`scope=session):`create_async_engine('sqlite+aiosqlite://', ...)` → 异步建表 → yield → dispose
- `async_db_session`scope=function):异步连接 + 事务 + AsyncSession → yield → rollback
- `tenant_context`scope=function):接受 `tenant_id` 参数(默认 1),设置 `current_tenant_id` ContextVaryield 后 reset
- `bypass_tenant`scope=function):进入 `bypass_tenant_filter()` 上下文
- **兼容性保障**: F001 测试文件自带的 `dao_engine`/`session`/`filter_engine` fixture 仍然独立工作,conftest 的 `db_engine`/`db_session` 只在显式声明时使用
**验证**:
- `pytest test/test_tenant_*.py -v` 全部 PASSEDF001 回归)
- 编写一个临时测试验证 `db_session` 能做 CRUD
**覆盖 AC**: AC-02, AC-03
**依赖**: T002, T003
- [x] **T005**: 外部服务 mockRedis / MinIO / OpenFGA
**文件(新建)**:
- `src/backend/test/fixtures/mock_openfga.py` — InMemoryOpenFGAClient
**文件(修改)**:
- `src/backend/test/conftest.py` — 新增 `mock_redis`, `mock_minio`, `mock_openfga` fixture
**逻辑**:
- `mock_redis`scope=function):返回 `fakeredis.FakeRedis()` 实例,每个测试自动 `flushall()`
- `mock_minio`scope=function):返回 `MagicMock()` 配置 `put_object`/`get_object`/`remove_object` 方法返回值
- `mock_openfga`scope=function):返回 `InMemoryOpenFGAClient()` 实例,每个测试自动 `reset()`
- **InMemoryOpenFGAClient 实现**:
- 内部存储 `_tuples: set[tuple[str, str, str]]`object, relation, user 三元组)
- `write_tuples(writes)` — 将 `[{"object": ..., "relation": ..., "user": ...}]` 添加到集合
- `delete_tuples(deletes)` — 从集合移除
- `check(user, relation, object)` — 直接匹配三元组是否存在(不实现 userset 展开)
- `list_objects(user, relation, type)` — 返回 user 对 type 类型有 relation 关系的所有 object
- `list_users(relation, object, user_type)` — 返回对 object 有 relation 关系的所有 user_type 类型用户
- `assert_tuple_exists(user, relation, object)` — 断言元组存在,不存在时 raise AssertionError 含详细信息
- `assert_tuple_count(expected)` — 断言元组总数
- `reset()` — 清空 `_tuples`
- 所有 async 方法内部无真正异步操作,仅 `async def` 签名以匹配未来 F004 的异步 client 接口
**验证**: 单独 `import test.fixtures.mock_openfga` 无报错;在临时测试中验证 write + check 基本流程
**覆盖 AC**: AC-04, AC-05, AC-06
**依赖**: T002
- [x] **T006**: TestClient fixture
**文件(修改)**:
- `src/backend/test/conftest.py` — 新增 `test_client` fixture
**逻辑**:
- `test_client`scope=function):
1. 导入 `bisheng.main:create_app` 创建 FastAPI app(或直接导入 `app`
2. 通过 `app.dependency_overrides` 覆盖关键依赖:
- `UserPayload.get_login_user` → 返回固定的 mock UserPayloaduser_id=1, tenant_id=1
- 数据库 session → 使用 `db_session` fixture 的 session
3. 使用 `starlette.testclient.TestClient(app)` 创建客户端
4. yield client
5. teardown:清空 `dependency_overrides`
- **已知限制**: 由于 lifespan 会初始化 DB/Redis/MinIO 等真实连接,TestClient 需要使用 `raise_server_exceptions=False` 或 mock 掉 lifespan。具体策略在实现时根据 `create_app` 的 lifespan 实现决定
- **测试降级**: 如果 mock lifespan 复杂度过高,降级为仅验证 `/health` 端点(不需要 DB 连接),并在偏差记录中说明
**验证**: `test_client.get("/health")` 返回 200
**覆盖 AC**: AC-07
**依赖**: T004, T005
- [x] **T007**: Test data 工厂函数
**文件(新建)**:
- `src/backend/test/fixtures/factories.py` — 工厂函数
**逻辑**:
- 纯函数(非 fixture),接受 session 参数,创建记录并返回:
- `create_tenant(session, code='test', name='Test Tenant', **kwargs) -> dict` — INSERT tenant 记录,返回包含 id 的 dict
- `create_user_tenant(session, user_id, tenant_id, is_default=1) -> dict` — INSERT user_tenant 记录
- `create_test_user(session, user_name='testuser', tenant_id=1, **kwargs) -> dict` — INSERT user 记录
- 返回 dict 而非 ORM 对象,避免导入生产 ORM 模型引发 import chain 问题
- 使用 `sqlalchemy.text()` 执行 raw SQL INSERT,不依赖 SQLModel
- 每个函数有合理的默认值,调用方只需覆盖关心的字段
**验证**: 在临时测试中 `create_tenant(session)``SELECT * FROM tenant` 返回一条记录
**覆盖 AC**: AC-08
**依赖**: T003
---
### 验证与前端
- [x] **T008**: 基础设施 smoke test
**文件(新建)**:
- `src/backend/test/test_infrastructure_smoke.py` — smoke test
**逻辑**:
- ~10 个测试,验证 F000 所有 fixture 和工具正常工作:
- `test_db_engine_creates_tables``db_engine` fixture 建表成功,`PRAGMA table_info(tenant)` 返回列信息
- `test_db_session_crud` — 通过 `db_session` INSERT + SELECT 一条 tenant 记录
- `test_db_session_rollback_isolation` — 一个测试中 INSERT 数据,另一个测试看不到(ROLLBACK 生效)
- `test_async_db_session_crud` — 通过 `async_db_session` 异步 INSERT + SELECT
- `test_mock_redis_basic_ops``mock_redis.set("key", "val")``get("key")` 返回 `b"val"`
- `test_mock_minio_callable``mock_minio.put_object(...)` 不抛异常
- `test_mock_openfga_write_and_check``write_tuples``check` 返回 True,未写入的返回 False
- `test_mock_openfga_list_objects``write_tuples``list_objects` 返回正确列表
- `test_mock_openfga_assert_helpers``assert_tuple_exists` 正确断言、`assert_tuple_count` 正确计数
- `test_factory_create_tenant``create_tenant(session)` 创建记录,SELECT 验证字段值
- `test_test_client_health`(条件性)— 如果 T006 的 TestClient 可用,验证 `/health` 返回 200
**验证**: `pytest test/test_infrastructure_smoke.py -v` 全部 PASSED
**覆盖 AC**: AC-09, AC-10(通过同时运行 `pytest test/test_tenant_*.py` 验证回归)
**依赖**: T004, T005, T006, T007
- [x] **T009**: 前端 Platform Vitest 配置
**文件(修改)**:
- `src/frontend/platform/package.json` — 添加 devDependencies + scripts
**文件(新建)**:
- `src/frontend/platform/vitest.config.ts` — Vitest 配置
- `src/frontend/platform/src/test/setup.ts` — 测试 setup
- `src/frontend/platform/src/test/test-utils.tsx` — 自定义 render
- `src/frontend/platform/src/test/smoke.test.ts` — smoke test
**逻辑**:
- `package.json` devDependencies 添加:`vitest@^3.0`, `@testing-library/react@^16.0`, `@testing-library/jest-dom@^6.0`, `@testing-library/user-event@^14.0`, `@vitest/coverage-v8@^3.0`, `jsdom@^25.0`
- `package.json` scripts 添加:`"test": "vitest run"`, `"test:watch": "vitest"`, `"test:coverage": "vitest run --coverage"`
- `vitest.config.ts``mergeConfig(viteConfig, defineConfig({ test: { globals: true, environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'] } }))`
- `setup.ts``import '@testing-library/jest-dom'` + i18n mock(参考 Client 的 `test/setupTests.js` 模式)
- `test-utils.tsx`:自定义 `render` 函数包裹 `BrowserRouter`re-export `@testing-library/react` 全部导出
- `smoke.test.ts`:1 个基础测试验证 Vitest 运行正常
**验证**: `cd src/frontend/platform && npm install && npm test` smoke test PASSED
**覆盖 AC**: AC-11
**依赖**: 无(与后端任务独立)
---
## AC 覆盖追溯
| AC | 覆盖任务 |
|----|---------|
| AC-01 | T001pytest 配置)+ T002import chain 兼容) |
| AC-02 | T003DDL 定义)+ T004db_session fixture |
| AC-03 | T004async_db_session fixture |
| AC-04 | T005mock_redis fixture |
| AC-05 | T005mock_minio fixture |
| AC-06 | T005mock_openfga fixture |
| AC-07 | T006test_client fixture |
| AC-08 | T007create_tenant 工厂函数) |
| AC-09 | T008smoke test 全部通过) |
| AC-10 | T008F001 回归验证) |
| AC-11 | T009Vitest smoke test |
---
## 实际偏差记录
> 完成后,在此记录实现与 spec.md 的偏差,供后续参考。
- _(待实现后填写)_
@@ -1,44 +0,0 @@
# E2E 验证清单: F001 多租户核心基础设施
**测试环境**: http://192.168.106.114:4001 (Platform)
**前置条件**: 后端已运行 Alembic 迁移 + 重启
## 数据库验证
### AC-01: DDL 结构
- [ ] 连接 MySQL,执行 `SHOW CREATE TABLE tenant` — 确认字段/索引匹配 spec §5
- [ ] 执行 `SHOW CREATE TABLE user_tenant` — 确认 UniqueConstraint(user_id, tenant_id)
- [ ] 执行 `DESCRIBE flow` — 确认包含 `tenant_id INT NOT NULL DEFAULT 1` + `idx_flow_tenant_id`
### AC-02: 默认租户
- [ ] 执行 `SELECT * FROM tenant WHERE id=1` — 确认 tenant_code='default', status='active'
- [ ] 执行 `SELECT COUNT(*) FROM user_tenant WHERE tenant_id=1` — 应等于 `SELECT COUNT(*) FROM user`
### AC-03: 业务表 tenant_id
- [ ] 抽查 5 张表: `SELECT tenant_id FROM flow LIMIT 5` / `knowledge` / `assistant` / `chatmessage` / `role` — 全部为 1
## Platform 前端回归
### AC-07 + AC-11: 登录 + 基本功能
- [ ] 以 admin/admin123 登录 Platform — 登录成功,无异常
- [ ] 导航到"构建"页面 — 应用列表正常加载
- [ ] 导航到"知识库"页面 — 知识库列表正常加载
- [ ] 导航到"模型管理"页面 — 页面正常加载
- [ ] 创建一个测试工作流(名称: `e2e-f001-test-flow`)— 创建成功
- [ ] 删除该测试工作流 — 删除成功
- [ ] 打开浏览器 DevTools → Console — 无 JS 错误
### AC-07: WebSocket 验证
- [ ] 打开任意工作流/助手的对话页面 — WebSocket 连接成功(DevTools Network → WS 无断开)
- [ ] 发送一条消息 — 收到流式回复(确认 WS 中间件的 tenant context 不影响功能)
## Celery Worker 验证
### AC-09: 任务上下文传播
- [ ] 上传一个文件到知识库 — Celery worker 日志无 tenant_id 相关报错
- [ ] 文件处理完成(状态变为"成功")— 确认异步任务在 tenant 上下文下正常执行
## 回归检查
- [ ] 系统管理页面正常加载
- [ ] 非 admin 用户(如有)登录后功能正常
- [ ] 所有页面无 console 错误
@@ -1,323 +0,0 @@
# Feature: 多租户核心基础设施
> **前置步骤**:本文档编写前已完成 Spec Discovery(架构师提问),
> PRD 中的不确定性已与用户对齐。
**关联 PRD**: [2.5 多租户需求文档 §1-4](../../../docs/archive/2.5%20权限管理体系改造%20PRD/2.5%20多租户需求文档.md)
**优先级**: P0
**所属版本**: v2.5.0
---
## 范围界定
**IN**:
- Tenant ORMPRD §2.1 全字段含 quota_config JSON schema)、UserTenant ORM
- `current_tenant_id` ContextVar 定义 + `bypass_tenant_filter` 旁路机制
- SQLAlchemy event hooks:查询自动 `WHERE tenant_id=X`、写入自动填充
- JWT payload 扩展:增加 tenant_id 字段
- UserPayload/LoginUser:增加 tenant_id 字段
- Celery 任务 headers 写入/读取 tenant_id
- 存储隔离工具函数(MinIO/Milvus/ES/Redis 前缀逻辑)— 仅定义函数,不改调用点
- config.yaml 新增 `multi_tenant` 配置节
- DDL 迁移:创建 tenant/user_tenant 表,23+ 业务表加 tenant_id 列,创建默认租户(id=1),回填 tenant_id=1
- 错误码模块 200
- 最小 pytest conftest.pySQLite in-memory + settings mock
**OUT**:
- 租户 CRUD API 端点 → F010-tenant-management-ui
- 租户管理 UI → F010
- 登录流程变更 / 租户选择页 → F010
- 配额执行逻辑 → F005-role-menu-quota
- OpenFGA 租户元组写入 → F004-rebac-core
- 存储调用点改造(MinIO/Milvus/ES 现有代码修改)→ F008-resource-rebac-adaptation
**关联不变量**: INV-1, INV-6, INV-8, INV-9, INV-13, INV-14
---
## 1. 概述与用户故事
F001 是 v2.5.0 权限体系改造的地基。它在系统的每一层(数据库、请求上下文、异步任务、存储路径)植入租户感知能力,但本身不暴露任何新的 API 或 UI。所有后续 FeatureF002~F010)依赖 F001 提供的租户隔离保障。
**用户故事 1**:
作为 **BiSheng 运维人员**
我希望 **所有业务数据自动按 tenant_id 隔离**
以便 **后续多租户功能(F010)可以依赖数据库级别的隔离保障,无需逐模块手动添加 WHERE 条件**
**用户故事 2**:
作为 **BiSheng 开发者**
我希望 **Celery 异步任务自动继承 HTTP 请求的租户上下文**
以便 **知识库文件解析、工作流执行等异步任务在正确的租户隔离下运行**
**用户故事 3**:
作为 **BiSheng 运维人员**
我希望 **升级到 v2.5.0 时所有存量数据自动归入默认租户(id=1)且存储路径不变**
以便 **零迁移成本升级,不影响生产环境已有的知识库文件和向量数据**
---
## 2. 验收标准
> AC-ID 在本特性内唯一,格式 `AC-NN`。
> tasks.md 中的测试任务必须通过 `覆盖 AC: AC-NN` 追溯到此表。
| ID | 角色 | 操作 | 预期结果 |
|----|------|------|---------|
| AC-01 | 运维人员 | 执行 DDL 迁移 | `tenant` 表和 `user_tenant` 表按 PRD §2.1 DDL 创建,字段类型、索引、约束完全匹配 |
| AC-02 | 运维人员 | 首次启动应用 | 自动创建默认租户(id=1, tenant_code="default", tenant_name="Default Tenant", status="active"),所有现有用户自动获得 user_tenant 关联(tenant_id=1, is_default=1) |
| AC-03 | 运维人员 | 执行 DDL 迁移 | 23+ 业务表均包含 `tenant_id INT UNSIGNED NOT NULL DEFAULT 1` 列和 `idx_tenant_id` 索引,存量数据 tenant_id=1 |
| AC-04 | 开发者 | 执行 `session.exec(select(Flow))` | 返回结果自动附加 `WHERE tenant_id=当前上下文值`,不返回其他租户的数据 |
| AC-05 | 开发者 | 执行 `session.add(Flow(...))` 且未手动设置 tenant_id | 记录自动填充 tenant_id=当前上下文值 |
| AC-06 | 开发者 | 在 `with bypass_tenant_filter():` 上下文中执行 SELECT | 查询不附加 tenant_id 过滤,返回所有租户数据 |
| AC-07 | 用户 | 使用含 tenant_id 的新版 JWT 通过 HTTP 请求和 WebSocket 连接访问;使用不含 tenant_id 的旧版 JWT 访问 | 新版 JWT 正确提取 tenant_id 并设置 ContextVarHTTP 中间件和 WebSocket 中间件均生效);旧版 JWT 自动回退到 tenant_id=1,不报错 |
| AC-08 | 开发者 | 在 endpoint handler 中访问 `login_user.tenant_id` | 返回当前请求的 tenant_id(从 JWT 解析) |
| AC-09 | 开发者 | 通过 `.delay()``.apply_async()` 派发 Celery 任务 | 任务 headers 中包含 `tenant_id`Worker 执行时 `get_current_tenant_id()` 返回正确的 tenant_id |
| AC-10 | 开发者 | 调用 `get_minio_prefix(tenant_id=1, tenant_code="default")` | 返回空字符串 `""`(默认租户零前缀)。调用 `get_minio_prefix(tenant_id=2, tenant_code="cofco")` 返回 `"tenant_cofco/"` |
| AC-11 | 运维人员 | 配置 `multi_tenant.enabled=false` 启动系统 | 行为与现有单租户系统完全一致:自动使用默认租户,无租户选择,SELECT 自动附加 `WHERE tenant_id=1` |
---
## 3. 边界情况
-**multi_tenant.enabled=true 但请求中无 tenant 上下文**(无 JWT cookie 或 JWT 中无 tenant_id)时,系统应对需要认证的端点返回 401,公开端点(如 `/health`)正常响应
- 当 **系统管理员需要跨租户查询**时,代码必须显式使用 `bypass_tenant_filter()` 上下文管理器,否则仍受租户过滤约束
- 当 **Celery Beat 定时任务执行**时(无 HTTP 请求上下文),默认使用 `DEFAULT_TENANT_ID=1`。多租户启用后,需要遍历所有活跃租户逐个执行的场景由 F010 处理
- 当 **raw SQL`text()`)查询**时,SQLAlchemy ORM 事件不会拦截。这是已知限制,raw SQL 使用者须自行添加 `WHERE tenant_id=X`。F001 在代码中添加注释文档说明此限制
- 当 **已有 JWT token 无 tenant_id 字段**时(升级过渡期),系统通过 `subject.get('tenant_id', DEFAULT_TENANT_ID)` 回退到默认租户,不中断现有会话
- **不支持**:运行时动态切换租户隔离策略(延后到 v3.x)
- **不支持**:物理隔离(独立数据库 per tenant)(延后到 v3.x
---
## 4. 架构决策
| ID | 决策 | 选项 | 结论 | 理由 |
|----|------|------|------|------|
| AD-01 | SQLAlchemy 租户过滤实现方式 | A: `do_orm_execute` 事件(查询拦截)+ `before_flush`(写入填充) / B: Session `execution_options` + 自定义编译扩展 | 选 A | `do_orm_execute` 与 SQLModel 的 `session.exec(select(...))` 直接兼容,拦截点明确,实现透明。方案 B 过于底层,调试困难 |
| AD-02 | 系统管理员跨租户行为 | A: 系统管理员始终在某个租户上下文中操作,跨租户端点显式绕过 / B: 系统管理员不设 tenant 上下文 | 选 A | 符合 PRD §5.5.2(系统管理员始终在某个租户上下文中操作),统一了代码路径。方案 B 会导致所有 DAO 查询需要额外处理 NULL tenant 分支 |
| AD-03 | 默认租户存储路径 | A: 默认租户(id=1)保留原路径,新租户加前缀 / B: 所有租户统一加前缀 | 选 A | 零迁移兼容(INV-9)。存量 MinIO 文件、Milvus collection、ES index 无需移动/重命名 |
---
## 5. 数据库 & Domain 模型
### 数据库表定义
#### tenant 表
```python
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, DateTime, String, Integer, JSON, text
from sqlmodel import Field
from bisheng.common.models.base import SQLModelSerializable
class Tenant(SQLModelSerializable, table=True):
__tablename__ = "tenant"
id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, primary_key=True, autoincrement=True)
)
tenant_code: str = Field(
sa_column=Column(String(64), nullable=False, unique=True, comment="租户编码")
)
tenant_name: str = Field(
sa_column=Column(String(128), nullable=False, comment="租户名称")
)
logo: Optional[str] = Field(
default=None,
sa_column=Column(String(512), nullable=True, comment="租户 Logo URL")
)
root_dept_id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, nullable=True, comment="根部门 ID")
)
status: str = Field(
default="active",
sa_column=Column(String(16), nullable=False, server_default=text("'active'"),
index=True, comment="状态: active/disabled/archived")
)
contact_name: Optional[str] = Field(
default=None,
sa_column=Column(String(64), nullable=True, comment="联系人姓名")
)
contact_phone: Optional[str] = Field(
default=None,
sa_column=Column(String(32), nullable=True, comment="联系人电话")
)
contact_email: Optional[str] = Field(
default=None,
sa_column=Column(String(128), nullable=True, comment="联系人邮箱")
)
quota_config: Optional[dict] = Field(
default=None,
sa_column=Column(JSON, nullable=True, comment="租户级资源配额")
)
storage_config: Optional[dict] = Field(
default=None,
sa_column=Column(JSON, nullable=True, comment="租户级存储配置覆盖")
)
create_user: Optional[int] = Field(
default=None,
sa_column=Column(Integer, nullable=True, comment="创建人")
)
create_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text("CURRENT_TIMESTAMP"))
)
update_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"))
)
```
#### user_tenant 表
```python
class UserTenant(SQLModelSerializable, table=True):
__tablename__ = "user_tenant"
id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, primary_key=True, autoincrement=True)
)
user_id: int = Field(sa_column=Column(Integer, nullable=False, index=True))
tenant_id: int = Field(sa_column=Column(Integer, nullable=False, index=True))
is_default: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default=text("0"),
comment="是否为用户的默认租户")
)
status: str = Field(
default="active",
sa_column=Column(String(16), nullable=False, server_default=text("'active'"),
comment="状态: active/disabled")
)
last_access_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=True, comment="最后访问时间")
)
join_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text("CURRENT_TIMESTAMP"))
)
__table_args__ = (
UniqueConstraint("user_id", "tenant_id", name="uk_user_tenant"),
)
```
### DAO 方法
| 类 | 方法 | 说明 |
|----|------|------|
| TenantDao | `get_by_id(id)` / `aget_by_id(id)` | 按 ID 查询租户 |
| TenantDao | `get_by_code(code)` / `aget_by_code(code)` | 按编码查询租户 |
| TenantDao | `create_tenant(tenant)` / `acreate_tenant(tenant)` | 创建租户 |
| UserTenantDao | `get_user_tenants(user_id)` / `aget_user_tenants(user_id)` | 获取用户所属租户列表 |
| UserTenantDao | `get_user_default_tenant(user_id)` | 获取用户默认租户 |
| UserTenantDao | `add_user_to_tenant(user_id, tenant_id)` | 添加用户到租户 |
### 业务表 tenant_id 字段
23+ 业务表添加 `tenant_id INT UNSIGNED NOT NULL DEFAULT 1` + `INDEX idx_tenant_id (tenant_id)`
具体表清单见 PRD §2.3。不加 tenant_id 的表:`user`, `user_link`, `tenant`, `user_tenant`, `recall_chunk`, `failed_tuples`, `relation_definition`
---
## 6. API 契约
N/A — F001 不新增 API 端点。租户 CRUD API 由 F010 负责。
---
## 7. Service 层逻辑
N/A — F001 是纯基础设施 Feature,不包含业务 Service。核心逻辑分布在:
| 组件 | 文件 | 职责 |
|------|------|------|
| TenantContextVar | `core/context/tenant.py` | ContextVar 定义、getter/setter、bypass 机制 |
| TenantFilter | `core/database/tenant_filter.py` | SQLAlchemy 事件钩子(查询过滤 + 写入填充) |
| TenantStorage | `core/storage/tenant_storage.py` | 存储路径前缀工具函数 |
| TenantCeleryContext | `worker/tenant_context.py` | Celery 信号处理(headers 写入/读取) |
---
## 8. 前端设计
N/A — F001 不涉及前端变更。
---
## 9. 文件清单
### 新建
| 文件 | 说明 |
|------|------|
| `src/backend/bisheng/database/models/tenant.py` | Tenant + UserTenant ORM 模型 + TenantDao + UserTenantDao |
| `src/backend/bisheng/core/context/tenant.py` | `current_tenant_id` ContextVar、getter/setter、bypass 上下文管理器 |
| `src/backend/bisheng/core/database/tenant_filter.py` | SQLAlchemy `do_orm_execute` + `before_flush` 事件钩子 |
| `src/backend/bisheng/core/config/multi_tenant.py` | `MultiTenantConf` Pydantic 配置模型 |
| `src/backend/bisheng/core/storage/tenant_storage.py` | MinIO/Milvus/ES/Redis 前缀工具函数 |
| `src/backend/bisheng/common/errcode/tenant.py` | 200xx 租户错误码 |
| `src/backend/bisheng/worker/tenant_context.py` | Celery `before_task_publish` / `task_prerun` 信号处理 |
| Alembic 迁移脚本 | DDL: 创建 tenant/user_tenant 表 + 23+ 表添加 tenant_id |
| `src/backend/test/conftest.py` | 最小 pytest fixtureSQLite in-memory + settings mock |
| `src/backend/test/test_tenant_context.py` | ContextVar + bypass 机制测试 |
| `src/backend/test/test_tenant_filter.py` | SQLAlchemy 事件钩子集成测试 |
| `src/backend/test/test_tenant_storage.py` | 存储前缀函数单元测试 |
| `src/backend/test/test_celery_tenant.py` | Celery 租户上下文传播测试 |
### 修改
| 文件 | 变更内容 |
|------|---------|
| `src/backend/bisheng/core/config/settings.py` | 添加 `multi_tenant: MultiTenantConf = MultiTenantConf()` 字段 |
| `src/backend/bisheng/user/domain/services/auth.py` | LoginUser 添加 `tenant_id` 字段;JWT payload 添加 `tenant_id``init_login_user` 接受 `tenant_id``get_login_user` 从 subject 提取 `tenant_id` |
| `src/backend/bisheng/utils/http_middleware.py` | `CustomMiddleware.dispatch` 解析 JWT cookie 提取 tenant_id 并设置 ContextVar`WebSocketLoggingMiddleware` 同理 |
| `src/backend/bisheng/core/database/manager.py` | 引擎创建后调用 `register_tenant_filter_events(engine)` |
| `src/backend/bisheng/common/init_data.py` | 添加 `init_default_tenant()` 函数调用 |
| `src/backend/bisheng/config.yaml` | 添加 `multi_tenant:` 配置节(enabled: false, default_tenant_code: "default" |
---
## 10. 非功能要求
- **性能**: SQLAlchemy 事件钩子增加的延迟 < 1ms/查询。存储前缀函数为纯字符串操作,无网络开销
- **安全**: tenant_id 过滤是数据隔离的安全底线。`multi_tenant.enabled=true` 时,无 tenant 上下文的 ORM 查询将抛出 `NoTenantContextError`fail-closed 防止数据泄漏
- **兼容性**: 默认租户(id=1)零迁移兼容。升级后所有存量数据 tenant_id=1,所有存储路径不变。旧 JWT token 自动回退到 tenant_id=1
- **可测试性**: 通过 `bypass_tenant_filter()` 上下文管理器,测试代码可以在不设置 tenant 上下文时操作数据库
---
## 11. 错误码表
> 模块编码 200tenant),已在 release-contract.md 注册。
| HTTP Status | MMMEE Code | Error Class | 场景 | 关联 AC |
|-------------|------------|-------------|------|---------|
| 200 (body) | 20000 | TenantNotFoundError | 指定的租户 ID 或编码不存在 | — |
| 200 (body) | 20001 | TenantDisabledError | 租户已被禁用 | — |
| 200 (body) | 20002 | UserNotInTenantError | 用户不属于当前租户 | — |
| 200 (body) | 20003 | TenantCodeDuplicateError | 租户编码重复 | — |
| 200 (body) | 20004 | NoTenantContextError | 请求中缺少租户上下文(multi_tenant.enabled=true 时) | AC-11 |
---
## 相关文档
- 版本契约: [features/v2.5.0/release-contract.md](../release-contract.md)
- 多租户需求文档: `docs/archive/2.5 权限管理体系改造 PRD/2.5 多租户需求文档.md`
- 技术方案: `docs/archive/2.5 权限管理体系改造 PRD/2.5 技术方案.md`
- 权限改造 PRD: `docs/archive/2.5 权限管理体系改造 PRD/2.5 权限管理体系改造 PRD.md`
@@ -1,310 +0,0 @@
# Tasks: 多租户核心基础设施
**关联规格**: [spec.md](./spec.md)
**版本**: v2.5.0
---
## 状态
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | ✅ 已评审 | 14 项检查通过,3 个问题已修复 |
| tasks.md | ✅ 已拆解 | 21 项检查通过(Round 2),9 个任务 |
| 实现 | ✅ 已完成 | 9 / 9 完成 |
---
## 开发模式
**后端 Test-First(务实版)**
- 理想流程:先写测试(红),再写实现(绿)
- 务实适配:当前项目测试基础薄弱(无 conftest/fixtures),T001 首先搭建最小 pytest 基础设施(conftest.py、SQLite fixture
- 如果某任务的测试编写成本极高(如需要完整的 Milvus/ES mock),标注 `**测试降级**: 手动验证 + TODO 标记`
**前端 Test-Alongside(暂缓版)**
- F001 不涉及前端,无前端测试
**自包含任务**:每个任务内联文件、逻辑、测试上下文,实现阶段不需要回读 spec.md。
---
## 依赖图
```
T001 (conftest + 配置)
v
T002 (ORM + DAO + 错误码)
v
T003 (ContextVar + Bypass)
├──────────┬──────────┐
v v v
T004 T005 T007
(SQLAlchemy (JWT+Auth) (Celery)
事件钩子)
│ │
v v
└────┬─────┘
v
T006 (HTTP/WS 中间件)
v
T008 (存储前缀函数)
v
T009 (DDL 迁移 + 默认数据)
```
---
## Tasks
### 基础设施
- [x] **T001**: 测试基础设施 + MultiTenantConf 配置
**文件(新建)**:
- `src/backend/test/conftest.py` — 最小 pytest fixtureSQLite in-memory engine + sync/async session + settings mock
- `src/backend/bisheng/core/config/multi_tenant.py``MultiTenantConf(BaseModel)``enabled: bool = False`, `default_tenant_code: str = "default"`
**文件(修改)**:
- `src/backend/bisheng/core/config/settings.py` — Settings 类添加 `multi_tenant: MultiTenantConf = MultiTenantConf()`
**逻辑**:
- conftest.py 提供 `db_engine` fixtureSQLite in-memory)、`sync_session` / `async_session` fixture、`mock_settings` fixture
- `MultiTenantConf` 定义两个字段 + config.yaml 不在此任务修改(T009 迁移时一并加入)
**覆盖 AC**: —(基础设施,无直接 AC
**依赖**: 无
- [x] **T002**: Tenant/UserTenant ORM + DAO + 错误码
**文件(新建)**:
- `src/backend/bisheng/database/models/tenant.py` — Tenant + UserTenant SQLModel 表定义 + TenantDao + UserTenantDao
- `src/backend/bisheng/common/errcode/tenant.py` — 200xx 错误码
- `src/backend/test/test_tenant_dao.py` — DAO 单元测试
**逻辑**:
- `Tenant` 表:id(INT PK), tenant_code(VARCHAR 64 UNIQUE), tenant_name(VARCHAR 128), logo, root_dept_id, status(active/disabled/archived), contact_name/phone/email, quota_config(JSON), storage_config(JSON), create_user, create_time, update_time
- `UserTenant` 表:id(INT PK), user_id, tenant_id, is_default, status, last_access_time, join_time。UniqueConstraint(user_id, tenant_id)
- `TenantDao` classmethods: `get_by_id`/`aget_by_id`, `get_by_code`/`aget_by_code`, `create_tenant`/`acreate_tenant`
- `UserTenantDao` classmethods: `get_user_tenants`/`aget_user_tenants`, `get_user_default_tenant`, `add_user_to_tenant`/`aadd_user_to_tenant`
- 错误码类继承 `BaseErrorCode`,模块编码 200,编号 20000~20004TenantNotFoundError, TenantDisabledError, UserNotInTenantError, TenantCodeDuplicateError, NoTenantContextError
**测试**: `test_tenant_dao.py``test_create_tenant`, `test_get_by_code`, `test_create_user_tenant`, `test_unique_constraint`, `test_get_user_tenants`
**覆盖 AC**: AC-01
**依赖**: T001conftest fixture
---
### 后端核心基础设施(Test-First 配对)
- [x] **T003**: 租户 ContextVar + Bypass 机制
**文件(新建)**:
- `src/backend/bisheng/core/context/tenant.py` — ContextVar 定义 + 工具函数
- `src/backend/test/test_tenant_context.py` — ContextVar 单元测试
**逻辑**:
- `DEFAULT_TENANT_ID: int = 1` 常量
- `current_tenant_id: ContextVar[Optional[int]] = ContextVar("current_tenant_id", default=None)`
- `get_current_tenant_id() -> Optional[int]`: 读取 ContextVar
- `set_current_tenant_id(tenant_id: int) -> Token`: 设置 ContextVar,返回 token 用于 reset
- `_bypass_tenant_filter: ContextVar[bool] = ContextVar("_bypass_tenant_filter", default=False)`
- `bypass_tenant_filter()`: contextmanager,进入时设 True,退出时 reset
- `is_tenant_filter_bypassed() -> bool`: 读取 bypass 状态
**测试**: `test_tenant_context.py`
- `test_default_is_none` — 未设置时返回 None
- `test_set_get` — 设置后读取正确值
- `test_bypass_context_manager` — 进入 bypass 后 is_bypassed=True,退出后 False
- `test_bypass_nested` — 嵌套 bypass 正确恢复
- `test_async_isolation` — 不同 asyncio task 间 ContextVar 隔离
**覆盖 AC**: AC-06
**依赖**: T001
- [x] **T004**: SQLAlchemy 事件钩子(租户自动过滤 + 自动填充)
**文件(新建)**:
- `src/backend/bisheng/core/database/tenant_filter.py` — 事件注册 + 处理函数
- `src/backend/test/test_tenant_filter.py` — 集成测试(SQLite in-memory
**文件(修改)**:
- `src/backend/bisheng/core/database/manager.py` — 引擎创建后调用 `register_tenant_filter_events(engine)`
**逻辑**:
- `_tenant_aware_tables: Set[str]` — 启动时通过 `SQLModel.metadata.tables` 自动发现含 `tenant_id` 列的表名集合(不硬编码)
- `register_tenant_filter_events(sync_engine, async_engine)` — 注册以下两个事件
- **`do_orm_execute` 事件**(查询拦截):
1. 检查 `is_tenant_filter_bypassed()` → True 则跳过
2. 检查 `orm_execute_state.is_select` → 非 SELECT 跳过
3. 检查语句涉及的表是否在 `_tenant_aware_tables`
4. 获取 `get_current_tenant_id()` → None 时:`multi_tenant.enabled=false` 用 DEFAULT_TENANT_ID`enabled=true``NoTenantContextError`
5. 修改 statement`orm_execute_state.statement = statement.where(table.c.tenant_id == tid)`
- **`before_flush` 事件**(写入填充):
1. 遍历 `session.new`(新增对象)
2. 如果对象的表名在 `_tenant_aware_tables` 中且 `tenant_id` 为 None 或 0
3. 设置 `obj.tenant_id = get_current_tenant_id() or DEFAULT_TENANT_ID`
- **已知限制**: `text()` raw SQL 不触发 ORM 事件,在函数 docstring 中注明
**测试**: `test_tenant_filter.py`(使用 conftest.py 的 SQLite session
- `test_select_auto_filter` — 插入两条不同 tenant_id 的记录,设置 context=1SELECT 只返回 tenant_id=1 的 → AC-04
- `test_insert_auto_fill` — 不设 tenant_id 创建记录,验证自动填充 → AC-05
- `test_bypass_returns_all` — bypass 上下文中 SELECT 返回所有记录 → AC-06
- `test_no_context_enabled_raises` — enabled=true 且无 context 时 SELECT 抛 NoTenantContextError → AC-11
- `test_no_context_disabled_uses_default` — enabled=false 且无 context 时使用 DEFAULT_TENANT_ID → AC-11
- `test_non_tenant_table_unaffected` — 无 tenant_id 列的表不受过滤影响
**覆盖 AC**: AC-04, AC-05, AC-06, AC-11
**依赖**: T003
- [x] **T005**: JWT Payload 扩展 + LoginUser/UserPayload 改造
**文件(修改)**:
- `src/backend/bisheng/user/domain/services/auth.py`
**文件(新建)**:
- `src/backend/test/test_tenant_auth.py` — JWT + LoginUser 测试
**逻辑**:
- `LoginUser` 添加字段: `tenant_id: int = Field(default=1, description="Current tenant ID")`
- `LoginUser.__init__` 中: `self.tenant_id = kwargs.get('tenant_id', DEFAULT_TENANT_ID)`
- `LoginUser.create_access_token(user, auth_jwt, tenant_id=None)`: payload dict 加 `'tenant_id': tenant_id or DEFAULT_TENANT_ID`
- `LoginUser.init_login_user(user_id, user_name, tenant_id=DEFAULT_TENANT_ID)` / `init_login_user_sync`: 接受并传递 tenant_id
- `LoginUser.get_login_user(auth_jwt)`: 从 `subject.get('tenant_id', DEFAULT_TENANT_ID)` 提取
- `LoginUser.get_login_user_from_ws(websocket, auth_jwt, t)`: 同上
- `LoginUser.get_admin_user` / `get_admin_user_from_ws`: 同步传递 tenant_id
- `UserPayload`(继承 LoginUser)自动获得 `tenant_id` 字段,无需额外修改
**测试**: `test_tenant_auth.py`
- `test_jwt_encode_with_tenant_id` — 编码含 tenant_id 的 JWT,解码验证 → AC-07
- `test_jwt_decode_old_token_fallback` — 解码不含 tenant_id 的旧 JWTtenant_id 默认为 1 → AC-07
- `test_login_user_has_tenant_id` — 构造 LoginUser 后访问 tenant_id → AC-08
- `test_init_login_user_passes_tenant_id` — init_login_user 传入 tenant_id=2,验证实例中值为 2
**覆盖 AC**: AC-07, AC-08
**依赖**: T003
- [x] **T006**: HTTP/WS 中间件 — 租户上下文注入
**文件(修改)**:
- `src/backend/bisheng/utils/http_middleware.py`
**逻辑**:
- `CustomMiddleware.dispatch` 中,在 `trace_id_var.set(trace_id)` 之后:
1. 尝试从 `request.cookies.get("access_token_cookie")` 获取 JWT token
2. 如果有 token`AuthJwt(req=request).decode_jwt_token(token)` → 提取 `subject.get('tenant_id', DEFAULT_TENANT_ID)`
3. 调用 `set_current_tenant_id(tenant_id)`
4. 如果无 token 且 `multi_tenant.enabled=false``set_current_tenant_id(DEFAULT_TENANT_ID)`
5. 如果无 token 且 `multi_tenant.enabled=true`:不设 ContextVar(公开端点如 /health 不需要)
6. JWT 解析异常时静默跳过(认证失败由 endpoint 层的 Depends 处理)
- `WebSocketLoggingMiddleware.__call__` 中,在 `trace_id_var.set(trace_id)` 之后:
1.`scope.get("headers")` 或 cookies 提取 JWT token
2. 同样解析 tenant_id 并设置 ContextVar
**测试降级**: 中间件依赖完整的 ASGI 栈,集成测试在 T009 后通过端到端手动验证确认
**手动验证**:
- 启动后端,登录后在浏览器中发起 API 请求
- 在 endpoint handler 中打断点验证 `get_current_tenant_id()` 返回值
- 建立 WebSocket 连接,验证 ContextVar 正确设置
**覆盖 AC**: AC-07(运行时验证)
**依赖**: T003, T005
- [x] **T007**: Celery 租户上下文传播
**文件(新建)**:
- `src/backend/bisheng/worker/tenant_context.py` — 信号处理函数
- `src/backend/test/test_celery_tenant.py` — mock 信号测试
**文件(修改)**:
- `src/backend/bisheng/worker/main.py` — 导入 tenant_context 模块以触发信号注册
**逻辑**:
- `@before_task_publish.connect` 信号:
```python
def inject_tenant_header(headers=None, **kwargs):
tid = get_current_tenant_id()
if tid is not None and headers is not None:
headers['tenant_id'] = tid
```
- `@task_prerun.connect` 信号:
```python
def restore_tenant_context(sender=None, **kwargs):
request = sender.request
tenant_id = (getattr(request, 'headers', None) or {}).get('tenant_id')
if tenant_id is not None:
set_current_tenant_id(int(tenant_id))
else:
set_current_tenant_id(DEFAULT_TENANT_ID)
```
- `@task_postrun.connect` 信号: reset ContextVar 避免线程池复用时泄漏
- 在 `worker/main.py` 添加 `import bisheng.worker.tenant_context` 确保信号注册
**测试**: `test_celery_tenant.py` —
- `test_inject_tenant_header` — mock headers dict,设置 ContextVar=2,调用信号函数,验证 headers['tenant_id']=2 → AC-09
- `test_restore_tenant_context` — mock sender.request.headers={'tenant_id': 3},调用信号函数,验证 get_current_tenant_id()=3 → AC-09
- `test_no_header_uses_default` — headers 中无 tenant_id,验证回退到 DEFAULT_TENANT_ID → AC-09
- `test_postrun_resets_context` — 执行后 ContextVar 被 reset
**覆盖 AC**: AC-09
**依赖**: T003
---
### 存储与迁移
- [x] **T008**: 存储前缀工具函数
**文件(新建)**:
- `src/backend/bisheng/core/storage/tenant_storage.py` — 前缀工具函数
- `src/backend/test/test_tenant_storage.py` — 前缀函数单元测试
**逻辑**:
- `get_minio_prefix(tenant_id: int, tenant_code: str) -> str`: 默认租户→`""`,新租户→`"tenant_{tenant_code}/"`
- `get_milvus_collection_prefix(tenant_id: int) -> str`: 默认→`""`,新→`"t{tenant_id}_"`
- `get_es_index_prefix(tenant_id: int) -> str`: 默认→`""`,新→`"t{tenant_id}_"`
- `get_redis_key_prefix(tenant_id: int) -> str`: 默认→`""`,新→`"t:{tenant_id}:"`
**测试**: `test_tenant_storage.py` —
- `test_minio_prefix_default_tenant` — get_minio_prefix(1, "default") == "" → AC-10
- `test_minio_prefix_new_tenant` — get_minio_prefix(2, "cofco") == "tenant_cofco/" → AC-10
- `test_milvus_prefix_default` — get_milvus_collection_prefix(1) == "" → AC-10
- `test_milvus_prefix_new` — get_milvus_collection_prefix(2) == "t2_" → AC-10
- `test_es_prefix_default` — get_es_index_prefix(1) == "" → AC-10
- `test_es_prefix_new` — get_es_index_prefix(3) == "t3_" → AC-10
- `test_redis_prefix_default` — get_redis_key_prefix(1) == "" → AC-10
- `test_redis_prefix_new` — get_redis_key_prefix(2) == "t:2:" → AC-10
**覆盖 AC**: AC-10
**依赖**: T003
- [x] **T009**: DDL 迁移 + 默认租户初始化 + config.yaml
**文件(新建)**:
- `src/backend/bisheng/core/database/alembic/versions/v2_5_0_f001_multi_tenant_{hash}.py` — Alembic 迁移脚本
**文件(修改)**:
- `src/backend/bisheng/common/init_data.py` — `init_default_data()` 中调用 `init_default_tenant()`
- `src/backend/bisheng/config.yaml` — 添加 `multi_tenant:` 配置节(enabled: false, default_tenant_code: "default"
**逻辑**:
- **DDL upgrade**:
1. CREATE TABLE `tenant`(按 spec §5 DDL
2. CREATE TABLE `user_tenant`(按 spec §5 DDL
3. 对 23+ 业务表: `ALTER TABLE {table} ADD COLUMN tenant_id INT UNSIGNED NOT NULL DEFAULT 1; ALTER TABLE {table} ADD INDEX idx_tenant_id (tenant_id);`
4. 业务表清单: flow, flow_version, assistant, assistant_link, knowledge, gpts_tools, channel, t_report, evaluation, dataset, session, chat_message, tag, tag_link, template, t_variable_value, `group`, role, role_access, audit_log, mark_task, mark_record, mark_app_user, invite_code
5. INSERT 默认租户: `(id=1, tenant_code='default', tenant_name='Default Tenant', status='active')`
6. 回填 user_tenant: `INSERT INTO user_tenant (user_id, tenant_id, is_default, status) SELECT user_id, 1, 1, 'active' FROM user`
- **DDL downgrade**(回滚方案):
1. 对 23+ 业务表: `ALTER TABLE {table} DROP INDEX idx_tenant_id; ALTER TABLE {table} DROP COLUMN tenant_id;`
2. DROP TABLE `user_tenant`
3. DROP TABLE `tenant`
- 注意:回滚会丢失新租户数据(tenant_id>1 的记录的 tenant_id 信息不可恢复),仅适用于升级失败时紧急回退
- **init_default_tenant()** (`init_data.py`):
- 幂等检查:如果 `Tenant(id=1)` 不存在则创建
- 检查 user 表中无 user_tenant 关联的用户,补充 user_tenant(tenant_id=1) 记录
- 在 `init_default_data()` 函数的表创建之后、角色初始化之前调用
**手动验证**:
- 在远程服务器 192.168.106.114 上运行迁移脚本
- 验证 `SELECT * FROM tenant` 返回默认租户 → AC-02
- 验证 `SELECT tenant_id FROM flow LIMIT 5` 全部为 1 → AC-03
- 验证 `SELECT COUNT(*) FROM user_tenant` 等于 user 表总数 → AC-02
**覆盖 AC**: AC-02, AC-03
**依赖**: T002, T003, T004
---
## AC 覆盖追溯
| AC | 覆盖任务 |
|----|---------|
| AC-01 | T002ORM 模型定义 + DAO 测试) |
| AC-02 | T009(默认租户初始化 + DDL 迁移) |
| AC-03 | T009DDL 迁移 23+ 表) |
| AC-04 | T004do_orm_execute 事件钩子) |
| AC-05 | T004before_flush 事件钩子) |
| AC-06 | T003bypass 机制)+ T004(集成验证) |
| AC-07 | T005JWT 编解码)+ T006(中间件运行时) |
| AC-08 | T005LoginUser.tenant_id 字段) |
| AC-09 | T007Celery 信号) |
| AC-10 | T008(存储前缀函数) |
| AC-11 | T004enabled=false/true 行为分支) |
---
## 实际偏差记录
> 完成后,在此记录实现与 spec.md 的偏差,供后续参考。
1. **DDL 迁移表数量扩展**: spec 提到 "23+ 业务表",实际迁移覆盖了 46 张表(包含 DDD 模块中的 knowledge/tool/channel/finetune/linsight/llm/message/share_link 等),遵循 INV-1 "所有业务表必须含 tenant_id"
2. **T006 测试降级**: HTTP/WS 中间件测试降级为手动验证(需要完整 ASGI 栈),符合 tasks.md 预期
3. **tenant_filter 事件注册**: 采用 Session 类级别全局注册(而非 Engine 级别),更简洁且与 SQLModel 兼容性更好
4. **_extract_tenant_id_from_token**: 在 http_middleware.py 中提取了一个辅助函数,避免在 HTTP 和 WS 两处重复 JWT 解码逻辑
-587
View File
@@ -1,587 +0,0 @@
# Feature: 部门树
> **前置步骤**:本文档编写前已完成 Spec Discovery(架构师提问),
> PRD 中的不确定性已与用户对齐。
**关联 PRD**: [2.5 权限管理体系改造 PRD §3](../../docs/archive/2.5%20权限管理体系改造%20PRD/2.5%20权限管理体系改造%20PRD.md)
**优先级**: P0
**所属版本**: v2.5.0
---
## 范围界定
**IN**:
- Department ORM(物化路径 path、dept_id、external_id、source、status、default_role_ids、tenant_id
- UserDepartment ORMuser_id、department_id、is_primary、source
- Department CRUD API(创建/详情/树形查询/更新/归档/移动/成员增删/成员列表)共 9 个端点
- 物化路径维护逻辑(创建/移动/删除时自动更新 path)
- 提供 `create_root_department(tenant_id, name)` 服务方法供租户创建调用
- init_data 为默认租户(id=1)自动创建根部门
- DepartmentChangeHandlerTupleOperation DTO 定义 + 事件方法产出元组列表 + execute() 日志 stub
- 错误码模块 21021000~21009
**OUT**:
- 前端部门管理页面 → F010-tenant-management-ui
- 三方组织同步 → F009-org-syncP2
- 部门管理员(adminCRUD → F004-rebac-coreadmin 关系存 OpenFGA
- 部门 admin OpenFGA 元组实际写入 → 委托 F004-rebac-core 的 PermissionService
- 复杂授权 UI → F007-resource-permission-ui
**关键决策(预判)**:
- AD-01: 物化路径格式 `/1/2/3/`,子树查询用 `LIKE '/1/2/%'`
- AD-02: DepartmentChangeHandler 产出元组操作列表但不直接写 OpenFGA,委托 PermissionServiceF004),尊重领域归属边界
- AD-03: 根部门创建是租户创建的同步副作用,F002 提供 service 方法,F001/F010 调用
- AD-04: ORM 放 `database/models/department.py`,与 TenantF001)一致
- AD-05: 权限检查暂用系统管理员判断(`login_user.is_admin()`),F004 后替换为 OpenFGA 检查
- AD-06: path 两阶段写入(INSERT → UPDATE path),因 path 包含自身 auto_increment id
- AD-07: UserDepartment 不加 tenant_id,隔离通过 Department.tenant_id 传递
**关键文件(预判)**:
- 新建: `src/backend/bisheng/database/models/department.py`
- 新建: `src/backend/bisheng/department/`DDD 模块:api/ + domain/
- 新建: `src/backend/bisheng/common/errcode/department.py`
- 修改: `src/backend/bisheng/api/router.py`(路由注册)
- 修改: `src/backend/bisheng/common/init_data.py`(默认根部门创建)
**关联不变量**: INV-1, INV-12, INV-14
---
## 1. 概述与用户故事
F002 为 BiSheng 引入组织架构的核心数据结构——部门树。部门树使用物化路径存储,支持无限层级、高效子树查询,是后续 ReBAC 权限(F004)、角色配额(F005)、资源授权管理(F007/F008)的基础。
无论多租户是否启用,部门功能始终可用。单租户模式下默认租户(id=1)拥有一棵组织树;多租户模式下每个租户各有独立的组织树。
**用户故事 1**:
作为 **BiSheng 系统管理员**
我希望 **能创建、编辑、移动和归档部门,构建树形组织架构**
以便 **将用户按组织结构分组管理,后续为部门级别分配资源访问权限**
**用户故事 2**:
作为 **BiSheng 系统管理员**
我希望 **能批量将用户添加到部门并管理主/挂靠关系**
以便 **每个用户都有明确的组织归属,支持一人多部门场景**
**用户故事 3**:
作为 **BiSheng 后续 Feature 开发者**
我希望 **F002 提供 `create_root_department()` 服务方法和 DepartmentChangeHandler 元组 DTO**
以便 **F001/F010 的租户创建流程能原子地创建根部门,F004 的 PermissionService 能消费部门变更事件**
**用户故事 4**:
作为 **BiSheng 运维人员**
我希望 **升级到 v2.5.0 时默认租户自动获得根部门,存量系统正常运行**
以便 **零迁移成本升级,不影响现有功能**
---
## 2. 验收标准
> AC-ID 在本特性内唯一,格式 `AC-NN`。
> tasks.md 中的测试任务必须通过 `覆盖 AC: AC-NN` 追溯到此表。
| ID | 角色 | 操作 | 预期结果 |
|----|------|------|---------|
| AC-01 | 管理员 | POST /api/v1/departmentsname="研发部", parent_id=根部门 id | 返回 200data 含 id/dept_id/name/pathpath 格式 `/{root_id}/{new_id}/`,dept_id 为自动生成的业务键 |
| AC-02 | 管理员 | POST 创建同名兄弟部门 | 返回 21001 DepartmentNameDuplicateError |
| AC-03 | 管理员 | GET /api/v1/departments/tree | 返回当前租户完整部门树(嵌套结构),每个节点含 id/dept_id/name/path/children/member_count |
| AC-04 | 管理员 | GET /api/v1/departments/{dept_id}(存在的 dept_id | 返回部门详情,含全部字段 |
| AC-05 | 管理员 | PUT /api/v1/departments/{dept_id}(修改 name | 返回更新后的部门,name 已变更 |
| AC-06 | 管理员 | PUT 修改 source='feishu' 的部门 name | 返回 21005 DepartmentSourceReadonlyError |
| AC-07 | 管理员 | DELETE /api/v1/departments/{dept_id}(无子部门无成员) | 返回 200,部门 status 变为 'archived' |
| AC-08 | 管理员 | DELETE 有子部门的部门 | 返回 21002 DepartmentHasChildrenError |
| AC-09 | 管理员 | DELETE 有成员的部门 | 返回 21003 DepartmentHasMembersError |
| AC-10 | 管理员 | POST /api/v1/departments/{dept_id}/movenew_parent_id=有效部门) | 返回 200,该部门及其所有子孙的 path 正确更新 |
| AC-11 | 管理员 | POST move 将部门移到自己的子孙下 | 返回 21004 DepartmentCircularMoveError |
| AC-12 | 管理员 | POST /api/v1/departments/{dept_id}/membersuser_ids=[1,2,3] | 返回 200,三个用户成为部门成员 |
| AC-13 | 管理员 | POST members 添加已存在的成员 | 返回 21007 DepartmentMemberExistsError |
| AC-14 | 管理员 | GET /api/v1/departments/{dept_id}/members?page=1&limit=20 | 返回分页成员列表(PageData 格式),含 user_id/user_name/is_primary/source |
| AC-15 | 管理员 | DELETE /api/v1/departments/{dept_id}/members/{user_id} | 返回 200,用户从部门移除 |
| AC-16 | 非管理员 | 调用任何部门管理 API | 返回 21009 DepartmentPermissionDeniedError |
| AC-17 | 运维人员 | 首次启动应用(已有默认租户) | 默认租户(id=1)自动获得根部门,tenant.root_dept_id 已回写 |
| AC-18 | 开发者 | 调用 create_root_department(tenant_id, name) | 创建根部门(parent_id=None, path=`/{id}/`),回写 tenant.root_dept_id |
| AC-19 | 开发者 | 对已有根部门的租户再次调用 create_root_department | 返回 21006 DepartmentRootExistsError |
| AC-20 | 开发者 | 部门创建/移动/归档/成员变更后检查 DepartmentChangeHandler | 各事件方法返回正确的 TupleOperation 列表(action/user/relation/object |
---
## 3. 边界情况
- 当 **物化路径长度接近 512 字符**时(约 50+ 层嵌套),path 字段 VARCHAR(512) 可能不足。实际企业组织通常不超过 10 层。当前不做软限制,未来需要时可扩展字段长度
- 当 **并发创建同名兄弟部门**时,依赖 MySQL 事务隔离和 name 唯一性检查(同一 parent_id 下)。不加分布式锁,允许并发失败后重试
- 当 **移动部门时子树规模很大**时(数百个子孙),`UPDATE ... WHERE path LIKE` 批量更新在单事务中执行。MySQL InnoDB 行锁可能导致短暂阻塞,但企业组织树通常规模有限(< 1000 部门),不构成性能瓶颈
- 当 **已归档部门**被引用时,归档部门不出现在树查询结果中(`WHERE status='active'`),但数据保留。物理删除需管理员在 F010 中二次确认
- 当 **用户被删除但 user_department 记录残留**时,成员查询 JOIN user 表时自动过滤已删除用户(`user.delete=0`
- 当 **dept_id 生成冲突**时(极低概率),INSERT 触发 UNIQUE 约束异常,Service 层捕获后重试(最多 3 次)
- 当 **multi_tenant.enabled=false**时,所有部门操作正常工作,tenant_id 自动填充为默认租户(id=1)
- 当 **DepartmentChangeHandler.execute() 被调用**时,当前为日志 stub(F004 未实现),不影响部门操作本身的执行
---
## 4. 架构决策
| ID | 决策 | 选项 | 结论 | 理由 |
|----|------|------|------|------|
| AD-01 | 树结构存储方案 | A: 物化路径(path) / B: 嵌套集合 / C: 闭包表 | 选 A | PRD 规定物化路径。子树查询 `LIKE '/x/%'` + INDEX 高效,移动操作只需批量字符串替换,实现简单 |
| AD-02 | 变更事件处理 | A: Handler 产出 DTO,委托 F004 写入 / B: F002 直接写 OpenFGA | 选 A | 尊重领域归属边界(release-contract 规定 PermissionTuple Owner 是 F004)。DTO 是契约,便于 F004 集成 |
| AD-03 | 根部门创建时机 | A: 租户创建的同步副作用 / B: 异步事件触发 | 选 A | INV-14 要求原子性。F002 提供 service 方法,调用方(init_data / F010)在同一事务中调用 |
| AD-04 | ORM 模型位置 | A: `database/models/department.py` / B: `department/domain/models/` | 选 A | 与 F001 Tenant 一致,arch-guard 兼容。DAO 方法内聚在 ORM 文件中 |
| AD-05 | F004 前的权限检查 | A: 系统管理员判断 / B: 无权限检查 | 选 A | 基本安全保障,防止普通用户修改组织架构。F004 实现后替换为 OpenFGA 细粒度检查 |
| AD-06 | path 写入策略 | A: 两阶段(INSERT → UPDATE path / B: 预分配 ID | 选 A | MySQL auto_increment 不支持预分配。先插入获取 id,再 UPDATE path 是标准做法 |
| AD-07 | UserDepartment 是否含 tenant_id | A: 不含(通过 Department.tenant_id 传递) / B: 含 tenant_id | 选 A | 纯关联表,JOIN 查询时 Department 已按 tenant 过滤。避免冗余字段,与 user_tenant 模式一致 |
---
## 5. 数据库 & Domain 模型
### 数据库表定义
#### department 表
```python
class Department(SQLModelSerializable, table=True):
__tablename__ = 'department'
id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, primary_key=True, autoincrement=True),
)
dept_id: str = Field(
sa_column=Column(String(64), nullable=False, unique=True,
comment='Business key, e.g. BS@89757'),
)
name: str = Field(
sa_column=Column(String(128), nullable=False, comment='Department name'),
)
parent_id: Optional[int] = Field(
default=None,
sa_column=Column(Integer, nullable=True, index=True,
comment='Parent department ID, NULL=root'),
)
tenant_id: int = Field(
default=1,
sa_column=Column(Integer, nullable=False, server_default=text('1'),
index=True, comment='Tenant ID'),
)
path: str = Field(
default='',
sa_column=Column(String(512), nullable=False, server_default=text("''"),
index=True, comment='Materialized path /1/2/3/'),
)
sort_order: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default=text('0'),
comment='Sort order among siblings'),
)
source: str = Field(
default='local',
sa_column=Column(String(32), nullable=False, server_default=text("'local'"),
comment='Source: local/feishu/wecom/dingtalk'),
)
external_id: Optional[str] = Field(
default=None,
sa_column=Column(String(128), nullable=True,
comment='External department ID for sync'),
)
status: str = Field(
default='active',
sa_column=Column(String(16), nullable=False, server_default=text("'active'"),
index=True, comment='Status: active/archived'),
)
default_role_ids: Optional[list] = Field(
default=None,
sa_column=Column(JSON, nullable=True,
comment='Default role IDs for department members'),
)
create_user: Optional[int] = Field(
default=None,
sa_column=Column(Integer, nullable=True, comment='Creator user ID'),
)
create_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text('CURRENT_TIMESTAMP')),
)
update_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
)
__table_args__ = (
UniqueConstraint('source', 'external_id', name='uk_source_external_id'),
)
```
#### user_department 表
```python
class UserDepartment(SQLModelSerializable, table=True):
__tablename__ = 'user_department'
id: Optional[int] = Field(
default=None,
sa_column=Column(BigInteger, primary_key=True, autoincrement=True),
)
user_id: int = Field(
sa_column=Column(Integer, nullable=False, index=True, comment='User ID'),
)
department_id: int = Field(
sa_column=Column(Integer, nullable=False, index=True, comment='Department ID'),
)
is_primary: int = Field(
default=1,
sa_column=Column(SmallInteger, nullable=False, server_default=text('1'),
comment='1=primary department, 0=secondary'),
)
source: str = Field(
default='local',
sa_column=Column(String(32), nullable=False, server_default=text("'local'"),
comment='Source: local/feishu/wecom/dingtalk'),
)
create_time: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime, nullable=False,
server_default=text('CURRENT_TIMESTAMP')),
)
__table_args__ = (
UniqueConstraint('user_id', 'department_id', name='uk_user_dept'),
)
```
### DAO 方法
| 类 | 方法 | 说明 |
|----|------|------|
| DepartmentDao | `get_by_id(id)` / `aget_by_id(id)` | 按主键查询 |
| DepartmentDao | `get_by_dept_id(dept_id)` / `aget_by_dept_id(dept_id)` | 按业务键查询 |
| DepartmentDao | `create(dept)` / `acreate(dept)` | 插入部门(含 flush 获取 id |
| DepartmentDao | `update(dept)` / `aupdate(dept)` | 更新部门 |
| DepartmentDao | `get_children(parent_id)` / `aget_children(parent_id)` | 直接子部门列表 |
| DepartmentDao | `get_subtree(path_prefix)` / `aget_subtree(path_prefix)` | 子树查询 `LIKE '{path}%'` |
| DepartmentDao | `get_subtree_ids(path_prefix)` / `aget_subtree_ids(path_prefix)` | 子树 ID 列表(用于权限展开) |
| DepartmentDao | `get_all_active()` / `aget_all_active()` | 当前租户全部活跃部门(树查询用) |
| DepartmentDao | `update_paths_batch(old_prefix, new_prefix)` / `aupdate_paths_batch(...)` | 移动时批量更新子树 path |
| DepartmentDao | `get_root_by_tenant(tenant_id)` / `aget_root_by_tenant(tenant_id)` | 租户根部门查询 |
| DepartmentDao | `check_name_duplicate(parent_id, name, exclude_id)` / `acheck_name_duplicate(...)` | 同级名称重复检查 |
| UserDepartmentDao | `add_member(user_id, dept_id, is_primary)` / `aadd_member(...)` | 添加成员 |
| UserDepartmentDao | `batch_add_members(entries)` / `abatch_add_members(...)` | 批量添加成员 |
| UserDepartmentDao | `remove_member(user_id, dept_id)` / `aremove_member(...)` | 移除成员 |
| UserDepartmentDao | `get_members(dept_id, page, limit, keyword)` / `aget_members(...)` | 分页成员列表(JOIN user |
| UserDepartmentDao | `get_member_count(dept_id)` / `aget_member_count(dept_id)` | 成员计数 |
| UserDepartmentDao | `get_user_departments(user_id)` / `aget_user_departments(user_id)` | 用户所属部门列表 |
| UserDepartmentDao | `get_user_primary_department(user_id)` / `aget_user_primary_department(user_id)` | 用户主部门 |
| UserDepartmentDao | `check_member_exists(user_id, dept_id)` / `acheck_member_exists(...)` | 成员关系是否存在 |
---
## 6. API 契约
### 6.1 创建部门
```
POST /api/v1/departments
Auth: UserPayload (admin only)
```
**Request Body**:
```json
{
"name": "研发部", // 2-50 chars, required
"parent_id": 1, // required, must exist and be active
"sort_order": 0, // optional, default 0
"default_role_ids": [2] // optional, role ID list
}
```
**Response** (200):
```json
{
"status_code": 200,
"status_message": "success",
"data": {
"id": 2,
"dept_id": "BS@a3f7e",
"name": "研发部",
"parent_id": 1,
"path": "/1/2/",
"sort_order": 0,
"source": "local",
"status": "active",
"default_role_ids": [2],
"create_time": "2026-04-12T10:00:00"
}
}
```
### 6.2 获取部门树
```
GET /api/v1/departments/tree
Auth: UserPayload (admin only)
```
**Response** (200):
```json
{
"status_code": 200,
"status_message": "success",
"data": [
{
"id": 1,
"dept_id": "BS@root1",
"name": "Default Organization",
"parent_id": null,
"path": "/1/",
"sort_order": 0,
"source": "local",
"status": "active",
"member_count": 5,
"children": [
{
"id": 2,
"dept_id": "BS@a3f7e",
"name": "研发部",
"parent_id": 1,
"path": "/1/2/",
"member_count": 3,
"children": []
}
]
}
]
}
```
### 6.3 获取部门详情
```
GET /api/v1/departments/{dept_id}
Auth: UserPayload (admin only)
Path: dept_id — 部门业务键 (e.g. "BS@a3f7e")
```
**Response** (200): 部门完整字段(同创建响应格式)+ member_count
### 6.4 更新部门
```
PUT /api/v1/departments/{dept_id}
Auth: UserPayload (admin only)
```
**Request Body**partial update,仅传需要修改的字段):
```json
{
"name": "新名称", // optional, 2-50 chars
"sort_order": 1, // optional
"default_role_ids": [2, 3] // optional
}
```
**约束**: source 非 'local' 的部门不允许修改 name
### 6.5 归档/删除部门
```
DELETE /api/v1/departments/{dept_id}
Auth: UserPayload (admin only)
```
**约束**: 有子部门返回 21002,有成员返回 21003。成功后 status='archived'。
### 6.6 移动部门
```
POST /api/v1/departments/{dept_id}/move
Auth: UserPayload (admin only)
```
**Request Body**:
```json
{
"new_parent_id": 3 // required, must exist, not self or descendant
}
```
**逻辑**: 更新 parent_id,批量更新 path(该部门及其全部子孙)。
### 6.7 获取部门成员列表
```
GET /api/v1/departments/{dept_id}/members
Auth: UserPayload (admin only)
Query: page=1, limit=20, keyword="" (optional, search by user_name)
```
**Response** (200):
```json
{
"status_code": 200,
"status_message": "success",
"data": {
"data": [
{
"user_id": 1,
"user_name": "admin",
"department_id": 2,
"is_primary": 1,
"source": "local",
"create_time": "2026-04-12T10:00:00"
}
],
"total": 1
}
}
```
### 6.8 批量添加成员
```
POST /api/v1/departments/{dept_id}/members
Auth: UserPayload (admin only)
```
**Request Body**:
```json
{
"user_ids": [1, 2, 3], // required, non-empty
"is_primary": 0 // optional, default 0 (secondary)
}
```
**约束**: 已存在的成员关系返回 21007(整体原子性:任一冲突则全部拒绝)。
### 6.9 移除成员
```
DELETE /api/v1/departments/{dept_id}/members/{user_id}
Auth: UserPayload (admin only)
```
**约束**: 不存在的成员关系返回 21008。
---
## 7. Service 层逻辑
### DepartmentService
| 方法 | 核心逻辑 |
|------|---------|
| `create_department(name, parent_id, login_user, ...)` | 权限检查(admin) → 校验父部门存在+active → 校验名称不重复 → 生成 dept_id → INSERT → UPDATE path=`{parent.path}{id}/` → 调用 ChangeHandler.on_created → 返回 |
| `get_tree(login_user)` | 查询当前租户全部 active 部门 → 内存构建树(parent_id 关联)→ 按 sort_order 排序 → 附加 member_count → 返回嵌套结构 |
| `get_department(dept_id, login_user)` | 权限检查 → 按 dept_id 查询 → 附加 member_count → 返回 |
| `update_department(dept_id, update_data, login_user)` | 权限检查 → 校验 source='local'(否则 21005)→ 校验名称不重复 → UPDATE → 返回 |
| `delete_department(dept_id, login_user)` | 权限检查 → 校验无子部门(21002)→ 校验无成员(21003)→ UPDATE status='archived' → 调用 ChangeHandler.on_archived → 返回 |
| `move_department(dept_id, new_parent_id, login_user)` | 权限检查 → 校验新父部门存在 → 校验不是移到自己子树(循环检测:new_parent.path 不以 dept.path 开头)→ 计算 new_path → 批量 UPDATE 子树 pathREPLACE old_prefix → new_prefix)→ UPDATE parent_id → 调用 ChangeHandler.on_moved → 返回 |
| `create_root_department(tenant_id, name)` | 校验该租户无根部门(21006)→ INSERT(parent_id=None) → UPDATE path=`/{id}/` → UPDATE tenant.root_dept_id → 返回 |
| `add_members(dept_id, user_ids, is_primary, login_user)` | 权限检查 → 校验部门存在 → 校验成员不重复(21007)→ 批量 INSERT → 调用 ChangeHandler.on_members_added → 返回 |
| `remove_member(dept_id, user_id, login_user)` | 权限检查 → 校验成员关系存在(21008)→ DELETE → 调用 ChangeHandler.on_member_removed → 返回 |
| `get_members(dept_id, page, limit, keyword, login_user)` | 权限检查 → 分页查询 UserDepartment JOIN User → 返回 PageData |
### DepartmentChangeHandler
为 F004 预留的契约接口。定义 `TupleOperation` DTO,各事件方法产出正确的元组操作列表,`execute()` 当前为日志 stub。
```python
@dataclass
class TupleOperation:
action: Literal['write', 'delete']
user: str # e.g. "user:7" or "department:5#member"
relation: str # e.g. "member", "admin", "parent"
object: str # e.g. "department:5"
```
| 事件方法 | 产出的元组 |
|---------|-----------|
| `on_created(dept)` | `write(department:{parent_id}, parent, department:{id})` |
| `on_moved(dept, old_parent, new_parent)` | `delete(department:{old_parent}, parent, department:{id})` + `write(department:{new_parent}, parent, department:{id})` |
| `on_archived(dept)` | `delete(department:{parent_id}, parent, department:{id})` |
| `on_members_added(dept, user_ids)` | 每个 user_id: `write(user:{uid}, member, department:{id})` |
| `on_member_removed(dept, user_id)` | `delete(user:{uid}, member, department:{id})` |
### dept_id 生成
工具函数 `generate_dept_id(prefix="BS") -> str`,格式 `{prefix}@{random_5_hex}`。INSERT 时如果 UNIQUE 冲突,重试最多 3 次。
---
## 8. 前端设计
N/A — F002 不涉及前端变更。部门管理前端页面由 F010 实现。
---
## 9. 文件清单
### 新建
| 文件 | 说明 |
|------|------|
| `src/backend/bisheng/database/models/department.py` | Department + UserDepartment ORM + DepartmentDao + UserDepartmentDao |
| `src/backend/bisheng/common/errcode/department.py` | 210xx 部门错误码(10 个) |
| `src/backend/bisheng/department/__init__.py` | DDD 模块包 |
| `src/backend/bisheng/department/api/__init__.py` | API 子包 |
| `src/backend/bisheng/department/api/router.py` | 路由聚合,prefix `/departments` |
| `src/backend/bisheng/department/api/endpoints/__init__.py` | 端点子包 |
| `src/backend/bisheng/department/api/endpoints/department.py` | 部门 CRUD + tree + move 端点 |
| `src/backend/bisheng/department/api/endpoints/department_member.py` | 成员管理端点 |
| `src/backend/bisheng/department/domain/__init__.py` | 领域子包 |
| `src/backend/bisheng/department/domain/schemas/__init__.py` | DTO 子包 |
| `src/backend/bisheng/department/domain/schemas/department_schema.py` | 请求/响应 Pydantic DTO |
| `src/backend/bisheng/department/domain/services/__init__.py` | 服务子包 |
| `src/backend/bisheng/department/domain/services/department_service.py` | 部门核心业务逻辑 |
| `src/backend/bisheng/department/domain/services/department_change_handler.py` | TupleOperation DTO + 事件方法 + execute() 日志 stub |
| `src/backend/test/test_department_dao.py` | DAO 单元测试 |
| `src/backend/test/test_department_service.py` | Service 单元测试 |
| `src/backend/test/test_department_api.py` | API 集成测试 |
### 修改
| 文件 | 变更内容 |
|------|---------|
| `src/backend/bisheng/api/router.py` | 导入并注册 `department_router` |
| `src/backend/bisheng/common/init_data.py` | 添加 `_init_default_root_department(session)` 函数,在 `_init_default_tenant()` 后调用 |
| `src/backend/test/fixtures/table_definitions.py` | TABLE_DEPARTMENT 增加 dept_id/default_role_ids/create_userTABLE_USER_DEPARTMENT 增加 source |
| `src/backend/test/fixtures/factories.py` | 添加 `create_department()` + `create_user_department()` 工厂函数 |
---
## 10. 非功能要求
- **性能**: 树查询一次加载当前租户全部活跃部门(通常 < 1000),内存构建树结构。物化路径 INDEX 支持高效子树查询。移动操作的批量 path 更新在单事务中完成
- **安全**: 所有端点要求管理员权限(F004 前使用 `login_user.is_admin()` 判断)。tenant_id 自动过滤防止跨租户数据泄漏(INV-1)
- **兼容性**: 默认租户(id=1)升级后自动获得根部门。单租户模式行为不变。UserDepartment 无 tenant_id,通过 Department 传递隔离
- **可测试性**: DAO 测试使用 SQLite in-memoryconftest.py fixture)。Service 测试可 mock DAO。API 测试使用 TestClient
- **可扩展性**: DepartmentChangeHandler 的 TupleOperation DTO 是 F004 集成的契约边界。dept_id 前缀可配置化。source 字段为 F009 三方同步预留
---
## 11. 错误码表
> 模块编码 210department),已在 release-contract.md 注册。
| HTTP Status | MMMEE Code | Error Class | 场景 | 关联 AC |
|-------------|------------|-------------|------|---------|
| 200 (body) | 21000 | DepartmentNotFoundError | 部门 ID 不存在 | AC-04 |
| 200 (body) | 21001 | DepartmentNameDuplicateError | 同级部门名称重复 | AC-02 |
| 200 (body) | 21002 | DepartmentHasChildrenError | 有子部门不可删除 | AC-08 |
| 200 (body) | 21003 | DepartmentHasMembersError | 有成员不可删除 | AC-09 |
| 200 (body) | 21004 | DepartmentCircularMoveError | 不能移动到自己的子树 | AC-11 |
| 200 (body) | 21005 | DepartmentSourceReadonlyError | 三方同步部门不可修改 | AC-06 |
| 200 (body) | 21006 | DepartmentRootExistsError | 租户根部门已存在 | AC-19 |
| 200 (body) | 21007 | DepartmentMemberExistsError | 用户已是部门成员 | AC-13 |
| 200 (body) | 21008 | DepartmentMemberNotFoundError | 用户不是部门成员 | AC-15 |
| 200 (body) | 21009 | DepartmentPermissionDeniedError | 无部门操作权限 | AC-16 |
---
## 相关文档
- 版本契约: [features/v2.5.0/release-contract.md](../release-contract.md)
- 权限改造 PRD: `docs/archive/2.5 权限管理体系改造 PRD/2.5 权限管理体系改造 PRD.md`
- 多租户需求文档: `docs/archive/2.5 权限管理体系改造 PRD/2.5 多租户需求文档.md`
- 技术方案: `docs/archive/2.5 权限管理体系改造 PRD/2.5 技术方案.md`
@@ -1,464 +0,0 @@
# Tasks: 部门树
**关联规格**: [spec.md](./spec.md)
**版本**: v2.5.0
---
## 状态
| 步骤 | 状态 | 备注 |
|------|------|------|
| spec.md | ✅ 已评审 | 2026-04-12 审查通过(1 low 项已确认跳过) |
| tasks.md | ✅ 已拆解 | 2026-04-12 审查通过(Round 2),7 个任务 |
| 实现 | ✅ 已完成 | 7 / 7 完成,64 tests passed |
---
## 开发模式
**后端 Test-First(务实版)**
- 理想流程:先写测试(红),再写实现(绿)
- F000 已搭建 pytest 基础设施(conftest.py、SQLite fixture、factories
- **务实适配**ORM + DAO 层和 Service 层的实现与测试合并在同一任务中(先写 ORM/DAO 骨架,随即在同任务内编写测试验证)。这与 F001 的 T002ORM+DAO+test 合并)模式一致
- API 层可实现后补集成测试
**前端**N/A — F002 不涉及前端
**自包含任务**:每个任务内联文件、逻辑、测试上下文,实现阶段不需要回读 spec.md。
---
## 依赖图
```
T001 (ORM + DAO + 错误码 + DAO 测试)
├─→ T002 (测试基础设施更新)
├─→ T003 (DepartmentChangeHandler DTO + stub)
│ │
│ └─→ T004 (DepartmentService + Service 测试)
│ │
│ └─→ T005 (API Schema + 端点 + 路由注册)
│ │
│ └─→ T006 (API 集成测试)
└─→ T007 (init_data 默认根部门 + 测试)
```
---
## Tasks
### 数据层
- [x] **T001**: Department/UserDepartment ORM + DAO + 错误码 + DAO 单元测试
**文件(新建)**:
- `src/backend/bisheng/database/models/department.py` — Department + UserDepartment SQLModel 表定义 + DepartmentDao + UserDepartmentDao
- `src/backend/bisheng/common/errcode/department.py` — 210xx 错误码
- `src/backend/test/test_department_dao.py` — DAO 单元测试
**逻辑**:
- **`Department` 表**id(INT PK AUTO), dept_id(VARCHAR 64 UNIQUE, 业务键如"BS@a3f7e"), name(VARCHAR 128 NOT NULL), parent_id(INT nullable, NULL=根部门), tenant_id(INT NOT NULL DEFAULT 1, INDEX), path(VARCHAR 512 NOT NULL, INDEX, 物化路径如"/1/2/3/"), sort_order(INT DEFAULT 0), source(VARCHAR 32 DEFAULT 'local'), external_id(VARCHAR 128 nullable), status(VARCHAR 16 DEFAULT 'active', INDEX), default_role_ids(JSON nullable), create_user(INT nullable), create_time(DATETIME server_default CURRENT_TIMESTAMP), update_time(DATETIME server_default CURRENT_TIMESTAMP ON UPDATE)
- `__table_args__``UniqueConstraint('source', 'external_id', name='uk_source_external_id')`
- **`UserDepartment` 表**id(BIGINT PK AUTO), user_id(INT NOT NULL, INDEX), department_id(INT NOT NULL, INDEX), is_primary(SMALLINT DEFAULT 1, 1=主部门 0=挂靠), source(VARCHAR 32 DEFAULT 'local'), create_time(DATETIME)。`UniqueConstraint('user_id', 'department_id', name='uk_user_dept')`
- 两个表均继承 `SQLModelSerializable`,列定义用 `sa_column=Column(...)` 模式(参照 `database/models/tenant.py`
- **`DepartmentDao`** classmethodssync `get_xxx` + async `aget_xxx`:
- `get_by_id(id)` / `aget_by_id(id)``select(Department).where(Department.id == id)`
- `get_by_dept_id(dept_id)` / `aget_by_dept_id(dept_id)``where(Department.dept_id == dept_id)`
- `create(dept)` / `acreate(dept)``session.add(dept)` + `session.flush()` + `session.refresh(dept)` 获取 auto id
- `update(dept)` / `aupdate(dept)``session.add(dept)` + `session.commit()` + `session.refresh(dept)`
- `get_children(parent_id)` / `aget_children(parent_id)``where(parent_id==X, status=='active')` 返回列表
- `get_subtree(path_prefix)` / `aget_subtree(path_prefix)``where(Department.path.like(f'{path_prefix}%'), status=='active')` 返回列表
- `get_subtree_ids(path_prefix)` / `aget_subtree_ids(path_prefix)` — 同上但 `select(Department.id)`
- `get_all_active()` / `aget_all_active()``where(status=='active')` 返回当前租户全部活跃部门
- `update_paths_batch(old_prefix, new_prefix)` / `aupdate_paths_batch(...)``update(Department).where(Department.path.like(f'{old_prefix}%')).values(path=func.replace(Department.path, old_prefix, new_prefix))`
- `get_root_by_tenant(tenant_id)` / `aget_root_by_tenant(tenant_id)``where(parent_id==None)``bypass_tenant_filter()` 按指定 tenant 查询
- `check_name_duplicate(parent_id, name, exclude_id=None)` / `acheck_name_duplicate(...)``where(parent_id==X, name==Y, id!=exclude_id, status=='active')` 返回 bool
- **`UserDepartmentDao`** classmethods:
- `add_member(user_id, dept_id, is_primary, source)` / `aadd_member(...)` — INSERT
- `batch_add_members(entries: list[dict])` / `abatch_add_members(...)` — 批量 INSERT
- `remove_member(user_id, dept_id)` / `aremove_member(...)` — DELETE
- `get_members(dept_id, page, limit, keyword)` / `aget_members(...)` — JOIN user 表分页查询,`WHERE user.delete==0`keyword 模糊匹配 user_name
- `get_member_count(dept_id)` / `aget_member_count(dept_id)``select(func.count(...))`
- `get_user_departments(user_id)` / `aget_user_departments(user_id)` — 返回用户所属部门列表
- `get_user_primary_department(user_id)` / `aget_user_primary_department(...)``where(is_primary==1)`
- `check_member_exists(user_id, dept_id)` / `acheck_member_exists(...)` — 返回 bool
- **错误码**(继承 `BaseErrorCode`,参照 `common/errcode/tenant.py`:
- `DepartmentNotFoundError(Code=21000, Msg='Department not found')`
- `DepartmentNameDuplicateError(Code=21001, Msg='Department name already exists at this level')`
- `DepartmentHasChildrenError(Code=21002, Msg='Cannot delete department with children')`
- `DepartmentHasMembersError(Code=21003, Msg='Cannot delete department with members')`
- `DepartmentCircularMoveError(Code=21004, Msg='Cannot move department to its own subtree')`
- `DepartmentSourceReadonlyError(Code=21005, Msg='Third-party synced department is read-only')`
- `DepartmentRootExistsError(Code=21006, Msg='Root department already exists for this tenant')`
- `DepartmentMemberExistsError(Code=21007, Msg='User is already a member of this department')`
- `DepartmentMemberNotFoundError(Code=21008, Msg='User is not a member of this department')`
- `DepartmentPermissionDeniedError(Code=21009, Msg='No permission for this department operation')`
**测试**`test_department_dao.py`,使用 `db_session` fixture + factory 函数):
- `test_create_department` — 使用 factory 创建部门,验证返回 dict 含所有字段
- `test_dept_id_unique` — 插入相同 dept_id 抛 IntegrityError
- `test_get_children` — 创建父+2子部门,`get_children(parent_id)` 返回 2 条
- `test_get_subtree` — 创建 3 层树 A→B→C`get_subtree('/A/')` 返回 A+B+C
- `test_get_subtree_ids` — 同上但只返回 ID 列表
- `test_update_paths_batch` — 创建子树,执行路径替换,验证所有后代 path 更新
- `test_check_name_duplicate_true` — 同级同名存在时返回 True
- `test_check_name_duplicate_false` — 不同级或不同名返回 False
- `test_check_name_duplicate_exclude` — 更新时排除自身 ID
- `test_add_member` — 添加成员,验证 user_department 记录
- `test_add_member_duplicate` — 重复添加抛 IntegrityError
- `test_remove_member` — 移除成员后查询不存在
- `test_get_members_paged` — 创建 3 个成员,page=1 limit=2 返回 2 条 + total=3
- `test_get_member_count` — 添加 3 个成员后 count=3
- `test_get_user_departments` — 1 用户 2 部门,返回 2 条
- `test_check_member_exists` — 存在返回 True,不存在返回 False
**覆盖 AC**: AC-01, AC-02, AC-12, AC-13, AC-14, AC-15
**依赖**: 无
---
### 测试基础设施
- [x] **T002**: 测试 table_definitions + factories 更新
**文件(修改)**:
- `src/backend/test/fixtures/table_definitions.py` — 更新 TABLE_DEPARTMENT 和 TABLE_USER_DEPARTMENT 的 DDL
- `src/backend/test/fixtures/factories.py` — 新增 `create_department()``create_user_department()` 工厂函数
**逻辑**:
- **TABLE_DEPARTMENT** 更新(当前缺少 dept_id/default_role_ids/create_user,多了 level/admin_user_id:
```sql
CREATE TABLE IF NOT EXISTS department (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dept_id VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
parent_id INTEGER,
tenant_id INTEGER NOT NULL DEFAULT 1,
path VARCHAR(512) NOT NULL DEFAULT '',
sort_order INTEGER DEFAULT 0,
source VARCHAR(32) DEFAULT 'local',
external_id VARCHAR(128),
status VARCHAR(16) DEFAULT 'active',
default_role_ids JSON,
create_user INTEGER,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE(source, external_id)
)
```
- **TABLE_USER_DEPARTMENT** 更新(当前缺少 source:
```sql
CREATE TABLE IF NOT EXISTS user_department (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
department_id INTEGER NOT NULL,
is_primary INTEGER DEFAULT 1,
source VARCHAR(32) DEFAULT 'local',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE(user_id, department_id)
)
```
- **`create_department()`** 工厂函数(参照 `create_tenant()` 模式,使用 raw SQL + `session.execute(text(...))`:
```python
def create_department(
session: Session,
dept_id: str = 'BS@test1',
name: str = 'Test Dept',
tenant_id: int = 1,
parent_id: int = None,
path: str = '',
**kwargs,
) -> dict:
```
- **`create_user_department()`** 工厂函数:
```python
def create_user_department(
session: Session,
user_id: int,
department_id: int,
is_primary: int = 1,
) -> dict:
```
**覆盖 AC**: —(测试基础设施)
**依赖**: T001(需要 ORM 定义对齐 DDL
---
### 领域服务层
- [x] **T003**: DepartmentChangeHandler — TupleOperation DTO + 事件方法 + 日志 stub
**文件(新建)**:
- `src/backend/bisheng/department/__init__.py` — 空 init
- `src/backend/bisheng/department/domain/__init__.py` — 空 init
- `src/backend/bisheng/department/domain/services/__init__.py` — 空 init
- `src/backend/bisheng/department/domain/services/department_change_handler.py` — TupleOperation + DepartmentChangeHandler
**逻辑**:
- `TupleOperation` dataclass:
```python
@dataclass
class TupleOperation:
action: Literal['write', 'delete']
user: str # e.g. "user:7" or "department:5#member"
relation: str # e.g. "member", "admin", "parent"
object: str # e.g. "department:5"
```
- `DepartmentChangeHandler` 类,全部 `@staticmethod`:
- `on_created(dept_id: int, parent_id: int) -> List[TupleOperation]` — 返回 `[TupleOperation(action='write', user=f'department:{parent_id}', relation='parent', object=f'department:{dept_id}')]`
- `on_moved(dept_id: int, old_parent_id: int, new_parent_id: int) -> List[TupleOperation]` — 返回 delete 旧 parent + write 新 parent 两条
- `on_archived(dept_id: int, parent_id: int) -> List[TupleOperation]` — 返回 delete parent 关系
- `on_members_added(dept_id: int, user_ids: List[int]) -> List[TupleOperation]` — 每个 uid 返回 `write(user:{uid}, member, department:{dept_id})`
- `on_member_removed(dept_id: int, user_id: int) -> List[TupleOperation]` — 返回 `delete(user:{uid}, member, department:{dept_id})`
- `execute(operations: List[TupleOperation]) -> None` — **日志 stub**: `logger.info(f"DepartmentChangeHandler: {len(operations)} tuple operations (stub, F004 not yet)")` + 逐条 debug 日志
**覆盖 AC**: AC-20
**依赖**: T001
- [x] **T004**: DepartmentService + Schema + Service 单元测试
**文件(新建)**:
- `src/backend/bisheng/department/domain/schemas/__init__.py` — 空 init
- `src/backend/bisheng/department/domain/schemas/department_schema.py` — Pydantic 请求/响应 DTO
- `src/backend/bisheng/department/domain/services/department_service.py` — DepartmentService 类
- `src/backend/test/test_department_service.py` — Service 单元测试
**逻辑**:
- **Pydantic Schema**`department_schema.py`:
- `DepartmentCreate(BaseModel)`: name(str, 2-50 chars), parent_id(int), sort_order(int=0), default_role_ids(Optional[List[int]])
- `DepartmentUpdate(BaseModel)`: name(Optional[str]), sort_order(Optional[int]), default_role_ids(Optional[List[int]])
- `DepartmentMoveRequest(BaseModel)`: new_parent_id(int)
- `DepartmentMemberAdd(BaseModel)`: user_ids(List[int], min 1), is_primary(int=0)
- `DepartmentTreeNode(BaseModel)`: id, dept_id, name, parent_id, path, sort_order, source, status, member_count(int=0), children(List['DepartmentTreeNode']=[])
- `DepartmentMemberInfo(BaseModel)`: user_id, user_name, department_id, is_primary, source, create_time
- **DepartmentService 类**(全部 `@classmethod`,使用 `async with get_async_db_session() as session`:
- `generate_dept_id(prefix="BS") -> str` — `f"{prefix}@{secrets.token_hex(3)}"` 生成 6 位 hex,冲突重试 3 次
- `acreate_department(data: DepartmentCreate, login_user) -> Department`:
1. 权限检查:`if not _is_admin(login_user): raise DepartmentPermissionDeniedError`
2. 校验父部门:`parent = await DepartmentDao.aget_by_id(data.parent_id)`,不存在 raise DepartmentNotFoundError
3. 校验父部门 active`parent.status != 'active'` raise DepartmentNotFoundError
4. 校验名称:`await DepartmentDao.acheck_name_duplicate(data.parent_id, data.name)` → raise DepartmentNameDuplicateError
5. 生成 dept_id(重试 3 次)
6. INSERT Departmentdept_id, name, parent_id, sort_order, default_role_ids, source='local', status='active', create_user=login_user.user_id
7. UPDATE path = `f"{parent.path}{dept.id}/"`
8. 调用 `DepartmentChangeHandler.on_created(dept.id, parent.id)` → `execute(ops)`
9. 返回 dept
- `aget_tree(login_user) -> List[DepartmentTreeNode]`:
1. 权限检查
2. `depts = await DepartmentDao.aget_all_active()`
3. 批量查询 member_count(一次 GROUP BY department_id
4. 内存构建树:按 parent_id 分组 → 递归组装 children → sort_order 排序
5. 返回嵌套结构
- `aget_department(dept_id: str, login_user) -> Department`:
1. 权限检查
2. `dept = await DepartmentDao.aget_by_dept_id(dept_id)`,不存在 raise DepartmentNotFoundError
3. 附加 member_count
4. 返回
- `aupdate_department(dept_id: str, data: DepartmentUpdate, login_user) -> Department`:
1. 权限检查
2. 查询部门,不存在 raise DepartmentNotFoundError
3. `dept.source != 'local'` 且 data.name 非 None → raise DepartmentSourceReadonlyError
4. data.name 非 None → `acheck_name_duplicate(dept.parent_id, data.name, exclude_id=dept.id)` → raise DepartmentNameDuplicateError
5. 更新字段(仅 non-None 字段)
6. 返回
- `adelete_department(dept_id: str, login_user)`:
1. 权限检查
2. 查询部门
3. `await DepartmentDao.aget_children(dept.id)` 非空 → raise DepartmentHasChildrenError
4. `await UserDepartmentDao.aget_member_count(dept.id)` > 0 → raise DepartmentHasMembersError
5. UPDATE status='archived'
6. `DepartmentChangeHandler.on_archived(dept.id, dept.parent_id)` → `execute(ops)`
- `amove_department(dept_id: str, data: DepartmentMoveRequest, login_user)`:
1. 权限检查
2. 查询部门 + 新父部门(不存在 raise DepartmentNotFoundError
3. 循环检测:`new_parent.path.startswith(dept.path)` → raise DepartmentCircularMoveError
4. 也检查 `data.new_parent_id == dept.id` → raise DepartmentCircularMoveError
5. old_path = dept.path
6. new_path = `f"{new_parent.path}{dept.id}/"`
7. `await DepartmentDao.aupdate_paths_batch(old_path, new_path)` — 批量更新子树
8. UPDATE dept.parent_id, dept.path
9. `DepartmentChangeHandler.on_moved(dept.id, dept.parent_id_old, data.new_parent_id)` → `execute(ops)`
- `acreate_root_department(tenant_id: int, name: str = 'Default Organization') -> Department`:
1. `with bypass_tenant_filter():` 上下文
2. 检查租户是否存在根部门:`await DepartmentDao.aget_root_by_tenant(tenant_id)` → 存在则 raise DepartmentRootExistsError
3. INSERT Departmentparent_id=None, tenant_id=tenant_id, source='local', status='active'
4. UPDATE path = `f"/{dept.id}/"`
5. UPDATE Tenant.root_dept_id = dept.id
6. 返回 dept
- `aadd_members(dept_id: str, data: DepartmentMemberAdd, login_user)`:
1. 权限检查
2. 查询部门
3. 对每个 user_id: `await UserDepartmentDao.acheck_member_exists(uid, dept.id)` → 存在 raise DepartmentMemberExistsError
4. `await UserDepartmentDao.abatch_add_members(entries)` — entries = [{user_id, department_id, is_primary, source='local'}]
5. `DepartmentChangeHandler.on_members_added(dept.id, data.user_ids)` → `execute(ops)`
- `aremove_member(dept_id: str, user_id: int, login_user)`:
1. 权限检查
2. `await UserDepartmentDao.acheck_member_exists(user_id, dept_id_int)` → 不存在 raise DepartmentMemberNotFoundError
3. `await UserDepartmentDao.aremove_member(user_id, dept_id_int)`
4. `DepartmentChangeHandler.on_member_removed(dept_id_int, user_id)` → `execute(ops)`
- `aget_members(dept_id: str, page: int, limit: int, keyword: str, login_user) -> PageData`:
1. 权限检查
2. `await UserDepartmentDao.aget_members(dept.id, page, limit, keyword)` — 返回 list + total
3. 包装为 `PageData`
- **辅助函数** `_is_admin(login_user) -> bool`: 检查 `login_user.role == 'admin'` 或 user_role 中包含 AdminRole(1)。F004 后替换为 PermissionService.check()
**测试**`test_department_service.py`,使用 `db_session` fixture + factory 函数):
- `test_create_department_success` — 创建部门,验证返回 dept_id/path 格式
- `test_create_department_name_duplicate` — 同级同名 raise DepartmentNameDuplicateError
- `test_create_department_parent_not_found` — parent_id 不存在 raise DepartmentNotFoundError
- `test_get_tree` — 创建 3 层树,验证嵌套结构 + sort_order 排序 + member_count
- `test_get_department` — 查询单个部门详情
- `test_update_department_name` — 修改名称成功
- `test_update_department_source_readonly` — source='feishu' 修改 name raise DepartmentSourceReadonlyError
- `test_delete_department_success` — 无子无成员,status 变 archived
- `test_delete_department_has_children` — raise DepartmentHasChildrenError
- `test_delete_department_has_members` — raise DepartmentHasMembersError
- `test_move_department_success` — 移动后 path 正确更新(父+子孙全部)
- `test_move_department_circular` — 移到自己子树 raise DepartmentCircularMoveError
- `test_add_members_batch` — 批量添加 3 个用户
- `test_add_members_duplicate` — 已存在 raise DepartmentMemberExistsError
- `test_get_members_paged` — 分页查询验证
- `test_remove_member` — 移除成员
- `test_remove_member_not_found` — 不存在 raise DepartmentMemberNotFoundError
- `test_permission_denied` — 非 admin 调用 raise DepartmentPermissionDeniedError
- `test_create_root_department` — 创建根部门,验证 parent_id=None + path=`/{id}/` + tenant.root_dept_id 回写
- `test_create_root_department_exists` — 重复创建 raise DepartmentRootExistsError
- `test_change_handler_on_created` — 验证 on_created 返回正确 TupleOperation
- `test_change_handler_on_moved` — 验证 on_moved 返回 delete+write 两条
- `test_change_handler_on_members_added` — 验证每个 uid 一条 write
**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16, AC-18, AC-19, AC-20
**依赖**: T001, T002, T003
---
### API 层
- [x] **T005**: API Schema + 端点 + 路由注册
**文件(新建)**:
- `src/backend/bisheng/department/api/__init__.py` — 空 init
- `src/backend/bisheng/department/api/router.py` — 路由聚合
- `src/backend/bisheng/department/api/endpoints/__init__.py` — 空 init
- `src/backend/bisheng/department/api/endpoints/department.py` — 部门 CRUD + tree + move 端点
- `src/backend/bisheng/department/api/endpoints/department_member.py` — 成员管理端点
**文件(修改)**:
- `src/backend/bisheng/api/router.py` — 导入并注册 department_router
**逻辑**:
- **router.py**:
```python
from bisheng.department.api.endpoints.department import router as department_router
from bisheng.department.api.endpoints.department_member import router as department_member_router
router = APIRouter(prefix='/departments', tags=['Department'])
router.include_router(department_router)
router.include_router(department_member_router)
```
- **department.py** 端点(6 个):
- `POST /` — `create_department(data: DepartmentCreate, login_user=Depends(UserPayload.get_login_user))` → `resp_200(await DepartmentService.acreate_department(data, login_user))`
- `GET /tree` — `get_tree(login_user=Depends(...))` → `resp_200(await DepartmentService.aget_tree(login_user))`
- `GET /{dept_id}` — `get_department(dept_id: str, login_user=Depends(...))` → `resp_200(...)`
- `PUT /{dept_id}` — `update_department(dept_id: str, data: DepartmentUpdate, login_user=Depends(...))` → `resp_200(...)`
- `DELETE /{dept_id}` — `delete_department(dept_id: str, login_user=Depends(...))` → `resp_200(...)`
- `POST /{dept_id}/move` — `move_department(dept_id: str, data: DepartmentMoveRequest, login_user=Depends(...))` → `resp_200(...)`
- **department_member.py** 端点(3 个):
- `GET /{dept_id}/members` — `get_members(dept_id: str, page: int = 1, limit: int = 20, keyword: str = '', login_user=Depends(...))` → `resp_200(PageData)`
- `POST /{dept_id}/members` — `add_members(dept_id: str, data: DepartmentMemberAdd, login_user=Depends(...))` → `resp_200(...)`
- `DELETE /{dept_id}/members/{user_id}` — `remove_member(dept_id: str, user_id: int, login_user=Depends(...))` → `resp_200(...)`
- 所有端点用 `try/except BaseErrorCode as e: return e.return_resp()` 捕获业务异常
- **api/router.py 注册**(在 `src/backend/bisheng/api/router.py` 中添加):
```python
from bisheng.department.api.router import router as department_router
router.include_router(department_router)
```
**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16
**依赖**: T004
---
### API 测试
- [x] **T006**: API 集成测试
**文件(新建)**:
- `src/backend/test/test_department_api.py` — API 集成测试
**逻辑**:
使用 TestClient + conftest 的 mock login_user fixture,测试所有端点。
**测试用例**:
- `test_create_department_success` — POST /departments,验证 200 + data 结构 → AC-01
- `test_create_department_duplicate_name` — 21001 → AC-02
- `test_get_tree` — GET /departments/tree,验证嵌套结构 → AC-03
- `test_get_department` — GET /departments/{dept_id},验证详情 → AC-04
- `test_update_department` — PUT 修改 name → AC-05
- `test_update_department_source_readonly` — PUT source=feishu → 21005 → AC-06
- `test_delete_department` — DELETE 无子无成员 → AC-07
- `test_delete_department_has_children` — 21002 → AC-08
- `test_delete_department_has_members` — 21003 → AC-09
- `test_move_department` — POST move → AC-10
- `test_move_department_circular` — 21004 → AC-11
- `test_add_members` — POST members → AC-12
- `test_add_members_duplicate` — 21007 → AC-13
- `test_get_members` — GET members 分页 → AC-14
- `test_remove_member` — DELETE member → AC-15
- `test_permission_denied` — 非 admin 调用 → 21009 → AC-16
**覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-15, AC-16
**依赖**: T005
---
### 初始化集成
- [x] **T007**: init_data 默认根部门创建 + 测试
**文件(修改)**:
- `src/backend/bisheng/common/init_data.py` — 新增 `_init_default_root_department(session)` 函数
**文件(新建)**:
- `src/backend/test/test_init_root_department.py` — init_data 根部门创建测试
**逻辑**:
- 新增 async 函数 `_init_default_root_department(session)`:
1. `from bisheng.core.context.tenant import DEFAULT_TENANT_ID, bypass_tenant_filter`
2. `from bisheng.database.models.tenant import Tenant`
3. `from bisheng.database.models.department import Department`
4. `with bypass_tenant_filter():`
5. 查询默认租户:`tenant = (await session.exec(select(Tenant).where(Tenant.id == DEFAULT_TENANT_ID))).first()`
6. 如果 `tenant is None` 或 `tenant.root_dept_id is not None`return(幂等检查)
7. 创建根部门:
```python
dept = Department(
dept_id='BS@root',
name='Default Organization',
parent_id=None,
tenant_id=DEFAULT_TENANT_ID,
path='',
source='local',
status='active',
)
session.add(dept)
await session.flush()
await session.refresh(dept)
dept.path = f'/{dept.id}/'
tenant.root_dept_id = dept.id
await session.commit()
```
8. `logger.info(f'Default root department initialized (id={dept.id})')`
- 在 `init_default_data()` 中,`await _init_default_tenant(session)` 之后调用 `await _init_default_root_department(session)`
**测试**`test_init_root_department.py`:
- `test_init_creates_root_department` — 先创建 tenant(id=1),调用 `_init_default_root_department`,验证 department 创建且 tenant.root_dept_id 已回写
- `test_init_idempotent` — 连续调用两次,验证第二次不创建新部门(幂等)
**覆盖 AC**: AC-17
**依赖**: T001, T004
---
## AC 覆盖矩阵
| AC | T001 | T002 | T003 | T004 | T005 | T006 | T007 |
|----|------|------|------|------|------|------|------|
| AC-01 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-02 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-03 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-04 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-05 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-06 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-07 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-08 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-09 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-10 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-11 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-12 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-13 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-14 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-15 | DAO+test | — | — | ✓ | ✓ | ✓ | — |
| AC-16 | — | — | — | ✓ | ✓ | ✓ | — |
| AC-17 | — | — | — | — | — | — | ✓ |
| AC-18 | — | — | — | ✓ | — | — | — |
| AC-19 | — | — | — | ✓ | — | — | — |
| AC-20 | — | — | DTO | ✓ | — | — | — |

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