User directive: daily not weekly; clear responsibilities per layer with no
overlap; functional criteria (does the function WORK) not process liveness;
problems get FIXED via LLM with file-and-report discipline (act first,
report after); plus a meta-layer watching the watchers; deeply integrated
into F健康.
Architecture (responsibility matrix in dev-spec.md):
- L0 agents_health_check (5min): port/HTTP/DB liveness + auto_heal executor
- L1 functional_health_check (15min trading): per-module FUNCTIONAL
criteria — output freshness/validity per REGISTRY (live_prices/market_
snapshots/mtf_cache/macro_context/bot/LLM/cron engine), not process alive
- L2 system_hygiene_audit (daily 08:20, was weekly): divergence/hardlink/
zombie/orphan/dead-cron/db-freshness
- L3 self_repair (30min): reads L1/L2 failures -> LLM diagnoses -> executes
WHITELISTED repair actions directly (rerun_script/restart_service/
sync_links/switch_llm_key/none) -> repair_log.jsonl + XMPP report.
Max 2 repairs/module/day anti-loop. LLM unavailable -> rule fallback.
- L4 meta_watchdog (hourly): checks L0-L3 output freshness + L3 cron
registration + XMPP bridge; direct XMPP alert as last resort
Retired (overlap): Cron监护-高频 (cron_watchdog -> L3), 全局cron健康监控
(cron_health_monitor -> L1).
Dashboard: mofin_health.py now emits self_check section (functional/meta/
hygiene/recent_repairs); mofin_health.html new '🩺 自检体系' tab rendering
L4 layers, L1 module checks, L2 issues, L3 repair history.
E2E verified: stopped xmpp bot -> L1 flagged fail -> systemd recovered ->
L3 LLM correctly diagnosed 'none needed' and logged; rerun_script whitelist
path executes real scripts successfully; meta_watchdog all-green after fix.
255 lines
10 KiB
Python
255 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""self_repair.py — L3 LLM 修复循环(报备制)
|
||
|
||
读取 L1 functional_health.json + L2 hygiene_report.json 的失败项,
|
||
调用 LLM 诊断并**直接执行修复**(先斩后奏),然后记录日志并 XMPP 报备。
|
||
|
||
安全设计:
|
||
- LLM 只能从白名单动作中选择(不许任意执行代码)
|
||
- 每个模块每天最多自动修复 2 次(防修复循环)
|
||
- LLM 不可用时降级为规则默认动作(rerun_script)
|
||
- 所有动作记录 repair_log.jsonl
|
||
|
||
频率:每 30 分钟(cron)。
|
||
"""
|
||
import os, sys, json, subprocess, sqlite3, time
|
||
from datetime import datetime
|
||
|
||
sys.path.insert(0, '/home/hmo/MoFin')
|
||
|
||
FUNCTIONAL_REPORT = '/home/hmo/MoFin/gateway/logs/functional_health.json'
|
||
HYGIENE_REPORT = '/home/hmo/MoFin/gateway/logs/hygiene_report.json'
|
||
REPAIR_LOG = '/home/hmo/MoFin/gateway/logs/repair_log.jsonl'
|
||
REPAIR_STATE = '/home/hmo/MoFin/gateway/logs/repair_state.json'
|
||
GATEWAY = 'http://127.0.0.1:8643/v1/chat/completions'
|
||
SCRIPTS_DIR = '/home/hmo/.hermes/profiles/position-analyst/scripts'
|
||
MAX_REPAIRS_PER_MODULE_PER_DAY = 2
|
||
|
||
WHITELIST_ACTIONS = """
|
||
可执行的白名单动作(只能选其一,不许自创):
|
||
1. {"action": "rerun_script", "script": "<脚本名>"} — 立即重跑指定 cron 脚本(限注册表内的脚本)
|
||
2. {"action": "restart_service", "service": "<服务名>"} — systemctl 重启(限: hermes-gateway-zhiwei, xmpp-zhiwei, mofin-dashboard)
|
||
3. {"action": "sync_links"} — 重建脚本硬链接(修复 cron 跑旧代码)
|
||
4. {"action": "switch_llm_key"} — 切换 LLM key(429 限流时)
|
||
5. {"action": "none", "reason": "<原因>"} — 判断为无需动作(如非交易时段的正常空闲)
|
||
"""
|
||
|
||
|
||
def log_repair(entry):
|
||
os.makedirs(os.path.dirname(REPAIR_LOG), exist_ok=True)
|
||
with open(REPAIR_LOG, 'a', encoding='utf-8') as f:
|
||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||
|
||
|
||
def load_state():
|
||
try:
|
||
return json.load(open(REPAIR_STATE))
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def save_state(s):
|
||
with open(REPAIR_STATE, 'w') as f:
|
||
json.dump(s, f)
|
||
|
||
|
||
def repairs_today(state, module):
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
return state.get(today, {}).get(module, 0)
|
||
|
||
|
||
def bump_repair(state, module):
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
state.setdefault(today, {})
|
||
state[today][module] = state[today].get(module, 0) + 1
|
||
# 只保留最近 3 天
|
||
for k in list(state.keys()):
|
||
if k < (datetime.now().strftime('%Y-%m-%d'))[:8] + '17':
|
||
del state[k]
|
||
save_state(state)
|
||
|
||
|
||
def collect_failures():
|
||
failures = []
|
||
try:
|
||
fh = json.load(open(FUNCTIONAL_REPORT))
|
||
for c in fh.get('checks', []):
|
||
if c.get('status') == 'fail':
|
||
failures.append({
|
||
'source': 'functional', 'module': c['module'],
|
||
'function': c['function'], 'reason': c['reason'],
|
||
'repair_hint': c.get('repair'),
|
||
})
|
||
except Exception:
|
||
pass
|
||
try:
|
||
hy = json.load(open(HYGIENE_REPORT))
|
||
for i in hy.get('issues', []):
|
||
failures.append({
|
||
'source': 'hygiene', 'module': i.get('type'),
|
||
'function': i.get('file') or i.get('job') or i.get('table', '?'),
|
||
'reason': json.dumps(i, ensure_ascii=False)[:200],
|
||
'repair_hint': {'action': 'llm_diagnose'},
|
||
})
|
||
except Exception:
|
||
pass
|
||
return failures
|
||
|
||
|
||
def run_action(action, module):
|
||
"""执行白名单动作,返回 (ok, detail)"""
|
||
act = action.get('action')
|
||
try:
|
||
if act == 'rerun_script':
|
||
script = action.get('script', '')
|
||
# 只允许注册表内脚本
|
||
if not script.endswith('.py') or '/' in script or '..' in script:
|
||
return False, f'非法脚本名: {script}'
|
||
path = os.path.join(SCRIPTS_DIR, script)
|
||
if not os.path.exists(path):
|
||
return False, f'脚本不存在: {script}'
|
||
r = subprocess.run(['python3', path], capture_output=True, text=True,
|
||
timeout=600, cwd=SCRIPTS_DIR)
|
||
tail = (r.stdout or r.stderr)[-150:]
|
||
return r.returncode == 0, f'exit={r.returncode} {tail}'
|
||
|
||
elif act == 'restart_service':
|
||
svc = action.get('service', '')
|
||
allowed = {'hermes-gateway-zhiwei': ['sudo', '-n', 'systemctl', 'restart', svc],
|
||
'xmpp-zhiwei': ['sudo', '-n', 'systemctl', 'restart', svc],
|
||
'mofin-dashboard': ['sudo', '-n', 'systemctl', 'restart', svc]}
|
||
if svc not in allowed:
|
||
return False, f'服务不在白名单: {svc}'
|
||
subprocess.Popen(allowed[svc], stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||
return True, f'{svc} 重启已触发(异步)'
|
||
|
||
elif act == 'sync_links':
|
||
r = subprocess.run(['bash', '/home/hmo/MoFin/deploy/profile-scripts/sync_profile_scripts.sh'],
|
||
capture_output=True, text=True, timeout=60)
|
||
return True, r.stdout.strip()
|
||
|
||
elif act == 'switch_llm_key':
|
||
sys.path.insert(0, '/home/hmo/MoFin')
|
||
from xmpp_logger import best_key, switch_key, KEY_TO_PROVIDER, current_provider
|
||
bk = best_key()
|
||
if not bk:
|
||
return False, '无可用 key'
|
||
target = KEY_TO_PROVIDER.get(bk['key_id'])
|
||
if not target or target == current_provider():
|
||
return False, f'已在最优 key ({bk["key_id"]})'
|
||
r = switch_key(bk['key_id'])
|
||
return r.get('switched', False), json.dumps(r, ensure_ascii=False)[:150]
|
||
|
||
elif act == 'none':
|
||
return True, f'无需动作: {action.get("reason", "")}'
|
||
|
||
return False, f'未知动作: {act}'
|
||
except subprocess.TimeoutExpired:
|
||
return False, '动作超时(600s)'
|
||
except Exception as e:
|
||
return False, f'动作异常: {str(e)[:120]}'
|
||
|
||
|
||
def llm_diagnose(failure):
|
||
"""调 LLM 诊断并返回白名单动作。LLM 不可用 → 返回 None(走规则默认)"""
|
||
hint = failure.get('repair_hint') or {}
|
||
default_action = hint if hint.get('action') and hint['action'] != 'llm_diagnose' else None
|
||
|
||
prompt = f"""你是 MoFin 系统的自动修复工程师。一个健康检查发现了功能异常,请诊断并给出一个修复动作。
|
||
|
||
【异常信息】
|
||
模块: {failure['module']}
|
||
功能: {failure['function']}
|
||
异常表现: {failure['reason']}
|
||
来源: {failure['source']}
|
||
|
||
【背景】
|
||
- 系统: Linux 246, MoFin 股票分析系统
|
||
- cron 脚本目录: {SCRIPTS_DIR}
|
||
- 数据库: /home/hmo/MoFin/data/mofin.db
|
||
- 当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}(注意是否交易时段,非交易时段部分管道停跑属正常)
|
||
|
||
{WHITELIST_ACTIONS}
|
||
|
||
请只输出一个 JSON 动作(不要其他文字)。如果是非交易时段的正常空闲,选 none。"""
|
||
|
||
try:
|
||
payload = json.dumps({
|
||
'model': 'deepseek-v4-flash',
|
||
'messages': [{'role': 'user', 'content': prompt}],
|
||
'max_tokens': 300, 'stream': False,
|
||
}).encode()
|
||
import urllib.request
|
||
req = urllib.request.Request(GATEWAY, data=payload, headers={
|
||
'Content-Type': 'application/json', 'Authorization': 'Bearer hermes123',
|
||
'X-Hermes-Session-Id': 'self-repair'})
|
||
resp = urllib.request.urlopen(req, timeout=120)
|
||
text = json.loads(resp.read())['choices'][0]['message']['content']
|
||
# 提取 JSON
|
||
import re
|
||
m = re.search(r'\{[^{}]*"action"[^{}]*\}', text)
|
||
if m:
|
||
action = json.loads(m.group(0))
|
||
if action.get('action') in ('rerun_script', 'restart_service', 'sync_links',
|
||
'switch_llm_key', 'none'):
|
||
return action, text[:200]
|
||
except Exception as e:
|
||
print(f' LLM 诊断不可用: {str(e)[:80]}')
|
||
return (default_action, None) if default_action else (None, None)
|
||
|
||
|
||
def xmpp_report(lines):
|
||
try:
|
||
import urllib.request
|
||
payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), 'type': 'chat'}).encode()
|
||
req = urllib.request.Request('http://127.0.0.1:5805/', data=payload,
|
||
headers={'Content-Type': 'application/json'})
|
||
urllib.request.urlopen(req, timeout=5)
|
||
except Exception as e:
|
||
print(f'XMPP 失败: {e}')
|
||
|
||
|
||
def main():
|
||
now = datetime.now()
|
||
print('🔧 self_repair', now.strftime('%H:%M'))
|
||
failures = collect_failures()
|
||
if not failures:
|
||
print(' ✅ 无失败项')
|
||
return
|
||
|
||
state = load_state()
|
||
reports = []
|
||
for f in failures:
|
||
module = f['module']
|
||
if repairs_today(state, module) >= MAX_REPAIRS_PER_MODULE_PER_DAY:
|
||
print(f' ⏭ {module}: 今日已修复 {MAX_REPAIRS_PER_MODULE_PER_DAY} 次,跳过(防循环)')
|
||
continue
|
||
|
||
print(f' 🔍 {module}: {f["reason"][:60]}')
|
||
action, llm_note = llm_diagnose(f)
|
||
if action is None:
|
||
print(f' 无可用动作,跳过')
|
||
log_repair({'ts': now.isoformat(), 'module': module, 'reason': f['reason'],
|
||
'action': 'skip_no_action', 'ok': None})
|
||
continue
|
||
|
||
ok, detail = run_action(action, module)
|
||
bump_repair(state, module)
|
||
entry = {
|
||
'ts': now.isoformat(), 'module': module, 'function': f['function'],
|
||
'reason': f['reason'], 'action': action, 'ok': ok, 'detail': detail[:300],
|
||
'llm_note': llm_note,
|
||
}
|
||
log_repair(entry)
|
||
icon = '✅' if ok else '❌'
|
||
print(f' {icon} {action.get("action")} -> {detail[:80]}')
|
||
reports.append(f'{icon} [{module}] {action.get("action")}: {detail[:80]}')
|
||
|
||
if reports:
|
||
xmpp_report(['🔧 自检系统自动修复报备:', f'发现 {len(failures)} 个功能异常,已直接处理:'] + reports +
|
||
['', '详情: gateway/logs/repair_log.jsonl'])
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main() |