| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- #!/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 jload(p: Path):
- """读 JSON 配置。必须用 utf-8-sig: 记事本和 PowerShell 5.1 的 `Set-Content -Encoding UTF8`
- 写出的都是 **带 BOM** 的 UTF-8, 裸 utf-8 读会 JSONDecodeError (Unexpected UTF-8 BOM)。"""
- return json.loads(Path(p).read_text(encoding="utf-8-sig"))
- def cfg():
- c = dict(DEFAULT); p = ROOT / "configs/serve.json"
- if p.exists():
- try: c.update(jload(p))
- except Exception as ex: # 配置写坏不该让整个产品起不来: 退回内置默认端口, 但要说清楚
- print(f" [X] {p} 读不了 ({ex.__class__.__name__}: {str(ex)[:100]}); 本次用内置默认端口。"
- f" 重跑 install 脚本可重写该文件")
- 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 = jload(ROOT / "configs/models.json")
- 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 = jload(PIDS) 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 = jload(PIDS) 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 = jload(PIDS)
- 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())
|