#!/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))