Files
WQ_GUI/.qwen/skills/facade_kwargs_defense/SKILL.md

16 KiB
Raw Blame History

name, description, source, extracted_at
name description source extracted_at
PipelineRunner Facade 防御性 kwargs 兜底 WQ_GUI 14 个 stepX_... Facade 方法必须以 **kwargs 收尾——配合 PipelineRunner 调度模式杜绝 "unexpected keyword argument" TypeError auto-skill 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 的形参表 = 显式声明的形参 + , **kwargskwargs 必须严格位于形参表最后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._invokesrc/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,把这一行改成:

                                       skip_dependency_check: bool = False, **kwargs) -> str:

注意缩进必须与原行一致13 空格 / 35 空格 / 47 空格 / 48 空格不等,按方法原始缩进)。用 edit 工具的 old_string 必须含 docstring 第一行"""步骤X: ...""")作唯一标识。

2. 验证

写一个临时校验脚本(项目根目录运行后删掉):

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 执行

cd /d D:\111\office\ZHLduijie\1.WQ\WQ_GUI
py _check.py
del _check.py

必加 utf-8-sigwater_quality_inversion_pipeline_GUI.py 头部可能含 BOMcode_replacement_state_audit skill 里有同样提示)。

⚠️ 这层防御不能解决什么

**kwargs 兜底 ≠ 形参名错位修复。如果 Runner 注入 kwargs["training_csv_path"] 而方法形参是 csv_path

  • 不会报 TypeErrortraining_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_mapStepSpec 已有字段runner.py:33作用是把 ctx 字段重命名到方法形参名。优先用 parameter_map 而非改 requires——保持 ctx 字段语义清晰(声明式描述上游依赖),形参名是方法私有约定。

step6_75 路由修复特殊案例2026-06-04 commit 64aa5b8

step6_75_custom_regression 不是简单的 ctx 字段名错位——方法体内的 fallback 链透露了真正的数据源是 indices_path

# 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_maptraining_csv_path → csv_path 看似能跑通,但实际用错了数据training_csv 是 step5 输出,不是 step6_75 想要的 indices CSV

正确做法 = 同时改 requires + parameter_map

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_5ctx.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

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_pathkwargs[deglint_img_path] = ctx.deglint_img_path
  2. processed_csv_pathkwargs[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

parameter_map={
    "processed_csv_path": "csv_path",       # 主路径(注入到方法形参)
    "csv_path": "_raw_csv_ignored",         # 占位(落到 step5 形参列表末尾的 **kwargs
},

注入顺序重排后:

  • processed_csv_pathkwargs[csv_path] = ctx.processed_csv_path ← 主路径
  • csv_pathkwargs[_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 的字段顺序都是动态的:

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.pyWindows 一行模式)。

何时需要警惕这个冲突

修改 StepSpec 时检查清单(先看这一段再写 parameter_map

  • requires 里是否有多于 1 个 ctx 字段,解析后会落到同名方法形参? 典型撞车:
    • 同名字段(如 processed_csv_pathcsv_path 都能映射到 csv_path
    • 同名 _default_param_name 退化(如 boundary_pathboundary_shp_path 默认都映射到 boundary_path——但要注意 _default_param_name 已废弃去后缀,原样返回 ctx key所以 boundary_pathboundary_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

**kwargsL2 的"消极兜底"——宁愿吞掉多余 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 全部加 , **kwargs0 个 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_pathstep4 产物raw csv_path 保留为 user_config 覆盖入口,落占位名 _raw_csv_ignored 后被 **kwargs 吞。skip_when_missing 块同步加 _notify 通知,拒绝静默跳过15 条 _notify 全带具体 missing 字段列表证据)。