feat(step8): 外部模型从单文件升级为母文件夹多模型字典扫描

This commit is contained in:
DXC
2026-06-08 09:56:02 +08:00
parent 4efe5b871e
commit 2b76d7908f
12 changed files with 935 additions and 29 deletions

View File

@ -0,0 +1,309 @@
---
name: PipelineRunner Facade 防御性 kwargs 兜底
description: WQ_GUI 14 个 stepX_... Facade 方法必须以 **kwargs 收尾——配合 PipelineRunner 调度模式杜绝 "unexpected keyword argument" TypeError
source: auto-skill
extracted_at: '2026-06-04T00:54:50.036Z'
---
# PipelineRunner Facade 防御性 kwargs 兜底
## 适用场景
在 WQ_GUI 中,**任何被 `PipelineRunner` 调用的 14 个 `stepX_...` Facade 方法**(位于 `src/core/water_quality_inversion_pipeline_GUI.py`),其形参表末尾**必须**带 `**kwargs`。触发信号:
- 用户报错 `TypeError: stepX_xxx() got an unexpected keyword argument 'yyy'`
- 改 PIPELINE_STEPS 的 `requires` 列表
- 新增 / 重命名一个 step 方法
- 重构 PipelineRunner 的 `_invoke` 注入逻辑
## 核心原则
**Facade 的形参表 = 显式声明的形参 + `, **kwargs`**`kwargs` 必须**严格位于形参表最后**Python 语法硬要求)。
```python
# ✅ 正确
def step3_remove_glint(self, img_path: str,
method: str = "subtract_nir",
# ... 30+ 业务形参 ...
skip_dependency_check: bool = False,
**kwargs) -> str:
...
# ❌ 错误:**kwargs 不能放中间或前面
def step3_remove_glint(self, img_path, **kwargs, skip_dependency_check): # SyntaxError
```
## 为什么需要这层防御
`PipelineRunner._invoke``src/core/pipeline/runner.py`)会向方法注入两类参数:
| 层 | 来源 | 形参 key 怎么定 |
|---|---|---|
| **L2** | ctx 字段(按 `spec.requires` 列表) | `_default_param_name(ctx_key)` 默认去 `_path` 后缀 |
| **L3** | `ctx.user_config[step_id]`14 panel dict 整体) | dict 的 key 原样注入 |
**L2 触发 TypeError 的真实场景**2026-06-04 真实发生):
- `PIPELINE_STEPS.step3.requires = ["img_path", "water_mask_path", "glint_mask_path"]`
- Runner 注入 `kwargs["glint_mask_path"] = ctx.glint_mask_path`
- `step3_remove_glint` 形参表**没有** `glint_mask_path`(虽然业务上耀斑掩膜是在子调用 `GlintRemovalStep.run` 内部用的Facade 本身不接)
-**TypeError: step3_remove_glint() got an unexpected keyword argument 'glint_mask_path'**
**L3 触发 TypeError 的场景**
- user_config 14 panel dict 里残留了旧字段名 / 跨 step 串味的字段
- 任何 `user_config[step_id][k]` 中的 `k` 都会被注入
`**kwargs` 一次性解决两类问题。
## 14 个 Facade 方法清单(截至 2026-06-04 已全部带 **kwargs
| step | method | 形参表闭合示例 |
|---|---|---|
| 1 | `step1_generate_water_mask` | `output_path: Optional[str] = None, **kwargs) -> str:` |
| 2 | `step2_find_glint_area` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 3 | `step3_remove_glint` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 4 | `step4_process_csv` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 5 | `step5_extract_training_spectra` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 5.5 | `step5_5_calculate_water_quality_indices` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 6 | `step6_train_models` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 6.5 | `step6_5_non_empirical_modeling` | `skip_dependency_check: bool = False, **kwargs) -> Dict[str, str]:` |
| 6.75 | `step6_75_custom_regression` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 7 | `step7_generate_sampling_points` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
| 8 | `step8_predict_water_quality` | `skip_dependency_check: bool = False, **kwargs) -> Dict[str, str]:` |
| 8.5 | `step8_5_predict_with_non_empirical_models` | `skip_dependency_check: bool = False, **kwargs) -> Dict[str, str]:` |
| 8.75 | `step8_75_predict_with_custom_regression` | `skip_dependency_check: bool = False, **kwargs) -> Dict[str, str]:` |
| 9 | `step9_generate_distribution_map` | `skip_dependency_check: bool = False, **kwargs) -> str:` |
## 标准操作
### 1. 编辑(最小外科手术式)
每个方法的最后形参是 `skip_dependency_check: bool = False`,把这一行改成:
```python
skip_dependency_check: bool = False, **kwargs) -> str:
```
**注意缩进必须与原行一致**13 空格 / 35 空格 / 47 空格 / 48 空格不等,按方法原始缩进)。用 `edit` 工具的 old_string **必须含 docstring 第一行**`"""步骤X: ..."""`)作唯一标识。
### 2. 验证
写一个临时校验脚本(项目根目录运行后删掉):
```python
import ast, re
target = r'D:\111\office\ZHLduijie\1.WQ\WQ_GUI\src\core\water_quality_inversion_pipeline_GUI.py'
src = open(target, encoding='utf-8-sig').read()
ast.parse(src) # AST 语法
expected = [
'step1_generate_water_mask', 'step2_find_glint_area', 'step3_remove_glint',
'step4_process_csv', 'step5_extract_training_spectra',
'step5_5_calculate_water_quality_indices', 'step6_train_models',
'step7_generate_sampling_points', 'step8_predict_water_quality',
'step9_generate_distribution_map', 'step6_5_non_empirical_modeling',
'step6_75_custom_regression', 'step8_5_predict_with_non_empirical_models',
'step8_75_predict_with_custom_regression',
]
pat = re.compile(r'def\s+(\w+)\s*\((?:[^()]|\([^()]*\))*\*\*kwargs\)\s*->\s*[^:]+:', re.DOTALL)
found = set(pat.findall(src))
print('Missing:', [m for m in expected if m not in found])
print('Extra :', [m for m in found if m not in expected])
```
期望输出:两个列表都为空。
### 3. Windows 执行
```bat
cd /d D:\111\office\ZHLduijie\1.WQ\WQ_GUI
py _check.py
del _check.py
```
> 必加 `utf-8-sig``water_quality_inversion_pipeline_GUI.py` 头部可能含 BOM`code_replacement_state_audit` skill 里有同样提示)。
## ⚠️ 这层防御不能解决什么
**` **kwargs` 兜底 ≠ 形参名错位修复**。如果 Runner 注入 `kwargs["training_csv_path"]` 而方法形参是 `csv_path`
-**不会报 TypeError**`training_csv_path``**kwargs` 收走)
-**但 `csv_path` 仍是 None**(方法体内的 `if csv_path is not None: ... else: ...` 走 fallback 分支,可能读 `self.training_csv_path` 哨兵)
**已知形参名错位的方法**2026-06-04 已修commit `64aa5b8`
| step | Runner 注入的 ctx key | 方法实际形参 | 实际落地的修复 |
|---|---|---|---|
| step6_5 | `training_csv_path` | `csv_path` | `parameter_map={"training_csv_path": "csv_path"}` |
| step6_75 | (已切到 `indices_path` | `csv_path` | ⚠️ 见下方"step6_75 路由修复"专题 |
| step8_5 | `models_dir` | `non_empirical_models_dir` | `parameter_map={"models_dir": "non_empirical_models_dir"}` |
| step8_75 | `models_dir` | `custom_regression_dir` | `parameter_map={"models_dir": "custom_regression_dir"}` |
> **parameter_map** 是 `StepSpec` 已有字段runner.py:33作用是把 ctx 字段重命名到方法形参名。**优先用 parameter_map 而非改 requires**——保持 ctx 字段语义清晰(声明式描述上游依赖),形参名是方法私有约定。
### step6_75 路由修复特殊案例2026-06-04 commit `64aa5b8`
`step6_75_custom_regression` **不是简单的 ctx 字段名错位**——方法体内的 fallback 链透露了**真正的数据源是 `indices_path`**
```python
# step6_75 形参csv_path
# 方法体 fallback:
# if csv_path is not None: input_csv = csv_path
# elif self.indices_path is not None: input_csv = self.indices_path # ★ 真相
```
`parameter_map``training_csv_path → csv_path` 看似能跑通,但**实际用错了数据**training_csv 是 step5 输出,不是 step6_75 想要的 indices CSV
**正确做法 = 同时改 requires + parameter_map**
```python
StepSpec(
step_id="step6_75", method_name="step6_75_custom_regression",
requires=["indices_path"], # ★ 从 training_csv_path 切到 indices_path
produces=["models_dir"],
parameter_map={"indices_path": "csv_path"}, # ★ 同步改 key
description="自定义回归分析",
),
```
**配合 `skip_when_missing` 兜底**:若用户没跑 step5_5`ctx.indices_path` 为 Nonerunner 自动 skip 整个 step6_75不会用错位数据静默执行。
**判别何时需要"路由切"vs"纯 rename"**
- 看方法体 fallback 链fallback 到 `self.indices_path`/`self.deglint_img_path`/其他 ctx 字段名 → **需要改 requires**
- 仅是 key 名字不同,方法体直接用形参 → 只改 parameter_map
## L2 注入顺序冲突:多个 requires 字段解析到同一形参名
### 场景
`StepSpec.requires` 里有**多个 ctx 字段**,经过 `_default_param_name` / `parameter_map` 解析后,**会落到同一个方法形参名**。L2 注入是**顺序敏感**的(后者覆盖前者),后注入的会**默默覆盖**前一个的赋值。
### 真实案例2026-06-04 step5 修复)
业务需求step5 真正需要 step4 产物 `processed_csv_path`,但**保留 raw `csv_path` 字段**作为 `user_config` 覆盖入口。
**❌ 错误的 parameter_map 写法**(用户原方案的隐藏 bug
```python
StepSpec(
step_id="step5", method_name="step5_extract_training_spectra",
requires=["deglint_img_path", "processed_csv_path", "csv_path", ...], # raw csv_path 也在
parameter_map={"processed_csv_path": "csv_path"}, # ★ 只映射了一个
...
)
```
L2 注入顺序(`runner.py:184-186`
1. `deglint_img_path``kwargs[deglint_img_path] = ctx.deglint_img_path`
2. `processed_csv_path``kwargs[csv_path] = ctx.processed_csv_path` ← 主路径生效
3. `csv_path`(无映射 → 默认)→ `kwargs[csv_path] = ctx.csv_path`**后注入 None 覆盖了主路径!**
**症状**step5 形参 `csv_path` 拿到的是 raw `ctx.csv_path`(通常是 None方法体 fallback 到 `self.processed_csv_path`——但这个 fallback 也可能是 Nonestep4 没跑step5 内部空跑 → "**静默错误**"。
**✅ 修法parameter_map 双向映射 + 占位名落 **kwargs**
```python
parameter_map={
"processed_csv_path": "csv_path", # 主路径(注入到方法形参)
"csv_path": "_raw_csv_ignored", # 占位(落到 step5 形参列表末尾的 **kwargs
},
```
注入顺序重排后:
- `processed_csv_path``kwargs[csv_path] = ctx.processed_csv_path` ← 主路径
- `csv_path``kwargs[_raw_csv_ignored] = ctx.csv_path` ← 落 **kwargs被吞
step5 形参 `csv_path` 最终拿到 `ctx.processed_csv_path` 的值 ✓。
### 验证模板(行为模拟)
写临时 `_verify_l2_inject.py` 复刻 `runner.py:184-186` 的 L2 注入循环,**不要只靠 AST 静态检查**——parameter_map 的 key 顺序、requires 的字段顺序都是动态的:
```python
import sys
sys.path.insert(0, r'D:\111\office\ZHLduijie\1.WQ\WQ_GUI')
from src.core.pipeline.context import PipelineContext
from src.core.pipeline.runner import PIPELINE_STEPS
spec5 = next(s for s in PIPELINE_STEPS if s.step_id == 'step5')
# 复刻 L2 注入(与 runner.py:184-186 完全一致)
def l2_inject(spec, ctx):
kwargs = {}
for ctx_key in spec.requires:
param_name = spec.parameter_map.get(ctx_key, ctx_key) # ★ 必须原样复刻
kwargs[param_name] = ctx.get(ctx_key)
return kwargs
# 关键断言
ctx = PipelineContext(processed_csv_path='/csv/processed.csv', csv_path='/csv/raw.csv')
kw = l2_inject(spec5, ctx)
assert kw.get('csv_path') == '/csv/processed.csv', \
f"形参 csv_path 应等于 processed_csv_path, 实际 {kw.get('csv_path')!r}"
assert '_raw_csv_ignored' in kw, "占位名应被注入到 kwargs"
print(f'OK: csv_path 形参 = {kw["csv_path"]!r} (processed, 主路径正确)')
print(f'OK: _raw_csv_ignored 占位 = {kw["_raw_csv_ignored"]!r} (raw, 落 **kwargs 被吞)')
```
跑完删掉:`py _verify_l2_inject.py & del _verify_l2_inject.py`Windows 一行模式)。
### 何时需要警惕这个冲突
修改 StepSpec 时检查清单(**先看这一段再写 parameter_map**
- [ ] **requires 里是否有多于 1 个 ctx 字段,解析后会落到同名方法形参?** 典型撞车:
- 同名字段(如 `processed_csv_path``csv_path` 都能映射到 `csv_path`
- 同名 `_default_param_name` 退化(如 `boundary_path``boundary_shp_path` 默认都映射到 `boundary_path`——但要注意 `_default_param_name` 已废弃去后缀,原样返回 ctx key所以 `boundary_path``boundary_shp_path` 默认就会撞 `boundary_path` / `boundary_shp_path` 不会撞,要撞就必须显式 parameter_map
- 字段名 + parameter_map 重命名撞车
- [ ] **"主路径"字段在 requires 列表靠前位置**(让后续"备路径"覆盖,但**这不解决冲突**——只要有第二次注入就一定会覆盖)
- [ ] **"备路径"字段**用占位名 `_xxx_ignored` / `_xxx_kwargs_only` 映射,让它落到 **kwargs
- [ ] **确认方法形参表末尾有 `**kwargs`** 兜底(`facade_kwargs_defense` skill 核心要求,已 14/14 落地)
### 反例(不要做)
- ❌ "我让 `csv_path` 不在 requires 里就行了"——会**丢失 user_config 覆盖入口**(如果用户想用 raw CSV 而不是 processed
- ❌ "改 L2 注入循环,让 parameter_map 字段最后注入"——会**改变 runner 通用语义**,影响所有 step 的注入顺序
- ❌ "加 `if param_name in kwargs: continue` 在 L2 注入里"——隐式"第一次优先"语义,新人读代码摸不着头脑
- ❌ "用 position in requires 做加权"——把数据语义哪个字段优先级高塞到列表顺序里runner 应该保持"声明式"
### 与"纯 rename"的区别
| 维度 | 纯 rename已有 skill 案例) | 多→1 冲突(本节案例) |
|---|---|---|
| 典型场景 | step6_5/6_75/8_5/8_751 个 requires 重命名到形参 | step52 个 requires 撞到同一形参 |
| parameter_map | 1 个 key→value | 2 个 key→同名 value + 占位名 |
| requires | 1 个字段 | 2 个字段(主 + 备) |
| 冲突来源 | 不会出现(单 key | 出现(顺序敏感 + 撞名) |
| 修法 | 只加 parameter_map | 双向 parameter_map + 占位名 |
## 与其他防御层的关系
```
PipelineRunner.run() 主循环
├─ L1 runner.py:152 skip_when_missing ─── ctx.<required> 全 None → skip step
├─ L2 runner.py:182 ctx 字段注入 ─── 形参表里没声明 → TypeError ⚠️ → **kwargs 兜底
├─ L3 runner.py:188 user_config 合并 ─── user_config 有"空字符串"/None → 跳过(上一轮加的守卫)✅
└─ L4 runner.py:211 except 捕获 ─── 业务抛异常 → ctx.status="error" + raise
```
`**kwargs`**L2 的"消极兜底"**——宁愿吞掉多余 key 也不报 TypeError。**真正的"积极修复"是 parameter_map**(让 ctx 字段名映射到正确形参名)。两层配合:
- **保守期间(重构初期)**:先 `**kwargs` 兜住TypeError 消失
- **稳定阶段**:补 parameter_map让方法收到正确数据
## 反例(不要做)
- ❌ "不写 `**kwargs`,靠 type hint + IDE 检查兜底"——Runner 是运行时注入IDE 看不到
- ❌ "把 `**kwargs` 放形参表中间"——Python 语法错误
- ❌ "改 requires 列表去掉冗余 ctx 字段"——会导致 `skip_when_missing` 误判(以为 step 不需要该 ctx 字段),应该用 `parameter_map` 重命名而非删除 requires
- ❌ "在 14 个 Facade 方法体里加 `if 'glint_mask_path' in kwargs: kwargs.pop('glint_mask_path')`"——脏活,且每个方法都要加,远不如 `**kwargs` 一行优雅
## 案例来源
- 2026-06-04 WQ_GUI PipelineRunner 迁移第二步
- 触发:`step3_remove_glint() got an unexpected keyword argument 'glint_mask_path'`
- 根因:`PIPELINE_STEPS.step3.requires` 写了 `glint_mask_path`,但 `GlintRemovalStep` 内部使用Facade 自身不接这个形参
- 落地14 个 Facade 全部加 `, **kwargs`0 个 TypeError
- 验证:临时 `_check.py` 14/14 命中 + AST 解析通过
-4 个 parameter_map 全部落地commit `64aa5b8`),含 step6_75 路由切到 indices_pathL3 非空过滤同步加入 `runner._invoke:188`
- 2026-06-04 step5 严格依赖修复:发现 L2 注入顺序冲突requires 多个字段解析到同一形参名),引入"双向 parameter_map + 占位名落 **kwargs"模式step5 形参 `csv_path` 真正接到 `processed_csv_path`step4 产物raw `csv_path` 保留为 user_config 覆盖入口,落占位名 `_raw_csv_ignored` 后被 `**kwargs` 吞。skip_when_missing 块同步加 `_notify` 通知,**拒绝静默跳过**15 条 _notify 全带具体 missing 字段列表证据)。