feat: Profile health score system (scoring engine, API, frontend)

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>
This commit is contained in:
enzo
2026-07-22 17:14:30 +08:00
co-authored by Claude
parent 6d4a5c955b
commit 8bfefd199c
5 changed files with 893 additions and 3 deletions
+341
View File
@@ -0,0 +1,341 @@
"""健康分计算引擎 — 评分规则 + 缓存。
评分维度:
- 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("健康分缓存已清除")