Compare commits

...

1 Commits

Author SHA1 Message Date
CI Bot 14b38d7687 fix(viral-video): 修复用户参数未生效 6 项问题 #2249
Bug1(P0): worker 用 target_customer 替代 target_audience,目标客户正确注入 prompt
Bug2(P0): 调用 _persona_style_hint()、扩充 _PERSONA_STYLE_GUIDE 覆盖老板型IP等、<persona> 进 prompt
Bug3(P0): 新增 _viral_structure_hint() 映射结构名到写作指导、<viral_structure> 进 prompt
Bug4(P1): <industry> 标签进 prompt,render 时传入 industry
Bug5(P1): language 字段全链路:Domain/Schema/DB Model/Repository/Migration/Prompt/TTS
Bug6(P1): ConfirmCopyRequest 补 voice_id/voice_source/language,confirm_copy 路由持久化

Prompt 改动:_STORYBOARD_USER 加 industry/persona/viral_structure/language 标签
_SYSTEM 追加规则:必须按用户参数写文案和分镜
alembic migration 107: ADD COLUMN language VARCHAR(20) DEFAULT zh-CN
2026-10-09 18:52:48 +08:00
9 changed files with 325 additions and 6 deletions
@@ -0,0 +1,26 @@
"""add language column to viral_video_jobs
Revision ID: 107
Revises: 106
Create Date: 2026-10-09
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "107"
down_revision = "106"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"ALTER TABLE viral_video_jobs "
"ADD COLUMN IF NOT EXISTS language VARCHAR(20) NOT NULL DEFAULT 'zh-CN'"
)
def downgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS language")
+12 -1
View File
@@ -142,6 +142,7 @@ def _to_response(job) -> ViralVideoJobResponse:
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
language=getattr(job, "language", "zh-CN") or "zh-CN",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
@@ -243,6 +244,7 @@ def analyze_images(
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
language=getattr(request, "language", "zh-CN") or "zh-CN",
video_ratio=request.video_ratio or "9:16",
video_model=request.video_model or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
@@ -314,6 +316,7 @@ def generate_copy(
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.language = getattr(request, "language", "") or job.language or "zh-CN"
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.video_model = request.video_model or job.video_model or ""
job.video_resolution = getattr(request, "video_resolution", "") or job.video_resolution or "720p"
@@ -366,6 +369,12 @@ def confirm_copy(
job.video_ratio = request.video_ratio
if request.video_model is not None:
job.video_model = request.video_model
if request.voice_id is not None:
job.voice_id = request.voice_id
if request.voice_source is not None:
job.voice_source = request.voice_source
if getattr(request, "language", None) is not None:
job.language = request.language
param_changed = (
(request.duration is not None and int(request.duration) != old_duration)
@@ -422,7 +431,9 @@ def confirm_copy(
)
job.credits_prepaid = new_est
job.credits_transaction_id = res.get("transaction_id", "") or ""
logger.info("[爆款视频][confirm-copy] 参数变更,积分重算: old=%d new=%d job_id=%s", old_est, new_est, job.id)
logger.info(
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%d new=%d job_id=%s", old_est, new_est, job.id
)
elif not already_paid:
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
+7
View File
@@ -87,6 +87,7 @@ class CreateViralVideoRequest(BaseModel):
style_template_id: str = ""
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@@ -121,6 +122,7 @@ class AnalyzeImagesRequest(BaseModel):
video_model: str = ""
video_resolution: str = "720p"
duration: int = Field(default=15, ge=5, le=30)
language: str = "zh-CN"
class GenerateCopyRequest(BaseModel):
@@ -142,6 +144,7 @@ class GenerateCopyRequest(BaseModel):
style_guide: dict | None = None
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@@ -171,6 +174,9 @@ class ConfirmCopyRequest(BaseModel):
video_resolution: str | None = Field(default=None, description="用户选定的分辨率(confirm时可选)")
video_ratio: str | None = Field(default=None, description="用户选定的比例(confirm时可选)")
duration: int | None = Field(default=None, ge=5, le=30, description="用户选定的时长秒数(confirm时可选,5~30)")
voice_id: str | None = Field(default=None, description="用户选定的音色ID(confirm时可选)")
voice_source: str | None = Field(default=None, description="用户选定的音色来源(confirm时可选)")
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
class ConfirmIntentRequest(BaseModel):
@@ -222,6 +228,7 @@ class ViralVideoJobResponse(BaseModel):
# 音色/视频参数
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
+67 -4
View File
@@ -484,6 +484,14 @@ _PERSONA_STYLE_GUIDE: dict[str, str] = {
"店主": "热情实在,像当面招呼客人,突出靠谱和实在优惠",
"专业顾问": "专业可信,讲清原理和效果,用事实打消顾虑",
"年轻达人": "活泼有网感,节奏轻快,金句和梗自然不尬",
"老板型IP": "以老板第一人称出镜,真诚接地气,像招呼街坊邻居一样分享,突出创业初心和靠谱",
"知识博主": "条理清晰、数据说话,干货密度高,语气专业但不枯燥",
"生活美学": "画面感强,注重氛围和质感描述,语速偏慢,文字有诗意",
"健身教练": "energetic、鼓励式口吻,强调动作要领和效果变化",
"美妆达人": "细腻讲质地和妆效,像闺蜜安利,语气亲切有感染力",
"美食博主": "色香味描述丰富,口语化带馋感,节奏轻快",
"穿搭博主": "讲搭配逻辑和场景适配,时尚但不高冷,像朋友建议",
"育儿师": "科学育儿角度,温柔坚定,给具体可操作的建议",
}
@@ -498,6 +506,47 @@ def _persona_style_hint(persona_id: str) -> str:
return "【人设风格:未指定】亲切自然、像朋友分享好物"
_VIRAL_STRUCTURE_GUIDE: dict[str, str] = {
"反差破局+亮明观点+还原现状": "开头3秒用反差/痛点钩子抓注意力,中段亮出核心卖点或观点,结尾还原真实到店/使用场景引导行动",
"痛点切入+方案展示+效果对比": "开头直击用户痛点场景,中间展示产品/服务解决方案,结尾用前后对比强化效果",
"故事引入+产品种草+行动引导": "用一个真实小故事/案例引入,自然过渡到产品种草,结尾明确引导用户下一步行动",
"场景展示+价值输出+信任背书": "开头展示使用场景让用户代入,中间输出核心价值主张,结尾用客户评价/数据等信任背书收尾",
"悬念开场+层层递进+高潮转化": "开头制造悬念引发好奇,内容层层推进保持张力,高潮处给出转化钩子",
}
def _viral_structure_hint(structure: str) -> str:
"""根据 viral_structure 映射具体写作指导;未命中返回通用提示。"""
s = (structure or "").strip()
if s in _VIRAL_STRUCTURE_GUIDE:
return f"【爆款结构:{s}】{_VIRAL_STRUCTURE_GUIDE[s]}"
if s:
return f"【爆款结构:{s}】按该结构编排内容节奏和叙事逻辑"
return "【爆款结构:未指定】自由组织,保证开头有钩子、中段有卖点、结尾有行动引导"
def _language_hint(language: str) -> str:
"""根据 language 代码返回语言提示。"""
lang = (language or "zh-CN").strip().lower()
mapping = {
"zh-cn": "使用标准普通话,口语化表达",
"zh-tw": "使用台湾腔中文,语气温柔亲切",
"zh-hk": "使用粤语风格中文表达",
"en-us": "使用美式英语,自然口语化",
"en-gb": "使用英式英语",
"ja-jp": "使用日语,自然口语化",
"ko-kr": "使用韩语,亲切自然",
}
hint = mapping.get(lang, "")
if hint:
return f"【语言:{lang}】{hint}"
if lang.startswith("zh"):
return f"【语言:{lang}】使用中文,口语化表达,可带方言特色"
if lang.startswith("en"):
return f"【语言:{lang}】使用英语,自然口语化"
return f"【语言:{lang}】按该语言习惯组织口播内容"
def _determine_theme(image_analysis: dict | None, marketing_purpose: str = "") -> str:
"""根据图片类型分布和营销目的推断默认主题。"""
images = _images_of(image_analysis)
@@ -774,15 +823,24 @@ def _step_script_generation(job: ViralVideoJob, image_analysis: dict) -> dict:
+ "</reference_video_style>"
)
persona_hint = _persona_style_hint(getattr(job, "persona_id", ""))
viral_structure_hint = _viral_structure_hint(getattr(job, "viral_structure", ""))
language_hint = _language_hint(getattr(job, "language", "zh-CN"))
industry = getattr(job, "industry", "") or "通用"
user = render_user_prompt(
template,
marketing_purpose=marketing_purpose,
industry=industry,
image_summary=images_summary,
theme_hint=theme_hint,
dur=str(dur),
duration=str(dur),
aspect_ratio=getattr(job, "video_ratio", None) or "9:16",
tone=getattr(job, "tone", "") or "亲切自然",
target_audience=getattr(job, "target_audience", "") or "未指定",
target_audience=getattr(job, "target_customer", "") or "未指定",
persona_hint=persona_hint,
viral_structure_hint=viral_structure_hint,
language_hint=language_hint,
extra_requirements=job.user_copy_text or "(未提供额外要求,由 AI 创作)",
video_style_section=style_section,
)
@@ -1078,17 +1136,22 @@ def _step_tts(job: ViralVideoJob, voiceover_script: str):
if not text:
logger.warning("[爆款视频] voiceover_script 为空,跳过 TTS")
return None
language = getattr(job, "language", "zh-CN") or "zh-CN"
try:
result = tts_service.synthesize(
text=text,
voice_id=voice_id,
format="mp3",
language=language,
)
except TypeError:
try:
result = tts_service.synthesize(text=text, voice_id=voice_id)
result = tts_service.synthesize(text=text, voice_id=voice_id, language=language)
except TypeError:
result = tts_service.synthesize(text=text)
try:
result = tts_service.synthesize(text=text, voice_id=voice_id)
except TypeError:
result = tts_service.synthesize(text=text)
if result is None:
return None
p = _Path(result) if not isinstance(result, _Path) else result
@@ -961,6 +961,7 @@ class ViralVideoJobModel(Base):
# v1.5 音频/视频参数
voice_id = Column(String(200), nullable=False, default="")
voice_source = Column(String(20), nullable=False, default="")
language = Column(String(20), nullable=False, default="zh-CN")
video_ratio = Column(String(10), nullable=False, default="9:16")
video_model = Column(String(100), nullable=False, default="")
# 结果与状态
@@ -49,6 +49,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
style_template_id=getattr(model, "style_template_id", "") or "",
voice_id=getattr(model, "voice_id", "") or "",
voice_source=getattr(model, "voice_source", "") or "",
language=getattr(model, "language", "zh-CN") or "zh-CN",
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
video_model=getattr(model, "video_model", "") or "",
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
@@ -102,6 +103,7 @@ class SQLAlchemyViralVideoJobRepository:
style_template_id=job.style_template_id,
voice_id=job.voice_id,
voice_source=job.voice_source,
language=getattr(job, "language", "zh-CN") or "zh-CN",
video_ratio=job.video_ratio,
video_model=job.video_model,
status=job.status,
@@ -169,6 +171,7 @@ class SQLAlchemyViralVideoJobRepository:
model.style_template_id = job.style_template_id
model.voice_id = job.voice_id or ""
model.voice_source = job.voice_source or ""
model.language = getattr(job, "language", "zh-CN") or "zh-CN"
model.video_ratio = job.video_ratio or "9:16"
model.video_model = job.video_model or ""
model.updated_at = datetime.now(timezone.utc)
+6 -1
View File
@@ -146,10 +146,12 @@ _STORYBOARD_SYSTEM = (
- 最后一个 clip 必须结束于 total_duration 秒
- 相邻 clip 首尾相接,不能有间隙也不能重叠
- 每个 clip 的时长 = Y - X,必须 >= 2 秒
10. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1"""
10. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1
11. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜,不能忽略任何一项用户参数"""
)
_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
<industry>{industry}</industry>
<image_analysis>
{image_summary}
</image_analysis>
@@ -159,6 +161,9 @@ _STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
<aspect_ratio>{aspect_ratio}</aspect_ratio>
<tone>{tone}</tone>
<target_audience>{target_audience}</target_audience>
<persona>{persona_hint}</persona>
<viral_structure>{viral_structure_hint}</viral_structure>
<language>{language_hint}</language>
<extra_requirements>{extra_requirements}</extra_requirements>
</user_parameters>
{video_style_section}
+1
View File
@@ -107,6 +107,7 @@ class ViralVideoJob:
# v1.5.1 音频/视频参数
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
# v1.4+ 产物
+202
View File
@@ -907,3 +907,205 @@ class TestWanNativeAudioSkipTTS:
assert "第3个镜头[10-15秒]" in prompt
assert "配音" in prompt
assert "推荐大家来" in prompt
# ═══════════════════════════════════════════════════════════════
# Issue #2249: 用户参数未生效修复验证
# ═══════════════════════════════════════════════════════════════
class TestIssue2249UserParamsFix:
"""Issue #2249: 爆款视频用户参数未生效 6 项修复验证"""
# ── Bug1: target_customer 字段名修正 ──
def test_bug1_target_customer_field_used_in_prompt(self):
"""Bug1: worker 应使用 job.target_customer 而非 job.target_audience"""
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.target_customer = "25-35岁女性"
job.persona_id = ""
job.viral_structure = ""
job.industry = "美容"
job.language = "zh-CN"
job.marketing_purpose = "引流到店"
job.user_copy_text = ""
job.video_ratio = "9:16"
job.tone = "亲切自然"
job.style_guide = None
job.duration = 15
# Verify the code uses target_customer
source = open(f"{BASE}/apps/worker/worker_app/tasks/viral_video.py").read()
assert 'getattr(job, "target_customer"' in source
assert 'getattr(job, "target_audience"' not in source
# ── Bug2: persona_id 进入 prompt ──
def test_bug2_persona_style_guide_covers_boss_ip(self):
"""Bug2: _PERSONA_STYLE_GUIDE 必须覆盖'老板型IP'"""
from apps.worker.worker_app.tasks.viral_video import _PERSONA_STYLE_GUIDE
assert "老板型IP" in _PERSONA_STYLE_GUIDE
assert "老板" in _PERSONA_STYLE_GUIDE["老板型IP"] or "第一人称" in _PERSONA_STYLE_GUIDE["老板型IP"]
def test_bug2_persona_style_hint_called_and_returns_hint(self):
"""Bug2: _persona_style_hint 返回有意义的提示"""
from apps.worker.worker_app.tasks.viral_video import _persona_style_hint
hint = _persona_style_hint("老板型IP")
assert "老板型IP" in hint
assert "人设风格" in hint
def test_bug2_persona_style_hint_unknown_value(self):
"""Bug2: 未知人设值返回通用提示而非空"""
from apps.worker.worker_app.tasks.viral_video import _persona_style_hint
hint = _persona_style_hint("自由职业者")
assert "自由职业者" in hint # 自由值照直提示
def test_bug2_persona_style_hint_empty(self):
"""Bug2: 空人设返回默认提示"""
from apps.worker.worker_app.tasks.viral_video import _persona_style_hint
hint = _persona_style_hint("")
assert "未指定" in hint
def test_bug2_persona_in_storyboard_template(self):
"""Bug2: _STORYBOARD_USER 模板包含 <persona> 标签"""
from packages.application.viral_video.prompts import _STORYBOARD_USER
assert "<persona>" in _STORYBOARD_USER
assert "{persona_hint}" in _STORYBOARD_USER
# ── Bug3: viral_structure 进入 prompt ──
def test_bug3_viral_structure_hint_known(self):
"""Bug3: 已知结构返回具体写作指导"""
from apps.worker.worker_app.tasks.viral_video import _viral_structure_hint
hint = _viral_structure_hint("反差破局+亮明观点+还原现状")
assert "反差破局" in hint
assert "开头" in hint or "钩子" in hint
def test_bug3_viral_structure_hint_unknown(self):
"""Bug3: 未知结构返回通用提示"""
from apps.worker.worker_app.tasks.viral_video import _viral_structure_hint
hint = _viral_structure_hint("自定义结构")
assert "自定义结构" in hint
def test_bug3_viral_structure_hint_empty(self):
"""Bug3: 空结构返回默认提示"""
from apps.worker.worker_app.tasks.viral_video import _viral_structure_hint
hint = _viral_structure_hint("")
assert "未指定" in hint
def test_bug3_viral_structure_in_template(self):
"""Bug3: _STORYBOARD_USER 模板包含 <viral_structure> 标签"""
from packages.application.viral_video.prompts import _STORYBOARD_USER
assert "<viral_structure>" in _STORYBOARD_USER
assert "{viral_structure_hint}" in _STORYBOARD_USER
# ── Bug4: industry 进入 prompt ──
def test_bug4_industry_in_template(self):
"""Bug4: _STORYBOARD_USER 模板包含 <industry> 标签"""
from packages.application.viral_video.prompts import _STORYBOARD_USER
assert "<industry>" in _STORYBOARD_USER
assert "{industry}" in _STORYBOARD_USER
# ── Bug5: language 全链路 ──
def test_bug5_language_in_domain_model(self):
"""Bug5: ViralVideoJob 领域模型有 language 字段"""
from packages.domain.viral_video import ViralVideoJob
job = ViralVideoJob(user_id="test")
assert hasattr(job, "language")
assert job.language == "zh-CN"
def test_bug5_language_in_create_request(self):
"""Bug5: CreateViralVideoRequest 接受 language"""
from apps.api.app.schemas.viral_video import CreateViralVideoRequest
req = CreateViralVideoRequest(images=["http://a.jpg"], language="en-US")
assert req.language == "en-US"
def test_bug5_language_in_generate_copy_request(self):
"""Bug5: GenerateCopyRequest 接受 language"""
from apps.api.app.schemas.viral_video import GenerateCopyRequest
req = GenerateCopyRequest(language="zh-TW")
assert req.language == "zh-TW"
def test_bug5_language_in_confirm_copy_request(self):
"""Bug5+6: ConfirmCopyRequest 接受 language"""
from apps.api.app.schemas.viral_video import ConfirmCopyRequest
req = ConfirmCopyRequest(language="en-US")
assert req.language == "en-US"
def test_bug5_language_hint_function(self):
"""Bug5: _language_hint 返回正确提示"""
from apps.worker.worker_app.tasks.viral_video import _language_hint
assert "普通话" in _language_hint("zh-CN")
assert (
"英语" in _language_hint("en-US")
or "English" in _language_hint("en-US").lower()
or "英语" in _language_hint("en-us")
)
def test_bug5_language_in_template(self):
"""Bug5: _STORYBOARD_USER 模板包含 <language> 标签"""
from packages.application.viral_video.prompts import _STORYBOARD_USER
assert "<language>" in _STORYBOARD_USER
assert "{language_hint}" in _STORYBOARD_USER
def test_bug5_language_in_db_model(self):
"""Bug5: DB model 有 language 列"""
from packages.adapters.sqlalchemy_impl.models import ViralVideoJobModel
assert hasattr(ViralVideoJobModel, "language")
def test_bug5_migration_exists(self):
"""Bug5: alembic migration 107 存在"""
from pathlib import Path
migration = Path(f"{BASE}/alembic/versions/107_add_language_to_viral_video_jobs.py")
assert migration.exists()
# ── Bug6: voice 选择持久化 ──
def test_bug6_confirm_copy_request_has_voice_fields(self):
"""Bug6: ConfirmCopyRequest 有 voice_id 和 voice_source 字段"""
from apps.api.app.schemas.viral_video import ConfirmCopyRequest
req = ConfirmCopyRequest(voice_id="longxiaochun_v3", voice_source="preset")
assert req.voice_id == "longxiaochun_v3"
assert req.voice_source == "preset"
def test_bug6_confirm_copy_request_voice_defaults_none(self):
"""Bug6: ConfirmCopyRequest voice 字段默认 None(可选)"""
from apps.api.app.schemas.viral_video import ConfirmCopyRequest
req = ConfirmCopyRequest()
assert req.voice_id is None
assert req.voice_source is None
# ── System prompt 规则 ──
def test_system_prompt_has_param_enforcement_rule(self):
"""系统 prompt 包含参数必须遵守的规则"""
from packages.application.viral_video.prompts import _STORYBOARD_SYSTEM
assert "marketing_purpose" in _STORYBOARD_SYSTEM or "persona" in _STORYBOARD_SYSTEM
assert "不能忽略" in _STORYBOARD_SYSTEM or "必须" in _STORYBOARD_SYSTEM