feat(platform): 媒体契约升级为三数组image_files/audio_files/video_files(对齐sage/llmage)——变长媒体数组按Jinja for动态组装,禁按示例个数展开固定槽位(r2v渲染崩根因);干跑双形态校验;提示词+工具schema同步;新增硬规则禁让用户手改模板

This commit is contained in:
yumoqing 2026-09-06 10:23:01 +08:00
parent 523f3307d7
commit 4573aeba3f

View File

@ -766,6 +766,7 @@ async def _audit_media_convention(sor, profile_ids):
"(%s)的产物 URL 必须落地为本地持久 URL" % (name, cap)) "(%s)的产物 URL 必须落地为本地持久 URL" % (name, cap))
# 上传参数线索:模板里出现 image/video/audio 文件参数但没有 b64media2url # 上传参数线索:模板里出现 image/video/audio 文件参数但没有 b64media2url
has_upload_ref = any(k in rt for k in ('image_file', 'video_file', 'audio_file', has_upload_ref = any(k in rt for k in ('image_file', 'video_file', 'audio_file',
'image_files', 'video_files', 'audio_files',
'first_frame', 'image_url', 'media')) 'first_frame', 'image_url', 'media'))
if has_upload_ref and 'b64media2url' not in rt: if has_upload_ref and 'b64media2url' not in rt:
issues.append("「%s」request 模板含媒体上传参数但缺 b64media2url——" issues.append("「%s」request 模板含媒体上传参数但缺 b64media2url——"
@ -787,7 +788,8 @@ _MEDIA_OUT_FIELD = {
'video': 'video_url', 'image': 'image_url', 'audio': 'audio_url', 'glb': 'model_url', 'video': 'video_url', 'image': 'image_url', 'audio': 'audio_url', 'glb': 'model_url',
} }
# 文档示例里表示「上传媒体」的结构线索 # 文档示例里表示「上传媒体」的结构线索
_MEDIA_TYPES = ('first_frame', 'last_frame', 'ref_image', 'ref_images', 'image', 'img') _MEDIA_TYPES = ('first_frame', 'last_frame', 'ref_image', 'ref_images', 'image', 'img',
'reference_image', 'reference_video', 'reference_audio', 'reference')
_MEDIA_KEY_HINTS = ('img_url', 'image_url', 'first_frame_url', 'last_frame_url', _MEDIA_KEY_HINTS = ('img_url', 'image_url', 'first_frame_url', 'last_frame_url',
'ref_image_url', 'video_url', 'audio_url') 'ref_image_url', 'video_url', 'audio_url')
# 需要「输入媒体」的能力(这些能力里出现 URL 叶子才判定为上传媒体) # 需要「输入媒体」的能力(这些能力里出现 URL 叶子才判定为上传媒体)
@ -795,13 +797,31 @@ _MEDIA_INPUT_CAPS = ('i2v', 'i2i', 'v2v', 'i2t', '2i2v', 'r2v')
def _media_param_name(typeval, keyname): def _media_param_name(typeval, keyname):
"""按文档媒体结构推断运行时统一媒体参数名(xxx_file 契约)。""" """按文档媒体结构推断运行时统一媒体参数名。
2026-09-06 用户定夺契约(对齐 sage/llmage):媒体统一三数组参数
image_files / audio_files / video_files——值是数组或单字符串,
模板 Jinja 动态判断两种形态;有些模型三种任意组合(r2v 最多 9 图),
有些只支持图片,按文档示例动态组装,不固定槽位。
"""
t = ('%s %s' % (typeval or '', keyname or '')).lower() t = ('%s %s' % (typeval or '', keyname or '')).lower()
if 'video' in t: if 'video' in t:
return 'video_file' return 'video_files'
if 'audio' in t: if 'audio' in t:
return 'audio_file' return 'audio_files'
return 'image_file' return 'image_files'
def _media_list_expr(param):
"""Jinja 表达式:params.<param> 归一为列表(字符串→单元素;缺省→空列表)。"""
return ("(params.{p} if params.{p} is not string and params.{p} "
"else ([params.{p}] if params.{p} is string else []))").format(p=param)
def _media_scalar_expr(param):
"""Jinja 表达式:params.<param> 取单值(字符串原样;数组取首个;缺省→空串)。"""
return ("(params.{p} if params.{p} is string "
"else ((params.{p} or [''])[0]))").format(p=param)
def _is_url_example(v): def _is_url_example(v):
@ -826,14 +846,85 @@ def _example_to_nested(fields):
return root return root
def _is_media_array(lst, capability):
"""判定「媒体数组」(2026-09-06 r2v 教训):文档示例里
[{"type": "reference_image", "url": "https://..."}, ...] 这类变长数组
(r2v 最多 9 张参考图,有些模型图/视频/音频任意组合)。
判据:非空 dict 列表,每个元素都含 URL 叶子且 type/键名带媒体线索
(或能力属输入媒体类)。整组换 Jinja 动态组装,**禁止按示例元素个数
展开固定槽位**——那是上一版 r2v 模板渲染崩(image_file)的根因。
"""
if not isinstance(lst, list) or not lst:
return False
for d in lst:
if not isinstance(d, dict):
return False
tval = str(d.get('type') or '').lower()
url_hit = any(_is_url_example(v) for v in d.values())
hinted = (tval in _MEDIA_TYPES
or any(str(k).lower() in _MEDIA_KEY_HINTS for k in d)
or capability in _MEDIA_INPUT_CAPS)
if not (url_hit and hinted):
return False
return True
def _media_array_expr(elements):
"""媒体数组示例 → Jinja 动态组装表达式(三数组契约 image_files/
audio_files/video_files,值为字符串或数组均可)。
按元素 type/键名归组到统一参数,每组渲染一个 for 循环(元素结构逐字
保留,仅 URL 叶子换 b64media2url);混合类型多循环用 namespace 计数
合并逗号。返回 (expr, media_params)。
"""
groups, seen = [], set()
for d in elements:
if not isinstance(d, dict):
continue
tval = d.get('type') if isinstance(d.get('type'), str) else ''
urlkey = next((k for k, v in d.items() if _is_url_example(v)), '')
if not urlkey:
continue
mp = _media_param_name(tval, urlkey)
if mp in seen:
continue
seen.add(mp)
parts = []
for k, v in d.items():
if k == urlkey:
parts.append('"%s": {{ b64media2url(request, _f)|tojson }}' % k)
else:
parts.append('%s: %s' % (json.dumps(str(k), ensure_ascii=False),
json.dumps(v, ensure_ascii=False)))
groups.append((mp, '{%s}' % ', '.join(parts)))
if not groups:
return '', []
params = [g[0] for g in groups]
if len(groups) == 1:
mp, elem = groups[0]
expr = ('[{%% for _f in %s %%}%s{%% if not loop.last %%},'
'{%% endif %%}{%% endfor %%}]' % (_media_list_expr(mp), elem))
return expr, params
segs = ['[{% set _c = namespace(n=0) %}']
for mp, elem in groups:
segs.append('{%% for _f in %s %%}{%% if _c.n %%},{%% endif %%}'
'{%% set _c.n = _c.n + 1 %%}%s{%% endfor %%}'
% (_media_list_expr(mp), elem))
segs.append(']')
return ''.join(segs), params
def _tpl_from_example(example, capability): def _tpl_from_example(example, capability):
"""文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。 """文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。
规则(2026-09-05,替代旧的硬编码骨架): 规则(2026-09-06 三数组契约版,对齐 sage/llmage):
model 键 → {{ model|tojson }} model 键 → {{ model|tojson }}
prompt/text 键 → {{ prompt|tojson }} prompt/text 键 → {{ prompt|tojson }}
上传媒体(示例里是 URL 叶子,且结构/键名带媒体线索或能力属输入媒体类) 媒体数组(r2v media 等变长数组)→ Jinja for 循环按 image_files/
→ {{ b64media2url(request, params.<media>_file)|tojson }} audio_files/video_files 动态组装(见 _media_array_expr)
单值上传媒体 → {{ b64media2url(request, <scalar_expr>)|tojson }}
(params.xxx_files 字符串/数组两形态兼容)
其他标量 → {{ params.<key>|default(<文档示例值>)|tojson }} 其他标量 → {{ params.<key>|default(<文档示例值>)|tojson }}
**示例里没有上传媒体就不生成媒体参数**——纯文生视频(t2v)曾因硬编码 **示例里没有上传媒体就不生成媒体参数**——纯文生视频(t2v)曾因硬编码
params.xxx_file 骨架在运行时崩('dict object' has no attribute 'xxx_file')。 params.xxx_file 骨架在运行时崩('dict object' has no attribute 'xxx_file')。
@ -852,6 +943,13 @@ def _tpl_from_example(example, capability):
def walk(node, parent_key='', sib_type=None): def walk(node, parent_key='', sib_type=None):
if isinstance(node, list): if isinstance(node, list):
if _is_media_array(node, capability):
expr, mps = _media_array_expr(node)
if expr:
for mp in mps:
if mp not in media_params:
media_params.append(mp)
return _put(expr)
return [walk(v, parent_key, sib_type) for v in node] return [walk(v, parent_key, sib_type) for v in node]
if not isinstance(node, dict): if not isinstance(node, dict):
return node return node
@ -876,7 +974,8 @@ def _tpl_from_example(example, capability):
mp = _media_param_name(tval, k) mp = _media_param_name(tval, k)
if mp not in media_params: if mp not in media_params:
media_params.append(mp) media_params.append(mp)
out[k] = _put('{{ b64media2url(request, params.%s)|tojson }}' % mp) out[k] = _put('{{ b64media2url(request, %s)|tojson }}'
% _media_scalar_expr(mp))
continue continue
biz_params.append({'name': k, 'example': v}) biz_params.append({'name': k, 'example': v})
if v == '' or v is None: if v == '' or v is None:
@ -1015,7 +1114,10 @@ def _gen_templates(protocol: str, capability: str, spec: dict) -> dict:
# 旧骨架模板标记:含这些标记的 profile 视为不可用,apply 时按文档自愈重建 # 旧骨架模板标记:含这些标记的 profile 视为不可用,apply 时按文档自愈重建
_SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file') _SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file',
# 旧单值媒体契约(2026-09-06 前):params.image_file) 等。
# 新三数组契约表达式是 params.image_files if ...,不会误伤。
'params.image_file)', 'params.video_file)', 'params.audio_file)')
def _dry_render_check(req_tpl, capability, biz_params, media_params): def _dry_render_check(req_tpl, capability, biz_params, media_params):
@ -1034,25 +1136,33 @@ def _dry_render_check(req_tpl, capability, biz_params, media_params):
def _stub(request, value, *a, **k): def _stub(request, value, *a, **k):
return str(value) # StrictUndefined 传入未定义值时在此抛错 return str(value) # StrictUndefined 传入未定义值时在此抛错
params = {} params_arr = {}
for b in biz_params or []: for b in biz_params or []:
# 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串 # 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串
# (模板 {{ params.x|tojson }} 无默认——运行时必须显式传,属预期契约) # (模板 {{ params.x|tojson }} 无默认——运行时必须显式传,属预期契约)
params[b['name']] = b['example'] if b.get('example') not in ('', None) else 'dry' params_arr[b['name']] = b['example'] if b.get('example') not in ('', None) else 'dry'
params_str = dict(params_arr)
for mp in media_params or []: for mp in media_params or []:
params[mp] = 'https://dry-run.invalid/sample.bin' # 三数组契约两形态都要干跑(2026-09-06):数组走 for 循环路径,
ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [], # 字符串走 `is string` 归一路径——模板只兼容其一会在运行时崩
'params': params, 'api_key': 'sk-dry-run', 'org_id': '0', params_arr[mp] = ['https://dry-run.invalid/sample.bin']
'request': None, 'json': json, params_str[mp] = 'https://dry-run.invalid/sample.bin'
'b64media2url': _stub, 'downloadfile2url': _stub} errors = []
try: for tag, params in (('数组形态', params_arr), ('字符串形态', params_str)):
env = Environment(undefined=StrictUndefined) ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
out = env.from_string(req_tpl).render(**ns) 'params': params, 'api_key': 'sk-dry-run', 'org_id': '0',
json.loads(out) 'request': None, 'json': json,
except Exception as e: 'b64media2url': _stub, 'downloadfile2url': _stub}
return ("请求模板干跑渲染失败(落库前拦截):%s: %s——模板引用了运行时不存在的" try:
"变量(业务参数可用:%s)" % (type(e).__name__, str(e)[:160], env = Environment(undefined=StrictUndefined)
sorted(params.keys()) or '无')) out = env.from_string(req_tpl).render(**ns)
json.loads(out)
except Exception as e:
errors.append("%s渲染失败:%s: %s" % (tag, type(e).__name__, str(e)[:120]))
if errors:
return ("请求模板干跑渲染失败(落库前拦截):%s——模板引用了运行时不存在的"
"变量(业务参数可用:%s)" % (";".join(errors),
sorted(params_arr.keys()) or '无'))
return "" return ""
@ -1330,7 +1440,8 @@ async def _h_test_model_call(sor, params, ctx):
"""真实调用测试:走完整治理链(门禁→上游→结算),返回调用结果+流水ID。 """真实调用测试:走完整治理链(门禁→上游→结算),返回调用结果+流水ID。
参数:model_name 必填;prompt 文本提示(t2t);业务参数放 params JSON 参数:model_name 必填;prompt 文本提示(t2t);业务参数放 params JSON
(如 image_file/resolution/duration);timeout 等待秒数(异步模型默认 600)。 (媒体统一三数组契约 image_files/audio_files/video_files——字符串或数组
均可;另 resolution/duration 等);timeout 等待秒数(异步模型默认 600)。
前置:供应商账号已配 api_key(无 key 会报可行动错误,不静默)。 前置:供应商账号已配 api_key(无 key 会报可行动错误,不静默)。
""" """
err = await _require_owner(sor, ctx) err = await _require_owner(sor, ctx)
@ -1548,7 +1659,10 @@ PLATFORM_TOOLS = [
parameters={ parameters={
"model_name": "模型名(llm_model.name 或 vendor_model_id)", "model_name": "模型名(llm_model.name 或 vendor_model_id)",
"prompt": "文本提示词(t2t 默认「回复两个字:正常」;生成类填生成描述)", "prompt": "文本提示词(t2t 默认「回复两个字:正常」;生成类填生成描述)",
"params": "业务参数 JSON 字符串(生成类模型用,如 {\"image_file\": \"https://...\", \"resolution\": \"480P\", \"duration\": 5})", "params": "业务参数 JSON 字符串(生成类模型用;媒体统一三数组契约 "
"image_files/audio_files/video_files,字符串或数组均可,如 "
"{\\\"image_files\\\": [\\\"https://...\\\", \\\"https://...\\\"], "
"\\\"resolution\\\": \\\"480P\\\", \\\"duration\\\": 5})",
"timeout": "等待秒数(默认600,上限900)", "timeout": "等待秒数(默认600,上限900)",
}, },
category="platform", category="platform",
@ -1586,7 +1700,8 @@ PLATFORM_PROMPT = """
三轮全编造 vendor{}/api_profile{} 等不存在的字段)。** 三轮全编造 vendor{}/api_profile{} 等不存在的字段)。**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定) 6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定)
7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补, 7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补,
不要伪造结果)——生成类模型传 params(image_file/resolution/duration 等) 不要伪造结果)——生成类模型传 params(媒体三数组 image_files/audio_files/
video_files + resolution/duration 等)
8. `check_model_accounting` 检查记账(传上一步的 task_ref): 8. `check_model_accounting` 检查记账(传上一步的 task_ref):
- accounted 且 amount_match=true → 全链通过,汇报金额 - accounted 且 amount_match=true → 全链通过,汇报金额
- created → 等 60~120 秒再查一次(出账循环 60 秒一轮) - created → 等 60~120 秒再查一次(出账循环 60 秒一轮)
@ -1610,8 +1725,12 @@ PLATFORM_PROMPT = """
- **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导 - **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导
- **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL - **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL
- 能力分类不存在 → 先加分类(字典/种子/提示词/端点注释四处同步)再配模型,禁止塞近似分类 - 能力分类不存在 → 先加分类(字典/种子/提示词/端点注释四处同步)再配模型,禁止塞近似分类
- 生成类模型媒体铁律:上行 b64media2url(request, xxx_file)、下行 downloadfile2url(request, url); - 生成类模型媒体铁律:上行统一三数组契约 image_files/audio_files/video_files
apply 返回的 media_audit 警告必须如实转告 (字符串或数组均可)经 b64media2url 转公网 URL;下行产物用
downloadfile2url(request, url) 落地;apply 返回的 media_audit 警告必须如实转告
- **模板渲染报错(如 'dict object' has no attribute 'xxx_file')是机制缺陷,
不是要用户去页面手改模板**:如实报告错误原文 + 你判断的根因,由维护者修生成器;
禁止提出「请你在模型治理页面把模板改成…」这类人工兜底方案,禁止把模板 JSON 抄给用户
- 非标协议骨架模板需人工核对的项(__note__)必须如实转告用户,不假装完成 - 非标协议骨架模板需人工核对的项(__note__)必须如实转告用户,不假装完成
- 文档抓取失败/内容不足时如实说明,不要凭记忆编造 API 格式 - 文档抓取失败/内容不足时如实说明,不要凭记忆编造 API 格式
- 测试没有 api_key 就停在配置阶段如实汇报,禁止跳过测试谎称完成 - 测试没有 api_key 就停在配置阶段如实汇报,禁止跳过测试谎称完成