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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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("健康分缓存已清除")
|
||||
Reference in New Issue
Block a user