feat(platform): 媒体契约升级为三数组image_files/audio_files/video_files(对齐sage/llmage)——变长媒体数组按Jinja for动态组装,禁按示例个数展开固定槽位(r2v渲染崩根因);干跑双形态校验;提示词+工具schema同步;新增硬规则禁让用户手改模板
This commit is contained in:
parent
523f3307d7
commit
4573aeba3f
@ -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/duration);timeout 等待秒数(异步模型默认 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,没配则如实告知用户去补,
|
||||
不要伪造结果)——生成类模型传 params(image_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 就停在配置阶段如实汇报,禁止跳过测试谎称完成
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user