Ver código fonte

文本 I/O 收尾: 18 处同类站点清零, 门禁 WARN 归零

接上一提交 (本体层 10 处), 把剩下的"缺 encoding=" 一并清掉 —— 都是同一个病根:
Python 文本 I/O 默认用系统 locale 编码 (中文 Windows = cp936/GBK), 而本库的 JSON/HTML/日志都是 UTF-8。
读会乱码/UnicodeDecodeError, 写遇到 GBK 编不出的字符 (零宽空格/emoji/罕见汉字) 直接崩。

站点 (18 处 / 9 文件):
  scripts\ontology_p2_verify.py   读 objects.json 与待校验文本
  scripts\windscada_serve.py      CMS 报告 md 读 + 问答答案落盘 ×2
  src\ontology\scenario_29.py     沙盘 JSON 落盘 (中文)
  src\windcms\knowledge.py        kb.json 读 + 写 (ensure_ascii=False 全是中文)
  src\windcms\pipeline.py         analyze_log.json 落盘 (含中文命令回显)
  src\windcms\plugins.py          views.json 读+写 · analyze.lock 读+写 · analyze_stdout.log
  src\windcms\data.py             model_summary.json 读
  src\windcms\serve.py            /api/views 读 views.json
  src\windscada\subsys\fusion.py 振动 handoff / 华标油样 / CMS 报告 三处读
  src\windscada\taxonomy.py       CMS 报告读

写侧改动都保持原语义: `json.dump(x, open(p,'w'))` 这种"匿名句柄"改成 with 块 (正确关文件);
`plugins.py` 里给子进程当 stdout 的那个句柄**不 with-close** (父进程持引用, 免得提前关掉),
只补 encoding 让门禁不再误警。

门禁 (scripts\check_portability.py WARN 项 6) 同时扩了扫描面:
  · 新增 `open(..., 'w')` (文本模式) 与 `open(p)` (无 mode = 文本读) 两种形态; 二进制模式 ('rb'/'wb') 不算;
  · 与 ERROR 扫描共用 ALLOW 例外表 —— 门禁自己的规则/文档里必须写出这些形态, 整份豁免 (此前会误报 10 处);
  · 修完实测: 命中 0 (此前 16 处 / 5 文件)。

冒烟验证 (改了运行期模块, 必须确认没改坏):
  · py_compile 11 个文件通过; windcms 五个模块 + fusion/taxonomy/scenario_29 全部导入成功;
  · 重启服务 5/7 ok; 页面字节与改前逐项一致 ( / 20225828 · /detail/ 229643 · /sim/ 50998 ·
    /sim/sys/ 123455 · /viewer/ 4986 ); 维护页数据层四个数字不变 (报警 39211 · 工单 5876 · 油样 404 · 月表 3729);
  · 维护页响应体 +32 B 已定位: 是「实机参数表」行由 `存在=false` 变为 `覆盖=turbine_params.parquet`
    (上一轮补出的产物), 属预期而非回归。

顺带记录 (未改, 不是本次引入): src\windcms\serve.py 里 JS 代码字符串有 `\*` `\d` 等非法转义,
Python 3.12 报 SyntaxWarning; 将来 Python 收紧为错误时会炸, 建议把那些字符串改成 raw (r"""...""")。
zhouyang.xie 1 mês atrás
pai
commit
0848dd1ae1

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

@@ -294,4 +294,5 @@ P.venv_python()   # 跨平台探测 .venv/Scripts/python.exe 或 .venv/bin/pytho
 | 2026-09-11 | **路径跨平台化**:新增 `src\paths.py` 唯一真源;清理机器绝对路径(mac 检出/TCM 主仓/写死解释器)与 20+ 处 cwd 相对路径;`serve.json` 的 python 改相对;新增静态门禁 `check_portability.py` 与回归尺子 `page_fingerprint.py`;**改造前后 10 个端点指纹逐字节一致**;补本文件 §6 产物地图与 §7 跨平台约定 |
 | 2026-09-11 | **门户拆包**:`release\portal.html` 拆成受管外壳 `release\portal_src\shell.html`(138 KB) + 不入库内嵌件 `templates\`(28 件 20.04 MB)与 `governance_sources.json`;新增 `scripts\portal_build.py`(`--extract/--verify/--check`),实测拆→装**逐字节一致**(见 §6);配套 `git rm --cached release\portal.html` |
 | 2026-09-11 | 验证闸门退出码修复:控制台 GBK 编不出 `✔` 时降级为 `?`(新增 `src\console.py`),修 `page_fingerprint` / `check_portability` / `rebuild_from_raw` / `scan_stations` / `portal_build` —— 此前"全部一致"会因 print 抛异常而**退出码 1(通过被报成失败)** |
-| 2026-09-11 | **从零重算实测** + 落位扩范围:`place_raw_data.py` 新增 `--scope a2\|mech\|full`(机理层技术资料 + 对译表 + `scada_1min`)、修"单文件规则恒不落件"与缺 `import os`(自 d90da05 起脚本跑不通)、同尺寸文件跳过;`src\ontology\` 全层 10 处文本 I/O 补 `encoding='utf-8'`(**中文 Windows 上本体层原本根本不可用**:默认 cp936 写 objects.json 崩在 `\u200b`);`check_portability.py` 新增 WARN 项 6「文本读写缺 encoding=」(存量 12 处);实测数字见 §4 末 |
+| 2026-09-11 | **从零重算实测** + 落位扩范围:`place_raw_data.py` 新增 `--scope a2\|mech\|full`(机理层技术资料 + 对译表 + `scada_1min`)、修"单文件规则恒不落件"与缺 `import os`(自 d90da05 起脚本跑不通)、同尺寸文件跳过;`src\ontology\` 全层 10 处文本 I/O 补 `encoding='utf-8'`(**中文 Windows 上本体层原本根本不可用**:默认 cp936 写 objects.json 崩在 `\u200b`);`check_portability.py` 新增 WARN 项 6「文本读写缺 encoding=」;实测数字见 §4 末 |
+| 2026-09-11 | 收尾这 18 处同类文本 I/O(`scripts\ontology_p2_verify.py`、`scripts\windscada_serve.py`、`src\ontology\scenario_29.py`、`src\windcms\{knowledge,pipeline,plugins}.py`、`src\windscada\subsys\fusion.py`、`src\windscada\taxonomy.py`),门禁 WARN 项 6 扫描面同时扩到 `open('w')` 与 `open(p)`(无 mode = 文本读);**WARN 归零** |

+ 14 - 5
scripts/check_portability.py

@@ -21,7 +21,8 @@ r"""跨平台/相对路径 门禁 (2026-09-11 用户令) —— 换电脑、换
      · 写: 内容里出现 GBK 编不出的字符 (零宽空格 `\u200b`、emoji、罕见汉字) → UnicodeEncodeError 崩溃。
    2026-09-11 从零重算时实逮: `src/ontology/store.py` 写 objects.json 崩在 `\u200b` 上 —— 也就是说
    **本体层在中文 Windows 上根本不可用**(开发机是 UTF-8 locale, 所以一直没暴露)。
-   本项按 WARN 列出 (存量较多, 逐步收敛); 新写的代码请一律 `encoding='utf-8'`。
+   本项扫三种形态: `.read_text(...)` / `.write_text(...)`、`open(..., 'w')`(文本模式)、`open(p)`(无 mode = 文本读);
+   二进制模式 (`'rb'`/`'wb'`) 不算。2026-09-11 已把当时存量 18 处全部清零, 新写的代码请一律 `encoding='utf-8'`。
 
 ## 例外必须**具名**登记
 
@@ -54,13 +55,17 @@ RE_CWD = re.compile(r"""(?:open|Path|pathlib\.Path)\(\s*['"](outputs|data|releas
 # os.sep 出现在"拼字符串"里 (显示函数除外)
 RE_OSSEP = re.compile(r"""\+\s*os\.sep""")
 # 文本读写调用 (用于 WARN 项 6: 缺 encoding= 时按系统 locale 编解码, 中文 Windows 上是 GBK)
-RE_TXTIO = re.compile(r"""\.(read_text|write_text)\(""")   # portability-allow: 本门禁的规则源里必须写出这两个方法名
+# 三种形态都要逮: read_text/write_text · open(文本模式) · open(没给 mode = 文本读)
+RE_TXTIO = re.compile(r"""\.(read_text|write_text)\(|(?<![\w.])open\(""")   # portability-allow: 本门禁的规则源里必须写出这些形态
+# 二进制模式 ('rb'/'wb'/'ab'/'r+b'…) 不需要 encoding
+RE_BINMODE = re.compile(r"""['"][rwa]\+?b['"]|['"][rwa]b\+?['"]""")
 
 
 def scan_encoding(globs, extra_files=()) -> list:
-    """WARN: 找出 `.read_text(...)` / `.write_text(...)` 没给 encoding= 的调用。   # portability-allow: 说明文字即规则本身
+    """WARN: 找出没给 encoding= 的文本 I/O 调用。   # portability-allow: 说明文字即规则本身
 
-    跨行调用 (如 retrieval.py 里分三行写的 write_text) 要把括号配对后再找 encoding,
+    覆盖 `.read_text(...)` / `.write_text(...)` / `open(..., 'w')` / `open(p)`(无 mode = 文本读);
+    二进制模式跳过。跨行调用 (如 retrieval.py 里分三行写的 write_text) 要把括号配对后再找 encoding,
     所以不能只看一行。返回 [(相对路径, 行号, 片段)]。
     """
     out = []
@@ -70,6 +75,10 @@ def scan_encoding(globs, extra_files=()) -> list:
     files += [ROOT / f for f in extra_files if (ROOT / f).exists()]
     for f in files:
         rel = f.relative_to(ROOT).as_posix()
+        # 与 ERROR 扫描同一份例外表: 具名登记过的文件整份跳过 (如本门禁自己 —— 它的规则/文档里
+        # 必须写出 `open('data/…')`、`.read_text(...)` 这些形态, 否则规则没法表达)
+        if rel in ALLOW or any(rel.startswith(k) for k in DELIVERY_ALLOW):
+            continue
         try:
             text = f.read_text(encoding='utf-8')
         except Exception:
@@ -84,7 +93,7 @@ def scan_encoding(globs, extra_files=()) -> list:
                     depth -= 1
                 j += 1
             call = text[m.start():j]
-            if 'encoding' in call:
+            if 'encoding' in call or RE_BINMODE.search(call):
                 continue
             if 'portability-allow' in text[text.rfind('\n', 0, m.start()) + 1:text.find('\n', m.start())]:
                 continue

+ 2 - 2
scripts/ontology_p2_verify.py

@@ -5,7 +5,7 @@
 import json, pathlib, re, sys
 
 ROOT = pathlib.Path(__file__).resolve().parent.parent
-OBJ = json.loads((ROOT / 'outputs/rudong/ontology/objects.json').read_text())
+OBJ = json.loads((ROOT / 'outputs/rudong/ontology/objects.json').read_text(encoding='utf-8'))
 # 事实契约 id (claim/<claim_id>): 离线问答接契约后 (2026-09-06) 答案会引用它, 对象不在本体而在契约里.
 _CP = ROOT / 'outputs/rudong/guanlan/facts_contract_v0.json'
 CLAIMS = {c['claim_id']: c for c in json.loads(_CP.read_text(encoding='utf-8')).get('claims', [])} if _CP.is_file() else {}
@@ -102,6 +102,6 @@ def verify(text, lang='zh'):
 if __name__ == '__main__':
     p = pathlib.Path(sys.argv[1])
     lang = sys.argv[2] if len(sys.argv) > 2 else 'zh'
-    r = verify(p.read_text(), lang=lang)
+    r = verify(p.read_text(encoding='utf-8'), lang=lang)
     print(json.dumps(r, ensure_ascii=False, indent=1, default=str))
     sys.exit(0 if r['全通过'] else 2)

+ 3 - 3
scripts/windscada_serve.py

@@ -482,7 +482,7 @@ def vibcms_results():
     取最新一期报告md; '融合级(模型)/CMS红黄'两列=模型输出, 不出结果层; 分析功能留独立cms."""
     try:
         rp = sorted(ST.parent.glob('windcms/报告_CMS振动状态评估报告_*.md'))[-1]
-        md = rp.read_text()
+        md = rp.read_text(encoding='utf-8')       # 不写 encoding 会按系统 locale(cp936) 读 → 中文报告乱码
         date = rp.stem.rsplit('_', 1)[-1]
         overall = ''
         if '### 3.1' in md:
@@ -1374,7 +1374,7 @@ def _ask_worker(q, model, lang='zh'):
             r = _fast(q, model, on_step=_on_step, lang=lang, on_partial=_on_partial)
             _esc = f" · 升档 {r['escalated_from']}→{r['model']}" if r.get('escalated_from') else ''
             ans = r['answer'] + f"\n\n({r['gate']} · {r['rounds']}轮 · 直连快路径{_esc})"
-            out.write_text(ans)
+            out.write_text(ans, encoding='utf-8')     # 答案是中文: 缺 encoding 在中文 Windows 上按 GBK 写, 可能崩
             from ontology_p2_verify import verify as _v0
             ASK.update(answer=ans, verify=_v0(ans, lang=lang), partial=False, refined_from=r.get('escalated_from'))
             # 交叉审核: 换一个本地模型复核同一份证据 (同模型自审 = 自己给自己判卷)
@@ -1399,7 +1399,7 @@ def _ask_worker(q, model, lang='zh'):
             cmd.append(q + ' 每个结论句末尾用[对象id]标注来源。最后调用claim_check。')
         r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
         ans = r.stdout.strip() or ('(空输出) stderr: ' + r.stderr[-500:])
-        out.write_text(ans)
+        out.write_text(ans, encoding='utf-8')
         sys.path.insert(0, str(pathlib.Path('scripts').resolve()))
         from ontology_p2_verify import verify as _v
         ASK.update(answer=ans, verify=_v(ans, lang=lang), state='done')

+ 2 - 1
src/ontology/scenario_29.py

@@ -135,7 +135,8 @@ def build():
     s.save()
     out = ONT / 'scenario_29_repair.json'
     out.write_text(json.dumps(dict(scenarios=scenarios, key_insight=key_insight,
-                                   recommendation=recommendation), ensure_ascii=False, indent=1))
+                                   recommendation=recommendation), ensure_ascii=False, indent=1),
+                   encoding='utf-8')     # 中文 JSON: 缺 encoding 在中文 Windows 上按 GBK 写
     return scenarios, key_insight, recommendation
 
 

+ 1 - 1
src/windcms/data.py

@@ -117,7 +117,7 @@ def load_model(cfg):
         # (2026-08-24 实逮: 03# 发电机 候选 16.15× 线因此丢失, 台被标优秀)
         l6['台'] = l6['台'].astype(str).str.replace(r'^0(\d#)$', r'\1', regex=True)
     fusion = pd.read_csv(cfg['fusion'], dtype=str) if cfg['fusion'].exists() else pd.DataFrame()
-    summ = json.load(open(cfg['model_summary'])) if cfg['model_summary'].exists() else {}
+    summ = json.load(open(cfg['model_summary'], encoding='utf-8')) if cfg['model_summary'].exists() else {}
     return l6, fusion, summ
 
 

+ 4 - 2
src/windcms/knowledge.py

@@ -56,7 +56,9 @@ def build(cfg):
         d['tokens'] = _tokens(d['text'] + ' ' + d['source'])
     out = cfg['out'] / 'kb.json'
     out.parent.mkdir(parents=True, exist_ok=True)
-    json.dump(docs, open(out, 'w'), ensure_ascii=False)
+    # encoding 必须给: ensure_ascii=False 写的是中文, 缺 encoding 会按系统 locale(cp936) 编 → 中文 Windows 崩
+    with open(out, 'w', encoding='utf-8') as f:
+        json.dump(docs, f, ensure_ascii=False)
     print(f'kb: {len(docs)} chunks → {out}')
     _KB.pop(str(cfg['out']), None)
     return docs
@@ -69,7 +71,7 @@ def load(cfg):
     key = str(cfg['out'])
     if key not in _KB:
         p = cfg['out'] / 'kb.json'
-        _KB[key] = json.load(open(p)) if p.exists() else build(cfg)
+        _KB[key] = json.load(open(p, encoding='utf-8')) if p.exists() else build(cfg)
     return _KB[key]
 
 

+ 3 - 1
src/windcms/pipeline.py

@@ -114,6 +114,8 @@ def analyze(cfg, input_path=None, window=None, steps=None, bands='ALL'):
         log.append(dict(step='FAILED', cmd='', rc=1, seconds=0, tail=str(e)[-1500:]))
         status = 'failed'
     summary = dict(status=status, windows=list(data.windows(cfg)), seconds=round(time.time() - t_all, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'), steps=log)
-    json.dump(summary, open(out / 'analyze_log.json', 'w'), ensure_ascii=False, indent=1)
+    # 日志里有中文命令回显: 缺 encoding 会按系统 locale(cp936) 写 → 中文 Windows 崩
+    with open(out / 'analyze_log.json', 'w', encoding='utf-8') as f:
+        json.dump(summary, f, ensure_ascii=False, indent=1)
     print(f'analyze {status}: {summary["seconds"]}s → {out}/analyze_log.json', flush=True)
     return summary

+ 13 - 7
src/windcms/plugins.py

@@ -332,8 +332,10 @@ def render(ctx, title, panels):
     ctx['views'].append(view)
     try:
         vp = ctx['cfg']['out'] / 'views.json'
-        old = json.load(open(vp)) if vp.exists() else []
-        json.dump((old + [dict(id=view['id'], title=view['title'], panels=[dict(plugin=x['plugin'], args=x['args'], svg=x['svg'], text=x['text']) for x in out])])[-20:], open(vp, 'w'), ensure_ascii=False)
+        # 读写都要显式 utf-8: 面板文本是中文, 缺 encoding 会按系统 locale(cp936) 读/写 → 乱码或崩
+        old = json.load(open(vp, encoding='utf-8')) if vp.exists() else []
+        with open(vp, 'w', encoding='utf-8') as f:
+            json.dump((old + [dict(id=view['id'], title=view['title'], panels=[dict(plugin=x['plugin'], args=x['args'], svg=x['svg'], text=x['text']) for x in out])])[-20:], f, ensure_ascii=False)
     except Exception:
         pass
     return dict(kind='view', data=view, text=f'视图 "{title}" 已组装 {len(out)} 个面板: ' + '; '.join(f'{x["plugin"]}: {x["text"][:120]}' for x in out))
@@ -349,7 +351,7 @@ def analyze(ctx, input='', window='', steps='', confirm=False):
     lock = ctx['cfg']['out'] / 'analyze.lock'
     if lock.exists():
         try:
-            pid = int(lock.read_text().strip())
+            pid = int(lock.read_text(encoding='utf-8').strip())
             os.kill(pid, 0)
             return dict(kind='text', text=f'analyze 已在运行 (pid {pid}), 不重复启动; 用 job_status 看进度.')
         except Exception:
@@ -365,8 +367,11 @@ def analyze(ctx, input='', window='', steps='', confirm=False):
         cmd += ['--steps'] + [s for s in str(steps).replace(',', ' ').split() if s]
     logp = ctx['cfg']['out'] / 'analyze_stdout.log'
     ctx['cfg']['out'].mkdir(parents=True, exist_ok=True)
-    p = subprocess.Popen(cmd, cwd=str(ROOT), stdout=open(logp, 'w'), stderr=subprocess.STDOUT)
-    lock.write_text(str(p.pid))
+    # stdout= 只用到这个文件的 fd (内容由子进程自己写), 所以这里不 with-close:
+    # 句柄留着, 免得父进程提前关掉; 显式 encoding 是为了不留"缺 encoding"的门禁告警。
+    _logf = open(logp, 'w', encoding='utf-8')
+    p = subprocess.Popen(cmd, cwd=str(ROOT), stdout=_logf, stderr=subprocess.STDOUT)
+    lock.write_text(str(p.pid), encoding='utf-8')
     return dict(kind='text', data=dict(pid=p.pid, log=str(logp)), text=f'全链已在后台启动 (pid {p.pid}); 进度看 job_status; 日志 {logp}. 完成后产物/报告/知识库自动刷新 (界面需刷新页面).')
 
 
@@ -385,8 +390,9 @@ def farm_overview(ctx):
 def job_status(ctx):
     out = ctx['cfg']['out']
     logp, lp = out / 'analyze_stdout.log', out / 'analyze_log.json'
-    tail = open(logp).read()[-1200:] if logp.exists() else '(无运行日志)'
-    last = json.load(open(lp)) if lp.exists() else None
+    # 日志与结果都是中文文本: 不写 encoding 会按系统 locale(cp936) 读 → 乱码/解码失败
+    tail = open(logp, encoding='utf-8').read()[-1200:] if logp.exists() else '(无运行日志)'
+    last = json.load(open(lp, encoding='utf-8')) if lp.exists() else None
     summ = (f"上次结果: {last['status']} {last['seconds']}s @ {last['finished']}; 窗 {last['windows']}; 步骤 " + ', '.join(f"{s['step']}:{s['rc']}/{s['seconds']}s" for s in last['steps'])) if last else '尚无完成记录'
     return dict(kind='text', data=last, text=f'{summ}\n--- 实时日志尾 ---\n{tail}')
 

+ 1 - 1
src/windcms/serve.py

@@ -355,7 +355,7 @@ def run(cfg, port=8030, model=None):
                 return self._send(json.dumps(dict(models=llm.models(), picked=llm.pick(model)), ensure_ascii=False), 'application/json')
             if p == '/api/views':
                 vp = out / 'views.json'
-                return self._send(json.dumps(json.load(open(vp)) if vp.exists() else [], ensure_ascii=False, default=str), 'application/json')
+                return self._send(json.dumps(json.load(open(vp, encoding='utf-8')) if vp.exists() else [], ensure_ascii=False, default=str), 'application/json')
             f = out / p.lstrip('/')
             if f.exists() and f.is_file():
                 ct = 'text/html; charset=utf-8' if f.suffix == '.html' else 'text/plain; charset=utf-8'

+ 3 - 3
src/windscada/subsys/fusion.py

@@ -31,7 +31,7 @@ def load_handoff():
     """振动线接口 v2; 缺失响亮 (判据必需输入禁容错取值)."""
     if not HANDOFF.exists():
         raise FileNotFoundError(f'振动 handoff 接口缺失: {HANDOFF} — 融合面不可静默降级')
-    return json.loads(HANDOFF.read_text())
+    return json.loads(HANDOFF.read_text(encoding='utf-8'))   # 中文 JSON: 缺 encoding 会按系统 locale(cp936) 读
 
 
 _HB = None
@@ -43,7 +43,7 @@ def huabiao_analysis(t):
     global _HB
     if _HB is None:
         f = (P.ont() / 'oil_2026H2_huabiao.json')
-        _HB = json.loads(f.read_text()) if f.exists() else dict(meta={}, 抽样数值=[])
+        _HB = json.loads(f.read_text(encoding='utf-8')) if f.exists() else dict(meta={}, 抽样数值=[])
     rows = [r for r in _HB.get('抽样数值', []) if r.get('t') == t]
     if t == 'WTG29':
         return '★本轮缺采 (29#不在17台名单) — TCM-16分支B前置(主轴后Fe/PQ vs 2025-03基线15/12)未验'
@@ -323,7 +323,7 @@ def windcms_grades():
     _WCMS = {}
     try:
         rp = sorted((P.cms()).glob('报告_CMS振动状态评估报告_*.md'))[-1]
-        md = rp.read_text()
+        md = rp.read_text(encoding='utf-8')
         for l in md.split('## 附录 A')[1].splitlines():
             if not l.startswith('| WTG'):
                 continue

+ 1 - 1
src/windscada/taxonomy.py

@@ -215,7 +215,7 @@ def system_matrix(cfg=None):
             _wdate = _wmd[-1].stem.rsplit('_', 1)[-1] if _wmd else '?'
             _wpart = {}
             if _wmd:
-                for _l in _wmd[-1].read_text().split('## 附录 A')[1].splitlines():
+                for _l in _wmd[-1].read_text(encoding='utf-8').split('## 附录 A')[1].splitlines():
                     if _l.startswith('| WTG'):
                         _c = [x.strip() for x in _l.strip('|').split('|')]
                         _wpart[_c[0]] = dict(主轴承前=_c[1], 主轴承后=_c[2], 齿轮箱=_c[3], 发电机=_c[4])