Просмотр исходного кода

手工重算操作手册 + 清 18 个 CLI 的控制台 GBK 崩溃

## 一、新增 docs/重算操作手册_v0.1.md(用户要的"手动执行重算的操作方法")

按今天这台机器的实测状态写成逐步手册: 0 摸清现状(3 条只读命令) → 1 放数据(place_raw_data --scope)
→ 2 重算(三门台账 + --scada 十个构建器, 含每件产物的期望行数) → 3 本体层(四条命令 + 期望数字)
→ 4 等价验收(基线临时放回的具体 xcopy/rmdir, **实测跑通**: 3 件 → --verify 退出 4 且结论"1 处需人工看"
→ 撤走后产物回到 19 件) → 5 重启服务 → 6 怎么看数据真进去了(命令行 + 维护页 + 指纹尺子)
→ 7 "算不出来"对照表(7 种现象/原因/怎么办) → 8 两个必须知道的坑 → 一页速记。

★手册里两条最要紧的警告:
  1. `products_state.py --on` 按清单把随包产物挪回来, 且**目标目录存在就先 rmtree**
     (scripts/products_state.py:110-112) —— 想让"原始重算"的结果留着就别跑 --on;
     只要验收基线 → 用手册 §4 的 xcopy, 不要用 --on。
  2. 顺序永远是 停服务 → 挪/放数据与产物 → 算 → 起服务(CMS 是启动时加载数据)。

## 二、清掉 18 个 CLI 的控制台 GBK 崩溃

起因: 写手册时要给一条"命令行验收"命令, 发现 `python -m src.ontology.maintenance` **直接崩** ——
`UnicodeEncodeError: 'gbk' codec can't encode '\u2714'`(它 print 的 ✔ 编不出来), 明明算出来了却以退出码 1 结束。

系统排查(两种扫描互补):
  · 按行扫(print 行里有非 GBK 字符): 17 个模块 —— 直接证据;
  · AST 扫(字符串常量里有非 GBK 字符): 30 个 —— 多出的 13 个其字形只在**正则/HTML**里
    (如 windscada_serve.py 的 22 种是 SVG 制表符、`鿿` 是 `[\u4e00-\u9fff]` 这种正则范围), 不进 stdout, 不动;
  · 纯按行扫还有**假阴性**: maintenance.py 的 ✔ 是先 `mark = '✔' if ... else '✗'` 再 print 的, 只有真跑才暴露。

修了 18 个(17 + maintenance.py): 在 `__main__` 入口插 3 行 stdout/stderr 兜底
(`reconfigure(errors='replace')`, 编不出的字形降级为 `?`), 内联不 import —— 这些模块既可能被
`python -m` 跑也可能被当脚本跑。服务端本来就不受影响: 启动器给子进程设了 PYTHONUTF8=1 +
PYTHONIOENCODING=utf-8, 日志也是二进制追加(open(log,'ab'))。

验证: 18 个文件 py_compile 通过 · `python -m src.ontology.maintenance` 现在退出 0 并打印数据层清单 ·
按行复扫 **0 个** · 重启后页面字节与改前逐项一致( /  20225828 · /detail/ 229643 · /sim/ 50998 ·
/sim/sys/ 123455 · /viewer/ 4986) · 维护页数据层四个数字不变(39211 / 5876 / 404 / 3729)。

另: `python -m src.ontology.audit` 仍退出 1 —— 但**不是**控制台问题, 是它要 `pitch\pitch_daily.parquet`
(无生成端的产物)。已写进手册 §3 与 §7。

## 三、自己踩的坑(记录在案): 补丁器把 10 个文件的行尾整体翻转了

第一版补丁器用**文本模式**读文件 `p.read_text()`(universal newlines: CRLF → LF) 再
`write_text(..., newline='')` 写回 —— 于是 10 个 CRLF 文件被整份翻成 LF, diff 从 +7 行炸成
`windscada_serve.py 10171 行 / cases.py 813 行 …`, 合计 7555 增 / 7213 删。
**这跟本仓前几轮修的"文本模式 I/O 咬掉 CRLF"是同一个病根, 我把它踩在了自己身上。**

修法: `git checkout HEAD~1 -- <10 个文件>` 字节级还原 → 补丁器改成**字节级读写**
(`read_bytes().decode('utf-8')` / `write_bytes(...)`) 并**跟随文件自己的行尾**(`\r\n` 或 `\n`,
插入的 7 行也用该行尾) → 重打。修后 `git diff --stat` 是干净的 `10 files changed, 70 insertions(+)`
(每文件正好 7 行), 行尾零翻转(`wrapup.py` CRLF 192 → 199, 新增行也是 CRLF), `check_eol` 复查 0 个翻转。

教训: 改源码的脚本自己也要守"字节级 I/O + 跟随行尾"这条纪律 —— 尤其是一次改多个文件时,
先把 diff 规模当作自检信号 (只 +7 行才对得上)。
zhouyang.xie 4 недель назад
Родитель
Сommit
9a736bfdf4

+ 1 - 0
docs/数据目录结构与落位约定_v0.2.md

@@ -153,6 +153,7 @@
 
 
 > **要补什么、找谁补**: 见 `docs\重算缺口与补件清单_v0.1.md` —— 按"源件缺失(现场能补)"与
 > **要补什么、找谁补**: 见 `docs\重算缺口与补件清单_v0.1.md` —— 按"源件缺失(现场能补)"与
 > "生成端缺失(研发补脚本)"两类逐项列了证据路径、影响面与补齐判据。
 > "生成端缺失(研发补脚本)"两类逐项列了证据路径、影响面与补齐判据。
+> **手工重算怎么敲**: 见 `docs\重算操作手册_v0.1.md`(逐步命令 + 期望输出 + 会踩的坑)。
 
 
 **从零重算实测(2026-09-11,只用 `data\raw`,不吃随包产物)**
 **从零重算实测(2026-09-11,只用 `data\raw`,不吃随包产物)**
 
 

+ 212 - 0
docs/重算操作手册_v0.1.md

@@ -0,0 +1,212 @@
+# 观澜 · 如东 v2 重算操作手册 (v0.1, 2026-09-11)
+
+**给谁看**: 要手工把 `data\raw` 下的原始数据算成系统能显示的东西的人。
+**前提**: 已经跑过 `install.bat`(有 `.venv`)、`.venv\Scripts\python.exe` 存在。
+**所有命令都在安装根目录下敲**(`F:\temp\guanlan-rudong-v2_0.2.0\`) —— 脚本按自身位置解析路径, 但相对路径参数
+(如 `--src`)会按当前目录解释, 站在根目录最省事。
+
+> 一句话原理: 页面的数来自 `outputs\rudong\` 里的**产物**; 产物由 `data\raw\` 下的**原始件**算出来;
+> `data\raw\` 的下一级目录名就是**场站名称**(扫描辨识, 不写死)。所以流程永远是:
+> **放数据 → 算 → 重启服务 → 看页面**。
+
+---
+
+## 0. 先摸清现状(三条只读命令, 不改任何东西)
+
+```bat
+.venv\Scripts\python.exe scripts\scan_stations.py              :: 系统认到哪个场站、四类源各多少件、有没有"缺"
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --dry-run  :: 重算会做哪几步(不写盘)
+.venv\Scripts\python.exe scripts\products_state.py --status     :: 产物在位还是被挪走
+```
+
+`scan_stations.py` 的期望输出(当前这台机器):
+
+```
+扫描到 1 个场站目录:
+  如东  (631 件)  ...\data\raw\如东    ← 就绪
+      scada_10min        38 件
+      scada_1min         38 件
+      故障报警              16 件
+      风机故障记录           135 件
+      油样报告             404 件
+辨识结论 : how=raw_station   ← 目录名与场配置 raw_station 完全相同
+```
+
+## 1. 放数据(现场包 → 约定目录)
+
+现场给的通常是几个 GBK 名的大压缩包。**不要手工拖拽**, 用映射表脚本落位(可复核、可反复跑):
+
+```bat
+:: 先看要落什么
+.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full --dry-run
+:: 真落位
+.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+```
+
+| 范围 | 落什么 |
+|---|---|
+| `--scope a2` | 四项数据层: `scada_10min` / `故障报警` / `风机故障记录` / `油样报告` |
+| `--scope mech` | 机理层源件 `data\raw\西门子4.0技术资料\`(含那份**对译表**) + `<场站>\scada_1min\` |
+| `--scope full` | 两组一起(从零重算要用的全集) |
+
+同尺寸文件会自动跳过, 所以**重复跑很便宜**(不用怕重抄十几 GB)。`--src` 不给会去找 `data\_incoming\`(默认)。
+
+### 放数据的四条硬规矩
+
+1. 下一级目录名 = **场站名称**, 且要与 `configs\` 场配置里的 `raw_station` 一致(当前 `如东`)。
+2. 四个(加可选 1min 共五个)子目录名固定: `scada_10min` / `scada_1min` / `故障报警` / `风机故障记录` / `油样报告`。
+3. `scada_10min` 必须是**逐台平铺**的 `WTG01.csv…WTG38.csv` 本身 —— 不能是压缩包、不能再套一层目录。
+4. 机理层资料放 `data\raw\西门子4.0技术资料\`(**不在**场站目录下)。
+
+## 2. 重算
+
+```bat
+:: ① 三门台账(报警/工单/油样): 秒级~分钟级
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py
+
+:: ② SCADA 侧 10 个构建器(逐台读 38 个 ~380MB CSV, **约 15 分钟**, 建议放后台或别关窗口)
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --scada
+```
+
+②跑的 10 步(顺序 = 依赖顺序): 功率曲线 → 可用率与损失 → 七镜头曲线 / 控制策略件 / 停机事件 →
+温度 NBM / 偏航 / 液压蓄能 / 热链 / 系统辅助。
+
+**期望结果**(本机实测):
+
+| 产物 | 行数 |
+|---|---|
+| `alarms.parquet` | 39,211 |
+| `workorders.parquet` | 5,876 |
+| `oil_samples_index.parquet` | 404(见 §7 的 102 行说明) |
+| `loss_monthly.parquet` | 3,729 |
+| `temp_bins.parquet` | 24,624 |
+| `curve_lenses.parquet` | 7,676 |
+| `stop_events.parquet` | 2,318 |
+| `powercurve_bins.parquet` | 912 |
+| `curve_liveness` / `control_schedule` | 304 |
+| `yaw_daily` / `hydraulic_accum` / `thermal_chain` / `system_aux` / `control_profile` / `powercurve_dev` | 各 38(逐台一行) |
+
+**没换 SCADA 数据就不用跑 ②**(① 是吃台账表的, 现场按月给表时跑 ① 就够)。
+
+## 3. 本体层(机理资料那一层, 前提是技术资料已放好)
+
+```bat
+:: 对象库(技术资料 + 故障处理手册 + 码表 + 工单/报警) → objects.json
+.venv\Scripts\python.exe -m src.ontology.kb_ingest
+
+:: 检索索引(BM25 词法索引; 向量那半要 Ollama 的 bge-m3, 本机没模型时会跳过, 纯词法仍可用)
+.venv\Scripts\python.exe -c "from src.ontology import retrieval as R; R.build(use_vec=False)"
+
+:: 实机参数表(1706 条整定值, 源件是技术资料里的 Turbine+Parameters.*.xlsx)
+.venv\Scripts\python.exe -c "from src.ontology.maintenance import refresh_params as f; print(f())"
+
+:: 决策链进度入图谱(需要 windscada 服务已在跑, 从 /api/fleet 取)
+.venv\Scripts\python.exe -m src.ontology.chain_ingest
+```
+
+期望: `kb_ingest` 打印 `{'AlarmCode': 555, 'WorkInstruction': 559, 'FailureMode': 150, 'MaintTask': 559, 'Doc': 303, 'Component': 8, ...}`
+(本机实测数字; 技术资料不在时它会打印 `⚠ 源缺失` 并降级 —— 不静默)。
+
+**跑不通的两个**(不是操作错, 是缺别的产物, 见 §7): `python -m src.ontology.populate` 与
+`python -m src.ontology.audit` 都会因 `outputs\rudong\pitch\pitch_daily.parquet` 不存在而退出 1。
+
+## 4. 等价验收(可选, 但强烈建议做一次)
+
+`--verify` 拿**随包基线**逐键逐值比对重算件, 把差异分成"旧链已知缺陷 / 本链新增覆盖 / 无法归类"三类,
+只有第三类才算不通过(退出码 4)。
+
+基线**不参与运行**, 平时是单独存着的(`_products_off\_baseline_kept\`), 验收时临时放回:
+
+```bat
+:: ① 临时放回基线(3 件 1.1 MB)
+xcopy /E /I /Y "_products_off\_baseline_kept\_pre_rebuild_20260911" "outputs\rudong\windscada\_pre_rebuild_20260911"
+
+:: ② 验收(只比不算)
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --verify
+
+:: ③ 验完撤走, 让产物仓只留重算出来的东西
+rmdir /S /Q "outputs\rudong\windscada\_pre_rebuild_20260911"
+```
+
+**期望结论**: `alarms` 39211/39211 逐值完全一致; `workorders` 8 行差异全部可归类(旧链的时间解析缺陷)
++ 69 行本链新增覆盖; **1 处"需人工看" = 油样 506→404**(那 102 行的源件不在包里)。
+
+## 5. 重启服务(**必做**)
+
+产物换了必须重启: CMS 等模块是**启动时**加载数据的, 不重启页面还是旧数; 反过来, 产物被挪走期间起的
+那次 CMS 会直接崩(表现为 `/cms/` 503)。
+
+```bat
+.venv\Scripts\python.exe guanlan.py stop
+.venv\Scripts\python.exe guanlan.py serve
+```
+
+期望: `状态 degraded offline  模块 5/7 ok`, 并列出 DOWN 的项。
+
+## 6. 怎么看"数据真的进去了"
+
+**命令行(不开浏览器)**:
+
+```bat
+.venv\Scripts\python.exe -m src.ontology.maintenance        :: 数据层+机理层逐行: 覆盖/条数/末次更新/摄入命令
+.venv\Scripts\python.exe guanlan.py status                  :: 各服务端口与进程
+.venv\Scripts\python.exe guanlan.py check                   :: 自检(依赖/产物/发布件/模型)
+```
+
+**浏览器**: `http://127.0.0.1:28084/detail/` → 维护页「数据层 + 机理层」那两屏 ——
+每行都写着**它自己的摄入命令**, 以后忘了敲什么直接看页面。
+
+**回归尺子**(与出厂基线比对 10 个端点):
+
+```bat
+.venv\Scripts\python.exe scripts\page_fingerprint.py --save F:\temp\fp_now.json
+.venv\Scripts\python.exe scripts\page_fingerprint.py --diff F:\temp\fp_before.json
+```
+
+## 7. 会遇到的"算不出来"(都不是操作错)
+
+| 现象 | 原因 | 怎么办 |
+|---|---|---|
+| `/cms/` 503 | CMS 的源是**振动线 handoff(测点索引)**, 现场包里只有成品牌报告 PDF | 找振动线要测点导出件 |
+| `/detail/api/query|turbine|fleet` 回 `err=no_products` | 它们依赖**没有生成端**的 `temp_monthly.parquet` 等 L1 底座 | 见 `docs\重算缺口与补件清单_v0.1.md` |
+| 油样只有 404 行(出厂 506) | 差的那 102 行来自一份**合并报告**, 该 PDF 不在现场包里 | 把该批报告放进 `油样报告\` 后重跑 ① |
+| 门户没有"契约结论段"、`/api/facts` 503 | 契约要 `sop\findings.json` + `paradigm_r1` 底稿, 两者本身没有生成端 | 找研发要 |
+| `populate` / `audit` 退出 1 | 卡在 `pitch\pitch_daily.parquet`(变桨产物无生成端) | 同上 |
+| `/local-ai/` 503 | 本机 Ollama 未运行或没有模型(环境项, 与数据无关) | `ollama pull` 见 `configs\models.json` |
+
+## 8. 两个必须知道的坑
+
+1. **`products_state.py --on` 会删掉重算产物**。它按清单把随包产物挪回来, 且**目标目录存在就先 `rmtree`**
+   (`scripts\products_state.py` 第 110-112 行)。当前清单覆盖 `outputs\rudong\{ontology, windscada, …}` ——
+   也就是说: **想让"原始重算"的结果留着, 就不要跑 `--on`**。要拿随包产物就先备份自己的重算结果。
+   只想取验收基线 → 用 §4 的 xcopy, 别用 `--on`。
+2. **顺序永远是: 停服务 → 挪/放数据与产物 → 算 → 起服务**。挪产物期间起的服务会带着"没有数据"的
+   状态跑起来(CMS 直接崩), 表现为页面 503, 而数据其实是好的。
+
+---
+
+## 一页速记(最短路径)
+
+```bat
+:: 现状
+.venv\Scripts\python.exe scripts\scan_stations.py
+
+:: 放数据(现场包还在 F:\temp\如东风场数据)
+.venv\Scripts\python.exe scripts\place_raw_data.py --src F:\temp\如东风场数据 --scope full
+
+:: 算
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py
+.venv\Scripts\python.exe scripts\rebuild_from_raw.py --scada        :: 约 15 分钟
+.venv\Scripts\python.exe -m src.ontology.kb_ingest
+
+:: 重启
+.venv\Scripts\python.exe guanlan.py stop
+.venv\Scripts\python.exe guanlan.py serve
+
+:: 看
+.venv\Scripts\python.exe -m src.ontology.maintenance
+:: 浏览器 http://127.0.0.1:28084/detail/  → 维护页「数据层」
+```
+
+相关文档: `docs\数据目录结构与落位约定_v0.2.md`(§2 落位 · §4 重算边界表) ·
+`docs\重算缺口与补件清单_v0.1.md`(缺什么找谁补) · `_修复记录_20260911\README.md`(踩过的坑)

+ 3 - 0
docs/重算缺口与补件清单_v0.1.md

@@ -123,6 +123,9 @@
 
 
 ## 4. 补齐之后怎么验(照这个顺序)
 ## 4. 补齐之后怎么验(照这个顺序)
 
 
+> **手工操作的逐步手册见 `docs\重算操作手册_v0.1.md`** —— 含每步的期望输出、会踩的坑
+> (尤其:`products_state.py --on` 会删掉重算产物)、以及"算不出来"对照表。下面是压缩版命令。
+
 ```bat
 ```bat
 :: ① 源件落位 (现场包 → 约定目录; 同尺寸自动跳过, 可反复跑)
 :: ① 源件落位 (现场包 → 约定目录; 同尺寸自动跳过, 可反复跑)
 .venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full
 .venv\Scripts\python.exe scripts\place_raw_data.py --src <现场包目录> --scope full

+ 7 - 0
scripts/audit_chinese_terms.py

@@ -105,4 +105,11 @@ def main():
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     sys.exit(main())
     sys.exit(main())

+ 7 - 0
scripts/guanlan_cloud_face.py

@@ -133,6 +133,13 @@ def check():
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     ap = argparse.ArgumentParser(); ap.add_argument("mode", nargs="?", default="check", choices=["build", "check"]); ap.add_argument("--unitize", action="store_true", help="机组号代号化 (用户裁后再开)")
     ap = argparse.ArgumentParser(); ap.add_argument("mode", nargs="?", default="check", choices=["build", "check"]); ap.add_argument("--unitize", action="store_true", help="机组号代号化 (用户裁后再开)")
     a = ap.parse_args()
     a = ap.parse_args()
     if a.mode == "build": sys.exit(build(a.unitize))
     if a.mode == "build": sys.exit(build(a.unitize))

+ 7 - 0
scripts/llm_known_answer.py

@@ -36,4 +36,11 @@ def main():
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     sys.exit(main())
     sys.exit(main())

+ 7 - 0
scripts/security_scan.py

@@ -90,4 +90,11 @@ def main():
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     sys.exit(main())
     sys.exit(main())

+ 7 - 0
scripts/windscada_cloud_pack.py

@@ -240,4 +240,11 @@ def main():
     return 0
     return 0
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     sys.exit(main())
     sys.exit(main())

+ 7 - 0
scripts/windscada_serve.py

@@ -5046,6 +5046,13 @@ class H(BaseHTTPRequestHandler):
             self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
             self._send(_jdump(dict(err=str(e), tb=traceback.format_exc()[-500:]), ensure_ascii=False), 'application/json; charset=utf-8', 500)
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     import argparse
     import argparse
     ap = argparse.ArgumentParser()
     ap = argparse.ArgumentParser()
     import os
     import os

+ 7 - 0
src/ontology/audit.py

@@ -87,6 +87,13 @@ def audit(sample_turbines=3, seed=7):
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     r = audit()
     r = audit()
     print(f"本体审计: {r['n']} 对象, 问题 {len(r['issues'])} 项")
     print(f"本体审计: {r['n']} 对象, 问题 {len(r['issues'])} 项")
     for i in r['issues']:
     for i in r['issues']:

+ 7 - 0
src/ontology/kb_ingest.py

@@ -498,5 +498,12 @@ def build(dry=False):
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     import sys
     import sys
     build(dry='--dry' in sys.argv)
     build(dry='--dry' in sys.argv)

+ 7 - 0
src/ontology/maintenance.py

@@ -191,6 +191,13 @@ def survey():
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     if '--refresh-params' in sys.argv:
     if '--refresh-params' in sys.argv:
         print(refresh_params()); sys.exit(0)
         print(refresh_params()); sys.exit(0)
     rs = survey()
     rs = survey()

+ 7 - 0
src/ontology/populate.py

@@ -290,5 +290,12 @@ def populate():
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     p, n0, n1 = populate()
     p, n0, n1 = populate()
     print(f'✔ P1 铺开: {n0} → {n1} 对象 ({p})')
     print(f'✔ P1 铺开: {n0} → {n1} 对象 ({p})')

+ 7 - 0
src/ontology/seed_rudong29.py

@@ -150,5 +150,12 @@ def build(out=None):
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     p, n = build()
     p, n = build()
     print(f'✔ 本体种子落盘 {p} ({n} 对象)')
     print(f'✔ 本体种子落盘 {p} ({n} 对象)')

+ 7 - 0
src/sop/assistant_llm.py

@@ -86,6 +86,13 @@ def ollama_llm_fn(model='qwen2.5:7b', url='http://localhost:11434/api/generate',
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # CLI: python -m src.sop.assistant_llm <farm> "问题"  (有Ollama则LLM叙事, 否则回退结构化)
     # CLI: python -m src.sop.assistant_llm <farm> "问题"  (有Ollama则LLM叙事, 否则回退结构化)
     import argparse
     import argparse
     import json
     import json

+ 7 - 0
src/sop/backfill.py

@@ -150,6 +150,13 @@ def render_md(report, farm='?'):
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # CLI: python -m src.sop.backfill <farm|path>  —— 读 cases.json → 出 backfill_report.{json,md}
     # CLI: python -m src.sop.backfill <farm|path>  —— 读 cases.json → 出 backfill_report.{json,md}
     import argparse
     import argparse
     import json
     import json

+ 7 - 0
src/sop/cases.py

@@ -370,6 +370,13 @@ def apply_analysis(case, verdict, mechanism, now, review=None, field_needs=None,
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # CLI: python -m src.sop.cases <farm> [--date YYYY-MM-DD]
     # CLI: python -m src.sop.cases <farm> [--date YYYY-MM-DD]
     #   读 outputs/<farm>/sop/{findings.json, cases.json(若有)} → 生产 → 校验 → 写 cases.json
     #   读 outputs/<farm>/sop/{findings.json, cases.json(若有)} → 生产 → 校验 → 写 cases.json
     import argparse
     import argparse

+ 7 - 0
src/sop/farm_paths.py

@@ -95,6 +95,13 @@ def winstr(p) -> str:
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # 零依赖自检
     # 零依赖自检
     ok = True
     ok = True
     cases = {
     cases = {

+ 7 - 0
src/sop/fleet_query.py

@@ -249,6 +249,13 @@ def render_board(fleet, cases_fleet=None):
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     import argparse
     import argparse
     ROOT = Path(__file__).resolve().parents[2]
     ROOT = Path(__file__).resolve().parents[2]
     ap = argparse.ArgumentParser(description='跨场健康查询 (柱3 grounded 底座)')
     ap = argparse.ArgumentParser(description='跨场健康查询 (柱3 grounded 底座)')

+ 7 - 0
src/sop/validate_pair.py

@@ -221,6 +221,13 @@ def _load_farm(cfg):
 
 
 
 
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # CLI: python -m src.sop.validate_pair  → 各场 Tier-1 + CMH 池化 (复现验证程序)
     # CLI: python -m src.sop.validate_pair  → 各场 Tier-1 + CMH 池化 (复现验证程序)
     import argparse
     import argparse
     ap = argparse.ArgumentParser()
     ap = argparse.ArgumentParser()

+ 7 - 0
src/sop/wrapup.py

@@ -123,6 +123,13 @@ def render_md(wrap, farm='?'):
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 if __name__ == '__main__':
 if __name__ == '__main__':
+    # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
+    # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
+    # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
+    import sys as _sys
+    for _s in (_sys.stdout, _sys.stderr):
+        try: _s.reconfigure(errors='replace')
+        except Exception: pass
     # CLI: python -m src.sop.wrapup <farm> [--price-anchor 300]
     # CLI: python -m src.sop.wrapup <farm> [--price-anchor 300]
     #   读 outputs/<farm>/sop/findings.json (+ cases.json 上批 + cleaned parquet) → 三柱收尾 → 写盘
     #   读 outputs/<farm>/sop/findings.json (+ cases.json 上批 + cleaned parquet) → 三柱收尾 → 写盘
     import argparse, json, sys
     import argparse, json, sys