Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dffc716ba0 | |||
| ad7e88d0dc |
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
```
|
||||
@@ -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 check(hook 自动处理) |
|
||||
|
||||
### 维度 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
|
||||
```
|
||||
@@ -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 2:AC 分析(仅 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 | 跳过: K(UI 交互类)| 失败: 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(那是安全底线)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 4:OpenFGA 权限未同步
|
||||
|
||||
**症状**:创建资源后,同一用户立即查询却被权限拒绝。
|
||||
|
||||
**原因**:资源创建时应同步写入 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 元组没写入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 5:cleanup 顺序错误
|
||||
|
||||
**症状**:`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"] # 总数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 8:WebSocket 测试
|
||||
|
||||
**症状**: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()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 9:RSA 公钥缓存
|
||||
|
||||
**症状**:多个测试用不同用户登录,部分登录失败。
|
||||
|
||||
**原因**:公钥可能在短时间内变化,或 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 一个 TestClass,fixture 管理生命周期
|
||||
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 自动注入,不需手动设置)
|
||||
|
||||
# 验证:不同租户的用户看不到此数据
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.md(What 和 AC,校验 design 没有偏离需求)
|
||||
- {feature_dir}/tasks.md(若已存在;校验流水账与 design 现状一致)
|
||||
- features/_templates/design.md(模板结构基线)
|
||||
- features/v{X.Y.Z}/release-contract.md(确认关键约束、不变量、依赖契约对齐)
|
||||
- docs/constitution.md(架构铁律 C1–C7,用于 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 任一条 C1–C7?违反 → SEVERITY high(BLOCKER)。
|
||||
|
||||
**§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(架构铁律 C1–C7)
|
||||
|
||||
**需求覆盖**:
|
||||
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 C1–C7 冲突?
|
||||
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
|
||||
- 前端任务必须区分 Platform(src/frontend/platform/)和 Client(src/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
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
name: task-review
|
||||
description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查,
|
||||
确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。
|
||||
用法:/task-review <feature_dir> <task_id>
|
||||
TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。
|
||||
---
|
||||
|
||||
# Task Review Skill(L1 任务级审查)
|
||||
|
||||
## 调用方式
|
||||
|
||||
```
|
||||
/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;前端页面 PascalCase,store 文件 camelCase+Store,API 函数 camelCase;i18n 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 未同步 → FAIL(MEDIUM);触发且已同步 / 未触发 → 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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -1,20 +0,0 @@
|
||||
## What
|
||||
|
||||
简要描述做了什么改动。
|
||||
|
||||
## Why
|
||||
|
||||
为什么需要这个改动?
|
||||
|
||||
## How
|
||||
|
||||
实现方式、设计决策(如有)。
|
||||
|
||||
## Test
|
||||
|
||||
- [ ] 本地测试通过
|
||||
- [ ] 114 测试服务器验证通过
|
||||
|
||||
## Related
|
||||
|
||||
- Issue/ticket:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)$
|
||||
|
||||
@@ -1,107 +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) (C1–C7); 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.
|
||||
|
||||
---
|
||||
|
||||
## 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/`, 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.
|
||||
|
||||
@@ -107,4 +107,3 @@ Welcome to join our discussion group
|
||||
|
||||
[](https://star-history.com/#dataelement/bisheng&Date)
|
||||
-->
|
||||
|
||||
|
||||
@@ -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 配置
|
||||
# 单点模式(兼容现有写法):
|
||||
# celery的broken地址
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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 里写 HEALTHCHECK:openfga 官方镜像为 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-fix
|
||||
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 官方镜像无 HEALTHCHECK,Compose v5 无法用 service_healthy
|
||||
openfga:
|
||||
condition: service_started
|
||||
|
||||
|
||||
backend_worker:
|
||||
container_name: bisheng-backend-worker
|
||||
image: dataelement/bisheng-backend:v2.6.0-fix
|
||||
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-fix
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 属性值需做最小转义(`"` → `"`,`<` → `<`);目前 `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 Note:1 人 · 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 内核规划任务<br>write_todos &nbsp;|&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 沙箱<br>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 中断:弹出输入框<br>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="完成 · 全程流式可见<br>todo / 工具 / 产物 &nbsp;|&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<br>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="对账报告<br>成功 / 失败 / 跳过 · 异常可人工处理<br>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<br>按租户隔离" 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="是否进入<br>任务模式" 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="普通对话模式<br>本节不展开" 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="输入框工具栏<br>选用预设工具·独立入口<br>同日常模式·不生成 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="添加技能<br>勾选启用·不勾不用" 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="添加知识空间<br>个人私有" 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="添加组织知识库<br>租户共享" 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="添加附件<br>解析见 §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="提交前一览<br>逐项可移除" 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="提交任务<br>进入执行 §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="发送置灰并提示<br>请输入任务描述" 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="即时拦截<br>提示支持的格式清单" 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="即时拦截<br>提示上限并建议改用知识库" 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<br>状态: 上传中 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<br>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<br>转为结构化资料" 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<br>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="状态: 可用<br>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="发送前: 附件出现在<br>本轮上下文一览 可逐项移除" 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="执行中: 灵思先看文件结构<br>用到才翻相关章节" 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="执行流出现可见步骤<br>正在翻阅《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="产物中按原文件名标注来源<br>可点击溯源回看" 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="上传失败<br>自动移除 + 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="解析失败<br>自动移除 + 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="进入执行视图<br>全局状态:规划中" 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="思考过程流式滚动<br>生成任务清单" 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="全局状态:执行中<br>清单逐项点亮" 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="全局状态:等待你的补充<br>输入区高亮 见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="全局状态:已完成<br>产物区聚合交付物" 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="全局状态:执行失败<br>原因摘要+重试" 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="全局状态:已终止<br>保留已产出结果" 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="灵思遇到<br>需要用户决策的点?" 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="当前任务暂停<br>挂起到追问态" 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="执行区出现追问卡<br>顶部高亮 等待你的输入" 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="二次确认后终止<br>保留已产出的中间结果" 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 持久保存<br>回来仍可回答并续跑" 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="追问卡收起为已回答摘要<br>任务从中断点继续执行" 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="是否还有新的<br>追问点?" 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="本租户自定义技能列表<br>名称·描述·启停状态<br>无分组" 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="就地标红 + 提示如何修正<br>保存按钮置灰" 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="预览确认<br>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="列表出现新项<br>默认启用<br>成功提示" 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="保存失败提示 + 可重试<br>不丢失已填内容" 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="终端用户输入区<br>该技能 即时出现 可选" 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="终端用户输入区<br>该技能 即时消失 不可选" 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="终端用户输入区<br>该技能 移除<br>已勾选者本轮失效并提示" 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="升级完成后 租户管理员<br>首次进入技能管理页" 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="页面顶部出现<br>迁移结果提示条" 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="对账报告<br>按状态分组" 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="成功项<br>已转为技能 带 由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="失败项<br>转换未成功 附原因" 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="跳过项<br>超大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="可直接在管理页<br>查看/编辑/启停" 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="人工处理路径:<br>查看原因→修正后重建为技能" 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="人工处理路径:<br>超大SOP拆分后<br>重新上传为技能" 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="处理完成项<br>从待办中消除" 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="对账报告可重复进入<br>直到待办清零" 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="根本不展示<br>列表里查不到 搜不出" 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="可见 可激活选用<br>不可编辑/删除" 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="可见 可激活<br>可创建/编辑/删除/启停" 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="输入区不再出现<br>已选中的本轮自动失效" 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 切换<br>进入目标租户身份" 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="在该租户视角下<br>按租户管理员权限维护" 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<br>进入灵思模式" 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 附件上传与按需处理<br>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 发起任务·统一输入区<br>「+」技能 / 知识空间 / 组织知识库 / 附件 + 输入框工具栏选用工具<br>提交前一览·可见可改" 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 排队中<br>并发超限时排队·可感知" 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 执行过程可视化<br>规划→子任务委派→执行<br>含产物交付·长任务后台运行" 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<br>执行中追问·用户回答/干预/叫停" 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="产物交付<br>下载 / 复制 / 溯源" 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 管理页<br>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 迁移体验<br>动态 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 权限与可见性<br>三角色 × 三资源 可见/可改" 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,手里没 task(Tier-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(独立 context,spec 名义 = "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(...)` **同步跑完**再回传 ToolMessage(subagents.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 段 11050–11069 / `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 不 shadow(deepagents 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` 过滤**租户自定义** skill;built-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-16,commit `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/上传/启停 API(10 端点) | ✅ 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` 包装,多 1–2 天。
|
||||
- **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,仅租户自定义技能)**:约 **5–9 人天**,单人 **1–2 周** 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 灵思(Linsight)2.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 key(zh-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)
|
||||
│
|
||||
│ 每个 chunk:subgraphs=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)
|
||||
│
|
||||
▼ 产出 BaseEvent:GenerateSubTask / 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 upsert;thinking output 拼接 prev+new)
|
||||
state_message_manager.py:441-467(thinking 拼接 :456-457;MessageEventType 10 取值 :32-56)
|
||||
│
|
||||
▼ WS 推送 → 前端
|
||||
useLinsightWebSocket(versionId) → Recoil linsightMapState
|
||||
ExecutionFlow.tsx:68 / TaskTurnPanel.tsx:75;useLinsightManager.tsx:24,441
|
||||
│ 伪任务回填 splitSessionPseudoTask(stepUtils.ts:278)
|
||||
▼
|
||||
StepList:buildFlowNodes(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 + output(ToolRow.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+new(state_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 + 命中文件 + output(KnowledgeRow.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=None,name="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_search)default-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 的 chunk(tool-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-457;delta 语义单测 `test_stream_event_mapper.py:224-236`;切段被钉为规格的单测 `:238-251`;前端拼接 `stepUtils.ts:102-107`;标题失效 `ThinkingRow.tsx:17`。
|
||||
- **判定**:**设计缺陷为主**(切段粒度规则与运行真相背离,被单测固化为绿)+ **前端实现小瑕疵**(标题恒英文)。非模型「只想了几个字」,非数据层 bug(reasoning_content 是 delta、拼接正确,确定度高)。
|
||||
- **影响面**:所有 deepagents 任务模式运行结果,尤其 reasoning 模型(DeepSeek-R1)+ 子代理委派场景;114 部署用 DeepSeek,命中最重。实时流与历史回看同源(落库 history 与实时流一致),均受影响。严重度中(无数据错乱/功能失效,但严重损害可读性 + 暴露英文 "thinking" 本地化破窗)。
|
||||
- **✅ 114 实测**:截图那次运行 thinking 347 帧 → 347 个 distinct call_id(347 行),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,:393;call_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_type(tool/knowledge)并带 namespace 作为 children。
|
||||
|
||||
| # | 改动点 (file:line) | 做法 | 风险 |
|
||||
|---|---|---|---|
|
||||
| B1 | `stream_event_mapper.py:484-497` | 删除 `if ns: return "subagent"`。namespace 只影响归组(已写 extra_info.namespace),不改写 step_type;step_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% 是 thinking,B 路线修好后 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/end,name=工具名**:`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`
|
||||
- **子代理 spec(name="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 :26;subagent 走 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 的 stream(messages 模式 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_todos(ask_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)。
|
||||
- **全路径回归**:live(WS 落 `svid`→sessionSteps)/ reload(`svid` 伪任务 history → splitSessionPseudoTask)/ 多轮 ConversationRound(live 快照分轮,reload 单条合并=今天)/ 分享只读 / clarify HITL(ask_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` 拼成**一整段**「思考内容」,左轨 timeline(16px 图标 + 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 关联)在真实数据上不可行**:主图 task(ns=None)与子图 step 是两套独立 ID、无共享字符串,burst 并行派发场景时序错配。三份校验一致建议**放弃强关联**,改用方案②的洞察「**ns 即子代理身份**」——按 `extra_info.namespace` group-by,distinct ns 数天然 = 真实子代理数(22→3)。
|
||||
3. **后端 B5(thinking 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 改稳定 id(session_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 case(mapper 从不产出、注册表恒空)或显式标 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` 抽出的组级耗时 hook(start/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 非 thinking,M=thinking),运行态「正在调研…」或当前工具动词;标题优先 `delegate_goal`,无则「子智能体 N」。
|
||||
- **手动折叠持久化**:组展开态 key=组稳定 id 存 Recoil sessionStorage,切会话/刷新不丢;组创建默认 = 持久化值 `?? true`。
|
||||
- **running 反馈统一**:活跃组头 `animate-pulse` + 用时 100ms tick;移除 spinner + pulse 蓝点 + pulse-scale 灰点三套并存;`BreathingRow` 三态合一两面共用。
|
||||
- **自动滚动**:流式期 instant、阈值 64px、用户上滑即脱离直到回底恢复;TaskTurnPanel 补接入。
|
||||
- **并行表达**:`SubagentTeamGroup` 内宽屏(≥560px)side-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,顺带修日常失效类)。
|
||||
- 耗时 tick:100ms `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-level(todo 尚未生成时的 ask_user,task_id 等于伪 session 任务、无 `LinsightExecuteTask` 行——注释 `linsight.py:353-357`)硬置 `already_completed=False`(`:361`)并**跳过 `set_user_input`**(否则 task 找不到会抛 500:not-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`=RPUSH(pickle),`get_wait`→`ablpop`=BLPOP(unpickle),`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 自然结束、协程 return,done_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` 仍为 None(planner 没产 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` upsert(thinking 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/images,offload-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/grep(FilesystemMiddleware) | 默认装,挂 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" 断言) |
|
||||
| Skills(SkillsMiddleware) | 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 文件系统:WorkspaceBackend(offload-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 再立即落 MinIO;read 走 `_materialize`(先 cache 后 MinIO 回填);ls 以 MinIO 为权威。大文件不进窗口——`prepare_file_list` 只给指针,body 按需 `read_file`。
|
||||
|
||||
### 3.5 checkpointer 与防腐层
|
||||
|
||||
- **`PlainRedisCheckpointer`**(`checkpointer.py:59`):纯标准 Redis 命令避开 RediSearch,thread_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 kernel(write_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 文件随 checkpoint(park/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-first(WorkspaceBackend + 指针块)**:牺牲"每写都打 MinIO"的 I/O 成本与"文件不随 checkpoint",换上下文窗口不被文件正文撑爆、大文件按需读、交付物有持久真理源。
|
||||
- **防腐层 + 分布式队列**:牺牲单进程的简洁与"少一跳",换排队/并发/断线/跨进程恢复/可审计落库的工业能力。
|
||||
|
||||
净效果:灵思任务模式不是"deepagents 的功能超集",而是**为企业级交付正确性做了定向取舍的框架特化**——demo 强在上下文纯净度与规划确定性,灵思强在 HITL 工业化、交付物落地与分布式可调度。
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 关键代码锚点表(CURRENT,HEAD=`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` |
|
||||
| 前端 ExecutionFlow(WS/澄清/续问) | `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` |
|
||||
| StreamEventMapper(interrupt→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 message(history_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-A3B,MoE 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 透传不受影响;抢救组装。
|
||||
- L2:mock `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**。
|
||||
@@ -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/) | 设计科普(如"企业权限体系的前世今生") |
|
||||
@@ -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 (C1–C7)**, 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** (human–AI 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 C1–C7 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.
|
||||
@@ -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)。标签作用域是单个 KB(business_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 图片标记(``),渲染时建议保留。 |
|
||||
| `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 归一化。如下游对全局排序有强需求,可在调用前把同一类知识库分组、或在自己侧再做一次重排。
|
||||
|
||||
**Q:top_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` 开关。
|
||||
@@ -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`
|
||||
@@ -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 模式 |
|
||||
@@ -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 异步回调实现
|
||||
```
|
||||
@@ -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 异步任务定义
|
||||
@@ -1,393 +0,0 @@
|
||||
# Linsight Agent 框架与 MCP 协议集成
|
||||
|
||||
Linsight(灵思)是 BiSheng 平台内置的自主任务执行框架,面向需要多步骤推理、工具调用和人机交互的复杂任务场景。它通过 SOP(标准操作流程)将用户需求拆解为结构化的任务树,由 Agent 自主执行每个步骤,并在必要时暂停等待用户输入。MCP(Model 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` | 使用 ReAct(Reasoning + 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,同时在 Milvus(collection: `col_linsight_sop`)和 Elasticsearch 中建立索引。
|
||||
- **检索**:使用 `EnsembleRetriever` 混合向量检索和关键词检索,权重各 50%。
|
||||
- **批量导入**:支持从 Excel 文件批量导入 SOP,处理重名冲突(覆盖/另存/提示)。
|
||||
- **SOP 记录**:任务执行后自动生成 `LinsightSOPRecord`,记录执行效果和评分。
|
||||
- **向量库重建**:提供 `rebuild_sop_vector_store_task` 方法,支持在更换 Embedding 模型后重建全部向量索引。
|
||||
|
||||
## MCP 协议集成
|
||||
|
||||
MCP(Model 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 | Zustand(18+ 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-dist(PDF 渲染) |
|
||||
| `vendor-xlsx` | xlsx, mammoth 及其传递依赖(文档处理) |
|
||||
| `vendor-editor` | react-ace, ace-builds, react-syntax-highlighter, vditor(代码/文本编辑) |
|
||||
| `vendor-markdown` | react-markdown, rehype/remark 插件, MathJax, DOMPurify(Markdown 渲染) |
|
||||
| `vendor` | 其余所有 node_modules(React, Radix, recharts, xyflow, i18n 等) |
|
||||
|
||||
业务代码不做手动分包,依赖 Rollup 根据 `lazy()` 动态导入自动生成 code splitting。
|
||||
|
||||
#### Client 分包
|
||||
|
||||
Client 采用更细粒度的分包策略,将 30+ 种依赖分别拆分为独立 chunk(sandpack, 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`
|
||||
@@ -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`)负责管理数据库引擎的创建和连接池配置:
|
||||
|
||||
- 自动将同步 URL(pymysql)转换为异步 URL(aiomysql)
|
||||
- 连接池默认配置:`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) | 存储服务部署与配置系统 |
|
||||
@@ -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 | 模型微调服务,需要 GPU(nvidia 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 # 端口 3001,API 代理到 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. 本地 IDE(Claude 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`
|
||||
@@ -1,357 +0,0 @@
|
||||
# 开发指南
|
||||
|
||||
本文档面向 BiSheng 项目的开发者,涵盖环境搭建、服务启动、新模块开发约定、工作流节点扩展、API 端点添加、测试和代码风格规范。后端使用 Python 3.11(pyproject `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. 安装后端依赖(使用 uv,lockfile 为 uv.lock)
|
||||
cd src/backend
|
||||
uv sync --frozen --python $(which python)
|
||||
```
|
||||
|
||||
`uv sync` 会在 `src/backend/.venv/` 下创建虚拟环境并安装全部依赖。后续启动服务均通过 `.venv/bin/` 下的可执行文件调用。
|
||||
|
||||
### 前端环境
|
||||
|
||||
```bash
|
||||
# Platform 前端(主应用)
|
||||
cd src/frontend/platform
|
||||
npm install
|
||||
|
||||
# Client 前端(客户端嵌入应用)
|
||||
cd src/frontend/client
|
||||
npm 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
|
||||
npm 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 ORM,DAO 方法(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`
|
||||
@@ -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`)
|
||||
- DefaultGroup(id=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 配置项
|
||||
@@ -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 配置 POJO(URL、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 个端点 URL(authorizeUrl, 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` 实例(令牌桶算法)
|
||||
- 未配置规则的资源使用默认 RateLimiter(10 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` 频道通知 Gateway,Gateway 的 `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: # 自定义 SSO(OAuth2 标准流程)
|
||||
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 权限体系设计
|
||||
@@ -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["签发新 JWT(tenant_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 与多租户的集成方向
|
||||
@@ -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 等。
|
||||
@@ -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
@@ -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`).
|
||||
@@ -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_pyautogen(autogen)**:仅被遗留的动态 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/TTS(openai 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 历史)。
|
||||
@@ -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 过白名单后的收件人 skip(skipped) | `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 QPS(5 分钟速率):
|
||||
```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_*` 桶恒为累积计数。
|
||||
@@ -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 审查 spec(11 项检查)
|
||||
↓ ★ 手动暂停点:用户确认
|
||||
4. 编写 tasks.md 拆解为原子任务
|
||||
↓
|
||||
5. /sdd-review <dir> tasks 审查 tasks(17 项,自动推进)
|
||||
↓
|
||||
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. 填写领域对象归属、不变量、依赖图
|
||||
```
|
||||
@@ -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 暴露 |
|
||||
@@ -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 评审不通过
|
||||
|
||||
---
|
||||
|
||||
## 表 3:Feature 依赖图
|
||||
|
||||
| Feature | 依赖(必须先完成) | 说明 |
|
||||
|---------|-----------------|------|
|
||||
| F{NNN}-{name} | F{NNN}-{name} | 原因:需要某领域对象/API |
|
||||
|
||||
---
|
||||
|
||||
## 已分配模块编码(MMMEE)
|
||||
|
||||
> 新 Feature 分配错误码时,必须检查此表避免冲突。
|
||||
|
||||
| 模块编码 (MMM) | 模块 | Owner Feature |
|
||||
|----------------|------|---------------|
|
||||
| _(从 common/errcode/ 中同步已有编码,新增时在此追加)_ | — | — |
|
||||
|
||||
---
|
||||
|
||||
## 变更历史
|
||||
|
||||
| 日期 | 变更内容 | 影响范围 |
|
||||
|------|---------|---------|
|
||||
| YYYY-MM-DD | 初始版本 | — |
|
||||
@@ -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 链接>
|
||||
@@ -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 classmethod(get_xxx/aget_xxx/create_xxx/update_xxx/delete_xxx)
|
||||
**依赖**: 无
|
||||
|
||||
- [ ] **T002**: 错误码定义
|
||||
**文件**: `src/backend/bisheng/common/errcode/{module}.py`
|
||||
**逻辑**: 定义 MMMEE 错误码类,继承 BaseErrorCode,在 release-contract.md 注册模块编码
|
||||
**依赖**: 无
|
||||
|
||||
### 后端 Domain Service(Test-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_id,Worker 执行前恢复 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.py(DB 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 fixture(Redis → 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 PASSED,test-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: Vitest(Platform)/ B: Jest(统一) | 选 A | Platform 使用 Vite 构建,Vitest 天然共享 `vite.config.mts`(路径别名、插件等)。Client 已有 Jest 配置但不在 F000 范围 |
|
||||
| AD-04 | F001 测试回迁 | A: 不回迁 / B: 统一迁移到共享 fixture | 选 A | F001 测试稳定且自包含,迁移有回归风险无业务价值。新 Feature 使用共享 fixture,F001 保持原样 |
|
||||
|
||||
---
|
||||
|
||||
## 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、markers(e2e/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` | 测试 setup(jest-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` 全部 PASSED(F001 回归)
|
||||
**覆盖 AC**: AC-01(import 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` ContextVar,yield 后 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` 全部 PASSED(F001 回归)
|
||||
- 编写一个临时测试验证 `db_session` 能做 CRUD
|
||||
**覆盖 AC**: AC-02, AC-03
|
||||
**依赖**: T002, T003
|
||||
|
||||
- [x] **T005**: 外部服务 mock(Redis / 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 UserPayload(user_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 | T001(pytest 配置)+ T002(import chain 兼容) |
|
||||
| AC-02 | T003(DDL 定义)+ T004(db_session fixture) |
|
||||
| AC-03 | T004(async_db_session fixture) |
|
||||
| AC-04 | T005(mock_redis fixture) |
|
||||
| AC-05 | T005(mock_minio fixture) |
|
||||
| AC-06 | T005(mock_openfga fixture) |
|
||||
| AC-07 | T006(test_client fixture) |
|
||||
| AC-08 | T007(create_tenant 工厂函数) |
|
||||
| AC-09 | T008(smoke test 全部通过) |
|
||||
| AC-10 | T008(F001 回归验证) |
|
||||
| AC-11 | T009(Vitest 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 ORM(PRD §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.py(SQLite 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。所有后续 Feature(F002~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 并设置 ContextVar(HTTP 中间件和 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 fixture(SQLite 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. 错误码表
|
||||
|
||||
> 模块编码 200(tenant),已在 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 fixture:SQLite 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` fixture(SQLite 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~20004(TenantNotFoundError, 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
|
||||
**依赖**: T001(conftest 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=1,SELECT 只返回 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 的旧 JWT,tenant_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 | T002(ORM 模型定义 + DAO 测试) |
|
||||
| AC-02 | T009(默认租户初始化 + DDL 迁移) |
|
||||
| AC-03 | T009(DDL 迁移 23+ 表) |
|
||||
| AC-04 | T004(do_orm_execute 事件钩子) |
|
||||
| AC-05 | T004(before_flush 事件钩子) |
|
||||
| AC-06 | T003(bypass 机制)+ T004(集成验证) |
|
||||
| AC-07 | T005(JWT 编解码)+ T006(中间件运行时) |
|
||||
| AC-08 | T005(LoginUser.tenant_id 字段) |
|
||||
| AC-09 | T007(Celery 信号) |
|
||||
| AC-10 | T008(存储前缀函数) |
|
||||
| AC-11 | T004(enabled=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 解码逻辑
|
||||
@@ -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 ORM(user_id、department_id、is_primary、source)
|
||||
- Department CRUD API(创建/详情/树形查询/更新/归档/移动/成员增删/成员列表)共 9 个端点
|
||||
- 物化路径维护逻辑(创建/移动/删除时自动更新 path)
|
||||
- 提供 `create_root_department(tenant_id, name)` 服务方法供租户创建调用
|
||||
- init_data 为默认租户(id=1)自动创建根部门
|
||||
- DepartmentChangeHandler:TupleOperation DTO 定义 + 事件方法产出元组列表 + execute() 日志 stub
|
||||
- 错误码模块 210(21000~21009)
|
||||
|
||||
**OUT**:
|
||||
- 前端部门管理页面 → F010-tenant-management-ui
|
||||
- 三方组织同步 → F009-org-sync(P2)
|
||||
- 部门管理员(admin)CRUD → F004-rebac-core(admin 关系存 OpenFGA)
|
||||
- 部门 admin OpenFGA 元组实际写入 → 委托 F004-rebac-core 的 PermissionService
|
||||
- 复杂授权 UI → F007-resource-permission-ui
|
||||
|
||||
**关键决策(预判)**:
|
||||
- AD-01: 物化路径格式 `/1/2/3/`,子树查询用 `LIKE '/1/2/%'`
|
||||
- AD-02: DepartmentChangeHandler 产出元组操作列表但不直接写 OpenFGA,委托 PermissionService(F004),尊重领域归属边界
|
||||
- AD-03: 根部门创建是租户创建的同步副作用,F002 提供 service 方法,F001/F010 调用
|
||||
- AD-04: ORM 放 `database/models/department.py`,与 Tenant(F001)一致
|
||||
- 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/departments(name="研发部", parent_id=根部门 id) | 返回 200,data 含 id/dept_id/name/path,path 格式 `/{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}/move(new_parent_id=有效部门) | 返回 200,该部门及其所有子孙的 path 正确更新 |
|
||||
| AC-11 | 管理员 | POST move 将部门移到自己的子孙下 | 返回 21004 DepartmentCircularMoveError |
|
||||
| AC-12 | 管理员 | POST /api/v1/departments/{dept_id}/members(user_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 子树 path(REPLACE 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_user,TABLE_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-memory(conftest.py fixture)。Service 测试可 mock DAO。API 测试使用 TestClient
|
||||
- **可扩展性**: DepartmentChangeHandler 的 TupleOperation DTO 是 F004 集成的契约边界。dept_id 前缀可配置化。source 字段为 F009 三方同步预留
|
||||
|
||||
---
|
||||
|
||||
## 11. 错误码表
|
||||
|
||||
> 模块编码 210(department),已在 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 的 T002(ORM+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`** classmethods(sync `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 Department(dept_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 Department(parent_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 | ✓ | — | — | — |
|
||||
@@ -1,534 +0,0 @@
|
||||
# Feature: 用户组
|
||||
|
||||
> **前置步骤**:本文档编写前已完成 Spec Discovery(架构师提问),
|
||||
> PRD 中的不确定性已与用户对齐。
|
||||
|
||||
**关联 PRD**: [2.5 权限管理体系改造 PRD §3.2.2](../../docs/archive/2.5%20权限管理体系改造%20PRD/2.5%20权限管理体系改造%20PRD.md)
|
||||
**优先级**: P0
|
||||
**所属版本**: v2.5.0
|
||||
|
||||
---
|
||||
|
||||
## 范围界定
|
||||
|
||||
**IN**:
|
||||
- 复用现有 Group 表,扩展 `visibility` 列(`tenant_id` 已由 F001 迁移添加)
|
||||
- 改 `group_name` 唯一约束从全局改为 `(tenant_id, group_name)` 复合唯一
|
||||
- 新建 `user_group/` DDD 模块(api/ + domain/),提供 9 个 REST 端点
|
||||
- UserGroup CRUD API(创建/列表/详情/更新/删除)
|
||||
- 成员管理 API(列表/添加/移除)+ 管理员设置 API
|
||||
- 可见性:public(全租户可见)/ private(仅超管/创建者/成员可见)
|
||||
- GroupChangeHandler:TupleOperation DTO 定义 + 事件方法产出元组列表 + execute() 日志 stub
|
||||
- 成员变更时同步产出 OpenFGA `(user_group:X, member, user:Y)` 元组(stub)
|
||||
- 管理员变更时同步产出 OpenFGA `(user_group:X, admin, user:Y)` 元组(stub)
|
||||
- init_data 确保默认租户的默认用户组有 tenant_id=1 + visibility='public'
|
||||
- Alembic 迁移脚本(加 visibility 列 + 改 unique 约束)
|
||||
- 错误码模块 230(23000~23006)
|
||||
|
||||
**OUT**:
|
||||
- 资源通过用户组授权 → F004-rebac-core 处理检查,F008 处理模块集成
|
||||
- 用户组在资源授权 UI 中的选择 → F007-resource-permission-ui
|
||||
- 前端用户组管理页面更新 → 推迟(现有前端继续使用旧 `/api/v1/group/` API)
|
||||
- OpenFGA 元组实际写入 → 委托 F004-rebac-core 的 PermissionService
|
||||
- GroupResource 表操作 → F006-permission-migration 处理废弃迁移
|
||||
|
||||
**关键决策(预判)**:
|
||||
- AD-01: 新建 `user_group/` DDD 模块,旧 `api/v1/usergroup.py` + `RoleGroupService` 保留不动
|
||||
- AD-02: ORM 继续在 `database/models/group.py` 和 `user_group.py`,与 F001/F002 一致
|
||||
- AD-03: Group 表加 `visibility` 列 + 改 unique 约束为 `(tenant_id, group_name)`
|
||||
- AD-04: UserGroup 表 tenant_id 不额外处理(F001 已添加,SQLAlchemy event 自动注入)
|
||||
- AD-05: 权限检查暂用系统管理员判断(`login_user.is_admin()`),F004 后替换
|
||||
- AD-06: is_group_admin 保留 MySQL 字段 + GroupChangeHandler 产出 admin 元组(双轨)
|
||||
- AD-07: 用户组硬删除(临时项目组无需归档,与部门 archived 模式不同)
|
||||
- AD-08: GroupChangeHandler 独立定义 TupleOperation(与 F002 相同 dataclass)
|
||||
- AD-09: 新端点 `/api/v1/user-groups/...`,旧 `/api/v1/group/...` 原样保留
|
||||
|
||||
**关键文件(预判)**:
|
||||
- 修改: `src/backend/bisheng/database/models/group.py`(扩展字段 + async DAO)
|
||||
- 修改: `src/backend/bisheng/database/models/user_group.py`(async DAO)
|
||||
- 新建: `src/backend/bisheng/user_group/`(DDD 模块:api/ + domain/)
|
||||
- 新建: `src/backend/bisheng/common/errcode/user_group.py`
|
||||
- 新建: `src/backend/bisheng/core/database/alembic/versions/v2_5_0_f003_user_group.py`
|
||||
- 修改: `src/backend/bisheng/api/router.py`(路由注册)
|
||||
- 修改: `src/backend/bisheng/common/init_data.py`(默认用户组初始化)
|
||||
|
||||
**关联不变量**: INV-1, INV-2
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述与用户故事
|
||||
|
||||
F003 为 BiSheng 引入多租户感知的用户组管理。用户组是跨部门的临时项目组,与部门(长期组织架构)互补。用户组是 ReBAC 权限体系的三大授权主体之一(`user`、`department#member`、`user_group#member`),F003 建立其数据基础和 OpenFGA 契约。
|
||||
|
||||
无论多租户是否启用,用户组功能始终可用。单租户模式下默认租户(id=1)拥有全部用户组;多租户模式下每个租户有独立的用户组空间,名称仅需租户内唯一。
|
||||
|
||||
**用户故事 1**:
|
||||
作为 **BiSheng 系统管理员**,
|
||||
我希望 **能创建、编辑和删除用户组,并设置用户组的公开/私密可见性**,
|
||||
以便 **灵活管理跨部门项目团队,私密组可用于敏感项目的权限隔离**。
|
||||
|
||||
**用户故事 2**:
|
||||
作为 **BiSheng 系统管理员**,
|
||||
我希望 **能向用户组批量添加/移除成员,并设置组管理员**,
|
||||
以便 **后续通过用户组批量授予资源访问权限,组管理员可辅助管理组内事务**。
|
||||
|
||||
**用户故事 3**:
|
||||
作为 **BiSheng 普通用户**,
|
||||
我希望 **能看到所有公开的用户组和自己加入的私密用户组**,
|
||||
以便 **了解自己的组织归属,在资源授权时选择合适的用户组**。
|
||||
|
||||
**用户故事 4**:
|
||||
作为 **BiSheng 后续 Feature 开发者**,
|
||||
我希望 **F003 提供 GroupChangeHandler 元组 DTO 和 `acreate_default_group()` 服务方法**,
|
||||
以便 **F004 的 PermissionService 能消费用户组变更事件,F010 的租户创建流程能原子地创建默认用户组**。
|
||||
|
||||
**用户故事 5**:
|
||||
作为 **BiSheng 运维人员**,
|
||||
我希望 **升级到 v2.5.0 时现有用户组自动获得 visibility='public',旧 API 继续正常工作**,
|
||||
以便 **零迁移成本升级,不影响现有功能**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验收标准
|
||||
|
||||
> AC-ID 在本特性内唯一,格式 `AC-NN`。
|
||||
> tasks.md 中的测试任务必须通过 `覆盖 AC: AC-NN` 追溯到此表。
|
||||
|
||||
| ID | 角色 | 操作 | 预期结果 |
|
||||
|----|------|------|---------|
|
||||
| AC-01 | 管理员 | POST /api/v1/user-groups(group_name="Project Alpha", visibility="public") | 返回 200,data 含 id/group_name/visibility/create_time |
|
||||
| AC-02 | 管理员 | POST 创建租户内同名用户组 | 返回 23001 UserGroupNameDuplicateError |
|
||||
| AC-03 | 管理员 | POST 在不同租户创建同名用户组 | 返回 200 成功(名称唯一性是租户级别) |
|
||||
| AC-04 | 管理员 | GET /api/v1/user-groups?page=1&limit=20 | 返回 200,分页列表,每个组含 member_count + group_admins |
|
||||
| AC-05 | 管理员 | GET /api/v1/user-groups/{id}(存在的 group_id) | 返回 200,完整组详情含 member_count + group_admins |
|
||||
| AC-06 | 管理员 | GET /api/v1/user-groups/{id}(不存在的 group_id) | 返回 23000 UserGroupNotFoundError |
|
||||
| AC-07 | 管理员 | PUT /api/v1/user-groups/{id}(修改 group_name) | 返回 200,group_name 已变更 |
|
||||
| AC-08 | 管理员 | PUT 修改 group_name 为租户内已存在的名称 | 返回 23001 UserGroupNameDuplicateError |
|
||||
| AC-09 | 管理员 | DELETE /api/v1/user-groups/{id}(空组,非默认组) | 返回 200,组已删除 |
|
||||
| AC-10 | 管理员 | DELETE 默认用户组 | 返回 23002 UserGroupDefaultProtectedError |
|
||||
| AC-11 | 管理员 | DELETE 有成员的用户组 | 返回 23003 UserGroupHasMembersError |
|
||||
| AC-12 | 管理员 | POST /api/v1/user-groups/{id}/members(user_ids=[3,5,7]) | 返回 200,三个用户成为组成员 |
|
||||
| AC-13 | 管理员 | POST members 添加已存在的成员 | 返回 23004 UserGroupMemberExistsError |
|
||||
| AC-14 | 管理员 | GET /api/v1/user-groups/{id}/members?page=1&limit=20 | 返回 200,分页成员列表(PageData 格式),含 user_id/user_name |
|
||||
| AC-15 | 管理员 | DELETE /api/v1/user-groups/{id}/members/{user_id} | 返回 200,用户从组中移除 |
|
||||
| AC-16 | 管理员 | DELETE 不存在的组成员 | 返回 23005 UserGroupMemberNotFoundError |
|
||||
| AC-17 | 非管理员 | 调用任何管理员专属的用户组 API(创建/更新/删除/添加成员等) | 返回 23006 UserGroupPermissionDeniedError |
|
||||
| AC-18 | 非管理员 | GET 用户组列表(该用户是某 private 组 "X" 的成员) | 列表包含组 "X" 和所有 public 组 |
|
||||
| AC-19 | 非管理员 | GET private 组详情(该用户不是成员) | 返回 23006 UserGroupPermissionDeniedError |
|
||||
| AC-20 | 管理员 | PUT /api/v1/user-groups/{id}/admins(user_ids=[1,5]) | 返回 200,管理员列表被全量替换,GroupChangeHandler.on_admin_set/on_admin_removed 被调用 |
|
||||
| AC-21 | 运维人员 | 首次启动应用(已有默认租户和默认用户组) | 默认用户组(id=2)的 tenant_id=1, visibility='public' |
|
||||
| AC-22 | 开发者 | 用户组创建/删除/成员变更/管理员变更后检查 GroupChangeHandler | 各事件方法返回正确的 TupleOperation 列表(action/user/relation/object) |
|
||||
| AC-23 | 运维人员 | 升级后调用旧 API /api/v1/group/* | 所有现有端点继续正常工作 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 边界情况
|
||||
|
||||
- 当 **并发创建同名用户组**时,依赖 MySQL `uk_tenant_group_name` 复合唯一约束。后到的请求收到 IntegrityError,Service 层捕获后返回 23001。不加分布式锁
|
||||
- 当 **批量添加成员时部分已存在**时,采用整体拒绝策略:任一 user_id 已是成员则全部拒绝并返回 23004(与 F002 部门成员一致)
|
||||
- 当 **删除组管理员后该用户仍是组成员**时,仅移除 `is_group_admin=1` 的记录,保留 `is_group_admin=0` 的成员记录。如果该用户只有 admin 记录没有 member 记录,则只移除 admin 身份
|
||||
- 当 **multi_tenant.enabled=false** 时,所有用户组操作正常工作,tenant_id 自动填充为默认租户(id=1)
|
||||
- 当 **GroupChangeHandler.execute() 被调用**时,当前为日志 stub(F004 未实现),不影响用户组操作本身的执行
|
||||
- 当 **默认用户组(id=2)被尝试删除或重命名**时,返回 23002 保护错误。默认组名称和存在性是系统约束
|
||||
- 当 **非管理员查看用户组列表**时,只返回 public 组 + 该用户所属的 private 组(通过 JOIN user_group 过滤)
|
||||
- 当 **Alembic 迁移在已有数据的生产环境执行**时,visibility 列 DEFAULT 'public' 确保所有现有组自动获得 public 可见性,无需额外回填
|
||||
|
||||
---
|
||||
|
||||
## 4. 架构决策
|
||||
|
||||
| ID | 决策 | 选项 | 结论 | 理由 |
|
||||
|----|------|------|------|------|
|
||||
| AD-01 | 模块结构 | A: 新建 `user_group/` DDD 模块 / B: 扩展现有 `api/v1/usergroup.py` | 选 A | F002 建立的 DDD 模式。旧代码混合了 GroupResource 逻辑,新模块干净分离 |
|
||||
| AD-02 | ORM 位置 | A: `database/models/group.py` / B: `user_group/domain/models/` | 选 A | 与 F001 Tenant、F002 Department 一致,ORM+DAO 内聚在 models 文件 |
|
||||
| AD-03 | Group 表变更策略 | A: 仅加 visibility + 改 unique 约束(tenant_id 已由 F001 添加)/ B: 全面重建表 | 选 A | 最小变更原则,F001 Alembic 已添加 tenant_id |
|
||||
| AD-04 | UserGroup 表 tenant_id | A: 不额外处理(F001 已添加,自动注入)/ B: 加冗余 tenant_id 字段 | 选 A | 与 F002 UserDepartment 一致,关联表隔离通过主表传递 |
|
||||
| AD-05 | F004 前的权限检查 | A: 管理员判断 `login_user.is_admin()` / B: 无权限检查 | 选 A | 与 F002 AD-05 一致,基本安全保障 |
|
||||
| AD-06 | 删除策略 | A: 硬删除 / B: 软删除(archived) | 选 A | 用户组是临时项目组,无需归档保留。与现有 GroupDao.delete_group 行为一致 |
|
||||
| AD-07 | is_group_admin 处理 | A: 保留 MySQL + GroupChangeHandler 双轨 / B: 立即废弃 | 选 A | 旧 API/前端依赖该字段,F006 迁移后可废弃。双轨确保向后兼容 |
|
||||
| AD-08 | ChangeHandler 模式 | A: 独立定义 TupleOperation(与 F002 相同 dataclass)/ B: 复用 F002 的导入 | 选 A | 模块独立性,F004 会提供统一入口后各模块 Handler 可收拢 |
|
||||
| AD-09 | API 路径 | A: 新端点 `/api/v1/user-groups/`,旧保留 / B: 修改旧端点 | 选 A | 旧 API 与 GroupResource 耦合,修改风险大。新 API 干净分离 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据库 & Domain 模型
|
||||
|
||||
### 数据库表定义
|
||||
|
||||
#### group 表(已有,扩展)
|
||||
|
||||
```python
|
||||
class GroupBase(SQLModelSerializable):
|
||||
group_name: str = Field(index=False, description='用户组名称') # 去掉 unique=True
|
||||
remark: Optional[str] = Field(default=None, index=False)
|
||||
create_user: Optional[int] = Field(default=None, index=True)
|
||||
update_user: Optional[int] = Field(default=None)
|
||||
create_time: Optional[datetime] = Field(default=None, sa_column=Column(
|
||||
DateTime, nullable=False, index=True, 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')))
|
||||
|
||||
|
||||
class Group(GroupBase, table=True):
|
||||
__tablename__ = 'group'
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
tenant_id: int = Field(
|
||||
default=1,
|
||||
sa_column=Column(Integer, nullable=False, server_default=text('1'),
|
||||
index=True, comment='Tenant ID'),
|
||||
)
|
||||
visibility: str = Field(
|
||||
default='public',
|
||||
sa_column=Column(String(16), nullable=False, server_default=text("'public'"),
|
||||
comment='Visibility: public/private'),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('tenant_id', 'group_name', name='uk_tenant_group_name'),
|
||||
)
|
||||
```
|
||||
|
||||
#### user_group 表(已有,无结构变更)
|
||||
|
||||
UserGroup 表结构不变。`tenant_id` 已由 F001 Alembic 迁移添加,SQLAlchemy event 自动注入。`is_group_admin` 保留用于旧 API 兼容。
|
||||
|
||||
### Alembic 迁移
|
||||
|
||||
```python
|
||||
# v2_5_0_f003_user_group.py
|
||||
# Revises: f002_department_tree (或实际的上一个 revision)
|
||||
|
||||
def upgrade():
|
||||
# 1. Add visibility column
|
||||
op.add_column('group', sa.Column('visibility', sa.String(16), nullable=False,
|
||||
server_default='public', comment='Visibility: public/private'))
|
||||
# 2. Drop old unique index on group_name (if exists)
|
||||
try:
|
||||
op.drop_index('ix_group_group_name', table_name='group')
|
||||
except Exception:
|
||||
pass # Index may not exist or have different name
|
||||
# 3. Add composite unique constraint
|
||||
op.create_unique_constraint('uk_tenant_group_name', 'group', ['tenant_id', 'group_name'])
|
||||
```
|
||||
|
||||
### DAO 方法
|
||||
|
||||
| 类 | 方法 | 说明 |
|
||||
|----|------|------|
|
||||
| GroupDao | `aget_by_id(group_id)` | 按 ID 异步查询 |
|
||||
| GroupDao | `acreate(group)` | 异步插入(flush + refresh 获取 id) |
|
||||
| GroupDao | `aupdate(group)` | 异步更新(commit + refresh) |
|
||||
| GroupDao | `adelete(group_id)` | 异步硬删除 |
|
||||
| GroupDao | `aget_all_groups(page, limit, keyword)` | 分页查询当前租户全部组(tenant 自动过滤) |
|
||||
| GroupDao | `acheck_name_duplicate(name, exclude_id=None)` | 租户内名称重复检查,返回 bool |
|
||||
| GroupDao | `aget_visible_groups(user_id, page, limit, keyword)` | 非 admin 视角:public + 用户所属的 private 组 |
|
||||
| UserGroupDao | `aget_group_members(group_id, page, limit, keyword)` | 分页成员列表 JOIN user(is_group_admin=0),含 user_name |
|
||||
| UserGroupDao | `aget_group_member_count(group_id)` | 非 admin 成员计数 |
|
||||
| UserGroupDao | `aadd_members_batch(group_id, user_ids)` | 批量添加成员(is_group_admin=0) |
|
||||
| UserGroupDao | `aremove_member(group_id, user_id)` | 移除单个成员 |
|
||||
| UserGroupDao | `acheck_members_exist(group_id, user_ids)` | 返回已存在于组中的 user_id 列表 |
|
||||
| UserGroupDao | `aget_group_admins_detail(group_id)` | 获取组管理员 JOIN user(含 user_id + user_name) |
|
||||
| UserGroupDao | `aset_admins_batch(group_id, add_ids, remove_ids)` | 批量设置/移除管理员 |
|
||||
| UserGroupDao | `aget_user_visible_group_ids(user_id)` | 获取用户所属(member 或 admin)的所有 group_id 列表 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API 契约
|
||||
|
||||
### 6.1 创建用户组
|
||||
|
||||
```
|
||||
POST /api/v1/user-groups
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"group_name": "Project Alpha", // required, 1-128 chars
|
||||
"visibility": "public", // optional, default "public", enum: public/private
|
||||
"remark": "跨部门项目组", // optional
|
||||
"admin_user_ids": [1, 5] // optional, initial admin user IDs
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"status_message": "success",
|
||||
"data": {
|
||||
"id": 3,
|
||||
"group_name": "Project Alpha",
|
||||
"visibility": "public",
|
||||
"remark": "跨部门项目组",
|
||||
"create_user": 1,
|
||||
"create_time": "2026-04-12T10:00:00",
|
||||
"update_time": "2026-04-12T10:00:00",
|
||||
"member_count": 0,
|
||||
"group_admins": [
|
||||
{"user_id": 1, "user_name": "admin"},
|
||||
{"user_id": 5, "user_name": "manager1"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 用户组列表
|
||||
|
||||
```
|
||||
GET /api/v1/user-groups
|
||||
Auth: UserPayload (admin sees all; non-admin sees public + own groups)
|
||||
Query: page=1, limit=20, keyword="" (search by group_name)
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"status_message": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": 2,
|
||||
"group_name": "Default user group",
|
||||
"visibility": "public",
|
||||
"remark": null,
|
||||
"member_count": 5,
|
||||
"create_user": 1,
|
||||
"create_time": "2026-04-01T00:00:00",
|
||||
"update_time": "2026-04-12T10:00:00",
|
||||
"group_admins": [{"user_id": 1, "user_name": "admin"}]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 用户组详情
|
||||
|
||||
```
|
||||
GET /api/v1/user-groups/{group_id}
|
||||
Auth: UserPayload (admin or group member/admin for private groups)
|
||||
Path: group_id — 用户组 ID (int)
|
||||
```
|
||||
|
||||
**Response** (200): 同创建响应格式 + member_count + group_admins
|
||||
|
||||
### 6.4 更新用户组
|
||||
|
||||
```
|
||||
PUT /api/v1/user-groups/{group_id}
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**Request Body**(partial update,仅传需要修改的字段):
|
||||
```json
|
||||
{
|
||||
"group_name": "New Name", // optional, 1-128 chars
|
||||
"visibility": "private", // optional
|
||||
"remark": "Updated remark" // optional
|
||||
}
|
||||
```
|
||||
|
||||
**约束**: 不可修改默认用户组(id=DefaultGroup)的 group_name
|
||||
|
||||
### 6.5 删除用户组
|
||||
|
||||
```
|
||||
DELETE /api/v1/user-groups/{group_id}
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**约束**: 默认组返回 23002,有成员返回 23003。成功后硬删除。
|
||||
|
||||
### 6.6 成员列表
|
||||
|
||||
```
|
||||
GET /api/v1/user-groups/{group_id}/members
|
||||
Auth: UserPayload (admin or group member for private groups)
|
||||
Query: page=1, limit=20, keyword="" (search by user_name)
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
```json
|
||||
{
|
||||
"status_code": 200,
|
||||
"status_message": "success",
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"user_id": 3,
|
||||
"user_name": "alice",
|
||||
"is_group_admin": false,
|
||||
"create_time": "2026-04-12T10:00:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.7 批量添加成员
|
||||
|
||||
```
|
||||
POST /api/v1/user-groups/{group_id}/members
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"user_ids": [3, 5, 7] // required, non-empty
|
||||
}
|
||||
```
|
||||
|
||||
**约束**: 已存在的成员关系返回 23004(整体原子性:任一冲突则全部拒绝)。
|
||||
|
||||
### 6.8 移除成员
|
||||
|
||||
```
|
||||
DELETE /api/v1/user-groups/{group_id}/members/{user_id}
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**约束**: 不存在的成员关系返回 23005。
|
||||
|
||||
### 6.9 设置组管理员
|
||||
|
||||
```
|
||||
PUT /api/v1/user-groups/{group_id}/admins
|
||||
Auth: UserPayload (admin only)
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"user_ids": [1, 5] // required, full replacement of admin list
|
||||
}
|
||||
```
|
||||
|
||||
**逻辑**: 全量替换——提供的列表成为完整的管理员集合。Diff 当前与新列表,产出 on_admin_set/on_admin_removed 元组。
|
||||
|
||||
---
|
||||
|
||||
## 7. Service 层逻辑
|
||||
|
||||
### UserGroupService
|
||||
|
||||
| 方法 | 核心逻辑 |
|
||||
|------|---------|
|
||||
| `acreate_group(data, login_user)` | 权限检查(admin) → 校验 group_name 租户内不重复(23001) → INSERT Group → 设置初始 admins(如提供) → 调用 ChangeHandler.on_created → 返回完整对象 |
|
||||
| `alist_groups(page, limit, keyword, login_user)` | admin 查全部组 / 非 admin 查 public + 所属组 → 分页 → 批量附加 member_count + group_admins → 返回 PageData |
|
||||
| `aget_group(group_id, login_user)` | 按 ID 查询(23000 not found) → 可见性检查(private 组非 admin 需是成员, 23006) → 附加 member_count + admins → 返回 |
|
||||
| `aupdate_group(group_id, data, login_user)` | 权限检查 → 查询组(23000) → 如修改 group_name 则校验不重复(23001) → UPDATE → 返回 |
|
||||
| `adelete_group(group_id, login_user)` | 权限检查 → 不可删默认组(23002) → 检查无成员(23003) → DELETE → 调用 ChangeHandler.on_deleted → 返回 |
|
||||
| `aget_members(group_id, page, limit, keyword, login_user)` | 可见性检查(private 组非 admin 需是成员) → 分页查询 UserGroup JOIN User(is_group_admin=0) → 返回 PageData |
|
||||
| `aadd_members(group_id, user_ids, login_user)` | 权限检查 → 校验组存在(23000) → 校验无重复成员(23004) → 批量 INSERT → 调用 ChangeHandler.on_members_added → 返回 |
|
||||
| `aremove_member(group_id, user_id, login_user)` | 权限检查 → 校验成员关系存在(23005) → DELETE → 调用 ChangeHandler.on_member_removed → 返回 |
|
||||
| `aset_admins(group_id, user_ids, login_user)` | 权限检查 → 获取当前 admin 列表 → Diff(to_add, to_remove) → 批量增删 admin 行 → 调用 ChangeHandler.on_admin_set/on_admin_removed → 返回 |
|
||||
| `acreate_default_group(tenant_id, creator_id)` | 静态方法,使用 bypass_tenant_filter → INSERT 默认组(group_name='Default user group', visibility='public') → 返回。供 init_data / F010 调用 |
|
||||
|
||||
### GroupChangeHandler
|
||||
|
||||
为 F004 预留的契约接口。定义 `TupleOperation` DTO,各事件方法产出正确的元组操作列表,`execute()` 当前为日志 stub。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class TupleOperation:
|
||||
action: Literal['write', 'delete']
|
||||
user: str # e.g. "user:7"
|
||||
relation: str # e.g. "member", "admin"
|
||||
object: str # e.g. "user_group:5"
|
||||
```
|
||||
|
||||
| 事件方法 | 产出的元组 |
|
||||
|---------|-----------|
|
||||
| `on_created(group_id, creator_id)` | `write(user:{creator}, admin, user_group:{group_id})` |
|
||||
| `on_deleted(group_id)` | 空列表(F004 负责级联清理删除组相关的全部元组) |
|
||||
| `on_members_added(group_id, user_ids)` | 每个 user_id: `write(user:{uid}, member, user_group:{group_id})` |
|
||||
| `on_member_removed(group_id, user_id)` | `delete(user:{uid}, member, user_group:{group_id})` |
|
||||
| `on_admin_set(group_id, user_ids)` | 每个 user_id: `write(user:{uid}, admin, user_group:{group_id})` |
|
||||
| `on_admin_removed(group_id, user_ids)` | 每个 user_id: `delete(user:{uid}, admin, user_group:{group_id})` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端设计
|
||||
|
||||
N/A — F003 不涉及前端变更。现有前端用户组管理页面继续使用旧 `/api/v1/group/` 端点。前端迁移到新 API 推迟到后续 Feature。
|
||||
|
||||
---
|
||||
|
||||
## 9. 文件清单
|
||||
|
||||
### 新建
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/backend/bisheng/common/errcode/user_group.py` | 230xx 用户组错误码(7 个) |
|
||||
| `src/backend/bisheng/user_group/__init__.py` | DDD 模块包 |
|
||||
| `src/backend/bisheng/user_group/api/__init__.py` | API 子包 |
|
||||
| `src/backend/bisheng/user_group/api/router.py` | 路由聚合,prefix `/user-groups` |
|
||||
| `src/backend/bisheng/user_group/api/endpoints/__init__.py` | 端点子包 |
|
||||
| `src/backend/bisheng/user_group/api/endpoints/user_group.py` | 用户组 CRUD 端点(5 个) |
|
||||
| `src/backend/bisheng/user_group/api/endpoints/user_group_member.py` | 成员管理端点(4 个) |
|
||||
| `src/backend/bisheng/user_group/domain/__init__.py` | 领域子包 |
|
||||
| `src/backend/bisheng/user_group/domain/schemas/__init__.py` | DTO 子包 |
|
||||
| `src/backend/bisheng/user_group/domain/schemas/user_group_schema.py` | 请求/响应 Pydantic DTO |
|
||||
| `src/backend/bisheng/user_group/domain/services/__init__.py` | 服务子包 |
|
||||
| `src/backend/bisheng/user_group/domain/services/user_group_service.py` | 用户组核心业务逻辑 |
|
||||
| `src/backend/bisheng/user_group/domain/services/group_change_handler.py` | TupleOperation DTO + 事件方法 + execute() 日志 stub |
|
||||
| `src/backend/bisheng/core/database/alembic/versions/v2_5_0_f003_user_group.py` | Alembic 迁移脚本 |
|
||||
| `src/backend/test/test_user_group_dao.py` | DAO 单元测试 |
|
||||
| `src/backend/test/test_group_change_handler.py` | ChangeHandler 单元测试 |
|
||||
| `src/backend/test/test_user_group_service.py` | Service 单元测试 |
|
||||
| `src/backend/test/test_user_group_api.py` | API 集成测试 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更内容 |
|
||||
|------|---------|
|
||||
| `src/backend/bisheng/database/models/group.py` | Group 类添加 `__tablename__`、`tenant_id`、`visibility` 字段声明、`__table_args__` 复合唯一约束;GroupBase 去掉 `unique=True`;GroupDao 添加 async 方法 |
|
||||
| `src/backend/bisheng/database/models/user_group.py` | UserGroupDao 添加 async 方法 |
|
||||
| `src/backend/bisheng/api/router.py` | 导入并注册 `user_group_router` |
|
||||
| `src/backend/bisheng/common/init_data.py` | 添加 `_init_default_user_group(session)` 函数确保默认组有 visibility='public' |
|
||||
| `features/v2.5.0/release-contract.md` | 注册模块编码 230 = user_group |
|
||||
| `src/backend/test/fixtures/table_definitions.py` | TABLE_GROUP 增加 visibility 列定义 |
|
||||
| `src/backend/test/fixtures/factories.py` | 添加 `create_group()` + `create_user_group_member()` 工厂函数 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 非功能要求
|
||||
|
||||
- **性能**: 列表查询分页,每页默认 20 条。admin 列表一次加载全部组(通常 < 100),内存附加 member_count + admins。非 admin 视角需额外 JOIN user_group 过滤,但数据量有限不构成瓶颈
|
||||
- **安全**: 管理操作(CRUD/成员管理)要求管理员权限(F004 前使用 `login_user.is_admin()`)。查询操作按可见性控制。tenant_id 自动过滤防止跨租户数据泄漏(INV-1)
|
||||
- **兼容性**: 旧 API `/api/v1/group/` 和 `RoleGroupService` 完全不动。Alembic 迁移 visibility 列 DEFAULT 'public' 确保存量数据零迁移。DefaultGroup=2 常量保留
|
||||
- **可测试性**: DAO 测试使用 SQLite in-memory(conftest.py fixture)。Service 测试可 mock DAO。API 测试使用 TestClient
|
||||
- **可扩展性**: GroupChangeHandler 的 TupleOperation DTO 是 F004 集成的契约边界。visibility 枚举未来可扩展(如 'team_only')。`acreate_default_group()` 方法供 F010 租户创建调用
|
||||
|
||||
---
|
||||
|
||||
## 11. 错误码表
|
||||
|
||||
> 模块编码 230(user_group),需在 release-contract.md 注册。
|
||||
|
||||
| HTTP Status | MMMEE Code | Error Class | 场景 | 关联 AC |
|
||||
|-------------|------------|-------------|------|---------|
|
||||
| 200 (body) | 23000 | UserGroupNotFoundError | 用户组 ID 不存在 | AC-06 |
|
||||
| 200 (body) | 23001 | UserGroupNameDuplicateError | 租户内用户组名称重复 | AC-02, AC-08 |
|
||||
| 200 (body) | 23002 | UserGroupDefaultProtectedError | 不可删除/重命名默认用户组 | AC-10 |
|
||||
| 200 (body) | 23003 | UserGroupHasMembersError | 有成员不可删除 | AC-11 |
|
||||
| 200 (body) | 23004 | UserGroupMemberExistsError | 用户已是组成员 | AC-13 |
|
||||
| 200 (body) | 23005 | UserGroupMemberNotFoundError | 用户不是组成员 | AC-16 |
|
||||
| 200 (body) | 23006 | UserGroupPermissionDeniedError | 无权限操作 | AC-17, AC-19 |
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- 版本契约: [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`
|
||||
- F002 部门树 spec(模式参考): `features/v2.5.0/002-department-tree/spec.md`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user