diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py index 3824ad8..f7e5571 100644 --- a/pipeline_platform/platform_ability.py +++ b/pipeline_platform/platform_ability.py @@ -16,6 +16,7 @@ pipeline-service: platform_ability — 平台内部 agent 能力包(pipeline_i """ import asyncio +import hashlib import json import logging import re @@ -525,8 +526,10 @@ def _capability_guard(capability, description): async def _h_apply_llm_config(sor, params, ctx): """按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型。 - 幂等:供应商按名称复用;模型按 name/vendor_model_id 复用(name 全局唯一键)—— - 已存在则更新元数据;供应商归属不一致时不改挂、如实报 vendor_conflicts。 + 幂等:供应商按名称复用;模板按 (协议×能力×形态指纹) 复用——同形态复用/收编, + 异形态各建各的(见 _tpl_fingerprint);模型按 name/vendor_model_id 复用 + (name 全局唯一键)——已存在则更新元数据;供应商归属不一致时不改挂、 + 如实报 vendor_conflicts。 规格来源(2026-09-05 防编造改造): use_last_extract=true(推荐)→ 取本会话 extract 锚定的规格, @@ -681,34 +684,40 @@ async def _h_apply_llm_config(sor, params, ctx): vendor_action = "新建供应商 %s(%s)" % (vendor_id, vendor_name) vendor_endpoints = eps_norm - # 2. 适配模板:按 (协议×能力) 复用;旧骨架模板(硬编码 xxx_file/__from_doc__, - # 2026-09-05 实测 t2v 因它渲染崩)视为不可用,按文档示例自愈重建 + # 2. 适配模板:按 (协议×能力×形态指纹) 复用(2026-09-06 用户定夺)。 + # 纯按能力复用的事故:happyhorse r2v 纯图 vs wan3.0 r2v 四模态,共享模板 + # 静默丢媒体;一模型一模板则 wan3.1 同 API 时白白新增。指纹同→复用, + # 指纹异→各建各的。旧骨架模板(硬编码 xxx_file/__from_doc__,2026-09-05 + # 实测 t2v 因它渲染崩)视为不可用,按文档示例自愈重建。 + # 旧命名 {protocol}-{cap}-自动配置(无指纹段):形态一致则改名收编, + # 不一致则保留(已挂模型继续可用),新形态另建。 profile_ids = {} tpl_errors = [] tpl_notes = [] tpl_notes.extend(cap_notes) # 能力守卫纠正/告警先进 notes(前置阶段产生) caps = sorted(set((m.get("capability") or "t2t") for m in models)) for cap in caps: - # 名称过滤必须加(2026-09-05 e2e 实测 bug):查询/下载步骤模板同库同协议 - # 同能力,不加过滤会被「最新优先」误选为提交模板 → 提交打到查询 path → 404 - # 两种命名都要排(2026-09-06 i2v 实测漏网): - # 共享命名 {protocol}-{purpose}-{path} → 含中缀 -query- - # legacy 命名 {vmid}-{purpose} → 以 -query 结尾(无后置短横) - # 只排中缀时 legacy 查询模板会被当成提交模板挂到模型上(schema 为空 - # 过不了体检门禁也不报错——空 schema 无 hard 违规,静默误挂) + # 先生成再比对:指纹来自生成结果(path/req/resp/schema 归一化 sha256[:12]) + tpl = _gen_templates(protocol, cap, spec) + dry_err = _dry_render_check(tpl["req"], cap, tpl["biz_params"], tpl["media_params"]) + if dry_err: + # 宁可不建,不埋雷:渲染不了的模板落库=运行时必崩,如实报错由助手修正规格重跑 + # 模型已挂的旧模板不动(更新路径 IF 保护,profile_id 保持原值) + tpl_errors.append("%s: %s" % (cap, dry_err)) + continue + fp = _tpl_fingerprint(tpl["path"], tpl["req"], tpl["resp"], tpl["param_schema"]) + shared_name = "%s-%s-自动配置-%s" % (protocol, cap, fp) + legacy_name = "%s-%s-自动配置" % (protocol, cap) recs = await sor.sqlExe( - "SELECT id, request_template, response_template, param_schema " + "SELECT id, name, path, 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}$ " - "AND name NOT LIKE ${l3}$ AND name NOT LIKE ${l4}$ " - "ORDER BY created_at DESC LIMIT 1", - {"p": protocol, "c": cap, - "l1": "%-query-%", "l2": "%-download-%", - "l3": "%-query", "l4": "%-download"}) + "WHERE name IN (${n1}$, ${n2}$) AND status='active' " + "ORDER BY CASE WHEN name=${n1}$ THEN 0 ELSE 1 END LIMIT 1", + {"n1": shared_name, "n2": legacy_name}) await sor.sqlExe("COMMIT", {}) if recs: pid_old = getattr(recs[0], "id", "") + nm_old = getattr(recs[0], "name", "") old_tpl = (getattr(recs[0], "request_template", "") or "") + \ (getattr(recs[0], "response_template", "") or "") # schema 体检:存量脏 schema(未注册 uitype/default/重复字段/结构常量)判死重建, @@ -716,27 +725,45 @@ async def _h_apply_llm_config(sor, params, ctx): 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): + fp_old = _tpl_fingerprint(getattr(recs[0], "path", "") or "", + getattr(recs[0], "request_template", "") or "", + getattr(recs[0], "response_template", "") or "", + getattr(recs[0], "param_schema", "") or "") + broken = bool(sv['hard']) or any(mk in old_tpl for mk in _SKELETON_MARKERS) + if not broken and fp_old == fp: + if nm_old != shared_name: + # 旧命名收编:形态一致,改名挂指纹(存量模型按 id 引用,不受影响) + await sor.sqlExe( + "UPDATE llm_api_profile SET name=${n}$ WHERE id=${i}$", + {"n": shared_name, "i": pid_old}) + await sor.sqlExe("COMMIT", {}) + tpl_notes.append("模板 %s(%s)形态与本次生成一致,旧命名收编为 %s" + % (pid_old, cap, shared_name)) 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", {}) - if sv['hard']: - tpl_notes.append("存量模板 %s(%s)param_schema 不合规,已弃用重建:%s" - % (pid_old, cap, ";".join(sv['hard']))) + if broken or nm_old == shared_name: + # 坏模板(hard 违规/骨架标记)一律弃用——不论命名; + # 指纹命名但内容漂移(人工改过库)的也弃用重建 + await sor.sqlExe("UPDATE llm_api_profile SET status='deprecated' " + "WHERE id=${i}$", {"i": pid_old}) + await sor.sqlExe("COMMIT", {}) + if sv['hard']: + tpl_notes.append("存量模板 %s(%s)param_schema 不合规,已弃用重建:%s" + % (pid_old, cap, ";".join(sv['hard']))) + elif any(mk in old_tpl for mk in _SKELETON_MARKERS): + tpl_notes.append("旧骨架模板 %s(%s)含硬编码占位符,已弃用并按文档示例重建" + % (pid_old, cap)) + else: + tpl_notes.append("模板 %s(%s)内容与命名指纹不符(漂移),已弃用重建" + % (pid_old, cap)) 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: - # 宁可不建,不埋雷:渲染不了的模板落库=运行时必崩,如实报错由助手修正规格重跑 - tpl_errors.append("%s: %s" % (cap, dry_err)) - continue + # 旧命名、健康、但形态不一致:保留(还挂着存量模型,弃用会断运行时), + # 新形态走下面新建 + tpl_notes.append("旧命名模板 %s(%s)形态与本次生成不一致(指纹 %s≠%s)," + "保留旧模板、按新形态另建" % (pid_old, cap, fp_old, fp)) pid = getID() await sor.C("llm_api_profile", { - "id": pid, "name": "%s-%s-自动配置" % (protocol, cap), + "id": pid, "name": shared_name, "protocol": protocol, "capability": cap, "path": tpl["path"], # method 独立列(2026-09-06 用户定夺):提交类模板恒 POST @@ -1276,6 +1303,36 @@ _SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file', 'params.image_file)', 'params.video_file)', 'params.audio_file)') +def _tpl_fingerprint(path: str, req: str, resp: str, schema) -> str: + """模板形态指纹(2026-09-06 用户定夺):提交模板复用键=协议×能力×指纹。 + + 为什么不能只按能力复用(实测事故):happyhorse r2v 纯图参考、wan3.0 r2v + 四模态参考(图/视频/音频)——按能力共享一份模板,后配模型的媒体模态被 + 静默丢弃。也不能一模型一模板:wan3.1 若 API 与 wan3.0 完全一致应零新增。 + 指纹=归一化 (path, request_template, response_template, param_schema) 的 + sha256 前 12 位:形态同→复用,形态异→各建各的,无需人工设计签名维度。 + + 归一化规则:path 去空白;req/resp/schema JSON 往返(loads→dumps sorted + 紧凑分隔符)消除键序/空白差异;解析失败退化为去空白原文(保守不误判同形)。 + headers/method 不入指纹:headers 按协议统一,提交模板 method 恒 POST。 + """ + def _canon(s): + s = (s or "").strip() + if not s: + return "" + try: + return json.dumps(json.loads(s), sort_keys=True, + ensure_ascii=False, separators=(",", ":")) + except Exception: + return re.sub(r"\s+", "", s) + + if not isinstance(schema, str): + schema = json.dumps(schema, sort_keys=True, ensure_ascii=False) + blob = "|".join([(path or "").strip(), + _canon(req), _canon(resp), _canon(schema)]) + return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12] + + def _schema_violations(raw): """存量 param_schema 合规体检(2026-09-06 用户指正后补的自愈门禁)。