Files
hive-manage-v3/backend/app/services/health_scorer.py
T
enzoandClaude 8bfefd199c 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>
2026-07-22 17:14:30 +08:00

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""健康分计算引擎 — 评分规则 + 缓存。
评分维度:
- 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("健康分缓存已清除")