cli.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """观澜 · 如东样板 v2 启动器 (跨平台, 只用标准库). 用法:
  4. python guanlan.py check 自检: Python/依赖/端口/数据产物/发布件/Ollama
  5. python guanlan.py serve 启动全部服务 (网关 + 工作台 + CMS + 仿真×2 + 三维), 写 run/pids.json, 等 healthz
  6. python guanlan.py status 各服务端口/进程状态
  7. python guanlan.py stop 停止本启动器起的全部服务
  8. python guanlan.py open 打印入口地址 (并尝试用系统浏览器打开)
  9. python guanlan.py qa 离线模型已知答案回放 (需服务已起; 参数见 scripts/llm_known_answer.py)
  10. 端口与路径在 configs/serve.json (没有则用内置默认). 组件默认只绑 127.0.0.1;
  11. 门户网关可用 serve.json 的 public_host 放到所有网卡 (用户令 2026-09-19「监听所有 IP」, 见 §7)。"""
  12. from __future__ import annotations
  13. try:
  14. from app_common.app_common_guanlan.api import install_root as _install_root
  15. except ImportError: # 极端兜底(包结构异常时按位置上跳)
  16. from pathlib import Path as _P
  17. def _install_root(_f): return _P(_f).resolve().parents[3]
  18. import json, os, re, socket, subprocess, sys, time, urllib.request, warnings, webbrowser, signal
  19. from pathlib import Path
  20. ROOT = _install_root(__file__); RUN = ROOT / "run"; LOGS = ROOT / "logs"; PIDS = RUN / "pids.json"
  21. sys.path.insert(0, str(ROOT))
  22. from src import paths as P # 配置/路径唯一取用口 (P.SERVE_JSON / P.config()…, 见 docs §11)
  23. WIN = os.name == "nt"
  24. def _hermetic():
  25. """摘掉机器全局的 PYTHONPATH: 本项目依赖都装在 .venv 里, 而 PYTHONPATH 会被插到 sys.path 前面,
  26. 顶掉 venv 里 pin 住的版本 (实测 requests 2.33.0 顶掉 2.34.2), 还会让 pip 少装传递依赖。
  27. 子进程从 env() 继承的是摘干净之后的环境。"""
  28. for d in [x for x in os.environ.pop("PYTHONPATH", "").split(os.pathsep) if x]:
  29. while d in sys.path: sys.path.remove(d)
  30. _hermetic()
  31. DEFAULT = {
  32. "host": "127.0.0.1", "public_host": "0.0.0.0", "gateway": 28084, "detail": 18033, "cms": 18020, "sim": 18791, "sim_sys": 18792, "viewer": 64292, "ollama": 11434,
  33. "release_dir": "release", "viewer_dir": "release/viewer", "sim_dir": "resources/oem_envision_sc1_rudong2014",
  34. "python": "", # 空 = 当前解释器 (安装脚本建的 .venv)
  35. }
  36. # ★`host` = 组件(内部 API: detail/cms/viewer/sim)监听地址, **127.0.0.1**(只本机, 不联网)。
  37. # `public_host` = **门户网关**监听地址(用户令 2026-09-19「观澜改为监听所有 IP」):
  38. # 出厂/内置默认 **"0.0.0.0"** = 门户在任何网卡上都能访问(别的机器开 http://<本机IP>:28084/);
  39. # 空串 ⇒ 跟 host(只本机); 填某个内网 IP ⇒ 只放那一个地址。configs/serve.json 是运行期真源,
  40. # 这里只是"配置读不到时的兜底", 两边口径保持一致。
  41. # ★只放网关一个口子出去,**组件仍绑本机** —— 对外只有一个 28084 入口(网关自己做路由与改写),
  42. # 内部 API(/api/*、CMS 18020、viewer 64292、sim 18791)不直接暴露。
  43. # ★页面**没有鉴权**:对外监听等于把全场的运行数据摊开。请同时做一层限制(防火墙白名单/反代加
  44. # 认证/只绑内网 IP),别把它直接挂公网。改法与回退口径见 README 第二节与 docs/系统设计说明.md §7。
  45. def jload(p: Path):
  46. """读 JSON 配置。必须用 utf-8-sig: 记事本和 PowerShell 5.1 的 `Set-Content -Encoding UTF8`
  47. 写出的都是 **带 BOM** 的 UTF-8, 裸 utf-8 读会 JSONDecodeError (Unexpected UTF-8 BOM)。"""
  48. return json.loads(Path(p).read_text(encoding="utf-8-sig"))
  49. def cfg():
  50. c = dict(DEFAULT); p = P.SERVE_JSON # 端口/路径真源 (配置取用口 = src/paths.py)
  51. if p.exists():
  52. try: c.update(jload(p))
  53. except Exception as ex: # 配置写坏不该让整个产品起不来: 退回内置默认端口, 但要说清楚
  54. print(f" [X] {p} 读不了 ({ex.__class__.__name__}: {str(ex)[:100]}); 本次用内置默认端口。"
  55. f" 重跑 install 脚本可重写该文件")
  56. return c
  57. def py(c):
  58. """解释器: serve.json 的 python (相对路径按**安装根**解析) → 本目录 .venv (按平台探测) → 当前解释器。
  59. 2026-09-11: 原来只认绝对路径 —— 安装目录整个拷到别的电脑/别的盘后, 那个绝对路径就失效了。"""
  60. v = (c.get("python") or "").strip()
  61. if v:
  62. p = Path(v)
  63. p = p if p.is_absolute() else (ROOT / p)
  64. if p.exists(): return str(p)
  65. for rel in (".venv/Scripts/python.exe", ".venv/bin/python", ".venv/bin/python3"):
  66. p = ROOT / rel
  67. if p.exists(): return str(p)
  68. return sys.executable
  69. def up(host, port, t=0.4):
  70. try:
  71. with socket.create_connection((host, port), timeout=t): return True
  72. except OSError: return False
  73. def env(c):
  74. e = dict(os.environ); rel = str(ROOT / c["release_dir"])
  75. raw = Path(c.get("raw_dir", "data/raw")); raw = raw if raw.is_absolute() else (ROOT / raw)
  76. 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),
  77. WINDSCADA_RUDONG_SRC=str(raw), GUANLAN_OLLAMA_PORT=str(c["ollama"]), PYTHONUTF8="1", PYTHONIOENCODING="utf-8"); return e
  78. def public_host(c) -> str:
  79. """门户网关对外监听地址: `public_host` 空则跟 `host`(出厂=127.0.0.1 只本机)。"""
  80. return (str(c.get("public_host") or "").strip() or str(c["host"]))
  81. def lan_ips():
  82. """本机可用的 IPv4(给"该用什么地址打开"那句话用; 取不到就空)。"""
  83. out = []
  84. try:
  85. for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
  86. ip = info[4][0]
  87. if ip and not ip.startswith("127.") and ip not in out:
  88. out.append(ip)
  89. except OSError:
  90. pass
  91. return out
  92. def services(c):
  93. P = py(c); h = c["host"]; hp = public_host(c)
  94. return [
  95. *([] if c.get("retire_detail") else [
  96. ("detail", c["detail"], [P, "scripts/windscada_serve.py", "--host", h, "--port", str(c["detail"])]),
  97. ]), # ★P12:detail 已由 Java 后端承接,retire_detail 控制是否还拉起
  98. # ★2026-09-29 用户令:**算法服务随 serve 默认起**(算法层 = Python + FastAPI,P10)。
  99. # 端口取自 configs/serve.json 的 algorithm(缺省 18050);它只读、不改盘上产物,
  100. # 是后端(各栈)与前端调用算法的统一入口。
  101. ("algorithm", c.get("algorithm", 18050),
  102. [P, "scripts/algorithm_service.py", "--host", h, "--port", str(c.get("algorithm", 18050))]),
  103. # ★2026-09-29 用户令:前端完全 Vue/Node(P11)—— Vue 构建产物 release/web/ 由静态伺服托起,
  104. # 经网关 /web 前缀暴露,于是前端与后端/算法**同源**取数(前端代码里不带 host)。
  105. ("web", c.get("web", 28110),
  106. [P, "scripts/static_server.py", "--port", str(c.get("web", 28110)), "--host", h,
  107. "--dir", str(ROOT / "release" / "web"), "--comp", "web"]),
  108. ("cms", c["cms"], [P, "scripts/windcms.py", "serve", "--farm", "rudong", "--port", str(c["cms"])]),
  109. ("viewer", c["viewer"], [P, "scripts/static_server.py", "--port", str(c["viewer"]), "--host", h,
  110. "--dir", str(ROOT / c["viewer_dir"]), "--comp", "viewer"]),
  111. ("sim_sys", c["sim_sys"], [P, "-u", str(ROOT / c["release_dir"] / "sim_sys_server.py")]),
  112. ("sim", c["sim"], [P, "scripts/static_server.py", "--port", str(c["sim"]), "--host", h,
  113. "--dir", str(ROOT / c["sim_dir"]), "--comp", "sim"]),
  114. # ★只有网关用 public_host(用户令 2026-09-19「监听所有 IP」): 组件一律留在 host(本机)
  115. ("gateway", c["gateway"], [P, "scripts/guanlan_gateway.py", "--host", hp, "--port", str(c["gateway"])]),
  116. ]
  117. def spawn(cmd, log: Path, e):
  118. f = open(log, "ab")
  119. kw = dict(cwd=str(ROOT), env=e, stdout=f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
  120. if WIN:
  121. # 2026-09-16: 统一到 CREATE_NO_WINDOW (src/proc.py) —— 原先用 DETACHED_PROCESS, 子进程没有控制台,
  122. # 而 .venv 的 python.exe 是**转发器**, 它会再拉一个真解释器; 落地实测那一步会冒出可见黑窗。
  123. # CREATE_NO_WINDOW 给的是"没有窗口的控制台", stdio 照旧重定向到日志, 且与 DETACHED 互斥故只用前者。
  124. from src import proc as _proc
  125. kw["creationflags"] = _proc.flags()
  126. else: kw["start_new_session"] = True
  127. return subprocess.Popen(cmd, **kw).pid
  128. def alive(pid):
  129. if not pid:
  130. return False
  131. if WIN:
  132. # errors="replace" 必须有: tasklist 的输出是**控制台代码页**(中文 Windows = GBK), 而本进程
  133. # 可能是 PYTHONUTF8=1 起的(默认文本编码变 UTF-8) → 解码失败会让 r.stdout 变成 None,
  134. # 下一行 `str(pid) in None` 直接 TypeError。2026-09-12 由运维控制台的动作进程实测逮到。
  135. # ★ 走 src.proc.run_text: 本函数也会被**无控制台**的进程调用 (网关/运维任务), 裸 spawn tasklist
  136. # 会给它新建可见控制台 → 反复闪窗 (2026-09-16)。
  137. from src import proc as _proc
  138. r = _proc.run_text(["tasklist", "/FI", f"PID eq {pid}"])
  139. return r.stdout is not None and str(pid) in r.stdout
  140. try: os.kill(pid, 0); return True
  141. except OSError: return False
  142. def kill(pid):
  143. from src import proc as _proc
  144. if WIN: _proc.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True)
  145. else:
  146. try: os.killpg(os.getpgid(pid), signal.SIGTERM)
  147. except OSError:
  148. try: os.kill(pid, signal.SIGTERM)
  149. except OSError: pass
  150. def cmd_check(c):
  151. ok = True
  152. def row(name, good, note=""):
  153. nonlocal ok; ok &= bool(good); print(f" [{'OK' if good else 'FAIL'}] {name}{(' — ' + note) if note else ''}")
  154. print(f"观澜·如东样板 v2 自检 @ {ROOT}"); v = sys.version_info; row("Python ≥ 3.11", v >= (3, 11), f"{v.major}.{v.minor}.{v.micro} ({sys.executable})")
  155. # 源码可编译性 —— 重点是**非法转义序列**: 普通字符串里写 `\d` `\*` 这类, Python 3.12 只给
  156. # SyntaxWarning, 未来版本会变成 SyntaxError。这类问题在开发机上永远看不见, 换台机器/换个解释器才炸
  157. # (2026-09-11 逮到 src\windcms\serve.py 里 JS 正则的两处, 已按"补成合法转义"修好)。
  158. srcs = sorted(list((ROOT / "src").rglob("*.py")) + list((ROOT / "scripts").rglob("*.py")))
  159. bad = []
  160. with warnings.catch_warnings(record=True) as w:
  161. warnings.simplefilter("always")
  162. for f in srcs:
  163. try: compile(f.read_text(encoding="utf-8"), str(f), "exec")
  164. except SyntaxError as e: bad.append(f"{f.relative_to(ROOT)}:{e.lineno} {e.msg}")
  165. for it in w:
  166. if issubclass(it.category, SyntaxWarning): bad.append(f"{Path(str(it.filename)).name}:{it.lineno} {it.message}")
  167. row(f"源码可编译 ({len(srcs)} 个文件, 无非法转义/语法错)", not bad, "; ".join(bad[:3]))
  168. # 语言包闸前移 (2026-09-19 实逮): `app_frontEnd/.../pages/build.py`(原 `src/windscada/ui/build.py`)在**出页之前**会跑 lang.check(),
  169. # 不过就抛 RuntimeError —— 设计是对的(不会把中文残留发给用户), 但代价是**页面 500**:
  170. # 实测我加了一条英文措辞里含 'spectra'(36 条内部审核黑话之一), `/v2` 与 `/detail/` 当场全 500。
  171. # 这类错误在部署前就该报出来, 所以在这里也跑一遍(与出页同一个 check(), 不是另写一套)。
  172. try:
  173. from app_frontEnd.app_frontEnd_guanlan.api import lang as _lang
  174. _lb = _lang.check()
  175. row(f"语言包 (前后端界面文案 zh/en 成对, {len(_lang.L)} 条)", not _lb, "; ".join(_lb[:3]))
  176. except Exception as e: # 语言包读不出来同样要报, 不静默
  177. row("语言包", False, f"{type(e).__name__}: {e}")
  178. # 子进程口径自检 (2026-09-16 加): 这三条是**回归守卫** —— 它们各自都真炸过一次, 而炸法都是"静默":
  179. # ① capture_output=True 与显式 stdout 互斥 → 异常被 except 吞成 alive=False ⇒ 在跑的任务被
  180. # 误判成"被强杀", 页面显示完成、重算按钮可再点(可能并发重算);
  181. # ② CREATE_NO_WINDOW 新建的控制台会把子进程标准句柄吸走 ⇒ 重算日志一个字都没有、任务挂住;
  182. # ③ 不弹窗 (NO_WINDOW 位必须真的设上, 否则"点按钮闪黑窗"回来)。
  183. try:
  184. from src import proc as _proc
  185. _r = _proc.run_text([sys.executable, "-c", "print('proc-ok')"])
  186. row("子进程口径: run_text(capture_output) 可用", _r.returncode == 0 and "proc-ok" in (_r.stdout or ""),
  187. "src/proc.py (capture_output 与显式 stdout 互斥, 冲突会让 job_running 误判任务已死)")
  188. import tempfile as _tf
  189. _lg = Path(_tf.gettempdir()) / "_guanlan_proc_check.log"
  190. _p = _proc.spawn([sys.executable, "-c", "print('spawn-ok')"], log=_lg); _p.wait(timeout=60)
  191. _txt = _lg.read_text(encoding="utf-8", errors="replace") if _lg.exists() else ""
  192. try: _lg.unlink()
  193. except OSError: pass
  194. row("子进程口径: spawn(log=) 输出进日志", "spawn-ok" in _txt,
  195. "CREATE_NO_WINDOW 会吸走句柄, 不显式继承则日志空白 (重算看起来卡住)")
  196. row("子进程口径: 无窗口位已设 (CREATE_NO_WINDOW)", bool(_proc.flags() & 0x08000000) or os.name != "nt",
  197. f"flags={hex(_proc.flags())}")
  198. except Exception as _e:
  199. row("子进程口径自检", False, f"{type(_e).__name__}: {_e}")
  200. # 入口引用闭合 (2026-09-16 现场实炸后加): 目标机 (D:\产品\app) 双击 start.bat 报
  201. # "无法找到脚本文件 start_hidden.vbs" —— 包少带了一个被引用的文件。这条守卫在**装机后**复查一遍:
  202. # start.bat/check.bat/stop.bat/install.* 引用的每个文件都必须在位, 缺一个就 FAIL。
  203. # (那个 .vbs 已按用户令删除, 无窗口启动改用 pythonw.exe + scripts\guanlan_start_hidden.py;
  204. # 守卫与实现语言无关, 照样生效 —— 现在它盯的是 start.bat → scripts\guanlan_start_hidden.py 这类引用。)
  205. try:
  206. from src.entry_refs import encoding_problems, missing_refs, referenced_tree_files
  207. _miss = missing_refs(ROOT)
  208. _nref = sum(len(v) for v in referenced_tree_files(ROOT).values())
  209. row(f"入口脚本引用闭合 ({_nref} 条: start.bat→guanlan_start_hidden.py 等)", not _miss,
  210. "; ".join(f"{e} 缺 {r}" for e, r in _miss[:3]) or "每个入口引用的文件都在位")
  211. _enc = encoding_problems(ROOT)
  212. row("入口脚本编码守则 (.ps1 = UTF-8 BOM + CRLF; .bat = 纯 ASCII/CRLF/无 BOM/无 `<<`; .sh = LF)", not _enc,
  213. "; ".join(_enc[:2]) or "编码与字符都对 (PS 5.1 才不会把中文按 GBK 解错; 批处理里不放中文/重定向符)")
  214. except Exception as _e:
  215. row("入口脚本引用闭合", False, f"{type(_e).__name__}: {_e}")
  216. # 版本管理 (2026-09-17 用户令): ① 版本记录 md 必须与 src/version.py 一致(否则包里会出现
  217. # "包说 v2.5.0、记录表最后一行还是 v2.4.0");② 卸载入口必须在位(装得上卸不掉 = 交付缺陷)。
  218. try:
  219. from src import version as _V
  220. import importlib.util as _ilu2
  221. _sp = _ilu2.spec_from_file_location('_version_log', ROOT / "scripts" / "version_log.py")
  222. _vm = _ilu2.module_from_spec(_sp)
  223. _sp.loader.exec_module(_vm)
  224. _doc = ROOT / "docs" / "版本记录.md"
  225. _ok = _doc.is_file() and _vm.block_of(_doc.read_text(encoding="utf-8")) == _vm.body().strip()
  226. row(f"版本管理: {_V.NAME} v{_V.VERSION}(记录表与代码一致, 包名 {_V.package_name()})", _ok,
  227. "" if _ok else "docs/版本记录.md 与 src/version.py 不一致 —— 跑 python scripts/version_log.py --write")
  228. _un = [f for f in ("uninstall.bat", "uninstall.sh") if (ROOT / f).is_file()]
  229. _unpy = ROOT / "scripts" / "guanlan_uninstall.py"
  230. _uok = len(_un) == 2 and _unpy.is_file()
  231. row("卸载入口在位 (uninstall.bat / uninstall.sh → scripts/guanlan_uninstall.py)", _uok,
  232. f"{len(_un)}/2 个入口" + ("" if _uok else " —— 缺卸载入口: 装得上卸不掉"))
  233. except Exception as _e:
  234. row("版本管理与卸载入口", False, f"{type(_e).__name__}: {_e}")
  235. # 模块化目录与边界(用户令 2026-09-22「按算法、数据接入管理、前端、后端等系统模块重构,每模块一个目录」):
  236. # 组件化/高内聚低耦合/可插拔这条**必须机器可查** —— 边界审计查五件事: 目录结构齐、模块间只经 api 调用、
  237. # 依赖方向合规(按 configs/modules.yaml 的 allow)、公共层不依赖业务模块、api 的转发目标真实存在。
  238. try:
  239. import importlib.util as _ilu5
  240. _sp5 = _ilu5.spec_from_file_location('_modb', ROOT / "scripts" / "module_boundary_audit.py")
  241. _mb = _ilu5.module_from_spec(_sp5)
  242. _sp5.loader.exec_module(_mb)
  243. _rc5, _probs, _notes, _mods = _mb.audit(verbose=False)
  244. row(f"模块化目录与边界({len(_mods)} 模块:算法/数据接入/前端/后端/本体/公共/质量门)", _rc5 == 0,
  245. ("登记 %d 模块 · 模块间只经 api 调用 · 依赖方向合规 · 接口目标全在位" % len(_mods)
  246. if _rc5 == 0 else
  247. "违规 %d 条 ⇒ " % len(_probs) + "; ".join(f'{r}:{m[:60]}' for r, m in _probs[:2])))
  248. except Exception as _e:
  249. row("模块化目录与边界", False, f"{type(_e).__name__}: {_e}")
  250. # 交付文档三件在位(2026-09-22 用户令「需求分析/系统设计说明/数据要求说明」): 文档名里带**当前版本号**,
  251. # 所以升一次版本号, 旧文档名就过期了 —— 不发 FAIL 的话, 包会悄悄带着 v2.9.2 名字的文档进 v2.10.0 交付。
  252. # ★2026-09-22 用户令:「三份 docx 不同步到远端服务器」⇒ 交付部署可以不携带这批文档。
  253. # 判据先看 `docs/src` 在不在: 不在 = 本机是**不随文档的部署**(远端演示机就是这种), 只报提示行、不 FAIL,
  254. # 且在 import python-docx **之前**分流 —— 那台机没装 docx 依赖, 直接进渲染器会炸成 ModuleNotFoundError。
  255. try:
  256. if not (ROOT / "docs" / "src").is_dir():
  257. row("交付文档三件 (需求分析/系统设计说明/数据要求说明)", True,
  258. "本机未部署交付文档(docs/src 不在位, 按部署口径跳过;需要时跑 "
  259. "python scripts/delivery_docs_build.py 重建, 依赖 python-docx)")
  260. else:
  261. import importlib.util as _ilu4
  262. _sp4 = _ilu4.spec_from_file_location('_ddb', ROOT / "scripts" / "delivery_docs_build.py")
  263. _ddb = _ilu4.module_from_spec(_sp4)
  264. _sp4.loader.exec_module(_ddb)
  265. _V2 = _ddb.VERSION
  266. _miss, _stale, _figmiss, _leak = [], [], [], {}
  267. for _k, _spec in _ddb.DOCS.items():
  268. _out = ROOT / _spec['out'].format(v=_V2)
  269. _src = ROOT / _spec['src'].format(v=_V2)
  270. if not _out.is_file():
  271. _miss.append(_out.name)
  272. _old = sorted((ROOT / "docs").glob(_spec['out'].format(v='*').split('/')[-1]))
  273. _stale += [p.name for p in _old if p.name != _out.name]
  274. if not _src.is_file():
  275. _miss.append(_src.name)
  276. continue
  277. _mdtxt = _src.read_text(encoding='utf-8')
  278. for _w, _n in _ddb.forbidden_hits(_mdtxt).items(): # ★对外件去标识化硬门
  279. _leak[_w] = _leak.get(_w, 0) + _n
  280. if _spec.get('strict_terms'): # ★数据要求说明: 去实现细节/去现状
  281. for _w, _n in _ddb.strict_hits(_mdtxt).items():
  282. _leak['严格:' + _w] = _leak.get('严格:' + _w, 0) + _n
  283. for _rel in re.findall(r'!\[[^\]]*\]\((figures/[^)]+)\)', _mdtxt):
  284. if not (ROOT / "docs" / _rel).is_file():
  285. _figmiss.append(_rel)
  286. _miss += _figmiss
  287. # ★图表器源码也要扫:图题是代码里的字符串,源码里带样本场字样就一定会画进图(对外件不许有)
  288. _figsrc = ROOT / "scripts" / "delivery_docs_figures.py"
  289. if _figsrc.is_file():
  290. for _w, _n in _ddb.forbidden_hits(_figsrc.read_text(encoding='utf-8')).items():
  291. _leak['图表器:' + _w] = _leak.get('图表器:' + _w, 0) + _n
  292. _ok4 = not _miss and not _leak
  293. row(f"交付文档三件 (需求分析/系统设计说明/数据要求说明 · v{_V2})", _ok4,
  294. ("3/3 份在位, 文档名含当前版本号, 插图全在位, 去标识化 clean" if _ok4 else
  295. (f"缺 {len(_miss)} 件: {', '.join(_miss[:3])}" if _miss else '')
  296. + (f";同名旧版残留: {', '.join(_stale[:3])}" if _stale else '')
  297. + (f";去标识化泄漏 {_leak}(对外件不得出现样本场标识)" if _leak else '')
  298. + " —— 跑 python scripts/delivery_docs_build.py 重建"))
  299. except Exception as _e:
  300. row("交付文档三件", False, f"{type(_e).__name__}: {_e}")
  301. # 反向呼应审计 (用户令 2026-09-17): 自**输出**回溯 功能与算法 → **输入**, 逐件问"这件产物能不能
  302. # 由输入推导出来"。正向那几条跨度判据查不出"盘上这件产物到底有没有来路"(缺件时一条都不报),
  303. # 反向一对账就清楚了: 哪些有来路(生成端+输入+判据)、哪些没有(无生成端, 只能从交付包补)。
  304. try:
  305. import importlib.util as _ilu3
  306. _spec3 = _ilu3.spec_from_file_location('_rev_audit', ROOT / "scripts" / "products_reverse_audit.py")
  307. _ra = _ilu3.module_from_spec(_spec3)
  308. _spec3.loader.exec_module(_ra)
  309. _rows, _v, _cnt, _fails, _uncl, _rc = _ra.audit(None, verbose=False)
  310. row(f"反向呼应 (输出→功能/算法→输入): 成立 {_cnt.get('✓', 0)} 件 · 不成立 {_cnt.get('✗', 0)} 件 "
  311. f"· 人工件 {_cnt.get('◆', 0)} 件 · 无法验证 {_cnt.get('~', 0)} 件 · 未归类 {_cnt.get('?', 0)} 件", _rc == 0,
  312. ";".join(_fails[:2]) or "每件产物都能指到生成端与输入, 或如实标了 shipped/人工件(docs §13.6)")
  313. except Exception as _e:
  314. row("反向呼应审计", False, f"{type(_e).__name__}: {_e}")
  315. # 页面归口 (2026-09-17 用户令 1): 门户里哪些页面是"数据派生快照"(属于产物)、它们陈旧没陈旧。
  316. # 用户问过"#findings/#sim/#documents 该不该随输入数据变、算不算产物" —— 答案落在
  317. # configs/portal_pages.yaml 登记表 + scripts/pages_audit.py 的检查里, 这里只报结论。
  318. # 退出码: 0 一致 / 5 缺文件或未登记 / 6 溯源缺失 / 7 **页面陈旧**(数据变了页面没变) / 8 交付件缺版本 / 9 分类错。
  319. try:
  320. import importlib.util as _ilu
  321. _spec = _ilu.spec_from_file_location('_pages_audit', ROOT / "scripts" / "pages_audit.py")
  322. _m = _ilu.module_from_spec(_spec)
  323. _spec.loader.exec_module(_m)
  324. _rc, _res, _gaps = _m.audit()
  325. _bad = [r for r in _res if r[0] not in ("OK", "i")]
  326. _known = [r for r in _res if r[0] == "i"]
  327. _note = (f"检查 {len(_res)} 项: 不一致 {len(_bad)}"
  328. + (f" ({'; '.join(f'{r[1]}: {r[2][:60]}' for r in _bad[:2])})" if _bad else "")
  329. + f" · 已知缺口 {len(_known)} (引用悬空/无溯源, 登记表明写理由)"
  330. + (f" · 未登记页 {len(_gaps)}" if _gaps else ""))
  331. row("页面归口: 页面是否随数据变 / 是否陈旧 (configs/portal_pages.yaml)", _rc == 0, _note)
  332. except Exception as _e:
  333. row("页面归口审计", False, f"{type(_e).__name__}: {_e}")
  334. # ★2026-09-29 用户令(三栈重写):**HTTP 契约复核** —— 把"重构前的接口行为"变成自动判据。
  335. # 本门在现场是"前端 Vue / 后端 Java / 算法 FastAPI 三条线都不许偏离重构前"的对拍口:
  336. # 方法/状态码/JSON 顶层键必须与 `app_backEnd/.../contract/http_api_v1.json` 一致(值不比)。
  337. # rc: 0 一致 · 9 有不一致 · 3 服务不可达(后者按"未起服务"如实报,不判 FAIL)。
  338. try:
  339. import importlib.util as _ilu4
  340. _sp4 = _ilu4.spec_from_file_location('_http_contract',
  341. ROOT / "scripts" / "http_contract_audit.py")
  342. _hc = _ilu4.module_from_spec(_sp4)
  343. _sp4.loader.exec_module(_hc)
  344. _rows4 = _hc.audit.__wrapped__ if hasattr(_hc.audit, '__wrapped__') else _hc.audit
  345. _rc4 = _hc.audit(False)
  346. row("HTTP 契约 (重构前接口行为对拍: 方法/状态/顶层键)", _rc4 in (0, 3),
  347. "与契约一致" if _rc4 == 0 else ("服务不可达 —— 起服务后再复核" if _rc4 == 3 else
  348. "见上(跑 python scripts/http_contract_audit.py 看明细)"))
  349. except Exception as _e:
  350. row("HTTP 契约复核", False, f"{type(_e).__name__}: {_e}")
  351. # 配置与日志的统一口径 (2026-09-17 用户令 2): 配置目录/文件、日志目录/命名/格式。
  352. # 两个检查器都是"看一眼实物 + 代码"的静态检查, 很便宜; 有问题就 FAIL 并指到具体文件。
  353. for _script, _label in (("config_audit.py", "配置统一: 目录约定/命名/内容/引用闭合/不手拼路径"),
  354. ("log_audit.py", "日志统一: logs/ 唯一落点/命名/行格式/保留策略")):
  355. try:
  356. import importlib.util as _ilu2
  357. _sp = _ilu2.spec_from_file_location(f'_{_script[:-3]}', ROOT / "scripts" / _script)
  358. _mod = _ilu2.module_from_spec(_sp)
  359. _sp.loader.exec_module(_mod)
  360. _rc2, _res2, _info2 = _mod.audit()
  361. _lvl = {}
  362. for _l in _res2:
  363. _lvl[_l[0]] = _lvl.get(_l[0], 0) + 1
  364. _bad2 = [r for r in _res2 if r[0] in ("X", "!")]
  365. row(_label, _rc2 == 0,
  366. (f"rc={_rc2}: " + "; ".join(f"{r[1]}: {r[2][:70]}" for r in _bad2[:2])) if _bad2
  367. else f"无不一致 · 已知缺口/白名单 {_lvl.get('i', 0)} · 提示 {_lvl.get('?', 0)}")
  368. except Exception as _e:
  369. row(_label, False, f"{type(_e).__name__}: {_e}")
  370. # 输入数据放置体检 (2026-09-17 用户令 3): data/raw 的结构/命名是否合约定、源类齐不齐。
  371. # 判 FAIL 的只有**结构类**问题 (放错层/名字不对/压缩包没解压) —— 那类问题会让摄入静默读不到;
  372. # "缺某类源件"只作为提示 (新装的机器本来就没数据, 不该因此判失败)。
  373. try:
  374. import importlib.util as _ilu3
  375. _sp3 = _ilu3.spec_from_file_location('_raw_check', ROOT / "scripts" / "raw_data_check.py")
  376. _rc3 = _ilu3.module_from_spec(_sp3)
  377. _sp3.loader.exec_module(_rc3)
  378. _r3, _n3, _s3 = _rc3.check_tree(Path(_rc3.station_dir()), deep=False)
  379. _struct = [x for x in _r3 if x[3] in (_rc3.RC_STRUCT, _rc3.RC_CLASH)]
  380. _gaps = [x for x in _r3 if x[3] == _rc3.RC_GAP]
  381. if not _s3.get('exists'):
  382. row("输入数据放置体检 (data/raw)", True,
  383. "尚未放置输入数据 —— 属预期; 放置规范见 docs/输入数据放置指导_v0.1.md")
  384. else:
  385. row("输入数据放置体检 (data/raw 结构/命名/源类)", not _struct,
  386. (f"{len(_struct)} 项结构问题: " + "; ".join(f"{x[1]}: {x[2][:60]}" for x in _struct[:2])) if _struct
  387. else f"{_s3.get('files', 0):,} 件源件 · 结构合规"
  388. + (f" · 缺 {len(_gaps)} 类/项: " + "; ".join(f"{x[1]}: {x[2][:50]}" for x in _gaps[:2]) if _gaps else "")
  389. + (f" · {len(_n3)} 条提示" if _n3 else ""))
  390. except Exception as _e:
  391. row("输入数据放置体检", False, f"{type(_e).__name__}: {_e}")
  392. for m in ("numpy", "pandas", "pyarrow", "polars", "yaml", "matplotlib", "plotly", "jinja2", "docx"):
  393. try: __import__(m); row(f"依赖 {m}", True)
  394. except Exception as ex: row(f"依赖 {m}", False, f"未安装: {ex.__class__.__name__} (运行 install 脚本)")
  395. # ★2026-09-16 用户令: 打包"不含 输入数据/产物/日志"。产物缺失时, 若包内 dist-manifest.json 就声明了
  396. # no_products/no_data, 这里显示 `--`(待重算) 而**不是 FAIL** —— 否则开箱自检会把"本来就该重算"的
  397. # 状态报成失败, install 脚本收尾的那次 check 也会失败 (把正常状态说成问题)。
  398. _man = {}
  399. try:
  400. _mp = ROOT / "dist-manifest.json"
  401. _man = jload(_mp) if _mp.is_file() else {}
  402. except Exception:
  403. _man = {}
  404. _no_products = bool(_man.get("no_products"))
  405. 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", "事实契约"),
  406. (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"] + "/如东", "治理清单交付件 (可选)")):
  407. p = ROOT / rel; exists = p.exists() and (any(p.iterdir()) if p.is_dir() else p.stat().st_size > 0)
  408. if "可选" in n: print(f" [{'OK' if exists else '--'}] {n}: {rel}")
  409. elif _no_products and rel.startswith("outputs/"):
  410. print(f" [--] {n}: {rel} (本包按用户令**未随产物** —— 把现场包放好后跑 "
  411. f"scripts/place_raw_data.py --src <现场包目录> --scope full 再 scripts/rebuild_all.py)")
  412. else: row(n, exists, rel)
  413. raw = Path(c.get("raw_dir", "data/raw")); raw = raw if raw.is_absolute() else (ROOT / raw)
  414. print(f" [{'OK' if raw.exists() else '--'}] 原始件目录 (补数据放这里): {raw}{'' if raw.exists() else ' (不存在; 原始件不随包分发, 只影响摄入命令, 不影响页面)'}")
  415. for name, port, _ in services(c): print(f" [{'运行中' if up(c['host'], port) else '空闲'}] 端口 {port} ({name})")
  416. # 模型闸: 探针 (generate 非 tags) + 档位模型是否都在本机 + 摘要记录; 无模型时问答不可用, 其余页面不受影响
  417. sys.path.insert(0, str(ROOT))
  418. try:
  419. from src.ontology import llm_gate; mcfg = jload(P.MODELS_JSON)
  420. 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 拉模型)")
  421. try:
  422. 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", [])}
  423. for tier, t in mcfg["tiers"].items():
  424. 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 + ')'}")
  425. except Exception as ex: print(f" [--] 模型清单不可读: {ex.__class__.__name__}")
  426. st = llm_gate.state(); print(f" [{'FAIL' if st['open'] else 'OK'}] 模型可用性检查: {'已熔断 (连续失败后自动停用, 60 秒后重试)' if st['open'] else '正常'}; 审计日志 {st['audit']}")
  427. except Exception as ex: row("模型可用性检查", False, f"{ex.__class__.__name__}: {str(ex)[:100]}")
  428. print("结论:", "全绿, 可 serve" if ok else "有 FAIL 项, 先按提示处理"); return 0 if ok else 2
  429. def cmd_serve(c):
  430. from src import logfile as _lf; _lf.prefix_stdout('serve') # 统一日志格式 (用户令 2)
  431. RUN.mkdir(exist_ok=True); LOGS.mkdir(exist_ok=True); pids = jload(PIDS) if PIDS.exists() else {}; e = env(c)
  432. for name, port, cmd in services(c):
  433. if up(c["host"], port): print(f" {name} {port} 已在运行 (复用)"); continue
  434. if name == "gateway": time.sleep(1.5)
  435. pid = spawn(cmd, LOGS / f"{name}.log", e); pids[name] = {"pid": pid, "port": port}; print(f" {name} {port} 启动 pid {pid}")
  436. PIDS.write_text(json.dumps(pids, ensure_ascii=False, indent=1), encoding="utf-8")
  437. url = f"http://{c['host']}:{c['gateway']}/healthz"
  438. for _ in range(40):
  439. try:
  440. with urllib.request.urlopen(url, timeout=20) as r: d = json.load(r); break
  441. except Exception: time.sleep(1)
  442. else: print("网关 healthz 60 s 内未就绪, 看 logs/gateway.log"); return 2
  443. 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")
  444. hp = public_host(c)
  445. if hp not in ("127.0.0.1", "localhost"):
  446. ips = lan_ips()
  447. print(f" 对外监听: 网关绑在 {hp}:{c['gateway']}(组件仍在 {c['host']})"
  448. + (f" ⇒ 别的机器可开 http://{ips[0]}:{c['gateway']}/" if ips else ""))
  449. print(" ★页面没有鉴权: 对外暴露 = 全场运行数据对同网段/公网可见。请在防火墙侧限来源 IP, "
  450. "或改 public_host 回 127.0.0.1(只本机 + SSH 隧道)")
  451. for m in bad: print(f" [DOWN] {m['path']} {m['name']} (看 logs/)")
  452. return 0 if not bad else 1
  453. def cmd_status(c):
  454. pids = jload(PIDS) if PIDS.exists() else {}
  455. for name, port, _ in services(c):
  456. 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 ''}")
  457. return 0
  458. def cmd_stop(c):
  459. if not PIDS.exists(): print("没有 pids.json (不是本启动器起的, 或已停)"); return 0
  460. try:
  461. pids = jload(PIDS) or {}
  462. except (OSError, ValueError) as e: # 记录被写坏/读到一半 (服务宿主与启动器并存时会发生)
  463. print(f" pids.json 读不动 ({e.__class__.__name__}), 按空记录处理")
  464. pids = {}
  465. for name, v in pids.items(): kill(v.get("pid")); print(f" 停 {name} pid {v.get('pid')}")
  466. time.sleep(1.5); left = [n for n, p, _ in services(c) if up(c["host"], p)]
  467. # 删运行态记录: 文件可能已被服务宿主/别的实例清掉, 或被杀软占着句柄 ⇒ 未加保护的 unlink 会让
  468. # `guanlan.py stop` 抛 FileNotFoundError 中断 (2026-09-28 远端实逮: 组件已被停掉, 命令却报错退出)。
  469. # 删不掉**不算失败**, 如实报一句就够 —— 这条命令的职责是"把服务停掉", 不是"保证文件消失"。
  470. try:
  471. PIDS.unlink()
  472. except OSError as e:
  473. print(f" (运行态 pids.json 未删除: {e.__class__.__name__}, 不影响停机结果)")
  474. print("全部已停" if not left else f"仍在监听 (可能是别的实例): {left}"); return 0
  475. def cmd_version(c):
  476. """版本号 / 规则 / 版本记录 (用户令 2026-09-17: 打包版本管理)。"""
  477. from src import version as V
  478. print(f"{V.NAME} v{V.VERSION} ({V.EDITION})")
  479. for ln in V.rule_lines(): # 规则 + 例 + 包名(措辞与 README/版本记录同一份)
  480. print(" " + ln)
  481. print(" 版本表 (完整见 docs/版本记录.md):")
  482. for h in V.HISTORY:
  483. print(f" v{h.get('version', '?'):<8} {h.get('date', '?')} [{h.get('level', '?')}] {h.get('title', '')[:60]}")
  484. return 0
  485. def main():
  486. a = sys.argv[1] if len(sys.argv) > 1 else "check"; c = cfg()
  487. if a == "open": url = f"http://{c['host']}:{c['gateway']}/"; print(url); webbrowser.open(url); return 0
  488. 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))
  489. return {"check": cmd_check, "serve": cmd_serve, "status": cmd_status, "stop": cmd_stop,
  490. "version": cmd_version}.get(a, lambda c: (print(__doc__), 2)[1])(c)
  491. if __name__ == "__main__":
  492. sys.exit(main())