feat(step8): 外部模型从单文件升级为母文件夹多模型字典扫描
This commit is contained in:
309
.qwen/skills/facade_kwargs_defense/SKILL.md
Normal file
309
.qwen/skills/facade_kwargs_defense/SKILL.md
Normal 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` 为 None),runner 自动 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 也可能是 None(step4 没跑),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_75:1 个 requires 重命名到形参 | step5:2 个 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_path;L3 非空过滤同步加入 `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 字段列表证据)。
|
||||
Reference in New Issue
Block a user