feat(platform): 提交模板复用键改形态指纹(协议×能力×指纹)——根治r2v媒体模态混用(2026-09-06用户确认方案)

- _tpl_fingerprint: 归一化(path,req,resp,schema) sha256[:12],JSON往返消键序/空白差,Jinja原文退化去空白
- 命名 {protocol}-{cap}-自动配置-{指纹}: 同形态复用(旧命名收编改名),异形态各建各的(旧模板保留不断存量)
- 坏模板(hard违规/骨架)一律弃用重建;指纹命名但内容漂移的也弃用重建
- 精确命名查找取代LIKE排除查询/下载模板(命名形态不同天然不误选)
- 效果: happyhorse r2v纯图与wan3.0 r2v四模态各挂各的;wan3.1同API零新增
- 本地单测8项全过(键序归一/媒体差异敏感/path敏感/schema混传等价/Jinja退化)
This commit is contained in:
yumoqing 2026-09-06 19:51:57 +08:00
parent 3c0de3bad4
commit 7af34c1251

View File

@ -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%sschema 提示:%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%sparam_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%sparam_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 往返loadsdumps 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 用户指正后补的自愈门禁)。