Files
MoFin/deploy/profile-scripts/functional_health_check.py
T

216 lines
9.8 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.
#!/usr/bin/env python3
"""functional_health_check.py — L1 功能健康检查
判据不是"进程活着",而是"功能是否达成":每个核心模块检查其
**输出物的新鲜度和有效性**。输出物不新鲜 = 功能未达成 = 异常。
频率:交易时段每 15 分钟(cron),输出 gateway/logs/functional_health.json。
责任矩阵 L1。修复由 L3 self_repair 读取本报告执行。
"""
import os, sys, json, sqlite3, subprocess
from datetime import datetime, timedelta
sys.path.insert(0, '/home/hmo/MoFin')
DB = '/home/hmo/MoFin/data/mofin.db'
OUT = '/home/hmo/MoFin/gateway/logs/functional_health.json'
# ── 功能注册表:模块 → 功能判据 ──────────────────────────────
# type:
# db_freshness: DB 表 MAX(col) 距今不超过 max_agetrading=交易时段才检查, daily=每日, always=总是)
# file_freshness: 文件 mtime 距今不超过 max_age
# bot_activity: XMPP bot 活动检查(journal
# agent_log: gateway agent.log 最近有成功 LLM 调用
# soft=True: 无输出可能属正常(如区间未触发),降级为 warn 而非 fail
REGISTRY = [
{"module": "price_monitor", "function": "实时价格写入DB(live_prices)",
"check": {"type": "db_freshness", "table": "live_prices", "col": "updated_at",
"max_age_min": 6, "when": "trading"},
"repair": {"action": "rerun_script", "script": "price_monitor.py"}},
{"module": "market_watch", "function": "市场快照采集(market_snapshots)",
"check": {"type": "db_freshness", "table": "market_snapshots", "col": "created_at",
"max_age_min": 40, "when": "trading"},
"repair": {"action": "rerun_script", "script": "market_watch.py"}},
{"module": "mtf_cache", "function": "多周期均线缓存刷新(mtf_cache)",
"check": {"type": "db_freshness", "table": "mtf_cache", "col": "updated_at",
"max_age_min": 75, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_mtf_cache.py"}},
{"module": "macro_context", "function": "宏观上下文刷新(macro_context_log)",
"check": {"type": "db_freshness", "table": "macro_context_log", "col": "created_at",
"max_age_min": 45, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_macro_context.py"}},
{"module": "health_collector", "function": "健康数据采集(mofin_health.json)",
"check": {"type": "file_freshness", "path": "/home/hmo/web-dashboard/static/mofin_health.json",
"max_age_min": 20, "when": "trading"},
"repair": {"action": "rerun_script", "script": "mofin_health.py"}},
{"module": "premarket", "function": "盘前全量重评(premarket summary)",
"check": {"type": "file_freshness", "path": "/tmp/mofin_premarket/summary.json",
"max_age_h": 26, "when": "daily"},
"repair": {"action": "rerun_script", "script": "premarket_full_review.py"}},
{"module": "gateway_llm", "function": "LLM调用链可用(gateway agent.log)",
"check": {"type": "agent_log", "max_age_min": 30, "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "xmpp_bot", "function": "XMPP消息收发(bot journal)",
"check": {"type": "bot_activity", "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "cron_engine", "function": "cron调度引擎本身(有job在最近10min运行)",
"check": {"type": "cron_engine", "max_age_min": 12, "when": "trading"},
"repair": {"action": "llm_diagnose"}},
]
def is_trading_now(now):
return now.weekday() < 5 and 9 <= now.hour <= 16
def check_db_freshness(conn, chk, now):
try:
row = conn.execute(f"SELECT MAX({chk['col']}) FROM {chk['table']}").fetchone()
if not row or not row[0]:
return "fail", f"表 {chk['table']} 无数据"
last = datetime.fromisoformat(str(row[0]).replace("Z", ""))
age_min = (now - last).total_seconds() / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{chk['table']} 最新记录 {last.strftime('%m-%d %H:%M')}{age_min/60:.1f}h前,阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
except Exception as e:
return "fail", f"查询失败: {str(e)[:80]}"
def check_file_freshness(chk, now):
p = chk["path"]
if not os.path.exists(p):
return "fail", f"文件不存在: {p}"
age_min = (now.timestamp() - os.path.getmtime(p)) / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{os.path.basename(p)} {age_min/60:.1f}h 未更新(阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
def check_agent_log(chk, now):
try:
sys.path.insert(0, '/home/hmo/MoFin')
from xmpp_logger import _scan_agent_log
r = _scan_agent_log(now.timestamp(), "zhiwei")
if r["status"] == "ok":
age = r.get("age_sec", -1)
if age > chk["max_age_min"] * 60:
return "warn", f"最近成功LLM调用在 {age//60}min 前(无新流量,可能正常)"
return "ok", f"latency={r.get('latency')}"
return "fail", f"agent.log 最近调用失败: {r.get('error','?')[:100]}"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_bot_activity(chk, now):
try:
# 第一判据:服务当前是否 active(权威"现在在不在跑"
r2 = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei"],
capture_output=True, text=True, timeout=5)
if r2.stdout.strip() != "active":
return "fail", "bot 服务非 active(已停止)"
# 第二判据:最近 journal 是否处于断线循环
r = subprocess.run(
["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "30 min ago", "-o", "cat"],
capture_output=True, timeout=8, text=True)
lines = r.stdout
if "连接超时" in lines and "就绪" not in lines:
return "fail", "bot 处于断线重连循环"
if "XMPP 就绪" in lines or "已发送" in lines or "收到" in lines:
return "ok", "bot 活动正常"
return "ok", "bot 在线空闲(无新消息)"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_cron_engine(chk, now):
"""cron 引擎:最近 max_age_min 内是否有任何 job 运行过"""
try:
d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'))
jobs = d if isinstance(d, list) else d.get('jobs', [])
latest = None
for j in jobs:
lr = j.get('last_run_at')
if lr:
try:
t = datetime.fromisoformat(lr.replace('Z', '+00:00')).replace(tzinfo=None)
if latest is None or t > latest:
latest = t
except Exception:
pass
if not latest:
return "fail", "所有 job 均无运行记录"
age_min = (now - latest).total_seconds() / 60
if age_min > chk["max_age_min"]:
return "fail", f"最近 job 运行在 {age_min:.0f}min 前,调度引擎疑似停摆"
return "ok", f"最近 job 运行于 {age_min:.0f}min 前"
except Exception as e:
return "fail", f"检查失败: {e}"
def main():
now = datetime.now()
trading = is_trading_now(now)
results = []
conn = sqlite3.connect(DB, timeout=10)
for item in REGISTRY:
chk = item["check"]
when = chk.get("when", "always")
if when == "trading" and not trading:
results.append({"module": item["module"], "function": item["function"],
"status": "skip", "reason": "非交易时段"})
continue
if when == "daily":
# 每日类:只 fail 不 warn,且非交易日放宽到 72h
if now.weekday() >= 5:
chk = dict(chk)
chk["max_age_h"] = 72
t = chk["type"]
if t == "db_freshness":
status, reason = check_db_freshness(conn, chk, now)
elif t == "file_freshness":
status, reason = check_file_freshness(chk, now)
elif t == "agent_log":
status, reason = check_agent_log(chk, now)
elif t == "bot_activity":
status, reason = check_bot_activity(chk, now)
elif t == "cron_engine":
status, reason = check_cron_engine(chk, now)
else:
status, reason = "fail", f"未知检查类型 {t}"
results.append({"module": item["module"], "function": item["function"],
"status": status, "reason": reason,
"repair": item.get("repair")})
conn.close()
fails = [r for r in results if r["status"] == "fail"]
warns = [r for r in results if r["status"] == "warn"]
report = {
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"trading_hours": trading,
"summary": {"total": len(results), "ok": sum(1 for r in results if r["status"] == "ok"),
"warn": len(warns), "fail": len(fails),
"skip": sum(1 for r in results if r["status"] == "skip")},
"status": "fail" if fails else ("warn" if warns else "ok"),
"checks": results,
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"功能健康: {report['summary']} status={report['status']}")
for r in results:
if r["status"] in ("fail", "warn"):
print(f" {r['status'].upper()} {r['module']}: {r['reason']}")
if __name__ == "__main__":
main()