feat: v0.19.2 prompt 里 @素材名 按火山规范转为「图片N/视频N/音频N」
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m58s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m58s
火山 Seedance 模型只能理解"素材类型+序号"的指代(官方文档 FAQ Q3);
对文件名 / asset id / URL 类字符串一律读不懂,只能按 content 数组里
图片出现顺序瞎猜谁是谁,导致用户看到的"人物颠倒"概率性现象(典型
任务 cgt-20260422163517-4k8x6)。
改动
- backend/apps/generation/views.py:
- 新增 _format_prompt_for_ark(prompt, label_placeholders) helper
用 str.replace 避 regex 元字符崩溃, 按 label 长度降序防子串吞噬
- video_generate_view references 循环同步维护 image_n/video_n/audio_n
三个独立计数器 + label_to_placeholder 映射
- 关键不变量: 任意时刻 counter == content_items 里该类型 *_url 已 push 数
group 老路径 counter 照推但不登记 label + WARNING, 避免编号错位
- 调 create_task 前构造 api_prompt 传给火山, DB.prompt 保留用户原文
(带 @xxx.jpg) 以便 reEdit 重建带缩略图标签
测试覆盖 14 项 (airlabs-test MySQL 全绿)
- 单元 9 项: 基础替换 / 多类型独立计数 / 重复 @ / 子串冲突 / 正则元字符 /
空 mapping / label 未 @ / 中文标点 / 空 label 跳过
- 集成 5 项: local 正常替换 / DB 原文保留 / group 老路径不换 + WARN /
混用 local+group counter 对齐 (关键回归) / 图片音频独立计数
兼容性
- reEdit: DB 保留原文, PromptInput.rebuildMentionSpans 按 @label 正则
仍能重建 span, 缩略图正常
- regenerate: 走同一 POST /api/v1/video/generate, 二次过转换
- Celery: 只 query 不重发, 不受影响
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f2dc8d4713
commit
13440f2709
28
CLAUDE.md
28
CLAUDE.md
@ -244,6 +244,34 @@ jimeng-clone/
|
||||
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
|
||||
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) |
|
||||
|
||||
## AI Skills Reference
|
||||
|
||||
本项目用 **skill**(可复用方法论)+ **memory**(个人纪律)+ **hook**(系统强制)三层指引 AI 开发。按编辑场景对应加载:
|
||||
|
||||
| 编辑场景 | 应用的 skill / memory / hook |
|
||||
|---|---|
|
||||
| `backend/utils/seedance_client.py`、任何 Seedance API 对接 | **Skill** `seedance-api-integration` — 参数构造、token 计费、异步轮询、错误码映射、asset:// 引用、白标、5 起真实踩坑 |
|
||||
| Django models 新增字段(`backend/apps/*/models.py`)或改 create() 调用 | **Memory** `feedback_mysql_default` / `feedback_mysql_explicit_fields` — MySQL 严格模式下 create() 必须显式传所有 CharField 值(已 1 起 2026-03-19 线上 500 事故) |
|
||||
| 任何 `git push` 操作 | **Hook** `~/.claude/hooks/pre-git-push.sh` 会拦截;用户明确授权后用 `ALLOW_PUSH=1 git push` 重试 |
|
||||
| 用户可见 label / 按钮 / 错误提示 | **Memory** `feedback_write_full_labels`(单位/类型/含义写全)+ `feedback_user_facing_docs_plain_language`(禁程序员术语)+ `feedback_seconds_unit`(只用秒,不换分钟/小时) |
|
||||
| 任何 user-facing 字符串含模型名 / 错误提示 / 帮助文案 | **Memory** `feedback_no_seedance_branding` — 白标合同禁出现 "Seedance",用 "AirDrama" / "AirDrama Fast" |
|
||||
| 内联编辑按钮(保存 / 取消) | **Memory** `feedback_inline_edit_style` — `whiteSpace: 'nowrap'` 必加,历史多次被挤成两行 |
|
||||
| 对接火山 / 豆包 / Seedance 等第三方 API 添加新字段或错误码 | **Memory** `feedback_follow_official_api` — 严格按官方文档,不瞎编字段 / 错误码(历史:曾脑补 `.PolicyViolation` 子类型) |
|
||||
| 生成页 / `@` mention / 素材库相关测试 | 用 `tudou`(团管)账号登录;`admin` 账号默认没 team,进不了生成页 |
|
||||
| 改完代码、宣称"完成"之前 | **Memory** `feedback_verify_before_deliver` + `feedback_verify_thoroughly` — 必须自己跑完整 user 路径验证关键改动,不让用户替我们背锅 |
|
||||
| 商业级代码要求(并发、失败、资源复用) | **Memory** `feedback_commercial_grade` — 不准说"可以接受 / 后续优化",要么做到位要么明确标风险 |
|
||||
| 改方案 / 改 UI 前发现截图或讨论 | **Memory** `feedback_no_rush_changes` + `feedback_simple_first` — 先提方案(最简单的先给)等用户说"改吧"再动手 |
|
||||
|
||||
**纪律分级:**
|
||||
- **Skill** — 跨项目可复用的大块方法论(`~/.claude/skills/`)
|
||||
- **Memory** — 协作纪律 / 个人偏好(自动加载到 session context,在 `~/.claude/projects/c--Airlabs-Project/memory/`)
|
||||
- **Hook** — 系统级硬拦截(`~/.claude/settings.json` + `~/.claude/hooks/`)
|
||||
- 项目特定架构约定直接写在本 CLAUDE.md 里(Project Architecture / API Endpoints / Database Models 等章节)
|
||||
|
||||
**增删映射项时:** 新增跨项目规则写 memory、本项目特定规则写 CLAUDE.md 对应章节、跨项目技术方法论才做 skill(参考 `feedback_no_skill_bloat`)。
|
||||
|
||||
---
|
||||
|
||||
## Incremental Development Guide
|
||||
|
||||
### How to Add Features to This Project
|
||||
|
||||
@ -69,6 +69,27 @@ def _sum_video_duration(references):
|
||||
return total
|
||||
|
||||
|
||||
def _format_prompt_for_ark(prompt, label_placeholders):
|
||||
"""把 prompt 中的 @label 替换为火山可识别的「图片N/视频N/音频N」。
|
||||
|
||||
火山 Seedance 模型只能理解"素材类型+序号"格式的指代(官方文档 FAQ Q3);
|
||||
文件名 / asset id / URL 对它都是不可理解的字符串,会按位置概率性对齐,
|
||||
表现为"人物颠倒"。此函数在发给火山之前做一次静默替换,用户 prompt 原文
|
||||
保留在 DB 便于 reEdit 回填带缩略图的标签。
|
||||
|
||||
label_placeholders: [(label, placeholder), ...] 调用方需保证按 label 长度
|
||||
降序,防止"碧"先于"碧碧"被替换的子串吞噬问题。
|
||||
|
||||
用 str.replace 而非 re.sub,避免 label 含正则元字符(如 "@[test].png")时崩溃。
|
||||
"""
|
||||
result = prompt
|
||||
for label, placeholder in label_placeholders:
|
||||
if not label:
|
||||
continue
|
||||
result = result.replace(f'@{label}', placeholder)
|
||||
return result
|
||||
|
||||
|
||||
def _get_token_price(config, model, has_video_ref, resolution):
|
||||
"""根据模型、是否含视频、分辨率选择单价。
|
||||
|
||||
@ -328,6 +349,20 @@ def video_generate_view(request):
|
||||
seen_urls = set() # 去重:同一个素材只引用一次
|
||||
_asset_cache = {} # group_id → [(asset_url, asset_type), ...],避免同一素材组重复查询
|
||||
|
||||
# 火山规范要求 prompt 里用「图片N/视频N/音频N」指代素材(不能用文件名/asset id)。
|
||||
# 循环同步维护各类型 counter 和 label→placeholder 映射,循环结束后一次性替换 prompt。
|
||||
# 不变量:任意时刻 image_n / video_n / audio_n == content_items 里该类型 *_url 已 push 的个数。
|
||||
label_to_placeholder: dict = {}
|
||||
image_n = video_n = audio_n = 0
|
||||
|
||||
def _placeholder_for(asset_type):
|
||||
"""读取当前 counter 值对应的 placeholder。调用前 counter 必须已递增。"""
|
||||
if asset_type == 'Video':
|
||||
return f'视频{video_n}'
|
||||
if asset_type == 'Audio':
|
||||
return f'音频{audio_n}'
|
||||
return f'图片{image_n}'
|
||||
|
||||
from .models import Asset as AssetModel
|
||||
|
||||
def _resolve_asset_group_all(gid, lbl):
|
||||
@ -408,11 +443,16 @@ def video_generate_view(request):
|
||||
aid = 'asset-' + aid[6:]
|
||||
resolved_asset_url = f'asset://{aid}'
|
||||
if asset_obj.asset_type == 'Video':
|
||||
video_n += 1
|
||||
content_items.append({'type': 'video_url', 'video_url': {'url': resolved_asset_url}, 'role': 'reference_video'})
|
||||
elif asset_obj.asset_type == 'Audio':
|
||||
audio_n += 1
|
||||
content_items.append({'type': 'audio_url', 'audio_url': {'url': resolved_asset_url}, 'role': 'reference_audio'})
|
||||
else:
|
||||
image_n += 1
|
||||
content_items.append({'type': 'image_url', 'image_url': {'url': resolved_asset_url}, 'role': 'reference_image'})
|
||||
if label and label not in label_to_placeholder:
|
||||
label_to_placeholder[label] = _placeholder_for(asset_obj.asset_type)
|
||||
except AssetModel.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'asset_not_found',
|
||||
@ -442,11 +482,17 @@ def video_generate_view(request):
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for asset_url, asset_type in asset_list:
|
||||
if asset_type == 'Video':
|
||||
video_n += 1
|
||||
content_items.append({'type': 'video_url', 'video_url': {'url': asset_url}, 'role': 'reference_video'})
|
||||
elif asset_type == 'Audio':
|
||||
audio_n += 1
|
||||
content_items.append({'type': 'audio_url', 'audio_url': {'url': asset_url}, 'role': 'reference_audio'})
|
||||
else:
|
||||
image_n += 1
|
||||
content_items.append({'type': 'image_url', 'image_url': {'url': asset_url}, 'role': 'reference_image'})
|
||||
# 老兼容路径:一个 label 对应 N 张图,展开成"图片N"会改变语义,不登记 label。
|
||||
# 但 counter 必须继续递增,否则后续 local 分支的编号会错位。
|
||||
logger.warning('legacy asset://group-%s used (label=%s), skip @-replacement (counter advanced by %d)', group_id, label, len(asset_list))
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
|
||||
return Response({
|
||||
@ -456,6 +502,7 @@ def video_generate_view(request):
|
||||
continue # 素材组已展开为多个 content_items,跳过下面的单项处理
|
||||
|
||||
if ref_type == 'image':
|
||||
image_n += 1
|
||||
item = {'type': 'image_url', 'image_url': {'url': url}}
|
||||
# API 文档要求:参考图模式下所有图片的 role 必须为 reference_image
|
||||
if mode == 'universal':
|
||||
@ -464,15 +511,25 @@ def video_generate_view(request):
|
||||
item['role'] = role
|
||||
content_items.append(item)
|
||||
elif ref_type == 'video':
|
||||
video_n += 1
|
||||
item = {'type': 'video_url', 'video_url': {'url': url}}
|
||||
if role:
|
||||
item['role'] = role
|
||||
content_items.append(item)
|
||||
elif ref_type == 'audio':
|
||||
audio_n += 1
|
||||
item = {'type': 'audio_url', 'audio_url': {'url': url}}
|
||||
if role:
|
||||
item['role'] = role
|
||||
content_items.append(item)
|
||||
else:
|
||||
# 防御性:未知 ref_type(脏数据或未来扩展)→ 不推 content_item, 不登记
|
||||
logger.warning('unknown ref_type=%s url=%s label=%s, skipped', ref_type, url, label)
|
||||
continue
|
||||
|
||||
if label and label not in label_to_placeholder:
|
||||
_type_map = {'image': 'Image', 'video': 'Video', 'audio': 'Audio'}
|
||||
label_to_placeholder[label] = _placeholder_for(_type_map[ref_type])
|
||||
|
||||
logger.info('Video generate: %d content_items built (prompt=%s...)', len(content_items), prompt[:60])
|
||||
|
||||
@ -501,9 +558,14 @@ def video_generate_view(request):
|
||||
# ── 调用 AirDrama API(事务外,避免持锁) ──
|
||||
from django.conf import settings as django_settings
|
||||
if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY:
|
||||
# 按火山规范把 @label 替换为「图片N/视频N/音频N」;DB 的 record.prompt 仍保留原文
|
||||
sorted_pairs = sorted(label_to_placeholder.items(), key=lambda kv: -len(kv[0]))
|
||||
api_prompt = _format_prompt_for_ark(prompt, sorted_pairs)
|
||||
logger.info('[ark-prompt] original=%s | converted=%s | mapping=%s',
|
||||
prompt, api_prompt, label_to_placeholder)
|
||||
try:
|
||||
ark_response = create_task(
|
||||
prompt=prompt,
|
||||
prompt=api_prompt,
|
||||
model=model,
|
||||
content_items=content_items,
|
||||
aspect_ratio=aspect_ratio,
|
||||
|
||||
239
backend/tests/test_ark_prompt_format.py
Normal file
239
backend/tests/test_ark_prompt_format.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""
|
||||
测试 prompt 转火山「图片N/视频N/音频N」格式 — v0.19.1+
|
||||
|
||||
火山模型无法理解文件名/asset id,必须用「素材类型+序号」指代(官方文档 FAQ Q3)。
|
||||
本测试文件覆盖:
|
||||
单元测试:纯函数 _format_prompt_for_ark
|
||||
集成测试:video_generate_view 端到端(含 counter 对齐关键回归)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from unittest import mock
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
django.setup()
|
||||
|
||||
import unittest
|
||||
from django.test import TestCase, override_settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from apps.accounts.models import Team
|
||||
from apps.generation.models import QuotaConfig, AssetGroup, Asset, GenerationRecord
|
||||
from apps.generation.views import _format_prompt_for_ark
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 单元测试:纯函数 _format_prompt_for_ark
|
||||
# ────────────────────────────────────────────────
|
||||
|
||||
class TestFormatPromptForArk(unittest.TestCase):
|
||||
"""覆盖各种 label 替换场景(字符串级别)。"""
|
||||
|
||||
def test_basic_replacement(self):
|
||||
"""@label 替换为 placeholder。"""
|
||||
out = _format_prompt_for_ark('@碧碧.jpg 是碧儿', [('碧碧.jpg', '图片1')])
|
||||
self.assertEqual(out, '图片1 是碧儿')
|
||||
|
||||
def test_multi_type_independent_counters(self):
|
||||
"""图片/视频/音频各自独立编号。"""
|
||||
pairs = [
|
||||
('img1.jpg', '图片1'),
|
||||
('video1.mp4', '视频1'),
|
||||
('audio1.mp3', '音频1'),
|
||||
]
|
||||
out = _format_prompt_for_ark('用 @img1.jpg @video1.mp4 和 @audio1.mp3', pairs)
|
||||
self.assertEqual(out, '用 图片1 视频1 和 音频1')
|
||||
|
||||
def test_same_label_multiple_at_signs(self):
|
||||
"""同一 label 在 prompt 里 @ 多次,全部替换成同一 placeholder(str.replace 全局)。"""
|
||||
out = _format_prompt_for_ark('@foo 然后 @foo 再 @foo', [('foo', '图片1')])
|
||||
self.assertEqual(out, '图片1 然后 图片1 再 图片1')
|
||||
|
||||
def test_substring_conflict_long_first(self):
|
||||
"""当存在子串关系('碧' 是 '碧碧' 的子串),长 label 必须先替换。"""
|
||||
# 模拟调用方已经按长度降序传入
|
||||
pairs = [('碧碧', '图片2'), ('碧', '图片1')]
|
||||
out = _format_prompt_for_ark('@碧碧 和 @碧 是姐妹', pairs)
|
||||
self.assertEqual(out, '图片2 和 图片1 是姐妹')
|
||||
|
||||
def test_label_with_regex_metachars(self):
|
||||
"""label 含正则元字符([ ] + . * ? 等),str.replace 不当正则处理。"""
|
||||
pairs = [
|
||||
('[test].png', '图片1'),
|
||||
('a+b.png', '图片2'),
|
||||
('a.b*.png', '图片3'),
|
||||
]
|
||||
prompt = '@[test].png 和 @a+b.png 还有 @a.b*.png'
|
||||
out = _format_prompt_for_ark(prompt, pairs)
|
||||
self.assertEqual(out, '图片1 和 图片2 还有 图片3')
|
||||
|
||||
def test_empty_mapping(self):
|
||||
"""无 @ 素材的 prompt 原样返回。"""
|
||||
out = _format_prompt_for_ark('今天天气真好', [])
|
||||
self.assertEqual(out, '今天天气真好')
|
||||
|
||||
def test_label_in_mapping_not_in_prompt(self):
|
||||
"""mapping 里有 label 但 prompt 里没 @ → 不动。"""
|
||||
out = _format_prompt_for_ark('一段普通文字', [('foo.jpg', '图片1')])
|
||||
self.assertEqual(out, '一段普通文字')
|
||||
|
||||
def test_chinese_punctuation_around_label(self):
|
||||
"""中文标点不影响替换。"""
|
||||
out = _format_prompt_for_ark('@碧碧.jpg,说:"你好。"', [('碧碧.jpg', '图片1')])
|
||||
self.assertEqual(out, '图片1,说:"你好。"')
|
||||
|
||||
def test_empty_label_skipped(self):
|
||||
"""label 为空字符串时跳过,不崩溃。"""
|
||||
out = _format_prompt_for_ark('@real.jpg 内容', [('', '图片0'), ('real.jpg', '图片1')])
|
||||
self.assertEqual(out, '图片1 内容')
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# 集成测试:video_generate_view
|
||||
# ────────────────────────────────────────────────
|
||||
|
||||
@override_settings(SEEDANCE_ENABLED=True, ARK_API_KEY='fake-test-key')
|
||||
class TestVideoGenerateArkPrompt(TestCase):
|
||||
"""经 POST /api/v1/video/generate 验证 prompt 转换 + DB 原文保留 + counter 对齐。"""
|
||||
|
||||
def setUp(self):
|
||||
QuotaConfig.objects.get_or_create(pk=1)
|
||||
self.team = Team.objects.create(
|
||||
name='test-ark-prompt',
|
||||
is_active=True,
|
||||
monthly_spending_limit=10000,
|
||||
markup_percentage=0,
|
||||
balance=10000,
|
||||
frozen_amount=0,
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username='ark_prompt_user',
|
||||
email='arkprompt@example.com',
|
||||
password='testpass123',
|
||||
team=self.team,
|
||||
spending_limit=-1,
|
||||
daily_generation_limit=-1,
|
||||
monthly_generation_limit=-1,
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# 建两个 local asset 方便多场景复用
|
||||
self.group_a = AssetGroup.objects.create(
|
||||
team=self.team, remote_group_id='group-fake-a', name='角色A',
|
||||
)
|
||||
self.asset_bibi = Asset.objects.create(
|
||||
group=self.group_a, remote_asset_id='asset-fake-bibi', name='碧碧.jpg',
|
||||
url='https://fake/bibi.jpg', asset_type='Image', status='active',
|
||||
)
|
||||
self.asset_bubu = Asset.objects.create(
|
||||
group=self.group_a, remote_asset_id='asset-fake-bubu', name='布布.jpg',
|
||||
url='https://fake/bubu.jpg', asset_type='Image', status='active',
|
||||
)
|
||||
|
||||
def _post_generate(self, prompt, references):
|
||||
return self.client.post('/api/v1/video/generate', {
|
||||
'prompt': prompt,
|
||||
'mode': 'universal',
|
||||
'model': 'seedance_2.0',
|
||||
'aspect_ratio': '9:16',
|
||||
'duration': 5,
|
||||
'resolution': '720p',
|
||||
'references': references,
|
||||
}, format='json')
|
||||
|
||||
@mock.patch('apps.generation.tasks.poll_video_task')
|
||||
@mock.patch('apps.generation.views.create_task')
|
||||
def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):
|
||||
"""prompt 里两个 @local 素材 → 发给火山的 prompt 变成「图片1/图片2」。"""
|
||||
mock_create_task.return_value = {'id': 'ark-mock-1'}
|
||||
prompt = '@碧碧.jpg 是碧儿,@布布.jpg 是步若'
|
||||
resp = self._post_generate(prompt, [
|
||||
{'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
|
||||
{'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'},
|
||||
])
|
||||
self.assertEqual(resp.status_code, 202, resp.content)
|
||||
self.assertTrue(mock_create_task.called, 'create_task must be called')
|
||||
sent_prompt = mock_create_task.call_args.kwargs['prompt']
|
||||
self.assertEqual(sent_prompt, '图片1 是碧儿,图片2 是步若')
|
||||
|
||||
@mock.patch('apps.generation.tasks.poll_video_task')
|
||||
@mock.patch('apps.generation.views.create_task')
|
||||
def test_view_db_prompt_unchanged_for_reedit(self, mock_create_task, mock_poll):
|
||||
"""DB.prompt 必须保留用户原文(含 @xxx.jpg),reEdit 才能重建带缩略图的标签。"""
|
||||
mock_create_task.return_value = {'id': 'ark-mock-2'}
|
||||
prompt = '@碧碧.jpg 走过来'
|
||||
resp = self._post_generate(prompt, [
|
||||
{'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
|
||||
])
|
||||
self.assertEqual(resp.status_code, 202, resp.content)
|
||||
rec = GenerationRecord.objects.filter(user=self.user).order_by('-id').first()
|
||||
self.assertIsNotNone(rec)
|
||||
self.assertEqual(rec.prompt, prompt) # 原文,不含 '图片1'
|
||||
self.assertIn('@碧碧.jpg', rec.prompt)
|
||||
self.assertNotIn('图片1', rec.prompt)
|
||||
|
||||
@mock.patch('apps.generation.tasks.poll_video_task')
|
||||
@mock.patch('apps.generation.views.create_task')
|
||||
def test_legacy_group_url_skips_replacement(self, mock_create_task, mock_poll):
|
||||
"""asset://group-{id} 老路径:counter 递增但不登记 label,WARNING 日志,@组名 原样留在 prompt。"""
|
||||
mock_create_task.return_value = {'id': 'ark-mock-3'}
|
||||
prompt = '@角色A 做动作'
|
||||
with self.assertLogs('apps.generation.views', level='WARNING') as cm:
|
||||
resp = self._post_generate(prompt, [
|
||||
{'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'},
|
||||
])
|
||||
self.assertEqual(resp.status_code, 202, resp.content)
|
||||
sent_prompt = mock_create_task.call_args.kwargs['prompt']
|
||||
self.assertEqual(sent_prompt, prompt) # 未替换
|
||||
# 验证 WARNING log
|
||||
self.assertTrue(any('legacy asset://group-' in line for line in cm.output),
|
||||
f'expected legacy warning, got: {cm.output}')
|
||||
|
||||
@mock.patch('apps.generation.tasks.poll_video_task')
|
||||
@mock.patch('apps.generation.views.create_task')
|
||||
def test_counter_alignment_with_mixed_local_and_group(self, mock_create_task, mock_poll):
|
||||
"""关键回归:group 展开 2 张图后,紧跟的 local asset 的 label 必须映射到「图片3」不是「图片1」。"""
|
||||
mock_create_task.return_value = {'id': 'ark-mock-4'}
|
||||
prompt = '@foo 是主角'
|
||||
resp = self._post_generate(prompt, [
|
||||
{'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'}, # 展开 2 张图
|
||||
{'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': 'foo'},
|
||||
])
|
||||
self.assertEqual(resp.status_code, 202, resp.content)
|
||||
sent_prompt = mock_create_task.call_args.kwargs['prompt']
|
||||
# foo 对应第 3 张 image(group 两张在前)
|
||||
self.assertEqual(sent_prompt, '图片3 是主角')
|
||||
# content_items 验证长度
|
||||
sent_content_items = mock_create_task.call_args.kwargs['content_items']
|
||||
image_items = [it for it in sent_content_items if it['type'] == 'image_url']
|
||||
self.assertEqual(len(image_items), 3)
|
||||
|
||||
@mock.patch('apps.generation.tasks.poll_video_task')
|
||||
@mock.patch('apps.generation.views.create_task')
|
||||
def test_counter_alignment_mixed_types(self, mock_create_task, mock_poll):
|
||||
"""图片/音频独立计数 — 图片序号不因音频夹在中间而跳变。"""
|
||||
mock_create_task.return_value = {'id': 'ark-mock-5'}
|
||||
# 新建一个 audio asset
|
||||
asset_audio = Asset.objects.create(
|
||||
group=self.group_a, remote_asset_id='asset-fake-audio', name='speech.mp3',
|
||||
url='https://fake/speech.mp3', asset_type='Audio', status='active',
|
||||
)
|
||||
prompt = '@碧碧.jpg 说 @speech.mp3 的话,@布布.jpg 听'
|
||||
resp = self._post_generate(prompt, [
|
||||
{'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
|
||||
{'url': f'asset://local-{asset_audio.id}', 'type': 'audio', 'label': 'speech.mp3'},
|
||||
{'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'},
|
||||
])
|
||||
self.assertEqual(resp.status_code, 202, resp.content)
|
||||
sent_prompt = mock_create_task.call_args.kwargs['prompt']
|
||||
# 碧碧=图片1, speech=音频1, 布布=图片2(图片/音频独立计数)
|
||||
self.assertEqual(sent_prompt, '图片1 说 音频1 的话,图片2 听')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
x
Reference in New Issue
Block a user