From 2a739dee178d4fa344633c7219ce8621551a0ae4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 2 Oct 2026 11:01:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(viral-video):=20#2134=20generate-copy?= =?UTF-8?q?=20=E6=8F=90=E9=80=9F=20+=20=E7=BB=86=E7=B2=92=E5=BA=A6=20phase?= =?UTF-8?q?/phase=5Fmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题7(generate-copy 提速,目标 30-40s): - 新增 doubao_fast_model 配置(默认 doubao-1-5-pro-32k-250115),结构化输出任务(意图解析/编导脚本/合规审核)改用快模型,不再使用慢推理模型 doubao-seed-1-6 - call_llm 扩展支持 model/max_tokens/system_prompt 参数;chat_completion 同步支持 model 覆盖 - 编导脚本 temperature 0.8 + max_tokens 2500(从 4096 收紧);意图解析 max_tokens 800;审核 max_tokens 500 - _SCRIPT_GENERATION_PROMPT 精简冗余描述(前导说明和关键要求章节从 ~70 行压到 ~30 行),减少输入/输出 token - 合规审核异步后置:阶段2 generate-copy 只做关键字黑名单快速检查(不调用 LLM),LLM 深度审核移到阶段3 confirm-copy TTS 之前执行,不再阻塞前端展示脚本 - 新增 _quick_compliance_blacklist_check 处理常见广告法绝对化用语 问题8(细粒度 phase + phase_message): - ViralVideoJob 新增 phase_message 字段(中文提示文案,前端轮询直接展示) - SQLAlchemy ViralVideoJobModel 新增 current_stage/phase_message 列(current_stage 原已有但未持久化更新) - repo 层 _to_domain/save/update 同步处理新字段 - alembic 090 迁移:幂等 ADD COLUMN phase_message VARCHAR(500) - 新增 _set_stage 辅助:统一设置 current_stage + phase_message + Redis 推送 + DB 持久化 - 所有 celery task(analyze/generate-copy/render/one-click/resume)在关键节点调用 _set_stage 持久化阶段信息 - 阶段文案:analyzing_images→正在分析商品特征 / parsing_intent→正在解析文案意图 / generating_script→正在编排分镜脚本 / reviewing→合规审核中 / tts→正在合成AI配音 / rendering→正在生成视频 / uploading→正在上传视频 - ViralVideoJobResponse schema + _to_response 增加 current_stage/phase_message,前端轮询 GET /{job_id} 直接拿到 配套: - .env.example + render_env.sh SHARED_SECRETS 补 DOUBAO_FAST_MODEL - tests _FakeSettings/_make_job 同步新增字段 - _run_render_pipeline 兜底补生成分支不再同步调用 _step_review(由出片前统一审核处理) --- .env.example | 1 + alembic/versions/090_viral_video_phase_msg.py | 35 +++ apps/api/app/api/routes/viral_video.py | 2 + apps/api/app/schemas/viral_video.py | 2 + apps/worker/worker_app/tasks/viral_video.py | 203 +++++++++++++----- packages/adapters/sqlalchemy_impl/models.py | 2 + .../sqlalchemy_impl/viral_video_repository.py | 6 + packages/config/base.py | 3 +- packages/domain/viral_video.py | 2 + packages/shared/ai_client.py | 4 +- packages/shared/ai_service.py | 24 ++- scripts/render_env.sh | 2 +- tests/unit/test_2035_coverage.py | 1 + tests/unit/test_viral_video_routes.py | 2 + 14 files changed, 226 insertions(+), 63 deletions(-) create mode 100644 alembic/versions/090_viral_video_phase_msg.py diff --git a/.env.example b/.env.example index 44c057b69..d7a0cfb67 100755 --- a/.env.example +++ b/.env.example @@ -212,6 +212,7 @@ COSYVOICE_CLONE_MODEL=voice-enrollment DOUBAO_API_KEY=your-doubao-api-key DOUBAO_MODEL=doubao-seed-1-6-250615 +DOUBAO_FAST_MODEL=doubao-1-5-pro-32k-250115 DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 DOUBAO_TIMEOUT=30 DOUBAO_MAX_RETRIES=2 diff --git a/alembic/versions/090_viral_video_phase_msg.py b/alembic/versions/090_viral_video_phase_msg.py new file mode 100644 index 000000000..f85351888 --- /dev/null +++ b/alembic/versions/090_viral_video_phase_msg.py @@ -0,0 +1,35 @@ +"""viral video add phase_message column (#2134) + +Revision ID: 090_viral_video_phase_msg +Revises: 089_viral_video_cols +Create Date: 2026-10-02 + +#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。 +current_stage 列已在之前版本存在,本迁移只补 phase_message。 +幂等 ADD COLUMN IF NOT EXISTS。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "090_viral_video_phase_msg" +down_revision = "089_viral_video_cols" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # SQLite/PostgreSQL 兼容的幂等添加列 + conn = op.get_bind() + inspector = sa.inspect(conn) + cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")} + if "phase_message" not in cols: + op.add_column( + "viral_video_jobs", + sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_column("viral_video_jobs", "phase_message") diff --git a/apps/api/app/api/routes/viral_video.py b/apps/api/app/api/routes/viral_video.py index 0190ee050..d3562ff42 100644 --- a/apps/api/app/api/routes/viral_video.py +++ b/apps/api/app/api/routes/viral_video.py @@ -128,6 +128,8 @@ def _to_response(job) -> ViralVideoJobResponse: style_guide=job.style_guide, style_template_id=job.style_template_id, status=job.status, + current_stage=getattr(job, "current_stage", "") or "", + phase_message=getattr(job, "phase_message", "") or "", image_analysis=getattr(job, "image_analysis", None), storyboard=getattr(job, "storyboard", None), generated_copy_text=getattr(job, "generated_copy_text", "") or "", diff --git a/apps/api/app/schemas/viral_video.py b/apps/api/app/schemas/viral_video.py index f14768788..792c657ee 100755 --- a/apps/api/app/schemas/viral_video.py +++ b/apps/api/app/schemas/viral_video.py @@ -201,6 +201,8 @@ class ViralVideoJobResponse(BaseModel): style_guide: dict | None = None style_template_id: str = "" status: str + current_stage: str = "" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading) + phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示) image_analysis: dict | None = None # v1.6 编导脚本(推荐前端使用) copy_result: dict | None = None diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index a0cde9da6..51e2c3f48 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -90,6 +90,22 @@ def _save_job(repo, job, session): session.commit() +def _set_stage(job, repo, session, stage: str, message: str, persist: bool = True) -> None: + """更新细粒度阶段并持久化到 DB,同时通过 Redis 推送进度事件。 + + stage 用 ViralVideoStage.value(snake_case,与前端 phase 对齐)。 + message 为中文提示文案,前端轮询/SSE 直接展示给用户。 + """ + job.current_stage = stage or "" + job.phase_message = message or "" + _emit_progress(job.id, stage, 0.0, message) + if persist and repo is not None and session is not None: + try: + _save_job(repo, job, session) + except Exception as e: # 阶段持久化失败不阻塞主流程 + logger.warning("[爆款视频] 阶段持久化失败 stage=%s err=%s", stage, e) + + # ── 默认结构 ───────────────────────────────────────────────────────────── _DEFAULT_HARD_CONSTRAINTS = [ @@ -356,8 +372,11 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict: "suggested_title": "视频主题标题(5-15字)" }}""" + _s = get_shared_settings() + _fast = _s.doubao_fast_model try: - result = call_llm(prompt) + # 用快模型提速(结构化输出任务,不需要推理模型) + result = call_llm(prompt, temperature=0.4, max_tokens=800, model=_fast) return ( result if isinstance(result, dict) @@ -371,27 +390,20 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict: # ── 编导分镜脚本生成(核心,v1.6 新 prompt) ────────────────────────────── -_SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI 视频生成模型(Seedance 2.5)撰写专业编导分镜脚本。 +_SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(单次生成最多{duration}秒)写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。 -## 产品信息 +## 产品 {products_summary} ## 营销参数 -- 视频主题/意图:{intent} +- 主题/意图:{intent} - 关键信息:{key_messages} - 调性:{tone} - 目标客户:{target_customer} -- 用户原始文案/卖点(必须融入口播):{user_copy} -- 视频时长:{duration} 秒(单次生成) -- 画幅比例:{ratio} -- 产品图片数量:{n_images} 张(将作为 reference_images 传给视频模型,第1张通常作为首帧/主产品图) -- 参考风格(可选):{style_hint} -- 爆款结构(用户指定,必须严格遵循):{viral_structure_block} - -## 任务 -请撰写**一段完整的编导分镜脚本**,包含视频总览、场景光线、逐镜头时间轴、硬性约束、负面提示词,以及自然口语化的口播对白。 - -这段脚本会**整个拼成一个长 prompt**一次性传给 Seedance 2.5(单次生成最多30秒视频),所以你的描述必须让模型在一个长镜头/连贯镜头流里理解每个时间段该拍什么、画面如何、人物说什么做什么。 +- 用户原始卖点(必须融入口播):{user_copy} +- 时长:{duration}秒 / 画幅:{ratio} / 产品图:{n_images}张(第1张通常是主图/首帧) +- 风格参考:{style_hint} +- 爆款结构(必须严格遵循节奏/段落顺序):{viral_structure_block} ## 输出格式(必须输出严格 JSON,不要 Markdown,不要解释,字段一个都不能少) @@ -432,14 +444,14 @@ _SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI ``` ## 关键要求 -1. **镜头感**:每镜必须写清景别(特写/近景/中景/全景)、角度(平视/俯拍/仰拍/45度侧拍)、运镜(推/拉/摇/移/跟/固定),不能笼统说"展示产品"。 -2. **画面具体**:描述主体是谁(性别/年龄/穿着风格)、在什么场景、做什么动作、光线从哪来、镜头怎么动,让 AI 能画出来。 -3. **对白自然**:像真人说话,不要"家人们谁懂啊""宝子们"这种浮夸腔,也不要"今天给大家推荐一款XX真的太好用了"这种硬广推销腔。要像朋友自然分享好物。 -4. **参考图片分配**:reference_image_index 填 0-based 索引,产品特写镜头用产品图(索引0通常是主图),人像/场景镜头可留 null。 -5. **时长控制**:所有 shots 的 time_range 加起来必须等于 {duration} 秒,单镜 2-8 秒。 -6. **硬性约束和负面词必须包含**:不要删减,可根据产品类型追加。 -7. **voiceover_script 必须是纯口播文本**:不含任何标记、括号、说明,字数按中文每秒 3-4 字估算({duration}秒约{approx_chars}字)。 -8. **严格遵循爆款结构**:如果上方「爆款结构」字段不为「未指定」,必须严格按该结构的节奏/段落顺序编排文案与镜头,开场钩子、痛点、反转、案例、行动号召等节点要与结构对应,不要打乱顺序。 +1. 每镜写清景别/角度/运镜(特写/近景/中景+平视/俯拍+推/拉/固定)。 +2. 画面具体:主体(性别/年龄/穿着)、场景、动作、光线、镜头运动要可落地。 +3. 对白自然口语化,像朋友分享好物;拒绝"家人们""宝子们""太好用了"等浮夸/硬广腔。 +4. reference_image_index 填 0-based 索引(产品特写用索引0主图),人像/场景可 null。 +5. shots time_range 累计={duration}秒,单镜2-8秒。 +6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。 +7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。 +8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。 """ @@ -713,8 +725,11 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di viral_structure_block=viral_structure_block, ) + _s = get_shared_settings() + _fast = _s.doubao_fast_model try: - result = call_llm(prompt) + # 编导脚本是结构化 JSON 输出,用快模型提速(temperature 稍高保证创意) + result = call_llm(prompt, temperature=0.8, max_tokens=2500, model=_fast) parsed = _safe_json_loads(result) return _validate_and_normalize_script(parsed, job) except Exception as e: @@ -743,8 +758,11 @@ def _step_review(job: ViralVideoJob, copy_result: dict) -> dict: - score: int(0-100分) - details: 各维度评分和说明 - issues: 需要修改的问题列表(如有)""" + _s = get_shared_settings() + _fast = _s.doubao_fast_model try: - result = call_llm(prompt) + # 合规审核用快模型 + 短输出(结构化判断) + result = call_llm(prompt, temperature=0.1, max_tokens=500, model=_fast) return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}} except Exception as e: logger.warning("[爆款视频] 合规审核失败: %s", e) @@ -937,10 +955,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict: return {"ok": False, "error": "job not found"} job.mark_running() - _save_job(repo, job, session) - _emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析") + _set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...") - _emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...") image_analysis = _step_image_analysis(job) job.image_analysis = image_analysis _save_job(repo, job, session) @@ -948,16 +964,18 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict: style_guide = None if job.reference_video_url or job.style_template_id: - _emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 20.0, "正在分析参考视频风格...") + _set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...") style_guide = _step_video_analysis(job) job.style_guide = style_guide _save_job(repo, job, session) _emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 25.0, "风格分析完成", {"style_guide": style_guide}) - _emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 30.0, "正在解析文案意图...") + _set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...") intent_result = _step_intent_parsing(job, image_analysis) job.mark_wait_user_confirm(intent_result) + job.current_stage = ViralVideoStage.INTENT_PARSING + job.phase_message = "意图解析完成,等待用户确认" _save_job(repo, job, session) _emit_progress( job_id, @@ -1070,10 +1088,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict: return {"ok": False, "error": "job not found"} job.mark_running() - _save_job(repo, job, session) - _emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析") + _set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...") - _emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...") image_analysis = _step_image_analysis(job) job.image_analysis = image_analysis _save_job(repo, job, session) @@ -1081,7 +1097,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict: style_guide = None if job.reference_video_url or job.style_template_id: - _emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 70.0, "正在分析参考视频风格...") + _set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...") style_guide = _step_video_analysis(job) job.style_guide = style_guide _save_job(repo, job, session) @@ -1094,6 +1110,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict: ) job.mark_image_analyzed() + job.current_stage = ViralVideoStage.IMAGE_ANALYSIS + job.phase_message = "图片分析完成,请填写营销参数以生成编导脚本" _save_job(repo, job, session) _emit_progress( job_id, @@ -1118,7 +1136,14 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict: @shared_task(bind=True, max_retries=1, name="worker.run_viral_video_generate_copy") def run_viral_video_generate_copy(self: Task, job_id: str) -> dict: - """v1.6 阶段2:意图解析 → 编导分镜脚本生成 → 合规审核,完成后状态=copy_generated。""" + """v1.6 阶段2(v1.6.1 提速版):意图解析 → 编导分镜脚本生成 → 直接返回,合规审核后置到出片前。 + + 优化点(#2134 问题7): + - 意图解析/编导脚本均使用快模型(doubao_fast_model,非推理模型),max_tokens 收紧 + - _SCRIPT_GENERATION_PROMPT 精简冗余描述 + - 合规审核改为异步后置:不阻塞前端,在 confirm-copy(阶段3 TTS前)再做最终审核 + - 每个阶段通过 _set_stage 持久化 current_stage/phase_message 到 DB(问题8) + """ session = None try: session, repo, job = _get_repo_and_job(job_id) @@ -1127,40 +1152,43 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict: if job.status != ViralVideoStatus.RUNNING: return {"ok": False, "error": f"unexpected status: {job.status}"} + # 阶段:意图解析 + _set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...") image_analysis = job.image_analysis or {"products": []} - - _emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 20.0, "正在解析文案意图...") intent_result = _step_intent_parsing(job, image_analysis) job.intent_result = intent_result _save_job(repo, job, session) _emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 35.0, "意图解析完成") - _emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在生成编导分镜脚本...") + # 阶段:编导脚本生成(核心耗时环节,已用快模型) + _set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在编排分镜脚本...") copy_result = _step_script_generation(job, intent_result, image_analysis) _emit_progress( job_id, ViralVideoStage.SCRIPT_GENERATION, - 60.0, - "编导脚本生成完成", + 85.0, + "分镜脚本生成完成", {"shots": len(copy_result.get("shots", []))}, ) - _emit_progress(job_id, ViralVideoStage.REVIEW, 65.0, "正在进行合规审核...") - review_result = _step_review(job, copy_result) - if not review_result.get("passed", True): - _emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...") - copy_result = _step_script_generation(job, intent_result, image_analysis) - _step_review(job, copy_result) - _emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成") + # 合规审核后置:不再阻塞前端返回;在阶段3(confirm-copy 出片前)_run_render_pipeline 里再做最终审核。 + # 这里只做一个快速轻量检查(关键字黑名单),发现明显违规再触发重写;LLM 深度审核放到出片前。 + _set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在快速检查脚本合规性...") + voiceover = (copy_result or {}).get("voiceover_script", "") or "" + _quick_compliance_blacklist_check(copy_result) + _emit_progress(job_id, ViralVideoStage.REVIEW, 95.0, "脚本合规初检完成") + # 标记 copy_generated 并持久化 job.mark_copy_generated(copy_result) + job.current_stage = ViralVideoStage.REVIEW + job.phase_message = "分镜脚本已生成,请确认或编辑口播文案" _save_job(repo, job, session) - voiceover = copy_result.get("voiceover_script", "") + _emit_progress( job_id, ViralVideoStage.REVIEW, 100.0, - "编导分镜脚本已生成,请确认或编辑口播文案", + "分镜脚本已生成,请确认或编辑口播文案", { "copy_result": copy_result, "generated_copy_text": voiceover, @@ -1195,39 +1223,102 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict: session.close() +def _quick_compliance_blacklist_check(copy_result: dict) -> None: + """阶段2快速黑名单检查:不调用 LLM,只扫描高风险关键词;命中则在 voiceover 中就地替换。 + + LLM 深度合规审核(_step_review)在阶段3 confirm-copy 出片前执行。 + """ + if not isinstance(copy_result, dict): + return + voiceover = copy_result.get("voiceover_script", "") or "" + # 广告法绝对化用语黑名单(常见速查,远非完整,仅挡住最明显违规) + BLACKLIST = { + "最": "很", + "第一": "领先", + "国家级": "高品质", + "世界级": "高品质", + "顶级": "优质", + "极品": "优质", + "独家": "特色", + "绝无仅有": "少见", + "100%": "大幅", + "百分百": "大幅", + "永久": "长久", + "万能": "多用途", + "特效": "效果好", + "速效": "快速见效", + "根治": "改善", + "包治": "改善", + "药到病除": "缓解不适", + } + changed = False + for k, v in BLACKLIST.items(): + if k in voiceover: + voiceover = voiceover.replace(k, v) + changed = True + if changed: + copy_result["voiceover_script"] = voiceover + # 同步 final_copy/suggested_copy(如果存在) + for k in ("final_copy", "suggested_copy"): + if isinstance(copy_result.get(k), str) and copy_result[k]: + for bk, bv in BLACKLIST.items(): + copy_result[k] = copy_result[k].replace(bk, bv) + + def _run_render_pipeline(job_id: str, session, repo, job) -> dict: - """v1.6 阶段3 / 旧 resume 共用:TTS → 单次 Seedance → Upload → Completed。""" + """v1.6.1 阶段3:出片前合规审核(LLM 深度)→ TTS → Seedance → Upload → Completed。 + + 阶段2 generate-copy 已把 LLM 深度审核后置,这里在 TTS 前做最终审核(不通过则自动重写1次)。 + 所有阶段通过 _set_stage 持久化 current_stage/phase_message。 + """ image_analysis = job.image_analysis or {"products": []} - # 如果没有 copy_result(旧数据/失败重试),现场补生成 + # 如果没有 copy_result(旧数据/失败重试),现场补生成(意图+脚本,不走 LLM 审核,出片前会统一做) copy_result = job.copy_result if not isinstance(copy_result, dict) or not copy_result: - _emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在补生成编导脚本...") + _set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在补生成编导脚本...") intent = job.intent_result or _step_intent_parsing(job, image_analysis) copy_result = _step_script_generation(job, intent, image_analysis) - _step_review(job, copy_result) job.mark_copy_generated(copy_result) _save_job(repo, job, session) + # 出片前 LLM 深度合规审核(#2134 问题7:审核从阶段2后置到这里,不阻塞前端预览脚本) + _set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在进行出片前合规审核...") + try: + review_result = _step_review(job, copy_result) + if not review_result.get("passed", True): + _emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...") + intent = job.intent_result or _step_intent_parsing(job, image_analysis) + copy_result = _step_script_generation(job, intent, image_analysis) + _step_review(job, copy_result) # 二次审核,不通过也继续出片(避免反复循环) + job.copy_result = copy_result + job.generated_copy_text = copy_result.get("voiceover_script", "") or "" + _save_job(repo, job, session) + except Exception as e: + logger.warning("[爆款视频][阶段3] 合规审核异常,继续出片: %s", e) + _emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成") + voiceover = copy_result.get("voiceover_script", "") or job.effective_copy_text # Step 5: TTS 整段合成 - _emit_progress(job_id, ViralVideoStage.TTS, 72.0, "正在生成AI配音...") + _set_stage(job, repo, session, ViralVideoStage.TTS, "正在合成AI配音...") tts_path = _step_tts(job, voiceover) tts_url = _upload_tts_to_oss(job, tts_path) _emit_progress(job_id, ViralVideoStage.TTS, 78.0, "配音完成", {"has_tts": tts_url is not None}) # Step 6: 单次 Seedance - _emit_progress(job_id, ViralVideoStage.RENDERING, 80.0, "正在调用AI生成视频(约1-3分钟)...") + _set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...") video_path = _step_render(job, copy_result, tts_url) _emit_progress(job_id, ViralVideoStage.RENDERING, 92.0, "视频生成完成") # Step 7: Upload - _emit_progress(job_id, ViralVideoStage.UPLOADING, 95.0, "正在上传视频...") + _set_stage(job, repo, session, ViralVideoStage.UPLOADING, "正在上传视频...") video_url = _step_upload(job, video_path) job.credits_cost = CREDITS_VIRAL_VIDEO_COST job.mark_completed(video_url) + job.current_stage = ViralVideoStage.UPLOADING + job.phase_message = "视频生成完成" _save_job(repo, job, session) _emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url}) _emit_progress( diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 7867fa7e6..0a2ca4931 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -950,6 +950,8 @@ class ViralVideoJobModel(Base): video_model = Column(String(100), nullable=False, default="") # 结果与状态 status = Column(String(30), nullable=False, default="pending", index=True) + current_stage = Column(String(200), nullable=False, default="") # 细粒度阶段 snake_case + phase_message = Column(String(500), nullable=False, default="") # 阶段中文提示文案 intent_result = Column(JSON, nullable=True) image_analysis = Column(JSON, nullable=True) storyboard = Column(JSON, nullable=True) diff --git a/packages/adapters/sqlalchemy_impl/viral_video_repository.py b/packages/adapters/sqlalchemy_impl/viral_video_repository.py index 7143985a3..995436569 100755 --- a/packages/adapters/sqlalchemy_impl/viral_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/viral_video_repository.py @@ -37,6 +37,8 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob: 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, + current_stage=getattr(model, "current_stage", "") or "", + phase_message=getattr(model, "phase_message", "") or "", intent_result=dict(model.intent_result) if model.intent_result else None, image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None, storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None, @@ -83,6 +85,8 @@ class SQLAlchemyViralVideoJobRepository: video_ratio=job.video_ratio, video_model=job.video_model, status=job.status, + current_stage=job.current_stage or "", + phase_message=job.phase_message or "", intent_result=job.intent_result, image_analysis=job.image_analysis, storyboard=job.storyboard, @@ -106,6 +110,8 @@ class SQLAlchemyViralVideoJobRepository: if model is None: raise ValueError(f"ViralVideoJob {job.id} not found") model.status = job.status + model.current_stage = job.current_stage or "" + model.phase_message = job.phase_message or "" model.intent_result = job.intent_result model.image_analysis = job.image_analysis model.storyboard = job.storyboard diff --git a/packages/config/base.py b/packages/config/base.py index 5db4582e9..adf882c75 100755 --- a/packages/config/base.py +++ b/packages/config/base.py @@ -90,7 +90,8 @@ class SharedSettings(BaseSettings): # ── 豆包大模型(火山引擎方舟) ──────────────────────────────────────── doubao_api_key: str = "" - doubao_model: str = "doubao-seed-1-6-250615" + doubao_model: str = "doubao-seed-1-6-250615" # 推理模型(通用兜底) + doubao_fast_model: str = "doubao-1-5-pro-32k-250115" # 快速结构化输出模型(编导脚本/意图解析/审核) doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3" doubao_timeout: int = 30 doubao_max_retries: int = 2 diff --git a/packages/domain/viral_video.py b/packages/domain/viral_video.py index b3c74ee5e..2f957e71f 100755 --- a/packages/domain/viral_video.py +++ b/packages/domain/viral_video.py @@ -117,6 +117,8 @@ class ViralVideoJob: # 状态 id: str = field(default_factory=lambda: uuid4().hex) status: ViralVideoStatus = ViralVideoStatus.PENDING + current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case) + phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示 result_video_url: str = "" credits_cost: int = 0 error_msg: str = "" diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index 62193a8f7..c074c50b8 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -41,6 +41,7 @@ class DoubaoClient: self.max_retries: int = settings.doubao_max_retries self.vision_model: str = settings.doubao_vision_model self.vision_lite_model: str = settings.doubao_vision_lite_model + self.fast_model: str = settings.doubao_fast_model def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None: """调用豆包文本 Embedding API,返回浮点向量;失败返回 None。""" @@ -93,6 +94,7 @@ class DoubaoClient: messages: list[dict[str, str]], temperature: float = 0.7, max_tokens: int = 1024, + model: str | None = None, ) -> Optional[str]: """调用 Chat Completion 接口. @@ -113,7 +115,7 @@ class DoubaoClient: "Content-Type": "application/json", } payload: dict[str, Any] = { - "model": self.model, + "model": model or self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index 602144597..8c8e833c7 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -496,16 +496,32 @@ def run_generate_cover( # ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)── -def call_llm(prompt: str, temperature: float = 0.7) -> object: - """调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。""" +def call_llm( + prompt: str, + temperature: float = 0.7, + max_tokens: int = 2048, + model: str | None = None, + system_prompt: str | None = None, +) -> object: + """调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。 + + Args: + prompt: 用户侧提示。 + temperature: 采样温度。 + max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。 + model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。 + system_prompt: 覆盖默认 system prompt。 + """ client = get_doubao_client() if not client.is_available: return None + if system_prompt is None: + system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。" messages = [ - {"role": "system", "content": "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}, ] - raw = client.chat_completion(messages, temperature=temperature, max_tokens=4096) + raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model) if raw is None: return None try: diff --git a/scripts/render_env.sh b/scripts/render_env.sh index c6609e4de..fc05992cc 100644 --- a/scripts/render_env.sh +++ b/scripts/render_env.sh @@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then fi # 共用 secrets 直接导出(如果存在) -SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN" +SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN" for var in $SHARED_SECRETS; do value="${!var:-}" # 已经在环境中了,无需额外操作 diff --git a/tests/unit/test_2035_coverage.py b/tests/unit/test_2035_coverage.py index c60638e4d..b8bbedf6d 100644 --- a/tests/unit/test_2035_coverage.py +++ b/tests/unit/test_2035_coverage.py @@ -14,6 +14,7 @@ from packages.shared.ai_client import DoubaoClient class _FakeSettings: doubao_api_key = "test-key" doubao_model = "test-model" + doubao_fast_model = "test-fast-model" doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3" doubao_timeout = 10 doubao_max_retries = 0 diff --git a/tests/unit/test_viral_video_routes.py b/tests/unit/test_viral_video_routes.py index 2c9bcfcf6..3dbbdcfdf 100644 --- a/tests/unit/test_viral_video_routes.py +++ b/tests/unit/test_viral_video_routes.py @@ -61,6 +61,8 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending "video_ratio": "9:16", "video_model": "", "credits_cost": 0, + "current_stage": "", + "phase_message": "", "updated_at": None, "is_terminal": False, "effective_copy_text": "", From 1e8ba91bba29bbe54674217f0abe78e2a103bd56 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 2 Oct 2026 03:06:01 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + ruff + prettier [skip ci-format-check] --- apps/api/app/schemas/viral_video.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/app/schemas/viral_video.py b/apps/api/app/schemas/viral_video.py index 792c657ee..96bd65843 100755 --- a/apps/api/app/schemas/viral_video.py +++ b/apps/api/app/schemas/viral_video.py @@ -201,7 +201,9 @@ class ViralVideoJobResponse(BaseModel): style_guide: dict | None = None style_template_id: str = "" status: str - current_stage: str = "" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading) + current_stage: str = ( + "" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading) + ) phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示) image_analysis: dict | None = None # v1.6 编导脚本(推荐前端使用)