目录重组:web/ scripts/ config/ tests/ 标准化
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""cron_to_xmpp.py — 智能cron报告推送
|
||||
|
||||
只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。
|
||||
关键规则:
|
||||
1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据)
|
||||
2. 跳过自己的输出目录(30908cdc44a8),避免循环推送
|
||||
3. 正文太短(<20字)或只有 [SILENT] 的不推
|
||||
4. 超时自动跳过,不影响后续
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 使用绝对路径,不受 profile 环境变量影响
|
||||
REAL_HOME = Path("/home/hmo")
|
||||
|
||||
# 扫描目录
|
||||
CRON_DIRS = [
|
||||
REAL_HOME / ".hermes" / "cron" / "output",
|
||||
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output",
|
||||
]
|
||||
JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json"
|
||||
SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json"
|
||||
MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史
|
||||
|
||||
|
||||
def load_no_agent_job_ids():
|
||||
"""从两个profile的jobs.json中读取所有no_agent=true的job ID"""
|
||||
ids = set()
|
||||
for jobs_path in [
|
||||
REAL_HOME / ".hermes" / "cron" / "jobs.json",
|
||||
REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json",
|
||||
]:
|
||||
try:
|
||||
with open(jobs_path) as f:
|
||||
data = json.load(f)
|
||||
for j in data.get("jobs", []):
|
||||
if j.get("no_agent"):
|
||||
ids.add(j["id"])
|
||||
except:
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
# 硬编码保底(如果 jobs.json 读不到)
|
||||
SKIP_DIRS = {
|
||||
"30908cdc44a8", # cron-推XMPP中继自身输出
|
||||
"health", # 健康检查输出
|
||||
}
|
||||
|
||||
FROM = "zhiwei@yoin.fun"
|
||||
TO = "hmo@yoin.fun"
|
||||
|
||||
|
||||
def load_journal():
|
||||
try:
|
||||
return set(json.loads(JOURNAL.read_text()))
|
||||
except:
|
||||
return set()
|
||||
|
||||
|
||||
def save_journal(entries):
|
||||
JOURNAL.write_text(json.dumps(sorted(entries)))
|
||||
|
||||
|
||||
def is_pure_script_output(content):
|
||||
"""判断文件是否是纯脚本的机器输出(不是LLM报告)"""
|
||||
# LLM报告的特征:有 ## Response 节(包含agent的回复)
|
||||
if "## Response" in content:
|
||||
return False
|
||||
# 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出
|
||||
if content.startswith("# Cron Job:"):
|
||||
return True
|
||||
# 价格监控的触发输出
|
||||
if content.startswith("🔔") and "⏱" in content:
|
||||
return True
|
||||
# 健康检查报告
|
||||
if "MoFin 系统健康检查" in content:
|
||||
return True
|
||||
# 结构化数据标签(价格监控的机器数据)
|
||||
if "<structured_data>" in content:
|
||||
return True
|
||||
# no_agent 脚本的输出特征(Hermes自动添加的header)
|
||||
if "**Mode:** no_agent (script)" in content:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_report_body(body):
|
||||
"""质量检查 — 不拦截,返回改进建议"""
|
||||
issues = []
|
||||
text = body.strip()
|
||||
|
||||
if "重点推荐操作" not in text:
|
||||
issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)")
|
||||
|
||||
if "风险关注" not in text:
|
||||
issues.append("缺少【风险关注】区域(如无风险可写「无」)")
|
||||
|
||||
if len(text) > 600:
|
||||
issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内")
|
||||
|
||||
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
|
||||
if fuzzy:
|
||||
issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令")
|
||||
|
||||
if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text):
|
||||
issues.append("含选择题句式,建议只给一个确定建议")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def send_feedback(issues, job_name):
|
||||
"""发送质量反馈给知微自己"""
|
||||
from xml.sax.saxutils import escape
|
||||
feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues)
|
||||
safe = escape(feedback)
|
||||
stanza = (
|
||||
f"<message from='{FROM}' to='{FROM}' "
|
||||
f"type='chat' xml:lang='en'>"
|
||||
f"<body>{safe}</body></message>"
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl",
|
||||
"send_stanza", FROM, FROM, stanza],
|
||||
capture_output=True, timeout=10, text=True,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def extract_body(path):
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if is_pure_script_output(content):
|
||||
return None
|
||||
|
||||
parts = content.split("## Response")
|
||||
body = parts[1].strip() if len(parts) > 1 else content.strip()
|
||||
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
|
||||
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
|
||||
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
|
||||
|
||||
# 去掉agent的思考过程("Now let me...", "Let me...", "Now I have..."等开头)
|
||||
body = re.sub(r'^(Now let me|Let me|I need|I will|First let me|First,? I|Now I have|Here.i|I.ll|I.m ).*?\n\n', '', body, flags=re.DOTALL).strip()
|
||||
# 去掉末尾的思考尾巴
|
||||
body = re.sub(r'\n\s*(Now I|This |I have |I used |The report|The data).*?$', '', body, flags=re.DOTALL).strip()
|
||||
# 如果只剩"好的"、"收到"等短回应,丢弃
|
||||
if re.match(r'^[\u4e00-\u9fff,。]{1,10}$', body):
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
# [SILENT] → 不推送(计数的逻辑在 scan() 中处理)
|
||||
if "[SILENT]" in body:
|
||||
return None
|
||||
|
||||
if len(body) < 20:
|
||||
return None
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def send(body):
|
||||
from xml.sax.saxutils import escape
|
||||
safe = escape(f"【知微】{body}")
|
||||
stanza = (
|
||||
f"<message from='{FROM}' to='{TO}' "
|
||||
f"type='chat' xml:lang='en'>"
|
||||
f"<body>{safe}</body></message>"
|
||||
)
|
||||
# 重试3次
|
||||
for attempt in range(3):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "ejabberd", "ejabberdctl",
|
||||
"send_stanza", FROM, TO, stanza],
|
||||
capture_output=True, timeout=10, text=True,
|
||||
)
|
||||
if r.stderr and "error" in r.stderr.lower():
|
||||
print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
return r.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"send timeout (attempt {attempt+1})", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr)
|
||||
if attempt < 2:
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def validate_format(body):
|
||||
"""格式检查 — 只记录不拦截,标记改进点"""
|
||||
text = body.strip()
|
||||
issues = []
|
||||
|
||||
# 必含区域检查
|
||||
has_key = "重点推荐操作" in text
|
||||
has_risk = "风险关注" in text
|
||||
has_rest = "其余持仓" in text or "今日关注" in text
|
||||
if not has_key:
|
||||
issues.append("缺【重点推荐操作】区域")
|
||||
if not has_risk:
|
||||
issues.append("缺【风险关注】区域")
|
||||
|
||||
# 超长提醒
|
||||
if len(text) > 600:
|
||||
issues.append(f"报告偏长({len(text)}字),建议压缩到600字内")
|
||||
|
||||
# 模糊词提醒
|
||||
fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text)
|
||||
if fuzzy:
|
||||
issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论")
|
||||
|
||||
# 选择题句式提醒
|
||||
if re.search(r"如果.*就|若.*则|可以.*也可以", text):
|
||||
issues.append("含选择题句式,应给唯一建议")
|
||||
|
||||
return text, issues # 始终通过,issues 为空就是干净
|
||||
|
||||
|
||||
def load_silent_stats():
|
||||
"""加载当日静默统计"""
|
||||
try:
|
||||
return json.loads(SILENT_STATS.read_text())
|
||||
except:
|
||||
return {"date": "", "silent": 0, "short": 0, "script": 0}
|
||||
|
||||
|
||||
def save_silent_stats(stats):
|
||||
SILENT_STATS.write_text(json.dumps(stats))
|
||||
|
||||
|
||||
def send_silent_summary(stats):
|
||||
"""发送当日静默报告汇总"""
|
||||
parts = []
|
||||
if stats.get("silent", 0) > 0:
|
||||
parts.append(f"静默[SILENT] {stats['silent']}次")
|
||||
if stats.get("short", 0) > 0:
|
||||
parts.append(f"过短(<20字) {stats['short']}次")
|
||||
if stats.get("script", 0) > 0:
|
||||
parts.append(f"脚本输出 {stats['script']}次")
|
||||
|
||||
if not parts:
|
||||
body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。"
|
||||
else:
|
||||
body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。"
|
||||
|
||||
send(body)
|
||||
|
||||
|
||||
def scan():
|
||||
processed = load_journal()
|
||||
new = set()
|
||||
n_pushed = 0
|
||||
n_silent = 0
|
||||
n_short = 0
|
||||
n_script = 0
|
||||
no_agent_ids = load_no_agent_job_ids()
|
||||
skip_all = SKIP_DIRS | no_agent_ids
|
||||
|
||||
for cron_dir in CRON_DIRS:
|
||||
if not cron_dir.exists():
|
||||
continue
|
||||
|
||||
for d in sorted(cron_dir.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
if d.name in skip_all:
|
||||
continue
|
||||
|
||||
for f in sorted(d.iterdir()):
|
||||
if f.suffix != ".md":
|
||||
continue
|
||||
key = str(f.resolve())
|
||||
if key in processed or key in new:
|
||||
continue
|
||||
new.add(key)
|
||||
|
||||
# 跳过超过MAX_AGE_HOURS小时的旧文件
|
||||
age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600
|
||||
if age_hours > MAX_AGE_HOURS:
|
||||
continue
|
||||
|
||||
content = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# 提前判断脚本输出
|
||||
if is_pure_script_output(content):
|
||||
n_script += 1
|
||||
continue
|
||||
|
||||
parts = content.split("## Response")
|
||||
body = parts[1].strip() if len(parts) > 1 else content.strip()
|
||||
body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip()
|
||||
body = re.sub(r'\n?\s*<structured_data>.*?</structured_data>\s*', '', body, flags=re.DOTALL).strip()
|
||||
body = re.sub(r'\*\*(.*?)\*\*', r'\1', body)
|
||||
|
||||
if not body:
|
||||
n_short += 1
|
||||
continue
|
||||
|
||||
# SILENT → 拦截,记数(在长度检查之前,因为 [SILENT] 只有8字符)
|
||||
if "[SILENT]" in body:
|
||||
n_silent += 1
|
||||
continue
|
||||
|
||||
if len(body) < 20:
|
||||
n_short += 1
|
||||
continue
|
||||
|
||||
# 格式校验 — 记录改进点,不拦截
|
||||
ok_body, issues = validate_format(body)
|
||||
|
||||
n_pushed += 1
|
||||
ok_sent = send(body)
|
||||
if not ok_sent:
|
||||
print(f" {d.name}: send failed", file=sys.stderr)
|
||||
if issues:
|
||||
print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr)
|
||||
|
||||
if new:
|
||||
save_journal(processed | new)
|
||||
|
||||
# 保存当日汇总到文件(供16:30汇总用)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
stats = load_silent_stats()
|
||||
if stats.get("date") != today:
|
||||
stats = {"date": today, "silent": 0, "short": 0, "script": 0}
|
||||
stats["silent"] += n_silent
|
||||
stats["short"] += n_short
|
||||
stats["script"] += n_script
|
||||
save_silent_stats(stats)
|
||||
|
||||
# 16:30~16:35 发送当日汇总(收盘后)
|
||||
now = datetime.now()
|
||||
hhmm = now.hour * 60 + now.minute
|
||||
if 990 <= hhmm <= 995: # 16:30~16:35
|
||||
send_silent_summary(stats)
|
||||
|
||||
log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份"
|
||||
print(log, file=sys.stderr)
|
||||
return n_pushed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scan()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""批量再生所有持仓+自选策略,结合技术面支撑/压力位"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, '/home/hmo/web-dashboard')
|
||||
|
||||
from technical_analysis import full_analysis
|
||||
from strategy_lifecycle import reassess_strategy
|
||||
|
||||
PF = '/home/hmo/web-dashboard/data/portfolio.json'
|
||||
WL = '/home/hmo/web-dashboard/data/watchlist.json'
|
||||
|
||||
def main():
|
||||
# 持仓
|
||||
pf = json.load(open(PF))
|
||||
for s in pf['holdings']:
|
||||
code = s['code']
|
||||
name = s['name']
|
||||
price = s.get('price', 0)
|
||||
cost = s.get('cost', 0)
|
||||
shares = s.get('shares', 0)
|
||||
|
||||
if not price:
|
||||
continue
|
||||
|
||||
print(f" {name}({code}) 现价{price} 成本{cost}...", end=' ')
|
||||
|
||||
try:
|
||||
tech = full_analysis(code)
|
||||
except:
|
||||
tech = None
|
||||
|
||||
result = reassess_strategy(
|
||||
code, name, price, cost, shares,
|
||||
current_action=s.get('analysis', {}).get('action', '')
|
||||
)
|
||||
|
||||
if 'analysis' not in s:
|
||||
s['analysis'] = {}
|
||||
s['analysis']['stop_loss'] = result['stop_loss']
|
||||
s['analysis']['take_profit'] = result['take_profit']
|
||||
s['analysis']['entry_low'] = result['entry_low']
|
||||
s['analysis']['entry_high'] = result['entry_high']
|
||||
s['analysis']['action'] = result['action']
|
||||
s['analysis']['status'] = result['status']
|
||||
s['analysis']['reassessed_at'] = result['reassessed_at']
|
||||
|
||||
print(f"损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']}")
|
||||
|
||||
json.dump(pf, open(PF, 'w'), ensure_ascii=False, indent=2)
|
||||
print(f"\n持仓策略已更新: {len(pf['holdings'])} 条")
|
||||
|
||||
# 自选股 - 简单重新计算买入区
|
||||
wl = json.load(open(WL))
|
||||
updated = 0
|
||||
for s in wl['stocks']:
|
||||
code = s['code']
|
||||
price = s.get('price', 0)
|
||||
if not price:
|
||||
continue
|
||||
tech = None
|
||||
try:
|
||||
tech = full_analysis(code)
|
||||
except:
|
||||
pass
|
||||
|
||||
# 买入区 = 弱支撑~弱压力
|
||||
if tech:
|
||||
sr = tech.get('support_resistance', {})
|
||||
ws = sr.get('weak_support') or price * 0.95
|
||||
wr = sr.get('weak_resist') or price * 1.05
|
||||
else:
|
||||
ws = price * 0.92
|
||||
wr = price * 1.08
|
||||
|
||||
if 'analysis' not in s:
|
||||
s['analysis'] = {}
|
||||
s['analysis']['buy_low'] = round(ws, 2)
|
||||
s['analysis']['buy_high'] = round(wr, 2)
|
||||
if tech:
|
||||
s['analysis']['tech_levels'] = {
|
||||
'strong_support': sr.get('strong_support'),
|
||||
'weak_support': sr.get('weak_support'),
|
||||
'weak_resist': sr.get('weak_resist'),
|
||||
'strong_resist': sr.get('strong_resist'),
|
||||
}
|
||||
updated += 1
|
||||
print(f" {s['name']}({code}) 买入区={ws:.2f}~{wr:.2f}")
|
||||
|
||||
json.dump(wl, open(WL, 'w'), ensure_ascii=False, indent=2)
|
||||
print(f"\n自选策略已更新: {updated} 条")
|
||||
print("\n✅ 全部策略再生完成")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""session_to_cron_bridge.py — 将Hermes session DB中的cron报告写到cron/output目录
|
||||
|
||||
Hermes cron jobs(如快速盯盘)将LLM输出存在 session DB (state.db) 中。
|
||||
cron_to_xmpp.py 扫描 ~/.hermes/cron/output/ 目录的 .md 文件推送到XMPP。
|
||||
这个脚本弥补这个缺口:从state.db读取最新的cron输出,生成.md文件。
|
||||
|
||||
工作方式:
|
||||
1. 查询 state.db 中最近的 cron 会话(source='cron')
|
||||
2. 提取 assistant 的最后一条非空消息
|
||||
3. 与 relay journal 对比去重
|
||||
4. 新消息写入 cron/output/<job_id>/ 目录
|
||||
5. cron_to_xmpp.py 自然捡起并推送
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
REAL_HOME = Path("/home/hmo")
|
||||
PROFILE = "position-analyst"
|
||||
|
||||
# 要中继的 cron job ID 列表(需要推送到 XMPP 的)
|
||||
RELAY_JOBS = {
|
||||
"62a2ba59f7ff": "快速盯盘-15分钟",
|
||||
"e27e2e92ed80": "知识萃取-盘后",
|
||||
"9d1236d8a07f": "策略评估-每日",
|
||||
"5dde4e1a42ce": "分析师-持仓复查",
|
||||
}
|
||||
|
||||
# 输出目录(与 cron_to_xmpp.py 一致)
|
||||
# 注意:~/.hermes 是 symlink 到 /home/hmo/.hermes/profiles/position-analyst/home/.hermes
|
||||
# cron_to_xmpp.py 使用绝对路径 REAL_HOME / ".hermes" / "cron" / "output"
|
||||
# 所以这里必须用绝对路径,不要相信 ~/.hermes 的解析
|
||||
OUTPUT_DIRS = [
|
||||
REAL_HOME / ".hermes" / "cron" / "output",
|
||||
REAL_HOME / ".hermes" / "profiles" / PROFILE / "cron" / "output",
|
||||
]
|
||||
|
||||
JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json"
|
||||
STATE_DB = REAL_HOME / ".hermes" / "profiles" / PROFILE / "state.db"
|
||||
|
||||
MAX_AGE_MINUTES = 70 # 只处理最近70分钟内的报告
|
||||
TRACK_FILE = REAL_HOME / ".hermes" / "cron" / ".bridge_track.json" # 追踪已桥接的session
|
||||
|
||||
|
||||
def load_track():
|
||||
try:
|
||||
return set(json.loads(TRACK_FILE.read_text()))
|
||||
except:
|
||||
return set()
|
||||
|
||||
|
||||
def save_track(entries):
|
||||
TRACK_FILE.write_text(json.dumps(sorted(entries)))
|
||||
|
||||
|
||||
def load_journal():
|
||||
try:
|
||||
return set(json.loads(JOURNAL.read_text()))
|
||||
except:
|
||||
return set()
|
||||
|
||||
|
||||
def save_journal(entries):
|
||||
JOURNAL.write_text(json.dumps(sorted(entries)))
|
||||
|
||||
|
||||
def ensure_output_dirs():
|
||||
for d in OUTPUT_DIRS:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
for job_id in RELAY_JOBS:
|
||||
(d / job_id).mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def extract_report_content(content):
|
||||
"""从assistant消息中提取报告正文"""
|
||||
if not content or content.strip() in ("", " ", "\n", "\n\n"):
|
||||
return None
|
||||
|
||||
text = content.strip()
|
||||
|
||||
# 跳过太短的消息
|
||||
if len(text) < 20:
|
||||
return None
|
||||
|
||||
# 跳过 [SILENT]
|
||||
if "[SILENT]" in text:
|
||||
return None
|
||||
|
||||
# 跳过思考过程(只留下实际报告内容)
|
||||
# 如果消息以"Now let me"/"Let me"/"I need"等开头,尝试找后面的报告正文
|
||||
lines = text.split('\n')
|
||||
report_lines = []
|
||||
in_report = False
|
||||
for line in lines:
|
||||
if not in_report:
|
||||
# 报告特征:以【开头 或 包含📊 或 包含【知微】
|
||||
if any(x in line for x in ["【", "📊", "【知微", "【⚡", "## "]):
|
||||
in_report = True
|
||||
report_lines.append(line)
|
||||
else:
|
||||
report_lines.append(line)
|
||||
|
||||
if report_lines:
|
||||
text = '\n'.join(report_lines)
|
||||
|
||||
if len(text) < 20:
|
||||
return None
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def scan():
|
||||
processed = load_journal()
|
||||
tracked = load_track()
|
||||
new = set()
|
||||
n_written = 0
|
||||
|
||||
if not STATE_DB.exists():
|
||||
print(f"state.db not found: {STATE_DB}", file=sys.stderr)
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(str(STATE_DB))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
for job_id, job_name in RELAY_JOBS.items():
|
||||
# Find recent sessions for this job
|
||||
cur.execute('''
|
||||
SELECT id, started_at, message_count, source
|
||||
FROM sessions
|
||||
WHERE id LIKE ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 10
|
||||
''', (f'cron_{job_id}_%',))
|
||||
|
||||
sessions = cur.fetchall()
|
||||
|
||||
for s in sessions:
|
||||
session_id = s['id']
|
||||
|
||||
# Skip already bridged sessions
|
||||
if session_id in tracked:
|
||||
continue
|
||||
|
||||
started_at = datetime.fromtimestamp(s['started_at']) if s['started_at'] else now
|
||||
|
||||
# Skip too old sessions
|
||||
age_minutes = (now - started_at).total_seconds() / 60
|
||||
if age_minutes > MAX_AGE_MINUTES:
|
||||
continue
|
||||
|
||||
# Find the last assistant message
|
||||
cur.execute('''
|
||||
SELECT content, timestamp
|
||||
FROM messages
|
||||
WHERE session_id = ? AND role = 'assistant'
|
||||
AND content NOT IN ('', ' ', '\n', '\n\n', '\n\n\n')
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
''', (session_id,))
|
||||
|
||||
msg = cur.fetchone()
|
||||
if not msg:
|
||||
tracked.add(session_id)
|
||||
continue
|
||||
|
||||
content = msg['content'].strip()
|
||||
report = extract_report_content(content)
|
||||
if not report:
|
||||
tracked.add(session_id)
|
||||
continue
|
||||
|
||||
# Mark as tracked even before writing
|
||||
tracked.add(session_id)
|
||||
|
||||
# Generate a unique key for this report
|
||||
ts = datetime.fromtimestamp(msg['timestamp']).strftime('%Y%m%d_%H%M%S') if msg['timestamp'] else started_at.strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{job_name}_{ts}.md"
|
||||
|
||||
for out_dir in OUTPUT_DIRS:
|
||||
out_path = out_dir / job_id / filename
|
||||
key = str(out_path.resolve())
|
||||
|
||||
if key in processed or key in new:
|
||||
continue
|
||||
|
||||
# Write the report as an .md file (matching cron_to_xmpp.py format)
|
||||
md_content = f"# Cron Job: {job_name} ({session_id})\n\n## Response\n\n{report}\n"
|
||||
out_path.write_text(md_content, encoding='utf-8')
|
||||
new.add(key)
|
||||
n_written += 1
|
||||
print(f" Written: {out_path.relative_to(REAL_HOME)}", file=sys.stderr)
|
||||
|
||||
conn.close()
|
||||
|
||||
if tracked:
|
||||
save_track(tracked)
|
||||
|
||||
print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr)
|
||||
# 桥接脚本只负责写入 .md 文件,不做去重追踪
|
||||
# 这样可以避免重复推送的复杂问题
|
||||
# 可能每次运行会写重复的文件,但cron_to_xmpp.py会用journal去重
|
||||
|
||||
print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scan()
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""system_health_check.py — MoFin 系统健康检查
|
||||
|
||||
每日运行,检查所有组件是否正常工作。
|
||||
输出报告,有问题才推送。
|
||||
"""
|
||||
import json, os, sys, subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path("/home/hmo/web-dashboard/data")
|
||||
DECISIONS_PATH = DATA_DIR / "decisions.json"
|
||||
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
|
||||
EVENTS_PATH = DATA_DIR / "price_events.json"
|
||||
EVALUATION_PATH = DATA_DIR / "evaluation.json"
|
||||
ACCURACY_PATH = DATA_DIR / "accuracy_stats.json"
|
||||
CRON_JOBS = "/home/hmo/.hermes/cron/jobs.json"
|
||||
POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json"
|
||||
|
||||
def check(ok, msg):
|
||||
icon = "✅" if ok else "⚠️"
|
||||
return f" {icon} {msg}"
|
||||
|
||||
def load_json(path, default=None):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {} if default is None else default
|
||||
|
||||
def check_cron_jobs(path, label):
|
||||
issues = []
|
||||
try:
|
||||
d = load_json(path, {"jobs": []})
|
||||
for j in d.get("jobs", []):
|
||||
name = j.get("name", "?")
|
||||
enabled = j.get("enabled", True)
|
||||
last = j.get("last_run_at", "")
|
||||
status = j.get("last_status", "")
|
||||
if not enabled:
|
||||
issues.append(f"{name} 已禁用")
|
||||
elif not last:
|
||||
issues.append(f"{name} 从未运行")
|
||||
elif status != "ok":
|
||||
issues.append(f"{name} 上次状态={status}")
|
||||
return len(d.get("jobs", [])), issues
|
||||
except:
|
||||
return 0, ["无法读取"]
|
||||
|
||||
def run():
|
||||
now = datetime.now()
|
||||
issues = []
|
||||
ok_count = 0
|
||||
warn_count = 0
|
||||
|
||||
lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"]
|
||||
lines.append("")
|
||||
|
||||
# 1. 进程检查
|
||||
lines.append("【进程】")
|
||||
procs = {
|
||||
"mofin-dashboard": "mofin-dashboard",
|
||||
"xmpp-zhiwei": "xmpp_zhiwei_bot",
|
||||
"ejabberd": "ejabberd",
|
||||
}
|
||||
for name, pattern in procs.items():
|
||||
# 先查 systemd,再查 pgrep
|
||||
r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5)
|
||||
alive = r.stdout.strip() == "active"
|
||||
if not alive:
|
||||
r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5)
|
||||
alive = r2.returncode == 0
|
||||
lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}"))
|
||||
if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1
|
||||
else: ok_count += 1
|
||||
|
||||
# 2. 端口检查
|
||||
lines.append("")
|
||||
lines.append("【端口】")
|
||||
ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"}
|
||||
for port, name in ports.items():
|
||||
r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5)
|
||||
listening = f":{port}" in r.stdout
|
||||
lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}"))
|
||||
if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1
|
||||
else: ok_count += 1
|
||||
|
||||
# 3. 数据文件检查
|
||||
lines.append("")
|
||||
lines.append("【数据文件】")
|
||||
files = {
|
||||
"portfolio.json": PORTFOLIO_PATH,
|
||||
"watchlist.json": DATA_DIR / "watchlist.json",
|
||||
"decisions.json": DECISIONS_PATH,
|
||||
"market.json": DATA_DIR / "market.json",
|
||||
"price_events.json": EVENTS_PATH,
|
||||
"evaluation.json": EVALUATION_PATH,
|
||||
"accuracy_stats.json": ACCURACY_PATH,
|
||||
}
|
||||
for name, path in files.items():
|
||||
exists = path.exists()
|
||||
size = path.stat().st_size if exists else 0
|
||||
lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)"))
|
||||
if not exists or size < 10:
|
||||
issues.append(f"{name} 缺失或为空")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 4. 价格事件统计
|
||||
lines.append("")
|
||||
lines.append("【价格事件】")
|
||||
events = load_json(EVENTS_PATH, {"events": []})
|
||||
ev_list = events.get("events", [])
|
||||
today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")]
|
||||
lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条"))
|
||||
lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条"))
|
||||
if len(ev_list) == 0:
|
||||
issues.append("price_events.json 无事件记录,price_monitor可能未触发过")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 5. 策略评估统计
|
||||
lines.append("")
|
||||
lines.append("【策略评估】")
|
||||
evals = load_json(EVALUATION_PATH, {"strategies": []})
|
||||
s_list = evals.get("strategies", [])
|
||||
lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条"))
|
||||
if len(s_list) > 0:
|
||||
avg = sum(s.get("score", 0) for s in s_list) / len(s_list)
|
||||
lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10"))
|
||||
ok_count += 1
|
||||
else:
|
||||
issues.append("evaluation.json 无评估数据")
|
||||
warn_count += 1
|
||||
|
||||
# 6. 建议记录统计
|
||||
lines.append("")
|
||||
lines.append("【建议记录】")
|
||||
decisions = load_json(DECISIONS_PATH, {"decisions": []})
|
||||
total_advice = sum(len(d.get("advice_timeline", [])) for d in decisions.get("decisions", []))
|
||||
lines.append(check(total_advice > 0, f"建议记录: {total_advice}条"))
|
||||
if total_advice == 0:
|
||||
issues.append("所有策略建议记录为空")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 7. Cron jobs
|
||||
lines.append("")
|
||||
lines.append("【Cron Jobs】")
|
||||
cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default")
|
||||
lines.append(check(cnt > 0, f"default profile: {cnt}个job"))
|
||||
for ci in cron_issues:
|
||||
lines.append(f" ⚠️ {ci}")
|
||||
warn_count += 1
|
||||
if cnt == 0: warn_count += 1
|
||||
cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst")
|
||||
lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job"))
|
||||
for ci in cron_issues2:
|
||||
lines.append(f" ⚠️ {ci}")
|
||||
warn_count += 1
|
||||
if cnt2 == 0: warn_count += 1
|
||||
|
||||
# 8. 数据新鲜度
|
||||
lines.append("")
|
||||
lines.append("【数据新鲜度】")
|
||||
# 各数据文件的合理最大陈旧时间(小时)
|
||||
freshness_thresholds = {
|
||||
"portfolio.json": 24, # 每日有数据即可
|
||||
"decisions.json": 48, # 策略参数更新频率较低
|
||||
"multi_tf_cache.json": 24, # K线缓存每日更新
|
||||
"macro_context.json": 24, # 宏观数据每日2次
|
||||
"market.json": 48, # 行业数据每日更新
|
||||
"strategy_staleness_report.json": 24, # 时效性报告每日生成
|
||||
}
|
||||
data_files = {
|
||||
"portfolio.json": PORTFOLIO_PATH,
|
||||
"decisions.json": DECISIONS_PATH,
|
||||
"multi_tf_cache.json": DATA_DIR / "multi_tf_cache.json",
|
||||
"macro_context.json": DATA_DIR / "macro_context.json",
|
||||
"market.json": DATA_DIR / "market.json",
|
||||
"strategy_staleness_report.json": DATA_DIR / "strategy_staleness_report.json",
|
||||
}
|
||||
for name, path in data_files.items():
|
||||
if not path.exists():
|
||||
lines.append(check(False, f"{name} 缺失"))
|
||||
issues.append(f"{name} 文件缺失")
|
||||
warn_count += 1
|
||||
continue
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
hours_ago = (now - mtime).total_seconds() / 3600
|
||||
threshold = freshness_thresholds.get(name, 24)
|
||||
fresh = hours_ago < threshold
|
||||
time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前"
|
||||
lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)"))
|
||||
if not fresh:
|
||||
issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 数据管道组件检查
|
||||
lines.append("")
|
||||
lines.append("【数据管道】")
|
||||
pipe_checks = [
|
||||
("再生器(regenerate_all)", r"strategy_lifecycle\.py"),
|
||||
("市场采集(market_watch)", r"market_watch\.py"),
|
||||
("宏观采集(macro)", r"macro_context_collector\.py"),
|
||||
]
|
||||
for pname, ppattern in pipe_checks:
|
||||
r = subprocess.run(["pgrep", "-f", ppattern], capture_output=True, timeout=5)
|
||||
if r.returncode == 0:
|
||||
lines.append(check(True, f"{pname} 进程存在"))
|
||||
ok_count += 1
|
||||
else:
|
||||
# no_agent脚本不常驻,不报warn
|
||||
lines.append(" 📎 {} 无常驻进程(no_agent脚本按cron调度运行)".format(pname))
|
||||
|
||||
# 价格数据更新时间检查(盘中应有当日数据)
|
||||
is_trading_day = now.weekday() < 5 # 周一到周五
|
||||
if is_trading_day and now.hour >= 9 and now.hour < 16:
|
||||
if PORTFOLIO_PATH.exists():
|
||||
mtime = datetime.fromtimestamp(PORTFOLIO_PATH.stat().st_mtime)
|
||||
hours_ago = (now - mtime).total_seconds() / 3600
|
||||
has_intraday_data = mtime.date() == now.date()
|
||||
lines.append(check(has_intraday_data, f"盘中有当日价格数据 {'是' if has_intraday_data else '否'}(最近{mtime.strftime('%H:%M')})"))
|
||||
if not has_intraday_data:
|
||||
issues.append(f"盘中交易时段但portfolio.json无今日数据(最近更新{mtime.strftime('%m-%d %H:%M')})")
|
||||
warn_count += 1
|
||||
else:
|
||||
ok_count += 1
|
||||
|
||||
# 汇总
|
||||
total = ok_count + warn_count
|
||||
lines.append("")
|
||||
lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注")
|
||||
if issues:
|
||||
lines.append("")
|
||||
lines.append("需关注项:")
|
||||
for i, issue in enumerate(issues[:10], 1):
|
||||
lines.append(f" {i}. {issue}")
|
||||
|
||||
report = "\n".join(lines)
|
||||
print(report)
|
||||
|
||||
# 如果有问题,写入报告文件供推送
|
||||
if warn_count > 0:
|
||||
report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health")
|
||||
report_path.mkdir(parents=True, exist_ok=True)
|
||||
report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md"
|
||||
report_file.write_text(f"# MoFin 系统健康检查\n\n{report}")
|
||||
print(f"\n报告已写入 {report_file}")
|
||||
else:
|
||||
print("\n[SILENT] 一切正常")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user