diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py index 215d5a0..fc08ffa 100644 --- a/pipeline_platform/platform_ability.py +++ b/pipeline_platform/platform_ability.py @@ -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. 归一为列表(字符串→单元素;缺省→空列表)。""" + 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. 取单值(字符串原样;数组取首个;缺省→空串)。""" + 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._file)|tojson }} + 媒体数组(r2v media 等变长数组)→ Jinja for 循环按 image_files/ + audio_files/video_files 动态组装(见 _media_array_expr) + 单值上传媒体 → {{ b64media2url(request, )|tojson }} + (params.xxx_files 字符串/数组两形态兼容) 其他标量 → {{ params.|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 就停在配置阶段如实汇报,禁止跳过测试谎称完成