#!/usr/bin/env python3 # -*- coding: utf-8 -*- """观澜 · 如东样板 v2 启动器 (跨平台, 只用标准库). 用法: python guanlan.py check 自检: Python/依赖/端口/数据产物/发布件/Ollama python guanlan.py serve 启动全部服务 (网关 + 工作台 + CMS + 仿真×2 + 三维), 写 run/pids.json, 等 healthz python guanlan.py status 各服务端口/进程状态 python guanlan.py stop 停止本启动器起的全部服务 python guanlan.py open 打印入口地址 (并尝试用系统浏览器打开) python guanlan.py qa 离线模型已知答案回放 (需服务已起; 参数见 scripts/llm_known_answer.py) 端口与路径在 configs/serve.json (没有则用内置默认). 所有服务只绑 127.0.0.1.""" from __future__ import annotations import json, os, socket, subprocess, sys, time, urllib.request, webbrowser, signal from pathlib import Path ROOT = Path(__file__).resolve().parent; RUN = ROOT / "run"; LOGS = ROOT / "logs"; PIDS = RUN / "pids.json" WIN = os.name == "nt" DEFAULT = { "host": "127.0.0.1", "gateway": 28084, "detail": 18033, "cms": 18020, "sim": 18791, "sim_sys": 18792, "viewer": 64292, "ollama": 11434, "release_dir": "release", "viewer_dir": "release/viewer", "sim_dir": "resources/oem_envision_sc1_rudong2014", "python": "", # 空 = 当前解释器 (安装脚本建的 .venv) } def cfg(): c = dict(DEFAULT); p = ROOT / "configs/serve.json" if p.exists(): c.update(json.loads(p.read_text(encoding="utf-8"))) return c def py(c): return c["python"] or sys.executable def up(host, port, t=0.4): try: with socket.create_connection((host, port), timeout=t): return True except OSError: return False def env(c): e = dict(os.environ); rel = str(ROOT / c["release_dir"]) raw = Path(c.get("raw_dir", "data/raw")); raw = raw if raw.is_absolute() else (ROOT / raw) e.update(GUANLAN_RELEASE_DIR=rel, GUANLAN_PORTAL=str(ROOT / c["release_dir"] / "portal.html"), GUANLAN_STATIC=rel, WINDSCADA_ROOT=str(ROOT), WINDCMS_ROOT=str(ROOT), WINDSCADA_RUDONG_SRC=str(raw), GUANLAN_OLLAMA_PORT=str(c["ollama"]), PYTHONUTF8="1", PYTHONIOENCODING="utf-8"); return e def services(c): P = py(c); h = c["host"] return [ ("detail", c["detail"], [P, "scripts/windscada_serve.py", "--host", h, "--port", str(c["detail"])]), ("cms", c["cms"], [P, "scripts/windcms.py", "serve", "--farm", "rudong", "--port", str(c["cms"])]), ("viewer", c["viewer"], [P, "-m", "http.server", str(c["viewer"]), "--bind", h, "--directory", str(ROOT / c["viewer_dir"])]), ("sim_sys", c["sim_sys"], [P, "-u", str(ROOT / c["release_dir"] / "sim_sys_server.py")]), ("sim", c["sim"], [P, "-m", "http.server", str(c["sim"]), "--bind", h, "--directory", str(ROOT / c["sim_dir"])]), ("gateway", c["gateway"], [P, "scripts/guanlan_gateway.py", "--host", h, "--port", str(c["gateway"])]), ] def spawn(cmd, log: Path, e): f = open(log, "ab") kw = dict(cwd=str(ROOT), env=e, stdout=f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) if WIN: kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0) else: kw["start_new_session"] = True return subprocess.Popen(cmd, **kw).pid def alive(pid): if WIN: r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True); return str(pid) in r.stdout try: os.kill(pid, 0); return True except OSError: return False def kill(pid): if WIN: subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True) else: try: os.killpg(os.getpgid(pid), signal.SIGTERM) except OSError: try: os.kill(pid, signal.SIGTERM) except OSError: pass def cmd_check(c): ok = True def row(name, good, note=""): nonlocal ok; ok &= bool(good); print(f" [{'OK' if good else 'FAIL'}] {name}{(' — ' + note) if note else ''}") print(f"观澜·如东样板 v2 自检 @ {ROOT}"); v = sys.version_info; row("Python ≥ 3.11", v >= (3, 11), f"{v.major}.{v.minor}.{v.micro} ({sys.executable})") for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"): try: __import__(m); row(f"依赖 {m}", True) except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)") for rel, n in (("outputs/rudong/windscada", "L0/L1 产物 (parquet)"), ("outputs/rudong/ontology/objects.json", "本体对象库"), ("outputs/rudong/sop/findings.json", "findings"), ("outputs/rudong/guanlan/facts_contract_v0.json", "事实契约"), (c["release_dir"] + "/portal.html", "门户"), (c["release_dir"] + "/sim_sys_server.py", "仿真合页服务"), (c["release_dir"] + "/如东SWT40_控制律仿真台_20260906.zip", "仿真合页资料包"), (c["viewer_dir"], "三维 viewer 资产"), (c["sim_dir"], "仿真回放资产"), (c["release_dir"] + "/如东", "治理清单交付件 (可选)")): p = ROOT / rel; exists = p.exists() and (any(p.iterdir()) if p.is_dir() else p.stat().st_size > 0) if "可选" in n: print(f" [{'OK' if exists else '--'}] {n}: {rel}") else: row(n, exists, rel) raw = Path(c.get("raw_dir", "data/raw")); raw = raw if raw.is_absolute() else (ROOT / raw) print(f" [{'OK' if raw.exists() else '--'}] 原始件目录 (补数据放这里): {raw}{'' if raw.exists() else ' (不存在; 原始件不随包分发, 只影响摄入命令, 不影响页面)'}") for name, port, _ in services(c): print(f" [{'运行中' if up(c['host'], port) else '空闲'}] 端口 {port} ({name})") # 模型闸: 探针 (generate 非 tags) + 档位模型是否都在本机 + 摘要记录; 无模型时问答不可用, 其余页面不受影响 sys.path.insert(0, str(ROOT)) try: from src.ontology import llm_gate; mcfg = json.loads((ROOT / "configs/models.json").read_text(encoding="utf-8")) pr = llm_gate.probe(mcfg["tiers"]["default_qa"]["model"], timeout=90); row("本机模型 Ollama 探针 (默认档)", pr["ok"], f"{pr.get('latency_s')} s, digest {pr.get('digest')}" if pr["ok"] else pr.get("err", "") + " (启动 Ollama 并按 configs/models.json 的 pull_commands 拉模型)") try: with urllib.request.urlopen(f"http://{c['host']}:{c['ollama']}/api/tags", timeout=10) as r: have = {m["name"] for m in json.load(r).get("models", [])} for tier, t in mcfg["tiers"].items(): name = t["model"]; ok_ = name in have or (name + ":latest") in have; print(f" [{'OK' if ok_ else '--'}] 模型档 {tier}: {name}{'' if ok_ else ' (未下载: ' + 'ollama pull ' + name + ')'}") except Exception as ex: print(f" [--] 模型清单不可读: {ex.__class__.__name__}") st = llm_gate.state(); print(f" [{'FAIL' if st['open'] else 'OK'}] 模型可用性检查: {'已熔断 (连续失败后自动停用, 60 秒后重试)' if st['open'] else '正常'}; 审计日志 {st['audit']}") except Exception as ex: row("模型可用性检查", False, f"{ex.__class__.__name__}: {str(ex)[:100]}") print("结论:", "全绿, 可 serve" if ok else "有 FAIL 项, 先按提示处理"); return 0 if ok else 2 def cmd_serve(c): RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True); pids = json.loads(PIDS.read_text(encoding="utf-8")) if PIDS.exists() else {}; e = env(c) for name, port, cmd in services(c): if up(c["host"], port): print(f" {name} {port} 已在运行 (复用)"); continue if name == "gateway": time.sleep(1.5) pid = spawn(cmd, LOGS / f"{name}.log", e); pids[name] = {"pid": pid, "port": port}; print(f" {name} {port} 启动 pid {pid}") PIDS.write_text(json.dumps(pids, ensure_ascii=False, indent=1), encoding="utf-8") url = f"http://{c['host']}:{c['gateway']}/healthz" for _ in range(40): try: with urllib.request.urlopen(url, timeout=20) as r: d = json.load(r); break except Exception: time.sleep(1) else: print("网关 healthz 60 s 内未就绪, 看 logs/gateway.log"); return 2 bad = [m for m in d["modules"] if not m["ok"]]; print(f"入口 http://{c['host']}:{c['gateway']}/ 状态 {d['status']} {d['mode']} 模块 {len(d['modules']) - len(bad)}/{len(d['modules'])} ok") for m in bad: print(f" [DOWN] {m['path']} {m['name']} (看 logs/)") return 0 if not bad else 1 def cmd_status(c): pids = json.loads(PIDS.read_text(encoding="utf-8")) if PIDS.exists() else {} for name, port, _ in services(c): p = pids.get(name, {}).get("pid"); print(f" {name:8s} 端口 {port} {'监听中' if up(c['host'], port) else '未监听'} pid {p if p else '-'} {'存活' if p and alive(p) else ''}") return 0 def cmd_stop(c): if not PIDS.exists(): print("没有 pids.json (不是本启动器起的, 或已停)"); return 0 pids = json.loads(PIDS.read_text(encoding="utf-8")) for name, v in pids.items(): kill(v["pid"]); print(f" 停 {name} pid {v['pid']}") time.sleep(1.5); left = [n for n, p, _ in services(c) if up(c["host"], p)]; PIDS.unlink() print("全部已停" if not left else f"仍在监听 (可能是别的实例): {left}"); return 0 def main(): a = sys.argv[1] if len(sys.argv) > 1 else "check"; c = cfg() if a == "open": url = f"http://{c['host']}:{c['gateway']}/"; print(url); webbrowser.open(url); return 0 if a == "qa": return subprocess.call([py(c), str(ROOT / "scripts/llm_known_answer.py"), "--port", str(c["detail"])] + sys.argv[2:], cwd=str(ROOT)) return {"check": cmd_check, "serve": cmd_serve, "status": cmd_status, "stop": cmd_stop}.get(a, lambda c: (print(__doc__), 2)[1])(c) if __name__ == "__main__": sys.exit(main())