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))
# 上传参数线索:模板里出现 image/video/audio 文件参数但没有 b64media2url
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'))
if has_upload_ref and 'b64media2url' not in rt:
issues.append("%s」request 模板含媒体上传参数但缺 b64media2url——"
@ -787,7 +788,8 @@ _MEDIA_OUT_FIELD = {
'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',
'ref_image_url', 'video_url', 'audio_url')
# 需要「输入媒体」的能力(这些能力里出现 URL 叶子才判定为上传媒体)
@ -795,13 +797,31 @@ _MEDIA_INPUT_CAPS = ('i2v', 'i2i', 'v2v', 'i2t', '2i2v', 'r2v')
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()
if 'video' in t:
return 'video_file'
return 'video_files'
if 'audio' in t:
return 'audio_file'
return 'image_file'
return 'audio_files'
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):
@ -826,14 +846,85 @@ def _example_to_nested(fields):
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):
"""文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。
规则2026-09-05替代旧的硬编码骨架
规则2026-09-06 三数组契约版对齐 sage/llmage
model {{ model|tojson }}
prompt/text {{ prompt|tojson }}
上传媒体示例里是 URL 叶子且结构/键名带媒体线索或能力属输入媒体类
{{ b64media2url(request, params.<media>_file)|tojson }}
媒体数组r2v media 等变长数组 Jinja for 循环按 image_files/
audio_files/video_files 动态组装 _media_array_expr
单值上传媒体 {{ b64media2url(request, <scalar_expr>)|tojson }}
params.xxx_files 字符串/数组两形态兼容
其他标量 {{ params.<key>|default(<文档示例值>)|tojson }}
**示例里没有上传媒体就不生成媒体参数**纯文生视频(t2v)曾因硬编码
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):
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]
if not isinstance(node, dict):
return node
@ -876,7 +974,8 @@ def _tpl_from_example(example, capability):
mp = _media_param_name(tval, k)
if mp not in media_params:
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
biz_params.append({'name': k, 'example': v})
if v == '' or v is None:
@ -1015,7 +1114,10 @@ def _gen_templates(protocol: str, capability: str, spec: dict) -> dict:
# 旧骨架模板标记:含这些标记的 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):
@ -1034,25 +1136,33 @@ def _dry_render_check(req_tpl, capability, biz_params, media_params):
def _stub(request, value, *a, **k):
return str(value) # StrictUndefined 传入未定义值时在此抛错
params = {}
params_arr = {}
for b in biz_params or []:
# 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串
# (模板 {{ 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 []:
params[mp] = 'https://dry-run.invalid/sample.bin'
ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
'params': params, 'api_key': 'sk-dry-run', 'org_id': '0',
'request': None, 'json': json,
'b64media2url': _stub, 'downloadfile2url': _stub}
try:
env = Environment(undefined=StrictUndefined)
out = env.from_string(req_tpl).render(**ns)
json.loads(out)
except Exception as e:
return ("请求模板干跑渲染失败(落库前拦截):%s: %s——模板引用了运行时不存在的"
"变量(业务参数可用:%s" % (type(e).__name__, str(e)[:160],
sorted(params.keys()) or ''))
# 三数组契约两形态都要干跑2026-09-06数组走 for 循环路径,
# 字符串走 `is string` 归一路径——模板只兼容其一会在运行时崩
params_arr[mp] = ['https://dry-run.invalid/sample.bin']
params_str[mp] = 'https://dry-run.invalid/sample.bin'
errors = []
for tag, params in (('数组形态', params_arr), ('字符串形态', params_str)):
ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
'params': params, 'api_key': 'sk-dry-run', 'org_id': '0',
'request': None, 'json': json,
'b64media2url': _stub, 'downloadfile2url': _stub}
try:
env = Environment(undefined=StrictUndefined)
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 ""
@ -1330,7 +1440,8 @@ async def _h_test_model_call(sor, params, ctx):
"""真实调用测试:走完整治理链(门禁→上游→结算),返回调用结果+流水ID。
参数model_name 必填prompt 文本提示t2t业务参数放 params JSON
image_file/resolution/durationtimeout 等待秒数异步模型默认 600
媒体统一三数组契约 image_files/audio_files/video_files字符串或数组
均可 resolution/duration timeout 等待秒数异步模型默认 600
前置供应商账号已配 api_key key 会报可行动错误不静默
"""
err = await _require_owner(sor, ctx)
@ -1548,7 +1659,10 @@ PLATFORM_TOOLS = [
parameters={
"model_name": "模型名llm_model.name 或 vendor_model_id",
"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",
},
category="platform",
@ -1586,7 +1700,8 @@ PLATFORM_PROMPT = """
三轮全编造 vendor{}/api_profile{} 等不存在的字段**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**定价+出处已锚定
7. `test_model_call` 真实调用测试前置账号已配 api_key没配则如实告知用户去补
不要伪造结果生成类模型传 paramsimage_file/resolution/duration
不要伪造结果生成类模型传 params媒体三数组 image_files/audio_files/
video_files + resolution/duration
8. `check_model_accounting` 检查记账传上一步的 task_ref
- accounted amount_match=true 全链通过汇报金额
- created 60~120 秒再查一次出账循环 60 秒一轮
@ -1610,8 +1725,12 @@ PLATFORM_PROMPT = """
- **定价只允许来自文档原文**doc_quote 强制禁止编造/换算/推导
- **出处必留**模型注册表描述字段与定价方案描述字段都必须保存文档 URL
- 能力分类不存在 先加分类字典/种子/提示词/端点注释四处同步再配模型禁止塞近似分类
- 生成类模型媒体铁律上行 b64media2url(request, xxx_file)下行 downloadfile2url(request, url)
apply 返回的 media_audit 警告必须如实转告
- 生成类模型媒体铁律上行统一三数组契约 image_files/audio_files/video_files
字符串或数组均可 b64media2url 转公网 URL下行产物用
downloadfile2url(request, url) 落地apply 返回的 media_audit 警告必须如实转告
- **模板渲染报错 'dict object' has no attribute 'xxx_file'是机制缺陷
不是要用户去页面手改模板**如实报告错误原文 + 你判断的根因由维护者修生成器
禁止提出请你在模型治理页面把模板改成这类人工兜底方案禁止把模板 JSON 抄给用户
- 非标协议骨架模板需人工核对的项__note__必须如实转告用户不假装完成
- 文档抓取失败/内容不足时如实说明不要凭记忆编造 API 格式
- 测试没有 api_key 就停在配置阶段如实汇报禁止跳过测试谎称完成