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
+187
View File
@@ -0,0 +1,187 @@
"""Health API — 健康分查询、对比、手动刷新。"""
from fastapi import APIRouter, Depends, HTTPException, Query
from app.middleware.auth import require_auth, require_role
from app.models.user import User
from app.services.health_scorer import (
PLACEHOLDER_PROFILES,
calculate_all_scores,
calculate_health_score,
invalidate_cache,
)
router = APIRouter(prefix="/health", tags=["health"])
def _score_to_row(hs, rank: int) -> dict:
return {
"rank": rank,
"name": hs.profile_name,
"total_score": hs.total_score,
"grade": hs.grade,
"gateway_score": hs.gateway.score,
"config_score": hs.config.score,
"skill_score": hs.skill.score,
"cron_score": hs.cron.score,
"deduct_summary": hs.deduct_summary,
}
@router.get("/scores")
async def list_scores(
grade: str | None = Query(None, pattern="^(A|B|C|D)$"),
sort: str = Query("asc", pattern="^(asc|desc)$"),
_user: User = Depends(require_auth),
):
"""返回全部 Profile 健康分排行列表。
- ?grade=A|B|C|D 按等级筛选
- ?sort=asc|desc 排序,默认从低到高(问题优先)
"""
scores = calculate_all_scores()
# 筛选
if grade:
scores = [s for s in scores if s.grade == grade]
# 排序
scores.sort(key=lambda s: s.total_score, reverse=(sort == "desc"))
rows = [_score_to_row(hs, i + 1) for i, hs in enumerate(scores)]
return {
"total": len(rows),
"distribution": {
"A": sum(1 for s in scores if s.grade == "A"),
"B": sum(1 for s in scores if s.grade == "B"),
"C": sum(1 for s in scores if s.grade == "C"),
"D": sum(1 for s in scores if s.grade == "D"),
},
"scores": rows,
}
@router.get("/scores/{profile}")
async def get_profile_score(
profile: str,
_user: User = Depends(require_auth),
):
"""返回单个 Profile 的健康分详情,含四维度扣分项和修复建议。"""
if profile not in PLACEHOLDER_PROFILES:
raise HTTPException(status_code=404, detail=f"Profile '{profile}' 不存在")
hs = calculate_health_score(profile)
return {
"profile_name": hs.profile_name,
"total_score": hs.total_score,
"grade": hs.grade,
"calculated_at": hs.calculated_at,
"dimensions": [
{
"name": "Gateway 稳定性",
"weight": "40%",
"score": hs.gateway.score,
"max_score": hs.gateway.max_score,
"deductions": hs.gateway.deductions,
"suggestions": hs.gateway.suggestions,
},
{
"name": "配置完整性",
"weight": "25%",
"score": hs.config.score,
"max_score": hs.config.max_score,
"deductions": hs.config.deductions,
"suggestions": hs.config.suggestions,
},
{
"name": "Skill 一致性",
"weight": "20%",
"score": hs.skill.score,
"max_score": hs.skill.max_score,
"deductions": hs.skill.deductions,
"suggestions": hs.skill.suggestions,
},
{
"name": "Cron 成功率",
"weight": "15%",
"score": hs.cron.score,
"max_score": hs.cron.max_score,
"deductions": hs.cron.deductions,
"suggestions": hs.cron.suggestions,
},
],
}
@router.post("/compare")
async def compare_profiles(
body: dict,
_user: User = Depends(require_auth),
):
"""返回多个 Profile 的配置/Skill 横向对比。
body: {"profile_names": ["hebo", "ops-engineer"]}
"""
names = body.get("profile_names", [])
if len(names) < 2:
raise HTTPException(status_code=400, detail="至少选择 2 个 Profile")
if len(names) > 20:
raise HTTPException(status_code=400, detail="最多对比 20 个 Profile")
invalid = [n for n in names if n not in PLACEHOLDER_PROFILES]
if invalid:
raise HTTPException(status_code=404, detail=f"Profile 不存在: {', '.join(invalid)}")
scores = {hs.profile_name: hs for hs in calculate_all_scores(names)}
# 收集所有配置字段和 Skill
from app.services.health_scorer import _REQUIRED_CONFIG_FIELDS, _BASELINE_SKILLS
from app.services.health_scorer import _placeholder_config_data, _placeholder_skill_data
config_fields = list(_REQUIRED_CONFIG_FIELDS)
skill_fields = sorted(_BASELINE_SKILLS)
rows: list[dict] = []
for field in config_fields:
row: dict = {"field": field, "category": "config"}
for name in names:
s = scores.get(name)
if s is None:
row[name] = "⚠"
else:
data = _placeholder_config_data(name)
row[name] = "✅" if field in data["present_fields"] else "❌"
rows.append(row)
for field in skill_fields:
row = {"field": field, "category": "skill"}
for name in names:
s = scores.get(name)
if s is None:
row[name] = "⚠"
else:
data = _placeholder_skill_data(name)
row[name] = "✅" if field in data["profile_skills"] else "❌"
rows.append(row)
# 检测差异行
for row in rows:
vals = {str(row[n]) for n in names}
row["has_diff"] = len(vals) > 1
return {
"profiles": names,
"rows": rows,
}
@router.post("/calculate")
async def recalculate_scores(
_user: User = Depends(require_role("owner")),
):
"""手动触发全量评分重算(仅 owner)。"""
invalidate_cache()
scores = calculate_all_scores()
return {
"message": f"已重新计算 {len(scores)} 个 Profile 的健康分",
"count": len(scores),
}
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway
from app.api import auth, users, audit, dashboard, gateway, health
from app.middleware.audit import AuditMiddleware
@@ -36,6 +36,7 @@ app.include_router(users.router)
app.include_router(audit.router)
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1")
@app.get("/api/v1/health")
+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("健康分缓存已清除")