Health scoring engine with 4 dimensions: Gateway stability (40%), config
completeness (25%), skill consistency (20%), and Cron success rate (15%).
Grades A(80-100) B(60-79) C(40-59) D(0-39) with detailed deduction items
and fix suggestions per dimension.
API endpoints:
- GET /health/scores — list all profiles with grade filter + sort
- GET /health/scores/{profile} — detail with deductions + suggestions
- POST /health/compare — multi-profile config/skill comparison
- POST /health/calculate — manual recalculation (owner only)
Frontend HealthPage: grade distribution cards, score table with expandable
detail rows, batch compare view with diff highlighting, grade/select/sort
filters, and owner-only recalculation.
Data sources use placeholders (TODO: hermes CLI / file reads) but the full
scoring logic is implemented end-to-end.
Co-Authored-By: Claude <noreply@anthropic.com>
342 lines
11 KiB
Python
342 lines
11 KiB
Python
"""健康分计算引擎 — 评分规则 + 缓存。
|
||
|
||
评分维度:
|
||
- 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("健康分缓存已清除")
|