diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py index c5289d1..daf515e 100644 --- a/pipeline_platform/platform_ability.py +++ b/pipeline_platform/platform_ability.py @@ -582,7 +582,8 @@ async def _h_apply_llm_config(sor, params, ctx): # 名称过滤必须加(2026-09-05 e2e 实测 bug):查询/下载步骤模板同库同协议 # 同能力,不加过滤会被「最新优先」误选为提交模板 → 提交打到查询 path → 404 recs = await sor.sqlExe( - "SELECT id, request_template, response_template FROM llm_api_profile " + "SELECT id, request_template, response_template, param_schema " + "FROM llm_api_profile " "WHERE protocol=${p}$ AND capability=${c}$ AND status='active' " "AND name NOT LIKE ${l1}$ AND name NOT LIKE ${l2}$ " "ORDER BY created_at DESC LIMIT 1", @@ -593,14 +594,23 @@ async def _h_apply_llm_config(sor, params, ctx): pid_old = getattr(recs[0], "id", "") old_tpl = (getattr(recs[0], "request_template", "") or "") + \ (getattr(recs[0], "response_template", "") or "") - if not any(mk in old_tpl for mk in _SKELETON_MARKERS): + # schema 体检:存量脏 schema(未注册 uitype/default/重复字段/结构常量)判死重建, + # 否则代码修了、用户前端还是坏表单(自愈门禁,见 _schema_violations) + sv = _schema_violations(getattr(recs[0], "param_schema", "")) + for s in sv['soft']: + tpl_notes.append("模板 %s(%s)schema 提示:%s" % (pid_old, cap, s)) + if not sv['hard'] and not any(mk in old_tpl for mk in _SKELETON_MARKERS): profile_ids[cap] = pid_old continue await sor.sqlExe("UPDATE llm_api_profile SET status='deprecated' " "WHERE id=${i}$", {"i": pid_old}) await sor.sqlExe("COMMIT", {}) - tpl_notes.append("旧骨架模板 %s(%s)含硬编码占位符,已弃用并按文档示例重建" - % (pid_old, cap)) + if sv['hard']: + tpl_notes.append("存量模板 %s(%s)param_schema 不合规,已弃用重建:%s" + % (pid_old, cap, ";".join(sv['hard']))) + else: + tpl_notes.append("旧骨架模板 %s(%s)含硬编码占位符,已弃用并按文档示例重建" + % (pid_old, cap)) tpl = _gen_templates(protocol, cap, spec) dry_err = _dry_render_check(tpl["req"], cap, tpl["biz_params"], tpl["media_params"]) if dry_err: @@ -1114,6 +1124,60 @@ _SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file', 'params.image_file)', 'params.video_file)', 'params.audio_file)') +def _schema_violations(raw): + """存量 param_schema 合规体检(2026-09-06 用户指正后补的自愈门禁)。 + + 为什么必须有它:apply 的模板复用门禁原先只看 request_template 里的骨架标记, + 完全不看 param_schema —— 于是「模板正常但 schema 是 textarea/number/default」的 + 存量 profile 会被永久复用(实测 happyhorse r2v/t2v/i2v 三个 active profile 全中招, + r2v 还带 3 个重复 type 字段)。代码修好了,用户在前端看到的仍是坏表单: + Input.create 遇未注册 uitype 返回 null 只打一行 debug(input.js:1294),字段静默消失。 + 所以复用前必须体检 schema,违规一律弃用重建,让存量数据跟着代码自愈。 + + 分级(避免误杀仿权威样例的 profile): + hard = 会导致字段消失/撞名覆盖/默认值不生效 → 判死重建 + (未注册 uitype、用 default、同名字段重复、结构常量入表单) + soft = 功能受限但不崩 → 只提示,不判死。典型:媒体数组缺 multiple:true + (input.js:426 决定值是数组还是 files[0];权威样例 minimax_h3_setup.sql:13 + 的 image_files 就没带 multiple,模板靠 for 循环兼容 str,故不能判死) + 返回 {'hard': [...], 'soft': [...]}。 + """ + out = {'hard': [], 'soft': []} + raw = (raw or "").strip() + if not raw: + return out + try: + schema = json.loads(raw) + except Exception as exc: + out['hard'].append("param_schema 不是合法 JSON(%s)" % exc) + return out + if not isinstance(schema, list): + out['hard'].append("param_schema 顶层应为数组") + return out + seen = set() + for f in schema: + if not isinstance(f, dict): + out['hard'].append("schema 元素非对象: %r" % (f,)) + continue + nm = f.get("name") + ut = f.get("uitype") + if ut not in _BRICKS_UITYPES: + out['hard'].append("字段 %s 的 uitype %r 未注册" % (nm, ut)) + if "default" in f: + out['hard'].append("字段 %s 用了 default(应为 defaultvalue)" % nm) + # 同名字段重复:前端 dom_element.id 撞名,后者覆盖前者 + if nm in seen: + out['hard'].append("字段 %s 重复出现" % nm) + seen.add(nm) + # 结构常量不该出现在表单(值已固化在模板里) + if nm in _STRUCT_CONST_FIELDS: + out['hard'].append("结构常量字段 %s 不该让用户填" % nm) + # 媒体数组参数缺 multiple:UI 只能选 1 个(soft,权威样例也这样) + if nm in _MEDIA_UITYPE and not f.get("multiple"): + out['soft'].append("媒体数组字段 %s 缺 multiple:true(UI 只能选 1 个)" % nm) + return out + + def _biz_field_uitype(name, example, enums): """业务参数 → bricks uitype(2026-09-06 用户指正:只准用注册过的类型)。