feat(dev-workflow): 添加 Cross 模式统计报告功能

- 新增 stats.py 模块:record_event, get_stats, format_stats_report
- dev_workflow.py 添加 stats 和 record-event 命令
- 支持事件记录、统计报告、数据清理
- 安全特性:敏感数据过滤、文件锁、权限控制
- Cross-Debate 和 Cross-Review 记录文件
This commit is contained in:
2026-03-09 15:26:53 +08:00
parent 630dc09062
commit cee3598d37
5 changed files with 609 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Cross-Debate 结果
**创建时间:** 2026-03-09 14:33
**需求:** <!-- 填写需求 -->
---
## Agent 方案
### Claude Code(保守派)
**方案:**
<!-- 填写 -->
**理由:**
<!-- 填写 -->
---
### Codex(全面派)
**方案:**
<!-- 填写 -->
**理由:**
<!-- 填写 -->
---
### Gemini(快速派)
**方案:**
<!-- 填写 -->
**理由:**
<!-- 填写 -->
---
## 共识点
- ✅ <!-- 共识1 -->
- ✅ <!-- 共识2 -->
## 分歧点
| Agent | 方案 | 理由 |
|-------|------|------|
| <!-- Agent --> | <!-- 方案 --> | <!-- 理由 --> |
## 用户决策
**选择方案:** <!-- A/B/C 或调整 -->
---
+155
View File
@@ -0,0 +1,155 @@
# Spec: Cross 模式统计报告
**创建时间:** 2026-03-09 14:38
**状态:** approved
**分支:** feature/cross-stats
**来源:** Cross-Debate
---
## 概述
给 dev-workflow 添加统计报告功能,显示 Cross 模式(Cross-Debate + Cross-Review)的使用情况。
---
## 背景
Cross 模式是 dev-workflow 的核心功能,需要了解其使用情况:
- 调用频率
- 成功率
- 常见场景
- 问题分布
---
## Cross-Debate 统一方案
### 共识
三个 Agent(保守派、全面派、快速派)经过交叉审查后达成共识:
> **"快速不等于简陋,结构化 + 缓存 + 权限是底线"**
### 渐进式架构
#### 短期(MVP)
```
日志埋点 → 结构化 JSON 存储 → Redis 缓存(TTL 5分钟)
```
#### 中期(加固)
```
Grafana 可视化 + 权限隔离(API 层最小校验)
```
#### 长期(完善)
```
数据脱敏 + 告警通知 + 定期归档
```
---
## 需求
### 功能需求
- [ ] **REQ-01**: 记录 Cross-Debate 调用事件(时间、场景、Agent 参与情况)
- [ ] **REQ-02**: 记录 Cross-Review 调用事件(时间、轮次、问题数量)
- [ ] **REQ-03**: 统计报告命令:`python3 dev_workflow.py stats`
- [ ] **REQ-04**: 输出格式:JSON + Markdown
- [ ] **REQ-05**: 统计维度:日/周/月、按场景、按成功率
### 非功能需求
- 性能:查询响应 < 100ms(通过 Redis 缓存)
- 安全:API 层最小权限校验
- 兼容:不破坏现有 dev_workflow.py 接口
---
## 技术设计
### 数据结构
```json
{
"cross_events": [
{
"timestamp": "2026-03-09T14:38:00Z",
"type": "debate" | "review",
"scenario": "功能设计" | "Bug修复" | "重构",
"agents": ["claude-code", "codex", "gemini"],
"duration_ms": 45000,
"status": "success" | "failed",
"issues_found": 3,
"rounds": 2
}
]
}
```
### 文件结构
```
dev-workflow/
├── dev_workflow.py # 添加 stats 命令
├── stats.py # 新增:统计逻辑
└── .data/
└── cross_events.json # 事件存储
```
### 实现步骤
1. 添加 `record_event()` 函数记录事件
2. 在 Cross-Debate 和 Cross-Review 流程中埋点
3. 实现 `stats` 命令输出报告
4. 添加 Redis 缓存(可选)
### 依赖
- 无新增外部依赖
- 可选:Redis(用于缓存)
---
## 测试计划
### 单元测试
- [ ] `record_event()` 正常记录
- [ ] `record_event()` 异常处理
- [ ] `get_stats()` 空数据返回
- [ ] `get_stats()` 正常统计
### 集成测试
- [ ] 完整 Cross-Debate 流程后查看统计
- [ ] 完整 Cross-Review 流程后查看统计
---
## 验收标准
1. 执行 `python3 dev_workflow.py stats` 输出统计报告
2. 报告包含:总调用次数、成功率、Top 5 场景
3. 数据持久化到 JSON 文件
4. 不影响现有功能
---
## 风险与缓解
| 风险 | 缓解措施 |
|------|----------|
| 并发写入冲突 | 使用文件锁或队列 |
| 存储增长 | 定期归档旧数据 |
| 敏感信息泄露 | 不记录用户输入内容 |
---
## 变更日志
| 日期 | 变更 |
|------|------|
| 2026-03-09 14:38 | 初始创建(来自 Cross-Debate 共识) |
+14
View File
@@ -0,0 +1,14 @@
# 数据文件
data/
*.json
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
# 临时文件
*.tmp
*.bak
*.lock
+26
View File
@@ -428,6 +428,16 @@ def main():
# status
subparsers.add_parser('status', help='查看状态')
# stats
subparsers.add_parser('stats', help='查看 Cross 模式统计报告')
# record-event
event_parser = subparsers.add_parser('record-event', help='记录 Cross 模式事件')
event_parser.add_argument('--type', required=True, choices=['debate', 'review', 'develop', 'fix', 'other'], help='事件类型')
event_parser.add_argument('--mode', default='cross', choices=['cross', 'normal'], help='模式')
event_parser.add_argument('--metadata', default='{}', help='元数据(JSON)')
event_parser.add_argument('--status', default='success', help='状态')
args = parser.parse_args()
if args.command == 'create-spec':
@@ -442,6 +452,22 @@ def main():
result = record_review(args.round, args.issues)
elif args.command == 'status':
result = get_status()
elif args.command == 'stats':
# 导入 stats 模块
sys.path.insert(0, str(Path(__file__).parent))
from stats import get_stats, format_stats_report
stats = get_stats()
print(format_stats_report(stats))
result = stats
elif args.command == 'record-event':
# 导入 stats 模块
sys.path.insert(0, str(Path(__file__).parent))
from stats import record_event
try:
metadata = json.loads(args.metadata)
except:
metadata = {}
result = {"success": record_event(args.type, args.mode, metadata, args.status)}
else:
parser.print_help()
return
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
stats.py - Cross 模式统计报告模块
用法:
from stats import record_event, get_stats, format_stats_report
# 记录事件
record_event("debate", "cross", {"agents": 3})
# 获取统计
stats = get_stats()
print(format_stats_report(stats))
"""
import json
import os
import fcntl
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# 数据存储路径
DATA_DIR = Path(__file__).parent / "data"
STATS_FILE = DATA_DIR / "cross_stats.json"
LOCK_FILE = DATA_DIR / "cross_stats.lock"
MAX_EVENTS = 10000 # 最大事件数
# 白名单
ALLOWED_EVENT_TYPES = {"debate", "review", "develop", "fix", "other"}
ALLOWED_MODES = {"cross", "normal"}
# 敏感字段
SENSITIVE_KEYS = {"password", "token", "secret", "key", "credential", "api_key"}
def _ensure_data_dir():
"""确保数据目录存在"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
def _filter_sensitive_data(data: dict) -> dict:
"""过滤敏感数据"""
if not isinstance(data, dict):
return {}
filtered = {}
for key, value in data.items():
key_lower = key.lower()
# 检查是否是敏感字段
if any(s in key_lower for s in SENSITIVE_KEYS):
filtered[key] = "[REDACTED]"
elif isinstance(value, dict):
filtered[key] = _filter_sensitive_data(value)
else:
filtered[key] = value
return filtered
def _load_stats_data() -> dict:
"""加载统计数据(带文件锁)"""
_ensure_data_dir()
if not STATS_FILE.exists():
return {"events": [], "created_at": datetime.now().isoformat()}
try:
with open(STATS_FILE, "r", encoding="utf-8") as f:
# 获取共享锁(读锁)
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
try:
data = json.load(f)
if not isinstance(data, dict) or "events" not in data:
logger.warning("统计数据格式无效,返回空数据")
return {"events": [], "created_at": datetime.now().isoformat()}
return data
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except json.JSONDecodeError as e:
# 备份损坏文件
backup_file = STATS_FILE.with_suffix(f".json.bak.{datetime.now().strftime('%Y%m%d%H%M%S')}")
STATS_FILE.rename(backup_file)
logger.error(f"统计数据损坏,已备份到 {backup_file},错误: {e}")
return {"events": [], "created_at": datetime.now().isoformat()}
except Exception as e:
logger.error(f"加载统计数据失败: {e}")
return {"events": [], "created_at": datetime.now().isoformat()}
def _save_stats_data(data: dict):
"""保存统计数据(带文件锁和原子写入)"""
_ensure_data_dir()
# 原子写入:先写临时文件,再替换
temp_file = STATS_FILE.with_suffix(".tmp")
try:
with open(temp_file, "w", encoding="utf-8") as f:
# 获取排他锁(写锁)
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
os.fsync(f.fileno())
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
# 设置文件权限
os.chmod(temp_file, 0o600)
# 原子替换
temp_file.replace(STATS_FILE)
os.chmod(STATS_FILE, 0o600)
except Exception as e:
logger.error(f"保存统计数据失败: {e}")
if temp_file.exists():
temp_file.unlink()
raise
def record_event(
event_type: str,
mode: str,
metadata: Optional[dict] = None,
status: str = "success"
) -> bool:
"""
记录 Cross 模式事件
Args:
event_type: 事件类型 (debate, review, develop, fix, other)
mode: 模式 (cross, normal)
metadata: 事件元数据
status: 状态 (success, failed)
Returns:
是否记录成功
"""
# 参数校验
if event_type not in ALLOWED_EVENT_TYPES:
logger.warning(f"无效的 event_type: {event_type},使用 'other'")
event_type = "other"
if mode not in ALLOWED_MODES:
logger.warning(f"无效的 mode: {mode},使用 'normal'")
mode = "normal"
# metadata 类型校验
if metadata is not None and not isinstance(metadata, dict):
logger.warning(f"metadata 必须是 dict 类型,收到: {type(metadata)}")
metadata = {}
# 过滤敏感数据
if metadata:
metadata = _filter_sensitive_data(metadata)
# 创建事件
event = {
"timestamp": datetime.now().isoformat(),
"event_type": event_type,
"mode": mode,
"status": status,
"metadata": metadata or {}
}
try:
# 加载现有数据
data = _load_stats_data()
# 添加事件
data["events"].append(event)
data["last_updated"] = datetime.now().isoformat()
# 限制事件数量
if len(data["events"]) > MAX_EVENTS:
data["events"] = data["events"][-MAX_EVENTS:]
logger.info(f"事件数量超过 {MAX_EVENTS},已裁剪")
# 保存
_save_stats_data(data)
logger.info(f"事件已记录: {event_type}/{mode}/{status}")
return True
except Exception as e:
logger.error(f"记录事件失败: {e}")
return False
def get_stats() -> dict:
"""
获取统计报告
Returns:
统计数据字典
"""
data = _load_stats_data()
events = data.get("events", [])
if not events:
return {
"total_events": 0,
"message": "暂无统计数据"
}
# 统计
stats = {
"total_events": len(events),
"first_event": events[0].get("timestamp"),
"last_event": events[-1].get("timestamp"),
"by_event_type": {},
"by_mode": {},
"by_status": {},
"by_date": {}
}
for event in events:
# 按事件类型
event_type = event.get("event_type", "unknown")
stats["by_event_type"][event_type] = stats["by_event_type"].get(event_type, 0) + 1
# 按模式
mode = event.get("mode", "unknown")
stats["by_mode"][mode] = stats["by_mode"].get(mode, 0) + 1
# 按状态
status = event.get("status", "unknown")
stats["by_status"][status] = stats["by_status"].get(status, 0) + 1
# 按日期
try:
date = event.get("timestamp", "")[:10]
if date:
stats["by_date"][date] = stats["by_date"].get(date, 0) + 1
except:
pass
# 计算成功率
total = stats["total_events"]
success = stats["by_status"].get("success", 0)
stats["success_rate"] = f"{success / total * 100:.1f}%" if total > 0 else "0%"
return stats
def format_stats_report(stats: dict) -> str:
"""
格式化统计报告
Args:
stats: 统计数据
Returns:
格式化的报告字符串
"""
lines = [
"=" * 60,
"Cross 模式统计报告",
"=" * 60,
""
]
if stats.get("total_events") == 0:
lines.append("暂无统计数据")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
lines.append(f"总事件数: {stats['total_events']}")
lines.append(f"成功率: {stats.get('success_rate', 'N/A')}")
lines.append("")
lines.append(f"首次事件: {stats.get('first_event', 'N/A')}")
lines.append(f"最后事件: {stats.get('last_event', 'N/A')}")
lines.append("")
# 按事件类型
if stats.get("by_event_type"):
lines.append("按事件类型统计:")
for event_type, count in sorted(stats["by_event_type"].items(), key=lambda x: -x[1]):
lines.append(f" - {event_type}: {count}")
lines.append("")
# 按模式
if stats.get("by_mode"):
lines.append("按模式统计:")
for mode, count in sorted(stats["by_mode"].items(), key=lambda x: -x[1]):
lines.append(f" - {mode}: {count}")
lines.append("")
# 按日期(最近7天)
if stats.get("by_date"):
lines.append("按日期统计(最近7天):")
sorted_dates = sorted(stats["by_date"].items(), key=lambda x: x[0], reverse=True)[:7]
for date, count in sorted_dates:
lines.append(f" - {date}: {count}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def clear_stats(confirm: bool = False) -> bool:
"""
清空统计数据
Args:
confirm: 必须为 True 才能清空
Returns:
是否清空成功
"""
# 身份验证:检查 ADMIN_TOKEN
admin_token = os.environ.get("ADMIN_TOKEN", "")
if not admin_token:
logger.error("清空统计数据需要设置 ADMIN_TOKEN 环境变量")
return False
if not confirm:
logger.warning("清空统计数据需要 confirm=True")
return False
try:
_ensure_data_dir()
# 备份现有数据
if STATS_FILE.exists():
backup_file = STATS_FILE.with_suffix(f".json.cleared.{datetime.now().strftime('%Y%m%d%H%M%S')}")
STATS_FILE.rename(backup_file)
logger.info(f"统计数据已备份到 {backup_file}")
# 创建空数据
_save_stats_data({"events": [], "created_at": datetime.now().isoformat()})
logger.info("统计数据已清空")
return True
except Exception as e:
logger.error(f"清空统计数据失败: {e}")
return False
if __name__ == "__main__":
# 测试
print("测试 stats 模块...")
# 记录几个测试事件
record_event("debate", "cross", {"agents": 3, "result": "consensus"})
record_event("review", "cross", {"issues_found": 5})
record_event("develop", "cross", {"files_changed": 3})
# 获取统计
stats = get_stats()
print(format_stats_report(stats))