Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8df1d4a83c | |||
| aad5a6b317 | |||
| ad34750075 | |||
| cd7e24cd3c | |||
| 0521b7b6f0 | |||
| fc30f91183 | |||
| 8c72ff0565 | |||
| cc2e5aa64c | |||
| d09fd4a173 | |||
| 5db4c89fe7 | |||
| e70816a69f | |||
| 33521b8b39 | |||
| f55a037c98 | |||
| 923971ad92 | |||
| 3f07c528b6 | |||
| 207c2aaaef | |||
| a89a70a983 | |||
| 1c939bfa27 | |||
| 080d1d0b23 | |||
| d1ef64b5cc | |||
| e83ad1de73 | |||
| a27ea8ed89 | |||
| 146816303f | |||
| 11349b5225 | |||
| a037497053 | |||
| f6f26d7763 | |||
| 920bc75c53 |
@@ -0,0 +1,34 @@
|
||||
name: Bug 报告
|
||||
about: 报告一个 Bug
|
||||
title: "[moz] bug: "
|
||||
labels:
|
||||
- type/bug
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug 描述
|
||||
description: 清晰描述什么行为是错误的
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 预期行为
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
options:
|
||||
- P0 紧急
|
||||
- P1 高
|
||||
- P2 中
|
||||
- P3 低
|
||||
@@ -0,0 +1,27 @@
|
||||
name: 功能需求
|
||||
about: 提出一个新功能需求
|
||||
title: "[moz] feat: "
|
||||
labels:
|
||||
- type/feat
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 需求描述
|
||||
description: 你希望实现什么功能?为什么需要?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 如果有初步想法可以写 here
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
options:
|
||||
- P0 紧急
|
||||
- P1 高
|
||||
- P2 中
|
||||
- P3 低
|
||||
@@ -0,0 +1,20 @@
|
||||
name: 测试任务
|
||||
about: 创建一个测试任务(E2E、集成测试等)
|
||||
title: "[moz] test: "
|
||||
labels:
|
||||
- type/test
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 测试目标
|
||||
description: 要验证什么场景?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 测试步骤
|
||||
description: 关键步骤或验收标准
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,26 @@
|
||||
## 改动概述
|
||||
|
||||
<!-- 一句话说明这个 PR 做了什么 -->
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
<!-- #issue_number,如果没有关联可删掉 -->
|
||||
|
||||
## 改动类型
|
||||
|
||||
- [ ] feat: 新功能
|
||||
- [ ] impl: 实现
|
||||
- [ ] fix: 修复
|
||||
- [ ] docs: 文档
|
||||
- [ ] test: 测试
|
||||
- [ ] refactor: 重构
|
||||
- [ ] ci: CI/CD
|
||||
- [ ] chore: 杂项
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] 标题格式正确:`[代号] type(scope): 简述`
|
||||
- [ ] 改动在开发目录(`~/.openclaw/sanguo_projects/`)完成
|
||||
- [ ] 已同步到安装目录(`~/.sanguo_projects/`)
|
||||
- [ ] 已运行测试(如适用)
|
||||
- [ ] 已更新相关设计文档(如适用)
|
||||
+26
-4
@@ -27,6 +27,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
run: |
|
||||
python3 -m venv /tmp/ci-venv-lint
|
||||
/tmp/ci-venv-lint/bin/pip install --quiet --upgrade pip
|
||||
/tmp/ci-venv-lint/bin/pip install --quiet flake8
|
||||
|
||||
- name: Lint with flake8
|
||||
@@ -44,7 +45,8 @@ jobs:
|
||||
run: |
|
||||
rm -rf /tmp/ci-venv-test
|
||||
python3 -m venv /tmp/ci-venv-test
|
||||
/tmp/ci-venv-test/bin/pip install --quiet fastapi pydantic pyyaml uvicorn requests pytest pytest-asyncio httpx
|
||||
/tmp/ci-venv-test/bin/pip install --quiet --upgrade pip
|
||||
/tmp/ci-venv-test/bin/pip install --quiet --no-cache-dir fastapi pydantic pyyaml uvicorn requests pytest pytest-asyncio httpx
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
@@ -60,12 +62,30 @@ jobs:
|
||||
(echo '=== RETRY WITH VERBOSE ===' && \
|
||||
PYTHONPATH=$(pwd) /tmp/ci-venv-test/bin/pytest tests/ -m "not e2e" -x -v 2>&1 | tail -30)
|
||||
|
||||
# ── Job 3: CI 失败通知 ───────────────────────────────
|
||||
# ── Job 3: Frontend Build ───────────────────────────
|
||||
frontend:
|
||||
runs-on: macos-arm64
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install & Build
|
||||
run: |
|
||||
cd src/frontend
|
||||
npm ci || npm install
|
||||
npm run build
|
||||
|
||||
# ── Job 4: CI 失败通知 ───────────────────────────────
|
||||
# 使用 needs.<job>.result 直接判断,不查询 commit status API
|
||||
# 根因:notify 自身的 pending status 会污染 commit status 查询结果(竞态条件)
|
||||
notify-on-failure:
|
||||
runs-on: macos-arm64
|
||||
needs: [lint, test]
|
||||
needs: [lint, test, frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check results and notify
|
||||
@@ -73,12 +93,13 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
FRONTEND_RESULT: ${{ needs.frontend.result }}
|
||||
run: |
|
||||
echo "Lint result: $LINT_RESULT"
|
||||
echo "Test result: $TEST_RESULT"
|
||||
|
||||
# 只有 lint 或 test 明确失败时才发通知
|
||||
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ]; then
|
||||
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ] || [ "$FRONTEND_RESULT" = "failure" ]; then
|
||||
echo "CI has failures, sending notification..."
|
||||
|
||||
# 如果是 PR 事件,写评论通知
|
||||
@@ -88,6 +109,7 @@ jobs:
|
||||
FAILED_JOBS=""
|
||||
[ "$LINT_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}lint "
|
||||
[ "$TEST_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}test "
|
||||
[ "$FRONTEND_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}frontend "
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
|
||||
@@ -110,15 +110,7 @@ jobs:
|
||||
PR_AUTHOR=$(curl --max-time 5 -sf \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$API_URL/repos/$REPO/pulls?state=closed&sort=updated&order=desc&limit=10" | \
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sha = '$COMMIT_SHA'
|
||||
for pr in json.load(sys.stdin):
|
||||
merge_sha = pr.get('merge_commit_sha', '') or ''
|
||||
if merge_sha.startswith(sha) or sha.startswith(merge_sha):
|
||||
print(pr['user']['login'])
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
python3 -c "import json,sys; sha='$COMMIT_SHA'; matches=[pr['user']['login'] for pr in json.load(sys.stdin) if (pr.get('merge_commit_sha','') or '').startswith(sha) or sha.startswith(pr.get('merge_commit_sha','') or '')]; print(matches[0] if matches else '')" 2>/dev/null || echo "")
|
||||
|
||||
# 确定通知对象
|
||||
if [ -n "$PR_AUTHOR" ]; then
|
||||
|
||||
@@ -245,11 +245,14 @@ pm2 show sanguo-act-runner # 详情
|
||||
|------|------|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | Bug 修复 |
|
||||
| `impl` | 按设计文档实现 |
|
||||
| `refactor` | 重构(不改行为) |
|
||||
| `test` | 测试相关 |
|
||||
| `docs` | 文档 |
|
||||
| `chore` | 构建/工具/配置 |
|
||||
|
||||
sanguo 组织所有仓库统一使用此 commit 规范。
|
||||
|
||||
---
|
||||
|
||||
## §4. 问题管理
|
||||
@@ -266,15 +269,19 @@ pm2 show sanguo-act-runner # 详情
|
||||
|
||||
### 4.2 Issue 标签体系
|
||||
|
||||
| 标签 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| `bug` | 红 | 功能异常 |
|
||||
| `feature` | 蓝 | 新功能需求 |
|
||||
| `improvement` | 绿 | 改进优化 |
|
||||
| `security` | 橙 | 安全相关 |
|
||||
| `risk:high/standard/low` | 分级色 | 风险级别(见 §6.1 判定规则) |
|
||||
| `priority:high/medium/low` | 黄/灰 | 优先级 |
|
||||
| `blocked` | 紫 | 阻塞中 |
|
||||
| 标签 | 颜色 | 色值 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `type/bug` | 红 | #ee0701 | Bug 修复 |
|
||||
| `type/feat` | 蓝 | #84b6eb | 新功能 |
|
||||
| `type/impl` | 浅蓝 | #c5def5 | 按设计实现 |
|
||||
| `type/docs` | 黄 | #fbca04 | 文档 |
|
||||
| `type/test` | 绿 | #0e8a16 | 测试 |
|
||||
| `type/ci` | 紫 | #d4c5f9 | CI/CD |
|
||||
| `type/refactor` | 橙 | #ff6f00 | 重构 |
|
||||
| `priority/P0` | 深红 | #b60205 | 紧急 |
|
||||
| `priority/P1` | 红 | #d93f0b | 高 |
|
||||
| `priority/P2` | 黄 | #fbca04 | 中 |
|
||||
| `priority/P3` | 浅蓝 | #c5def5 | 低 |
|
||||
|
||||
### 4.3 需求/问题 Review 前置
|
||||
|
||||
@@ -296,6 +303,29 @@ Open → In Progress → Review → Closed
|
||||
└──── Reopened ←───────────────────┘
|
||||
```
|
||||
|
||||
### 4.5 标题规范
|
||||
|
||||
所有 Issue 和 PR 标题**必须**包含项目代号前缀,让人类一眼识别项目+类型:
|
||||
|
||||
**Issue**: `[代号] type: 简述`
|
||||
**PR**: `[代号] type(scope): 简述`
|
||||
|
||||
项目代号:
|
||||
|
||||
| 仓库 | 代号 |
|
||||
|------|------|
|
||||
| sanguo_moziplus_v2 | moz |
|
||||
| sanguo_quant_live | quant |
|
||||
| sanguo_vnpy | vnpy |
|
||||
|
||||
示例:
|
||||
- `[moz] bug: Mail API 500 when comment_type invalid`
|
||||
- `[moz] impl(daemon): 知识注入 L2 引擎层 — WikiGuideSection`
|
||||
- `[quant] feat: 趋势跟踪策略骨架`
|
||||
|
||||
此规范通过 L2 引擎层 `GiteaConventionSection`(priority=55)自动注入所有 Agent prompt。
|
||||
完整规范文档:L3 Skill `gitea-conventions`。规范设计原理详见 [§26](#26-gitea-协作规范设计)。
|
||||
|
||||
---
|
||||
|
||||
## §5. CI/CD 管道设计
|
||||
@@ -3297,3 +3327,120 @@ async def _handle_issue_comment(payload):
|
||||
|------|------|------|
|
||||
| 2026-06-09 | v1.0 | 初版:E2E 真实场景暴露问题 → 四层改造方案 + @mention 通知 + Mail type 改造 |
|
||||
|
||||
---
|
||||
|
||||
## §26. Gitea 协作规范设计
|
||||
|
||||
> **状态**: ✅ 已实现(PR #69)
|
||||
> **日期**: 2026-06-14
|
||||
|
||||
### 26.1 设计目标
|
||||
|
||||
团队三个仓库(moziplus_v2 / quant_live / vnpy)在 Gitea 上独立存在,协作时存在三个问题:
|
||||
|
||||
1. **标题不可辨识**:`Fix mail API` 看不出是哪个项目、什么类型的改动
|
||||
2. **Label 缺失**:无统一标签体系,无法按类型/优先级筛选
|
||||
3. **填写无约束**:Issue/PR 内容格式随意,审查时缺少关键信息
|
||||
|
||||
本规范的目标:让人类一眼识别项目+类型,让 Agent 可程序化遵循,让模板降低填写门槛。
|
||||
|
||||
### 26.2 规范设计
|
||||
|
||||
#### 26.2.1 标题规范
|
||||
|
||||
**规则**:所有 Issue/PR 标题必须包含项目代号前缀。
|
||||
|
||||
| 类型 | 格式 | 示例 |
|
||||
|------|------|------|
|
||||
| Issue | `[代号] type: 简述` | `[moz] bug: Mail API 500 when comment_type invalid` |
|
||||
| PR | `[代号] type(scope): 简述` | `[moz] impl(daemon): WikiGuideSection 注入` |
|
||||
|
||||
**项目代号**:moz=moziplus_v2, quant=quant_live, vnpy=vnpy
|
||||
|
||||
**type 清单**:bug / feat / impl / fix / docs / test / ci / refactor / chore
|
||||
|
||||
**设计决策**:
|
||||
- 为什么用代号前缀而不是靠仓库隔离?— 团队成员同时在多仓库协作,通知列表(Mail、Gitea dashboard)混合展示时代号前缀提供即时辨识。仓库隔离解决不了跨仓库视图的辨识问题
|
||||
- PR 加 `scope` 是因为 PR 通常涉及具体模块(daemon / api / frontend),Issue 不需要
|
||||
|
||||
#### 26.2.2 Label 体系
|
||||
|
||||
采用 `type/*` + `priority/*` 双命名空间,替代旧标签(bug/feature/improvement/security):
|
||||
|
||||
| 标签 | 色值 | 说明 |
|
||||
|------|------|------|
|
||||
| `type/bug` | #ee0701 | Bug 修复 |
|
||||
| `type/feat` | #84b6eb | 新功能 |
|
||||
| `type/impl` | #c5def5 | 按设计实现 |
|
||||
| `type/docs` | #fbca04 | 文档 |
|
||||
| `type/test` | #0e8a16 | 测试 |
|
||||
| `type/ci` | #d4c5f9 | CI/CD |
|
||||
| `type/refactor` | #ff6f00 | 重构 |
|
||||
| `priority/P0` | #b60205 | 紧急 |
|
||||
| `priority/P1` | #d93f0b | 高 |
|
||||
| `priority/P2` | #fbca04 | 中 |
|
||||
| `priority/P3` | #cfd3d7 | 低 |
|
||||
|
||||
**设计决策**:
|
||||
- 用 `type/` `priority/` 命名空间而非扁平命名,避免标签膨胀时冲突,且在 Gitea UI 中按前缀分组显示
|
||||
- type 用暖色系(红/橙/黄),priority 用冷→热渐变(灰→蓝→黄→红),视觉上两类标签不混淆
|
||||
- ⚠️ 已知问题:`type/impl`(#c5def5) 与 `priority/P3` 色值相近。P3 已调整为灰色 #cfd3d7 以区分
|
||||
|
||||
#### 26.2.3 Issue/PR 模板
|
||||
|
||||
**Issue 模板**(3 种):bug.yml / feature.yml / test.yml
|
||||
|
||||
覆盖决策:只做最高频的 3 种类型(bug 报告、功能需求、测试任务),其余类型(docs/ci/refactor)频率低,走自由创建。每种模板包含描述、复现/方案、优先级字段。
|
||||
|
||||
**PR 模板**(1 种):PULL_REQUEST_TEMPLATE.md
|
||||
|
||||
改动类型 checklist + 检查清单(标题格式、开发→安装目录同步、测试、设计文档更新)。
|
||||
|
||||
### 26.3 执行机制
|
||||
|
||||
规范通过四层路径保证执行,每层职责不同:
|
||||
|
||||
| 层 | 载体 | 职责 | Token 成本 |
|
||||
|----|------|------|-----------|
|
||||
| **L1** | TOOLS.md(Agent workspace) | 代号表 + 格式速查,Agent 静态可见 | ~100 |
|
||||
| **L2** | `GiteaConventionSection`(priority=55) | 每次 spawn 动态注入,提醒标题格式 | ~80 |
|
||||
| **L3** | `gitea-conventions` Skill(extraDirs) | 完整规范(标题/分支/commit/label),Agent 按需加载 | 按需 |
|
||||
| **Gitea** | Issue/PR Template + Label(仓库级) | 人类创建时表单引导,标签选择 | — |
|
||||
|
||||
**L2 设计决策**:
|
||||
- `GiteaConventionSection` priority=55,排在 Constraints(50) 之后、Extension(60) 之前。标题规范属于约束类,但优先级低于安全/流程约束
|
||||
- 注入所有 handler(Task/Toolchain/Mail),因为任何 handler 都可能创建 Issue/PR
|
||||
- ⚠️ L1 文件在各 Agent workspace(`~/.openclaw/workspace-*/TOOLS.md`),不在仓库管理。Agent workspace 变更不通过 PR
|
||||
|
||||
### 26.4 Label 迁移策略
|
||||
|
||||
旧标签(bug/feature/improvement/security/risk:high/priority:high)已由新体系替代:
|
||||
|
||||
- **旧标签保留不动**(不删除),避免历史 Issue 丢失标签信息
|
||||
- **新 Issue/PR 使用新标签**(type/* + priority/*)
|
||||
- 当前不做批量迁移。如有需要可后续通过 Gitea API 批量替换
|
||||
|
||||
### 26.5 与其他章节的关系
|
||||
|
||||
| 章节 | 关系 |
|
||||
|------|------|
|
||||
| §4.2 Issue 标签体系 | §26.2.2 Label 设计在问题管理场景的具体应用(已随 PR #69 更新) |
|
||||
| §4.5 标题规范 | §26.2.1 标题规范的执行层摘要(已随 PR #69 新增) |
|
||||
| §5 CI/CD 管道 | CI 事件通过标题前缀 `[CI]` 做事件路由(见 §16 事件中枢) |
|
||||
| §6 代码审查流程 | PR Template 检查清单约束审查前置条件 |
|
||||
|
||||
### 26.6 实现记录
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `prompt_composer.py` | 新增 `GiteaConventionSection`(priority=55) |
|
||||
| `task_handler.py` | `get_sections()` 注册 `GiteaConventionSection()` |
|
||||
| `toolchain_handler.py` | `get_sections()` 注册 `GiteaConventionSection()` |
|
||||
| `mail_handler.py` | `get_sections()` 注册 `GiteaConventionSection()` |
|
||||
| `db.py` | `COMMENT_TYPES` 补 `action_report`(修 API 500 bug) |
|
||||
| `.gitea/ISSUE_TEMPLATE/` | bug.yml / feature.yml / test.yml |
|
||||
| `.gitea/PULL_REQUEST_TEMPLATE.md` | PR 检查清单模板 |
|
||||
| Gitea Labels | 3 仓库各创建 11 个 Label(type × 7 + priority × 4) |
|
||||
|
||||
PR #69,2026-06-14 合并。
|
||||
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# #16 知识注入设计
|
||||
|
||||
> 状态:v2 设计中
|
||||
> 作者:庞统
|
||||
> 日期:2026-06-13(v1),2026-06-14(v2 对齐 #11 四层架构)
|
||||
> 评审:待司马懿评审
|
||||
|
||||
## 一、问题
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
Agent(庞统、司马懿、张飞等)在执行任务时,不主动查询已有知识库(wiki-vault)。导致:
|
||||
|
||||
1. **重复调研**:赵云查过的数据清洗经验,张飞又从头调研一遍
|
||||
2. **重复踩坑**:wiki-vault 里已有"vnpy load_bar 需要显式指定 end=None"的实践,张飞还是踩了
|
||||
3. **方案质量低**:做方案时纯靠推理,不查已有的优秀实践
|
||||
4. **知识 gap 无人管**:查不到相关知识时没记录,下次还是查不到
|
||||
|
||||
### 1.2 根因
|
||||
|
||||
不是没有知识库(wiki-vault 有 50+ practices 页面),也不是没有检索能力(wiki-query Skill 已存在)。
|
||||
|
||||
**根因是注入时机**:Agent 不知道什么时候该查、没有强制机制让 Agent 在关键决策点查。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
1. Agent 在关键决策点**主动查询** wiki-vault
|
||||
2. 查不到相关知识时**自动记录** knowledge gap
|
||||
3. 定时任务处理 gap + 总结经验,**持续丰富** wiki-vault
|
||||
4. 不增加 prompt token 负担(不自动注入知识全文,只引导查询)
|
||||
|
||||
## 二、调研
|
||||
|
||||
### 2.1 Superpowers:强制 Skill 检查(最有效)
|
||||
|
||||
**核心设计**:session-start hook 注入铁律级指令——
|
||||
|
||||
> "If you think there is even a **1% chance** a skill might apply, you **ABSOLUTELY MUST** invoke the skill. This is not negotiable."
|
||||
|
||||
配合 **Red Flags 表**防止 Agent 自合理化跳过:
|
||||
|
||||
| Agent 的想法 | Red Flag 驳回 |
|
||||
|---|---|
|
||||
| "这个问题很简单" | 简单问题也需要查实践 |
|
||||
| "我需要更多上下文" | Skill 检查在澄清问题之前 |
|
||||
| "先看看代码" | Skill 告诉你怎么看代码 |
|
||||
| "我记住了这个 Skill" | Skill 会更新,重新读 |
|
||||
|
||||
**为什么有效**:不靠 Agent "想起来",靠铁律强制。Skill 触发在任何响应之前。
|
||||
|
||||
### 2.2 Hermes:经验闭环 + Session Search
|
||||
|
||||
**经验闭环**:完成复杂任务(5+ tool calls)→ 自动创建 Skill → 下次自然触发。
|
||||
|
||||
**Session Search**:系统提示注入——"当用户提及过去内容时,主动搜索而非要求用户重复"。
|
||||
|
||||
**为什么有效**:不是"知识查询"而是"行为内化"——经验变成 Skill,Skill 有 description 触发词。
|
||||
|
||||
### 2.3 结论
|
||||
|
||||
综合两个项目的优势:
|
||||
|
||||
| 设计点 | 来源 | 我们的做法 |
|
||||
|--------|------|-----------|
|
||||
| 铁律级强制 | Superpowers | L0 Hook 注入 + L1 SOUL.md 行为引导 |
|
||||
| Red Flags 反合理化 | Superpowers | 知识查询 Red Flags 表(L1 SOUL.md) |
|
||||
| 经验内化 | Hermes | 经验→wiki-vault→下次查询 |
|
||||
| 渐进式披露 | Hermes | 先查 summary,按需读全文 |
|
||||
|
||||
## 三、设计决策(对齐 #11 四层架构)
|
||||
|
||||
> **层级体系严格对齐 [#11](./11-context-layers-redesign.md)**,不自创命名。
|
||||
|
||||
### 总览
|
||||
|
||||
| #11 层级 | 知识注入角色 | 本设计覆盖 | 注入方式 |
|
||||
|----------|------------|-----------|---------|
|
||||
| **L0 铁律层** | "做方案前先查 wiki-vault" | ✅ D16-1 | Hook 每轮强制注入 |
|
||||
| **L1 角色层** | TOOLS.md 知识库速查表 + SOUL.md Red Flags | ✅ D16-2 | Workspace 文件自动注入 |
|
||||
| **L2 引擎注入层** | 三种 handler 各注入 WikiGuideSection | ✅ D16-3 | PromptComposer 拼装 |
|
||||
| **L3 被动参考层** | wiki-query Skill 按需触发 | ✅ D16-4 | extraDirs Description 匹配 |
|
||||
| 运维层 | gap 闭环 cron job | ✅ D16-5 | 不属于上下文分层 |
|
||||
|
||||
### D16-1:L0 铁律层 — 新增一条 wiki 查询铁律
|
||||
|
||||
L0 只放跨系统通用的、不可绕过的行为底线。wiki 查询铁律和 GATE 门控同级。
|
||||
|
||||
**新增铁律**:
|
||||
|
||||
```
|
||||
<wiki-rule>
|
||||
做方案前先查 wiki-vault,有 1% 相关就要查。查不到记 knowledge-gaps.md。
|
||||
</wiki-rule>
|
||||
```
|
||||
|
||||
**注入方式**:和 `<gate-rules>` / `<delegation-rule>` 并列,Hook 每轮强制注入。
|
||||
|
||||
**覆盖范围**:所有 Agent、所有场景(不限于 moziplus spawn 的子任务)。
|
||||
|
||||
### D16-2:L1 角色层 — TOOLS.md + SOUL.md
|
||||
|
||||
#### TOOLS.md(✅ 已完成)
|
||||
|
||||
各 Agent workspace 的 TOOLS.md 中已有「LLM Wiki 知识库」段,包含:
|
||||
|
||||
- 速查表(场景 → 怎么做 → 什么时候用)
|
||||
- 检索原则(index.md → summary → grep → 整页读取,从便宜到昂贵)
|
||||
- 目录结构(wiki-vault / practices / concepts / skills / ...)
|
||||
- 铁律(做方案前先查、查不到记 gap)
|
||||
|
||||
#### SOUL.md Red Flags
|
||||
|
||||
在各 Agent 的 SOUL.md 中加入知识查询 Red Flags 表(和 Superpowers 一致):
|
||||
|
||||
| Agent 的想法 | 反驳 |
|
||||
|---|---|
|
||||
| "这个我以前做过" | 知识库可能已更新,查一下确认 |
|
||||
| "先做再说" | 做方案前查实践比做错了返工便宜 |
|
||||
| "这个领域我熟悉" | 熟悉≠知道最新实践,wiki-vault 持续更新 |
|
||||
| "查知识库浪费时间" | 重复踩坑浪费的时间远大于查询时间 |
|
||||
|
||||
### D16-3:L2 引擎注入层 — 三种 handler 各注入 WikiGuideSection
|
||||
|
||||
L2 是 BootstrapBuilder/PromptComposer 动态拼装的 prompt 段。当前有三种 handler,各有自己的 PromptSection 实现:
|
||||
|
||||
#### 当前 handler 结构
|
||||
|
||||
| Handler | Sections(priority) | 有 wiki 引导? |
|
||||
|---------|---------------------|--------------|
|
||||
| **TaskHandler** | Context(10) → Prior(20) → RoleSkill(30) → API(40) → Constraints(50) | ❌ |
|
||||
| **MailHandler** | Context(10) → API(40) → Constraints(50) | ❌ |
|
||||
| **ToolchainHandler** | Context(10) → API(40) → Constraints(50) | ❌ |
|
||||
|
||||
#### 新增 WikiGuideSection(priority=60,PRIORITY_EXTENSION)
|
||||
|
||||
创建一个**通用 PromptSection**,三种 handler 的 `get_sections()` 都注入:
|
||||
|
||||
```python
|
||||
# 可放在 prompt_composer.py 或独立文件,三种 handler 共用
|
||||
|
||||
class WikiGuideSection:
|
||||
"""知识查询引导段 — 引导 Agent 在关键决策点查 wiki-vault。"""
|
||||
|
||||
name: str = "wiki_guide"
|
||||
priority: int = 60 # PRIORITY_EXTENSION
|
||||
|
||||
WIKI_GUIDE = (
|
||||
"## 知识查询引导\n"
|
||||
"涉及方案设计、编码实现、故障排查时,先查 wiki-vault 相关实践:\n"
|
||||
"- 路径:/Volumes/KnowledgeBase/wiki-vault/\n"
|
||||
"- 速查:index.md → grep 关键词 → summary 字段 → 按需读全文\n"
|
||||
"- 查不到:在 _meta/knowledge-gaps.md 记录"
|
||||
)
|
||||
|
||||
def render(self, context: PromptContext) -> str:
|
||||
return self.WIKI_GUIDE
|
||||
|
||||
def should_include(self, context: PromptContext) -> bool:
|
||||
return True
|
||||
```
|
||||
|
||||
#### 三种 handler 改动
|
||||
|
||||
每种 handler 的 `get_sections()` 末尾加 `WikiGuideSection()`:
|
||||
|
||||
```python
|
||||
# TaskHandler
|
||||
def get_sections(self) -> list:
|
||||
return [
|
||||
TaskContextSection(),
|
||||
PriorOutputsSection(),
|
||||
RoleSkillSection(),
|
||||
TaskApiSection(),
|
||||
TaskConstraintsSection(),
|
||||
WikiGuideSection(), # ← 新增
|
||||
]
|
||||
|
||||
# MailHandler
|
||||
def get_sections(self) -> list:
|
||||
return [
|
||||
MailContextSection(),
|
||||
MailApiSection(),
|
||||
MailConstraintsSection(),
|
||||
WikiGuideSection(), # ← 新增
|
||||
]
|
||||
|
||||
# ToolchainHandler
|
||||
def get_sections(self) -> list:
|
||||
return [
|
||||
ToolchainContextSection(),
|
||||
ToolchainApiSection(),
|
||||
ToolchainConstraintsSection(),
|
||||
WikiGuideSection(), # ← 新增
|
||||
]
|
||||
```
|
||||
|
||||
#### 为什么三种 handler 都需要
|
||||
|
||||
- **TaskHandler**:executor 做方案/编码,最需要查实践
|
||||
- **ToolchainHandler**:CI 失败排查、部署问题,有相关运维实践可参考
|
||||
- **MailHandler**:request 类型回复杂问题时也可能需要查已有经验
|
||||
|
||||
#### token 开销
|
||||
|
||||
WikiGuideSection 固定 ~60 字(~30 tokens),对 L2 预算影响可忽略。
|
||||
|
||||
### D16-4:L3 被动参考层 — wiki-query Skill
|
||||
|
||||
#### 现状
|
||||
|
||||
`wiki-query` Skill 已部署在 `~/.sanguo_projects/sanguo_mozi/skills/wiki/wiki-query/SKILL.md`,description 包含中文触发词:
|
||||
|
||||
> 调查、研究、分析、优秀实践、最佳实践、经验、怎么做X、有没有X的经验、以前怎么处理的
|
||||
|
||||
#### 触发机制
|
||||
|
||||
Agent 通过 extraDirs 加载 Skill header(name + description),按 Description 匹配自主 `read` 全文。这是标准 L3 行为,和 #11 设计一致。
|
||||
|
||||
#### 待确认:extraDirs 子目录递归
|
||||
|
||||
wiki-query 在 `skills/wiki/wiki-query/` 子目录下。需确认 moziplus spawn 子 agent 时 extraDirs 是否递归扫描子目录。如果不递归,需要:
|
||||
- 方案 A:把 wiki-query 移到 `skills/` 顶层
|
||||
- 方案 B:配置 extraDirs 包含 `skills/wiki/` 子目录
|
||||
|
||||
### D16-5:知识 gap 记录 + 定时任务(运维层)
|
||||
|
||||
> 不属于上下文分层体系,是独立的运维流程。
|
||||
|
||||
#### gap 记录机制(已有基础设施)
|
||||
|
||||
- **位置**:`/Volumes/KnowledgeBase/wiki-vault/_meta/knowledge-gaps.md`
|
||||
- **格式**:`- [日期] Agent名查"主题" → 待处理`
|
||||
- **已有 20+ 条历史记录**,处理后标注 `→ 已建立 ✅`
|
||||
|
||||
wiki-query Skill 的 Step 5 已内置 gap 记录逻辑。
|
||||
|
||||
#### 定时任务(已有 cron 基础)
|
||||
|
||||
| 任务 | 时间 | 内容 | 状态 |
|
||||
|------|------|------|------|
|
||||
| wiki-daily-update | 每天 04:00 | 处理 knowledge gaps + 当天经验总结 → 写入 wiki-vault | ✅ 已有 cron,需完善 |
|
||||
| pangtong-vault-sync | 每天 05:00 | 同步 wiki-vault 到 agent workspace | ✅ 已有 |
|
||||
|
||||
**wiki-daily-update 完善内容**:
|
||||
1. 读取 knowledge-gaps.md 中"待处理"条目
|
||||
2. 对每个 gap:搜索 knowledge_base 是否有相关源码/文档 → 有则提炼写入 wiki-vault
|
||||
3. 搜索最近一天的 jsonl 日志,提取有价值的经验
|
||||
4. 新建或更新 wiki-vault 页面
|
||||
5. 更新 knowledge-gaps.md(标记为"已建立 ✅"或"无KB内容,跳过")
|
||||
|
||||
### D16-6:和 #11 各层关系总结
|
||||
|
||||
| #11 层级 | #11 原始定义 | 知识注入贡献 | 本设计 |
|
||||
|---------|------------|------------|--------|
|
||||
| L0 铁律 | GATE 门控 + Delegation + 安全底线 | wiki 查询铁律 | ✅ D16-1 |
|
||||
| L1 角色 | SOUL.md + AGENTS.md + TOOLS.md + MEMORY.md | TOOLS.md 速查表 + SOUL.md Red Flags | ✅ D16-2 |
|
||||
| L2 引擎 | 任务上下文 + 角色操作规范 + 硬约束 | WikiGuideSection 通用段 | ✅ D16-3 |
|
||||
| L3 参考 | A/B/C/D 类 Skill,靠 Description 触发 | wiki-query Skill | ✅ D16-4 |
|
||||
| 运维 | — | gap 闭环 cron job | ✅ D16-5 |
|
||||
|
||||
### D16-7:为什么不做 PromptComposer 自动注入知识全文
|
||||
|
||||
1. **token 浪费**:每次任务都注入可能不相关的知识
|
||||
2. **覆盖范围有限**:只影响 moziplus 子任务 Agent
|
||||
3. **Agent 主动查询更精准**:知道自己缺什么知识,按需查询
|
||||
|
||||
## 四、改动清单
|
||||
|
||||
### 4.1 已完成 ✅
|
||||
|
||||
| 改动 | 文件 | 层级 | 说明 |
|
||||
|------|------|------|------|
|
||||
| TOOLS.md 知识库段 | 各 Agent workspace TOOLS.md | L1 | 速查表 + 检索原则 + 目录结构 + 铁律 |
|
||||
| wiki-query Skill 部署 | `skills/wiki/wiki-query/SKILL.md` | L3 | 中文触发词 + 渐进式检索协议 |
|
||||
| knowledge-gaps.md | `_meta/knowledge-gaps.md` | 运维 | 已有 20+ 条记录 |
|
||||
| wiki-daily-update cron | cron job | 运维 | 每天 04:00,需完善处理逻辑 |
|
||||
| pangtong-vault-sync cron | cron job | 运维 | 每天 05:00 |
|
||||
|
||||
### 4.2 待实现
|
||||
|
||||
| 改动 | 文件 | 层级 | 说明 |
|
||||
|------|------|------|------|
|
||||
| L0 wiki 铁律 | Hook 注入配置(`prependContext`) | L0 | 新增 `<wiki-rule>` 段 |
|
||||
| SOUL.md Red Flags | 各 Agent workspace SOUL.md | L1 | 知识查询 Red Flags 表 |
|
||||
| WikiGuideSection | `prompt_composer.py` 或独立文件 | L2 | 通用 PromptSection,三种 handler 共用 |
|
||||
| TaskHandler 注入 | `task_handler.py` `get_sections()` | L2 | 末尾加 `WikiGuideSection()` |
|
||||
| MailHandler 注入 | `mail_handler.py` `get_sections()` | L2 | 末尾加 `WikiGuideSection()` |
|
||||
| ToolchainHandler 注入 | `toolchain_handler.py` `get_sections()` | L2 | 末尾加 `WikiGuideSection()` |
|
||||
| extraDirs 递归确认 | moziplus spawn 配置 | L3 | 确认 wiki-query 子目录可被发现 |
|
||||
| wiki-daily-update 完善 | cron job 脚本 | 运维 | gap 处理 + jsonl 经验提取 |
|
||||
|
||||
### 4.3 不做
|
||||
|
||||
| 项目 | 原因 |
|
||||
|------|------|
|
||||
| PromptComposer 知识全文注入 | token 浪费,Agent 主动查询更精准 |
|
||||
| experiences 表 | wiki-vault 已覆盖,不重复建设 |
|
||||
| 新 Skill(除 wiki-query 外) | wiki-query 已有,不需要新的 |
|
||||
|
||||
## 五、风险
|
||||
|
||||
| 风险 | 概率 | 缓解 |
|
||||
|------|------|------|
|
||||
| Agent 不主动查 wiki | 中 | L0 铁律强制 + L2 引导 + L3 Description 触发,三层保障 |
|
||||
| wiki-query 在子目录不被 extraDirs 发现 | 中 | 确认后决定移顶层或配置子目录 |
|
||||
| wiki-daily-update gap 处理质量不够 | 低 | 人工审核 + 逐步完善 |
|
||||
| WikiGuideSection 增加 token | 低 | 固定 ~30 tokens,影响可忽略 |
|
||||
@@ -0,0 +1,503 @@
|
||||
# API 聚合重构 + 工具链 Tab 设计
|
||||
|
||||
> **编号**: §18
|
||||
> **状态**: 设计中
|
||||
> **日期**: 2026-06-14
|
||||
> **作者**: 庞统(副军师)🐦
|
||||
> **审查**: 司马懿(mail-1781415763066 已回复,方案 B 调整版确认)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 问题
|
||||
|
||||
1. **blackboard_routes.py 膨胀**:572 行、22 个路由,task/comment/output/review/event/decision/observation/archive 全堆一个文件,维护困难
|
||||
2. **前端 N+1 请求**:打开 TaskModal 需要 5 次独立请求(task + events + subtasks + progress + comments),影响前端性能
|
||||
3. **工具链事件无前端展示**:`_toolchain` DB 隔离已完成,但前端无对应 Tab,工具链事件只能通过 Agent 收 Mail 感知
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
1. 按领域拆分 blackboard_routes.py → 3 个文件
|
||||
2. 实现细粒度 expand 聚合接口,前端 1-2 次请求拿全任务详情
|
||||
3. 新增工具链 Tab(列表 + 详情 + 搜索栏)
|
||||
4. 任务列表支持标题搜索
|
||||
|
||||
### 1.3 不做
|
||||
|
||||
- checkpoint_routes.py 不纳入拆分(已独立)
|
||||
- mail_routes / toolchain_routes / project_routes 不动
|
||||
- SQL JOIN / batch query 性能优化(当前 SQLite 单写下多次查询可接受)
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端 API 文件拆分
|
||||
|
||||
### 2.1 拆分方案(方案 B 调整版,司马懿确认)
|
||||
|
||||
| 新文件 | 内容 | 预估行数 |
|
||||
|--------|------|---------|
|
||||
| `task_routes.py` | task CRUD + create(含 AI 标题) + patch + progress + claim + status(含广播) + archive + archive-done | ~280 |
|
||||
| `task_relation_routes.py` | comments + outputs(含文件写入) + reviews + decisions + observations + events + experiences + summary | ~250 |
|
||||
| `shared.py` | `_bb()` / `_q()` / `_validate_project()` / `_task_to_dict()` / `_init_agent_ids()` / `_extract_mentions()` / 常量导入 | ~30 |
|
||||
|
||||
### 2.2 路由分配明细
|
||||
|
||||
**task_routes.py**(10 个路由):
|
||||
|
||||
| 路由 | 方法 | 函数 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/tasks` | GET | `list_tasks` | 列表(新增 `q` 搜索参数) |
|
||||
| `/tasks` | POST | `create_task` | 创建(含 `_generate_title`) |
|
||||
| `/tasks/{tid}` | GET | `get_task` | 详情(含 expand 聚合) |
|
||||
| `/tasks/{tid}` | PATCH | `patch_task` | 更新 |
|
||||
| `/tasks/{tid}/progress` | GET | `task_progress` | 进度 |
|
||||
| `/tasks/{tid}/claim` | POST | `claim_task` | 认领 |
|
||||
| `/tasks/{tid}/status` | POST | `update_status` | 状态流转(含广播逻辑) |
|
||||
| `/tasks/{tid}/archive` | POST | `archive_task` | 归档 |
|
||||
| `/tasks/archive-done` | POST | `archive_done_tasks` | 批量归档 |
|
||||
|
||||
**task_relation_routes.py**(13 个路由):
|
||||
|
||||
| 路由 | 方法 | 函数 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/tasks/{tid}/comments` | GET | `get_comments` | 评论列表 |
|
||||
| `/tasks/{tid}/comments` | POST | `add_comment` | 添加评论(含 @mention 提取) |
|
||||
| `/tasks/{tid}/outputs` | GET | `get_outputs` | 产出列表 |
|
||||
| `/tasks/{tid}/outputs` | POST | `write_output` | 写入产出(含文件写入逻辑) |
|
||||
| `/tasks/{tid}/decisions` | GET | `get_decisions` | 决策列表 |
|
||||
| `/tasks/{tid}/decisions` | POST | `add_decision` | 添加决策 |
|
||||
| `/tasks/{tid}/observations` | POST | `add_observation` | 添加观察 |
|
||||
| `/tasks/{tid}/reviews` | GET | `get_reviews` | 审查列表 |
|
||||
| `/tasks/{tid}/reviews` | POST | `add_review` | 添加审查 |
|
||||
| `/tasks/{tid}/events` | GET | `get_task_events` | 事件列表 |
|
||||
| `/tasks/{tid}/experiences` | GET | `get_task_experiences` | 经验列表 |
|
||||
| `/events` | GET | `get_events` | 项目级事件 |
|
||||
| `/summary` | GET | `task_summary` | 任务汇总 |
|
||||
|
||||
### 2.3 shared.py 共享件
|
||||
|
||||
从 blackboard_routes.py 提取到 shared.py:
|
||||
|
||||
| 符号 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `_validate_project()` | function | 项目 ID 校验 |
|
||||
| `_bb()` | function | Blackboard 实例获取 |
|
||||
| `_q()` | function | Queries 实例获取 |
|
||||
| `_task_to_dict()` | function | Task → dict 序列化 |
|
||||
| `_init_agent_ids()` | function | Agent ID 初始化 |
|
||||
| `_extract_mentions()` | function | @mention 提取 |
|
||||
| `VALID_STATUSES` | import | 从 db.py 重导出 |
|
||||
| `OUTPUT_TYPES` | import | 从 db.py 重导出 |
|
||||
|
||||
### 2.4 main.py 路由注册变更
|
||||
|
||||
```python
|
||||
# 拆分前
|
||||
from src.api.blackboard_routes import router as blackboard_router
|
||||
app.include_router(blackboard_router)
|
||||
|
||||
# 拆分后
|
||||
from src.api.task_routes import router as task_router
|
||||
from src.api.task_relation_routes import router as task_relation_router
|
||||
app.include_router(task_router)
|
||||
app.include_router(task_relation_router)
|
||||
```
|
||||
|
||||
URL prefix 不变:所有路由仍是 `/api/projects/{pid}/...`,前端 URL 零改动。
|
||||
|
||||
### 2.5 向后兼容
|
||||
|
||||
- 删除 `blackboard_routes.py`,所有引用指向新文件
|
||||
- `expand=all` 保持兼容(内部映射为全量 expand)
|
||||
- 不改变任何 API 的请求/响应格式(仅文件组织变化)
|
||||
|
||||
---
|
||||
|
||||
## 3. expand 聚合接口
|
||||
|
||||
### 3.1 设计
|
||||
|
||||
`GET /api/projects/{pid}/tasks/{tid}?expand=comments,outputs,reviews,events,decisions`
|
||||
|
||||
支持逗号分隔的细粒度选择,替代当前的 `expand=all`。
|
||||
|
||||
### 3.2 返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"task": { "id": "...", "title": "...", "status": "working", ... },
|
||||
"comments": {
|
||||
"items": [...],
|
||||
"total_count": 45,
|
||||
"limit": 20
|
||||
},
|
||||
"events": {
|
||||
"items": [...],
|
||||
"total_count": 120,
|
||||
"limit": 30
|
||||
},
|
||||
"outputs": [...],
|
||||
"reviews": [...],
|
||||
"decisions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 limit 策略
|
||||
|
||||
| 关联资源 | expand 返回 | 分页支持 | 理由 |
|
||||
|----------|------------|---------|------|
|
||||
| comments | 最新 20 条 + total_count | `GET /comments?limit=50&offset=0` | 高频资源,长任务可能积累几十条 |
|
||||
| events | 最新 30 条 + total_count | `GET /events?limit=100&offset=0` | 运行几天可能上百条 |
|
||||
| outputs | 全部 | 不需要 | 通常 <5 条 |
|
||||
| reviews | 全部 | 不需要 | 通常 <5 条 |
|
||||
| decisions | 全部 | 不需要 | 通常 <5 条 |
|
||||
|
||||
前端拿到 `total_count > items.length` 时显示"还有 N 条",按需点击加载。
|
||||
|
||||
### 3.4 实现伪码
|
||||
|
||||
```python
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task(project_id: str, task_id: str,
|
||||
expand: Optional[str] = None):
|
||||
bb = _bb(project_id)
|
||||
task = bb.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(404, f"Task not found: {task_id}")
|
||||
|
||||
result = _task_to_dict(task)
|
||||
|
||||
if not expand:
|
||||
return result
|
||||
|
||||
expand_list = expand.split(",") if expand != "all" else [
|
||||
"comments", "outputs", "reviews", "events", "decisions"
|
||||
]
|
||||
|
||||
q = _q(project_id)
|
||||
|
||||
if "comments" in expand_list:
|
||||
all_comments = bb.get_comments(task_id)
|
||||
result["comments"] = {
|
||||
"items": [dict(c.__dict__) for c in all_comments[-20:]],
|
||||
"total_count": len(all_comments),
|
||||
"limit": 20,
|
||||
}
|
||||
|
||||
if "events" in expand_list:
|
||||
all_events = q.task_events(task_id)
|
||||
result["events"] = {
|
||||
"items": all_events[-30:],
|
||||
"total_count": len(all_events),
|
||||
"limit": 30,
|
||||
}
|
||||
|
||||
if "outputs" in expand_list:
|
||||
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
|
||||
|
||||
if "reviews" in expand_list:
|
||||
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
|
||||
|
||||
if "decisions" in expand_list:
|
||||
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### 3.5 性能分析
|
||||
|
||||
| 场景 | 当前(无 expand) | expand 后 | 改善 |
|
||||
|------|-----------------|-----------|------|
|
||||
| 打开 TaskModal | 5 次 HTTP 请求 | 2 次(task+expand + subtasks) | -60% 请求 |
|
||||
| 单次 expand 响应体 | — | ~5-15KB(典型) | 一次大请求 < 五次小请求 |
|
||||
| DB 查询次数 | 5 次(各端点独立查) | 5 次(expand 内部循环) | 相同,暂不优化 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务列表搜索
|
||||
|
||||
### 4.1 设计
|
||||
|
||||
`GET /api/projects/{pid}/tasks?q=关键词`
|
||||
|
||||
在现有 `list_tasks` 基础上增加 `q` 查询参数,支持标题模糊搜索(SQL LIKE)。
|
||||
|
||||
### 4.2 实现
|
||||
|
||||
```python
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(project_id: str, q: Optional[str] = None, ...):
|
||||
bb = _bb(project_id)
|
||||
tasks = bb.list_tasks(status=status, ...)
|
||||
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
tasks = [t for t in tasks if q_lower in (t.title or "").lower()]
|
||||
|
||||
return {"tasks": [_task_to_dict(t) for t in tasks]}
|
||||
```
|
||||
|
||||
**设计决策**:过滤在 Python 层做而非 SQL 层。
|
||||
- 理由:当前 `list_tasks` 已在 Python 层做 status 筛选,加一层 title 过滤一致性更好
|
||||
- 如果后续任务量大(>1000),再改为 SQL LIKE 查询
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端:工具链 Tab
|
||||
|
||||
### 5.1 Tab 定义
|
||||
|
||||
```typescript
|
||||
// store.ts TabKey 新增
|
||||
| 'toolchain'
|
||||
|
||||
// TAB_DEFS 新增(插在 settings 前面)
|
||||
{ key: 'toolchain', label: '工具链', icon: '⛓️' },
|
||||
```
|
||||
|
||||
### 5.2 数据加载
|
||||
|
||||
```typescript
|
||||
// store.ts 新增
|
||||
toolchainTasks: any[];
|
||||
loadToolchain: async () => {
|
||||
const res = await fetch('/api/projects/_toolchain/tasks');
|
||||
const data = await res.json();
|
||||
set({ toolchainTasks: data.tasks || [] });
|
||||
}
|
||||
|
||||
// Tab 切换时加载
|
||||
if (tab === 'toolchain') s.loadToolchain();
|
||||
```
|
||||
|
||||
### 5.3 ToolchainPanel 组件
|
||||
|
||||
仿 MailPanel 结构,三个区域:
|
||||
|
||||
**搜索栏**(顶部):
|
||||
- 文本输入框,输入关键词实时过滤列表
|
||||
- 调用 `GET /api/projects/_toolchain/tasks?q=关键词`
|
||||
|
||||
**列表区**(左侧):
|
||||
- 工具链事件列表(时间倒序)
|
||||
- 每条显示:标题 + 时间 + 状态标签
|
||||
- 点击选中,高亮当前选中项
|
||||
|
||||
**详情区**(右侧):
|
||||
- 选中事件的完整内容
|
||||
- 调用 `GET /api/projects/_toolchain/tasks/{tid}?expand=comments` 获取详情
|
||||
- 展示:标题、描述、状态、评论(action_report 等)
|
||||
|
||||
### 5.4 和 Mail 的隔离
|
||||
|
||||
| 维度 | Mail Tab | 工具链 Tab |
|
||||
|------|---------|-----------|
|
||||
| 数据源 | `_mail` 项目 | `_toolchain` 项目 |
|
||||
| 事件类型 | Agent 间通信(inform/request) | 系统事件(CI/PR/部署/Review) |
|
||||
| 搜索 | 无(邮件量不大) | 有(工具链事件频率高) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试设计
|
||||
|
||||
### 6.1 后端 API 拆分测试
|
||||
|
||||
**目标**:验证拆分后所有路由 URL 不变、行为不变。
|
||||
|
||||
**测试文件**:`tests/integration/test_api.py`(扩展现有)+ 新增 `tests/unit/test_task_routes.py`
|
||||
|
||||
| 测试类 | 测试用例 | 验证点 |
|
||||
|--------|---------|--------|
|
||||
| TestTaskRoutes | test_list_tasks | GET /tasks 返回格式不变 |
|
||||
| | test_list_tasks_with_search | q 参数过滤正确 |
|
||||
| | test_list_tasks_empty_q | q 为空时返回全部 |
|
||||
| | test_get_task | GET /tasks/{tid} 基本详情 |
|
||||
| | test_get_task_expand_comments | expand=comments 返回带 total_count + limit |
|
||||
| | test_get_task_expand_events | expand=events 返回带 total_count + limit |
|
||||
| | test_get_task_expand_outputs | expand=outputs 全量返回 |
|
||||
| | test_get_task_expand_multiple | expand=comments,outputs,reviews 组合 |
|
||||
| | test_get_task_expand_all | expand=all 向后兼容 |
|
||||
| | test_get_task_no_expand | 不传 expand 返回基本 task |
|
||||
| | test_create_task | POST 格式不变 |
|
||||
| | test_claim_task | 认领行为不变 |
|
||||
| | test_update_status | 状态流转不变 |
|
||||
| | test_patch_task | PATCH 不变 |
|
||||
| | test_archive_task | 归档不变 |
|
||||
|
||||
| 测试类 | 测试用例 | 验证点 |
|
||||
|--------|---------|--------|
|
||||
| TestTaskRelationRoutes | test_comments_crud | GET/POST comments 不变 |
|
||||
| | test_outputs_crud | GET/POST outputs 不变 |
|
||||
| | test_write_output_file | 文件写入逻辑不变 |
|
||||
| | test_reviews_crud | GET/POST reviews 不变 |
|
||||
| | test_decisions_crud | GET/POST decisions 不变 |
|
||||
| | test_observations_add | POST observations 不变 |
|
||||
| | test_events_list | GET events 不变 |
|
||||
| | test_experiences_list | GET experiences 不变 |
|
||||
| | test_project_events | GET /events 不变 |
|
||||
| | test_summary | GET /summary 不变 |
|
||||
|
||||
**兼容性验证脚本**:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# tests/scripts/verify_api_compat.sh
|
||||
# 对比拆分前后所有路由 URL 和方法,确保零变化
|
||||
|
||||
echo "=== 拆分前路由清单 ==="
|
||||
# 从 git stash 或 main 分支提取
|
||||
git stash
|
||||
python -c "
|
||||
from src.main import app
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'methods') and hasattr(route, 'path'):
|
||||
for m in sorted(route.methods):
|
||||
if m in ('GET','POST','PATCH','DELETE','PUT'):
|
||||
print(f'{m} {route.path}')
|
||||
" | sort > /tmp/routes_before.txt
|
||||
|
||||
git stash pop
|
||||
|
||||
echo "=== 拆分后路由清单 ==="
|
||||
python -c "
|
||||
from src.main import app
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'methods') and hasattr(route, 'path'):
|
||||
for m in sorted(route.methods):
|
||||
if m in ('GET','POST','PATCH','DELETE','PUT'):
|
||||
print(f'{m} {route.path}')
|
||||
" | sort > /tmp/routes_after.txt
|
||||
|
||||
echo "=== Diff ==="
|
||||
diff /tmp/routes_before.txt /tmp/routes_after.txt
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ 路由完全一致"
|
||||
else
|
||||
echo "❌ 路由有差异"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### 6.2 expand 聚合测试
|
||||
|
||||
**测试文件**:`tests/unit/test_expand_api.py`
|
||||
|
||||
| 测试用例 | 验证点 |
|
||||
|---------|--------|
|
||||
| test_expand_comments_limit | comments 返回最新 20 条 + total_count=25 |
|
||||
| test_expand_comments_are_latest | 验证返回的是最新 20 条(index 5-24) |
|
||||
| test_expand_events_limit | events 返回最新 30 条 + total_count=35 |
|
||||
| test_expand_outputs_full | outputs 全量返回(list 格式,不分页) |
|
||||
| test_expand_reviews_full | reviews 全量返回 |
|
||||
| test_expand_decisions_full | decisions 全量返回 |
|
||||
| test_expand_multiple_fields | expand=comments,outputs,reviews 组合,未请求的不返回 |
|
||||
| test_expand_all_compat | expand=all 向后兼容 |
|
||||
| test_no_expand | 不传 expand 只返回基本 task |
|
||||
| test_expand_invalid_field_ignored | 无效字段静默忽略 |
|
||||
|
||||
### 6.3 搜索测试
|
||||
|
||||
**测试文件**:`tests/unit/test_task_routes.py` 内 `TestTaskListRoutes`
|
||||
|
||||
| 测试用例 | 验证点 |
|
||||
|---------|--------|
|
||||
| test_list_tasks_with_search | q 参数标题模糊搜索 |
|
||||
| test_list_tasks_search_case_insensitive | 大小写不敏感 |
|
||||
| test_list_tasks_search_no_match | 无匹配返回空列表 |
|
||||
| test_list_tasks_search_empty_q | q 为空返回全部 |
|
||||
|
||||
### 6.4 前端测试(手动验证)
|
||||
|
||||
| 验证点 | 操作 | 预期 |
|
||||
|--------|------|------|
|
||||
| 工具链 Tab 出现 | 打开前端 | Tab 栏有 ⛓️ 工具链 |
|
||||
| 列表加载 | 点击工具链 Tab | 显示 _toolchain 事件列表 |
|
||||
| 搜索过滤 | 输入关键词 | 列表实时过滤 |
|
||||
| 详情展示 | 点击某条事件 | 右侧/弹窗显示完整内容 |
|
||||
| Tab 切换不丢数据 | 切到其他 Tab 再回来 | 数据保持 |
|
||||
|
||||
### 6.5 CI 集成
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `bash tests/scripts/verify_api_compat.sh` | 路由兼容性验证(CI 必跑) |
|
||||
| `pytest tests/unit/test_task_routes.py tests/unit/test_expand_api.py tests/integration/test_api.py -m "not e2e" -v` | 新增单元 + 集成测试 |
|
||||
|
||||
> 测试用例详细设计(fixture + 完整代码 + 覆盖矩阵)见 `docs/design/18-test-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施计划
|
||||
|
||||
### Phase 1: 后端 API 拆分(不含功能变更)
|
||||
|
||||
| 步骤 | 内容 | 验证 |
|
||||
|------|------|------|
|
||||
| 1.1 | 创建 `shared.py`,提取共享 helper | import 无报错 |
|
||||
| 1.2 | 创建 `task_routes.py`,迁移 10 个路由 | 路由注册成功 |
|
||||
| 1.3 | 创建 `task_relation_routes.py`,迁移 13 个路由 | 路由注册成功 |
|
||||
| 1.4 | 更新 `main.py` router 注册 | app 启动无报错 |
|
||||
| 1.5 | 删除 `blackboard_routes.py` | — |
|
||||
| 1.6 | 运行 `verify_api_compat.sh` | 路由清单 diff = 0 |
|
||||
| 1.7 | 运行现有测试 | 全量通过 |
|
||||
|
||||
### Phase 2: expand 聚合 + 搜索
|
||||
|
||||
| 步骤 | 内容 | 验证 |
|
||||
|------|------|------|
|
||||
| 2.1 | 重写 `get_task` expand 逻辑(细粒度) | TestExpandAPI 通过 |
|
||||
| 2.2 | `list_tasks` 加 `q` 参数 | TestTaskSearch 通过 |
|
||||
| 2.3 | 新增测试用例 | 覆盖率达标 |
|
||||
|
||||
### Phase 3: 前端工具链 Tab
|
||||
|
||||
| 步骤 | 内容 | 验证 |
|
||||
|------|------|------|
|
||||
| 3.1 | store.ts 新增 toolchain 数据加载 | — |
|
||||
| 3.2 | api.ts 新增 expand 调用封装 | — |
|
||||
| 3.3 | 创建 `ToolchainPanel.tsx` | 组件渲染正常 |
|
||||
| 3.4 | App.tsx 注册新 Tab | Tab 显示正确 |
|
||||
| 3.5 | TaskModal 改用 expand 减少 | 请求次数减少 |
|
||||
|
||||
### Phase 4: 联调 + 评审
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|------|------|
|
||||
| 4.1 | 全量测试 `pytest -m "not e2e"` |
|
||||
| 4.2 | 发评审给司马懿 |
|
||||
| 4.3 | 前端手动验证 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险评估
|
||||
|
||||
| 风险 | 级别 | 缓解 |
|
||||
|------|------|------|
|
||||
| 拆分后 import 路径断裂 | 中 | IDE 全局搜索 + 运行时验证 |
|
||||
| expand 返回体过大 | 低 | comments/events 有 limit |
|
||||
| 工具链事件量大影响前端 | 低 | 搜索栏 + 分页 |
|
||||
| expand=all 向后兼容 | 低 | 单独兼容分支处理 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 评审记录
|
||||
|
||||
### 司马懿 mail-1781415763066(2026-06-14)
|
||||
|
||||
| 项目 | 结论 |
|
||||
|------|------|
|
||||
| 文件拆分 | 方案 B 调整版(task_routes + task_relation_routes + shared) |
|
||||
| expand | 细粒度,events/comments 带 limit+total_count |
|
||||
| 性能 | 当前 SQLite 多次查询可接受 |
|
||||
| checkpoint | 不纳入 |
|
||||
| _generate_title | 留在 task_routes.py |
|
||||
| write_output | 注意不是简单 CRUD |
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更记录
|
||||
|
||||
| 日期 | 版本 | 内容 |
|
||||
|------|------|------|
|
||||
| 2026-06-14 | v1.0 | 初版设计 |
|
||||
@@ -0,0 +1,484 @@
|
||||
# §18 测试用例详细设计
|
||||
|
||||
> **关联**: `docs/design/18-api-refactor-and-toolchain-tab.md`
|
||||
> **日期**: 2026-06-14
|
||||
|
||||
---
|
||||
|
||||
## 1. 测试文件规划
|
||||
|
||||
| 文件 | 类型 | 测试数 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `tests/integration/test_api.py` | 集成 | 扩展现有 | 拆分后回归验证 |
|
||||
| `tests/unit/test_task_routes.py` | 单元 | 14 | task_routes 专项 |
|
||||
| `tests/unit/test_expand_api.py` | 单元 | 7 | expand 聚合专项 |
|
||||
| `tests/unit/test_task_search.py` | 单元 | 4 | 搜索专项 |
|
||||
| `tests/scripts/verify_api_compat.sh` | 脚本 | 1 | CI 路由兼容性 |
|
||||
|
||||
**总计**:26 个测试 + 1 个 CI 脚本
|
||||
|
||||
---
|
||||
|
||||
## 2. 预置 Fixture
|
||||
|
||||
```python
|
||||
# tests/conftest.py 新增(如果不存在则补充)
|
||||
|
||||
@pytest.fixture
|
||||
def expand_env(tmp_path):
|
||||
"""expand 测试环境:1 个 task + 预置关联数据"""
|
||||
project_root = tmp_path / "projects"
|
||||
project_root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(project_root)
|
||||
|
||||
reg = ProjectRegistry(project_root)
|
||||
reg.create_project("test-proj", "Test Project", agents=["agent1"])
|
||||
|
||||
bb = Blackboard(project_root / "test-proj" / "blackboard.db")
|
||||
bb.create_task(Task(id="t1", title="Expand Test Task", task_type="coding"))
|
||||
|
||||
# 预置 25 条 comment
|
||||
for i in range(25):
|
||||
bb.add_comment("t1", agent="agent1", comment_type="general",
|
||||
content=f"Comment number {i}")
|
||||
|
||||
# 预置 5 条 output
|
||||
for i in range(5):
|
||||
bb.write_output("t1", agent="agent1",
|
||||
output_type="code",
|
||||
content=f"output content {i}",
|
||||
filename=f"file_{i}.py")
|
||||
|
||||
# 预置 3 条 review
|
||||
for i in range(3):
|
||||
bb.add_review("t1", reviewer="agent1",
|
||||
verdict="APPROVE",
|
||||
confidence=0.9,
|
||||
risk_level="low",
|
||||
summary=f"Review {i}")
|
||||
|
||||
# 预置 2 条 decision
|
||||
for i in range(2):
|
||||
bb.add_decision("t1", agent="agent1",
|
||||
decision_type="scope",
|
||||
rationale=f"Decision {i}")
|
||||
|
||||
# 预置 35 条 event
|
||||
from src.blackboard.queries import Queries
|
||||
q = Queries(project_root / "test-proj" / "blackboard.db")
|
||||
for i in range(35):
|
||||
q.add_event("t1", event_type="status_change",
|
||||
detail=f"Event {i}")
|
||||
|
||||
yield project_root
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. task_routes.py 测试(test_task_routes.py)
|
||||
|
||||
```python
|
||||
"""task_routes.py 路由测试 — 验证拆分后行为不变"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from src.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestTaskListRoutes:
|
||||
"""GET /tasks + 搜索"""
|
||||
|
||||
def test_list_tasks_basic(self, project_env):
|
||||
"""列表基本返回格式不变"""
|
||||
resp = client.get("/api/projects/test-proj/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tasks" in data
|
||||
assert isinstance(data["tasks"], list)
|
||||
|
||||
def test_list_tasks_with_search(self, project_env):
|
||||
"""q 参数搜索标题"""
|
||||
resp = client.get("/api/projects/test-proj/tasks?q=Existing")
|
||||
data = resp.json()
|
||||
assert len(data["tasks"]) == 1
|
||||
assert "Existing" in data["tasks"][0]["title"]
|
||||
|
||||
def test_list_tasks_search_case_insensitive(self, project_env):
|
||||
"""大小写不敏感"""
|
||||
resp = client.get("/api/projects/test-proj/tasks?q=existing")
|
||||
data = resp.json()
|
||||
assert len(data["tasks"]) == 1
|
||||
|
||||
def test_list_tasks_search_no_match(self, project_env):
|
||||
"""无匹配返回空列表"""
|
||||
resp = client.get("/api/projects/test-proj/tasks?q=nonexistent_xyz")
|
||||
data = resp.json()
|
||||
assert len(data["tasks"]) == 0
|
||||
|
||||
def test_list_tasks_search_empty_q(self, project_env):
|
||||
"""q 为空返回全部"""
|
||||
resp = client.get("/api/projects/test-proj/tasks?q=")
|
||||
data = resp.json()
|
||||
assert len(data["tasks"]) >= 1
|
||||
|
||||
|
||||
class TestTaskDetailRoutes:
|
||||
"""GET /tasks/{tid} + expand"""
|
||||
|
||||
def test_get_task_basic(self, project_env):
|
||||
"""无 expand 返回基本 task"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "t1"
|
||||
assert "comments" not in data # 无 expand 不含关联数据
|
||||
|
||||
def test_get_task_404(self, project_env):
|
||||
"""不存在的 task 返回 404"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestTaskActionRoutes:
|
||||
"""claim/status/patch/archive 行为不变"""
|
||||
|
||||
def test_claim_task(self, project_env):
|
||||
"""认领行为不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/claim",
|
||||
json={"agent": "agent1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_update_status(self, project_env):
|
||||
"""状态流转不变"""
|
||||
client.post("/api/projects/test-proj/tasks/t1/claim",
|
||||
json={"agent": "agent1"})
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/status",
|
||||
json={"agent": "agent1", "status": "working"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_status_transition(self, project_env):
|
||||
"""非法状态转换返回 409"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/status",
|
||||
json={"agent": "agent1", "status": "done"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_patch_task(self, project_env):
|
||||
"""PATCH 更新不变"""
|
||||
resp = client.patch("/api/projects/test-proj/tasks/t1",
|
||||
json={"priority": 5})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_archive_task(self, project_env):
|
||||
"""归档不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/archive",
|
||||
json={"agent": "agent1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestTaskCreateRoute:
|
||||
"""POST /tasks 创建行为不变"""
|
||||
|
||||
def test_create_task(self, project_env):
|
||||
"""创建格式不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks",
|
||||
json={"title": "New Task", "description": "test"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. expand 聚合测试(test_expand_api.py)
|
||||
|
||||
```python
|
||||
"""expand 聚合接口测试"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from src.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestExpandComments:
|
||||
"""expand=comments"""
|
||||
|
||||
def test_comments_limit_and_count(self, expand_env):
|
||||
"""返回最新 20 条 + total_count=25"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
|
||||
data = resp.json()
|
||||
|
||||
comments = data["comments"]
|
||||
assert isinstance(comments, dict)
|
||||
assert len(comments["items"]) == 20
|
||||
assert comments["total_count"] == 25
|
||||
assert comments["limit"] == 20
|
||||
|
||||
def test_comments_are_latest(self, expand_env):
|
||||
"""返回的是最新 20 条(Comment 5-24)"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
|
||||
data = resp.json()
|
||||
first_content = data["comments"]["items"][0]["content"]
|
||||
last_content = data["comments"]["items"][-1]["content"]
|
||||
# 最新 20 条 = index 5 到 24
|
||||
assert "5" in first_content or "24" in last_content
|
||||
|
||||
|
||||
class TestExpandEvents:
|
||||
"""expand=events"""
|
||||
|
||||
def test_events_limit_and_count(self, expand_env):
|
||||
"""返回最新 30 条 + total_count=35"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=events")
|
||||
data = resp.json()
|
||||
|
||||
events = data["events"]
|
||||
assert isinstance(events, dict)
|
||||
assert len(events["items"]) == 30
|
||||
assert events["total_count"] == 35
|
||||
assert events["limit"] == 30
|
||||
|
||||
|
||||
class TestExpandFullResources:
|
||||
"""outputs/reviews/decisions 全量返回"""
|
||||
|
||||
def test_expand_outputs_full(self, expand_env):
|
||||
"""outputs 全量返回(5 条),格式是 list 不是 dict"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=outputs")
|
||||
data = resp.json()
|
||||
|
||||
outputs = data["outputs"]
|
||||
assert isinstance(outputs, list)
|
||||
assert len(outputs) == 5
|
||||
|
||||
def test_expand_reviews_full(self, expand_env):
|
||||
"""reviews 全量返回(3 条)"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=reviews")
|
||||
data = resp.json()
|
||||
|
||||
reviews = data["reviews"]
|
||||
assert isinstance(reviews, list)
|
||||
assert len(reviews) == 3
|
||||
|
||||
def test_expand_decisions_full(self, expand_env):
|
||||
"""decisions 全量返回(2 条)"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=decisions")
|
||||
data = resp.json()
|
||||
|
||||
decisions = data["decisions"]
|
||||
assert isinstance(decisions, list)
|
||||
assert len(decisions) == 2
|
||||
|
||||
|
||||
class TestExpandCombinations:
|
||||
"""组合 expand"""
|
||||
|
||||
def test_expand_multiple_fields(self, expand_env):
|
||||
"""expand=comments,outputs,reviews 组合"""
|
||||
resp = client.get(
|
||||
"/api/projects/test-proj/tasks/t1?expand=comments,outputs,reviews"
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
assert "comments" in data
|
||||
assert "outputs" in data
|
||||
assert "reviews" in data
|
||||
assert "events" not in data # 未请求
|
||||
assert "decisions" not in data
|
||||
|
||||
def test_expand_all_compat(self, expand_env):
|
||||
"""expand=all 向后兼容"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1?expand=all")
|
||||
data = resp.json()
|
||||
|
||||
# all 返回所有关联资源
|
||||
assert "comments" in data
|
||||
assert "outputs" in data
|
||||
assert "reviews" in data
|
||||
assert "events" in data
|
||||
assert "decisions" in data
|
||||
|
||||
def test_no_expand(self, expand_env):
|
||||
"""不传 expand 只返回基本 task"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1")
|
||||
data = resp.json()
|
||||
|
||||
assert "comments" not in data
|
||||
assert "outputs" not in data
|
||||
assert data["id"] == "t1"
|
||||
|
||||
def test_expand_invalid_field_ignored(self, expand_env):
|
||||
"""无效 expand 字段静默忽略"""
|
||||
resp = client.get(
|
||||
"/api/projects/test-proj/tasks/t1?expand=comments,invalid_field"
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
assert "comments" in data
|
||||
assert "invalid_field" not in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. task_relation_routes.py 回归测试
|
||||
|
||||
```python
|
||||
"""task_relation_routes.py 路由回归 — 验证拆分后行为不变"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from src.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestRelationRoutesRegression:
|
||||
"""关联路由回归测试"""
|
||||
|
||||
def test_comments_crud(self, project_env):
|
||||
"""GET/POST comments 不变"""
|
||||
# POST
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/comments",
|
||||
json={"agent": "a1", "comment_type": "general",
|
||||
"content": "test comment"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# GET
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/comments")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["comments"]) >= 1
|
||||
|
||||
def test_outputs_crud(self, project_env):
|
||||
"""GET/POST outputs 不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
|
||||
json={"agent": "a1", "type": "code",
|
||||
"content": "print('hello')"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/outputs")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_write_output_with_filename(self, project_env):
|
||||
"""output 含 filename 的文件写入不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
|
||||
json={"agent": "a1", "type": "code",
|
||||
"content": "x = 1",
|
||||
"filename": "test.py"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_write_output_invalid_type(self, project_env):
|
||||
"""output 无效 type 返回 422"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
|
||||
json={"agent": "a1", "type": "invalid_type",
|
||||
"content": "x"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_reviews_crud(self, project_env):
|
||||
"""GET/POST reviews 不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/reviews",
|
||||
json={"reviewer": "a1", "verdict": "APPROVE",
|
||||
"confidence": 0.9, "risk_level": "low",
|
||||
"summary": "LGTM"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/reviews")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_decisions_crud(self, project_env):
|
||||
"""GET/POST decisions 不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/decisions",
|
||||
json={"agent": "a1", "decision_type": "scope",
|
||||
"rationale": "test"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/decisions")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_observations_add(self, project_env):
|
||||
"""POST observations 不变"""
|
||||
resp = client.post("/api/projects/test-proj/tasks/t1/observations",
|
||||
json={"agent": "a1", "observation_type": "note",
|
||||
"content": "observed"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_events_list(self, project_env):
|
||||
"""GET events 不变"""
|
||||
resp = client.get("/api/projects/test-proj/tasks/t1/events")
|
||||
assert resp.status_code == 200
|
||||
assert "events" in resp.json()
|
||||
|
||||
def test_project_events(self, project_env):
|
||||
"""GET /events 项目级事件不变"""
|
||||
resp = client.get("/api/projects/test-proj/events")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_summary(self, project_env):
|
||||
"""GET /summary 不变"""
|
||||
resp = client.get("/api/projects/test-proj/summary")
|
||||
assert resp.status_code == 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CI 集成
|
||||
|
||||
### .gitea/workflows/ci.yml 新增步骤
|
||||
|
||||
```yaml
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# ... 现有步骤 ...
|
||||
|
||||
# API 兼容性验证
|
||||
- name: Verify API Compatibility
|
||||
run: |
|
||||
cd src/frontend && npm run build
|
||||
bash tests/scripts/verify_api_compat.sh
|
||||
|
||||
# 新增测试
|
||||
- name: Run API Tests
|
||||
run: |
|
||||
pytest tests/unit/test_task_routes.py \
|
||||
tests/unit/test_expand_api.py \
|
||||
tests/integration/test_api.py \
|
||||
-m "not e2e" -v
|
||||
```
|
||||
|
||||
### 本地开发验证流程
|
||||
|
||||
```bash
|
||||
# 1. 改完代码后先跑兼容性验证
|
||||
bash tests/scripts/verify_api_compat.sh
|
||||
|
||||
# 2. 跑全量测试
|
||||
pytest -m "not e2e" -v
|
||||
|
||||
# 3. 跑新增专项测试
|
||||
pytest tests/unit/test_task_routes.py tests/unit/test_expand_api.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试覆盖矩阵
|
||||
|
||||
| 设计文档章节 | 测试文件 | 测试类 | 用例数 |
|
||||
|-------------|---------|--------|--------|
|
||||
| §2 路由拆分(兼容性) | verify_api_compat.sh | — | 1 |
|
||||
| §3.1 基本详情 | test_task_routes.py | TestTaskDetailRoutes | 2 |
|
||||
| §3.2 搜索 | test_task_routes.py | TestTaskListRoutes | 5 |
|
||||
| §3.3 动作路由 | test_task_routes.py | TestTaskActionRoutes | 5 |
|
||||
| §3.4 创建 | test_task_routes.py | TestTaskCreateRoute | 1 |
|
||||
| §4 expand comments | test_expand_api.py | TestExpandComments | 2 |
|
||||
| §4 expand events | test_expand_api.py | TestExpandEvents | 1 |
|
||||
| §4 expand 全量 | test_expand_api.py | TestExpandFullResources | 3 |
|
||||
| §4 expand 组合 | test_expand_api.py | TestExpandCombinations | 4 |
|
||||
| §5.2 关联回归 | test_api.py | TestRelationRoutesRegression | 10 |
|
||||
| **合计** | | | **34** |
|
||||
@@ -0,0 +1,73 @@
|
||||
"""共享 helper 和常量"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.queries import Queries
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
from src.utils import get_data_root
|
||||
|
||||
# 虚拟项目白名单
|
||||
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail", "_toolchain"})
|
||||
|
||||
|
||||
def _validate_project(project_id: str) -> str:
|
||||
"""校验 project_id"""
|
||||
if project_id in _VIRTUAL_PROJECTS:
|
||||
return project_id
|
||||
reg = ProjectRegistry(get_data_root())
|
||||
if reg.get_project(project_id):
|
||||
return project_id
|
||||
raise HTTPException(400, {
|
||||
"ok": False,
|
||||
"error": "project_not_found",
|
||||
"detail": f"Project '{project_id}' is not registered.",
|
||||
"suggestions": [
|
||||
f"Register first: POST /api/projects with id='{project_id}'",
|
||||
"Or use '_general' for tasks without a specific project",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _bb(project_id: str) -> Blackboard:
|
||||
_validate_project(project_id)
|
||||
return Blackboard(get_data_root() / project_id / "blackboard.db")
|
||||
|
||||
|
||||
def _q(project_id: str) -> Queries:
|
||||
_validate_project(project_id)
|
||||
return Queries(get_data_root() / project_id / "blackboard.db")
|
||||
|
||||
|
||||
def _task_to_dict(t: Task) -> Dict[str, Any]:
|
||||
d = {k: v for k, v in t.__dict__.items() if v is not None}
|
||||
return d
|
||||
|
||||
|
||||
_KNOWN_AGENT_IDS: list = []
|
||||
|
||||
|
||||
def _init_agent_ids():
|
||||
"""从配置文件加载 Agent ID 列表"""
|
||||
global _KNOWN_AGENT_IDS
|
||||
if _KNOWN_AGENT_IDS:
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
import os
|
||||
cfg_path = os.path.join(os.path.dirname(__file__), "..", "..", "config", "default.yaml")
|
||||
with open(cfg_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
_KNOWN_AGENT_IDS = list(cfg.get("daemon", {}).get("agent_profiles", {}).keys())
|
||||
except Exception:
|
||||
_KNOWN_AGENT_IDS = []
|
||||
|
||||
|
||||
def _extract_mentions(text: str) -> list:
|
||||
"""从文本中自动提取 @agent-id 格式的 mention"""
|
||||
import re
|
||||
_init_agent_ids()
|
||||
candidates = set(re.findall(r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)', text))
|
||||
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Task 关联路由 — comments / outputs / decisions / observations / reviews / events / experiences / summary"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from src.blackboard.models import Review
|
||||
from src.blackboard.db import OUTPUT_TYPES
|
||||
|
||||
from src.api.shared import (
|
||||
_bb,
|
||||
_q,
|
||||
_extract_mentions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Comments
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks/{task_id}/comments")
|
||||
async def get_comments(project_id: str, task_id: str,
|
||||
comment_type: Optional[str] = None):
|
||||
bb = _bb(project_id)
|
||||
comments = bb.get_comments(task_id, comment_type=comment_type)
|
||||
return {"comments": [dict(c.__dict__) for c in comments]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
mentions_raw = body.get("mentions")
|
||||
comment_body = body["body"]
|
||||
|
||||
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
|
||||
auto_mentions = _extract_mentions(comment_body)
|
||||
if isinstance(mentions_raw, str):
|
||||
try:
|
||||
explicit_mentions = json.loads(mentions_raw)
|
||||
except Exception:
|
||||
explicit_mentions = []
|
||||
elif isinstance(mentions_raw, list):
|
||||
explicit_mentions = mentions_raw
|
||||
else:
|
||||
explicit_mentions = []
|
||||
merged_mentions = list(set(explicit_mentions + auto_mentions))
|
||||
|
||||
cid = bb.add_comment(task_id, body["author"], comment_body,
|
||||
comment_type=body.get("comment_type", "general"),
|
||||
mentions=merged_mentions)
|
||||
if merged_mentions:
|
||||
bb.record_mentions(cid, task_id, merged_mentions)
|
||||
# #10: SSE 通知前端黑板有新 comment
|
||||
try:
|
||||
from src.api.sse_routes import get_broker
|
||||
broker = get_broker()
|
||||
broker.publish_sync("comment_added", {
|
||||
"project_id": project_id,
|
||||
"task_id": task_id,
|
||||
"comment_id": cid,
|
||||
"author": body["author"],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Outputs
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks/{task_id}/outputs")
|
||||
async def get_outputs(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/outputs")
|
||||
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
|
||||
# 字段校验 + Agent-friendly 错误
|
||||
agent = body.get("agent")
|
||||
if not agent:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: agent",
|
||||
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
|
||||
})
|
||||
|
||||
# type 字段:接受 type 或 content_type(别名兼容)
|
||||
output_type = body.get("type") or body.get("content_type")
|
||||
valid_types = sorted(OUTPUT_TYPES)
|
||||
if not output_type:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: type",
|
||||
"valid_values": {"type": valid_types},
|
||||
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
|
||||
})
|
||||
if output_type not in OUTPUT_TYPES:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": f"Invalid type: '{output_type}'",
|
||||
"valid_values": {"type": valid_types},
|
||||
})
|
||||
|
||||
title = body.get("title")
|
||||
if not title:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: title",
|
||||
"hint": "Provide a brief title describing this output",
|
||||
})
|
||||
|
||||
# 内容模式:content(直传)或 content_path(引用)
|
||||
content = body.get("content")
|
||||
content_path = body.get("content_path") or body.get("path")
|
||||
|
||||
if content and not content_path:
|
||||
# 内容直传模式:自动写文件
|
||||
artifacts_dir = os.path.join(
|
||||
os.path.dirname(bb.db_path), "artifacts", task_id
|
||||
)
|
||||
os.makedirs(artifacts_dir, exist_ok=True)
|
||||
# 安全文件名
|
||||
safe_name = "".join(
|
||||
c if c.isalnum() or c in "._-" else "_" for c in title)
|
||||
if not safe_name:
|
||||
safe_name = "output"
|
||||
file_path = os.path.join(artifacts_dir, safe_name)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
content_path = file_path
|
||||
|
||||
oid = bb.write_output(
|
||||
task_id, agent, output_type, title,
|
||||
content_path=content_path,
|
||||
summary=body.get("summary"),
|
||||
metadata=body.get("metadata"),
|
||||
)
|
||||
return {"ok": True, "output_id": oid}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Decisions
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks/{task_id}/decisions")
|
||||
async def get_decisions(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/decisions")
|
||||
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
did = bb.add_decision(task_id, body["decider"], body["decision"],
|
||||
body["rationale"],
|
||||
alternatives=body.get("alternatives"))
|
||||
return {"ok": True, "decision_id": did}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Observations
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.post("/tasks/{task_id}/observations")
|
||||
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
oid = bb.add_observation(task_id, body["observer"], body["body"],
|
||||
severity=body.get("severity", "info"))
|
||||
return {"ok": True, "observation_id": oid}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reviews
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks/{task_id}/reviews")
|
||||
async def get_reviews(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reviews")
|
||||
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
review = Review(
|
||||
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
|
||||
review_type=body["review_type"], verdict=body["verdict"],
|
||||
summary=body["summary"], confidence=body.get("confidence"),
|
||||
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
|
||||
)
|
||||
bb.add_review(review)
|
||||
return {"ok": True, "review_id": review.id}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-task Events & Experiences
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks/{task_id}/events")
|
||||
async def get_task_events(project_id: str, task_id: str,
|
||||
limit: int = Query(50, le=200)):
|
||||
q = _q(project_id)
|
||||
return {"events": q.task_events(task_id, limit)}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/experiences")
|
||||
async def get_task_experiences(project_id: str, task_id: str):
|
||||
q = _q(project_id)
|
||||
return {"experiences": q.task_experiences(task_id)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Global Events
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/events")
|
||||
async def get_events(project_id: str, limit: int = Query(50, le=200)):
|
||||
q = _q(project_id)
|
||||
return {"events": q.recent_events(limit)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Summary
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/summary")
|
||||
async def task_summary(project_id: str):
|
||||
q = _q(project_id)
|
||||
return {"summary": q.task_summary()}
|
||||
@@ -1,68 +1,45 @@
|
||||
"""API 路由 — 黑板 CRUD"""
|
||||
"""Task 核心路由 — CRUD、状态、归档"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.models import Task, Review
|
||||
from src.blackboard.queries import Queries
|
||||
from src.blackboard.db import VALID_STATUSES, OUTPUT_TYPES
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.db import VALID_STATUSES, VALID_TRANSITIONS
|
||||
from src.utils import get_data_root
|
||||
|
||||
from src.api.shared import (
|
||||
_bb,
|
||||
_q,
|
||||
_task_to_dict,
|
||||
_extract_mentions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
|
||||
|
||||
# 虚拟项目白名单(不需要在 registry 注册)
|
||||
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail"})
|
||||
|
||||
|
||||
def _validate_project(project_id: str) -> str:
|
||||
"""校验 project_id,已知项目/虚拟项目放行,未知项目返回 400"""
|
||||
if project_id in _VIRTUAL_PROJECTS:
|
||||
return project_id
|
||||
reg = ProjectRegistry(get_data_root())
|
||||
if reg.get_project(project_id):
|
||||
return project_id
|
||||
raise HTTPException(400, {
|
||||
"ok": False,
|
||||
"error": "project_not_found",
|
||||
"detail": f"Project '{project_id}' is not registered.",
|
||||
"suggestions": [
|
||||
f"Register first: POST /api/projects with id='{project_id}'",
|
||||
"Or use '_general' for tasks without a specific project",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _bb(project_id: str) -> Blackboard:
|
||||
_validate_project(project_id)
|
||||
return Blackboard(get_data_root() / project_id / "blackboard.db")
|
||||
|
||||
|
||||
def _q(project_id: str) -> Queries:
|
||||
_validate_project(project_id)
|
||||
return Queries(get_data_root() / project_id / "blackboard.db")
|
||||
|
||||
|
||||
# --- Tasks ---
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tasks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.get("/tasks")
|
||||
async def list_tasks(project_id: str,
|
||||
status: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
parent_task: Optional[str] = None):
|
||||
parent_task: Optional[str] = None,
|
||||
q: Optional[str] = None):
|
||||
bb = _bb(project_id)
|
||||
tasks = bb.list_tasks(
|
||||
status=status,
|
||||
assignee=assignee,
|
||||
parent_task=parent_task)
|
||||
tasks = bb.list_tasks(status=status, assignee=assignee, parent_task=parent_task)
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
tasks = [t for t in tasks if q_lower in (t.title or "").lower()]
|
||||
return {"tasks": [_task_to_dict(t) for t in tasks]}
|
||||
|
||||
|
||||
@@ -74,6 +51,11 @@ async def get_task(project_id: str, task_id: str,
|
||||
if not task:
|
||||
raise HTTPException(404, f"Task not found: {task_id}")
|
||||
result = _task_to_dict(task)
|
||||
|
||||
if not expand:
|
||||
return result
|
||||
|
||||
# expand=all: 保持旧格式(list + 聚合字段),向后兼容前端 TaskModal
|
||||
if expand == "all":
|
||||
q = _q(project_id)
|
||||
detail = q.task_detail(task_id)
|
||||
@@ -90,6 +72,37 @@ async def get_task(project_id: str, task_id: str,
|
||||
for d in bb.get_decisions(task_id)]
|
||||
result["events"] = q.task_events(task_id)
|
||||
result["experiences"] = q.task_experiences(task_id)
|
||||
return result
|
||||
|
||||
# 细粒度 expand: 新格式(comments/events 带 limit + total_count)
|
||||
expand_list = expand.split(",")
|
||||
q = _q(project_id)
|
||||
|
||||
if "comments" in expand_list:
|
||||
all_comments = bb.get_comments(task_id)
|
||||
result["comments"] = {
|
||||
"items": [dict(c.__dict__) for c in all_comments[-20:]],
|
||||
"total_count": len(all_comments),
|
||||
"limit": 20,
|
||||
}
|
||||
|
||||
if "events" in expand_list:
|
||||
all_events = q.task_events(task_id, limit=99999)
|
||||
result["events"] = {
|
||||
"items": all_events[-30:],
|
||||
"total_count": len(all_events),
|
||||
"limit": 30,
|
||||
}
|
||||
|
||||
if "outputs" in expand_list:
|
||||
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
|
||||
|
||||
if "reviews" in expand_list:
|
||||
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
|
||||
|
||||
if "decisions" in expand_list:
|
||||
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -100,11 +113,9 @@ async def create_task(project_id: str, body: Dict[str, Any]):
|
||||
task_id = body.get("id")
|
||||
if not task_id:
|
||||
import re
|
||||
from datetime import datetime
|
||||
prefix = re.sub(r'[^a-z0-9]', '-', project_id.lower()).strip('-')[:20]
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
# seq: 查当前项目最大 seq
|
||||
import sqlite3
|
||||
db_path = get_data_root() / project_id / "blackboard.db"
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
@@ -237,7 +248,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
})
|
||||
|
||||
# 检查转换是否合法
|
||||
from src.blackboard.db import VALID_TRANSITIONS
|
||||
current = old_task.status
|
||||
allowed = VALID_TRANSITIONS.get(current, set())
|
||||
if new_status not in allowed:
|
||||
@@ -271,220 +281,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
return {"ok": True, "old_status": current, "new_status": new_status}
|
||||
|
||||
|
||||
# --- @mention 自动提取(#04) ---
|
||||
_KNOWN_AGENT_IDS: list = []
|
||||
|
||||
|
||||
def _init_agent_ids():
|
||||
"""从配置文件加载 Agent ID 列表"""
|
||||
global _KNOWN_AGENT_IDS
|
||||
if _KNOWN_AGENT_IDS:
|
||||
return
|
||||
try:
|
||||
import yaml
|
||||
cfg_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"..",
|
||||
"config",
|
||||
"default.yaml")
|
||||
with open(cfg_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
_KNOWN_AGENT_IDS = list(
|
||||
cfg.get(
|
||||
"daemon",
|
||||
{}).get(
|
||||
"agent_profiles",
|
||||
{}).keys())
|
||||
except Exception:
|
||||
_KNOWN_AGENT_IDS = []
|
||||
|
||||
|
||||
def _extract_mentions(text: str) -> list:
|
||||
"""从文本中自动提取 @agent-id 格式的 mention"""
|
||||
import re
|
||||
_init_agent_ids()
|
||||
candidates = set(
|
||||
re.findall(
|
||||
r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)',
|
||||
text))
|
||||
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
|
||||
|
||||
|
||||
# --- Comments ---
|
||||
|
||||
@router.get("/tasks/{task_id}/comments")
|
||||
async def get_comments(project_id: str, task_id: str,
|
||||
comment_type: Optional[str] = None):
|
||||
bb = _bb(project_id)
|
||||
comments = bb.get_comments(task_id, comment_type=comment_type)
|
||||
return {"comments": [dict(c.__dict__) for c in comments]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/comments")
|
||||
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
mentions_raw = body.get("mentions")
|
||||
comment_body = body["body"]
|
||||
|
||||
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
|
||||
auto_mentions = _extract_mentions(comment_body)
|
||||
if isinstance(mentions_raw, str):
|
||||
try:
|
||||
explicit_mentions = json.loads(mentions_raw)
|
||||
except Exception:
|
||||
explicit_mentions = []
|
||||
elif isinstance(mentions_raw, list):
|
||||
explicit_mentions = mentions_raw
|
||||
else:
|
||||
explicit_mentions = []
|
||||
merged_mentions = list(set(explicit_mentions + auto_mentions))
|
||||
|
||||
cid = bb.add_comment(task_id, body["author"], comment_body,
|
||||
comment_type=body.get("comment_type", "general"),
|
||||
mentions=merged_mentions)
|
||||
if merged_mentions:
|
||||
bb.record_mentions(cid, task_id, merged_mentions)
|
||||
# #10: SSE 通知前端黑板有新 comment
|
||||
try:
|
||||
from src.api.sse_routes import get_broker
|
||||
broker = get_broker()
|
||||
broker.publish_sync("comment_added", {
|
||||
"project_id": project_id,
|
||||
"task_id": task_id,
|
||||
"comment_id": cid,
|
||||
"author": body["author"],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
|
||||
|
||||
|
||||
# --- Outputs ---
|
||||
|
||||
@router.get("/tasks/{task_id}/outputs")
|
||||
async def get_outputs(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/outputs")
|
||||
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
|
||||
# 字段校验 + Agent-friendly 错误
|
||||
agent = body.get("agent")
|
||||
if not agent:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: agent",
|
||||
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
|
||||
})
|
||||
|
||||
# type 字段:接受 type 或 content_type(别名兼容)
|
||||
output_type = body.get("type") or body.get("content_type")
|
||||
valid_types = sorted(OUTPUT_TYPES)
|
||||
if not output_type:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: type",
|
||||
"valid_values": {"type": valid_types},
|
||||
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
|
||||
})
|
||||
if output_type not in OUTPUT_TYPES:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": f"Invalid type: '{output_type}'",
|
||||
"valid_values": {"type": valid_types},
|
||||
})
|
||||
|
||||
title = body.get("title")
|
||||
if not title:
|
||||
raise HTTPException(422, {
|
||||
"error": "validation_failed",
|
||||
"detail": "Missing required field: title",
|
||||
"hint": "Provide a brief title describing this output",
|
||||
})
|
||||
|
||||
# 内容模式:content(直传)或 content_path(引用)
|
||||
content = body.get("content")
|
||||
content_path = body.get("content_path") or body.get("path")
|
||||
|
||||
if content and not content_path:
|
||||
# 内容直传模式:自动写文件
|
||||
import os
|
||||
artifacts_dir = os.path.join(
|
||||
os.path.dirname(bb.db_path), "artifacts", task_id
|
||||
)
|
||||
os.makedirs(artifacts_dir, exist_ok=True)
|
||||
# 安全文件名
|
||||
safe_name = "".join(
|
||||
c if c.isalnum() or c in "._-" else "_" for c in title)
|
||||
if not safe_name:
|
||||
safe_name = "output"
|
||||
file_path = os.path.join(artifacts_dir, safe_name)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
content_path = file_path
|
||||
|
||||
oid = bb.write_output(
|
||||
task_id, agent, output_type, title,
|
||||
content_path=content_path,
|
||||
summary=body.get("summary"),
|
||||
metadata=body.get("metadata"),
|
||||
)
|
||||
return {"ok": True, "output_id": oid}
|
||||
|
||||
|
||||
# --- Decisions ---
|
||||
|
||||
@router.get("/tasks/{task_id}/decisions")
|
||||
async def get_decisions(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/decisions")
|
||||
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
did = bb.add_decision(task_id, body["decider"], body["decision"],
|
||||
body["rationale"],
|
||||
alternatives=body.get("alternatives"))
|
||||
return {"ok": True, "decision_id": did}
|
||||
|
||||
|
||||
# --- Observations ---
|
||||
|
||||
@router.post("/tasks/{task_id}/observations")
|
||||
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
oid = bb.add_observation(task_id, body["observer"], body["body"],
|
||||
severity=body.get("severity", "info"))
|
||||
return {"ok": True, "observation_id": oid}
|
||||
|
||||
|
||||
# --- Reviews ---
|
||||
|
||||
@router.get("/tasks/{task_id}/reviews")
|
||||
async def get_reviews(project_id: str, task_id: str):
|
||||
bb = _bb(project_id)
|
||||
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/reviews")
|
||||
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
review = Review(
|
||||
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
|
||||
review_type=body["review_type"], verdict=body["verdict"],
|
||||
summary=body["summary"], confidence=body.get("confidence"),
|
||||
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
|
||||
)
|
||||
bb.add_review(review)
|
||||
return {"ok": True, "review_id": review.id}
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
"""更新任务元数据(归档、标题等)"""
|
||||
@@ -497,7 +293,6 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
# 直接用 SQL 更新
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(bb.db_path), timeout=5)
|
||||
try:
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
@@ -509,38 +304,9 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- Per-task Events & Experiences ---
|
||||
|
||||
@router.get("/tasks/{task_id}/events")
|
||||
async def get_task_events(project_id: str, task_id: str,
|
||||
limit: int = Query(50, le=200)):
|
||||
q = _q(project_id)
|
||||
return {"events": q.task_events(task_id, limit)}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/experiences")
|
||||
async def get_task_experiences(project_id: str, task_id: str):
|
||||
q = _q(project_id)
|
||||
return {"experiences": q.task_experiences(task_id)}
|
||||
|
||||
|
||||
# --- Global Events ---
|
||||
|
||||
@router.get("/events")
|
||||
async def get_events(project_id: str, limit: int = Query(50, le=200)):
|
||||
q = _q(project_id)
|
||||
return {"events": q.recent_events(limit)}
|
||||
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
@router.get("/summary")
|
||||
async def task_summary(project_id: str):
|
||||
q = _q(project_id)
|
||||
return {"summary": q.task_summary()}
|
||||
|
||||
|
||||
# --- Archive (v2.8) ---
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Archive (v2.8)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@router.post("/tasks/{task_id}/archive")
|
||||
async def archive_task(project_id: str, task_id: str,
|
||||
@@ -563,10 +329,3 @@ async def archive_done_tasks(project_id: str):
|
||||
bb = _bb(project_id)
|
||||
count = bb.archive_done_tasks()
|
||||
return {"ok": True, "archived_count": count}
|
||||
|
||||
|
||||
# --- Helper ---
|
||||
|
||||
def _task_to_dict(t: Task) -> Dict[str, Any]:
|
||||
d = {k: v for k, v in t.__dict__.items() if v is not None}
|
||||
return d
|
||||
@@ -209,6 +209,7 @@ VALID_TRANSITIONS = {
|
||||
COMMENT_TYPES = frozenset({
|
||||
"general", "handoff", "observation", "review", "rebuttal",
|
||||
"rebuttal_response", "debate_argument", "debate_rebuttal", "debate_judgment",
|
||||
"action_report",
|
||||
})
|
||||
|
||||
SEVERITY_LEVELS = frozenset({"blocking", "warning", "info", "audit"})
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler.mail")
|
||||
@@ -36,7 +36,7 @@ class MailHandler(BaseTaskHandler):
|
||||
return composer.compose(context)
|
||||
|
||||
def get_sections(self) -> list:
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection()]
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection()]
|
||||
|
||||
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
|
||||
"""Mail 完成验证:区分 inform/request。
|
||||
|
||||
@@ -127,3 +127,50 @@ class PromptComposer:
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
class GiteaConventionSection:
|
||||
"""Gitea 标题规范引导段 — 提醒 Agent 创建 Issue/PR 时遵循标题格式。"""
|
||||
|
||||
name: str = "gitea_convention"
|
||||
priority: int = 55 # CONSTRAINTS(50) 和 EXTENSION(60) 之间
|
||||
|
||||
CONVENTION_TEXT = (
|
||||
"## Gitea 标题规范\n"
|
||||
"创建 Issue/PR 时,标题**必须**包含项目代号前缀:\n"
|
||||
"- Issue: `[代号] type: 简述`,如 `[moz] bug: Mail API 500`\n"
|
||||
"- PR: `[代号] type(scope): 简述`,如 `[moz] impl(daemon): WikiGuideSection 注入`\n"
|
||||
"代号:moz=moziplus_v2, quant=quant_live, vnpy=vnpy\n"
|
||||
"type: bug/feat/impl/fix/docs/test/ci/refactor/chore"
|
||||
)
|
||||
|
||||
def render(self, context: "PromptContext") -> str:
|
||||
return self.CONVENTION_TEXT
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WikiGuideSection — 知识查询引导段
|
||||
# ---------------------------------------------------------------------------
|
||||
class WikiGuideSection:
|
||||
"""知识查询引导段 — 引导 Agent 在关键决策点查 wiki-vault。"""
|
||||
|
||||
name: str = "wiki_guide"
|
||||
priority: int = 60 # PRIORITY_EXTENSION
|
||||
|
||||
WIKI_GUIDE = (
|
||||
"## 知识查询引导\n"
|
||||
"涉及方案设计、编码实现、故障排查时,先查 wiki-vault 相关实践:\n"
|
||||
"- 路径:/Volumes/KnowledgeBase/wiki-vault/\n"
|
||||
"- 速查:index.md → grep 关键词 → summary 字段 → 按需读全文\n"
|
||||
"- 查不到:在 _meta/knowledge-gaps.md 记录"
|
||||
)
|
||||
|
||||
def render(self, context: "PromptContext") -> str:
|
||||
return self.WIKI_GUIDE
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler")
|
||||
@@ -306,13 +306,15 @@ class TaskHandler(BaseTaskHandler):
|
||||
return True
|
||||
|
||||
def get_sections(self) -> list:
|
||||
"""返回 5 个 PromptSection 实例。"""
|
||||
"""返回 PromptSection 实例。"""
|
||||
return [
|
||||
TaskContextSection(),
|
||||
PriorOutputsSection(),
|
||||
RoleSkillSection(),
|
||||
TaskApiSection(),
|
||||
TaskConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
@@ -221,11 +221,13 @@ class ToolchainHandler(BaseTaskHandler):
|
||||
return self._auto_mark_working(task_id, db_path)
|
||||
|
||||
def get_sections(self) -> list:
|
||||
"""返回 3 个 Toolchain PromptSection 实例"""
|
||||
"""返回 Toolchain PromptSection 实例"""
|
||||
return [
|
||||
ToolchainContextSection(),
|
||||
ToolchainApiSection(),
|
||||
ToolchainConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -17,6 +17,7 @@ import CourtCeremony from './components/CourtCeremony';
|
||||
import CourtDiscussion from './components/CourtDiscussion';
|
||||
import UsagePanel from './components/UsagePanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import ToolchainPanel from './components/ToolchainPanel';
|
||||
import GlobalSearch from './components/GlobalSearch';
|
||||
import NotificationCenter from './components/NotificationCenter';
|
||||
|
||||
@@ -100,6 +101,7 @@ export default function App() {
|
||||
usage: <UsagePanel />,
|
||||
morning: <MorningPanel />,
|
||||
settings: <SettingsPanel />,
|
||||
toolchain: <ToolchainPanel />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* ToolchainPanel — 工具链事件(系统级)
|
||||
* 展示 _toolchain 项目的 tasks:CI/PR/部署/Review 通知
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#f59e0b22', claimed: '#6a9eff22', working: '#6a9eff22',
|
||||
review: '#818cf822', done: '#2ecc8a22', failed: '#ef444422',
|
||||
cancelled: '#6b728022', blocked: '#ef444422',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '待处理', claimed: '已认领', working: '处理中',
|
||||
review: '审查中', done: '已完成', failed: '失败',
|
||||
cancelled: '已取消', blocked: '已拦截',
|
||||
};
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
const now = Date.now();
|
||||
const diff = now - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins}分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}小时前`;
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
export default function ToolchainPanel() {
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<any>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadTasks = async (q?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = q
|
||||
? `/api/projects/_toolchain/tasks?q=${encodeURIComponent(q)}`
|
||||
: `/api/projects/_toolchain/tasks`;
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
}
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { loadTasks(); }, []);
|
||||
|
||||
// 搜索防抖 300ms
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchQuery !== undefined) loadTasks(searchQuery || undefined);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/_toolchain/tasks/${selectedId}?expand=comments`
|
||||
);
|
||||
if (res.ok) setDetail(await res.json());
|
||||
} catch { /* */ }
|
||||
})();
|
||||
}, [selectedId]);
|
||||
|
||||
// 渲染评论列表(兼容 expand 和裸 list 格式)
|
||||
const renderComments = (comments: any[]) => {
|
||||
if (!comments || comments.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 600 }}>
|
||||
📋 处理记录 ({comments.length})
|
||||
</div>
|
||||
{comments.map((c: any, i: number) => (
|
||||
<div key={c.id || i} style={{
|
||||
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 6, marginBottom: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>
|
||||
{c.author || 'system'}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(c.created_at)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#a0aec0', lineHeight: 1.5 }}>{c.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 0, height: '100%', minHeight: 500 }}>
|
||||
{/* 左侧列表 */}
|
||||
<div style={{ width: 380, borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||
{/* 搜索栏 + 刷新 */}
|
||||
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--line)', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索工具链事件..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1, padding: '4px 8px', borderRadius: 4, fontSize: 11,
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#dde4f8',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => loadTasks(searchQuery || undefined)} style={{
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 10,
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#8899aa', cursor: 'pointer',
|
||||
}}>🔄</button>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{tasks.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 事件列表 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{tasks.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)', fontSize: 12 }}>
|
||||
{loading ? '加载中...' : '暂无工具链事件'}
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((t: any) => (
|
||||
<div key={t.id} onClick={() => setSelectedId(t.id)} style={{
|
||||
padding: '10px 14px', borderBottom: '1px solid var(--line)',
|
||||
cursor: 'pointer', transition: 'background .15s',
|
||||
background: selectedId === t.id ? 'var(--panel2)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--panel2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = selectedId === t.id ? 'var(--panel2)' : 'transparent'}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{
|
||||
fontSize: 9, padding: '1px 5px', borderRadius: 3,
|
||||
background: STATUS_COLORS[t.status] || '#2a3550',
|
||||
color: '#dde4f8',
|
||||
}}>{STATUS_LABELS[t.status] || t.status}</span>
|
||||
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(t.created_at)}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 500, color: '#dde4f8',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{t.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
<div style={{ flex: 1, padding: '16px 20px', overflowY: 'auto' }}>
|
||||
{!detail ? (
|
||||
<div style={{ textAlign: 'center', padding: 60, color: 'var(--muted)' }}>
|
||||
<div style={{ fontSize: 36, marginBottom: 12 }}>⛓️</div>
|
||||
<div style={{ fontSize: 13 }}>选择一条事件查看详情</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 头部 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, background: STATUS_COLORS[detail.status] || '#2a3550', color: '#dde4f8' }}>
|
||||
{STATUS_LABELS[detail.status] || detail.status}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{detail.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, lineHeight: 1.3 }}>{detail.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>
|
||||
{fmtTime(detail.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正文 */}
|
||||
{detail.description && (
|
||||
<div style={{
|
||||
padding: '14px 16px', background: 'var(--panel2)', borderRadius: 10,
|
||||
fontSize: 13, color: '#a0aec0', lineHeight: 1.7, whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{detail.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* action_report 评论 — expand 格式 {items, total_count} */}
|
||||
{detail.comments && detail.comments.items && detail.comments.items.length > 0 &&
|
||||
renderComments(detail.comments.items)
|
||||
}
|
||||
{/* 兼容裸 list 格式 */}
|
||||
{detail.comments && Array.isArray(detail.comments) && detail.comments.length > 0 &&
|
||||
renderComments(detail.comments)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function isArchived(t: Task): boolean {
|
||||
export type TabKey =
|
||||
| 'tasks' | 'court' | 'monitor' | 'agents'
|
||||
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
|
||||
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail';
|
||||
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail' | 'toolchain';
|
||||
|
||||
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'tasks', label: '任务看板', icon: '📜' },
|
||||
@@ -135,6 +135,7 @@ export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'archives', label: '奏折阁', icon: '📜' },
|
||||
{ key: 'morning', label: '早朝简报', icon: '🌅' },
|
||||
{ key: 'templates', label: '任务模板', icon: '📋' },
|
||||
{ key: 'toolchain', label: '工具链', icon: '⛓️' },
|
||||
{ key: 'settings', label: '系统设置', icon: '⚙️' },
|
||||
];
|
||||
|
||||
|
||||
+4
-2
@@ -7,7 +7,8 @@ from src.api.sse_routes import router as sse_router
|
||||
from src.api.project_routes import router as project_router
|
||||
from src.api.daemon_routes import router as daemon_router
|
||||
from src.api.checkpoint_routes import router as checkpoint_router
|
||||
from src.api.blackboard_routes import router as blackboard_router
|
||||
from src.api.task_routes import router as task_router
|
||||
from src.api.task_relation_routes import router as task_relation_router
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -273,7 +274,8 @@ app.add_middleware(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
app.include_router(blackboard_router)
|
||||
app.include_router(task_router)
|
||||
app.include_router(task_relation_router)
|
||||
app.include_router(checkpoint_router)
|
||||
app.include_router(daemon_router)
|
||||
app.include_router(project_router)
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# verify_api_compat.sh — 验证 API 拆分前后路由清单完全一致
|
||||
#
|
||||
# 用法:
|
||||
# bash tests/scripts/verify_api_compat.sh
|
||||
#
|
||||
# 前置:
|
||||
# - 当前在开发目录(sanguo_moziplus_v2/)
|
||||
# - git working tree 有拆分改动
|
||||
# - main 分支是拆分前的基准
|
||||
#
|
||||
# 输出:
|
||||
# ✅ 路由完全一致(exit 0)
|
||||
# ❌ 路由有差异(exit 1,打印 diff)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BEFORE_FILE="/tmp/routes_before_$$.txt"
|
||||
AFTER_FILE="/tmp/routes_after_$$.txt"
|
||||
|
||||
echo "=== 提取拆分前路由清单(main 分支)==="
|
||||
|
||||
# stash 当前改动(如果有 untracked 新文件,--include-untracked)
|
||||
STASHED=0
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
git stash --include-untracked
|
||||
STASHED=1
|
||||
fi
|
||||
|
||||
python3 -c "
|
||||
from src.main import app
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'methods') and hasattr(route, 'path'):
|
||||
for m in sorted(route.methods):
|
||||
if m in ('GET','POST','PATCH','DELETE','PUT'):
|
||||
print(f'{m} {route.path}')
|
||||
" | sort > "$BEFORE_FILE"
|
||||
|
||||
echo "Routes before: $(wc -l < "$BEFORE_FILE")"
|
||||
|
||||
# 恢复改动
|
||||
if [ "$STASHED" = "1" ]; then
|
||||
git stash pop
|
||||
fi
|
||||
|
||||
echo "=== 提取拆分后路由清单(当前 working tree)==="
|
||||
|
||||
python3 -c "
|
||||
from src.main import app
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'methods') and hasattr(route, 'path'):
|
||||
for m in sorted(route.methods):
|
||||
if m in ('GET','POST','PATCH','DELETE','PUT'):
|
||||
print(f'{m} {route.path}')
|
||||
" | sort > "$AFTER_FILE"
|
||||
|
||||
echo "Routes after: $(wc -l < "$AFTER_FILE")"
|
||||
|
||||
echo ""
|
||||
echo "=== Diff ==="
|
||||
|
||||
if diff "$BEFORE_FILE" "$AFTER_FILE"; then
|
||||
echo "✅ 路由完全一致"
|
||||
rm -f "$BEFORE_FILE" "$AFTER_FILE"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 路由有差异"
|
||||
rm -f "$BEFORE_FILE" "$AFTER_FILE"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user