diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 73c1615..124fea0 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -1,5 +1,7 @@ from pathlib import Path import os +import sys +from urllib.parse import urlsplit from dotenv import load_dotenv @@ -12,6 +14,20 @@ def env(name: str, default: str | None = None) -> str | None: return os.getenv(name, default) +def redis_url(name: str, database: int) -> str: + """本机运行时把 Docker 内部 Redis 主机名映射到已启动的本机 Redis。 + + 容器/Linux 与其他显式配置保持原样;不读取或输出 URL 中的凭证。 + """ + fallback = f"redis://127.0.0.1:6379/{database}" + configured = env(name, fallback) or fallback + if sys.platform == "darwin" and urlsplit(configured).hostname == "yingqing-redis": + # Celery 自身也优先读取 CELERY_BROKER_URL 环境变量,只改 Django settings 不够。 + os.environ[name] = fallback + return fallback + return configured + + def env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: @@ -191,12 +207,12 @@ CORS_ALLOW_CREDENTIALS = True CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"), + "LOCATION": redis_url("REDIS_CACHE_URL", 0), } } -CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1") -CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2") +CELERY_BROKER_URL = redis_url("CELERY_BROKER_URL", 1) +CELERY_RESULT_BACKEND = redis_url("CELERY_RESULT_BACKEND", 2) # 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。 # 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。 CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true" @@ -236,7 +252,7 @@ CREATION_AGENT_MAX_OUTPUT_TOKENS = env_int("CREATION_AGENT_MAX_OUTPUT_TOKENS", 1 # 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。 CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip() -REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3") +REDIS_LOCK_URL = redis_url("REDIS_LOCK_URL", 3) def _tos_region() -> str: """阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。""" diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 0345c54..87a82c6 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -83,7 +83,7 @@ def _meta_from_task(task, message: CreationMessage) -> dict: model = task.model_config.display_name or task.model_config.name return { "model": model or payload.get("model") or req.get("model") or "", - "ratio": req.get("ratio") or payload.get("ratio") or "", + "ratio": req.get("aspect_ratio") or req.get("ratio") or payload.get("ratio") or "", "prompt": payload.get("prompt") or req.get("prompt") or "", } @@ -103,11 +103,15 @@ def _message_task(message: CreationMessage): @transaction.atomic def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage: """生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。""" + # worker 与会话轮询可能同时回填同一条消息;先锁会话再锁消息,和 append_message 的锁顺序一致。 + conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id) + message = CreationMessage.objects.select_for_update().get(pk=message.pk) + if message.kind != CreationMessage.Kind.GENERATING: + return message is_person_reference = (message.payload or {}).get("kind") == "person_reference" message.kind = CreationMessage.Kind.ERROR message.text = (error or "生成失败")[:500] message.save(update_fields=["kind", "text", "updated_at"]) - conversation = message.conversation if is_person_reference: memory = dict(conversation.memory or {}) memory["person_source_pending"] = False @@ -443,6 +447,7 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message: "source_message_id": str(message.id), "source_task_ids": task_ids, "prompt": payload.get("prompt") or "", + "aspect_ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "", }, ) payload["merge_state"] = "queued" @@ -453,7 +458,11 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message: conversation, role="assistant", kind=CreationMessage.Kind.GENERATING, - payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""}, + payload={ + "task_id": str(merge_task.id), "kind": "video_merge", + "prompt": payload.get("prompt") or "", + "ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "", + }, task=merge_task, ) return merge_task, generating @@ -573,12 +582,13 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m 重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加; 这里改的只是同一次生成自己的中间态。 """ + # select_for_update 后重读状态:第二个回填者不能凭旧 GENERATING 对象再发一次确认。 + conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id) + message = CreationMessage.objects.select_for_update().get(pk=message.pk) + if message.kind != CreationMessage.Kind.GENERATING: + return message original_payload = dict(message.payload or {}) is_person_reference = original_payload.get("kind") == "person_reference" - message.kind = CreationMessage.Kind.RESULT - message.payload = {**original_payload, **meta, "assets": assets} - message.save(update_fields=["kind", "payload", "updated_at"]) - conversation = message.conversation if is_person_reference: from apps.assets.models import Asset, Model @@ -591,6 +601,10 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m ).first() if asset is None: return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产") + message.kind = CreationMessage.Kind.RESULT + message.payload = {**original_payload, **meta, "assets": assets} + message.save(update_fields=["kind", "payload", "updated_at"]) + if is_person_reference: cast_index = int(original_payload.get("cast_index") or 1) cast_total = int(original_payload.get("cast_total") or 1) cast_model_name = str(original_payload.get("cast_model_name") or "").strip() diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index cd1c903..c1d1100 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -29,14 +29,17 @@ from django.db import transaction from .creation import append_message, pin_refs from .creation_presets import ( + LEGACY_PLOT_TWIST_PRESET, PLOT_TWIST_PRESET, apply_image_preset_prompt, apply_plot_twist_story_contract, apply_plot_twist_direction_contract, format_plot_twist_direction_contract, apply_video_preset_prompt, + canonical_preset_name, is_click_swap_preset, is_fish_eye_outfit_preset, + is_plot_twist_preset, fish_eye_outfit_prompt_template, plot_twist_story_depth, plot_twist_story_contract, @@ -255,6 +258,7 @@ _STEP_CONFIRM_LABELS = { _PERSON_SOURCE_PRESETS = { "痛点解决演示", PLOT_TWIST_PRESET, + LEGACY_PLOT_TWIST_PRESET, "短剧反转带货", "达人口播种草", "鱼眼换装", @@ -278,6 +282,7 @@ _CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都 _PRODUCT_REQUIRED_PRESETS = { "痛点解决演示", PLOT_TWIST_PRESET, + LEGACY_PLOT_TWIST_PRESET, "达人口播种草", "点击换款", "多色商品换款", @@ -290,7 +295,7 @@ _PRODUCT_REQUIRED_PRESETS = { def is_plot_twist_conversation(conversation: CreationConversation) -> bool: - return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET + return conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(conversation.preset) @@ -594,6 +599,45 @@ _SELLING_POINT_FOCUS_RE = re.compile( _SELLING_POINT_BRIEF_CREATIVE_RE = re.compile( r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)", ) +_BRIEF_CLAUSE_END = r"[^。!?!?;;,,\n]{2,160}" +_BRIEF_AUDIENCE_RE = re.compile( + rf"(?:适用人群|目标人群|目标用户|适合人群|面向人群|受众)\s*(?:是|为|包括|:|:)\s*({_BRIEF_CLAUSE_END})" +) +_BRIEF_SCENARIO_RE = re.compile( + rf"(?:使用环境|使用场景|适用场景|拍摄场景|发生场景|场景)\s*(?:是|为|包括|设在|设定为|:|:)\s*({_BRIEF_CLAUSE_END})" +) +_BRIEF_ACTION_BENEFIT_RE = re.compile( + r"(?:^|[。!?!?;;,,]\s*)(?P(?:把|将)[^。!?!?;;,,\n]{2,100}?" + r"(?:就|即可|便能|可以)[^。!?!?;;,,\n]{2,100})" +) + + +def extract_brief_context(text: str) -> dict[str, str]: + """只采集用户明确说出的受众、场景和使用动作,不推断价格、功效等商品事实。""" + raw = (text or "").strip() + found: dict[str, str] = {} + for key, pattern in (("target_audience", _BRIEF_AUDIENCE_RE), ("usage_scenario", _BRIEF_SCENARIO_RE)): + matches = list(pattern.finditer(raw)) + if matches: + value = matches[-1].group(1).strip(" ::。;;,, ") + if value and not re.match(r"^(?:和|与|及|或)?(?:核心卖点|使用场景|目标用户|价格)$", value): + found[key] = value[:160] + action = _BRIEF_ACTION_BENEFIT_RE.search(raw) + if action and not re.search(r"(?:不要|不能|禁止|别)\s*$", raw[max(0, action.start() - 8):action.start()]): + found["usage_benefit"] = action.group("action").strip()[:160] + return found + + +def collect_product_brief_context(conversation: CreationConversation, user_text: str = "") -> dict[str, str]: + """按消息顺序合并商品 brief,后续补充覆盖先前描述。""" + found: dict[str, str] = {} + for message_text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True): + found.update(extract_brief_context(str(message_text or ""))) + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + found.update(extract_brief_context(str(memory.get("product_brief_note") or ""))) + if user_text: + found.update(extract_brief_context(user_text)) + return found def extract_declared_selling_point(text: str) -> str: @@ -614,6 +658,11 @@ def extract_declared_selling_point(text: str) -> str: value = value.strip("。;;!!??,,、 ") if len(value) >= 2: return value[:200] + # 用户可能没有说「卖点」二字,却明确描述了商品使用动作及结果。 + # 这里只复述该动作,不衍生「更快/更健康」等未经证实的功效。 + action = extract_brief_context(raw).get("usage_benefit", "") + if action: + return action # 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令 if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw): parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()] @@ -655,7 +704,7 @@ def lock_selling_point( memory = dict(conversation.memory or {}) memory["selling_point_ready"] = True memory["selling_point_mode"] = mode - memory["selling_point"] = selling_point if mode == "manual" else "" + memory["selling_point"] = selling_point if mode in {"manual", "inferred"} else "" conversation.memory = memory conversation.save(update_fields=["memory", "updated_at"]) @@ -674,17 +723,19 @@ def ensure_selling_point_ready_from_context( if memory.get("selling_point_ready"): return True - candidates: list[str] = [] - declared = extract_declared_selling_point(user_text) - if declared: - candidates.append(declared) + candidates: list[tuple[str, str]] = [] for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True): found = extract_declared_selling_point(text) if found: - candidates.append(found) + candidates.append((found, "inferred" if found == extract_brief_context(text).get("usage_benefit") else "manual")) + for source in (str(memory.get("product_brief_note") or ""), user_text): + found = extract_declared_selling_point(source) + if found: + candidates.append((found, "inferred" if found == extract_brief_context(source).get("usage_benefit") else "manual")) if candidates: # 以最近一次声明为准(主页首条或后续补充) - lock_selling_point(conversation, candidates[-1], mode="manual") + selling_point, mode = candidates[-1] + lock_selling_point(conversation, selling_point, mode=mode) return True summary = product_selling_points_summary(conversation) @@ -694,6 +745,17 @@ def ensure_selling_point_ready_from_context( return False +def record_product_brief_review(conversation: CreationConversation, note: str = "") -> None: + """提交商品补充时同步卖点状态,避免选完剧情方向后再次追问已填写的内容。""" + memory = dict(conversation.memory or {}) + memory["product_brief_reviewed"] = True + if note: + memory["product_brief_note"] = note + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + ensure_selling_point_ready_from_context(conversation, note) + + def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage: """在视频方案生成前确认卖点来源。 @@ -1117,6 +1179,20 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]: or "" ).strip() selling = str(memory.get("selling_point") or "").strip() + context = collect_product_brief_context(conversation) + audience = context.get("target_audience", "") + for ref in products: + if audience or ref.get("type") != "product": + continue + from apps.products.models import Product + audience = str( + Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id) + .values_list("target_audience", flat=True).first() or "" + ).strip() + scenario = context.get("usage_scenario", "") + audience_and_scenario = ";".join( + part for part in (f"目标用户:{audience}" if audience else "", f"使用场景:{scenario}" if scenario else "") if part + ) def item(label: str, status: str, value: str = "") -> dict: return {"label": label, "status": status, "value": value} @@ -1131,7 +1207,7 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]: item("名称与品牌", "ready" if name else "missing", name), item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""), item("核心卖点", "ready" if selling else "missing", selling), - item("使用场景与目标用户", "missing", ""), + item("使用场景与目标用户", "ready" if audience and scenario else "missing", audience_and_scenario), item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""), item("特别强调或禁止内容", "ready" if special else "not_needed", special), ] @@ -1144,6 +1220,20 @@ def product_brief_needs_review(conversation: CreationConversation) -> bool: return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed")) +def accept_complete_product_brief(conversation: CreationConversation) -> bool: + """用户和商品库已给足必要信息时,跳过重复的核对问答。""" + if not product_brief_needs_review(conversation): + return False + items = product_brief_items(conversation) + if any(item["status"] == "missing" for item in items): + return False + memory = dict(conversation.memory or {}) + memory["product_brief_reviewed"] = True + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return True + + def append_product_brief_review(conversation: CreationConversation) -> CreationMessage: """商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。""" return append_message( @@ -2024,7 +2114,7 @@ def emit_final_confirm_gate( }, "submitted": False, "params": snapshot_session_params(conversation), - "param_options": confirm_param_options(True), + "param_options": confirm_param_options(True, fixed_duration=is_plot_twist_conversation(conversation)), }, ) set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt) @@ -2090,7 +2180,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、 色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。 打光规则:光源方向、软硬、色温、人物和产品分别如何受光。 剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。 -剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。 +短剧带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。 声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。 场景:逐一写清可见地点、前中后景、环境道具与景深。 主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。 @@ -3028,6 +3118,8 @@ def apply_explicit_video_duration_from_text( """ if conversation.mode != CreationConversation.Mode.VIDEO: return False + if is_plot_twist_conversation(conversation): + return False match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or "")) if match is None: return False @@ -3057,6 +3149,8 @@ def apply_session_params(conversation, fields, answers: dict) -> bool: stored = _PARAM_TO_STORED.get(str(key), str(key)) if stored not in {"model", "ratio", "resolution", "duration", "count"}: continue + if stored == "duration" and is_plot_twist_conversation(conversation): + continue value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip() if not value or current.get(stored) == value: continue @@ -3094,12 +3188,12 @@ def snapshot_session_params(conversation) -> dict: } -def confirm_param_options(is_video: bool) -> dict: +def confirm_param_options(is_video: bool, *, fixed_duration: bool = False) -> dict: return { "model": VIDEO_MODELS if is_video else IMAGE_MODELS, "resolution": RESOLUTIONS if is_video else [], "ratio": RATIOS, - "duration": VIDEO_DURATIONS if is_video else [], + "duration": VIDEO_DURATIONS if is_video and not fixed_duration else [], "count": IMAGE_COUNTS if not is_video else [], } @@ -3119,7 +3213,11 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo old_duration = str(current.get("duration") or "") old_model = str(current.get("model") or "") changed = False - for key, raw in (incoming or {}).items(): + confirmed = dict(incoming or {}) + if conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_conversation(conversation): + # 确认卡不开放时长;旧客户端和旧会话也必须回到固定的 60 秒。 + confirmed["duration"] = "60 秒" + for key, raw in confirmed.items(): if key not in {"model", "ratio", "resolution", "duration", "count"}: continue value = str(raw or "").strip() @@ -3248,7 +3346,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "function": { "name": "present_story_directions", "description": ( - "剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。" + "短剧带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。" "每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。" "用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。" ), @@ -3319,7 +3417,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。" ) + ( - f"剧情反转带货须先写【完整故事线】的{plot_beats}," + f"短剧带货须先写【完整故事线】的{plot_beats}," f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。" if is_plot_twist_conversation(context.conversation) else "" ) @@ -4055,7 +4153,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ # 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。 if len(segments) == 1: - message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt} + message_payload = { + "task_id": str(tasks[0].id), "kind": "video", "prompt": prompt, + "ratio": submit["aspect_ratio"], + } else: submitted_by_index = { int(segment["index"]): str(task.id) @@ -4066,6 +4167,7 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ "task_ids": [str(task.id) for task in tasks], "kind": "video_segments", "prompt": prompt, + "ratio": submit["aspect_ratio"], "total_duration": total_duration, "segments": [ {**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})} @@ -4352,6 +4454,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;" "只补充可从素材或正常使用中证明的支撑,不得替换或夸大。" ) + elif selling_mode == "inferred" and selling_point: + lines.append( + f"- 从用户原话归纳出的商品使用动作:【{selling_point}】。可作为创意切入点," + "但不是已核实的功效、规格或价格;不得放大成未经证实的商品承诺。" + ) elif selling_mode == "auto": lines.append( "- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;" @@ -4360,6 +4467,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c product_note = str(memory.get("product_brief_note") or "").strip() if product_note: lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。") + brief_context = collect_product_brief_context(conversation) + if brief_context.get("target_audience"): + lines.append(f"- 用户指定的目标人群:【{brief_context['target_audience']}】。视频架构和 Prompt 必须体现。") + if brief_context.get("usage_scenario"): + lines.append(f"- 用户指定的使用/拍摄场景:【{brief_context['usage_scenario']}】。视频架构和 Prompt 必须体现。") lines.append( "- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充," "禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。" @@ -4411,7 +4523,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c if conversation.preset: # 只给名字模型只能靠猜;把这个预设的拍法约束一起给它 guidance = preset_guidance(conversation.preset) - lines.append(f"\n【创作预设】{conversation.preset}") + lines.append(f"\n【创作预设】{canonical_preset_name(conversation.preset)}") if context.is_video: lines.append( "预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、" @@ -4423,7 +4535,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c if is_plot_twist_conversation(conversation): depth = active_plot_twist_story_depth(conversation) if not depth: - lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。") + lines.append("短剧带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。") else: lines.append(plot_twist_story_contract(depth)) memory = conversation.memory if isinstance(conversation.memory, dict) else {} @@ -5112,6 +5224,7 @@ def iter_creation_agent_events( yield {"type": "done"} return + accept_complete_product_brief(conversation) if product_brief_needs_review(conversation): question = append_product_brief_review(conversation) set_video_gate_stage(conversation, "clarify") @@ -5917,7 +6030,7 @@ def _dispatch_tool( if name == "present_story_directions": if not is_plot_twist_conversation(context.conversation): - return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False + return {"payload": {"error": "当前会话不是短剧带货预设"}}, False directions = _coerce_plot_twist_directions(args.get("directions")) if not directions: return { @@ -5955,7 +6068,7 @@ def _dispatch_tool( return { "payload": { "error": ( - "剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡," + "短剧带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡," "等用户选择后再写策略。不得自行编造未选中的故事桥段。" ) } @@ -6061,7 +6174,7 @@ def _dispatch_tool( else: return { "payload": { - "error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。" + "error": "短剧带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。" } }, False if not card["usp"] or not card["points"]: diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 5f4ec21..1323c2d 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -11,6 +11,20 @@ from __future__ import annotations import re CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) +PLOT_TWIST_PRESET = "短剧带货" +LEGACY_PLOT_TWIST_PRESET = "剧情反转带货" + + +def is_plot_twist_preset(name: str | None) -> bool: + """新旧预设名称执行同一套短剧流程,兼容已保存的历史会话。""" + return str(name or "").strip() in {PLOT_TWIST_PRESET, LEGACY_PLOT_TWIST_PRESET} + + +def canonical_preset_name(name: str | None) -> str: + raw = str(name or "").strip() + return PLOT_TWIST_PRESET if raw == LEGACY_PLOT_TWIST_PRESET else raw + + _CLICK_SWAP_FINGER_GUIDANCE = ( "形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," "画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。" @@ -39,8 +53,8 @@ VIDEO_PRESETS: dict[str, str] = { "没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。" "用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。" ), - "剧情反转带货": ( - "剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物," + PLOT_TWIST_PRESET: ( + "短剧带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物," "并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。" "不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。" "开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;" @@ -100,9 +114,6 @@ VIDEO_PRESETS: dict[str, str] = { ), } - -PLOT_TWIST_PRESET = "剧情反转带货" - # 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。 PLOT_TWIST_STORY_DEPTH_OPTIONS = ( { @@ -164,7 +175,7 @@ def plot_twist_story_contract(value: str) -> str: depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"] if depth["value"] == "15s": return ( - "【剧情反转带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;" + "【短剧带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;" "只保留一条主冲突,主角 1 人、最多 1 名辅助人物。" "0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点," "11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;" @@ -172,7 +183,7 @@ def plot_twist_story_contract(value: str) -> str: ) if depth["value"] == "30s": return ( - "【剧情反转带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线," + "【短剧带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线," "不可只写故事中段;必须交代人物处境,可设置两人互动。" "0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试," "17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。" @@ -180,7 +191,7 @@ def plot_twist_story_contract(value: str) -> str: ) if depth["value"] == "60s": return ( - "【剧情反转带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。" + "【短剧带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。" "先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。" "0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;" "15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;" @@ -191,7 +202,7 @@ def plot_twist_story_contract(value: str) -> str: ) if depth["value"] == "180s": return ( - "【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。" + "【短剧带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。" "第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力," "28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。" "第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据," @@ -207,7 +218,7 @@ def plot_twist_story_contract(value: str) -> str: def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str: """在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。""" base = str(prompt or "").strip() - if (name or "").strip() != PLOT_TWIST_PRESET or not base: + if not is_plot_twist_preset(name) or not base: return base contract = plot_twist_story_contract(story_depth) if not contract or contract in base: @@ -277,7 +288,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。", "真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。", - "剧情反转带货": "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。", + PLOT_TWIST_PRESET: "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。", "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", @@ -304,8 +315,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤," "用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。" ), - "剧情反转带货": ( - "【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;" + PLOT_TWIST_PRESET: ( + "【预设执行层·短剧带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;" "人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。" "商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;" "商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。" @@ -411,12 +422,12 @@ ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS} def preset_guidance(name: str) -> str: """预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时, 退回「只有名字」的行为,不要报错。""" - return ALL_PRESETS.get((name or "").strip(), "") + return ALL_PRESETS.get(canonical_preset_name(name), "") def preset_workflow_guidance(name: str) -> str: """预设名 → Agent 的素材、追问和检查重点。""" - return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "") + return VIDEO_PRESET_WORKFLOWS.get(canonical_preset_name(name), "") _CLICK_SWAP_FINGER_CONTRACT = ( @@ -439,7 +450,7 @@ _CLICK_SWAP_CHARACTER_CONTRACT = ( def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str: """预设名 → 最终视频生成阶段必须执行的结构与镜头约束。""" - preset = (name or "").strip() + preset = canonical_preset_name(name) if preset in CLICK_SWAP_PRESETS: if (click_swap_mode or "").strip() == "character": return _CLICK_SWAP_CHARACTER_CONTRACT @@ -546,13 +557,13 @@ def scrub_click_swap_same_position_wording(text: str) -> str: def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str: """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" base = str(prompt or "").strip() - preset = (name or "").strip() + preset = canonical_preset_name(name) mode = (click_swap_mode or "").strip() contract = video_preset_delivery_contract(preset, click_swap_mode=mode) if not base or not contract: return base marker = f"【视频预设】{preset}" - if marker in base: + if marker in base or (is_plot_twist_preset(name) and f"【视频预设】{LEGACY_PLOT_TWIST_PRESET}" in base): return base if is_click_swap_preset(preset): if mode == "character": diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index 28d967d..1727b18 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .creation_presets import PLOT_TWIST_PRESET +from .creation_presets import is_plot_twist_preset from .models import ( AITask, CreationConversation, @@ -248,18 +248,20 @@ class CreationConversationSerializer(serializers.ModelSerializer): return progress def create(self, validated_data): - # 剧情反转带货的新会话直接以完整短剧的 60 秒开场,不再弹故事深度选择。 - # 旧客户端仍传「智能时长」时也要在服务端落成确定参数。 - if validated_data.get("mode") == CreationConversation.Mode.VIDEO and validated_data.get("preset") == PLOT_TWIST_PRESET: + # 短剧带货现阶段固定为完整的 60 秒短剧,旧客户端传任何时长也不能覆盖。 + if validated_data.get("mode") == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset")): params = dict(validated_data.get("params") or {}) - if not str(params.get("duration") or "").strip() or str(params.get("duration")).strip() == "智能时长": - params["duration"] = "60 秒" + params["duration"] = "60 秒" validated_data["params"] = params return super().create(validated_data) def update(self, instance, validated_data): # mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上 validated_data.pop("mode", None) + if instance.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset", instance.preset)): + params = dict(validated_data.get("params", instance.params) or {}) + params["duration"] = "60 秒" + validated_data["params"] = params return super().update(instance, validated_data) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index ed103cf..90e5fb6 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -4,6 +4,7 @@ 工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。 """ import json +from types import SimpleNamespace from unittest.mock import patch import requests @@ -30,6 +31,9 @@ from .creation_agent import ( apply_pain_point_direction, ensure_selling_point_ready_from_context, extract_declared_selling_point, + extract_brief_context, + product_brief_items, + accept_complete_product_brief, apply_restart_intent, apply_person_identity_guard, apply_product_reference_guard, @@ -60,6 +64,7 @@ from .creation_agent import ( resolve_smart_video_duration, is_continue_intent, is_greeting, + is_plot_twist_conversation, is_pure_chitchat, is_restart_intent, session_has_creative_context, @@ -623,6 +628,17 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): is_primary=True, ) + finish_generating_message( + generating, + assets=[{ + "id": str(asset.id), + "url": "https://cdn.example/person-generated.jpg", + "cover": "https://cdn.example/person-generated.jpg", + "type": "image", + }], + meta={"prompt": "person"}, + ) + # worker 与页面轮询都拿着旧的 GENERATING 对象回填时,只能出现一次确认。 finish_generating_message( generating, assets=[{ @@ -646,6 +662,9 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): self.assertTrue( self.conversation.messages.filter(text__contains="是否使用这个角色").exists() ) + self.assertEqual( + self.conversation.messages.filter(text__contains="是否使用这个角色").count(), 1 + ) def test_platform_person_submission_uses_model_mode(self): task = AITask.objects.create( @@ -1831,6 +1850,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(conversation.params["duration"], "60 秒") self.assertEqual(conversation.params["ratio"], "9:16") + def test_short_drama_new_name_and_legacy_name_share_the_same_workflow(self): + from apps.ai.creation_presets import ( + PLOT_TWIST_PRESET, + apply_plot_twist_story_contract, + apply_video_preset_prompt, + preset_guidance, + preset_workflow_guidance, + video_preset_delivery_contract, + ) + from apps.ai.serializers import CreationConversationSerializer + + self.assertEqual(PLOT_TWIST_PRESET, "短剧带货") + for name in ("短剧带货", "剧情反转带货"): + self.conversation.preset = name + self.assertTrue(is_plot_twist_conversation(self.conversation)) + self.assertEqual(preset_guidance(name), preset_guidance("短剧带货")) + self.assertEqual(preset_workflow_guidance(name), preset_workflow_guidance("短剧带货")) + self.assertEqual(video_preset_delivery_contract(name), video_preset_delivery_contract("短剧带货")) + self.assertIn("【短剧带货·60秒", apply_plot_twist_story_contract(name, "60s", "完整短剧提示词")) + self.assertIn("【视频预设】短剧带货", apply_video_preset_prompt(name, "完整短剧提示词")) + + serializer = CreationConversationSerializer(data={ + "title": "短剧带货", + "mode": "video", + "preset": "短剧带货", + "params": {"ratio": "9:16"}, + }) + self.assertTrue(serializer.is_valid(), serializer.errors) + created = serializer.save(team=self.team, created_by=self.user) + self.assertEqual(created.params["duration"], "60 秒") + + # 即使旧客户端在创建/修改会话时仍传其他时长,短剧也保持 60 秒。 + serializer = CreationConversationSerializer(data={ + "title": "短剧带货", "mode": "video", "preset": "短剧带货", + "params": {"duration": "15 秒", "ratio": "9:16"}, + }) + self.assertTrue(serializer.is_valid(), serializer.errors) + fixed = serializer.save(team=self.team, created_by=self.user) + self.assertEqual(fixed.params["duration"], "60 秒") + serializer = CreationConversationSerializer(fixed, data={"params": {"duration": "30 秒"}}, partial=True) + self.assertTrue(serializer.is_valid(), serializer.errors) + self.assertEqual(serializer.save().params["duration"], "60 秒") + + def test_short_drama_confirm_duration_is_fixed_and_legacy_session_rebuilds(self): + from .creation_agent import apply_confirm_params, confirm_param_options + + self.conversation.preset = "短剧带货" + self.conversation.params = {**self.conversation.params, "duration": "60 秒"} + self.conversation.save(update_fields=["preset", "params", "updated_at"]) + params, rebuild = apply_confirm_params(self.conversation, {"duration": "15 秒", "model": "Seedance 2.5"}) + self.assertEqual(params["duration"], "60 秒") + self.assertFalse(rebuild) + self.assertEqual(confirm_param_options(True, fixed_duration=True)["duration"], []) + self.assertEqual([segment["duration"] for segment in plan_video_segments(60)], [30, 30]) + + self.conversation.params = {**self.conversation.params, "duration": "30 秒"} + self.conversation.save(update_fields=["params", "updated_at"]) + params, rebuild = apply_confirm_params(self.conversation, {"duration": "30 秒"}) + self.assertEqual(params["duration"], "60 秒") + self.assertTrue(rebuild) + def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self): from apps.ai.creation_presets import plot_twist_story_contract @@ -1933,6 +2013,117 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): ) self.assertEqual(fake.calls, []) + def test_natural_product_brief_uses_stated_audience_scene_and_action(self): + self._pin_product("空气炸锅") + self.conversation.preset = "短剧带货" + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + brief = ( + "使用此商品制作一条短剧带货视频,商品的适用人群是懒得下厨做饭的人以及来不及做饭的人," + "将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。" + ) + append_message(self.conversation, role="user", text=brief) + self.assertTrue(ensure_selling_point_ready_from_context(self.conversation, brief)) + items = {item["label"]: item for item in product_brief_items(self.conversation)} + self.assertEqual(items["核心卖点"]["status"], "ready") + self.assertIn("将食材放进空气炸锅", items["核心卖点"]["value"]) + self.assertEqual(items["使用场景与目标用户"]["status"], "ready") + self.assertIn("懒得下厨做饭的人", items["使用场景与目标用户"]["value"]) + self.assertIn("公司午休环境", items["使用场景与目标用户"]["value"]) + self.assertEqual(items["价格与优惠"]["status"], "not_needed") + self.assertTrue(accept_complete_product_brief(self.conversation)) + self.conversation.refresh_from_db() + self.assertTrue(self.conversation.memory["product_brief_reviewed"]) + system = build_system_prompt(AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)) + self.assertIn("懒得下厨做饭的人", system) + self.assertIn("公司午休环境", system) + + def test_product_brief_does_not_invent_missing_claims(self): + self._pin_product("空气炸锅") + self.conversation.preset = "短剧带货" + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + append_message(self.conversation, role="user", text="面向人群是上班族。价格是多少?做个短剧。") + self.assertFalse(ensure_selling_point_ready_from_context(self.conversation)) + items = {item["label"]: item for item in product_brief_items(self.conversation)} + self.assertEqual(items["核心卖点"]["status"], "missing") + self.assertEqual(items["使用场景与目标用户"]["status"], "missing") + self.assertIn("上班族", items["使用场景与目标用户"]["value"]) + self.assertEqual(items["价格与优惠"]["status"], "missing") + self.assertFalse(accept_complete_product_brief(self.conversation)) + self.assertEqual(extract_brief_context("重点讲清使用场景和目标用户"), {}) + + def test_complete_natural_brief_skips_redundant_review_in_stream(self): + self._pin_product("空气炸锅") + self.conversation.preset = "短剧带货" + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + fake = FakeProvider([_text_chunks("继续创作")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + events = _events(stream_creation_agent( + conversation=self.conversation, + user=self.user, + text=("适用人群是懒得做饭的上班族,将食材放进空气炸锅一炸就可以吃了。" + "使用环境是家庭厨房或公司午休环境。"), + model_config=self.model, + )) + interactions = [ + event["message"]["payload"].get("interaction") + for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ] + self.assertNotIn("product_brief_review", interactions) + self.conversation.refresh_from_db() + self.assertTrue(self.conversation.memory.get("product_brief_reviewed")) + + @override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local") + def test_review_card_note_is_locked_as_selling_point_before_story_choice(self): + TeamMember.objects.create(team=self.team, user=self.user, role="owner") + self._pin_product("空气炸锅") + self.conversation.preset = "短剧带货" + self.conversation.memory = {} + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + review = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, + payload={"interaction": "product_brief_review", "submitted": False, "answers": {}}, + ) + note = ( + "商品的适用人群是懒得下厨做饭的人以及来不及做饭的人," + "将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。" + ) + client = APIClient() + client.force_authenticate(self.user) + with patch("apps.ai.views.begin_agent_planning", return_value=True), \ + patch("apps.ai.views.team_agent_busy", return_value=False), \ + patch("apps.ai.views.run_creation_agent_turn_task") as task: + response = client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "elicit_answer", "reply_to": str(review.id), "answers": { + "review_action": "supplement", "product_brief_note": note, + }}, + format="json", + ) + self.assertEqual(response.status_code, 202) + self.assertTrue(task.apply_async.called) + self.conversation.refresh_from_db() + self.assertTrue(self.conversation.memory.get("selling_point_ready")) + self.assertIn("将食材放进空气炸锅", self.conversation.memory.get("selling_point", "")) + from .views import _plot_twist_direction_continuation + _plot_twist_direction_continuation( + self.conversation, + {"directions": [{"id": "office", "title": "午休误会", "conflict": "同事误会", "product_role": "空气炸锅做饭", "reversal": "误会解除"}]}, + "office", + ) + result, _ = _dispatch_tool( + AgentContext(conversation=self.conversation, user=self.user, model_config=self.model), + "write_strategy", + {"target": "上班族", "trust": "用户描述的使用动作", "belief": "操作方便", "direction": "办公室轻喜剧"}, + ) + self.assertNotEqual(result.get("payload", {}).get("field"), "selling_point") + self.assertFalse(self.conversation.messages.filter( + kind=CreationMessage.Kind.ELICIT, payload__interaction="selling_point_gate" + ).exists()) + def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self): self.conversation.preset = "剧情反转带货" self.conversation.save(update_fields=["preset", "updated_at"]) @@ -2889,7 +3080,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): portrait_asset=portrait, ) self.conversation.preset = "达人口播种草" - self.conversation.params = {**self.conversation.params, "duration": "60 秒"} + self.conversation.params = {**self.conversation.params, "duration": "60 秒", "ratio": "16:9"} self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}] self.conversation.save(update_fields=["preset", "params", "pinned_refs", "updated_at"]) card = append_message( @@ -2922,6 +3113,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertEqual(message.payload["kind"], "video_segments") + self.assertEqual(message.payload["ratio"], "16:9") self.assertEqual(submit.call_count, 2) first = submit.call_args_list[0].kwargs["params"] second = submit.call_args_list[1].kwargs["params"] @@ -2929,6 +3121,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(first["seed"], second["seed"]) self.assertEqual(first["references"][0]["asset_id"], str(portrait.id)) for params in (first, second): + self.assertEqual(params["aspect_ratio"], "16:9") self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】")) self.assertIn("【开场】这是全片的第一段", params["prompt"]) self.assertNotIn("限时8折", params["prompt"]) @@ -3141,6 +3334,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): class VideoParamParsingTests(TestCase): + def test_video_result_ratio_reads_aspect_ratio_from_task(self): + from .creation import _meta_from_task + + task = SimpleNamespace( + model_config_id=None, + request_payload={"aspect_ratio": "16:9", "prompt": "横屏视频"}, + ) + message = SimpleNamespace(payload={"ratio": "9:16"}) + self.assertEqual(_meta_from_task(task, message)["ratio"], "16:9") + def test_duration_label_and_smart_fallback(self): self.assertEqual(video_duration({"duration": "15 秒"}), 15) self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 18e3303..c09886c 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -57,6 +57,7 @@ from .creation_agent import ( is_restart_intent, multi_character_relation_needs_clarification, restore_gated_step_after_cancel, + record_product_brief_review, get_video_gate_stage, set_video_gate_stage, sync_prompt_after_cast, @@ -2087,12 +2088,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): force_creative_turn = True continuation_instruction = _plot_twist_depth_continuation(depth) elif payload.get("interaction") == "product_brief_review": - memory = dict(conversation.memory or {}) - memory["product_brief_reviewed"] = True - if text: - memory["product_brief_note"] = text - conversation.memory = memory - conversation.save(update_fields=["memory", "updated_at"]) + record_product_brief_review(conversation, text) payload["answers"] = { "review_action": "supplement" if text else "continue", "product_brief_note": text, @@ -2715,12 +2711,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): note = str(answers.get("product_brief_note") or "").strip() if action == "supplement" and not note: return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400) - memory = dict(conversation.memory or {}) - memory["product_brief_reviewed"] = True - if note: - memory["product_brief_note"] = note - conversation.memory = memory - conversation.save(update_fields=["memory", "updated_at"]) + record_product_brief_review(conversation, note) text = "" record_user_message = False force_creative_turn = True diff --git a/core/backend/apps/common/test_celery_health.py b/core/backend/apps/common/test_celery_health.py index fb250d7..8b415cb 100644 --- a/core/backend/apps/common/test_celery_health.py +++ b/core/backend/apps/common/test_celery_health.py @@ -1,3 +1,4 @@ +import os from unittest.mock import Mock, patch from django.test import SimpleTestCase, override_settings @@ -7,6 +8,24 @@ from apps.common import celery_health from apps.common.celery_health import require_worker_task, worker_consumes_queue +class RedisLocalUrlTests(SimpleTestCase): + def test_macos_maps_docker_only_host_to_local_redis_for_celery_and_django(self): + from airshelf.settings import base + + with patch.dict(os.environ, {"CELERY_BROKER_URL": "redis://:example@yingqing-redis:6379/1"}): + with patch.object(base.sys, "platform", "darwin"): + self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), "redis://127.0.0.1:6379/1") + self.assertEqual(os.environ["CELERY_BROKER_URL"], "redis://127.0.0.1:6379/1") + + def test_container_keeps_configured_redis_host(self): + from airshelf.settings import base + + configured = "redis://:example@yingqing-redis:6379/1" + with patch.dict(os.environ, {"CELERY_BROKER_URL": configured}): + with patch.object(base.sys, "platform", "linux"): + self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), configured) + + class RequireWorkerTaskTests(SimpleTestCase): def setUp(self): celery_health._registered_task_cache.clear() diff --git a/core/frontend/src/components/omni-param-bar.tsx b/core/frontend/src/components/omni-param-bar.tsx index 7230a7d..ac68d47 100644 --- a/core/frontend/src/components/omni-param-bar.tsx +++ b/core/frontend/src/components/omni-param-bar.tsx @@ -89,7 +89,7 @@ function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string) // 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。 // 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。 return modelDurations(config).some((seconds) => seconds >= 30) - || /seedance\s*2\.5/i.test(model || ""); + || normalizeModelKey(model).includes("seedance25"); } function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] { @@ -111,6 +111,7 @@ export function OmniParamBar({ resolution, ratio, duration, + fixedDuration = false, catalogModels, onModel, onResolution, @@ -123,6 +124,7 @@ export function OmniParamBar({ resolution: string; ratio: string; duration: string; + fixedDuration?: boolean; catalogModels?: ModelConfig[]; onModel: (value: string) => void; onResolution: (value: string) => void; @@ -141,10 +143,11 @@ export function OmniParamBar({ [catalogModels, model, capability], ); - const models = withCurrent( - catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS), - model, - ); + const availableModels = catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS); + const compatibleModels = fixedDuration && isVideo + ? availableModels.filter((label) => canCreateSegmentedVideo(findCatalogModel(catalogModels, label, "video"), label)) + : []; + const models = withCurrent(fixedDuration && isVideo ? compatibleModels : availableModels, model); const allowedRes = isVideo ? modelResolutions(selected) : []; const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution); const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration); @@ -200,6 +203,7 @@ export function OmniParamBar({ if (nextRes.length && resolution && !nextRes.includes(resolution)) { onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]); } + if (fixedDuration) return; const nextDur = durationLabelsForModel(selected, model, true); const seconds = Number(String(duration || "").replace(/\D/g, "")); // 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。 @@ -215,6 +219,12 @@ export function OmniParamBar({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected?.id, isVideo, model]); + useEffect(() => { + if (!fixedDuration || !isVideo || !compatibleModels.length) return; + if (!canCreateSegmentedVideo(selected, model)) onModel(compatibleModels[0]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fixedDuration, isVideo, model, selected?.id, compatibleModels.join("|")]); + return ( <> -
+ {fixedDuration ? null :
} ); } diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index 75b7a6b..89d1298 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -1249,6 +1249,11 @@ body.sidebar-collapsed .user::after { display: none; } overflow: hidden; animation: none; } +.content:has(.omni-session-page) { + /* 对话页内部已按顶栏以下的视口高度排版;通用内容 padding 会让自动滚底遮住首条消息。 */ + padding: 0; + animation: none; +} @keyframes yz-page-enter { from { opacity: 0; } to { opacity: 1; } diff --git a/core/frontend/src/omni-preset-label.ts b/core/frontend/src/omni-preset-label.ts new file mode 100644 index 0000000..3529f1c --- /dev/null +++ b/core/frontend/src/omni-preset-label.ts @@ -0,0 +1,5 @@ +/** 已保存的旧短剧会话保留原始预设值,界面统一显示新名称。 */ +export function displayOmniPresetLabel(preset?: string | null): string { + if (preset === "剧情反转带货") return "短剧带货"; + return preset || "自由创作"; +} diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 930c18c..8ce6ef4 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -431,11 +431,40 @@ } /* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */ -.omni-person-reference-flow > .omni-result-card.is-person-reference, +.omni-person-reference-flow > .omni-result-card.is-person-reference { + flex: 0 0 min(372px, 100%); + width: min(372px, 100%); + max-width: 372px; +} + +/* 角色定妆图生成时占满整条流程区域;完成后恢复为可并排的大图卡。 */ +.omni-result-card.omni-process-card.is-person-reference { + width: min(760px, calc(100% - 44px)); + max-width: 760px; + height: 400px; + display: flex; + flex-direction: column; +} + .omni-person-reference-flow > .omni-process-card.is-person-reference { - flex-basis: 360px; - width: min(360px, 100%); - max-width: 360px; + flex: 1 0 100%; + width: 100%; + max-width: 100%; +} + +.omni-process-card.is-person-reference .omni-result-media { + flex: 1; + min-height: 0; +} + +.omni-process-card.is-person-reference .omni-result-tile { + height: 100%; + aspect-ratio: auto; +} + +.omni-process-card.is-person-reference .omni-process-frame { + min-height: 0; + aspect-ratio: auto; } .omni-result-card.is-person-reference .omni-result-tile img { @@ -1535,6 +1564,11 @@ margin-left: 0; } + .omni-result-card.omni-process-card.is-person-reference { + width: 100%; + height: clamp(280px, 58vw, 400px); + } + .omni-strategy-grid, .omni-plan-points, .omni-plan-timeline, @@ -2572,27 +2606,47 @@ height: 14px; } -/* 发送中:不要文案;左侧柔和呼吸高亮,气泡本身不变形 */ -.omni-chat-row.is-user .omni-chat-stack.is-pending { - padding-left: 6px; -} - -.omni-chat-row.is-user .omni-chat-stack.is-pending::before { - content: ""; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 3px; - border-radius: 2px; - background: #5b6572; - animation: omniSendPulse 1.35s ease-in-out infinite; - pointer-events: none; -} - +/* 发送状态只留在原消息气泡内,不额外占一行。 */ .omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble { - background: color-mix(in srgb, var(--surface) 84%, transparent); - border-color: var(--black-alpha-16); + flex-direction: row; + align-items: center; + background: var(--black-alpha-4); + border-color: var(--black-alpha-12); + animation: omniPendingBubble 1.5s ease-in-out infinite; +} + +.omni-send-dots { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 3px; +} + +.omni-send-dots i { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: .35; + animation: omniSendDotPulse 1.1s ease-in-out infinite; +} + +.omni-send-dots i:nth-child(2) { animation-delay: .18s; } +.omni-send-dots i:nth-child(3) { animation-delay: .36s; } + +@keyframes omniSendDotPulse { + 0%, 70%, 100% { opacity: .35; transform: translateY(0); } + 35% { opacity: 1; transform: translateY(-3px); } +} + +@keyframes omniPendingBubble { + 0%, 100% { border-color: var(--black-alpha-12); } + 50% { border-color: var(--black-alpha-24); } +} + +@media (prefers-reduced-motion: reduce) { + .omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble, + .omni-send-dots i { animation: none; } } .omni-send-spinner { @@ -2601,45 +2655,75 @@ border-radius: 50%; } -.omni-chat-bubble.is-thinking { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 8px 10px; - color: var(--black-alpha-64); -} - -.omni-chat-bubble.is-reasoning { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 8px; - color: var(--black-alpha-64); -} - -/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */ -.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning { +.omni-chat-row.agent.is-live .omni-chat-bubble.omni-wait-card { + position: relative; width: min(620px, calc(100% - 44px)); max-width: min(620px, calc(100% - 44px)); + align-items: stretch; + gap: 0; + overflow: hidden; + padding: 18px 20px 16px; + border-radius: var(--r-md); + color: var(--black-alpha-64); +} + +.omni-wait-card::before, +.omni-wait-card::after { + position: absolute; + top: 0; + left: 0; + height: 3px; + content: ""; +} + +.omni-wait-card::before { + width: 100%; + background: linear-gradient( + 90deg, + var(--border-muted) 0%, + var(--border-muted) 35%, + var(--heat-40) 50%, + var(--border-muted) 65%, + var(--border-muted) 100% + ); + background-size: 200% 100%; + animation: omniWaitPending 2.6s linear infinite; +} + +html[data-theme="dark"] .omni-wait-card::before { + background-image: linear-gradient( + 90deg, + var(--background-base) 0%, + var(--background-base) 35%, + var(--heat-40) 50%, + var(--background-base) 65%, + var(--background-base) 100% + ); +} + +.omni-wait-card::after { + width: 100%; + background: var(--klein); + transform: scaleX(.06); + transform-origin: left center; + animation: omniWaitProgress 36s linear forwards; } .omni-think-head { - display: inline-flex; + display: flex; align-items: center; - gap: 8px; + min-height: 24px; + color: var(--accent-black); font-size: 13px; - color: var(--black-alpha-64); + font-weight: 600; } .omni-think-log { display: grid; gap: 8px; - max-height: 248px; - overflow-y: auto; - padding: 10px 8px 2px 0; + margin-top: 12px; + padding-top: 12px; border-top: 1px solid var(--border-faint); - scrollbar-gutter: stable; } .omni-think-log-row { @@ -2673,27 +2757,17 @@ word-break: break-word; } -.omni-typing { - display: inline-flex; - align-items: center; - gap: 4px; +@keyframes omniWaitProgress { + 0% { transform: scaleX(.06); } + 10% { transform: scaleX(.38); } + 45% { transform: scaleX(.66); } + 75% { transform: scaleX(.8); } + 100% { transform: scaleX(.94); } } -.omni-typing i { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--klein); - opacity: .35; - animation: omniTyping 1.1s ease-in-out infinite; -} - -.omni-typing i:nth-child(2) { animation-delay: .15s; } -.omni-typing i:nth-child(3) { animation-delay: .3s; } - -@keyframes omniTyping { - 0%, 80%, 100% { opacity: .28; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-2px); } +@keyframes omniWaitPending { + from { background-position: 100% 0; } + to { background-position: -100% 0; } } @keyframes omniCurrentStepPulse { @@ -2701,15 +2775,13 @@ 50% { opacity: 1; } } -@keyframes omniSendPulse { - 0%, 100% { - background: #5b6572; - opacity: .45; - } - 50% { - background: #8b95a3; - opacity: 1; - } +@media (prefers-reduced-motion: reduce) { + .omni-wait-card::before, + .omni-wait-card::after, + .omni-think-log-row.is-current::before { animation: none; } + .omni-wait-card::before { background: var(--border-muted); } + html[data-theme="dark"] .omni-wait-card::before { background: var(--background-base); } + .omni-wait-card::after { transform: scaleX(.6); } } .omni-process-skeleton { diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 7fb7224..0fa80e4 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -19,6 +19,7 @@ import { X, } from "lucide-react"; import { api, ApiError } from "../api"; +import { displayOmniPresetLabel } from "../omni-preset-label"; import { findCatalogModel } from "../components/omni-param-bar"; import { modelResolutions } from "../components/free-create/constants"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; @@ -46,7 +47,7 @@ type PresetItem = { const VIDEO_PRESETS: PresetItem[] = [ { name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" }, - { name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" }, + { name: "短剧带货", category: "story", mode: "video", title: "短剧带货", desc: "用完整故事线和人物冲突建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条从起因到结局完整收束的短剧带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" }, { name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" }, { name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" }, { name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" }, @@ -667,7 +668,7 @@ export function OmniCreatePage({ model, ratio, ...(outputMode === "video" - ? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration } + ? { resolution, duration: selectedCase?.name === "短剧带货" ? "60 秒" : duration } : { count: duration }), }, }) @@ -931,7 +932,7 @@ export function OmniHistoryPage({

{item.title}

{[ - item.preset || "自由创作", + displayOmniPresetLabel(item.preset), item.mode === "video" ? "视频" : "图片", item.params?.model, item.params?.ratio, diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index e6e1fb6..2735b0f 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -23,6 +23,7 @@ import { X, } from "lucide-react"; import { api } from "../api"; +import { displayOmniPresetLabel } from "../omni-preset-label"; import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar"; import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants"; import { MediaLightbox } from "../components/overlays"; @@ -1620,7 +1621,7 @@ function ElicitCard({ {selected ? (