From e366a6359ad6d3e53a024faa688d53f52bb6fd3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Sun, 19 Jul 2026 20:59:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(bot):=20=E9=98=B2=E9=80=92=E5=BD=92?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=20guard=20-=20=E9=98=B2=E6=AD=A2=20on=5Fdisc?= =?UTF-8?q?onnect=20=E2=86=92=20reconnect=20=E2=86=92=20disconnect=20?= =?UTF-8?q?=E6=97=A0=E9=99=90=E9=80=92=E5=BD=92=E5=AF=BC=E8=87=B4=E6=A0=88?= =?UTF-8?q?=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agents_health_check.py | 190 ++ cron_to_xmpp.py | 730 +++---- dashboard.py | 304 +++ data/portfolio.json | 190 ++ deploy/bot/xmpp_agent_core.py | 8 +- dev-spec.md | 217 ++ docs/DASHBOARD.md | 105 + docs/DEPLOY.md | 99 + docs/HEALTH-PIPELINE.md | 111 + docs/QUICKSTART.md | 86 + docs/dev-spec.md | 214 ++ docs/learned.md | 12 + gateway/logs/dashboard.log | 52 + gateway/logs/health_check.log | 90 + gateway/logs/health_check_cron.log | 102 + gateway/logs/last_restart.txt | 1 + gateway/logs/xmpp_health_log.jsonl | 70 + gateway/logs/xmpp_messages.jsonl | 3 + gateway/temp/health_todos.jsonl | 43 + gateway/temp/last_health_check.json | 49 + index.html | 1695 +++++++++++++++ scripts/market_scanner.py | 399 ++-- server.py | 207 ++ specs/dashboard.json | 60 + specs/decisions.json | 53 + specs/evaluation.json | 48 + specs/health.json | 73 + specs/market.json | 42 + specs/portfolio.json | 60 + specs/prompts.json | 60 + specs/reports.json | 43 + specs/scanner.json | 56 + specs/signals.json | 50 + specs/watchlist.json | 42 + specs/xmpp_monitor.json | 72 + static/index.html | 3037 +++++++++++++++------------ templates/closing_brief.txt | 38 +- templates/dashboard.html | 277 +++ templates/intraday_monitor.txt | 44 +- templates/opening_brief.txt | 38 +- templates/self_buy_reminder.txt | 30 +- templates/strategy_eval.txt | 38 +- xmpp_logger.py | 518 +++++ 43 files changed, 7668 insertions(+), 1988 deletions(-) create mode 100644 agents_health_check.py create mode 100644 dashboard.py create mode 100644 data/portfolio.json create mode 100644 dev-spec.md create mode 100644 docs/DASHBOARD.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/HEALTH-PIPELINE.md create mode 100644 docs/QUICKSTART.md create mode 100644 docs/dev-spec.md create mode 100644 docs/learned.md create mode 100644 gateway/logs/dashboard.log create mode 100644 gateway/logs/health_check.log create mode 100644 gateway/logs/health_check_cron.log create mode 100644 gateway/logs/last_restart.txt create mode 100644 gateway/logs/xmpp_health_log.jsonl create mode 100644 gateway/logs/xmpp_messages.jsonl create mode 100644 gateway/temp/health_todos.jsonl create mode 100644 gateway/temp/last_health_check.json create mode 100644 index.html create mode 100644 specs/dashboard.json create mode 100644 specs/decisions.json create mode 100644 specs/evaluation.json create mode 100644 specs/health.json create mode 100644 specs/market.json create mode 100644 specs/portfolio.json create mode 100644 specs/prompts.json create mode 100644 specs/reports.json create mode 100644 specs/scanner.json create mode 100644 specs/signals.json create mode 100644 specs/watchlist.json create mode 100644 specs/xmpp_monitor.json create mode 100644 templates/dashboard.html create mode 100644 xmpp_logger.py diff --git a/agents_health_check.py b/agents_health_check.py new file mode 100644 index 00000000..77d81b5e --- /dev/null +++ b/agents_health_check.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +agents_health_check.py — MoFin Tier1 快速健康检查 +==================================================== +每 5 分钟运行一次(crontab)。检查关键服务的端口/HTTP/DB 可用性。 +全正常时静默(不输出)。异常时写入 TODO 文件和 JSON 报告。 +同时采集 XMPP 通道健康数据到时间序列日志。 + +部署: crontab */5 * * * * cd /home/hmo/MoFin && python3 agents_health_check.py +""" +import json, os, sys, socket, sqlite3, urllib.request +from datetime import datetime +from pathlib import Path + +# ---- Config ---- +SCRIPT_DIR = Path(__file__).resolve().parent +TEMP_DIR = SCRIPT_DIR / "gateway" / "temp" +LOGS_DIR = SCRIPT_DIR / "gateway" / "logs" + +# Ensure dirs +TEMP_DIR.mkdir(parents=True, exist_ok=True) +LOGS_DIR.mkdir(parents=True, exist_ok=True) + +REPORT_FILE = TEMP_DIR / "last_health_check.json" +TODO_FILE = TEMP_DIR / "health_todos.jsonl" +LOG_FILE = LOGS_DIR / "health_check.log" + +# ---- Service List ---- +SERVICES = [ + {"name": "mofin_api", "label": "MoFin API", "host": "127.0.0.1", "port": 8899, "type": "http", "check": "/api/health"}, + {"name": "zhiwei_gateway", "label": "知微 Gateway", "host": "127.0.0.1", "port": 8643, "type": "http", "check": "/v1/health"}, + {"name": "ejabberd", "label": "ejabberd XMPP", "host": "127.0.0.1", "port": 5222, "type": "tcp", "check": None}, + {"name": "mofin_db", "label": "MoFin 数据库", "host": "127.0.0.1", "port": 0, "type": "db", "check": "/home/hmo/web-dashboard/data/mofin.db"}, +] + +# ── XMPP 通道健康采集 ────────────────────────────── + +XMPP_HEALTH_LOG = LOGS_DIR / "xmpp_health_log.jsonl" + + +def _collect_xmpp_health(now): + """采集 XMPP 通道健康数据,追加到时间序列日志。""" + try: + from xmpp_logger import health as xmpp_health + h = xmpp_health() + except Exception as e: + h = {"status": "collector_error", "error": str(e)[:200]} + entry = {"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), **h} + XMPP_HEALTH_LOG.parent.mkdir(parents=True, exist_ok=True) + with open(XMPP_HEALTH_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + +# ---- Checkers ---- + +def check_tcp(host, port, timeout=3): + try: + sock = socket.create_connection((host, port), timeout=timeout) + sock.close() + return True, "ok" + except Exception as e: + return False, str(e) + + +def check_http(host, port, path, timeout=3): + try: + url = f"http://{host}:{port}{path}" + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=timeout) + return 200 <= resp.status < 300, f"HTTP {resp.status}" + except Exception as e: + return False, str(e) + + +def check_db(db_path): + try: + conn = sqlite3.connect(db_path) + conn.execute("SELECT 1") + conn.close() + return True, "ok" + except Exception as e: + return False, str(e) + + +# ---- Main ---- + +def run(): + now = datetime.now() + results = [] + issues = [] + + for svc in SERVICES: + if svc["type"] == "tcp": + ok, detail = check_tcp(svc["host"], svc["port"]) + elif svc["type"] == "http": + ok, detail = check_http(svc["host"], svc["port"], svc["check"]) + elif svc["type"] == "db": + ok, detail = check_db(svc["check"]) + else: + ok, detail = False, "unknown type" + + results.append({ + "name": svc["name"], "label": svc["label"], + "type": svc["type"], "port": svc["port"], + "health": {"ok": ok}, "detail": detail, + }) + if not ok: + issues.append(svc) + + # Write report + report = { + "services": results, + "summary": {"ok": sum(1 for r in results if r["health"]["ok"]), "total": len(results)}, + "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), + } + with open(REPORT_FILE, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + +# ── XMPP 通道健康采集 ── + _collect_xmpp_health(now) + + # ── 自愈:检测 rate-limit → 切 key;Gateway 异常 → systemctl restart ── + try: + from xmpp_logger import auto_heal as _xmpp_auto_heal + heal_result = _xmpp_auto_heal() + actions = heal_result.get("actions", []) + if actions: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] AUTO-HEAL actions:\n") + for a in actions: + f.write(f" {json.dumps(a, ensure_ascii=False)}\n") + print(f"[{now.strftime('%H:%M')}] Auto-heal: {len(actions)} action(s) — {heal_result.get('status')}") + for a in actions: + print(f" → {a.get('action', '?')}: success={a.get('success', a.get('switched', '?'))}") + except Exception as e: + print(f"[{now.strftime('%H:%M')}] Auto-heal error: {e}") + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] AUTO-HEAL ERROR: {e}\n") + + # ── Gateway 端口宕机兜底:异步 systemctl 重启(带 cooldown 防频繁触发)── + gateway_ok = any(r["name"] == "zhiwei_gateway" and r["health"]["ok"] for r in results) + if not gateway_ok: + import time as _t + import subprocess as _sp + cooldown_file = LOGS_DIR / "last_restart.txt" + cooldown_sec = 180 + try: + last_ts = float(cooldown_file.read_text().strip()) if cooldown_file.exists() else 0 + except Exception: + last_ts = 0 + elapsed = _t.time() - last_ts + if elapsed < cooldown_sec: + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] Gateway DOWN but cooldown {int(elapsed)}s < {cooldown_sec}s — skip restart\n") + print(f"[{now.strftime('%H:%M')}] Gateway DOWN but in cooldown ({int(elapsed)}s)") + else: + try: + _sp.Popen(["sudo", "-n", "systemctl", "restart", "hermes-gateway-zhiwei.service"], + stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True) + cooldown_file.write_text(str(_t.time())) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] Gateway DOWN → systemctl restart triggered (async, cooldown set)\n") + print(f"[{now.strftime('%H:%M')}] Gateway DOWN → systemctl restart triggered") + except Exception as e: + print(f"[{now.strftime('%H:%M')}] Gateway restart error: {e}") + + # Handle issues + if issues: + with open(TODO_FILE, "a", encoding="utf-8") as f: + for svc in issues: + entry = { + "service": svc["name"], "label": svc["label"], + "reason": next((r["detail"] for r in results if r["name"] == svc["name"]), "unknown"), + "timestamp": now.isoformat(), + } + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] ISSUES: {len(issues)} failed\n") + for svc in issues: + detail = next((r["detail"] for r in results if r["name"] == svc["name"]), "") + f.write(f" - {svc['label']}: {detail}\n") + print(f"[{now.strftime('%H:%M')}] Health check: {len(issues)}/{len(SERVICES)} services failed") + for svc in issues: + detail = next((r["detail"] for r in results if r["name"] == svc["name"]), "") + print(f" FAIL: {svc['label']} ({svc['name']}) — {detail}") + + +if __name__ == "__main__": + run() diff --git a/cron_to_xmpp.py b/cron_to_xmpp.py index b37f7292..d267f945 100644 --- a/cron_to_xmpp.py +++ b/cron_to_xmpp.py @@ -1,356 +1,374 @@ -#!/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中继自身输出 - "a231e9c39b4e", # 知识研究-日常(由莫荷负责推送) - "7bda62d24d22", # 梦境循环-知识库归并(由莫荷负责推送) - "0cbf6c317c60", # evolution-pulse 跨文档连接(由莫荷负责推送) - "1160671067e0", # wiki-self-growth 自修复(由莫荷负责推送) - "health", # 健康检查输出 - "b9fa4482dc1a", # 自成长知识库-22:10中继推送(莫荷的通道) -} - -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 - # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 - # 移除 [SILENT] 过滤,让报告始终送达 - # 结构化数据标签(价格监控的机器数据) - if "" 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"" - f"{safe}" - ) - 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*.*?\s*', '', body, flags=re.DOTALL).strip() - body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) - - if not body or len(body) < 20: - return None - - # 只过滤内容是纯[SILENT]的报告 - if body.strip() == "[SILENT]": - return None - - # 正文中混了[SILENT]标记(LLM写了报告又在末尾加了这个)— 去掉标记保留正文 - body = body.replace("[SILENT]", "").strip() - if len(body) < 20: - return None - - return body - - -def send(body): - from xml.sax.saxutils import escape - safe = escape(f"【知微】{body}") - stanza = ( - f"" - f"{safe}" - ) - # 重试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*.*?\s*', '', body, flags=re.DOTALL).strip() - body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) - - if not body or len(body) < 20: - n_short += 1 - continue - - # SILENT → 拦截,记数 - if "[SILENT]" in body: - n_silent += 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() +#!/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 + +# XMPP 消息日志 hook +try: + from xmpp_logger import log_xmpp +except ImportError: + def log_xmpp(*a, **kw): pass + +# 使用绝对路径,不受 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中继自身输出 + "a231e9c39b4e", # 知识研究-日常(由莫荷负责推送) + "7bda62d24d22", # 梦境循环-知识库归并(由莫荷负责推送) + "0cbf6c317c60", # evolution-pulse 跨文档连接(由莫荷负责推送) + "1160671067e0", # wiki-self-growth 自修复(由莫荷负责推送) + "health", # 健康检查输出 + "b9fa4482dc1a", # 自成长知识库-22:10中继推送(莫荷的通道) +} + +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 + # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 + # 移除 [SILENT] 过滤,让报告始终送达 + # 结构化数据标签(价格监控的机器数据) + if "" 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"" + f"{safe}" + ) + 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*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + return None + + # 只过滤内容是纯[SILENT]的报告 + if body.strip() == "[SILENT]": + return None + + # 正文中混了[SILENT]标记(LLM写了报告又在末尾加了这个)— 去掉标记保留正文 + body = body.replace("[SILENT]", "").strip() + if len(body) < 20: + return None + + return body + + +def send(body): + from xml.sax.saxutils import escape + import time as _time + t0 = _time.time() + safe = escape(f"【知微】{body}") + stanza = ( + f"" + f"{safe}" + ) + # 重试3次 + last_err = None + 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) + last_err = r.stderr.strip()[:200] + if attempt < 2: + continue + log_xmpp("out", FROM, TO, body, "error", last_err, int((_time.time()-t0)*1000)) + return False + ok = r.returncode == 0 + log_xmpp("out", FROM, TO, body, "ok" if ok else "error", None if ok else r.stderr, int((_time.time()-t0)*1000)) + return ok + except subprocess.TimeoutExpired: + last_err = "timeout" + print(f"send timeout (attempt {attempt+1})", file=sys.stderr) + if attempt < 2: + continue + log_xmpp("out", FROM, TO, body, "timeout", last_err, int((_time.time()-t0)*1000)) + return False + except Exception as e: + last_err = str(e)[:200] + print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr) + if attempt < 2: + continue + log_xmpp("out", FROM, TO, body, "error", last_err, int((_time.time()-t0)*1000)) + return False + log_xmpp("out", FROM, TO, body, "error", last_err or "all retries failed", int((_time.time()-t0)*1000)) + 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*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + n_short += 1 + continue + + # SILENT → 拦截,记数 + if "[SILENT]" in body: + n_silent += 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() diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 00000000..af5a95c2 --- /dev/null +++ b/dashboard.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +dashboard.py - MoFin management dashboard backend +================================================== +Minimal Flask app on :5804. Monitors MoFin services and serves +module specs (human_help + ai_spec) via ?§ button system. + +Adapted from AgentsMeeting dashboard.py. Does NOT modify server.py. +""" +import os, sys, json, socket, logging, time +from pathlib import Path +from datetime import datetime +from flask import Flask, jsonify, request, send_from_directory + +# ---- Paths (auto-detect from script location) ---- +_SCRIPT_DIR = Path(__file__).resolve().parent # MoFin/ +_TEMPLATES_DIR = _SCRIPT_DIR / "templates" +_SPECS_DIR = _SCRIPT_DIR / "specs" +_GATEWAY_DIR = _SCRIPT_DIR / "gateway" +_LOGS_DIR = _GATEWAY_DIR / "logs" +_TEMP_DIR = _GATEWAY_DIR / "temp" + +# Allow override via env +_PROJECT_ROOT = os.environ.get("MOFIN_ROOT") +if _PROJECT_ROOT: + _SCRIPT_DIR = Path(_PROJECT_ROOT) + _TEMPLATES_DIR = _SCRIPT_DIR / "templates" + _SPECS_DIR = _SCRIPT_DIR / "specs" + _GATEWAY_DIR = _SCRIPT_DIR / "gateway" + _LOGS_DIR = _GATEWAY_DIR / "logs" + _TEMP_DIR = _GATEWAY_DIR / "temp" + +app = Flask(__name__, template_folder=str(_TEMPLATES_DIR)) + +# ---- Logging ---- +_LOG_FILE = _LOGS_DIR / "dashboard.log" +_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) +logging.basicConfig( + filename=str(_LOG_FILE), + level=logging.INFO, + format="%(asctime)s [dashboard] %(message)s", +) +log = logging.getLogger("dashboard") + +# ---- Constants ---- +PORT = int(os.environ.get("MOFIN_DASHBOARD_PORT", 5807)) +START_TIME = time.time() + +# ---- Monitored Services ---- +SERVICES = [ + { + "name": "mofin_api", + "label": "MoFin API", + "port": 8899, + "host": "127.0.0.1", + "type": "http", + "check": "/api/portfolio", + "layer": "核心服务", + "critical": True, + }, + { + "name": "mofin_dashboard", + "label": "Dashboard", + "port": 5807, + "host": "127.0.0.1", + "type": "http", + "check": "/api/health", + "layer": "核心服务", + "critical": True, + }, + { + "name": "zhiwei_gateway", + "label": "知微 Gateway", + "port": 8643, + "host": "127.0.0.1", + "type": "http", + "check": "/v1/health", + "layer": "AI 网关", + "critical": True, + }, + { + "name": "ejabberd", + "label": "ejabberd XMPP", + "port": 5222, + "host": "127.0.0.1", + "type": "tcp", + "check": None, + "layer": "通信层", + "critical": True, + }, + { + "name": "mofin_db", + "label": "MoFin 数据库", + "port": 0, + "host": "127.0.0.1", + "type": "db", + "check": "/home/hmo/web-dashboard/data/mofin.db", + "layer": "数据层", + "critical": True, + }, +] + + +# ---- Service Check Helpers ---- + +def _check_tcp(host, port, timeout=3): + """Check if TCP port is open.""" + try: + sock = socket.create_connection((host, port), timeout=timeout) + sock.close() + return True + except Exception: + return False + + +def _check_http(host, port, path, timeout=3): + """Check HTTP endpoint returns 2xx.""" + import urllib.request + try: + url = f"http://{host}:{port}{path}" if host else f"http://127.0.0.1:{port}{path}" + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=timeout) + return 200 <= resp.status < 300 + except Exception: + return False + + +def _check_db(db_path): + """Check SQLite database is accessible.""" + import sqlite3 + try: + conn = sqlite3.connect(db_path) + conn.execute("SELECT 1") + conn.close() + return True + except Exception: + return False + + +def _check_service(svc): + """Check a single service, return (ok, detail).""" + if svc["type"] == "tcp": + ok = _check_tcp(svc["host"], svc["port"]) + return ok, "port open" if ok else "port closed" + elif svc["type"] == "http": + ok = _check_http(svc["host"], svc["port"], svc["check"]) + return ok, "HTTP 2xx" if ok else "HTTP fail" + elif svc["type"] == "db": + ok = _check_db(svc["check"]) + return ok, "DB accessible" if ok else "DB fail" + return False, "unknown type" + + +# ---- API Endpoints ---- + +@app.route("/") +def index(): + return send_from_directory(str(_TEMPLATES_DIR), "dashboard.html") + + +@app.route("/api/health") +def api_health(): + return jsonify({ + "status": "ok", + "uptime": int(time.time() - START_TIME), + "version": "1.0", + }) + + +@app.route("/api/services") +def api_services(): + """Return status of all monitored services.""" + result = [] + for svc in SERVICES: + ok, detail = _check_service(svc) + result.append({ + "name": svc["name"], + "label": svc["label"], + "port": svc["port"], + "type": svc["type"], + "layer": svc["layer"], + "critical": svc["critical"], + "health": {"ok": ok}, + "detail": detail, + }) + ok_count = sum(1 for s in result if s["health"]["ok"]) + return jsonify({ + "services": result, + "summary": {"ok": ok_count, "total": len(result)}, + }) + + +@app.route("/api/expected") +def api_expected(): + """Return expectation matrix.""" + expected = [] + for svc in SERVICES: + expected.append({ + "name": svc["name"], + "label": svc["label"], + "port": svc["port"], + "expected": "running", + "critical": svc["critical"], + "layer": svc["layer"], + "check": f"{svc['type']}:{svc['port']}" if svc["port"] else svc["type"], + }) + + # Actual status + actual = {} + for svc in SERVICES: + ok, _ = _check_service(svc) + actual[svc["name"]] = "running" if ok else "stopped" + + return jsonify({ + "expected": expected, + "actual": actual, + }) + + +@app.route("/api/monitor") +def api_monitor(): + """Aggregate health check data from Tier1/Tier2 reports.""" + tasks = [] + tier1 = {"summary": {"ok": 0, "total": 0}, "services": []} + tier2 = {"summary": {"ok": 0, "total": 0}, "services": []} + + # Try to read Tier1 report + t1_path = _TEMP_DIR / "last_health_check.json" + if t1_path.exists(): + try: + with open(t1_path, encoding="utf-8") as f: + tier1 = json.load(f) + tasks.append({"name": "agents-health-check", "status": "cron_ok"}) + except Exception: + tasks.append({"name": "agents-health-check", "status": "error"}) + else: + tasks.append({"name": "agents-health-check", "status": "not_deployed"}) + + # Try to read Tier2 report + t2_path = _TEMP_DIR / "last_daily_health.json" + if t2_path.exists(): + try: + with open(t2_path, encoding="utf-8") as f: + tier2 = json.load(f) + tasks.append({"name": "agents-daily-health", "status": "cron_ok"}) + except Exception: + tasks.append({"name": "agents-daily-health", "status": "error"}) + else: + tasks.append({"name": "agents-daily-health", "status": "not_deployed"}) + + # Self-check: are we running? + svc_result = api_services().get_json() + tasks.append({ + "name": "dashboard", + "status": "running", + "detail": f"services: {svc_result.get('summary', {}).get('ok', 0)}/{svc_result.get('summary', {}).get('total', 0)}", + }) + + return jsonify({ + "tasks": tasks, + "tier1": tier1, + "tier2": tier2, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + + +@app.route("/api/module-spec/") +def api_module_spec(module): + """Serve spec JSON for a module.""" + # Safety: prevent path traversal + module = module.replace("..", "").replace("/", "").replace("\\", "") + spec_path = _SPECS_DIR / f"{module}.json" + if spec_path.exists(): + try: + with open(spec_path, encoding="utf-8") as f: + return jsonify(json.load(f)) + except Exception as e: + return jsonify({"error": f"Failed to read spec: {e}"}), 500 + return jsonify({"error": f"Module '{module}' not found"}), 404 + + +# ---- Main ---- + +if __name__ == "__main__": + # Ensure directories exist + _LOGS_DIR.mkdir(parents=True, exist_ok=True) + _TEMP_DIR.mkdir(parents=True, exist_ok=True) + + log.info(f"MoFin Dashboard starting on port {PORT}") + log.info(f"Specs dir: {_SPECS_DIR}") + log.info(f"Templates dir: {_TEMPLATES_DIR}") + + # Optional: PID guard + try: + sys.path.insert(0, str(_SCRIPT_DIR)) + from proc_guard import guard + if not guard("mofin_dashboard"): + log.error("Another dashboard instance is already running") + sys.exit(1) + except ImportError: + log.warning("proc_guard not available, skipping PID lock") + + app.run(host="0.0.0.0", port=PORT, debug=False) diff --git a/data/portfolio.json b/data/portfolio.json new file mode 100644 index 00000000..4f9cbf14 --- /dev/null +++ b/data/portfolio.json @@ -0,0 +1,190 @@ +{ + "holdings": [ + { + "code": "518880", + "name": "黄金ETF华安", + "shares": 2400, + "cost": 12.1915, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 8.29, + "market_value": 19862.4, + "change_pct": -0.85, + "currency": "CNY" + }, + { + "code": "601899", + "name": "紫金矿业", + "shares": 2400, + "cost": 39.8885, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 28.38, + "market_value": 68184.0, + "change_pct": -2.97, + "currency": "CNY" + }, + { + "code": "688411", + "name": "海博思创", + "shares": 200, + "cost": 266.9461, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 184.26, + "market_value": 37048.0, + "change_pct": -4.97, + "currency": "CNY" + }, + { + "code": "688639", + "name": "华恒生物", + "shares": 2800, + "cost": 21.5085, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 16.47, + "market_value": 46480.0, + "change_pct": -4.19, + "currency": "CNY" + }, + { + "code": "688981", + "name": "中芯国际", + "shares": 300, + "cost": 126.0681, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 144.84, + "market_value": 44436.0, + "change_pct": -4.58, + "currency": "CNY" + }, + { + "code": "000850", + "name": "华茂股份", + "shares": 20400, + "cost": 3.9408, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 4.09, + "market_value": 83232.0, + "change_pct": 0.25, + "currency": "CNY" + }, + { + "code": "300035", + "name": "中科电气", + "shares": 1400, + "cost": 22.2914, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 12.31, + "market_value": 18116.0, + "change_pct": -1.44, + "currency": "CNY" + }, + { + "code": "00700", + "name": "腾讯控股", + "shares": 100, + "cost": 445.2906, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 461.0, + "market_value": 39837.28, + "change_pct": -4.75, + "currency": "CNY" + }, + { + "code": "01088", + "name": "中国神华", + "shares": 500, + "cost": 46.1178, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 41.62, + "market_value": 18114.13, + "change_pct": -0.62, + "currency": "CNY" + }, + { + "code": "01211", + "name": "比亚迪股份", + "shares": 600, + "cost": 105.3827, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 87.6, + "market_value": 45328.5, + "change_pct": -3.68, + "currency": "CNY" + }, + { + "code": "01478", + "name": "丘钛科技", + "shares": 11000, + "cost": 13.531, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 6.56, + "market_value": 62967.76, + "change_pct": -6.42, + "currency": "CNY" + }, + { + "code": "02202", + "name": "万科企业", + "shares": 19700, + "cost": 4.6906, + "position_pct": null, + "added_at": "2026-07-17", + "is_active": 1, + "closed_at": null, + "close_pnl": null, + "price": 2.36, + "market_value": 40141.19, + "change_pct": -5.98, + "currency": "CNY" + } + ], + "cash": 321271.0, + "frozen_cash": 0.0, + "total_market_value": 521073.53, + "total_assets": 842344.53, + "position_pct": 61.86, + "updated_at": "2026-07-17 14:08:07" +} \ No newline at end of file diff --git a/deploy/bot/xmpp_agent_core.py b/deploy/bot/xmpp_agent_core.py index e5e6e8a4..795b8a51 100644 --- a/deploy/bot/xmpp_agent_core.py +++ b/deploy/bot/xmpp_agent_core.py @@ -154,6 +154,7 @@ class XmppAgent(slixmpp.ClientXMPP): self._nick = nick self._muc_joined = False self._recent_sent = [] + self._reconnecting = False # 防递归重连 self.add_event_handler('session_start', self.on_start) self.add_event_handler('message', self.on_msg) self.add_event_handler('disconnected', self.on_disconnect) @@ -174,11 +175,16 @@ class XmppAgent(slixmpp.ClientXMPP): def on_disconnect(self, event): self._muc_joined = False log.info(f"{AGENT_NAME} XMPP 断开") - # 自动重连:slixmpp 1.15.0 没有 auto_reconnect 属性,需手动 + if self._reconnecting: + log.warning(f"{AGENT_NAME} 已在重连中,跳过递归重连") + return + self._reconnecting = True try: self.reconnect(wait=5.0, reason="断线自动重连") except Exception as e: log.warning(f"{AGENT_NAME} 重连失败: {e}") + finally: + self._reconnecting = False def on_msg(self, msg): if msg['type'] in ('chat', 'groupchat'): diff --git a/dev-spec.md b/dev-spec.md new file mode 100644 index 00000000..31b33aaa --- /dev/null +++ b/dev-spec.md @@ -0,0 +1,217 @@ +# MoFin 开发规范 + +> 版本: v1.0 | 更新: 2026-07-19 | 基于 AgentsMeeting 样板重构 +> +> 📋 样板参考: [AgentsMeeting TEMPLATE-GUIDE.md](../AgentsMeeting/docs/TEMPLATE-GUIDE.md) + +--- + +## 五条红线 + +1. **先读/写 Spec,再写代码** — 新增功能先写 spec 再实现;修改已有功能先读对应 spec 了解架构和约束再动手。没有 spec 的模块在 Dashboard 不可见,视为未完成 +2. **部署必验** — 部署后不打开 Dashboard F Tab 验证 = 部署未完成 +3. **不可见即不存在** — 组件不在 Dashboard 中显示 = 等于没部署。离线不告警 = 监控缺陷 +4. **实现后同步 Spec** — 每轮开发完毕后,必须将 `specs/{module}.json` 更新为与实际实现一致的状态。文档过期 = 等于没写 +5. **部署目标即验收标准** — 所有代码必须以部署目标环境(Linux 246)为基准编写和测试。禁止使用 Windows 专属 API(`tasklist`、`netstat`、`schtasks`、`wmic`)在 246 部署的代码中 + +--- + +## 一、双轨同源规范体系 + +每新增/修改一个独立功能模块,必须先写 `specs/{module}.json`。 +一个来源同时产出两套文档: + +``` +specs/{module}.json +├── human_help → ? 按钮(人类看说明/排错) +└── ai_spec → § 按钮(AI 看接口/约束/依赖) +``` + +### 什么算一个模块 + +满足以下任一条件即视为独立模块,必须写 spec: + +- 暴露独立的 HTTP API 端点 +- 在 Dashboard 上有独立 UI 面板(`?` + `§` 按钮) +- 有独立的配置文件 / 数据文件 +- 可独立部署(如定时任务、数据采集脚本) + +### Spec 字段标准 + +```json +{ + "module": "模块名(与 Dashboard 引用名一致)", + "version": "1.0", + "purpose": "一句话说明这个模块干什么", + + "human_help": { + "title": "面向人类的标题", + "description": ["说明段落数组"], + "usage": ["使用步骤数组"], + "troubleshooting": ["常见问题数组"] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/xxx", "returns": "返回值说明"} + ], + "dependencies": ["依赖的服务或文件"], + "constraints": ["AI 必须遵守的约束"], + "must_not": ["AI 绝对不能做的事"], + "tests": [{"id": "T1", "name": "测试用例名"}], + "related_files": ["实现文件路径"] + } +} +``` + +### 当前模块清单 + +| 模块 | spec 路径 | 说明 | 状态 | +|------|----------|------|------| +| portfolio | `specs/portfolio.json` | 持仓数据 + 总览 | ✅ | +| watchlist | `specs/watchlist.json` | 自选股管理 | ✅ | +| decisions | `specs/decisions.json` | 策略决策库 | ✅ | +| market | `specs/market.json` | 市场观察数据 | ✅ | +| signals | `specs/signals.json` | 信号 + 全市场扫描 | ✅ | +| scanner | `specs/scanner.json` | 全市场选股机制 | ✅ | +| evaluation | `specs/evaluation.json` | 策略评估 | ✅ | +| prompts | `specs/prompts.json` | 提示词版本管理 | ✅ | +| reports | `specs/reports.json` | 分析报告管理 | ✅ | +| dashboard | `specs/dashboard.json` | Dashboard 自身 | ✅ | +| health | `specs/health.json` | 健康监控管线 | ✅ | +| xmpp_monitor | `specs/xmpp_monitor.json` | XMPP 通信可观测性 | ✅ | +| price_monitor | `specs/price_monitor.json` | 价格监控 cron | 📋 | +| strategy_lifecycle | `specs/strategy_lifecycle.json` | 策略生命周期 | 📋 | + +> 状态: ✅ = spec 已完成 | 📋 = 待编写 + +--- + +## 二、验证闭环 + +``` +┌────────────┐ ┌──────────┐ ┌──────────┐ +│ G: 规范体系 │────→│ K: 测试 │────→│ F: 健康 │ +│ 定义期望 │ │ 验证实现 │ │ 持续监控 │ +└────────────┘ └──────────┘ └──────────┘ + ↑ ↑ │ + └────────────────┼────────────────┘ + │ + ┌────────┴────────┐ + │ F 异常 → 触发 K │ + │ K 失败 → 更新 G │ + └─────────────────┘ +``` + +### 核心反馈链路 + +| 方向 | 触发条件 | 动作 | +|------|---------|------| +| G → K | 新增/修改 spec | 对应测试 ID 必须新增/更新 | +| K → F | 测试全部通过 | F Tab 组件标记为已验证 | +| **F → K** | **F Tab 发现异常** | 触发对应测试重跑,确认是服务故障还是测试过期 | +| **F → G** | **F Tab 持续异常但测试通过** | 期望矩阵或 spec 过时,应更新 G 和对应 spec | + +### F — 系统健康度(Dashboard F Tab) + +- **期望矩阵**:应该运行的服务 vs 实际状态 +- **监控数据**:Tier1(5min)/ Tier2(日报)作为实时状态输入 +- **服务拓扑**:所有服务的健康、端口状态 +- **?§ 覆盖**:F Tab 中的每条服务必须有对应的 ?(human_help)和 §(ai_spec)按钮 + +--- + +## 三、开发流程 + +### 新增功能流程 + +``` +确定模块边界 + │ + ├─ 1. 创建 specs/{module}.json + │ human_help + ai_spec + │ + ├─ 2. 实现功能代码 + │ 包含 /health 端点 + PID 锁(proc_guard) + │ + ├─ 3. 注册到系统 + │ - 端口注册 + │ - 添加到期望矩阵(F Tab 自动检测) + │ + ├─ 4. 编写测试 + │ - ai_spec.tests 添加对应测试标识 + │ + ├─ 5. 同步更新 Spec + │ - 将 specs/{module}.json 更新为与实际实现一致 + │ + └─ 6. 提交 → 部署 → 验证 +``` + +### 修改已有功能流程 + +``` +识别要修改的模块(查看模块清单确定 module 名) + │ + ├─ 1. 读 specs/{module}.json + │ 重点读 ai_spec:apis / constraints / dependencies / must_not + │ + ├─ 2. 确认理解 + │ - 如果 spec 描述与代码实际行为不一致,优先怀疑 spec 过期 + │ + ├─ 3. 修改功能代码 + │ 只改动需求直接涉及的部分,不顺手优化无关代码 + │ + ├─ 4. 同步更新 Spec + │ + └─ 5. 提交 → 部署 → 验证 +``` + +### Git 操作规范 + +| # | 规则 | 说明 | +|---|------|------| +| 1 | 开工前必 pull | `git pull --rebase` | +| 2 | 改完即 commit | 一个逻辑单元一次提交。禁止含密钥 | +| 3 | 推前必拉 + 配代理 | push 前 `git pull --rebase`。远程操作前配 `:15000` 代理 | +| 4 | trunk-based | 日常在 main。仅长周期大改开分支 | + +### 已有编码规范 + +MoFin 已有的编码规范见 `docs/DEVELOPMENT_STANDARDS.md`,包含: +- 代码结构(mo_models → mo_data → mofin_db 三层) +- 数据规范(币种、汇率、数据源) +- DB 规范(表设计、迁移) +- LLM Prompt 规范 +- Cron 规范(独立运行、幂等性) +- 测试要求(`run_all_tests.py`) + +以上规范与本文件互补,不冲突。本文件侧重"先 spec 后代码"和"通过 Dashboard 保证可见性"。 + +--- + +## 四、部署环境 + +| 项目 | 值 | +|------|-----| +| **生产环境** | Linux 192.168.1.246 | +| **代码目录** | `/home/hmo/MoFin/` | +| **数据库** | `/home/hmo/web-dashboard/data/mofin.db`(SQLite) | +| **Flask API** | `server.py` → `:8899`(含 Dashboard) | +| **Python** | 系统 Python 3 | + +--- + +## 五、文档索引 + +| 文档 | 用途 | +|------|------| +| `docs/dev-spec.md` | 本文件 — 开发规范(含五条红线) | +| `docs/DEVELOPMENT_STANDARDS.md` | 编码规范(已有) | +| `SYSTEM_ARCHITECTURE.md` | 系统架构(已有) | +| `docs/cron-catalog.md` | Cron 任务清单(已有) | +| `docs/DEPLOY.md` | 部署指南 | +| `docs/QUICKSTART.md` | 快速操作 | +| `docs/DASHBOARD.md` | Dashboard API 参考 | +| `docs/HEALTH-PIPELINE.md` | 健康管线文档 | +| `docs/learned.md` | 经验教训记录 | +| `docs/decisions/` | 架构决策日志 | diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md new file mode 100644 index 00000000..cfb83a0e --- /dev/null +++ b/docs/DASHBOARD.md @@ -0,0 +1,105 @@ +# MoFin — Dashboard API 参考 + +> 版本: v1.0 | 端口: 5804 | 入口: http://192.168.1.246:5805 + +--- + +## Tab 结构 + +``` +MoFin Dashboard +├── Services — 服务状态总览(MoFin API / Dashboard / 知微 Gateway / ejabberd / DB) +└── 开发原则 + ├── G 规范 — 开发规范 + Spec 文档 + └── F 健康 — 系统健康监控(Tier1/Tier2) +``` + +--- + +## API 端点清单 + +### 服务监控 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/services` | 所有注册服务状态(含健康检查结果) | +| GET | `/api/expected` | 期望状态矩阵(含实际状态对比) | +| GET | `/api/monitor` | 聚合监控数据(tasks + Tier1 + Tier2) | +| GET | `/api/health` | Dashboard 自身健康检查 | + +### 知识管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/module-spec/` | 读取 `specs/{module}.json`(?§ 按钮后端) | + +### 响应格式 + +**GET /api/services**: +```json +{ + "services": [ + { + "name": "mofin_api", + "label": "MoFin API", + "port": 8899, + "type": "http", + "layer": "核心服务", + "critical": true, + "health": {"ok": true}, + "detail": "HTTP 200" + } + ], + "summary": {"ok": 5, "total": 5} +} +``` + +**GET /api/monitor**: +```json +{ + "tasks": [ + {"name": "agents-health-check", "status": "cron_ok"}, + {"name": "agents-daily-health", "status": "not_deployed"}, + {"name": "dashboard", "status": "running", "detail": "services: 5/5"} + ], + "tier1": {"summary": {"ok": 5, "total": 5}, "services": [...]}, + "tier2": {"summary": {"ok": 0, "total": 0}, "services": []}, + "generated_at": "2026-07-19 10:00:00" +} +``` + +--- + +## 前端架构 + +- 纯 HTML/CSS/JS(无框架) +- 深色主题(GitHub Dark 风格) +- 5 秒自动轮询(Services Tab) +- ?§ Spec 系统(human_help + ai_spec) + +### Spec 系统(?§ 按钮) + +每个有 spec 的模块在 UI 上显示两个按钮: +- `?` → 读取 `human_help` → 人类可读的帮助文档 +- `§` → 读取 `ai_spec` → AI 可用的接口/约束/依赖 + +**Spec 文件位置**: `specs/{module}.json` +**API 端点**: `GET /api/module-spec/{module}` + +--- + +## 数据流 + +``` +crontab (每 5 分钟) + └── agents_health_check.py → last_health_check.json + +Dashboard (:5805) + ├── /api/services ← 实时 TCP/HTTP 检测 + ├── /api/monitor ← 聚合读取 health check JSON + └── /api/module-spec/ ← 读取 specs/ + +前端 (dashboard.html) + ├── 5s 轮询 /api/services + └── 按需请求 /api/monitor(F Tab 打开时) +``` diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 00000000..fdd48113 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,99 @@ +# MoFin — 部署指南 + +> 版本: v1.0 | 部署目标: Linux 192.168.1.246 + +--- + +## 部署概览 + +| 组件 | 守护方式 | 端口 | 说明 | +|------|---------|------|------| +| **server.py** | systemd `mofin-api` | 8899 | 持仓情报 API(已有,不动) | +| **dashboard.py** | systemd `mofin-dashboard` | 5804 | 管理门户(新增) | +| **health_check** | crontab `*/5 * * * *` | — | Tier1 健康检查(新增) | + +--- + +## 1. Dashboard 部署 + +### 1.1 创建 systemd 服务 + +```bash +sudo tee /etc/systemd/system/mofin-dashboard.service << 'EOF' +[Unit] +Description=MoFin Dashboard +After=network.target + +[Service] +Type=simple +User=hmo +WorkingDirectory=/home/hmo/MoFin +Environment=MOFIN_ROOT=/home/hmo/MoFin +ExecStart=/usr/bin/python3 /home/hmo/MoFin/dashboard.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF +``` + +### 1.2 启动 + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now mofin-dashboard +sudo systemctl status mofin-dashboard +``` + +### 1.3 验证 + +```bash +curl http://127.0.0.1:5805/api/health +curl http://127.0.0.1:5805/api/services | python3 -m json.tool +# 浏览器访问: http://192.168.1.246:5805 +``` + +--- + +## 2. 健康管线部署 + +```bash +# 添加到 crontab +(crontab -l 2>/dev/null; echo '# MoFin health pipeline'; echo '*/5 * * * * cd /home/hmo/MoFin && /usr/bin/python3 agents_health_check.py >> gateway/logs/health_check_cron.log 2>&1') | crontab - + +# 验证 +crontab -l | grep health +``` + +--- + +## 3. 防火墙 + +```bash +sudo ufw status | grep -E '8899|5804' +# 如果未开放: +# sudo ufw allow 5804/tcp +``` + +--- + +## 4. 部署后验证清单 + +- [ ] `curl http://127.0.0.1:5805/api/health` → `{"status":"ok"}` +- [ ] `curl http://127.0.0.1:5805/api/services` → 返回 5 个服务状态 +- [ ] 浏览器打开 `http://192.168.1.246:5805` → Services Tab 显示服务状态 +- [ ] Dashboard F 健康 Tab → 定时任务状态显示正常 +- [ ] 点击各模块 ?§ 按钮 → 弹出 spec 帮助内容 +- [ ] `python3 agents_health_check.py` → 无输出(全正常) + +--- + +## 5. 故障恢复 + +| 问题 | 命令 | +|------|------| +| Dashboard 挂了 | `ssh hmo@246 'sudo systemctl restart mofin-dashboard'` | +| 健康检查不运行 | `ssh hmo@246 'crontab -l \| grep health'` | +| MoFin API 挂了 | `ssh hmo@246 'sudo systemctl restart mofin-api'` | +| 知微 Gateway 挂了 | `ssh hmo@246 'sudo systemctl restart hermes-gateway@zhiwei'` | diff --git a/docs/HEALTH-PIPELINE.md b/docs/HEALTH-PIPELINE.md new file mode 100644 index 00000000..934d2323 --- /dev/null +++ b/docs/HEALTH-PIPELINE.md @@ -0,0 +1,111 @@ +# MoFin — 健康监控管线 + +> 版本: v1.0 | 部署目标: Linux 246 + +--- + +## 概述 + +两层监控,通过 crontab 调度,聚合到 Dashboard F Tab。 + +``` + ┌─────────────────────────────────┐ + │ Dashboard F Tab │ + │ /api/monitor 聚合展示 │ + └──────────┬──────────────────────┘ + │ 读取报告文件 + ┌──────────┴──────────┐ + │ │ + ┌────▼─────┐ ┌────▼─────┐ + │ Tier 1 │ │ Tier 2 │ + │ 每 5 分钟 │ │ 每天 8:00│ + └──────────┘ └──────────┘ + │ │ + agents_health_check agents_daily_health + │ (规划中) + ┌────▼─────┐ + │ TODO 文件 │ + │ .jsonl │ + └──────────┘ +``` + +--- + +## Tier 1: 快速健康检查(每 5 分钟) + +**脚本**: `agents_health_check.py` +**调度**: `crontab: */5 * * * *` + +**检查内容**: +- 5 个服务:MoFin API (:8899) / Dashboard (:5804) / 知微 Gateway (:8643) / ejabberd (:5222) / MoFin DB +- 检查方式:socket 端口 + HTTP /health + SQLite connect +- 全正常时静默(不输出、不写日志) + +**异常处理**: +- 写入 `gateway/temp/health_todos.jsonl` +- 每条 TODO 包含:服务名、失败原因、时间戳 +- 写入 `gateway/temp/last_health_check.json` 供 Dashboard 读取 + +**日志**: `gateway/logs/health_check.log` +**报告**: `gateway/temp/last_health_check.json` + +--- + +## Tier 2: 每日全面检查(规划中) + +**计划脚本**: `agents_daily_health.py` +**计划调度**: `crontab: 0 8 * * * 1-5`(交易日 8:00) + +**计划检查内容**: +- 在 Tier 1 基础上增加: + - 磁盘空间检查(阈值 10G 警告 / 2G 严重) + - crontab 存活检查(验证关键定时任务) + - MoFin DB 大小和新鲜度检查 + - 生成结构化 JSON 报告 + +**注意**: MoFin 已有 `system_health_check.py`(每日 9:00)和 `morning_health_check.py`(交易日 8:00,8层48项),Tier2 将与现有检查互补,不重复。 + +--- + +## Dashboard 集成 + +### /api/monitor 端点 + +聚合展示两层数据: + +```json +{ + "tasks": [ + {"name": "agents-health-check", "status": "cron_ok"}, + {"name": "agents-daily-health", "status": "not_deployed"}, + {"name": "dashboard", "status": "running"} + ], + "tier1": { "services": [...], "summary": {"ok": 5, "total": 5} }, + "tier2": { "services": [...], "summary": {"ok": 0, "total": 0} } +} +``` + +### F Tab 展示 + +- 系统概览(Tier1 通过率) +- 定时任务状态(绿色=正常,黄色=未部署,红色=异常) +- Tier1 服务详情 + +--- + +## 如何新增监控 + +1. **添加服务到 Tier 1** — 编辑 `agents_health_check.py` 的 `SERVICES` 列表 +2. **更新 Dashboard** — 在 `dashboard.py` 的 `SERVICES` 中同步添加 +3. **写 Spec** — 在 `specs/` 创建或更新对应模块的 JSON + +--- + +## 故障排查 + +| 现象 | 检查 | +|------|------| +| F Tab 无数据 | `cat ~/MoFin/gateway/temp/last_health_check.json` 确认文件存在 | +| Tier1 任务显示"未部署" | `crontab -l \| grep health` 确认 crontab 条目 | +| TODO 堆积 | 手动检查失败服务的实际状态 | +| Dashboard 不显示新服务 | 确认 dashboard.py 的 SERVICES 列表和 health_check 同步 | diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 00000000..30f48994 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,86 @@ +# MoFin — 快速操作手册 + +> 生产环境: Linux 192.168.1.246 | 端口: API 8899 / Dashboard 5804 + +--- + +## 日常检查 + +```bash +# 打开 Dashboard 看全局 +http://192.168.1.246:5805 + +# 命令行快速状态 +ssh hmo@192.168.1.246 "curl -s http://127.0.0.1:5805/api/services | python3 -m json.tool | head -20" +``` + +--- + +## Dashboard + +```bash +# 查看状态 +ssh hmo@192.168.1.246 "sudo systemctl status mofin-dashboard" + +# 重启 +ssh hmo@192.168.1.246 "sudo systemctl restart mofin-dashboard" + +# 查看日志 +ssh hmo@192.168.1.246 "tail -50 ~/MoFin/gateway/logs/dashboard.log" +``` + +--- + +## MoFin API + +```bash +# 查看状态 +ssh hmo@192.168.1.246 "sudo systemctl status mofin-api" + +# 重启 +ssh hmo@192.168.1.246 "sudo systemctl restart mofin-api" + +# 测试 API +curl http://192.168.1.246:8899/api/portfolio +``` + +--- + +## 健康检查 + +```bash +# 查看定时任务 +ssh hmo@192.168.1.246 "crontab -l | grep health" + +# 手动运行(无输出 = 全正常) +ssh hmo@192.168.1.246 "cd ~/MoFin && python3 agents_health_check.py" + +# 查看最近报告 +ssh hmo@192.168.1.246 "cat ~/MoFin/gateway/temp/last_health_check.json | python3 -m json.tool" +``` + +--- + +## 部署更新 + +```bash +# 1. 拉代码 +ssh hmo@192.168.1.246 "cd ~/MoFin && git pull --rebase" + +# 2. 重启受影响的服务 +ssh hmo@192.168.1.246 "sudo systemctl restart mofin-dashboard" + +# 3. 验证 +ssh hmo@192.168.1.246 "curl -s http://127.0.0.1:5805/api/health" +``` + +--- + +## 常见问题 + +| 现象 | 操作 | +|------|------| +| Dashboard 不响应 | `ssh hmo@246 sudo systemctl restart mofin-dashboard` | +| F Tab 定时任务显示"未部署" | `ssh hmo@246 crontab -l \| grep health` 确认 | +| MoFin API 不响应 | `ssh hmo@246 sudo systemctl restart mofin-api` | +| 数据库查询失败 | `ssh hmo@246 'ls -la /home/hmo/web-dashboard/data/mofin.db'` | diff --git a/docs/dev-spec.md b/docs/dev-spec.md new file mode 100644 index 00000000..63ae1115 --- /dev/null +++ b/docs/dev-spec.md @@ -0,0 +1,214 @@ +# MoFin 开发规范 + +> 版本: v1.0 | 更新: 2026-07-19 | 基于 AgentsMeeting 样板重构 +> +> 📋 样板参考: [AgentsMeeting TEMPLATE-GUIDE.md](../AgentsMeeting/docs/TEMPLATE-GUIDE.md) + +--- + +## 五条红线 + +1. **先读/写 Spec,再写代码** — 新增功能先写 spec 再实现;修改已有功能先读对应 spec 了解架构和约束再动手。没有 spec 的模块在 Dashboard 不可见,视为未完成 +2. **部署必验** — 部署后不打开 Dashboard F Tab 验证 = 部署未完成 +3. **不可见即不存在** — 组件不在 Dashboard 中显示 = 等于没部署。离线不告警 = 监控缺陷 +4. **实现后同步 Spec** — 每轮开发完毕后,必须将 `specs/{module}.json` 更新为与实际实现一致的状态。文档过期 = 等于没写 +5. **部署目标即验收标准** — 所有代码必须以部署目标环境(Linux 246)为基准编写和测试。禁止使用 Windows 专属 API(`tasklist`、`netstat`、`schtasks`、`wmic`)在 246 部署的代码中 + +--- + +## 一、双轨同源规范体系 + +每新增/修改一个独立功能模块,必须先写 `specs/{module}.json`。 +一个来源同时产出两套文档: + +``` +specs/{module}.json +├── human_help → ? 按钮(人类看说明/排错) +└── ai_spec → § 按钮(AI 看接口/约束/依赖) +``` + +### 什么算一个模块 + +满足以下任一条件即视为独立模块,必须写 spec: + +- 暴露独立的 HTTP API 端点 +- 在 Dashboard 上有独立 UI 面板(`?` + `§` 按钮) +- 有独立的配置文件 / 数据文件 +- 可独立部署(如定时任务、数据采集脚本) + +### Spec 字段标准 + +```json +{ + "module": "模块名(与 Dashboard 引用名一致)", + "version": "1.0", + "purpose": "一句话说明这个模块干什么", + + "human_help": { + "title": "面向人类的标题", + "description": ["说明段落数组"], + "usage": ["使用步骤数组"], + "troubleshooting": ["常见问题数组"] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/xxx", "returns": "返回值说明"} + ], + "dependencies": ["依赖的服务或文件"], + "constraints": ["AI 必须遵守的约束"], + "must_not": ["AI 绝对不能做的事"], + "tests": [{"id": "T1", "name": "测试用例名"}], + "related_files": ["实现文件路径"] + } +} +``` + +### 当前模块清单 + +| 模块 | spec 路径 | 说明 | 状态 | +|------|----------|------|------| +| portfolio | `specs/portfolio.json` | 持仓数据 + 总览 | ✅ | +| watchlist | `specs/watchlist.json` | 自选股管理 | ✅ | +| decisions | `specs/decisions.json` | 策略决策库 | ✅ | +| market | `specs/market.json` | 市场观察数据 | ✅ | +| signals | `specs/signals.json` | 信号 + 小果扫描 | ✅ | +| evaluation | `specs/evaluation.json` | 策略评估 | ✅ | +| dashboard | `specs/dashboard.json` | Dashboard 自身 | ✅ | +| health | `specs/health.json` | 健康监控管线 | ✅ | +| price_monitor | `specs/price_monitor.json` | 价格监控 cron | 📋 | +| strategy_lifecycle | `specs/strategy_lifecycle.json` | 策略生命周期 | 📋 | + +> 状态: ✅ = spec 已完成 | 📋 = 待编写 + +--- + +## 二、验证闭环 + +``` +┌────────────┐ ┌──────────┐ ┌──────────┐ +│ G: 规范体系 │────→│ K: 测试 │────→│ F: 健康 │ +│ 定义期望 │ │ 验证实现 │ │ 持续监控 │ +└────────────┘ └──────────┘ └──────────┘ + ↑ ↑ │ + └────────────────┼────────────────┘ + │ + ┌────────┴────────┐ + │ F 异常 → 触发 K │ + │ K 失败 → 更新 G │ + └─────────────────┘ +``` + +### 核心反馈链路 + +| 方向 | 触发条件 | 动作 | +|------|---------|------| +| G → K | 新增/修改 spec | 对应测试 ID 必须新增/更新 | +| K → F | 测试全部通过 | F Tab 组件标记为已验证 | +| **F → K** | **F Tab 发现异常** | 触发对应测试重跑,确认是服务故障还是测试过期 | +| **F → G** | **F Tab 持续异常但测试通过** | 期望矩阵或 spec 过时,应更新 G 和对应 spec | + +### F — 系统健康度(Dashboard F Tab) + +- **期望矩阵**:应该运行的服务 vs 实际状态 +- **监控数据**:Tier1(5min)/ Tier2(日报)作为实时状态输入 +- **服务拓扑**:所有服务的健康、端口状态 +- **?§ 覆盖**:F Tab 中的每条服务必须有对应的 ?(human_help)和 §(ai_spec)按钮 + +--- + +## 三、开发流程 + +### 新增功能流程 + +``` +确定模块边界 + │ + ├─ 1. 创建 specs/{module}.json + │ human_help + ai_spec + │ + ├─ 2. 实现功能代码 + │ 包含 /health 端点 + PID 锁(proc_guard) + │ + ├─ 3. 注册到系统 + │ - 端口注册 + │ - 添加到期望矩阵(F Tab 自动检测) + │ + ├─ 4. 编写测试 + │ - ai_spec.tests 添加对应测试标识 + │ + ├─ 5. 同步更新 Spec + │ - 将 specs/{module}.json 更新为与实际实现一致 + │ + └─ 6. 提交 → 部署 → 验证 +``` + +### 修改已有功能流程 + +``` +识别要修改的模块(查看模块清单确定 module 名) + │ + ├─ 1. 读 specs/{module}.json + │ 重点读 ai_spec:apis / constraints / dependencies / must_not + │ + ├─ 2. 确认理解 + │ - 如果 spec 描述与代码实际行为不一致,优先怀疑 spec 过期 + │ + ├─ 3. 修改功能代码 + │ 只改动需求直接涉及的部分,不顺手优化无关代码 + │ + ├─ 4. 同步更新 Spec + │ + └─ 5. 提交 → 部署 → 验证 +``` + +### Git 操作规范 + +| # | 规则 | 说明 | +|---|------|------| +| 1 | 开工前必 pull | `git pull --rebase` | +| 2 | 改完即 commit | 一个逻辑单元一次提交。禁止含密钥 | +| 3 | 推前必拉 + 配代理 | push 前 `git pull --rebase`。远程操作前配 `:15000` 代理 | +| 4 | trunk-based | 日常在 main。仅长周期大改开分支 | + +### 已有编码规范 + +MoFin 已有的编码规范见 `docs/DEVELOPMENT_STANDARDS.md`,包含: +- 代码结构(mo_models → mo_data → mofin_db 三层) +- 数据规范(币种、汇率、数据源) +- DB 规范(表设计、迁移) +- LLM Prompt 规范 +- Cron 规范(独立运行、幂等性) +- 测试要求(`run_all_tests.py`) + +以上规范与本文件互补,不冲突。本文件侧重"先 spec 后代码"和"通过 Dashboard 保证可见性"。 + +--- + +## 四、部署环境 + +| 项目 | 值 | +|------|-----| +| **生产环境** | Linux 192.168.1.246 | +| **代码目录** | `/home/hmo/MoFin/` | +| **数据库** | `/home/hmo/web-dashboard/data/mofin.db`(SQLite) | +| **Flask API** | `server.py` → `:8899` | +| **Dashboard** | `dashboard.py` → `:5804`(新增) | +| **Python** | 系统 Python 3 | + +--- + +## 五、文档索引 + +| 文档 | 用途 | +|------|------| +| `docs/dev-spec.md` | 本文件 — 开发规范(含五条红线) | +| `docs/DEVELOPMENT_STANDARDS.md` | 编码规范(已有) | +| `SYSTEM_ARCHITECTURE.md` | 系统架构(已有) | +| `docs/cron-catalog.md` | Cron 任务清单(已有) | +| `docs/DEPLOY.md` | 部署指南 | +| `docs/QUICKSTART.md` | 快速操作 | +| `docs/DASHBOARD.md` | Dashboard API 参考 | +| `docs/HEALTH-PIPELINE.md` | 健康管线文档 | +| `docs/learned.md` | 经验教训记录 | +| `docs/decisions/` | 架构决策日志 | diff --git a/docs/learned.md b/docs/learned.md new file mode 100644 index 00000000..7686d744 --- /dev/null +++ b/docs/learned.md @@ -0,0 +1,12 @@ +# 经验教训记录 + +每次被纠正后追加一条记录。每次新任务前先扫一遍本文档。 + +## 格式 +- [YYYY-MM-DD] 问题: xxx | 根因: xxx | 正确做法: xxx + +--- + +## 记录 + +- [2026-07-19] 问题: MoFin 缺少 spec 体系和 Dashboard,功能模块不可见、不可监控 | 根因: 项目早期未引入"不可见即不存在"原则 | 正确做法: 参照 AgentsMeeting 样板重构,先建立 dev-spec.md + spec 体系 + Dashboard,再逐步迁移 diff --git a/gateway/logs/dashboard.log b/gateway/logs/dashboard.log new file mode 100644 index 00000000..363042bc --- /dev/null +++ b/gateway/logs/dashboard.log @@ -0,0 +1,52 @@ +2026-07-19 10:53:45,896 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:53:45,896 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:53:45,896 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:53:45,896 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:53:56,269 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:53:56,269 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:53:56,269 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:53:56,269 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:06,498 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:54:06,498 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:06,498 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:06,498 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:16,745 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:54:16,745 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:16,745 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:16,745 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:26,989 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:54:26,989 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:26,989 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:26,989 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:37,246 [dashboard] MoFin Dashboard starting on port 5805 +2026-07-19 10:54:37,246 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:37,246 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:37,246 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:47,458 [dashboard] MoFin Dashboard starting on port 5807 +2026-07-19 10:54:47,458 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:47,458 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:47,459 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:47,464 [dashboard] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5807 + * Running on http://192.168.1.246:5807 +2026-07-19 10:54:47,464 [dashboard] Press CTRL+C to quit +2026-07-19 10:54:55,309 [dashboard] MoFin Dashboard starting on port 5807 +2026-07-19 10:54:55,309 [dashboard] Specs dir: /home/hmo/MoFin/specs +2026-07-19 10:54:55,309 [dashboard] Templates dir: /home/hmo/MoFin/templates +2026-07-19 10:54:55,309 [dashboard] proc_guard not available, skipping PID lock +2026-07-19 10:54:55,314 [dashboard] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5807 + * Running on http://192.168.1.246:5807 +2026-07-19 10:54:55,314 [dashboard] Press CTRL+C to quit +2026-07-19 10:55:13,917 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:13] "GET /api/health HTTP/1.1" 200 - +2026-07-19 10:55:13,981 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:13] "GET /api/health HTTP/1.1" 200 - +2026-07-19 10:55:13,987 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:13] "GET /api/services HTTP/1.1" 200 - +2026-07-19 10:55:31,857 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:31] "GET /api/health HTTP/1.1" 200 - +2026-07-19 10:55:40,596 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:40] "GET /api/health HTTP/1.1" 200 - +2026-07-19 10:55:40,602 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:40] "GET /api/monitor HTTP/1.1" 200 - +2026-07-19 10:55:40,639 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:55:40] "GET /api/module-spec/health HTTP/1.1" 200 - +2026-07-19 10:56:17,871 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:56:17] "GET / HTTP/1.1" 200 - +2026-07-19 10:56:17,898 [dashboard] 127.0.0.1 - - [19/Jul/2026 10:56:17] "GET / HTTP/1.1" 200 - +2026-07-19 11:00:01,611 [dashboard] 127.0.0.1 - - [19/Jul/2026 11:00:01] "GET /api/health HTTP/1.1" 200 - diff --git a/gateway/logs/health_check.log b/gateway/logs/health_check.log new file mode 100644 index 00000000..5de28a2e --- /dev/null +++ b/gateway/logs/health_check.log @@ -0,0 +1,90 @@ +[2026-07-19 14:30:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 14:45:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 14:50:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:10:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:16:56] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:20:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:35:01] ISSUES: 1 failed + - 知微 Gateway: timed out +[2026-07-19 15:40:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:45:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 15:50:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:00:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:05:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:20:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:30:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:35:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:40:02] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 16:55:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:00:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:05:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:10:02] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:15:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:20:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:25:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:45:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 17:55:02] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:00:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:05:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:15:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:15:32] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:20:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:22:09] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:25:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:30:02] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:35:02] ISSUES: 1 failed + - 知微 Gateway: timed out +[2026-07-19 18:40:01] ISSUES: 1 failed + - 知微 Gateway: timed out +[2026-07-19 18:45:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:50:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 18:55:02] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 19:00:01] ISSUES: 1 failed + - 知微 Gateway: timed out +[2026-07-19 19:05:02] ISSUES: 1 failed + - 知微 Gateway: timed out +[2026-07-19 19:10:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 19:30:01] ISSUES: 1 failed + - 知微 Gateway: +[2026-07-19 19:50:01] AUTO-HEAL actions: + {"action": "check_keys", "best_key": "key6", "current_provider": "ocg-key6", "best_provider": "ocg-key6", "rolling_pct": 38, "weekly_pct": 28, "issues": []} + {"action": "restart_hermes_gateway", "target": "position-analyst", "success": true, "detail": "restart triggered (async)"} +[2026-07-19 20:40:01] Gateway DOWN → systemctl restart triggered (async, cooldown set) +[2026-07-19 20:40:01] ISSUES: 1 failed + - 知微 Gateway: timed out diff --git a/gateway/logs/health_check_cron.log b/gateway/logs/health_check_cron.log new file mode 100644 index 00000000..2953d755 --- /dev/null +++ b/gateway/logs/health_check_cron.log @@ -0,0 +1,102 @@ +[14:30] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[14:45] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[14:50] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[15:10] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +Traceback (most recent call last): + File "/home/hmo/MoFin/agents_health_check.py", line 143, in + run() + File "/home/hmo/MoFin/agents_health_check.py", line 110, in run + _collect_xmpp_health(now) + ^^^^^^^^^^^^^^^^^^^^ +NameError: name '_collect_xmpp_health' is not defined +[15:20] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[15:35] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out +[15:40] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[15:45] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[15:50] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:00] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:05] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:20] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:30] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:35] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:40] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[16:55] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:00] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:05] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:10] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:15] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:20] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:25] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:45] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[17:55] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:00] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:05] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:15] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:20] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:25] Auto-heal: Gateway DOWN → restarted +[18:25] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:30] Auto-heal: Gateway DOWN → restarted +[18:30] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:35] Auto-heal: Gateway DOWN → restarted +[18:35] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out +[18:40] Auto-heal: Gateway DOWN → restarted +[18:40] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out +[18:45] Auto-heal: Gateway DOWN → restarted +[18:45] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:50] Auto-heal: Gateway DOWN → restarted +[18:50] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[18:55] Auto-heal: Gateway DOWN → restarted +[18:55] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[19:00] Auto-heal: Gateway DOWN → restarted +[19:00] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out +[19:05] Auto-heal: Gateway DOWN → restarted +[19:05] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out +[19:10] Auto-heal: Gateway DOWN → restarted +[19:10] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[19:30] Auto-heal: Gateway DOWN → restarted +[19:30] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — +[19:50] Auto-heal: 2 action(s) — degraded + → check_keys: success=? + → restart_hermes_gateway: success=True +[20:40] Gateway DOWN → systemctl restart triggered +[20:40] Health check: 1/4 services failed + FAIL: 知微 Gateway (zhiwei_gateway) — timed out diff --git a/gateway/logs/last_restart.txt b/gateway/logs/last_restart.txt new file mode 100644 index 00000000..9ac47169 --- /dev/null +++ b/gateway/logs/last_restart.txt @@ -0,0 +1 @@ +1784464840.6959908 \ No newline at end of file diff --git a/gateway/logs/xmpp_health_log.jsonl b/gateway/logs/xmpp_health_log.jsonl new file mode 100644 index 00000000..81de9f9c --- /dev/null +++ b/gateway/logs/xmpp_health_log.jsonl @@ -0,0 +1,70 @@ +{"timestamp": "2026-07-19 15:16:56", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10509, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 60188, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2489884, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:20:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10509, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 60188, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2489884, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:25:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10200, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 59879, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2489575, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:30:02", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9887, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 59566, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2489262, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:35:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9576, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 59255, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2488951, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:40:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9262, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 58941, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2488637, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:45:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8952, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 58631, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2488327, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:50:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8643, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 58322, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2488018, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 15:55:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8333, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 58012, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2487708, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:00:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8022, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 57701, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2487397, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:05:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 7710, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 57389, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2487085, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:10:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 7397, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 57076, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2486772, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:15:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 7085, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 56764, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2486460, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:20:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 6768, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 56447, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2486143, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:25:01", "status": "degraded", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 6452, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 56131, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2485827, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:30:01", "status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 6142, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 55821, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2485517, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:35:01", "status": "ok", "last_message_age_sec": 59, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 5832, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 55511, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2485207, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:40:02", "status": "ok", "last_message_age_sec": 360, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 5832, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 55511, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2485207, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:45:01", "status": "degraded", "last_message_age_sec": 658, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 5516, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 55195, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2484891, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:50:01", "status": "degraded", "last_message_age_sec": 959, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 5206, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 54885, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2484581, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 16:55:01", "status": "critical", "last_message_age_sec": 1259, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 4897, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 54576, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2484272, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:00:01", "status": "critical", "last_message_age_sec": 1559, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 4589, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 54268, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2483964, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:05:01", "status": "critical", "last_message_age_sec": 1859, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 4280, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 53959, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2483655, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:10:02", "status": "critical", "last_message_age_sec": 2159, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 3970, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 53649, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2483345, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:15:01", "status": "critical", "last_message_age_sec": 2459, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 3659, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 53338, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2483034, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:20:01", "status": "critical", "last_message_age_sec": 2760, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 3350, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 53029, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2482725, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:25:01", "status": "degraded", "last_message_age_sec": 3059, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 3040, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 52719, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2482415, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:30:01", "status": "critical", "last_message_age_sec": 3360, "error_rate_1h": 0.0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 2731, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 52410, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2482106, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:35:01", "status": "degraded", "last_message_age_sec": 3659, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 2422, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 52101, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2481797, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:40:01", "status": "degraded", "last_message_age_sec": 3959, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 2104, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 51783, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2481479, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:45:01", "status": "critical", "last_message_age_sec": 4259, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 1795, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 51474, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2481170, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:50:01", "status": "degraded", "last_message_age_sec": 4561, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 1484, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 51163, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2480859, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 17:55:02", "status": "critical", "last_message_age_sec": 4860, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 1175, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 50854, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2480550, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:00:01", "status": "critical", "last_message_age_sec": 5159, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 866, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 50545, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2480241, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:05:01", "status": "critical", "last_message_age_sec": 5460, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 558, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 50237, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479933, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:10:01", "status": "degraded", "last_message_age_sec": 5759, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 251, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 49930, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479626, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:15:01", "status": "degraded", "last_message_age_sec": 6058, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 18000, "status": "ok", "usage_percent": 0}, "weekly": {"reset_in_sec": 49621, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479317, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:15:32", "status": "degraded", "last_message_age_sec": 6089, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 18000, "status": "ok", "usage_percent": 0}, "weekly": {"reset_in_sec": 49621, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479317, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:20:01", "status": "degraded", "last_message_age_sec": 6359, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 17827, "status": "ok", "usage_percent": 0}, "weekly": {"reset_in_sec": 49312, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479008, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:22:09", "status": "degraded", "last_message_age_sec": 6487, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 17827, "status": "ok", "usage_percent": 0}, "weekly": {"reset_in_sec": 49312, "status": "ok", "usage_percent": 13}, "monthly": {"reset_in_sec": 2479008, "status": "ok", "usage_percent": 6}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:25:01", "status": "degraded", "last_message_age_sec": 6659, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 17520, "status": "ok", "usage_percent": 1}, "weekly": {"reset_in_sec": 49005, "status": "ok", "usage_percent": 14}, "monthly": {"reset_in_sec": 2478701, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:30:02", "status": "degraded", "last_message_age_sec": 6959, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 17212, "status": "ok", "usage_percent": 1}, "weekly": {"reset_in_sec": 48697, "status": "ok", "usage_percent": 14}, "monthly": {"reset_in_sec": 2478393, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:35:02", "status": "degraded", "last_message_age_sec": 7263, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 16902, "status": "ok", "usage_percent": 3}, "weekly": {"reset_in_sec": 48387, "status": "ok", "usage_percent": 14}, "monthly": {"reset_in_sec": 2478083, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:40:01", "status": "degraded", "last_message_age_sec": 7562, "error_rate_1h": 0, "bot_activity": {"inbound": 1, "outbound": 0, "errors": 1, "last_error": "2026-07-19 18:12:21,700 ERROR call_hermes error: RemoteDisconnected: Remote end closed connection without response", "last_inbound": "2026-07-19 18:12:03,842 INFO 📩 收到: from=hmo@yoin.fun/gajim.02ZMOMIM type=chat body=> 其实我的现金和持仓都发生了变化。但是之前给你发截图,你好像并没有回应", "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 16594, "status": "ok", "usage_percent": 3}, "weekly": {"reset_in_sec": 48079, "status": "ok", "usage_percent": 14}, "monthly": {"reset_in_sec": 2477775, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:45:01", "status": "critical", "last_message_age_sec": 7859, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 16286, "status": "ok", "usage_percent": 3}, "weekly": {"reset_in_sec": 47771, "status": "ok", "usage_percent": 14}, "monthly": {"reset_in_sec": 2477467, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:50:01", "status": "degraded", "last_message_age_sec": 8159, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 15977, "status": "ok", "usage_percent": 4}, "weekly": {"reset_in_sec": 47462, "status": "ok", "usage_percent": 15}, "monthly": {"reset_in_sec": 2477158, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 18:55:02", "status": "critical", "last_message_age_sec": 8460, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 15665, "status": "ok", "usage_percent": 5}, "weekly": {"reset_in_sec": 47150, "status": "ok", "usage_percent": 15}, "monthly": {"reset_in_sec": 2476846, "status": "ok", "usage_percent": 7}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:00:01", "status": "degraded", "last_message_age_sec": 8762, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": false}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "[Errno 104] Connection reset by peer"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 15357, "status": "ok", "usage_percent": 6}, "weekly": {"reset_in_sec": 46842, "status": "ok", "usage_percent": 16}, "monthly": {"reset_in_sec": 2476538, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:05:02", "status": "degraded", "last_message_age_sec": 9062, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "Remote end closed connection without response"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 15040, "status": "ok", "usage_percent": 7}, "weekly": {"reset_in_sec": 46525, "status": "ok", "usage_percent": 16}, "monthly": {"reset_in_sec": 2476221, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:10:01", "status": "critical", "last_message_age_sec": 9359, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 14730, "status": "ok", "usage_percent": 8}, "weekly": {"reset_in_sec": 46215, "status": "ok", "usage_percent": 17}, "monthly": {"reset_in_sec": 2475911, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:15:01", "status": "degraded", "last_message_age_sec": 9659, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 14419, "status": "ok", "usage_percent": 10}, "weekly": {"reset_in_sec": 45904, "status": "ok", "usage_percent": 17}, "monthly": {"reset_in_sec": 2475600, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:20:02", "status": "degraded", "last_message_age_sec": 9960, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 14103, "status": "ok", "usage_percent": 10}, "weekly": {"reset_in_sec": 45588, "status": "ok", "usage_percent": 17}, "monthly": {"reset_in_sec": 2475284, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:25:01", "status": "degraded", "last_message_age_sec": 10259, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 14103, "status": "ok", "usage_percent": 10}, "weekly": {"reset_in_sec": 45588, "status": "ok", "usage_percent": 17}, "monthly": {"reset_in_sec": 2475284, "status": "ok", "usage_percent": 8}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:30:01", "status": "critical", "last_message_age_sec": 10560, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": false}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 13794, "status": "ok", "usage_percent": 18}, "weekly": {"reset_in_sec": 45279, "status": "ok", "usage_percent": 20}, "monthly": {"reset_in_sec": 2474975, "status": "ok", "usage_percent": 10}, "session_expired": false, "issues": [], "total_keys": 6}} +{"timestamp": "2026-07-19 19:35:01", "status": "degraded", "last_message_age_sec": 10859, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 13487, "status": "ok", "usage_percent": 22}, "weekly": {"reset_in_sec": 44972, "status": "ok", "usage_percent": 22}, "monthly": {"reset_in_sec": 2474668, "status": "ok", "usage_percent": 11}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 19:40:01", "status": "degraded", "last_message_age_sec": 11159, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 13178, "status": "ok", "usage_percent": 26}, "weekly": {"reset_in_sec": 44663, "status": "ok", "usage_percent": 24}, "monthly": {"reset_in_sec": 2474359, "status": "ok", "usage_percent": 12}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 19:45:02", "status": "degraded", "last_message_age_sec": 11459, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "error", "error": "timed out"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 12872, "status": "ok", "usage_percent": 32}, "weekly": {"reset_in_sec": 44357, "status": "ok", "usage_percent": 26}, "monthly": {"reset_in_sec": 2474053, "status": "ok", "usage_percent": 13}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 19:50:01", "status": "critical", "last_message_age_sec": 11759, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 12401, "status": "ok", "usage_percent": 38}, "weekly": {"reset_in_sec": 43886, "status": "ok", "usage_percent": 28}, "monthly": {"reset_in_sec": 2473582, "status": "ok", "usage_percent": 14}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:00:01", "status": "critical", "last_message_age_sec": 12359, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 11775, "status": "ok", "usage_percent": 49}, "weekly": {"reset_in_sec": 43260, "status": "ok", "usage_percent": 33}, "monthly": {"reset_in_sec": 2472956, "status": "ok", "usage_percent": 16}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:05:01", "status": "critical", "last_message_age_sec": 12659, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 11464, "status": "ok", "usage_percent": 60}, "weekly": {"reset_in_sec": 42949, "status": "ok", "usage_percent": 37}, "monthly": {"reset_in_sec": 2472645, "status": "ok", "usage_percent": 18}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:10:02", "status": "critical", "last_message_age_sec": 12960, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 11153, "status": "ok", "usage_percent": 70}, "weekly": {"reset_in_sec": 42638, "status": "ok", "usage_percent": 41}, "monthly": {"reset_in_sec": 2472334, "status": "ok", "usage_percent": 20}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:15:01", "status": "critical", "last_message_age_sec": 13259, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10842, "status": "ok", "usage_percent": 77}, "weekly": {"reset_in_sec": 42327, "status": "ok", "usage_percent": 44}, "monthly": {"reset_in_sec": 2472023, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:20:02", "status": "critical", "last_message_age_sec": 13559, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10532, "status": "ok", "usage_percent": 77}, "weekly": {"reset_in_sec": 42017, "status": "ok", "usage_percent": 44}, "monthly": {"reset_in_sec": 2471713, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:25:01", "status": "critical", "last_message_age_sec": 13859, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 10301, "status": "ok", "usage_percent": 78}, "weekly": {"reset_in_sec": 41786, "status": "ok", "usage_percent": 44}, "monthly": {"reset_in_sec": 2471482, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:30:01", "status": "critical", "last_message_age_sec": 14159, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9991, "status": "ok", "usage_percent": 78}, "weekly": {"reset_in_sec": 41476, "status": "ok", "usage_percent": 44}, "monthly": {"reset_in_sec": 2471172, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:35:01", "status": "critical", "last_message_age_sec": 14459, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9681, "status": "ok", "usage_percent": 78}, "weekly": {"reset_in_sec": 41166, "status": "ok", "usage_percent": 45}, "monthly": {"reset_in_sec": 2470862, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:40:01", "status": "critical", "last_message_age_sec": 14762, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9362, "status": "ok", "usage_percent": 79}, "weekly": {"reset_in_sec": 40847, "status": "ok", "usage_percent": 45}, "monthly": {"reset_in_sec": 2470543, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:45:02", "status": "critical", "last_message_age_sec": 15059, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 9047, "status": "ok", "usage_percent": 79}, "weekly": {"reset_in_sec": 40532, "status": "ok", "usage_percent": 45}, "monthly": {"reset_in_sec": 2470228, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:50:01", "status": "critical", "last_message_age_sec": 15363, "error_rate_1h": 0, "bot_activity": {"error": "journalctl failed"}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8732, "status": "ok", "usage_percent": 79}, "weekly": {"reset_in_sec": 40217, "status": "ok", "usage_percent": 45}, "monthly": {"reset_in_sec": 2469913, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} +{"timestamp": "2026-07-19 20:55:01", "status": "critical", "last_message_age_sec": 15661, "error_rate_1h": 0, "bot_activity": {"inbound": 0, "outbound": 0, "errors": 0, "last_error": null, "last_inbound": null, "last_outbound": null}, "gateways": {"zhiwei": {"port": 8643, "alive": true}, "mohe": {"port": 8642, "alive": true}, "xiaoguo": {"port": 8645, "alive": true}}, "llm_provider": {"status": "ok", "latency": "fast"}, "best_key": {"key_id": "key6", "label": "key6 (ycdennismo@163.com)", "masked": "sk-oF1WG...hCS3", "rolling": {"reset_in_sec": 8423, "status": "ok", "usage_percent": 79}, "weekly": {"reset_in_sec": 39908, "status": "ok", "usage_percent": 45}, "monthly": {"reset_in_sec": 2469604, "status": "ok", "usage_percent": 22}, "session_expired": false, "issues": [], "total_keys": 7}} diff --git a/gateway/logs/xmpp_messages.jsonl b/gateway/logs/xmpp_messages.jsonl new file mode 100644 index 00000000..3c2174c2 --- /dev/null +++ b/gateway/logs/xmpp_messages.jsonl @@ -0,0 +1,3 @@ +{"timestamp": "2026-07-19 16:30:03", "direction": "out", "from": "zhiwei@yoin.fun", "to": "hmo@yoin.fun", "body_preview": "【每日汇总】今日以下cron报告未送达(已拦截): • 脚本输出 99次 无操作信号的报告正常静默,有操作信号的都已送达。", "status": "ok", "error": null, "latency_ms": 1436, "epoch": 1784449803.6249352} +{"timestamp": "2026-07-19 16:32:02", "direction": "out", "from": "zhiwei@yoin.fun", "to": "hmo@yoin.fun", "body_preview": "【每日汇总】今日以下cron报告未送达(已拦截): • 脚本输出 99次 无操作信号的报告正常静默,有操作信号的都已送达。", "status": "ok", "error": null, "latency_ms": 401, "epoch": 1784449922.3636103} +{"timestamp": "2026-07-19 16:34:02", "direction": "out", "from": "zhiwei@yoin.fun", "to": "hmo@yoin.fun", "body_preview": "【每日汇总】今日以下cron报告未送达(已拦截): • 脚本输出 99次 无操作信号的报告正常静默,有操作信号的都已送达。", "status": "ok", "error": null, "latency_ms": 430, "epoch": 1784450042.5418942} diff --git a/gateway/temp/health_todos.jsonl b/gateway/temp/health_todos.jsonl new file mode 100644 index 00000000..8c13a428 --- /dev/null +++ b/gateway/temp/health_todos.jsonl @@ -0,0 +1,43 @@ +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T14:30:01.660636"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T14:45:01.509824"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T14:50:01.299686"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:10:01.664172"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:16:56.095701"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:20:01.499188"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T15:35:01.728475"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:40:01.641145"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:45:01.566000"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T15:50:01.481496"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:00:01.977074"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:05:01.820740"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:20:01.556094"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:30:01.714587"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:35:01.890435"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:40:02.012734"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T16:55:01.801842"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:00:01.449735"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:05:01.397273"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:10:02.184096"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:15:01.441236"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:20:01.917698"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:25:01.507624"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:45:01.480343"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T17:55:02.087320"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:00:01.432964"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:05:01.877289"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:15:01.180290"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:15:32.047339"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:20:01.729488"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:22:09.444652"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:25:01.641678"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:30:02.180070"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T18:35:02.017521"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T18:40:01.770567"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:45:01.936046"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:50:01.336498"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T18:55:02.111939"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T19:00:01.584617"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T19:05:02.034721"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T19:10:01.565114"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "", "timestamp": "2026-07-19T19:30:01.210250"} +{"service": "zhiwei_gateway", "label": "知微 Gateway", "reason": "timed out", "timestamp": "2026-07-19T20:40:01.598513"} diff --git a/gateway/temp/last_health_check.json b/gateway/temp/last_health_check.json new file mode 100644 index 00000000..50c8ee76 --- /dev/null +++ b/gateway/temp/last_health_check.json @@ -0,0 +1,49 @@ +{ + "services": [ + { + "name": "mofin_api", + "label": "MoFin API", + "type": "http", + "port": 8899, + "health": { + "ok": true + }, + "detail": "HTTP 200" + }, + { + "name": "zhiwei_gateway", + "label": "知微 Gateway", + "type": "http", + "port": 8643, + "health": { + "ok": true + }, + "detail": "HTTP 200" + }, + { + "name": "ejabberd", + "label": "ejabberd XMPP", + "type": "tcp", + "port": 5222, + "health": { + "ok": true + }, + "detail": "ok" + }, + { + "name": "mofin_db", + "label": "MoFin 数据库", + "type": "db", + "port": 0, + "health": { + "ok": true + }, + "detail": "ok" + } + ], + "summary": { + "ok": 4, + "total": 4 + }, + "generated_at": "2026-07-19 20:55:01" +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 00000000..3b5131b4 --- /dev/null +++ b/index.html @@ -0,0 +1,1695 @@ + + + + + +MoFin · 莫荷情报 + + + + + + +
+ +
+
+ 📊 +

MoFin

+ 知微 +
+
+ + +
+
+ + +
+ + + + + + + + + + + + 📸 上传 +
+ + +
+ + + + + + + + + + +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/market_scanner.py b/scripts/market_scanner.py index 8f29454e..4d06b7ee 100644 --- a/scripts/market_scanner.py +++ b/scripts/market_scanner.py @@ -1,194 +1,205 @@ -#!/usr/bin/env python3 -"""market_scanner.py — 全市场异动扫描(替代小果扫描线) - -每15分钟扫描: - 1. 板块轮动检测(从已采集的 sector_snapshots 读) - 2. 热门板块领涨股扫描(从腾讯API批量拉) - 3. 资金流向异常检测(从已有 capital_flow_cache 读) - 4. 输出候选股到 candidates 表 - -不依赖小果LLM,纯数据驱动。 -""" -import sys, json, sqlite3, urllib.request, re, time -from pathlib import Path -from datetime import datetime - -DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") -CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json") - -UA = "Mozilla/5.0" - -def get_conn(): - return sqlite3.connect(str(DB_PATH)) - -def fetch_qq_batch(symbols): - """腾讯批量行情""" - if not symbols: - return {} - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - proxy = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy) - with opener.open(req, timeout=15) as r: - text = r.read().decode("gbk") - results = {} - for line in text.strip().split("\n"): - if "~" not in line: - continue - parts = line.split("~") - if len(parts) < 40: - continue - m = re.search(r'_(\w+)=', parts[0]) - market = m.group(1) if m else "" - code = parts[2] - name = parts[1] - price = float(parts[3]) if parts[3] else 0 - chg_pct = float(parts[32]) if parts[32] else 0 - high = float(parts[33]) if parts[33] else 0 - low = float(parts[34]) if parts[34] else 0 - volume = int(parts[6]) if parts[6] else 0 - amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 - if price > 0: - results[code] = {"code": code, "name": name, "price": price, - "change_pct": chg_pct, "high": high, "low": low, - "volume": volume, "amount": amount, - "market": "SH" if market == "sh" else "SZ" if market == "sz" else "HK"} - return results - except Exception as e: - print(f"[SCANNER] 腾讯API错误: {e}", file=sys.stderr) - return {} - -def scan_hot_sectors(): - """从DB读热门板块,返回板块名+领涨股""" - conn = get_conn() - latest = conn.execute("SELECT MAX(id) FROM market_snapshots").fetchone()[0] - if not latest: - conn.close() - return [] - sectors = conn.execute(""" - SELECT name, change_pct, lead_stock, lead_stock_code, up_count, down_count - FROM sector_snapshots WHERE snapshot_id=? - ORDER BY change_pct DESC LIMIT 15 - """, (latest,)).fetchall() - conn.close() - return [{"name": s[0], "change": s[1], "lead_stock": s[2], - "lead_code": s[3], "up": s[4], "down": s[5]} for s in sectors if s[1] > 2.0] - -def scan_candidates(): - """主扫描流程""" - print(f"[SCANNER] {datetime.now().strftime('%H:%M')} 开始扫描", flush=True) - - # 1. 热门板块领涨股 - hot = scan_hot_sectors() - print(f" 热门板块(涨幅>2%): {len(hot)}个", flush=True) - - candidates = {} - - # 从热门板块拉领涨股 - lead_codes = [] - for s in hot: - if s.get("lead_code"): - lc = str(s["lead_code"]).strip() - if lc and lc not in candidates: - lead_codes.append(lc) - candidates[lc] = {"source": f"板块:{s['name']}(+{s['change']:.1f}%)", "sector": s["name"]} - - print(f" 领涨股待查: {len(lead_codes)}只", flush=True) - - # 2. 腾讯API批量查行情 - symbols = [] - for c in lead_codes: - if len(c) == 6: - if c.startswith(("5", "6", "9")): - symbols.append(f"sh{c}") - else: - symbols.append(f"sz{c}") - else: - symbols.append(f"hk{c}") - - prices = fetch_qq_batch(symbols) - print(f" 行情返回: {len(prices)}只", flush=True) - - # 3. 评估候选 - new_candidates = [] - for code, info in prices.items(): - if code not in candidates: - continue - src = candidates[code] - price = info["price"] - chg = info["change_pct"] - name = info["name"] - vol = info["volume"] - amt = info["amount"] - - # 条件:涨幅>3%,有量 - if chg < 3.0: - continue - if vol <= 0: - continue - - score = min(10, round(3 + chg * 0.5 + (amt / 1e8 if amt > 0 else 0) * 0.1, 1)) - - entry_low = round(price * 0.95, 2) - entry_high = round(price, 2) - stop_loss = round(price * 0.92, 2) - take_profit = round(price * 1.15, 2) - - candidate = { - "code": code, - "name": name, - "price": price, - "change_pct": chg, - "score": score, - "entry_low": entry_low, - "entry_high": entry_high, - "stop_loss": stop_loss, - "take_profit": take_profit, - "source": src["source"], - "sector": src.get("sector", ""), - "reason": f"热门板块{src['sector']}领涨+{chg:.1f}%" - } - new_candidates.append(candidate) - print(f" ✅ {code} {name} 价{price} (+{chg:.1f}%) 评分{score}", flush=True) - - # 4. 写入candidates表 - if new_candidates: - conn = get_conn() - for c in new_candidates: - conn.execute( - "INSERT OR REPLACE INTO candidates (code, name, price, change_pct, score, " - "entry_low, entry_high, stop_loss, take_profit, source, sector, reason, created_at) " - "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))", - (c["code"], c["name"], c["price"], c["change_pct"], c["score"], - c["entry_low"], c["entry_high"], c["stop_loss"], c["take_profit"], - c["source"], c["sector"], c["reason"]) - ) - conn.commit() - conn.close() - print(f" ✅ 写入{len(new_candidates)}只候选", flush=True) - else: - print(f" ⚪ 无新候选", flush=True) - - # 5. 推送到Dad(只推高分) - high_score = [c for c in new_candidates if c["score"] >= 6] - if high_score: - lines = ["🔍 市场扫描发现潜在机会:"] - for c in high_score[:3]: - lines.append( - f" {c['name']}({c['code']}) 价{c['price']:.2f}(+{c['change_pct']:.1f}%) " - f"评分{c['score']}/10 | {c['reason']}" - ) - msg = "\n".join(lines) - # 推XMPP - try: - payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "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) - print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True) - except Exception as e: - print(f" ⚠️ 推送失败: {e}", file=sys.stderr) - -if __name__ == "__main__": - scan_candidates() +#!/usr/bin/env python3 +"""market_scanner.py — 全市场异动扫描(替代小果扫描线) + +每15分钟扫描: + 1. 板块轮动检测(从已采集的 sector_snapshots 读) + 2. 热门板块领涨股扫描(从腾讯API批量拉) + 3. 资金流向异常检测(从已有 capital_flow_cache 读) + 4. 输出候选股到 candidates 表 + +不依赖小果LLM,纯数据驱动。 +""" +import sys, json, sqlite3, urllib.request, re, time +from pathlib import Path +from datetime import datetime + +# XMPP 日志 hook +try: + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from xmpp_logger import log_xmpp +except ImportError: + def log_xmpp(*a, **kw): pass + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json") + +UA = "Mozilla/5.0" + +def get_conn(): + return sqlite3.connect(str(DB_PATH)) + +def fetch_qq_batch(symbols): + """腾讯批量行情""" + if not symbols: + return {} + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + results = {} + for line in text.strip().split("\n"): + if "~" not in line: + continue + parts = line.split("~") + if len(parts) < 40: + continue + m = re.search(r'_(\w+)=', parts[0]) + market = m.group(1) if m else "" + code = parts[2] + name = parts[1] + price = float(parts[3]) if parts[3] else 0 + chg_pct = float(parts[32]) if parts[32] else 0 + high = float(parts[33]) if parts[33] else 0 + low = float(parts[34]) if parts[34] else 0 + volume = int(parts[6]) if parts[6] else 0 + amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 + if price > 0: + results[code] = {"code": code, "name": name, "price": price, + "change_pct": chg_pct, "high": high, "low": low, + "volume": volume, "amount": amount, + "market": "SH" if market == "sh" else "SZ" if market == "sz" else "HK"} + return results + except Exception as e: + print(f"[SCANNER] 腾讯API错误: {e}", file=sys.stderr) + return {} + +def scan_hot_sectors(): + """从DB读热门板块,返回板块名+领涨股""" + conn = get_conn() + latest = conn.execute("SELECT MAX(id) FROM market_snapshots").fetchone()[0] + if not latest: + conn.close() + return [] + sectors = conn.execute(""" + SELECT name, change_pct, lead_stock, lead_stock_code, up_count, down_count + FROM sector_snapshots WHERE snapshot_id=? + ORDER BY change_pct DESC LIMIT 15 + """, (latest,)).fetchall() + conn.close() + return [{"name": s[0], "change": s[1], "lead_stock": s[2], + "lead_code": s[3], "up": s[4], "down": s[5]} for s in sectors if s[1] > 2.0] + +def scan_candidates(): + """主扫描流程""" + print(f"[SCANNER] {datetime.now().strftime('%H:%M')} 开始扫描", flush=True) + + # 1. 热门板块领涨股 + hot = scan_hot_sectors() + print(f" 热门板块(涨幅>2%): {len(hot)}个", flush=True) + + candidates = {} + + # 从热门板块拉领涨股 + lead_codes = [] + for s in hot: + if s.get("lead_code"): + lc = str(s["lead_code"]).strip() + if lc and lc not in candidates: + lead_codes.append(lc) + candidates[lc] = {"source": f"板块:{s['name']}(+{s['change']:.1f}%)", "sector": s["name"]} + + print(f" 领涨股待查: {len(lead_codes)}只", flush=True) + + # 2. 腾讯API批量查行情 + symbols = [] + for c in lead_codes: + if len(c) == 6: + if c.startswith(("5", "6", "9")): + symbols.append(f"sh{c}") + else: + symbols.append(f"sz{c}") + else: + symbols.append(f"hk{c}") + + prices = fetch_qq_batch(symbols) + print(f" 行情返回: {len(prices)}只", flush=True) + + # 3. 评估候选 + new_candidates = [] + for code, info in prices.items(): + if code not in candidates: + continue + src = candidates[code] + price = info["price"] + chg = info["change_pct"] + name = info["name"] + vol = info["volume"] + amt = info["amount"] + + # 条件:涨幅>3%,有量 + if chg < 3.0: + continue + if vol <= 0: + continue + + score = min(10, round(3 + chg * 0.5 + (amt / 1e8 if amt > 0 else 0) * 0.1, 1)) + + entry_low = round(price * 0.95, 2) + entry_high = round(price, 2) + stop_loss = round(price * 0.92, 2) + take_profit = round(price * 1.15, 2) + + candidate = { + "code": code, + "name": name, + "price": price, + "change_pct": chg, + "score": score, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": stop_loss, + "take_profit": take_profit, + "source": src["source"], + "sector": src.get("sector", ""), + "reason": f"热门板块{src['sector']}领涨+{chg:.1f}%" + } + new_candidates.append(candidate) + print(f" ✅ {code} {name} 价{price} (+{chg:.1f}%) 评分{score}", flush=True) + + # 4. 写入candidates表 + if new_candidates: + conn = get_conn() + for c in new_candidates: + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, price, change_pct, score, " + "entry_low, entry_high, stop_loss, take_profit, source, sector, reason, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))", + (c["code"], c["name"], c["price"], c["change_pct"], c["score"], + c["entry_low"], c["entry_high"], c["stop_loss"], c["take_profit"], + c["source"], c["sector"], c["reason"]) + ) + conn.commit() + conn.close() + print(f" ✅ 写入{len(new_candidates)}只候选", flush=True) + else: + print(f" ⚪ 无新候选", flush=True) + + # 5. 推送到Dad(只推高分) + high_score = [c for c in new_candidates if c["score"] >= 6] + if high_score: + lines = ["🔍 市场扫描发现潜在机会:"] + for c in high_score[:3]: + lines.append( + f" {c['name']}({c['code']}) 价{c['price']:.2f}(+{c['change_pct']:.1f}%) " + f"评分{c['score']}/10 | {c['reason']}" + ) + msg = "\n".join(lines) + # 推XMPP(通过知微 Bot HTTP 桥 :5805) + import time as _time + t0 = _time.time() + try: + payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "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) + print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True) + log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "ok", None, int((_time.time()-t0)*1000)) + except Exception as e: + print(f" ⚠️ 推送失败: {e}", file=sys.stderr) + log_xmpp("out", "zhiwei@yoin.fun", "hmo@yoin.fun", msg, "error", str(e)[:200], int((_time.time()-t0)*1000)) + +if __name__ == "__main__": + scan_candidates() diff --git a/server.py b/server.py index 9e17d844..baa15f4a 100644 --- a/server.py +++ b/server.py @@ -14,6 +14,59 @@ sys.path.insert(0, "/home/hmo/MoFin/scripts") sys.path.insert(0, "/home/hmo/MoFin") from flask import Flask, jsonify, send_from_directory, request +import socket +import time +import sqlite3 + +SPECS_DIR = Path(__file__).parent / "specs" +GATEWAY_TEMP = Path(__file__).parent / "gateway" / "temp" +START_TIME = time.time() + +# ── Dashboard 监控服务列表 ── +DASH_SERVICES = [ + {"name": "mofin_api", "label": "MoFin API", "port": 8899, "host": "127.0.0.1", "type": "http", "check": "/api/portfolio", "layer": "核心服务", "critical": True}, + {"name": "zhiwei_gateway", "label": "知微 Gateway", "port": 8643, "host": "127.0.0.1", "type": "http", "check": "/v1/health", "layer": "AI 网关", "critical": True}, + {"name": "ejabberd", "label": "ejabberd XMPP", "port": 5222, "host": "127.0.0.1", "type": "tcp", "check": None, "layer": "通信层", "critical": True}, + {"name": "mofin_db", "label": "MoFin 数据库", "port": 0, "host": "127.0.0.1", "type": "db", "check": "/home/hmo/web-dashboard/data/mofin.db", "layer": "数据层", "critical": True}, +] + + +def _chk_tcp(host, port, timeout=3): + try: + s = socket.create_connection((host, port), timeout=timeout) + s.close() + return True + except Exception: + return False + + +def _chk_http(host, port, path, timeout=3): + try: + url = f"http://{host}:{port}{path}" + urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) + return True + except Exception: + return False + + +def _chk_db(db_path): + try: + conn = sqlite3.connect(db_path) + conn.execute("SELECT 1") + conn.close() + return True + except Exception: + return False + + +def _check_svc(svc): + if svc["type"] == "tcp": + return _chk_tcp(svc["host"], svc["port"]) + elif svc["type"] == "http": + return _chk_http(svc["host"], svc["port"], svc["check"]) + elif svc["type"] == "db": + return _chk_db(svc["check"]) + return False # 提示词管理模块 from prompt_manager.dashboard_views import register_routes @@ -1133,6 +1186,160 @@ def update_realtime(): }) +# ── Dashboard 管理门户 ────────────────────────────── + +@app.route("/dashboard") +def dashboard_page(): + return send_from_directory(str(Path(__file__).parent / "templates"), "dashboard.html") + + +@app.route("/api/health") +def api_health(): + return jsonify({"status": "ok", "uptime": int(time.time() - START_TIME)}) + + +@app.route("/api/services") +def api_services(): + result = [] + for svc in DASH_SERVICES: + ok = _check_svc(svc) + result.append({ + "name": svc["name"], "label": svc["label"], + "port": svc["port"], "type": svc["type"], "layer": svc["layer"], + "critical": svc["critical"], "health": {"ok": ok}, + }) + ok_count = sum(1 for s in result if s["health"]["ok"]) + return jsonify({"services": result, "summary": {"ok": ok_count, "total": len(result)}}) + + +@app.route("/api/expected") +def api_expected(): + expected = [{ + "name": s["name"], "label": s["label"], "port": s["port"], + "expected": "running", "critical": s["critical"], "layer": s["layer"], + "check": f"{s['type']}:{s['port']}" if s["port"] else s["type"], + } for s in DASH_SERVICES] + actual = {} + for svc in DASH_SERVICES: + actual[svc["name"]] = "running" if _check_svc(svc) else "stopped" + return jsonify({"expected": expected, "actual": actual}) + + +@app.route("/api/monitor") +def api_monitor(): + tasks = [] + tier1 = {"summary": {"ok": 0, "total": 0}, "services": []} + tier2 = {"summary": {"ok": 0, "total": 0}, "services": []} + + t1_path = GATEWAY_TEMP / "last_health_check.json" + if t1_path.exists(): + try: + with open(t1_path, encoding="utf-8") as f: + tier1 = json.load(f) + tasks.append({"name": "agents-health-check", "status": "cron_ok"}) + except Exception: + tasks.append({"name": "agents-health-check", "status": "error"}) + else: + tasks.append({"name": "agents-health-check", "status": "not_deployed"}) + + t2_path = GATEWAY_TEMP / "last_daily_health.json" + if t2_path.exists(): + try: + with open(t2_path, encoding="utf-8") as f: + tier2 = json.load(f) + tasks.append({"name": "agents-daily-health", "status": "cron_ok"}) + except Exception: + tasks.append({"name": "agents-daily-health", "status": "error"}) + else: + tasks.append({"name": "agents-daily-health", "status": "not_deployed"}) + + tasks.append({"name": "dashboard", "status": "running"}) + return jsonify({ + "tasks": tasks, "tier1": tier1, "tier2": tier2, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + + +@app.route("/api/module-spec/") +def api_module_spec(module): + spec_path = SPECS_DIR / f"{module.replace('..', '').replace('/', '').replace(chr(92), '')}.json" + if spec_path.exists(): + try: + with open(spec_path, encoding="utf-8") as f: + return jsonify(json.load(f)) + except Exception as e: + return jsonify({"error": str(e)}), 500 + return jsonify({"error": f"Module '{module}' not found"}), 404 + + +# ── XMPP 通信监控 API ───────────────────────────────── + +@app.route("/api/xmpp/messages") +def api_xmpp_messages(): + """查询 XMPP 消息日志""" + since = request.args.get("since", "") + agent = request.args.get("agent", "") + status = request.args.get("status", "") + limit = int(request.args.get("limit", 50)) + try: + from xmpp_logger import query + msgs = query(since=since or None, agent=agent or None, status=status or None, limit=limit) + return jsonify({"messages": msgs, "total": len(msgs)}) + except ImportError: + return jsonify({"messages": [], "total": 0}) + + +@app.route("/api/xmpp/health") +def api_xmpp_health(): + """XMPP 通道健康检查""" + try: + from xmpp_logger import health as xmpp_health + h = xmpp_health() + # 补充 ejabberd Docker 状态 + import subprocess + r = subprocess.run(["docker", "ps", "--filter", "name=ejabberd", "--format", "{{.Status}}"], + capture_output=True, timeout=5, text=True) + h["ejabberd"] = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else "not_found" + return jsonify(h) + except ImportError: + return jsonify({"status": "no_logger", "last_message_age_sec": -1, "error_rate_1h": 0, "ejabberd": "unknown"}) + + +@app.route("/api/xmpp/stats") +def api_xmpp_stats(): + """XMPP 消息统计""" + try: + from xmpp_logger import stats as xmpp_stats + return jsonify(xmpp_stats()) + except ImportError: + return jsonify({"today": {"sent": 0, "failed": 0, "latency_avg": 0}, "week": {"sent": 0, "failed": 0}}) + + +@app.route("/api/xmpp/autoheal", methods=["GET", "POST"]) +def api_xmpp_autoheal(): + """自愈:检测异常并自动修复。GET=查看状态, POST=执行修复""" + try: + from xmpp_logger import auto_heal + if request.method == "POST": + result = auto_heal() + return jsonify(result) + else: + return jsonify({"usage": "POST to trigger auto-heal"}) + except ImportError: + return jsonify({"error": "xmpp_logger not available"}), 500 + + +@app.route("/api/xmpp/keys") +def api_xmpp_keys(): + """API Key 可用性:从 AgentsMeeting 获取并选择最佳 key""" + try: + from xmpp_logger import best_key + bk = best_key() + return jsonify({"best_key": bk} if bk else {"error": "no keys available"}) + except ImportError: + return jsonify({"error": "xmpp_logger not available"}), 500 + + # 注册提示词管理路由 register_routes(app) diff --git a/specs/dashboard.json b/specs/dashboard.json new file mode 100644 index 00000000..ca98a671 --- /dev/null +++ b/specs/dashboard.json @@ -0,0 +1,60 @@ +{ + "module": "dashboard", + "version": "1.0", + "purpose": "MoFin 管理门户。独立 Flask 应用(端口 5804),统一展示系统健康状态、模块 spec 帮助和监控数据。", + + "human_help": { + "title": "Dashboard — 管理门户", + "description": [ + "MoFin 统一管理面板,提供系统健康状态总览和各模块的帮助文档。", + "采用深色主题 Web UI,与 AgentsMeeting Dashboard 一致的视觉风格。", + "访问地址: http://192.168.1.246:5804" + ], + "usage": [ + "打开浏览器访问 http://192.168.1.246:5804", + "F 健康 Tab — 查看所有服务运行状态和健康管线数据", + "G 规范 Tab — 查看开发规范文档", + "点击 ? 按钮 — 查看面向人类的模块帮助", + "点击 § 按钮 — 查看面向 AI 的接口约束文档" + ], + "troubleshooting": [ + "Dashboard 不响应 → ssh 246 'sudo systemctl restart mofin-dashboard'", + "F Tab 无数据 → 检查 crontab 中健康检查任务是否运行", + "Spec 加载失败 → 检查 specs/ 目录权限和 JSON 格式" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/health", "returns": "{status:'ok', uptime:N}"}, + {"method": "GET", "path": "/api/services", "returns": "{services[{name, type, port, status}]} — 所有注册服务的运行状态"}, + {"method": "GET", "path": "/api/expected", "returns": "{expected[{name, port, expected, critical}], actual{name:status}} — 期望状态矩阵"}, + {"method": "GET", "path": "/api/monitor", "returns": "{tier1{summary{ok,total}}, tier2{summary{ok,total}}, tasks[{name,status}]} — 聚合监控数据"}, + {"method": "GET", "path": "/api/module-spec/", "returns": "specs/{module}.json 内容"} + ], + "dependencies": [ + "specs/ 目录 — 所有模块的 spec JSON 文件", + "agents_health_check.py — Tier1 健康检查(生成 last_health_check.json)", + "agents_daily_health.py — Tier2 日检(生成 last_daily_health.json)", + "server.py :8899 — 业务 API(Dashboard 通过 HTTP 检测其可达性)" + ], + "constraints": [ + "Dashboard 部署在 Linux 246 上,端口 5804", + "通过 systemd 守护(mofin-dashboard.service)", + "不依赖 server.py :8899(可独立运行)", + "前端 5 秒轮询 /api/services + /api/expected", + "?§ 按钮通过 /api/module-spec/ 读取 spec JSON" + ], + "must_not": [ + "不要修改 server.py :8899 来集成 Dashboard(保持独立)", + "不要在 Dashboard 中硬编码业务数据(只做监控和文档展示)", + "不要移除 ?§ 按钮系统(这是 spec 可视化的核心)" + ], + "related_files": [ + "dashboard.py — Flask 后端", + "templates/dashboard.html — 前端", + "specs/ — Spec 文件目录", + "gateway/logs/ — 运行时日志" + ] + } +} diff --git a/specs/decisions.json b/specs/decisions.json new file mode 100644 index 00000000..f961e607 --- /dev/null +++ b/specs/decisions.json @@ -0,0 +1,53 @@ +{ + "module": "decisions", + "version": "1.0", + "purpose": "策略决策库。管理持仓和自选股的策略决策(止损/止盈/买入区/操作建议),支持新旧格式兼容。", + + "human_help": { + "title": "策略决策库", + "description": [ + "存储每只持仓/自选股的策略决策数据,包括止损价、止盈价、买入区间、操作建议等。", + "数据来自知微 LLM 分析,通过 /api/analysis/batch 写入。", + "支持新旧两种数据格式的自动兼容(新格式:stop_loss/take_profit 顶层字段;旧格式:trigger 对象)。" + ], + "usage": [ + "GET /api/decisions — 获取全部决策(按标签+执行状态排序)", + "POST /api/decisions/add — 新增/更新一条决策(同股票旧决策自动标记 superseded)", + "POST /api/decisions/tag — 设置/清除推荐标签(current_recommend / active_manual)", + "GET /api/decisions/pending — 获取有未确认建议的条目" + ], + "troubleshooting": [ + "决策列表为空 → 确认 regenerate_all 已执行或知微已写入", + "旧格式不显示 → 检查归一化逻辑(/api/decisions GET handler)" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/decisions", "returns": "{decisions[{code, name, type, status, tag, action, trigger{stop_loss, take_profit, entry_zone}, current, zone_breach, updated_reason, advice_timeline, changelog, execution, analysis}], total, regenerated_at}"}, + {"method": "POST", "path": "/api/decisions/add", "returns": "{status:'ok', entry:{...}}"}, + {"method": "POST", "path": "/api/decisions/tag", "returns": "{status:'ok', code, tag}"}, + {"method": "GET", "path": "/api/decisions/pending", "returns": "[{code, name, current, pending_advice[{date, direction, price, summary, status}]}]"} + ], + "dependencies": [ + "mo_data.py — read_decisions()", + "mofin_db.py — write_holding_strategy()", + "strategy_lifecycle.py — regenerate_all 触发全量重评" + ], + "constraints": [ + "支持新旧两种格式的读取兼容(normalized 逻辑)", + "同股票新决策会自动将旧决策标记为 superseded", + "排序规则:current_recommend 标签 > 执行状态(partial_exit > executing > observing) > code", + "advice_timeline 去重:同日期+同方向+摘要前40字相同 → skip" + ], + "must_not": [ + "不要在决策中硬编码价格阈值(应由 LLM 分析生成)", + "不要删除 superseded 的旧决策(保留历史记录)" + ], + "related_files": [ + "server.py — /api/decisions* 路由", + "mo_data.py — read_decisions()", + "mofin_db.py — holding_strategies 表" + ] + } +} diff --git a/specs/evaluation.json b/specs/evaluation.json new file mode 100644 index 00000000..772f685c --- /dev/null +++ b/specs/evaluation.json @@ -0,0 +1,48 @@ +{ + "module": "evaluation", + "version": "1.0", + "purpose": "策略评估系统。提供策略双维度评估结果查询、手动触发评估和准确率统计。", + + "human_help": { + "title": "策略评估", + "description": [ + "评估每只股票策略的有效性,来自 strategy_evaluator.py(每周六 21:00 自动运行)。", + "支持手动触发评估(POST /api/evaluation/trigger)。", + "评估数据主源为 evaluation.json,备选为 decisions.json 中的 evaluation 字段。" + ], + "usage": [ + "GET /api/evaluation — 获取全部策略评估结果", + "POST /api/evaluation/trigger — 手动触发策略评估(执行 strategy_evaluator.py)", + "GET /api/stats/accuracy — 获取准确率统计数据", + "GET /api/feedback — 获取策略反馈数据" + ], + "troubleshooting": [ + "评估数据为空 → 确认 strategy_evaluator.py 已运行过", + "触发失败 → 检查 strategy_evaluator.py 路径是否正确" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/evaluation", "returns": "[{code, name, type, current, evaluations[{date, dimension, score, reason}]}]"}, + {"method": "POST", "path": "/api/evaluation/trigger", "returns": "{status:'ok', output, error} — 执行 strategy_evaluator.py,timeout 60s"}, + {"method": "GET", "path": "/api/stats/accuracy", "returns": "accuracy_stats.json 内容"}, + {"method": "GET", "path": "/api/feedback", "returns": "strategy_feedback.json 内容"} + ], + "dependencies": [ + "strategy_evaluator.py — 双维度评估脚本(cron: 周六 21:00)", + "evaluation.json — 评估结果主数据源", + "accuracy_stats.json — 准确率统计", + "strategy_feedback.json — 反馈数据" + ], + "constraints": [ + "POST /api/evaluation/trigger 会阻塞最多 60 秒", + "评估数据优先读 evaluation.json,fallback 读 decisions.json 的 evaluation 字段" + ], + "related_files": [ + "server.py — /api/evaluation, /api/evaluation/trigger, /api/stats/accuracy, /api/feedback", + "strategy_evaluator.py", + "strategy_feedback.py" + ] + } +} diff --git a/specs/health.json b/specs/health.json new file mode 100644 index 00000000..caf08660 --- /dev/null +++ b/specs/health.json @@ -0,0 +1,73 @@ +{ + "module": "health", + "version": "1.0", + "purpose": "MoFin 系统健康监控管线。三层监控(Tier1 快速检查 + Tier2 日检),聚合到 Dashboard F Tab 展示。", + + "human_help": { + "title": "F 健康 — 系统健康", + "description": [ + "实时监控 MoFin 所有关键服务(Flask API、数据库、cron 任务)的运行状态。", + "两层监控:", + " Tier1 — 每 5 分钟快速端口/进程检查", + " Tier2 — 每日 8:00 开盘前全面体检(进程+端口+DB+磁盘+cron)", + "检查结果聚合到 Dashboard F Tab,异常自动告警。" + ], + "usage": [ + "1. 打开 Dashboard → F 健康 Tab 查看概览", + "2. 绿色 = 正常,黄色 = 部分异常,红色 = 严重异常", + "3. 异常服务列表直接显示影响描述", + "4. 定时任务区域检查所有 cron 是否正常运行" + ], + "troubleshooting": [ + "F Tab 显示无数据 → 检查 crontab 中健康检查是否部署", + "服务显示 down 但实际在运行 → 检查端口或 health 端点是否正确", + "TODO 堆积 → 检查 self_todo_executor 是否在 crontab 中" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/services", "returns": "{services[{name, type, port, health{ok}, status}]}"}, + {"method": "GET", "path": "/api/expected", "returns": "{expected[{name, port, expected, critical}], actual{name:status}}"}, + {"method": "GET", "path": "/api/monitor", "returns": "{tier1{summary{ok,total}}, tier2{summary{ok,total}}, tasks[{name,status}]}"} + ], + "dependencies": [ + "agents_health_check.py — Tier1(每 5 分钟,socket 端口 + HTTP /health)", + "agents_daily_health.py — Tier2(每日 8:00,端口+进程+DB+磁盘+cron)", + "mofin.db — SQLite 数据库(检查可读写)" + ], + "architecture": { + "monitored_services": [ + {"name": "mofin_api", "port": 8899, "type": "http", "check": "GET /api/portfolio"}, + {"name": "mofin_dashboard", "port": 5804, "type": "http", "check": "GET /api/health"}, + {"name": "zhiwei_gateway", "port": 8643, "type": "http", "check": "GET /v1/health"}, + {"name": "ejabberd", "port": 5222, "type": "tcp", "check": "socket connect"}, + {"name": "mofin_db", "port": 0, "type": "file", "check": "sqlite3 connect + SELECT"} + ] + }, + "constraints": [ + "Tier1 全正常时静默(不输出日志)", + "Tier1 异常写入 gateway/temp/health_todos.jsonl", + "Tier1 报告写入 gateway/temp/last_health_check.json", + "Tier2 报告写入 gateway/temp/last_daily_health.json", + "Dashboard /api/monitor 聚合读取以上 JSON 文件" + ], + "must_not": [ + "不要在健康检查中修改业务数据", + "不要硬编码 Windows 路径或命令", + "不要检查已停用的服务(wechat_agent 等)" + ], + "tests": [ + {"id": "H01", "name": "/api/services 返回服务列表", "endpoint": "GET /api/services"}, + {"id": "H02", "name": "/api/expected 返回期望矩阵", "endpoint": "GET /api/expected"}, + {"id": "H03", "name": "/api/monitor 返回监控数据", "endpoint": "GET /api/monitor"} + ], + "related_files": [ + "agents_health_check.py — Tier1 健康检查", + "agents_daily_health.py — Tier2 日检", + "dashboard.py — /api/services, /api/expected, /api/monitor", + "templates/dashboard.html — F Tab 渲染", + "specs/health.json — 本 spec 文件" + ] + } +} diff --git a/specs/market.json b/specs/market.json new file mode 100644 index 00000000..656d60ce --- /dev/null +++ b/specs/market.json @@ -0,0 +1,42 @@ +{ + "module": "market", + "version": "1.0", + "purpose": "市场观察数据。提供大盘指数和板块数据的查询和更新。", + + "human_help": { + "title": "市场观察", + "description": [ + "展示大盘指数(上证、深证、恒生等)和板块热度数据。", + "数据由 market_watch.py cron(每 30 分钟)自动采集。", + "优先从 DB 读取(market_snapshots / sector_snapshots 表),DB 无数据时 fallback 到 market.json。" + ], + "usage": [ + "GET /api/market — 获取最新市场数据(指数 + 板块)", + "POST /api/update/market — 更新市场数据(由 market_watch 调用)" + ], + "troubleshooting": [ + "市场数据为空 → 检查 market_watch cron 是否正常运行", + "数据显示旧 → 手动运行 python3 market_watch.py 更新" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/market", "returns": "{indices[{name, code, price, change_pct}], sectors[{name, change_pct, leader}]}"}, + {"method": "POST", "path": "/api/update/market", "returns": "{status:'ok'}"} + ], + "dependencies": [ + "mofin_db.py — market_snapshots / sector_snapshots 表", + "market_watch.py — 大盘采集 cron(*/30 9-15)", + "market_screener.py — 全市场筛选 cron" + ], + "constraints": [ + "DB 优先读取,JSON 仅做 fallback" + ], + "related_files": [ + "server.py — /api/market, /api/update/market", + "market_watch.py — 大盘数据采集", + "market_screener.py — 全市场筛选" + ] + } +} diff --git a/specs/portfolio.json b/specs/portfolio.json new file mode 100644 index 00000000..24806dd8 --- /dev/null +++ b/specs/portfolio.json @@ -0,0 +1,60 @@ +{ + "module": "portfolio", + "version": "1.0", + "purpose": "持仓数据查询与管理。提供持仓列表、资产概览、实时价格更新。", + + "human_help": { + "title": "持仓管理", + "description": [ + "本模块管理老爸的股票持仓数据,包括个股持仓明细和总资产概览。", + "数据存储在 SQLite (mofin.db),由 price_monitor cron 每 2 分钟更新价格。", + "港股以 HKD 存储,汇总时自动转换为 CNY。" + ], + "usage": [ + "GET /api/portfolio — 获取完整持仓列表(含价格、涨跌幅、盈亏)", + "GET /api/overview — 获取总资产概览(总资产、股票市值、现金、仓位、top movers)", + "POST /api/update/portfolio — 批量更新持仓数据(由 cron 调用)", + "POST /api/update/realtime — 实时价格更新(由 price_monitor 调用)" + ], + "troubleshooting": [ + "数据库查询失败 → 检查 mofin.db 是否存在且可读写", + "港股价格异常 → 确认 hk_rate.py 汇率 API 可达", + "数据不更新 → 检查 crontab 中 price_monitor 是否正常运行" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/portfolio", "returns": "{total_assets, stock_value, cash, position_pct, total_pnl, holdings[{code, name, price, cost, shares, change_pct, currency, ...}]}"}, + {"method": "GET", "path": "/api/overview", "returns": "{total_assets, stock_value, cash, position_pct, total_pnl, top_movers, market, alerts, updated_at}"}, + {"method": "POST", "path": "/api/update/portfolio", "returns": "{status:'ok'}"}, + {"method": "POST", "path": "/api/update/realtime", "returns": "{status:'ok'}"} + ], + "dependencies": [ + "mo_data.py — read_portfolio() 统一读取层", + "mofin_db.py — get_conn(), query_holdings(), query_portfolio_summary()", + "price_monitor.py — 唯一价格写入者,cron: */2 9-16 1-5" + ], + "constraints": [ + "港股个股价格/成本以 HKD 存储,currency='HKD'", + "A股个股价格/成本以 CNY 存储,currency='CNY'", + "总资产/总市值以 CNY 汇总(calc_total_assets 自动转换)", + "禁止跨币种直接比较或加减", + "price_monitor 是唯一的价格写入源,其他脚本禁止直接写价格" + ], + "must_not": [ + "不要在各业务脚本中直接写 SQL(必须通过 mofin_db.py)", + "不要硬编码汇率(必须通过 hk_rate.py 的 get_hk_rate())", + "不要直接 json.load 读数据(必须通过 mo_data.py)", + "不要自己实现 calc_total_assets / is_hk_stock(必须用 mo_models.py)" + ], + "related_files": [ + "server.py — API 路由定义", + "mo_models.py — 数据模型(calc_total_assets, is_hk_stock, to_cny)", + "mo_data.py — 统一读取层", + "mofin_db.py — DB 层", + "price_monitor.py — 价格更新 cron", + "hk_rate.py — 港币汇率" + ] + } +} diff --git a/specs/prompts.json b/specs/prompts.json new file mode 100644 index 00000000..51830431 --- /dev/null +++ b/specs/prompts.json @@ -0,0 +1,60 @@ +{ + "module": "prompts", + "version": "1.0", + "purpose": "LLM 提示词版本管理系统。管理知微使用的所有 LLM prompt,支持版本历史、效果追踪和 A/B 测试。", + + "human_help": { + "title": "提示词管理", + "description": [ + "集中管理 MoFin 系统中知微 LLM 使用的所有提示词模板。", + "每个提示词支持多版本管理,可以激活/回滚/废弃版本。", + "内置效果追踪:记录每个提示词版本被调用时的成功率和关联策略数。" + ], + "usage": [ + "GET /api/prompts — 获取所有提示词列表(含当前版本和版本数)", + "GET /api/prompts/ — 获取单个提示词的完整信息(含所有版本和历史)", + "POST /api/prompts — 创建新提示词", + "POST /api/prompts//versions — 添加新版本", + "POST /api/prompts//activate — 激活指定版本", + "GET /api/prompts/effectiveness — 获取各版本有效性统计", + "GET /api/prompts/report — 获取版本有效性报告", + "GET /api/prompts/associations/ — 获取某股票关联的提示词" + ], + "troubleshooting": [ + "提示词列表为空 → 运行 init_registry.py 初始化注册表", + "版本激活不生效 → 检查版本号是否存在,确认不是已废弃状态" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/prompts", "returns": "{prompts[{id, name, category, current_version, versions[]}], categories{}}"}, + {"method": "GET", "path": "/api/prompts/", "returns": "{prompt{...}, version_history[{version, label, status, created_at, changelog}]}"}, + {"method": "POST", "path": "/api/prompts", "returns": "{status:'ok', prompt_id}"}, + {"method": "POST", "path": "/api/prompts//versions", "returns": "{status:'ok', version}"}, + {"method": "POST", "path": "/api/prompts//activate", "returns": "{status:'ok'}"}, + {"method": "GET", "path": "/api/prompts/stats", "returns": "统计信息"}, + {"method": "GET", "path": "/api/prompts/effectiveness", "returns": "各版本成功率统计"}, + {"method": "GET", "path": "/api/prompts/report", "returns": "{report: 版本有效性报告文本}"}, + {"method": "GET", "path": "/api/prompts/associations/", "returns": "股票关联的提示词列表"} + ], + "dependencies": [ + "prompt_manager/init_registry.py — 初始化提示词注册表", + "prompt_manager/registry.py — 提示词注册管理", + "prompt_manager/models.py — 数据模型", + "prompt_manager/tracking.py — 效果追踪", + "prompt_manager/analytics.py — 分析统计" + ], + "constraints": [ + "提示词内容必须遵守 DEVELOPMENT_STANDARDS.md 中的 LLM Prompt 规范", + "不引用 JSON 文件名(S1规则)", + "港股价格标注 (HKD)(S2规则)", + "不在 prompt 里硬编码路径(S5规则)" + ], + "related_files": [ + "prompt_manager/dashboard_views.py — API 路由", + "prompt_manager/init_registry.py — 注册表初始化", + "docs/DEVELOPMENT_STANDARDS.md — Prompt 规范" + ] + } +} diff --git a/specs/reports.json b/specs/reports.json new file mode 100644 index 00000000..3e30fdc3 --- /dev/null +++ b/specs/reports.json @@ -0,0 +1,43 @@ +{ + "module": "reports", + "version": "1.0", + "purpose": "分析报告管理。存储和查询 MoFin 系统生成的各类分析报告(盘中/盘后/周报等)。", + + "human_help": { + "title": "报告管理", + "description": [ + "MoFin 系统自动生成的分析报告存档。", + "报告存储在 data/reports/ 目录下,每条报告为一个 JSON 文件。", + "支持按类型筛选:盘中、盘后、周报等。" + ], + "usage": [ + "GET /api/reports — 获取最近 100 条报告列表(含标题、类型、摘要)", + "GET /api/report/ — 获取单条报告完整内容(支持前缀匹配)", + "POST /api/update/report — 上传/更新报告" + ], + "troubleshooting": [ + "报告列表为空 → 确认 cron 任务(开盘简报/收盘简报/策略评估)是否正常运行", + "报告ID找不到 → 检查 reports/ 目录下的 JSON 文件名" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/reports", "returns": "[{id, title, type, created_at, summary}] — 最近 100 条报告"}, + {"method": "GET", "path": "/api/report/", "returns": "报告完整 JSON 内容(支持前缀匹配)"}, + {"method": "POST", "path": "/api/update/report", "returns": "{status:'ok', id}"} + ], + "dependencies": [ + "data/reports/ — 报告 JSON 文件存储目录", + "cron: 开盘简报 (9:35) / 收盘简报 (16:10) / 策略评估 (21:00) — LLM Cron" + ], + "constraints": [ + "报告 ID 前缀匹配:先精确查找 {id}.json,再按前缀匹配", + "报告类型:盘中/盘后/周报/其他" + ], + "related_files": [ + "server.py — /api/reports, /api/report/, /api/update/report", + "docs/cron-catalog.md — LLM Cron 调度说明" + ] + } +} diff --git a/specs/scanner.json b/specs/scanner.json new file mode 100644 index 00000000..9fa122cc --- /dev/null +++ b/specs/scanner.json @@ -0,0 +1,56 @@ +{ + "module": "scanner", + "version": "1.0", + "purpose": "全市场自动选股机制。纯数据驱动(不依赖 LLM),定期从 A 股热门板块筛选符合条件的候选股。", + + "human_help": { + "title": "全市场选股扫描", + "description": [ + "自动化选股管线,每 15 分钟运行一次。纯数据驱动,不依赖外部 LLM。", + "流程:读热门板块 → 拉腾讯实时行情 → 涨幅>3%+有量 → 评分 → 写入 candidates 表 → 高分推送 XMPP。", + "候选股在 Dashboard 市场 Tab 的「主力建仓候选」面板展示。" + ], + "usage": [ + "Dashboard 市场 Tab → 🎯 主力建仓候选 → 查看最近 50 只候选股", + "Dashboard 信号 Tab → 查看全市场扫描统计", + "cron: market_scanner.py(每 15 分钟,交易日 9-15)" + ], + "troubleshooting": [ + "候选池为空 → 检查 market_scanner.py cron 是否运行,sector_snapshots 表是否有数据", + "行情不更新 → 检查腾讯行情 API 是否可达(qt.gtimg.cn)" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/candidates", "returns": "[{code, name, reason, score_2nd..score_5th, score_final, pass_s2..pass_s5, promoted, promoted_at, log, created_at}] — 最近 50 只候选股"} + ], + "dependencies": [ + "market_scanner.py — 选股主脚本(cron: */15 9-15 1-5)", + "mofin_db.py — candidates 表(code, name, price, change_pct, score, entry_low, entry_high, stop_loss, take_profit, source, sector, reason)", + "market_watch.py — 提供 sector_snapshots 板块数据", + "腾讯行情 API: http://qt.gtimg.cn/q=" + ], + "architecture": { + "pipeline": "sector_snapshots → market_scanner.scan_hot_sectors() → 腾讯行情 → 过滤(涨>3%+有量) → 评分 → candidates 表 → XMPP 推送(高分)", + "scoring": "score = min(10, round(3 + change% * 0.5 + (amount/1e8) * 0.1))" + }, + "constraints": [ + "纯数据驱动,不调 LLM(区别于旧 xiaoguo_scanner 和 market_screener)", + "只扫描 A 股(6 位代码以 5/6/9 开头)", + "板块涨幅 >2% 才纳入热门板块", + "个股涨幅 >3% 才进入候选", + "高分候选(score>=6)推送到 XMPP(知微 bot :5805)" + ], + "must_not": [ + "不要调小果 LLM API(node122:18003)", + "不要写 candidate_pool.json(旧格式,已废弃)" + ], + "related_files": [ + "scripts/market_scanner.py — 选股脚本", + "server.py — /api/candidates", + "mofin_db.py — candidates 表", + "market_watch.py — 板块数据源" + ] + } +} diff --git a/specs/signals.json b/specs/signals.json new file mode 100644 index 00000000..2accd5b2 --- /dev/null +++ b/specs/signals.json @@ -0,0 +1,50 @@ +{ + "module": "signals", + "version": "1.0", + "purpose": "信号数据。提供市场信号查询,数据来自趋势检测(macro_context_collector/divergence_detector)和全市场扫描(market_scanner)。", + + "human_help": { + "title": "信号与扫描", + "description": [ + "展示系统产生的交易信号,包括趋势检测信号和全市场扫描候选股。", + "趋势信号来自 macro_context_collector(宏观)和 divergence_detector(背离检测)。", + "全市场扫描由 market_scanner.py 纯数据驱动(不依赖 LLM),每 15 分钟从热门板块筛选候选股。", + "所有信号存储在 signal_news 表中,候选股存储在 candidates 表中。" + ], + "usage": [ + "GET /api/signals — 获取最近 20 条信号(含趋势和扫描信号)", + "GET /api/candidates — 获取候选股池(最近 50 只,市场扫描产出)" + ], + "troubleshooting": [ + "信号为空 → 检查 macro_context_collector 和 market_scanner cron 状态", + "候选池为空 → 确认 sector_snapshots 有数据,market_scanner cron 在运行" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/signals", "returns": "[{id, sector, overall_sentiment, summary, source, created_at, signal_type, severity}] — 最近 20 条信号"}, + {"method": "GET", "path": "/api/candidates", "returns": "[{code, name, reason, score_2nd..score_5th, score_final, pass_s2..pass_s5, promoted, log, created_at}] — 候选股池(由 market_scanner 产出)"} + ], + "dependencies": [ + "mofin_db.py — signal_news / candidates 表", + "macro_context_collector.py — 宏观信号采集 cron", + "divergence_detector.py — 背离检测 cron", + "scripts/market_scanner.py — 全市场扫描 cron(*/15 9-15 1-5,纯数据驱动)" + ], + "constraints": [ + "signal_news 和 sector_signals 通过 LEFT JOIN 关联", + "candidates 表由 market_scanner 写入,纯数据驱动不调 LLM", + "旧 xiaoguo_scanner 已废弃,不再使用" + ], + "must_not": [ + "不要调小果 LLM API", + "不要依赖 xiaoguo_scan_tracker 表(已废弃)" + ], + "related_files": [ + "server.py — /api/signals, /api/candidates", + "scripts/market_scanner.py — 全市场扫描", + "macro_context_collector.py" + ] + } +} diff --git a/specs/watchlist.json b/specs/watchlist.json new file mode 100644 index 00000000..b69bef5f --- /dev/null +++ b/specs/watchlist.json @@ -0,0 +1,42 @@ +{ + "module": "watchlist", + "version": "1.0", + "purpose": "自选股列表管理。提供自选股查询和批量更新。", + + "human_help": { + "title": "自选股管理", + "description": [ + "管理老爸的自选股列表,与持仓分开存储。", + "数据存储在 SQLite (mofin.db) 的 watchlist_stocks 表。", + "小果扫描器会消费自选股信号并自动添加到自选列表。" + ], + "usage": [ + "GET /api/watchlist — 获取完整自选股列表", + "POST /api/update/watchlist — 批量更新自选股(由 cron 调用)" + ], + "troubleshooting": [ + "数据库查询失败 → 检查 mofin.db 是否可读写", + "自选股列表为空 → 确认 regenerate_all 或手动添加过自选股" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/watchlist", "returns": "{stocks[{code, name, price, change_pct, currency, analysis{...}}]}"}, + {"method": "POST", "path": "/api/update/watchlist", "returns": "{status:'ok'}"} + ], + "dependencies": [ + "mo_data.py — read_watchlist()", + "mofin_db.py — query_watchlist(), write_watchlist_stock()", + "xiaoguo_signal_consumer.py — 消费小果信号,自动加自选" + ], + "constraints": [ + "自选股也必须标注 currency 字段(HKD/CNY)" + ], + "related_files": [ + "server.py — API 路由定义", + "mo_data.py — read_watchlist()", + "mofin_db.py — watchlist_stocks 表" + ] + } +} diff --git a/specs/xmpp_monitor.json b/specs/xmpp_monitor.json new file mode 100644 index 00000000..92f0dc85 --- /dev/null +++ b/specs/xmpp_monitor.json @@ -0,0 +1,72 @@ +{ + "module": "xmpp_monitor", + "version": "1.0", + "purpose": "XMPP 通信通道全链路可观测性。记录消息收发日志、LLM 调用追踪、异常检测与自动修复。", + + "human_help": { + "title": "XMPP 通信监控", + "description": [ + "实时监控 MoFin 的 XMPP 通信通道健康状态。", + "追踪范围:知微 Bot ↔ XMPP 群聊、cron 报告推送、市场扫描推送、策略通知。", + "当消息流中断或异常时,Dashboard 自动告警并展示失败原因。" + ], + "usage": [ + "Dashboard 📊 仪表盘 → 查看消息流实时状态", + "Dashboard 🏥 健康 → 查看 XMPP 通道健康指标(最后消息时间、错误率)", + "GET /api/xmpp/messages — 查询消息历史(支持按时间/Agent/状态筛选)", + "GET /api/xmpp/health — XMPP 通道健康检查" + ], + "troubleshooting": [ + "消息推送失败 → 查看 xmpp_messages.jsonl 中的错误信息", + "ejabberd 挂了 → Dashboard 自动检测,手动 docker restart ejabberd", + "知微 Bot 离线 → 检查 systemctl status xmpp-zhiwei" + ] + }, + + "ai_spec": { + "apis": [ + {"method": "GET", "path": "/api/xmpp/messages", "returns": "{messages[{timestamp, direction, from, to, body_preview, status, error, latency_ms}], total} — 支持 ?since=&agent=&status=&limit=50 参数"}, + {"method": "GET", "path": "/api/xmpp/health", "returns": "{status, last_message_age_sec, error_rate_1h, queue_depth, ejabberd_status}"}, + {"method": "GET", "path": "/api/xmpp/stats", "returns": "{today{sent, failed, latency_avg}, week{sent, failed}}"} + ], + "dependencies": [ + "xmpp_logger.py — 消息日志采集(写入 gateway/logs/xmpp_messages.jsonl)", + "ejabberd Docker 容器 — XMPP 服务器(:5222)", + "cron_to_xmpp.py — 报告推送到 XMPP(send 函数写入 xmpp_logger)", + "market_scanner.py — 选股结果推送 XMPP(:5805)", + "知微 Bot — xmpp-zhiwei systemd 服务" + ], + "architecture": { + "log_format": "JSONL, 每行一条: {timestamp, direction(in/out), from_jid, to_jid, body_preview(前100字), status(ok/error/timeout), error, latency_ms}", + "health_checks": [ + "最后消息年龄 >10min → 🔴 告警", + "最近1小时错误率 >50% → 🟡 告警", + "ejabberd 容器不在运行 → 🔴 严重", + "知微 Bot 进程不在 → 🔴 严重" + ] + }, + "constraints": [ + "xmpp_logger.py 通过 hook 方式接入 cron_to_xmpp.send(),不动现有业务逻辑", + "消息日志保留最近 7 天,自动轮转", + "Dashboard 每 10 秒自动刷新消息流", + "错误信息脱敏:不记录完整消息体,只记录前 100 字预览" + ], + "must_not": [ + "不要在消息日志中记录 API Key 或密码", + "不要修改 cron_to_xmpp.py 的业务逻辑(只加 hook)", + "不要在 xmpp_monitor 中重复实现已有的 system_health_check 逻辑" + ], + "tests": [ + {"id": "XM01", "name": "/api/xmpp/health 返回 ejabberd 状态", "endpoint": "GET /api/xmpp/health"}, + {"id": "XM02", "name": "/api/xmpp/messages 返回消息列表", "endpoint": "GET /api/xmpp/messages"}, + {"id": "XM03", "name": "xmpp_logger 写入 JSONL 格式正确", "endpoint": "file check"} + ], + "related_files": [ + "xmpp_logger.py — 消息日志采集", + "cron_to_xmpp.py — 报告推送(send 函数)", + "scripts/market_scanner.py — 选股推送", + "server.py — /api/xmpp/* 端点", + "agents_health_check.py — Tier1 健康检查(含 XMPP 检测)" + ] + } +} diff --git a/static/index.html b/static/index.html index 5947e33a..3b5131b4 100644 --- a/static/index.html +++ b/static/index.html @@ -1,1344 +1,1695 @@ - - - - - -MoFin · 莫荷情报 - - - - - - -
- -
-
- 📊 -

MoFin

- 知微 -
-
- - -
-
- - -
- - - - - - - - - - - - 📸 上传 -
- - -
- - - - - - - - - - -
- - - - - - - - - + + + + + +MoFin · 莫荷情报 + + + + + + +
+ +
+
+ 📊 +

MoFin

+ 知微 +
+
+ + +
+
+ + +
+ + + + + + + + + + + + 📸 上传 +
+ + +
+ + + + + + + + + + +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/templates/closing_brief.txt b/templates/closing_brief.txt index 00e0dfc6..aa08fadf 100644 --- a/templates/closing_brief.txt +++ b/templates/closing_brief.txt @@ -1,19 +1,19 @@ -═══════════════════════════════════════ -知微 收盘简报 | {GENERATED_AT} -═══════════════════════════════════════ - -【数据面板 — 代码采集,LLM不得修改】 - -现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY -持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY -仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 - -现金来源: {CASH_SOURCE} - -持仓明细: -{HOLDINGS_TABLE} - -⚠️ 浮亏>20%: -{HOLDINGS_RISK} - -═══════════════════════════════════════ +═══════════════════════════════════════ +知微 收盘简报 | {GENERATED_AT} +═══════════════════════════════════════ + +【数据面板 — 代码采集,LLM不得修改】 + +现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY +持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY +仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 + +现金来源: {CASH_SOURCE} + +持仓明细: +{HOLDINGS_TABLE} + +⚠️ 浮亏>20%: +{HOLDINGS_RISK} + +═══════════════════════════════════════ diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 00000000..fa5ae8b0 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,277 @@ + +MoFin Dashboard + +

MoFin

持仓情报系统 · Dashboard
+
+
+
+ diff --git a/templates/intraday_monitor.txt b/templates/intraday_monitor.txt index da0e4914..ea550a75 100644 --- a/templates/intraday_monitor.txt +++ b/templates/intraday_monitor.txt @@ -1,22 +1,22 @@ -═══════════════════════════════════════ -MoFin 盘中监控 | {GENERATED_AT} -═══════════════════════════════════════ - -【数据面板 — 代码采集,LLM不得修改】 - -大盘: 上证 {market.sh_index} ({market.sh_change:+.2f}%) | 深证 {market.sz_index} ({market.sz_change:+.2f}%) -涨跌比 {market.advance_decline_ratio} | 情绪 {market.mood} - -现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY -持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY -仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 | 港币汇率 {HK_RATE} - -现金来源: {CASH_SOURCE} - -持仓明细: -{HOLDINGS_TABLE} - -⚠️ 浮亏>20%: -{HOLDINGS_RISK} - -═══════════════════════════════════════ +═══════════════════════════════════════ +MoFin 盘中监控 | {GENERATED_AT} +═══════════════════════════════════════ + +【数据面板 — 代码采集,LLM不得修改】 + +大盘: 上证 {market.sh_index} ({market.sh_change:+.2f}%) | 深证 {market.sz_index} ({market.sz_change:+.2f}%) +涨跌比 {market.advance_decline_ratio} | 情绪 {market.mood} + +现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY +持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY +仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 | 港币汇率 {HK_RATE} + +现金来源: {CASH_SOURCE} + +持仓明细: +{HOLDINGS_TABLE} + +⚠️ 浮亏>20%: +{HOLDINGS_RISK} + +═══════════════════════════════════════ diff --git a/templates/opening_brief.txt b/templates/opening_brief.txt index 723b73d6..af6505e9 100644 --- a/templates/opening_brief.txt +++ b/templates/opening_brief.txt @@ -1,19 +1,19 @@ -═══════════════════════════════════════ -知微 开盘简报 | {GENERATED_AT} -═══════════════════════════════════════ - -【数据面板 — 代码采集,LLM不得修改】 - -现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY -持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY -仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 - -现金来源: {CASH_SOURCE} - -持仓明细: -{HOLDINGS_TABLE} - -⚠️ 浮亏>20%: -{HOLDINGS_RISK} - -═══════════════════════════════════════ +═══════════════════════════════════════ +知微 开盘简报 | {GENERATED_AT} +═══════════════════════════════════════ + +【数据面板 — 代码采集,LLM不得修改】 + +现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY +持仓市值 {STOCK_VALUE} CNY | 总资产 {TOTAL_ASSETS} CNY +仓位 {POSITION_PCT}% | 持仓 {HOLDINGS_COUNT} 只 + +现金来源: {CASH_SOURCE} + +持仓明细: +{HOLDINGS_TABLE} + +⚠️ 浮亏>20%: +{HOLDINGS_RISK} + +═══════════════════════════════════════ diff --git a/templates/self_buy_reminder.txt b/templates/self_buy_reminder.txt index e2fe9e1f..abd6804e 100644 --- a/templates/self_buy_reminder.txt +++ b/templates/self_buy_reminder.txt @@ -1,15 +1,15 @@ -═══════════════════════════════════════ -自选买入提醒 | {GENERATED_AT} -═══════════════════════════════════════ - -【数据面板 — 代码采集,LLM不得修改】 - -总资产 {TOTAL_ASSETS} CNY | 现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY -仓位 {POSITION_PCT}% | 港币汇率 {HK_RATE} - -现金来源: {CASH_SOURCE} - -持仓明细(已有持仓,不计入现金占用): -{HOLDINGS_TABLE} - -═══════════════════════════════════════ +═══════════════════════════════════════ +自选买入提醒 | {GENERATED_AT} +═══════════════════════════════════════ + +【数据面板 — 代码采集,LLM不得修改】 + +总资产 {TOTAL_ASSETS} CNY | 现金 {CASH_AMOUNT} CNY | 冻结 {portfolio.frozen_cash} CNY +仓位 {POSITION_PCT}% | 港币汇率 {HK_RATE} + +现金来源: {CASH_SOURCE} + +持仓明细(已有持仓,不计入现金占用): +{HOLDINGS_TABLE} + +═══════════════════════════════════════ diff --git a/templates/strategy_eval.txt b/templates/strategy_eval.txt index bd05013c..35193c8a 100644 --- a/templates/strategy_eval.txt +++ b/templates/strategy_eval.txt @@ -1,19 +1,19 @@ -═══════════════════════════════════════ -MoFin 策略评估 | {GENERATED_AT} -═══════════════════════════════════════ - -【数据面板 — 代码采集,LLM不得修改】 - -组合概况: -总资产 {TOTAL_ASSETS} CNY | 现金 {CASH_AMOUNT} CNY | 仓位 {POSITION_PCT}% -持仓 {HOLDINGS_COUNT} 只 | 港币汇率 {HK_RATE} - -持仓明细: -{HOLDINGS_TABLE} - -⚠️ 浮亏>20%: -{HOLDINGS_RISK} - -现金来源: {CASH_SOURCE} - -═══════════════════════════════════════ +═══════════════════════════════════════ +MoFin 策略评估 | {GENERATED_AT} +═══════════════════════════════════════ + +【数据面板 — 代码采集,LLM不得修改】 + +组合概况: +总资产 {TOTAL_ASSETS} CNY | 现金 {CASH_AMOUNT} CNY | 仓位 {POSITION_PCT}% +持仓 {HOLDINGS_COUNT} 只 | 港币汇率 {HK_RATE} + +持仓明细: +{HOLDINGS_TABLE} + +⚠️ 浮亏>20%: +{HOLDINGS_RISK} + +现金来源: {CASH_SOURCE} + +═══════════════════════════════════════ diff --git a/xmpp_logger.py b/xmpp_logger.py new file mode 100644 index 00000000..fa4b76a6 --- /dev/null +++ b/xmpp_logger.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python3 +"""xmpp_logger.py — XMPP 消息日志采集器 + +记录所有 XMPP 通信事件到 JSONL 日志文件。通过 hook 方式接入: +只需在消息发送点加一行 log_xmpp() 调用,不动业务逻辑。 + +日志文件: gateway/logs/xmpp_messages.jsonl +自动轮转: 保留最近 7 天 +""" +import json +import time as _time +import subprocess as _sp +import urllib.request as _ur +import re as _re +from pathlib import Path +from datetime import datetime, timedelta + +LOG_DIR = Path(__file__).resolve().parent / "gateway" / "logs" +LOG_FILE = LOG_DIR / "xmpp_messages.jsonl" +MAX_AGE_DAYS = 7 + +# Position-analyst profile config path (where model.provider is set) +HERMES_CONFIG = Path("/home/hmo/.hermes/profiles/position-analyst/config.yaml") +GATEWAY_SERVICE = "hermes-gateway-zhiwei.service" +RESTART_COOLDOWN_FILE = LOG_DIR / "last_restart.txt" +RESTART_COOLDOWN_SEC = 180 # 3 min: don't trigger another restart within 3 min of last + +# Map AgentsMeeting key_id -> Hermes provider name +KEY_TO_PROVIDER = { + "key1": "ocg-new", + "key2": "ocg-old", + "key3": "ocg-3", + "key4": "ocg-key4", + "key5": "ocg-key5", + "key6": "ocg-key6", +} + + +def current_provider() -> str | None: + """Read current model.provider from Hermes config. + + Walks the file tracking the model: block to find its nested `provider:` + (avoiding other blocks like `agent.alerts[0].provider:`). + """ + try: + in_model = False + for line in HERMES_CONFIG.read_text().splitlines(): + stripped = line.rstrip() + # Detect top-level "model:" at column 0 + if stripped == "model:" or stripped.startswith("model:") and not line.startswith(" "): + in_model = True + continue + # If we're in the model: block and hit a new column-0 key, exit + if in_model and line and not line.startswith((" ", "\t")): + in_model = False + continue + if in_model: + m = _re.match(r"^ provider:\s*(\S+)\s*$", line) + if m: + return m.group(1) + except Exception: + return None + return None + + +def switch_key(key_id: str) -> dict: + """Switch Hermes config to use the given key_id's provider, then restart Gateway.""" + provider = KEY_TO_PROVIDER.get(key_id) + if not provider: + return {"switched": False, "detail": f"unknown key_id {key_id}"} + + old = current_provider() + if old == provider: + return {"switched": False, "old": old, "new": provider, "detail": "already on this key"} + + try: + txt = HERMES_CONFIG.read_text() + new_txt, n = _re.subn(r"^ provider:\s*\S+\s*$", + f" provider: {provider}", txt, count=1, flags=_re.MULTILINE) + if n == 0: + return {"switched": False, "detail": "no provider: line found"} + HERMES_CONFIG.write_text(new_txt) + except Exception as e: + return {"switched": False, "detail": f"config edit failed: {e}"} + + # systemctl restart blocks until gateway drain completes (60+s). + # Fire asynchronously via Popen to avoid blocking cron; verify later. + try: + _sp.Popen(["sudo", "-n", "systemctl", "restart", GATEWAY_SERVICE], + stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True) + RESTART_COOLDOWN_FILE.write_text(str(_time.time())) + ok = True + detail = "restart triggered (async)" + except Exception as e: + ok = False + detail = f"restart trigger failed: {e}" + + # Wait long enough for systemd to drain + restart the gateway (max 90s) + _time.sleep(45) + verify = _verify_llm() + + return { + "switched": True, + "old": old, + "new": provider, + "key_id": key_id, + "restart": ok, + "verify": verify, + "detail": detail, + } + + +def _verify_llm(): + """Quick LLM ping test through Gateway.""" + try: + payload = json.dumps({"model": "deepseek-v4-flash", + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 5, "stream": False}).encode() + req = _ur.Request("http://127.0.0.1:8643/v1/chat/completions", + data=payload, + headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123"}) + _ur.urlopen(req, timeout=90) + return {"status": "ok"} + except Exception as e: + msg = str(e)[:200] + is_429 = "429" in msg or "RateLimit" in msg or "Weekly usage" in msg + is_timeout = "timed out" in msg.lower() or "Timeout" in msg + return {"status": "rate_limited" if is_429 else ("timeout" if is_timeout else "error"), + "error": msg} + + +def log_xmpp(direction, from_jid, to_jid, body, status="ok", error=None, latency_ms=0): + """记录一条 XMPP 消息事件。 + + Args: + direction: "out"(发送) 或 "in"(接收) + from_jid: 发送者 JID + to_jid: 接收者 JID + body: 消息体(自动截取前 200 字预览) + status: "ok" / "error" / "timeout" + error: 错误信息(仅 status!="ok" 时) + latency_ms: 延迟(毫秒) + """ + LOG_DIR.mkdir(parents=True, exist_ok=True) + + entry = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "direction": direction, + "from": from_jid, + "to": to_jid, + "body_preview": (body or "")[:200].replace("\n", " "), + "status": status, + "error": str(error)[:200] if error else None, + "latency_ms": latency_ms, + "epoch": _time.time(), + } + + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + + # 每次写入后检查是否需要轮转(采样:每 20 条轮转一次) + if LOG_FILE.stat().st_size > 500 * 1024: # >500KB + _rotate() + + +def _rotate(): + """保留最近 MAX_AGE_DAYS 天,丢弃旧日志""" + if not LOG_FILE.exists(): + return + cutoff = datetime.now() - timedelta(days=MAX_AGE_DAYS) + kept = [] + try: + with open(LOG_FILE, "r", encoding="utf-8") as f: + for line in f: + try: + e = json.loads(line) + if e["timestamp"][:10] >= cutoff.strftime("%Y-%m-%d"): + kept.append(line) + except Exception: + continue + with open(LOG_FILE, "w", encoding="utf-8") as f: + f.writelines(kept) + except Exception: + pass + + +def query(since=None, agent=None, status=None, limit=50): + """查询消息日志 + + Args: + since: ISO datetime string, 只返回此时间之后的消息 + agent: JID 片段,筛选发送或接收方包含此字符串的消息 + status: 筛选状态 "ok"/"error"/"timeout" + limit: 最大返回条数(默认 50) + """ + if not LOG_FILE.exists(): + return [] + results = [] + try: + with open(LOG_FILE, "r", encoding="utf-8") as f: + for line in f: + try: + e = json.loads(line) + if since and e["timestamp"] < since: + continue + if agent and agent not in e["from"] and agent not in e["to"]: + continue + if status and e["status"] != status: + continue + results.append(e) + except Exception: + continue + except Exception: + pass + results.sort(key=lambda x: x["timestamp"], reverse=True) + return results[:limit] + + +def stats(): + """获取消息统计:今日 + 本周""" + if not LOG_FILE.exists(): + return {"today": {"sent": 0, "failed": 0, "latency_avg": 0}, "week": {"sent": 0, "failed": 0}} + + now = datetime.now() + today = now.strftime("%Y-%m-%d") + week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d") + + latencies = [] + s = {"today": {"sent": 0, "failed": 0}, "week": {"sent": 0, "failed": 0}} + + try: + with open(LOG_FILE, "r", encoding="utf-8") as f: + for line in f: + try: + e = json.loads(line) + d = e["timestamp"][:10] + if d >= week_ago: + s["week"]["sent"] += 1 + if e["status"] != "ok": + s["week"]["failed"] += 1 + if d == today: + s["today"]["sent"] += 1 + if e["status"] != "ok": + s["today"]["failed"] += 1 + if e.get("latency_ms"): + latencies.append(e["latency_ms"]) + except Exception: + continue + except Exception: + pass + + s["today"]["latency_avg"] = round(sum(latencies) / len(latencies)) if latencies else 0 + return s + + +def health(): + """快速健康检查:返回最后消息年龄 + 最近1h错误率 + bot 日志状态 + Hermes Gateway 状态""" + result = {"status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, + "bot_activity": {}, "gateways": {}, "llm_provider": {}} + + # 1. 检查 xmpp_messages.jsonl + now_epoch = _time.time() + if LOG_FILE.exists(): + last_epoch = 0 + recent_total = 0 + recent_errors = 0 + one_hour_ago = now_epoch - 3600 + try: + with open(LOG_FILE, "r", encoding="utf-8") as f: + for line in f: + try: + e = json.loads(line) + ep = e.get("epoch", 0) + if ep > last_epoch: last_epoch = ep + if ep > one_hour_ago: + recent_total += 1 + if e["status"] != "ok": recent_errors += 1 + except Exception: continue + except Exception: pass + result["last_message_age_sec"] = int(now_epoch - last_epoch) if last_epoch else -1 + result["error_rate_1h"] = round(recent_errors / recent_total * 100, 1) if recent_total else 0 + + # 2. 检查知微 Bot systemd journal + 持久化状态 + try: + import subprocess + r = subprocess.run(["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "30 min ago", "-o", "cat"], + capture_output=True, timeout=5, text=True) + lines = [l for l in r.stdout.split("\n") if l.strip()] + inbound = [l for l in lines if "📩 收到" in l] + outbound = [l for l in lines if "📤 发送" in l or "📤" in l] + errors = [l for l in lines if "ERROR" in l or "TimeoutError" in l or "timed out" in l] + result["bot_activity"] = { + "inbound": len(inbound), "outbound": len(outbound), "errors": len(errors), + "last_error": errors[-1][:250] if errors else None, + "last_inbound": inbound[-1][:250] if inbound else None, + "last_outbound": outbound[-1][:200] if outbound else None, + } + if errors and not outbound: result["status"] = "degraded" + if inbound and not outbound: result["status"] = "degraded" + except Exception: + result["bot_activity"] = {"error": "journalctl failed"} + + # 3. 检查 Hermes Gateway 各 profile 状态(端口检测) + try: + import socket + profiles = {"zhiwei": 8643, "mohe": 8642, "xiaoguo": 8645} + for name, port in profiles.items(): + ok = False + try: + s = socket.create_connection(("127.0.0.1", port), timeout=2) + s.close() + ok = True + except Exception: pass + result["gateways"][name] = {"port": port, "alive": ok} + + # 测试知微的 LLM 调用是否可达 + if result["gateways"].get("zhiwei", {}).get("alive"): + try: + import urllib.request + payload = json.dumps({"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 5, "stream": False}).encode() + req = urllib.request.Request("http://127.0.0.1:8643/v1/chat/completions", + data=payload, headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123"}) + urllib.request.urlopen(req, timeout=90) + result["llm_provider"] = {"status": "ok", "latency": "fast"} + except Exception as e: + result["llm_provider"] = {"status": "timeout" if "timeout" in str(e).lower() else "error", + "error": str(e)[:150]} + except Exception as e: + result["gateways"] = {"error": str(e)[:100]} + + # 4. 综合判定 + if result.get("status") != "degraded": + la = result.get("last_message_age_sec", -1) + er = result.get("error_rate_1h", 0) + llm = result.get("llm_provider", {}).get("status", "") + if llm in ("timeout", "error"): result["status"] = "degraded" + elif la < 0: result["status"] = "no_data" + elif la > 600: result["status"] = "critical" + elif er > 50: result["status"] = "degraded" + elif la > 0: result["status"] = "ok" + + # 5. API Key 可用性 + bk = best_key() + if bk: + result["best_key"] = bk + if bk["issues"]: + result["status"] = "degraded" + + return result + + +def _fetch_keys(): + """从 AgentsMeeting Dashboard 获取可用 API Key 列表""" + try: + req = _ur.Request("http://127.0.0.1:5803/api/keys") + resp = _ur.urlopen(req, timeout=5) + data = json.loads(resp.read()) + return data.get("keys", []) if data.get("ok") else [] + except Exception: + return [] + + +def best_key(): + """选择最佳可用 API Key。 + + 优先级: rolling ok > weekly ok > monthly ok > 最低 usage + 返回: {"key_id": "key6", "label": "...", "usage": {...}, "reason": "..."} + """ + keys = _fetch_keys() + if not keys: + return None + + def score(k): + """分数越低越好""" + s = 0 + r = k.get("rolling", {}) + w = k.get("weekly", {}) + m = k.get("monthly", {}) + # rate-limited 惩罚 + if r.get("status") != "ok": s += 1000 + if w.get("status") != "ok": s += 100 + if m.get("status") != "ok": s += 10 + # usage 越高越差 + s += r.get("usage_percent", 0) * 0.01 + s += w.get("usage_percent", 0) * 0.001 + s += m.get("usage_percent", 0) * 0.0001 + # session_expired 惩罚 + if k.get("session_expired"): s += 500 + return s + + best = min(keys, key=score) + reasons = [] + if best["rolling"]["status"] != "ok": reasons.append(f"rolling {best['rolling']['usage_percent']}%") + if best["weekly"]["status"] != "ok": reasons.append(f"weekly {best['weekly']['usage_percent']}%") + if best["monthly"]["status"] != "ok": reasons.append(f"monthly {best['monthly']['usage_percent']}%") + if best.get("session_expired"): reasons.append("session_expired") + + return { + "key_id": best["key_id"], + "label": best["label"], + "masked": best.get("key_masked", ""), + "rolling": best["rolling"], + "weekly": best["weekly"], + "monthly": best["monthly"], + "session_expired": best.get("session_expired", False), + "issues": reasons, + "total_keys": len(keys), + } + + +def auto_heal(): + """自愈:检测并尝试修复 XMPP 通信问题。""" + h = health() + actions = [] + + # 0. LLM Provider 异常 → 先查是否有更好的 Key 可切换 + llm_status = h.get("llm_provider", {}).get("status") + llm_error = h.get("llm_provider", {}).get("error", "") + + # Detect rate-limit / timeout / error + is_rate_limited = (llm_status == "rate_limited" or + "429" in llm_error or "Weekly usage" in llm_error or + "RateLimit" in llm_error) + is_timeout_or_error = llm_status in ("timeout", "error") + + if is_rate_limited or is_timeout_or_error: + bk = best_key() + if bk: + actions.append({ + "action": "check_keys", + "best_key": bk["key_id"], + "current_provider": current_provider(), + "best_provider": KEY_TO_PROVIDER.get(bk["key_id"]), + "rolling_pct": bk["rolling"]["usage_percent"], + "weekly_pct": bk["weekly"]["usage_percent"], + "issues": bk["issues"], + }) + + # 如果 best key 对应的 provider 跟当前不同 → 切换 + target_provider = KEY_TO_PROVIDER.get(bk["key_id"]) + current = current_provider() + if target_provider and target_provider != current: + # 只有当 best key 自身至少 weekly ok 才切,否则切过去也白搭 + if bk["weekly"]["status"] == "ok": + action = switch_key(bk["key_id"]) + action["action_group"] = "switch_key" + actions.append(action) + else: + actions.append({ + "action": "skip_switch", + "reason": f"best key {bk['key_id']} weekly status {bk['weekly']['status']}, no improvement", + }) + + # 1. LLM Provider timeout/error(非429)→ 重启 Gateway(不是切 key) + if is_timeout_or_error and not is_rate_limited: + gw = h.get("gateways", {}).get("zhiwei", {}) + if gw.get("alive"): + # Check cooldown to avoid restart loops + try: + last_restart = 0 + if RESTART_COOLDOWN_FILE.exists(): + last_restart = float(RESTART_COOLDOWN_FILE.read_text().strip() or 0) + except Exception: + last_restart = 0 + elapsed = _time.time() - last_restart + if elapsed < RESTART_COOLDOWN_SEC: + actions.append({"action": "skip_restart", + "reason": f"cooldown: last restart {int(elapsed)}s ago (need >{RESTART_COOLDOWN_SEC}s)", + "last_restart_ts": last_restart}) + else: + try: + _sp.Popen(["sudo", "-n", "systemctl", "restart", GATEWAY_SERVICE], + stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, start_new_session=True) + RESTART_COOLDOWN_FILE.write_text(str(_time.time())) + actions.append({"action": "restart_hermes_gateway", + "target": "position-analyst", + "success": True, + "detail": "restart triggered (async), cooldown set"}) + except Exception as e: + actions.append({"action": "restart_hermes_gateway", + "target": "position-analyst", + "success": False, + "detail": str(e)[:100]}) + + # 2. Bot 无出站 → 重启知微 Bot + ba = h.get("bot_activity", {}) + if ba.get("inbound", 0) > 0 and ba.get("outbound", 0) == 0 and ba.get("errors", 0) > 0: + try: + r = _sp.run(["sudo", "-n", "systemctl", "restart", "xmpp-zhiwei"], capture_output=True, timeout=30, text=True) + actions.append({"action": "restart_zhiwei_bot", "target": "xmpp-zhiwei", + "success": r.returncode == 0, "detail": "restarted" if r.returncode == 0 else r.stderr[:100]}) + except Exception as e: + actions.append({"action": "restart_zhiwei_bot", "target": "xmpp-zhiwei", + "success": False, "detail": str(e)[:100]}) + + # 3. ejabberd → 重启容器 + if h.get("ejabberd", "") and "Up" not in str(h["ejabberd"]): + try: + r = _sp.run(["sudo", "-n", "docker", "restart", "ejabberd"], capture_output=True, timeout=30, text=True) + actions.append({"action": "restart_ejabberd", "target": "ejabberd", + "success": r.returncode == 0, "detail": "restarted" if r.returncode == 0 else r.stderr[:100]}) + except Exception as e: + actions.append({"action": "restart_ejabberd", "target": "ejabberd", + "success": False, "detail": str(e)[:100]}) + + return {"actions": actions, "status": h.get("status", "unknown")} + + +def _verify_heal(): + """自愈后验证:等 Gateway 启动完成,检测 LLM 是否恢复""" + _time.sleep(8) # 等 Gateway 完全启动 + h = health() + llm_ok = h.get("llm_provider", {}).get("status") == "ok" + gw_ok = h.get("gateways", {}).get("zhiwei", {}).get("alive", False) + return {"llm_recovered": llm_ok, "gateway_alive": gw_ok, "status": h.get("status")}