"""健康分计算引擎 — 评分规则 + 缓存。 评分维度: - Gateway 稳定性 40%: 7天在线率 + 日均重启惩罚 - 配置完整性 25%: .env + config.yaml 必填字段检查 - Skill 一致性 20%: 与基准 Skill 清单对比 - Cron 成功率 15%: 24h Cron 成功率 等级: A(80-100) B(60-79) C(40-59) D(0-39) 数据来源暂用占位(TODO: hermes CLI / 文件读取),评分逻辑完整实现。 """ from __future__ import annotations import logging import math import random from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) # ── 类型 ──────────────────────────────────────────────────────────────── @dataclass class DimensionScore: score: float max_score: float deductions: list[str] = field(default_factory=list) suggestions: list[str] = field(default_factory=list) @dataclass class HealthScore: profile_name: str total_score: float grade: str gateway: DimensionScore config: DimensionScore skill: DimensionScore cron: DimensionScore deduct_summary: list[str] = field(default_factory=list) calculated_at: str = "" GRADE_THRESHOLDS = [ (80, "A"), (60, "B"), (40, "C"), (0, "D"), ] def _to_grade(score: float) -> str: for threshold, grade in GRADE_THRESHOLDS: if score >= threshold: return grade return "D" def _fmt_dt(dt: datetime | None = None) -> str: return (dt or datetime.now(timezone.utc)).isoformat(timespec="seconds") # ── 占位数据源(TODO: 对接真实数据) ────────────────────────────────────── def _placeholder_gateway_data(profile: str) -> dict: """返回 Profile 的 Gateway 占位状态数据。""" # 根据 profile 名 hash 出不同状态以产生多样结果 seed = sum(ord(c) for c in profile) uptime = 0.85 + (seed % 15) / 100 # 0.85~0.99 restarts = (seed % 7) / 10 # 0.0~0.6 return { "uptime_7d": min(uptime, 0.999), "avg_daily_restarts": restarts, "online": seed % 5 != 0, } _REQUIRED_CONFIG_FIELDS = [ "BOT_TOKEN", "BOT_SECRET", "APP_ID", "APP_SECRET", "PROFILE_NAME", "LOG_LEVEL", ] def _placeholder_config_data(profile: str) -> dict: """返回 Profile 的配置完整性占位数据。""" seed = sum(ord(c) for c in profile) # 每个 Profile 缺失 0~3 个字段 missing_count = seed % 4 rng = random.Random(profile) missing = rng.sample(_REQUIRED_CONFIG_FIELDS, missing_count) if missing_count else [] return { "total_required": len(_REQUIRED_CONFIG_FIELDS), "missing_fields": missing, "present_fields": [f for f in _REQUIRED_CONFIG_FIELDS if f not in missing], } _BASELINE_SKILLS = {"gateway", "feishu", "cron", "kanban", "search", "keys"} def _placeholder_skill_data(profile: str) -> dict: """返回 Skill 一致性占位数据。""" seed = sum(ord(c) for c in profile) rng = random.Random(profile) profile_skills = set(_BASELINE_SKILLS) # 可能缺失几个 for s in rng.sample(list(_BASELINE_SKILLS), seed % 4): profile_skills.discard(s) # 可能多出几个 extras = {"notion", "slack", "jira", "github", "gitlab"} for s in rng.sample(list(extras), seed % 3): profile_skills.add(s) missing = _BASELINE_SKILLS - profile_skills surplus = profile_skills - _BASELINE_SKILLS return { "baseline": list(_BASELINE_SKILLS), "profile_skills": list(profile_skills), "missing": list(missing), "surplus": list(surplus), } def _placeholder_cron_data(profile: str) -> dict: """返回 Cron 执行状态占位数据。""" seed = sum(ord(c) for c in profile) success_rate = 0.7 + (seed % 6) / 20 # 0.7~0.95 return { "total_executions_24h": 10 + seed % 10, "success_count": int((10 + seed % 10) * success_rate), "success_rate": success_rate, "recent_failures": [f"cron-job-{i}" for i in range(seed % 4)], } # ── 评分逻辑 ────────────────────────────────────────────────────────────── def _score_gateway(profile: str) -> DimensionScore: data = _placeholder_gateway_data(profile) deductions: list[str] = [] suggestions: list[str] = [] uptime = data["uptime_7d"] restarts = data["avg_daily_restarts"] # 基础分: 7天在线率 × 40 score = uptime * 40 if uptime < 0.99: deductions.append(f"7天在线率 {uptime:.1%}(目标 ≥99%)") suggestions.append("检查 Gateway 进程稳定性,考虑设置自动重启") # 日均重启惩罚: >0.3 次/天 开始扣分 if restarts > 0.3: penalty = min(score, (restarts - 0.3) * 15) score -= penalty deductions.append(f"日均重启 {restarts:.1f} 次(阈值 ≤0.3),扣 {penalty:.1f} 分") suggestions.append("排查频繁重启原因,检查内存泄漏或日志错误") if not data["online"]: score *= 0.5 deductions.append("Gateway 当前离线,得分减半") suggestions.append("立即启动 Gateway 进程") return DimensionScore( score=max(0, round(score, 1)), max_score=40, deductions=deductions, suggestions=suggestions, ) def _score_config(profile: str) -> DimensionScore: data = _placeholder_config_data(profile) deductions: list[str] = [] suggestions: list[str] = [] missing = data["missing_fields"] total = data["total_required"] score = 25.0 # 满分 25 if missing: deduction_pct = len(missing) * 0.1 # 缺 1 个扣 10% score *= max(0, 1 - deduction_pct) deductions.append(f"缺 {len(missing)} 个必填字段: {', '.join(missing)}") suggestions.append(f"在 .env 或 config.yaml 中补充: {', '.join(missing)}") return DimensionScore( score=max(0, round(score, 1)), max_score=25, deductions=deductions, suggestions=suggestions, ) def _score_skill(profile: str) -> DimensionScore: data = _placeholder_skill_data(profile) deductions: list[str] = [] suggestions: list[str] = [] missing = data["missing"] surplus = data["surplus"] # 基准技能匹配率 → 满分 20 baseline_hit = len(data["baseline"]) - len(missing) if data["baseline"]: match_ratio = baseline_hit / len(data["baseline"]) else: match_ratio = 1.0 score = match_ratio * 20 if missing: deductions.append(f"缺失基准 Skill: {', '.join(missing)}") suggestions.append(f"安装缺失 Skill: {', '.join(missing)}") if surplus: deductions.append(f"多余未识别 Skill: {', '.join(surplus)}") suggestions.append(f"检查多余 Skill 是否必要: {', '.join(surplus)}") return DimensionScore( score=max(0, round(score, 1)), max_score=20, deductions=deductions, suggestions=suggestions, ) def _score_cron(profile: str) -> DimensionScore: data = _placeholder_cron_data(profile) deductions: list[str] = [] suggestions: list[str] = [] success_rate = data["success_rate"] score = success_rate * 15 # 成功率 × 15 if success_rate < 0.95: deductions.append(f"24h Cron 成功率 {success_rate:.1%}(目标 ≥95%)") suggestions.append("检查失败 Cron 任务的日志,确认是否因超时或配置错误") if data["recent_failures"]: deductions.append(f"失败任务: {', '.join(data['recent_failures'])}") suggestions.append("逐个排查失败任务,优先修复高频失败项") return DimensionScore( score=max(0, round(score, 1)), max_score=15, deductions=deductions, suggestions=suggestions, ) # ── 对外接口 ────────────────────────────────────────────────────────────── SCORE_CACHE: dict[str, HealthScore] = {} _LAST_CALCULATED: datetime | None = None _CACHE_TTL = timedelta(hours=24) # 占位 Profile 列表(TODO: 从 hermes 配置目录读取) PLACEHOLDER_PROFILES = [ "hebo", "ops-engineer", "monitor", "visualizer", "gateway-monitor", "feishu-gw", "taiwan-briefing", "daily-report", "weekly-digest", "hindsight", "guangxi-donation", "europe-briefing", ] def calculate_health_score(profile_name: str) -> HealthScore: """计算单个 Profile 的健康分。""" gw = _score_gateway(profile_name) cf = _score_config(profile_name) sk = _score_skill(profile_name) cr = _score_cron(profile_name) total = round(gw.score + cf.score + sk.score + cr.score, 1) grade = _to_grade(total) deduct_summary = [] for dim, label in [(gw, "Gateway"), (cf, "配置"), (sk, "Skill"), (cr, "Cron")]: for d in dim.deductions: deduct_summary.append(f"[{label}] {d}") return HealthScore( profile_name=profile_name, total_score=total, grade=grade, gateway=gw, config=cf, skill=sk, cron=cr, deduct_summary=deduct_summary[:5], # 最多显示 5 条 calculated_at=_fmt_dt(), ) def _is_cache_stale() -> bool: if _LAST_CALCULATED is None: return True return datetime.now(timezone.utc) - _LAST_CALCULATED > _CACHE_TTL def calculate_all_scores(profiles: list[str] | None = None) -> list[HealthScore]: """计算全部 Profile 的健康分,使用缓存(TTL 24h)。""" global _LAST_CALCULATED if not _is_cache_stale() and SCORE_CACHE: logger.info("健康分缓存命中(%d 条)", len(SCORE_CACHE)) scores = list(SCORE_CACHE.values()) if profiles: return [s for s in scores if s.profile_name in profiles] return scores target_profiles = profiles or PLACEHOLDER_PROFILES scores: list[HealthScore] = [] for name in target_profiles: try: hs = calculate_health_score(name) SCORE_CACHE[name] = hs scores.append(hs) except Exception as e: logger.error("计算 %s 健康分异常: %s", name, e) _LAST_CALCULATED = datetime.now(timezone.utc) logger.info("健康分已刷新(%d 条)", len(scores)) return scores def invalidate_cache() -> None: """强制清除缓存,下次调用重新计算。""" global _LAST_CALCULATED SCORE_CACHE.clear() _LAST_CALCULATED = None logger.info("健康分缓存已清除")