diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 61292b1..6c5fbb3 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -116,6 +116,8 @@ class AdminUserSerializer(serializers.ModelSerializer): class AdminTaskSerializer(serializers.ModelSerializer): team_name = serializers.CharField(source="team.name", read_only=True, default=None) model_name = serializers.CharField(source="model_config.name", read_only=True, default=None) + # 不改变 AITask.task_type 的调度语义;后台展示/筛选使用来源分类。 + task_category = serializers.SerializerMethodField() cost_anomaly = serializers.SerializerMethodField() # 单任务毛利(¥):actual_cost(积分)÷汇率 − base_cost。base_cost=0(成本未知)时 None,报表侧过滤 margin_yuan = serializers.SerializerMethodField() @@ -125,11 +127,14 @@ class AdminTaskSerializer(serializers.ModelSerializer): class Meta: model = AITask fields = [ - "id", "task_type", "status", "team", "team_name", "model_name", + "id", "task_type", "task_category", "status", "team", "team_name", "model_name", "estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "reapable", "created_at", ] read_only_fields = fields + def get_task_category(self, obj) -> str: + return str(getattr(obj, "task_category", "standard") or "standard") + def get_cost_anomaly(self, obj) -> bool: return is_cost_anomaly(obj.estimated_cost, obj.actual_cost) diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index c040f74..3444d35 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -403,9 +403,10 @@ class AdminTaskMonitorTests(TestCase): ] self.assertTrue(task_selects) for sql in task_selects: - self.assertNotIn("request_payload", sql) + # 仅允许从 JSON 中提取 feature 做「全能创作」分类,不能把整份 Prompt/响应/报错读进列表页。 self.assertNotIn("response_payload", sql) self.assertNotIn("error_message", sql) + self.assertNotIn("x" * 100_000, str(response.data)) detail = self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/") self.assertEqual(detail.status_code, 200) @@ -420,6 +421,26 @@ class AdminTaskMonitorTests(TestCase): self.assertIn(str(self.t_anom.id), ids) self.assertNotIn(str(self.t_ok.id), ids) + def test_omni_create_tasks_have_separate_category_and_filter(self): + task = self.AITask.objects.create( + team=self.team, + model_config=self.mc, + task_type=self.AITask.Type.FREE_VIDEO, + status=self.AITask.Status.SUCCEEDED, + estimated_cost="1.0", + actual_cost="1.0", + idempotency_key="k-omni-create", + request_payload={"feature": "omni_create", "prompt": "完整出片指令"}, + ) + + all_rows = self.ac.get("/api/admin/tasks/").data["results"] + row = next(item for item in all_rows if item["id"] == str(task.id)) + self.assertEqual(row["task_type"], "free_video") + self.assertEqual(row["task_category"], "omni_create") + + category_rows = self.ac.get("/api/admin/tasks/?category=omni_create").data["results"] + self.assertEqual([item["id"] for item in category_rows], [str(task.id)]) + def test_cost_anomaly_flag(self): self.assertTrue(self.ac.get(f"/api/admin/tasks/{self.t_anom.id}/").data["cost_anomaly"]) self.assertFalse(self.ac.get(f"/api/admin/tasks/{self.t_ok.id}/").data["cost_anomaly"]) diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 4e1b850..c8aefd3 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -3,7 +3,7 @@ import logging from decimal import Decimal, ROUND_HALF_UP -from django.db.models import Count, F, Q +from django.db.models import Case, CharField, Count, F, Q, Value, When from rest_framework import status from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes @@ -469,6 +469,14 @@ def admin_tasks(request): qs = ( AITask.objects.select_related("team", "model_config") .defer("request_payload", "response_payload", "error_message") + # task_type 仍用于调度;用请求来源区分「全能创作」和自由视频/图片,不读取整份 Prompt。 + .annotate( + task_category=Case( + When(request_payload__feature="omni_create", then=Value("omni_create")), + default=Value("standard"), + output_field=CharField(), + ) + ) .order_by("-created_at") ) st = request.query_params.get("status") @@ -479,6 +487,9 @@ def admin_tasks(request): tt = request.query_params.get("task_type") if tt in dict(AITask.Type.choices): qs = qs.filter(task_type=tt) + category = request.query_params.get("category") + if category == "omni_create": + qs = qs.filter(request_payload__feature="omni_create") team_id = request.query_params.get("team") if team_id: qs = qs.filter(team_id=team_id) diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index ea21620..9d60a3e 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -103,10 +103,32 @@ def _message_task(message: CreationMessage): @transaction.atomic def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage: """生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。""" + 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 + memory.pop("person_source_ready", None) + conversation.memory = memory + conversation.status = CreationConversation.Status.RUNNING + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.last_active_at = timezone.now() + conversation.save(update_fields=[ + "memory", "status", "agent_status", "last_active_at", "updated_at", + ]) + append_message( + conversation, + role="assistant", + text="这次人物参考没生成成功。可以重新选择人物来源。", + payload={ + "reply_hint": "选择一种方式继续…", + "reply_options": [{"label": "重新选择人物", "text": "重新选择人物来源"}], + }, + ) + return message conversation.status = CreationConversation.Status.FAILED conversation.last_active_at = timezone.now() conversation.save(update_fields=["status", "last_active_at", "updated_at"]) @@ -141,7 +163,13 @@ def sync_generating_message(message: CreationMessage) -> bool: finish_generating_message(message, assets=assets, meta=_meta_from_task(task, message)) return True if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED): - fail_generating_message(message, task.error_message or "生成失败") + from .generation_errors import public_error_for_task + + public_error = public_error_for_task(task) + fail_generating_message( + message, + public_error.fallback_message if public_error else (task.error_message or "生成失败"), + ) return True return False @@ -187,8 +215,12 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool: None, ) if failed is not None: + from .generation_errors import public_error_for_task + number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1) - fail_generating_message(message, f"第 {number} 段生成失败:{failed.error_message or '请重试'}") + public_error = public_error_for_task(failed, operation="video_generate") + detail = public_error.fallback_message if public_error else (failed.error_message or "请重试") + fail_generating_message(message, f"第 {number} 段生成失败:{detail}") return True assets: list[dict] = [] completed_segments = 0 @@ -397,10 +429,68 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m 重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加; 这里改的只是同一次生成自己的中间态。 """ + original_payload = dict(message.payload or {}) + is_person_reference = original_payload.get("kind") == "person_reference" message.kind = CreationMessage.Kind.RESULT - message.payload = {**(message.payload or {}), **meta, "assets": assets} + 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 + + asset_id = str((assets[0] if assets else {}).get("id") or "") + asset = Asset.objects.filter( + id=asset_id, + team=conversation.team, + is_deleted=False, + purged_at__isnull=True, + ).first() + if asset is None: + return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产") + model = Model.objects.filter( + team=conversation.team, + portrait_asset=asset, + is_deleted=False, + purged_at__isnull=True, + ).first() + if model is None: + model = Model.objects.create( + team=conversation.team, + created_by=conversation.created_by, + name="平台生成出镜人物", + source=Model.Source.AI, + portrait_asset=asset, + description="由全能创作生成并锁定的视频出镜人物。", + metadata={"feature": "omni_create", "conversation_id": str(conversation.id)}, + ) + pin_refs(conversation, [{ + "type": "model", + "id": str(model.id), + "name": model.name, + "cover": (assets[0] if assets else {}).get("cover") or (assets[0] if assets else {}).get("url") or "", + }]) + memory = dict(conversation.memory or {}) + memory["person_source"] = "platform_generate" + memory["person_source_pending"] = False + memory["person_source_ready"] = True + memory["person_model_id"] = str(model.id) + conversation.memory = memory + conversation.status = CreationConversation.Status.RUNNING + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.last_active_at = timezone.now() + conversation.save(update_fields=[ + "memory", "status", "agent_status", "last_active_at", "updated_at", + ]) + append_message( + conversation, + role="assistant", + text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。", + payload={ + "reply_hint": "继续创作…", + "reply_options": [{"label": "继续创作", "text": "继续创作"}], + }, + ) + return message conversation.status = CreationConversation.Status.COMPLETED conversation.last_active_at = timezone.now() conversation.save(update_fields=["status", "last_active_at", "updated_at"]) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 2ab28ab..d14cc65 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -33,6 +33,7 @@ from .creation_presets import ( apply_image_preset_prompt, apply_plot_twist_story_contract, apply_video_preset_prompt, + is_click_swap_preset, plot_twist_story_depth, plot_twist_story_contract, preset_guidance, @@ -41,7 +42,13 @@ from .creation_presets import ( ) from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions from .models import CreationConversation, CreationMessage, ModelConfig -from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model +from .services import ( + build_provider, + enforce_no_embedded_captions, + get_default_model, + get_seed_text_model, + resolve_text_model, +) logger = logging.getLogger(__name__) @@ -54,17 +61,89 @@ MAX_BILLED_GENERATIONS = 1 # 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) # clarify → strategy → plan → prompt → confirm → done VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") +PAIN_POINT_PRESET = "痛点解决演示" +PAIN_POINT_DIRECTION_KEY = "pain_point_direction" _STEP_CONFIRM_LABELS = { "strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。", "plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。", "prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。", } +_PERSON_SOURCE_PRESETS = { + "痛点解决演示", + PLOT_TWIST_PRESET, + "达人口播种草", + "鱼眼换装", +} +_PERSON_VISUAL_RE = re.compile( + r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|" + r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)" +) + def is_plot_twist_conversation(conversation: CreationConversation) -> bool: return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET +def is_pain_point_conversation(conversation: CreationConversation) -> bool: + return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET + + +def is_pain_point_direction_payload(payload: dict) -> bool: + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + return bool(fields and str(fields[0].get("key") or "") == PAIN_POINT_DIRECTION_KEY) + + +def apply_pain_point_direction( + conversation: CreationConversation, + payload: dict, + choice: str, +) -> str: + """把已选方向直接作为本轮核心痛点/卖点,后续不再重复追问核心卖点。""" + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + options = (fields[0].get("options") or []) if fields else [] + selected = next( + ( + item for item in options + if isinstance(item, dict) + and str(item.get("value") or "") == str(choice or "") + ), + None, + ) + direction = str((selected or {}).get("label") or choice or "").strip() + memory = dict(conversation.memory or {}) + memory["pain_point_direction_ready"] = True + memory["pain_point_direction"] = direction + memory["selling_point_ready"] = True + memory["selling_point_mode"] = "manual" + memory["selling_point"] = direction + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return ( + f"商家已选择痛点方向:【{direction}】。这个选择同时就是本轮要突出的核心痛点与核心卖点。" + "现在只调用 write_strategy 写创作策略,并让痛点、正常使用过程和可见结果都围绕它展开;" + "不要再询问核心卖点,不要复述选择,不要直接写方案或出片。" + ) + + +def pain_point_direction_options_from_text(text: str) -> list[dict[str, str]]: + """模型偶尔只输出三条列表而忘记 ask_user;把列表确定性转成可点击选项。""" + items: list[str] = [] + for line in str(text or "").splitlines(): + match = re.match(r"^\s*(?:[-*+•]|[1-3][.、.)])\s*(.+?)\s*$", line) + if not match: + continue + label = re.sub(r"\*\*", "", match.group(1)).strip() + if label and label not in items: + items.append(label) + if len(items) != 3: + return [] + return [ + {"value": f"direction_{index}", "label": label} + for index, label in enumerate(items, start=1) + ] + + def active_plot_twist_story_depth(conversation: CreationConversation) -> str: """时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。""" from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or "")) @@ -260,6 +339,146 @@ def append_selling_point_gate(conversation: CreationConversation) -> CreationMes ) +def has_locked_person_reference(conversation: CreationConversation) -> bool: + """人物一致性的前提是会话里有可解析的人物 Ref。 + + 本地上传人物图由前端标记为 character,模特库则是 model;普通 asset + 不能被默认当人,否则商品图/场景图会误跳过这个闸门。 + """ + return any( + isinstance(ref, dict) and ref.get("type") in {"model", "character"} and ref.get("id") + for ref in (conversation.pinned_refs or []) + ) + + +def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool: + """需要真人/角色的视频在写策略前必须先锁定人物来源。""" + if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation): + return False + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if memory.get("person_source_ready") or memory.get("person_source_pending"): + return False + if conversation.preset in _PERSON_SOURCE_PRESETS: + return True + recent = list( + conversation.messages.order_by("-seq").values_list("text", flat=True)[:12] + ) + pending_prompt = str(memory.get("pending_video_prompt") or "") + return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent]))) + + +def append_person_source_gate(conversation: CreationConversation) -> CreationMessage: + """可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。""" + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。", + payload={ + "interaction": "person_source_gate", + "fields": [{ + "key": "person_source", + "label": "选择人物来源", + "type": "single", + "required": True, + "options": [ + {"value": "local_upload", "label": "本地上传"}, + {"value": "model_library", "label": "从模特库选择"}, + {"value": "platform_generate", "label": "平台帮忙生成"}, + ], + }], + "submitted": False, + "answers": {}, + }, + ) + + +def click_swap_sequence(conversation: CreationConversation) -> str: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return str(memory.get("click_swap_sequence") or "").strip() + + +def click_swap_needs_sequence(conversation: CreationConversation) -> bool: + """点击换款只有在商家明确款式和顺序后才能写脚本。""" + if conversation.mode != CreationConversation.Mode.VIDEO: + return False + if not is_click_swap_preset(conversation.preset): + return False + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + return not bool(memory.get("click_swap_ready") and click_swap_sequence(conversation)) + + +def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage: + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。", + payload={ + "interaction": "click_swap_sku_gate", + "fields": [{ + "key": "sku_sequence", + "label": "款式与切换顺序", + "type": "text", + "required": True, + "placeholder": "例如:黑色 → 白色 → 樱花粉", + }], + "submitted": False, + "answers": {}, + }, + ) + + +def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage: + """先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。""" + from .services import enqueue_standalone_images + + recent_user = list( + conversation.messages.filter(role=CreationMessage.Role.USER) + .order_by("-seq").values_list("text", flat=True)[:6] + ) + brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] + prompt = ( + "为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。" + "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," + "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," + "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" + ) + if conversation.preset: + prompt += f" 适配视频预设:{conversation.preset}。" + if brief: + prompt += f" 参考用户需求:{brief}" + tasks = enqueue_standalone_images( + team=conversation.team, + user=user, + prompt=prompt, + mode="model", + count=1, + ratio="portrait", + feature="omni_create", + ) + task = tasks[0] + memory = dict(conversation.memory or {}) + memory["person_source"] = "platform_generate" + memory["person_source_pending"] = True + conversation.memory = memory + conversation.status = CreationConversation.Status.RUNNING + conversation.agent_status = CreationConversation.AgentStatus.IDLE + conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"]) + return append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.GENERATING, + payload={ + "task_id": str(task.id), + "kind": "person_reference", + "prompt": prompt, + "label": "正在生成人物参考", + }, + task=task, + ) + + def _step_confirm_payload(step: str) -> dict: return { "interaction": "step_confirm", @@ -472,7 +691,9 @@ IMAGE_MODEL_BY_LABEL = { # 不能把每条片都悄悄压成 15 秒。 SMART_DURATION = 15 _SMART_DURATION_RE = re.compile( - r"(? int: memory.pop("selling_point_ready", None) memory.pop("selling_point_mode", None) memory.pop("selling_point", None) + memory.pop("pain_point_direction_ready", None) + memory.pop("pain_point_direction", None) + memory.pop("click_swap_ready", None) + memory.pop("click_swap_sequence", None) conversation.memory = memory conversation.save(update_fields=["memory", "updated_at"]) @@ -1181,6 +1462,15 @@ def confirm_param_options(is_video: bool) -> dict: } +def _normalize_confirm_duration(value) -> tuple[str, int | str]: + """确认卡只按实际秒数判断时长变化,兼容「8秒 / 8 秒」等历史格式。""" + raw = str(value or "").strip() + match = re.search(r"\d+(?:\.\d+)?", raw) + if match: + return "seconds", int(float(match.group())) + return "label", re.sub(r"\s+", "", raw).lower() + + def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]: """确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。""" current = dict(conversation.params or {}) @@ -1192,11 +1482,13 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo value = str(raw or "").strip() if not value or current.get(key) == value: continue + if key == "duration" and _normalize_confirm_duration(current.get(key)) == _normalize_confirm_duration(value): + continue current[key] = value changed = True duration_changed = ( conversation.mode == CreationConversation.Mode.VIDEO - and str(current.get("duration") or "") != old_duration + and _normalize_confirm_duration(current.get("duration")) != _normalize_confirm_duration(old_duration) and bool(str(current.get("duration") or "")) and bool(old_duration) ) @@ -1330,6 +1622,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "description": ( "写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。" "四个字段都必须写具体非空文案,禁止空字符串。" + "策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。" "调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。" "仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。" ), @@ -1357,6 +1650,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "用户确认方案后由平台展示 Prompt。" "video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、" "声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。" + "第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。" "先有已确认的 write_strategy,再调它。" ), "parameters": { @@ -1390,6 +1684,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。" "每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。" "已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。" + "人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。" ), }, }, @@ -1655,11 +1950,34 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str return ( f"{prompt.strip()}\n\n" f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。" - "仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线," + "仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物," + "不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。" + "承接上一段的动作、商品、场景和光线," "为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。" ) +def apply_person_identity_guard(prompt: str, references: list[dict]) -> str: + """把解析后的人物参考编号写进最终出片 Prompt。 + + resolve_refs 会把人物排在最前,但仍按真实位置计算 @图N,避免混入 + 场景/商品后编号指错。 + """ + indexes = [ + index for index, item in enumerate(references or [], start=1) + if isinstance(item, dict) and item.get("type") in {"model", "character"} + ] + if not indexes: + return prompt + labels = "、".join(f"参考图{index}" for index in indexes) + return ( + f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。" + "整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、" + "肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔," + "不得因镜头或光线变化而改变身份。" + ) + + def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int: """显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。""" raw = str(params.get("duration") or "") @@ -1804,6 +2122,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list active_plot_twist_story_depth(context.conversation), prompt, ) + prompt = apply_person_identity_guard(prompt, resolved.references) duration = resolve_smart_video_duration(context.conversation, prompt=prompt) # resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。 params = context.conversation.params or {} @@ -1818,6 +2137,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list "generate_audio": True, "references": resolved.references, } + if any(item.get("type") in {"model", "character"} for item in resolved.references): + # 长视频的多个任务共用同一个确定性 seed,减少两段各自随机采样导致的脸部/服装漂移。 + submit["seed"] = int(str(context.conversation.id).replace("-", "")[:8], 16) return submit, resolved.references @@ -1901,10 +2223,19 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ tasks = [] try: for segment in segments: + segment_prompt = ( + segment_video_prompt(submit["prompt"], segment, total_duration) + if len(segments) > 1 + else submit["prompt"] + ) segment_submit = { **submit, "duration": int(segment["duration"]), - "prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"], + # 长视频也必须从完整的最终 Prompt 分段。以前这里误用原始 prompt, + # 会丢掉预设约束、商品安全约束和人物锁定,是 60s 前后换人的直接原因。 + # 分段之后再执行一次统一清洗:移除模型/用户写进正文的任何屏显指令, + # 并让每个独立视频任务的首尾都带最高优先级画面洁净约束。 + "prompt": enforce_no_embedded_captions(segment_prompt), "extra_payload": { "omni_segment": { "index": segment["index"], @@ -2135,6 +2466,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。", "- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。", "- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。", + "- 安全检查前置到创作第一稿:人物默认明确为成年人,服装与构图得体;策略、方案和 video_prompt 只写改写后的正向可拍内容,不复述风险情节,不罗列平台禁用词,也不写否定式免责声明。", ]) # 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径) try: @@ -2237,6 +2569,21 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’," "不得先 write_strategy / write_plan,也不要让用户输入编号。" ) + if is_pain_point_conversation(conversation): + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + selected_direction = str(memory.get("pain_point_direction") or "").strip() + if selected_direction: + lines.append( + f"用户已选择痛点方向:【{selected_direction}】。这个方向就是已确认的核心卖点;" + "直接围绕它写策略,不得再询问核心卖点。" + ) + else: + lines.append( + "【强制下一步·痛点方向三选一】先根据商品事实与可见素材整理恰好 3 个明显不同、可被画面证明的痛点方向。" + "必须调用 ask_user:field key 固定为 pain_point_direction,type=single,options 恰好 3 项;" + "每个 label 直接写完整的‘具体困扰 + 商品正常使用后可见结果’,让用户点击即选中。" + "不得只在正文里列三条,不得要求用户回复编号,不得先 write_strategy,也不得另问核心卖点。" + ) workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else "" if workflow_guidance: lines.append(f"【当前预设的工作重点】{workflow_guidance}") @@ -2516,6 +2863,64 @@ def _coerce_strategy_args(args: dict) -> dict: } +_STRATEGY_TEXT_SECTION_ALIASES = { + "目标受众": "target", + "目标人群": "target", + "这条视频给谁看": "target", + "给谁看": "target", + "用户为什么相信": "trust", + "为什么相信": "trust", + "内容逻辑": "trust", + "可信依据": "trust", + "信任依据": "trust", + "希望用户相信什么": "belief", + "希望相信": "belief", + "核心卖点": "belief", + "核心主张": "belief", + "创作方向": "direction", + "视觉调性": "direction", + "视觉风格": "direction", + "表达方向": "direction", +} + + +def strategy_args_from_text(text: str) -> dict: + """模型漏调 write_strategy 时,把带明确栏目名的策略正文救回结构化卡片。 + + 只接受四个栏目都能识别的高置信文本,普通聊天不会被误转成策略卡。 + """ + sections = {"target": [], "trust": [], "belief": [], "direction": []} + current = "" + for raw_line in str(text or "").splitlines(): + line = re.sub(r"^\s*(?:[-*#>]+\s*)?", "", raw_line).replace("**", "").strip() + if not line: + continue + match = re.match(r"^([^::\n]{2,36})\s*[::]\s*(.*)$", line) + if match: + heading = re.sub(r"[((].*$", "", match.group(1)).strip().replace(" ", "") + key = _STRATEGY_TEXT_SECTION_ALIASES.get(heading) + if key: + current = key + content = match.group(2).strip() + if content: + sections[key].append(content) + continue + if heading in {"创作策略", "策略理解", "创作策略理解"}: + current = "" + continue + if not current: + continue + if re.match(r"^(?:你看|请确认|如果你|是否需要|可以再)", line): + continue + sections[current].append(line) + + payload = { + key: "\n".join(parts).strip() + for key, parts in sections.items() + } + return payload if all(payload.values()) else {} + + def _coerce_plan_card_args(args: dict) -> dict: usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point") points = _coerce_points( @@ -2543,6 +2948,69 @@ def _coerce_plan_card_args(args: dict) -> dict: } +def apply_click_swap_plan_card( + conversation: CreationConversation, + card: dict, + duration: int, +) -> dict: + """把模型可能写偏的方案卡收束成可核对的点击换款时间轴。""" + if not is_click_swap_preset(conversation.preset): + return card + sequence = click_swap_sequence(conversation) + if not sequence: + return card + total = max(4, min(int(duration or 0), 60)) + first_end = max(1, round(total * 0.2)) + second_end = max(first_end + 1, round(total * 0.45)) + third_end = max(second_end + 1, round(total * 0.75)) + third_end = min(third_end, total - 1) + second_end = min(second_end, third_end - 1) + return { + **card, + "usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款", + "points": [ + "固定机位、背景、光线与商品中心位置", + "每次换款都由一次清楚的手指点击触发", + f"严格按「{sequence}」逐款展示,结尾给出全款式总览", + ], + "timeline": [ + { + "start": 0, + "end": first_end, + "stage": "首款定帧", + "desc": "固定机位建立首款,商品位置、尺寸、角度和背景作为后续唯一基准。", + }, + { + "start": first_end, + "end": second_end, + "stage": "首次点击换款", + "desc": "手指清晰点击商品,接触瞬间在原位 match cut 为下一款。", + }, + { + "start": second_end, + "end": third_end, + "stage": "按序连续换款", + "desc": f"按「{sequence}」继续一触一换;机位、构图、商品比例和光线完全不变。", + }, + { + "start": third_end, + "end": total, + "stage": "全款式收束", + "desc": "保持同一构图完成全款式总览,不加入口播、剧情、换景或字幕。", + }, + ], + "matrix": { + "shots": 4, + "rows": [ + {"point": "固定构图", "hits": [1, 2, 3, 4]}, + {"point": "点击触发", "hits": [2, 3]}, + {"point": "款式顺序", "hits": [1, 2, 3, 4]}, + ], + }, + "voice_chars": [0, 0], + } + + def iter_creation_agent_events( *, conversation: CreationConversation, @@ -2633,6 +3101,27 @@ def iter_creation_agent_events( yield {"type": "done"} return + # 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。 + if click_swap_needs_sequence(conversation): + question = append_click_swap_sequence_gate(conversation) + set_video_gate_stage(conversation, "clarify") + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + yield {"type": "message", "message": _message_payload(question)} + yield {"type": "done"} + return + + # 需要真人/角色的视频必须先选定人物来源。这是平台闸门, + # 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。 + if video_needs_person_source(conversation, text): + question = append_person_source_gate(conversation) + set_video_gate_stage(conversation, "clarify") + conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER + conversation.save(update_fields=["agent_status", "updated_at"]) + yield {"type": "message", "message": _message_payload(question)} + yield {"type": "done"} + return + # 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。 requested_card = requested_asset_card_from_context(conversation, text) if requested_card: @@ -2748,6 +3237,26 @@ def iter_creation_agent_events( "asset_types": ["product"], }] + # 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条 + # 转成单选按钮;不允许用户再手抄一遍,也不进入重复的核心卖点闸门。 + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + if ( + not calls + and fallback_fields is None + and allow_plan + and is_pain_point_conversation(conversation) + and not memory.get("selling_point_ready") + ): + pain_options = pain_point_direction_options_from_text(said) + if pain_options: + fallback_fields = [{ + "key": PAIN_POINT_DIRECTION_KEY, + "label": "选择这条视频要重点解决的痛点", + "type": "single", + "required": True, + "options": pain_options, + }] + # 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具, # 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。 memory = conversation.memory if isinstance(conversation.memory, dict) else {} @@ -2770,6 +3279,32 @@ def iter_creation_agent_events( turn_has_gate = True break + # 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文, + # 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门, + # 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。 + current_stage = get_video_gate_stage(conversation) + may_write_strategy = ( + current_stage == "clarify" + or (current_stage == "strategy" and not bool(memory.get("strategy_confirmed"))) + ) + prose_strategy = ( + strategy_args_from_text(said) + if not calls and context.is_video and may_write_strategy + else {} + ) + if prose_strategy: + result, _stop = _dispatch_tool(context, "write_strategy", prose_strategy) + for event in result.get("_events", []): + yield event + if event.get("type") == "message": + msg = event.get("message") or {} + if msg.get("kind") in ( + CreationMessage.Kind.ELICIT, + CreationMessage.Kind.CONFIRM, + ): + turn_has_gate = True + break + # ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再 # 另存一条,否则界面会连续出现两遍几乎相同的问题。 asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls) @@ -3024,6 +3559,8 @@ def _guided_elicit_text(field: dict) -> str: return label if str(field.get("key") or "") in SESSION_PARAM_KEYS: return label + if field.get("type") == "single" and field.get("options"): + return f"{label} 直接点击一个选项,也可以输入自己的想法。" return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。" @@ -3137,7 +3674,34 @@ def _dispatch_tool( return {"payload": _run_search_library(context, args)}, False if name == "write_strategy": + if context.is_video and click_swap_needs_sequence(context.conversation): + gate = append_click_swap_sequence_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "sku_sequence"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True + if context.is_video and video_needs_person_source( + context.conversation, + json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False), + ): + gate = append_person_source_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "person_source"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {} + if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"): + return { + "payload": { + "error": ( + "痛点解决演示必须先调用 ask_user 展示痛点方向三选一:" + "key=pain_point_direction、type=single、恰好 3 个 options。" + "用户点击后该方向会直接成为核心卖点,不要另开卖点确认卡。" + ) + } + }, False if context.is_video and not memory.get("selling_point_ready"): gate = append_selling_point_gate(context.conversation) set_video_gate_stage(context.conversation, "clarify") @@ -3180,7 +3744,26 @@ def _dispatch_tool( video_prompt = str(args.get("video_prompt") or "").strip() if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + if context.is_video and click_swap_needs_sequence(context.conversation): + gate = append_click_swap_sequence_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "sku_sequence"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True + if context.is_video and video_needs_person_source(context.conversation, video_prompt): + gate = append_person_source_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "person_source"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) + if is_click_swap_preset(context.conversation.preset): + video_prompt = ( + f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n" + "只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。" + ) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) video_prompt = apply_video_platform_safety_guard(video_prompt) @@ -3218,6 +3801,7 @@ def _dispatch_tool( prompt=video_prompt, timeline=card["timeline"], ) + card = apply_click_swap_plan_card(context.conversation, card, duration) lo = max(20, round(duration * 3.4)) hi = max(lo + 1, round(duration * 4)) plan_payload = { @@ -3225,7 +3809,11 @@ def _dispatch_tool( "points": card["points"], "timeline": card["timeline"], "matrix": card["matrix"], - "voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]), + "voice_chars": ( + [0, 0] + if is_click_swap_preset(context.conversation.preset) + else _coerce_voice_chars(card["voice_chars"], [lo, hi]) + ), "ref_count": len(context.conversation.pinned_refs or []), } events = [] @@ -3251,6 +3839,13 @@ def _dispatch_tool( video_prompt = get_pending_video_prompt(context.conversation) if not video_prompt: return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False + if context.is_video and click_swap_needs_sequence(context.conversation): + gate = append_click_swap_sequence_gate(context.conversation) + set_video_gate_stage(context.conversation, "clarify") + return { + "payload": {"asked": True, "field": "sku_sequence"}, + "_events": [{"type": "message", "message": _message_payload(gate)}], + }, True video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 4d352ac..ecf469e 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -8,6 +8,14 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat """ from __future__ import annotations +CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) +_CLICK_SWAP_GUIDANCE = ( + "固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," + "画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。" + "每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。" + "禁止改成口播、剧情、使用教程、多场景展示、换人或商品飞舞变形;最后才用同一构图做全款式收束。" +) + VIDEO_PRESETS: dict[str, str] = { "痛点解决演示": ( "真实问题解决型短片。按「具体痛点 → 商品自然登场 → 正常使用步骤 → 可见结果 → 轻行动引导」推进。" @@ -47,15 +55,10 @@ VIDEO_PRESETS: dict[str, str] = { "鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**," "只有服装在变。每次换装用一个明确动作触发。" ), - "多色商品换款": ( - "先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚。" - "统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式。" - ), + "点击换款": _CLICK_SWAP_GUIDANCE, + "多色商品换款": _CLICK_SWAP_GUIDANCE, # 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。 - "点触换款": ( - "先识别并整理不同颜色、款式或 SKU,确保每款的比例、位置、外观与顺序清楚。" - "统一构图和机位,用点击/触碰或一个明确动作触发换款;背景、光线、机位全程稳定,最后用全家福镜头覆盖全部款式。" - ), + "点触换款": _CLICK_SWAP_GUIDANCE, "探店漫游": ( "以空间动线串联:入口 → 环境 → 关键细节 → 服务/主推项目。" "镜头连续移动有路线感,不要碎切。" @@ -181,8 +184,9 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", "鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。", - "多色商品换款": "优先识别每个颜色或款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致。", - "点触换款": "优先识别每个颜色或款式及其顺序;生成前核对所有 SKU 都已进入时间轴,比例、位置和机位保持一致。", + "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", + "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", + "点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", "探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。", "品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。", "前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。", @@ -224,13 +228,26 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。" "每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。" ), + "点击换款": ( + "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" + "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" + "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" + "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" + ), "多色商品换款": ( - "【预设执行层·多色商品换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。" - "每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。" + "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" + "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" + "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" + "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), "点触换款": ( - "【预设执行层·点触换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。" - "每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。" + "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" + "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" + "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" + "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), "探店漫游": ( "【预设执行层·探店漫游】用一条连续空间动线讲清入口、环境、关键细节和主推服务/商品。" @@ -293,6 +310,10 @@ def video_preset_delivery_contract(name: str) -> str: return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "") +def is_click_swap_preset(name: str) -> bool: + return (name or "").strip() in CLICK_SWAP_PRESETS + + def apply_video_preset_prompt(name: str, prompt: str) -> str: """把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。""" base = str(prompt or "").strip() @@ -303,6 +324,17 @@ def apply_video_preset_prompt(name: str, prompt: str) -> str: marker = f"【视频预设】{preset}" if marker in base: return base + if is_click_swap_preset(preset): + # 换款是镜头结构,不是可有可无的风格词。把强制执行层放在前面, + # 原始 Agent Prompt 只作为商品/SKU 事实来源;其中若有口播、剧情、运镜或换场景等拍法,不得执行。 + return ( + f"{marker}\n{contract}\n\n" + "【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;" + "下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n" + f"{base}\n\n" + "【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发," + "切换前后商品中心点、尺寸、角度、背景、光线和机位不变。" + ) return f"{base}\n\n{marker}\n{contract}" diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index f4dce1b..35a9b9c 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -367,13 +367,12 @@ def build_content_items( # @label 替换:按 label 长度降序,防子串吞噬 ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True) api_prompt = _format_prompt_for_ark(prompt, ordered) - # 自由创作 / 全能创作是整段一次出片(没有「第 2 段」),开场先验强 —— - # 挂上与专业创作首镜相同的正面洁净指令;最终 enforce_no_embedded_captions 还会改写口播并挂禁令。 - from .services import OPENING_SHOT_DIRECTIVE, rewrite_speech_as_audio_only + # 在素材编号替换完成后就做最终字幕清洗,不等 provider 层才处理。 + # 这样全能创作的每个 60s 分段、审核重提交和路由备选模型都共用同一份 + # 「纯实拍 + 开场洁净 + 口播仅音频」Prompt;provider 层仍会幂等再校验一次。 + from .services import enforce_no_embedded_captions - if OPENING_SHOT_DIRECTIVE not in api_prompt: - api_prompt = f"{OPENING_SHOT_DIRECTIVE}\n{api_prompt}" - api_prompt = rewrite_speech_as_audio_only(api_prompt) + api_prompt = enforce_no_embedded_captions(api_prompt) return { "content_items": content_items, diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index e94d972..483a98b 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -921,6 +921,7 @@ NO_EMBEDDED_CAPTIONS_REQUIREMENT = ( "画面里能看到的文字只有一种 —— 参考图中商品包装、标签上本来就印着的那些," "保持与参考图一致,不放大、不重排、不新增。" "人物说的话通过口型和声音表达,不写到画面上;不要字幕,也不要任何贴片、浮层或界面元素。" + "每一帧必须保持屏幕洁净;若出现商品原包装以外的任何可读字符,本次输出即为失败。" ) # 结尾再补一句极短的正面复述:末位权重高,但只用「叠加」这类中性词,不再重复负面名词。 @@ -1007,7 +1008,14 @@ def enforce_no_embedded_captions(prompt: str) -> str: base = strip_caption_directives(base).strip() # 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。 # 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。 - return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip() + # 上一版会在清理时移除 OPENING_SHOT_DIRECTIVE,却没有把它拼回最终 Prompt, + # 相当于丢了专门压制「电商口播开场自动打大字」的关键约束。 + return ( + f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n" + f"{OPENING_SHOT_DIRECTIVE}\n" + f"{base}\n" + f"{NO_EMBEDDED_CAPTIONS_TAIL}" + ).strip() def execute_routed_video_submit( diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 23830dc..1159606 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -10,10 +10,10 @@ from django.test import SimpleTestCase, TestCase, override_settings from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User -from apps.assets.models import Asset, AssetFile +from apps.assets.models import Asset, AssetFile, Model as AssetModel from apps.products.models import Product, ProductImage -from .creation import append_message +from .creation import append_message, finish_generating_message from .views import _typed_step_confirm_action from .creation_agent import ( AgentContext, @@ -24,7 +24,9 @@ from .creation_agent import ( _coerce_fields, _image_count, _merge_tool_call_deltas, + apply_pain_point_direction, apply_restart_intent, + apply_person_identity_guard, active_plot_twist_story_depth, build_system_prompt, build_messages, @@ -46,6 +48,7 @@ from .creation_agent import ( strip_numeric_reply_instruction, submit_confirmed_image, submit_confirmed_video, + submit_generated_person_reference, text_already_guides, tool_schemas, video_duration, @@ -274,7 +277,7 @@ class AskUserTests(CreationAgentBaseTests): self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat") self.assertEqual( elicit[0]["message"]["text"], - "想要什么调性? 直接用一句话告诉我就行,不用整理成完整需求。", + "想要什么调性? 直接点击一个选项,也可以输入自己的想法。", ) self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2) # 反问必须中断循环,否则模型会自问自答 @@ -450,6 +453,80 @@ class SseFramingTests(CreationAgentBaseTests): self.assertIn(str(task.id), encoded) +class PersonReferenceCompletionTests(CreationAgentBaseTests): + def test_platform_person_generation_becomes_a_locked_model_reference(self): + task = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.PERSON_IMAGE, + model_config=self.model, + idempotency_key="k-person-reference", + ) + generating = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.GENERATING, + payload={"kind": "person_reference", "task_id": str(task.id)}, + task=task, + ) + asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="AI 出镜人物", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.MODEL_PORTRAIT, + origin_task=task, + ) + AssetFile.objects.create( + asset=asset, + object_key="person-generated.jpg", + bucket="test", + preview_url="https://cdn.example/person-generated.jpg", + 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"}, + ) + + self.conversation.refresh_from_db() + generating.refresh_from_db() + self.assertEqual(generating.kind, CreationMessage.Kind.RESULT) + self.assertEqual(self.conversation.status, CreationConversation.Status.RUNNING) + self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.AWAITING_USER) + model_ref = next(ref for ref in self.conversation.pinned_refs if ref.get("type") == "model") + self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists()) + self.assertTrue(self.conversation.memory["person_source_ready"]) + self.assertTrue( + self.conversation.messages.filter(text__contains="人物参考已生成并锁定").exists() + ) + + def test_platform_person_submission_uses_model_mode(self): + task = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.PERSON_IMAGE, + model_config=self.model, + idempotency_key="k-person-submit", + ) + with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue: + message = submit_generated_person_reference(conversation=self.conversation, user=self.user) + + self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) + self.assertEqual(message.payload["kind"], "person_reference") + self.assertEqual(enqueue.call_args.kwargs["mode"], "model") + self.assertEqual(enqueue.call_args.kwargs["count"], 1) + self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create") + + class SendEndpointTests(TestCase): def setUp(self): self.user = User.objects.create_user(username="send-owner", password="p") @@ -465,6 +542,99 @@ class SendEndpointTests(TestCase): response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json") self.assertEqual(response.status_code, 400) + def test_person_source_upload_is_pinned_before_agent_continues(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.save(update_fields=["mode", "updated_at"]) + portrait = Asset.objects.create( + team=self.team, + created_by=self.user, + name="本地上传人物", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.UPLOAD, + ) + AssetFile.objects.create( + asset=portrait, + object_key="uploaded-person.jpg", + bucket="test", + preview_url="https://cdn.example/uploaded-person.jpg", + is_primary=True, + ) + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="先确定出镜人物", + payload={ + "interaction": "person_source_gate", + "fields": [{"key": "person_source", "type": "single"}], + "submitted": False, + "answers": {}, + }, + ) + + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": str(card.id), + "answers": {"person_source": "local_upload"}, + "refs": [{ + "type": "character", + "id": str(portrait.id), + "name": portrait.name, + "cover": "https://cdn.example/uploaded-person.jpg", + }], + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + card.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(card.payload["submitted"]) + self.assertTrue(self.conversation.memory["person_source_ready"]) + self.assertTrue(any( + ref.get("type") == "character" and str(ref.get("id")) == str(portrait.id) + for ref in self.conversation.pinned_refs + )) + + def test_click_swap_sequence_answer_is_saved_before_agent_continues(self): + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "点击换款" + self.conversation.save(update_fields=["mode", "preset", "updated_at"]) + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="确认款式顺序", + payload={ + "interaction": "click_swap_sku_gate", + "fields": [{"key": "sku_sequence", "type": "text"}], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("开始整理点击换款策略")]) + + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + { + "kind": "elicit_answer", + "reply_to": str(card.id), + "answers": {"sku_sequence": "黑色 → 白色 → 樱花粉"}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + card.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(card.payload["submitted"]) + self.assertTrue(self.conversation.memory["click_swap_ready"]) + self.assertEqual(self.conversation.memory["click_swap_sequence"], "黑色 → 白色 → 樱花粉") + def test_natural_step_confirmation_phrases_continue_instead_of_revising(self): for text in ("这个还行", "这种还行", "可以", "就这样", "没问题", "按这个继续", "好,继续"): with self.subTest(text=text): @@ -911,7 +1081,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): def test_plot_twist_always_displays_three_direction_cards(self): self.conversation.preset = "剧情反转带货" self.conversation.params = {**self.conversation.params, "duration": "30 秒"} - self.conversation.save(update_fields=["preset", "params", "updated_at"]) + self.conversation.memory = {"person_source_ready": True} + self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"]) # 即使模型只输出一句空话,平台也必须补上三个可点击方向,而不是让用户继续追问。 fake = FakeProvider([_text_chunks("我准备了三个剧情反转方向。")]) @@ -933,6 +1104,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(len(directions), 3) self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions)) + def test_pain_point_list_becomes_clickable_and_choice_is_the_selling_point(self): + product = Product.objects.create(team=self.team, created_by=self.user, title="舒缓面霜") + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "痛点解决演示" + self.conversation.memory = {"person_source_ready": True} + self.conversation.pinned_refs = [{"type": "product", "id": str(product.id), "name": product.title}] + self.conversation.save(update_fields=["mode", "preset", "memory", "pinned_refs", "updated_at"]) + fake = FakeProvider([_text_chunks( + "可以从下面三个方向选一个:\n" + "- 换季干燥紧绷,正常涂抹后保持舒适不拔干\n" + "- 空调房久坐脸颊不适,薄涂后肤感更柔润\n" + "- 妆前容易卡粉,按正常用量涂开后底妆更服帖" + )]) + + 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, + )) + + card = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ) + field = card["payload"]["fields"][0] + self.assertEqual(field["key"], "pain_point_direction") + self.assertEqual(len(field["options"]), 3) + self.assertIn("直接点击一个选项", card["text"]) + self.assertIn("pain_point_direction", fake.calls[0]["messages"][0]["content"]) + + stored = CreationMessage.objects.get(id=card["id"]) + selected = field["options"][0] + continuation = apply_pain_point_direction( + self.conversation, + stored.payload, + selected["value"], + ) + self.conversation.refresh_from_db() + memory = self.conversation.memory or {} + self.assertTrue(memory.get("selling_point_ready")) + self.assertEqual(memory.get("selling_point"), selected["label"]) + self.assertIn("不要再询问核心卖点", continuation) + def test_image_tool_is_hidden_and_video_tools_offered(self): fake = FakeProvider([_text_chunks("先聊聊")]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): @@ -945,7 +1161,11 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertNotIn("generate_image", names) def test_strategy_card_stops_with_step_confirm(self): - self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"} + self.conversation.memory = { + "selling_point_ready": True, + "selling_point_mode": "auto", + "person_source_ready": True, + } self.conversation.save(update_fields=["memory", "updated_at"]) fake = FakeProvider([ _tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", @@ -968,7 +1188,61 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): # 策略闸门必须停下等人确认,不能同轮连写方案 self.assertEqual(len(fake.calls), 1) + def test_plain_strategy_prose_is_recovered_as_strategy_card(self): + self.conversation.preset = "痛点解决演示" + self.conversation.memory = { + "selling_point_ready": True, + "selling_point_mode": "manual", + "selling_point": "温和不刺激", + "pain_point_direction_ready": True, + "pain_point_direction": "温和不刺激", + "person_source_ready": True, + } + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + fake = FakeProvider([_text_chunks( + "创作策略:小熊婴儿湿巾痛点短片\n" + "核心卖点:温和清洁,不会泛红干涩。\n" + "目标受众:家有 0-3 岁宝宝的年轻宝妈。\n" + "内容逻辑(严格遵循痛点解决路径):\n" + "1. 痛点钩子:普通湿巾摩擦后皮肤泛红。\n" + "2. 商品登场:展示棉花般柔软质地。\n" + "3. 使用实证:擦拭过程轻柔服帖。\n" + "视觉调性:明亮柔和的居家治愈风。\n" + "你看这个方向是否合适?" + )]) + 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, + )) + + cards = [ + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "strategy" + ] + self.assertEqual(len(cards), 1) + self.assertEqual(cards[0]["payload"]["target"], "家有 0-3 岁宝宝的年轻宝妈。") + self.assertIn("使用实证", cards[0]["payload"]["trust"]) + self.assertEqual(cards[0]["payload"]["belief"], "温和清洁,不会泛红干涩。") + self.assertEqual(cards[0]["payload"]["direction"], "明亮柔和的居家治愈风。") + self.assertFalse(any( + event.get("type") == "message" + and event["message"]["role"] == "assistant" + and event["message"]["kind"] == "text" + for event in events + )) + confirm = next( + event["message"] for event in events + if event.get("type") == "message" + and event["message"]["kind"] == "elicit" + ) + self.assertEqual(confirm["payload"].get("step"), "strategy") + def test_selling_point_is_confirmed_before_strategy(self): + self.conversation.memory = {"person_source_ready": True} + self.conversation.save(update_fields=["memory", "updated_at"]) fake = FakeProvider([ _tool_chunks("write_strategy", {"target": "油皮通勤人群", "trust": "真实使用反馈", "belief": "值得一试", "direction": "达人 UGC 口播"}), @@ -1010,6 +1284,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): # 方案闸门停下,不能同轮出 Prompt/积分卡 self.assertEqual(len(fake.calls), 1) + def test_click_swap_requires_sequence_before_calling_model(self): + 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, + )) + + gate = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ) + self.assertEqual(gate["payload"].get("interaction"), "click_swap_sku_gate") + self.assertEqual(gate["payload"]["fields"][0]["key"], "sku_sequence") + self.assertEqual(fake.calls, []) + + def test_click_swap_plan_overrides_conflicting_generic_script(self): + self.conversation.preset = "点击换款" + self.conversation.memory = { + "click_swap_ready": True, + "click_swap_sequence": "黑色 → 白色 → 樱花粉", + "person_source_ready": True, + } + self.conversation.save(update_fields=["preset", "memory", "updated_at"]) + fake = FakeProvider([_tool_chunks("write_plan", self._plan_args( + usp="多场景故事感展示", + points=["人物口播", "移动运镜"], + timeline=[{"start": 0, "end": 15, "stage": "剧情口播"}], + video_prompt="达人走进三个不同场景,一边口播一边展示商品", + ))]) + + 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, + )) + + plan = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "plan" + ) + self.assertIn("黑色 → 白色 → 樱花粉", plan["payload"]["usp"]) + self.assertEqual(plan["payload"]["voice_chars"], [0, 0]) + self.assertEqual( + [item["stage"] for item in plan["payload"]["timeline"]], + ["首款定帧", "首次点击换款", "按序连续换款", "全款式收束"], + ) + self.conversation.refresh_from_db() + stored = self.conversation.memory["pending_video_prompt"] + self.assertTrue(stored.startswith("【视频预设】点击换款")) + self.assertIn("【商家确认的换款顺序】黑色 → 白色 → 樱花粉", stored) + self.assertIn("口播片、剧情片", stored) + def test_manual_selling_point_is_locked_as_plan_usp(self): self.conversation.memory = { "selling_point_ready": True, @@ -1051,7 +1386,11 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): """模型若同轮连调 write_strategy+write_plan,只落策略闸门。""" from apps.ai.creation_agent import _parse_arguments # noqa: F401 - self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"} + self.conversation.memory = { + "selling_point_ready": True, + "selling_point_mode": "auto", + "person_source_ready": True, + } self.conversation.save(update_fields=["memory", "updated_at"]) # FakeProvider 一轮里多个 tool_call:模拟两个独立 rounds 不行,需单轮多 call。 @@ -1085,6 +1424,103 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertNotIn("plan", kinds) self.assertEqual(len(fake.calls), 1) + def test_person_video_requires_a_source_before_the_provider_runs(self): + self.conversation.preset = "达人口播种草" + self.conversation.save(update_fields=["preset", "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, + )) + + card = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ) + self.assertEqual(card["payload"]["interaction"], "person_source_gate") + self.assertEqual( + [option["value"] for option in card["payload"]["fields"][0]["options"]], + ["local_upload", "model_library", "platform_generate"], + ) + self.assertEqual(fake.calls, []) + + def test_sixty_second_segments_reuse_full_prompt_and_same_person_reference(self): + portrait = Asset.objects.create( + team=self.team, + created_by=self.user, + name="锁定女主", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, + category=Asset.Category.MODEL_PORTRAIT, + review_status="active", + review_remote_id="person-ref-1", + ) + AssetFile.objects.create( + asset=portrait, + object_key="person.jpg", + bucket="test", + preview_url="https://cdn.example/person.jpg", + is_primary=True, + ) + model = AssetModel.objects.create( + team=self.team, + created_by=self.user, + name="锁定女主", + portrait_asset=portrait, + ) + self.conversation.preset = "达人口播种草" + self.conversation.params = {**self.conversation.params, "duration": "60 秒"} + 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( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.CONFIRM, + payload={ + "video_prompt": "女主在日常场景分享真实使用体验\n字幕:限时8折", + "submitted": False, + }, + ) + tasks = [ + AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.FREE_VIDEO, + model_config=self.model, + idempotency_key=f"k-person-60-{index}", + status=AITask.Status.SUCCEEDED, + ) + for index in (1, 2) + ] + + with patch("apps.ai.free_video.submit_free_video", side_effect=tasks) as submit: + message, error = submit_confirmed_video( + conversation=self.conversation, + user=self.user, + confirm_message=card, + ) + + self.assertEqual(error, "") + self.assertEqual(message.payload["kind"], "video_segments") + self.assertEqual(submit.call_count, 2) + first = submit.call_args_list[0].kwargs["params"] + second = submit.call_args_list[1].kwargs["params"] + self.assertEqual(first["references"], second["references"]) + self.assertEqual(first["seed"], second["seed"]) + self.assertEqual(first["references"][0]["asset_id"], str(portrait.id)) + for params in (first, second): + self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】")) + self.assertIn("【开场】这是全片的第一段", params["prompt"]) + self.assertNotIn("限时8折", params["prompt"]) + self.assertEqual(params["prompt"].count("字幕"), 1) + self.assertIn("【人物一致性硬约束】", params["prompt"]) + self.assertIn("【预设执行层·达人口播种草】", params["prompt"]) + self.assertIn("必须继续使用参考图锁定的同一位人物", params["prompt"]) + def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self): fake = FakeProvider([ _tool_chunks("write_plan", self._plan_args(video_prompt="")), @@ -1114,7 +1550,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) - self.assertTrue(params["prompt"].startswith("0-3秒 近景手持商品…")) + self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】")) + self.assertIn("0-3秒 近景手持商品…", params["prompt"]) self.assertIn("不出现破损、漏液、渗水", params["prompt"]) # 顶栏参数直接用,label 要翻成火山真名 self.assertEqual(params["model"], "doubao-seedance-2-5-260628") @@ -1142,8 +1579,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertIsNotNone(message) self.assertIn("【视频预设】多色商品换款", prompt) - self.assertIn("每个颜色/SKU 都要清晰出现", prompt) - self.assertIn("最后给出全部款式同框总览", prompt) + self.assertIn("【预设执行层·点击换款·强制】", prompt) + self.assertIn("手指轻触/点击商品", prompt) + self.assertIn("不演剧情", prompt) + self.assertIn("原位 match cut", prompt) def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): self.conversation.preset = "商品拟人广告" @@ -1188,7 +1627,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(error, "") self.assertIsNotNone(message) - self.assertTrue(prompt.startswith(original)) + self.assertIn(original, prompt) self.assertIn("不出现破损、漏液、渗水", prompt) def test_confirm_without_stored_prompt_reports_instead_of_submitting(self): @@ -1223,6 +1662,15 @@ class VideoParamParsingTests(TestCase): self.assertEqual(infer_script_duration(timeline=timeline), 20) self.assertEqual(video_duration({"duration": "智能时长"}, timeline=timeline), 20) + def test_age_range_is_not_mistaken_for_video_duration(self): + prompt = ( + "人声是18-22岁软甜少女音,语气自然。\n" + "「0-3 秒」开场展示第一套穿搭。\n" + "「12-15 秒」人物定格收束。" + ) + self.assertEqual(infer_script_duration(prompt=prompt), 15) + self.assertEqual(video_duration({"duration": "智能时长"}, prompt=prompt), 15) + def test_long_video_is_split_into_seedance_sized_segments(self): self.assertEqual( plan_video_segments(45, [{"end": 14}, {"end": 25}, {"end": 45}]), @@ -1234,6 +1682,15 @@ class VideoParamParsingTests(TestCase): self.assertEqual(plan_video_segments(60)[0]["duration"], 30) self.assertEqual(plan_video_segments(60)[1]["duration"], 30) + def test_person_identity_guard_uses_real_reference_indexes(self): + prompt = apply_person_identity_guard("base", [ + {"type": "model", "url": "person-a"}, + {"type": "model", "url": "person-b"}, + {"type": "product", "url": "product"}, + ]) + self.assertIn("参考图1、参考图2", prompt) + self.assertNotIn("参考图3定义本片的固定出镜人物", prompt) + def test_model_label_maps_to_volcano_name(self): self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128") self.assertEqual(video_model_name({"model": "没见过的模型"}), DEFAULT_VIDEO_MODEL) @@ -1334,6 +1791,33 @@ class ConfirmEndpointTests(TestCase): self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p") self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8) + def test_confirm_ignores_duration_spacing_when_model_changes(self): + self.conversation.params = { + "model": "Seedance 2.0 Fast", "resolution": "480p", + "ratio": "9:16", "duration": "8秒", + } + self.conversation.save(update_fields=["params"]) + provider = ModelProvider.objects.create(name="fk-duration-format", display_name="F", base_url="https://x") + model = ModelConfig.objects.create( + provider=provider, name="fk-video-duration-format", display_name="V", + capability=ModelConfig.Capability.VIDEO, + ) + task = AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO, + model_config=model, idempotency_key="k-duration-format", + ) + with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit: + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "confirm", "reply_to": str(self.card.id), + "params": {"duration": "8 秒", "model": "Seedance 2.5", + "resolution": "720p", "ratio": "9:16"}}, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertFalse(response.json().get("regenerate")) + submit.assert_called_once() + class MemoryCompressionTests(CreationAgentBaseTests): """长会话记忆压缩(契约 §5)。""" @@ -1420,6 +1904,7 @@ class PresetGuidanceTests(CreationAgentBaseTests): def test_preset_guidance_reaches_the_system_prompt(self): conversation = CreationConversation.objects.create( team=self.team, created_by=self.user, mode="video", preset="鱼眼换装", params={}, + memory={"person_source_ready": True}, ) fake = FakeProvider([_text_chunks("好")]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): @@ -1475,8 +1960,9 @@ class PresetGuidanceTests(CreationAgentBaseTests): system = build_system_prompt(context) self.assertIn("当前预设的工作重点", system) - self.assertIn("所有 SKU 都已进入时间轴", system) - self.assertIn("比例、位置和机位保持一致", system) + self.assertIn("确认颜色/款式/SKU 和切换顺序", system) + self.assertIn("固定机位下手指逐次点击", system) + self.assertIn("禁止转成剧情或口播", system) def test_video_preset_is_injected_into_the_actual_generation_prompt(self): from .creation_presets import apply_video_preset_prompt @@ -1488,6 +1974,21 @@ class PresetGuidanceTests(CreationAgentBaseTests): # 出片确认、重试等多次经过此处,规则只写一次。 self.assertEqual(apply_video_preset_prompt("鱼眼换装", prompt), prompt) + def test_click_swap_preset_overrides_conflicting_shooting_method(self): + from .creation_presets import apply_video_preset_prompt + + prompt = apply_video_preset_prompt( + "点击换款", + "三款耳机是黑色、白色、樱花粉;达人边走边口播,在多个场景切换展示。", + ) + + self.assertTrue(prompt.startswith("【视频预设】点击换款")) + self.assertIn("【预设执行层·点击换款·强制】", prompt) + self.assertIn("原始商品与 SKU 信息", prompt) + self.assertIn("冲突的拍法一律忽略", prompt) + self.assertIn("每一次换款都必须由画面内手指的一次清晰点击触发", prompt) + self.assertEqual(apply_video_preset_prompt("点击换款", prompt), prompt) + def test_every_video_preset_has_a_delivery_contract(self): from .creation_presets import VIDEO_PRESETS, VIDEO_PRESET_DELIVERY_CONTRACTS @@ -1514,11 +2015,31 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertNotIn("暴打", prompt) self.assertNotIn("鲜血直流", prompt) self.assertNotIn("药到病除", prompt) - self.assertIn("安全、非暴力方式化解", prompt) + self.assertNotIn("血腥", prompt) + self.assertNotIn("自残", prompt) + self.assertNotIn("裸体", prompt) + self.assertNotIn("毒品", prompt) + self.assertNotIn("武器", prompt) + self.assertNotIn("政治敏感", prompt) + self.assertIn("通过沟通与日常误会化解", prompt) self.assertIn("真实、可观察的日常使用体验", prompt) # 方案卡、Prompt 文件、最终提交会多次经过这层,规则只能追加一次。 self.assertEqual(apply_video_platform_safety_guard(prompt), prompt) + def test_video_platform_safety_guard_normalizes_ambiguous_young_person_copy(self): + from .creation_agent import apply_video_platform_safety_guard + + prompt = apply_video_platform_safety_guard( + "18-22岁软甜少女音,甜妹穿短裙站在固定低角度鱼眼机位前。" + ) + + self.assertNotIn("18-22岁", prompt) + self.assertNotIn("少女", prompt) + self.assertNotIn("甜妹", prompt) + self.assertNotIn("低角度鱼眼机位", prompt) + self.assertIn("成年女性", prompt) + self.assertIn("正面鱼眼机位", prompt) + def test_video_system_prompt_requires_audit_safe_script(self): conversation = CreationConversation.objects.create( team=self.team, created_by=self.user, mode="video", params={}, @@ -1527,6 +2048,10 @@ class PresetGuidanceTests(CreationAgentBaseTests): system = build_system_prompt(AgentContext(conversation=conversation, user=self.user, model_config=self.model)) self.assertIn("平台安全优先于戏剧冲突", system) + self.assertIn("审核安全必须在第一次生成时完成", system) + self.assertIn("人物只写「成年女性 / 成年男性 / 成年人」", system) + self.assertIn("不要把平台风险类别、禁用词", system) + self.assertIn("否定式免责声明", system) self.assertIn("最终 video_prompt 必须是可直接提交审核和出片的安全版本", system) def test_image_preset_style_is_added_only_to_the_actual_image_prompt(self): @@ -1551,7 +2076,8 @@ class PresetGuidanceTests(CreationAgentBaseTests): """视频和图片预设都要有拍法,漏一个就等于那张卡是摆设。""" from .creation_presets import IMAGE_PRESETS, VIDEO_PRESETS - self.assertEqual(len(VIDEO_PRESETS), 14) + # 「点击换款」是当前前端名称;同时保留「多色商品换款 / 点触换款」兼容旧会话。 + self.assertEqual(len(VIDEO_PRESETS), 15) self.assertEqual(len(IMAGE_PRESETS), 12) self.assertTrue(all(text.strip() for text in {**VIDEO_PRESETS, **IMAGE_PRESETS}.values())) @@ -1940,6 +2466,29 @@ class ReplyGuidanceTests(CreationAgentBaseTests): self.assertEqual(labels, ["补充颜色和顺序", "上传各款实物图", "先按当前主款做"]) self.assertNotIn("改卖点", labels) + def test_reply_options_follow_final_detail_and_color_prompt(self): + options = default_reply_options( + self.conversation, + has_context=True, + is_video=True, + assistant_text=( + "这个方向会用年轻女生手部展示商品,不会抢商品风头。\n\n" + "你要是有想额外突出的细节,或者想调换配色顺序,现在直接告诉我就行。" + ), + ) + labels = [option["label"] for option in options] + self.assertEqual(labels, ["补充突出细节", "调整配色顺序", "按当前描述继续"]) + self.assertNotIn("发商品列表", labels) + + def test_incidental_product_mention_does_not_create_product_picker_actions(self): + options = default_reply_options( + self.conversation, + has_context=True, + is_video=True, + assistant_text="人物只做手部展示,商品保持真实结构。按这个方向继续就行。", + ) + self.assertEqual(options, []) + def test_unknown_context_question_does_not_get_fixed_actions(self): options = default_reply_options( self.conversation, diff --git a/core/backend/apps/ai/test_creation_conversation.py b/core/backend/apps/ai/test_creation_conversation.py index bc754af..3d084be 100644 --- a/core/backend/apps/ai/test_creation_conversation.py +++ b/core/backend/apps/ai/test_creation_conversation.py @@ -147,7 +147,27 @@ class GenerationBackfillTests(TestCase): self.assertEqual(sync_generating_messages(self.conversation), 1) message.refresh_from_db() self.assertEqual(message.kind, CreationMessage.Kind.ERROR) - self.assertIn("额度不足", message.text) + self.assertIn("积分不足", message.text) + + def test_sensitive_text_failure_is_shown_as_safe_chinese_message(self): + task = self._task(AITask.Status.FAILED, key="k-sensitive-text") + task.task_type = AITask.Type.FREE_VIDEO + task.error_code = "InputTextSensitiveContentDetected" + task.error_message = ( + "The request failed because the input text 'content[0]' may contain sensitive information. " + "Request id: provider-secret-reference" + ) + task.save(update_fields=["task_type", "error_code", "error_message"]) + message = append_message( + self.conversation, role="assistant", kind=CreationMessage.Kind.GENERATING, + payload={"task_id": str(task.id), "kind": "video"}, task=task, + ) + + self.assertEqual(sync_generating_messages(self.conversation), 1) + message.refresh_from_db() + self.assertEqual(message.kind, CreationMessage.Kind.ERROR) + self.assertIn("内容未通过生成审核", message.text) + self.assertNotIn("Request id", message.text) def test_running_task_stays_generating(self): task = self._task(AITask.Status.RESERVED, key="k-run") diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index f457dee..b23469c 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -87,6 +87,18 @@ class BuildContentItemsTests(TestCase): self.user = User.objects.create_user(username="fvowner", password="p") self.team = Team.objects.create(name="FV", owner=self.user) + def test_final_api_prompt_strips_requested_caption_copy(self): + built = build_content_items( + team=self.team, + prompt="人物对镜头说话\n字幕:今天限时8折", + mode="universal", + references=[], + ) + + self.assertNotIn("今天限时8折", built["api_prompt"]) + self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】")) + self.assertEqual(built["api_prompt"].count("字幕"), 1) + def test_label_replacement_length_desc_no_substring_swallow(self): refs = [ {"url": "http://x/a.png", "type": "image", "label": "碧"}, @@ -94,7 +106,8 @@ class BuildContentItemsTests(TestCase): ] built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs) # 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」 - self.assertEqual(built["api_prompt"], "图片2 拥抱 图片1") + self.assertIn("图片2 拥抱 图片1", built["api_prompt"]) + self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】")) def test_counters_match_content_items(self): refs = [ @@ -105,7 +118,8 @@ class BuildContentItemsTests(TestCase): built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs) self.assertEqual(built["image_n"], 2) self.assertEqual(built["video_n"], 1) - self.assertEqual(built["api_prompt"], "图片1 图片2 视频1") + self.assertIn("图片1 图片2 视频1", built["api_prompt"]) + self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】")) roles = [i.get("role") for i in built["content_items"]] self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"]) self.assertEqual(built["video_duration_total"], 3.0) diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py index 45b2b4a..4a03b24 100644 --- a/core/backend/apps/ai/test_free_video_asset_ref.py +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -99,7 +99,8 @@ class AssetReferenceBuildTests(TestCase): built = self._build(asset) self.assertEqual(built["image_n"], 1) self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png") - self.assertEqual(built["api_prompt"], "图片1 走过来") + self.assertIn("图片1 走过来", built["api_prompt"]) + self.assertTrue(built["api_prompt"].startswith("【画面洁净 · 最高优先级】")) def test_registered_asset_uses_volcano_asset_scheme(self): """已登记火山素材库的走 asset://,写实人脸传直链会被拒。""" diff --git a/core/backend/apps/ai/test_video_caption_policy.py b/core/backend/apps/ai/test_video_caption_policy.py index 6427728..88dc847 100644 --- a/core/backend/apps/ai/test_video_caption_policy.py +++ b/core/backend/apps/ai/test_video_caption_policy.py @@ -19,6 +19,7 @@ class VideoCaptionPolicyTests(SimpleTestCase): self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT)) # 首帧最容易被加标题页 self.assertTrue(prompt.endswith(NO_EMBEDDED_CAPTIONS_TAIL)) # 末位权重高 + self.assertEqual(prompt.count(OPENING_SHOT_DIRECTIVE), 1) self.assertIn("一位用户在厨房使用商品", prompt) def test_rule_is_never_duplicated(self): @@ -43,6 +44,7 @@ class VideoCaptionPolicyTests(SimpleTestCase): """裸摆一段台词时模型会把它当成要渲染的画面文本 —— 必须讲明只出声。""" self.assertIn("口型", NO_EMBEDDED_CAPTIONS_REQUIREMENT) self.assertIn("不写到画面上", NO_EMBEDDED_CAPTIONS_REQUIREMENT) + self.assertIn("本次输出即为失败", NO_EMBEDDED_CAPTIONS_REQUIREMENT) def test_strips_caption_field_lines(self): cleaned = strip_caption_directives("字幕:限时8折\n0-3s:近景;平视;女主抬眼") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 0cfb3fc..0bfcfb6 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from apps.assets.models import Asset +from apps.assets.models import Asset, Model as AssetModel from apps.assets.serializers import AssetFileSerializer, AssetSerializer from apps.common.api import TeamScopedViewSetMixin, get_current_team from apps.common.celery_health import require_worker, require_worker_task @@ -29,6 +29,7 @@ from .creation import ( begin_agent_planning, cleanup_stale_agent_planning, finish_agent_planning, + pin_refs, request_agent_cancel, start_segmented_video_merge, sync_generating_messages, @@ -37,6 +38,7 @@ from .creation import ( from .creation_agent import ( _ASSET_CARD_LABELS, _RESTART_CONTINUATION, + apply_pain_point_direction, apply_confirm_params, apply_restart_intent, apply_session_params, @@ -44,11 +46,14 @@ from .creation_agent import ( emit_final_confirm_gate, set_plot_twist_story_depth, is_greeting, + is_pain_point_conversation, + is_pain_point_direction_payload, is_restart_intent, restore_gated_step_after_cancel, set_video_gate_stage, submit_confirmed_image, submit_confirmed_video, + submit_generated_person_reference, ) from .tasks import run_creation_agent_turn_task from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions @@ -269,6 +274,20 @@ def _plot_twist_direction_continuation( ) +def _store_click_swap_sequence(conversation: CreationConversation, value: str) -> str: + sequence = str(value or "").strip() + memory = dict(conversation.memory or {}) + memory["click_swap_ready"] = True + memory["click_swap_sequence"] = sequence + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + return ( + f"商家已确认点击换款顺序:【{sequence}】。" + "严格使用固定机位、同一背景和同一商品中心位置,每次由一根手指清晰点击后原位切换到下一款。" + "现在只调用 write_strategy 写创作策略;禁止改成口播、剧情、换场景或普通使用演示。" + ) + + _STEP_CONTINUE_INSTRUCTIONS = { "strategy": ( "用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);" @@ -1893,6 +1912,18 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): continuation_instruction = _plot_twist_direction_continuation( conversation, payload, choice ) + elif payload.get("interaction") == "click_swap_sku_gate": + sequence = text.strip() + if sequence: + payload["answers"] = {"sku_sequence": sequence} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _store_click_swap_sequence(conversation, sequence) elif payload.get("phase") == "gate": pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)] primary_field = pending_fields[0] if pending_fields else {} @@ -2034,10 +2065,19 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): existing.add(mark) force_creative_turn = True - continuation_instruction = ( - "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" - "不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。" - ) + if is_pain_point_conversation(conversation) and is_pain_point_direction_payload(payload): + field_key = str(field.get("key") or "pain_point_direction") + choice = str(answers.get(field_key) or "").strip() + text = "" + record_user_message = False + continuation_instruction = apply_pain_point_direction( + conversation, payload, choice + ) + else: + continuation_instruction = ( + "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" + "不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。" + ) if params_changed: continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" @@ -2132,6 +2172,46 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): return JsonResponse({"detail": "请选择自己填写卖点或系统推荐"}, status=400) if mode == "manual" and not selling_point: return JsonResponse({"detail": "请先填写一个真实卖点,或选择系统推荐"}, status=400) + if payload.get("interaction") == "person_source_gate": + source = str(answers.get("person_source") or "").strip() + if source not in {"local_upload", "model_library", "platform_generate"}: + return JsonResponse({"detail": "请选择本地上传、模特库或平台生成"}, status=400) + if source == "local_upload": + candidates = [ + ref for ref in refs + if isinstance(ref, dict) and ref.get("type") == "character" and ref.get("id") + ] + valid = any( + Asset.objects.filter( + team=conversation.team, + id=ref.get("id"), + is_deleted=False, + purged_at__isnull=True, + ).exists() + for ref in candidates + ) + if not valid: + return JsonResponse({"detail": "请先上传一张人物参考图"}, status=400) + elif source == "model_library": + candidates = [ + ref for ref in refs + if isinstance(ref, dict) and ref.get("type") == "model" and ref.get("id") + ] + valid = any( + AssetModel.objects.filter( + team=conversation.team, + id=ref.get("id"), + is_deleted=False, + purged_at__isnull=True, + ).exists() + for ref in candidates + ) + if not valid: + return JsonResponse({"detail": "请先从模特库选择一位人物"}, status=400) + if payload.get("interaction") == "click_swap_sku_gate": + sequence = str(answers.get("sku_sequence") or "").strip() + if not sequence: + return JsonResponse({"detail": "请填写要展示的款式和切换顺序"}, status=400) payload["answers"] = answers payload["submitted"] = True card.payload = payload @@ -2161,6 +2241,18 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): continuation_instruction = _plot_twist_direction_continuation( conversation, payload, choice ) + elif is_pain_point_conversation(conversation) and is_pain_point_direction_payload(payload): + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + field_key = str((fields[0] if fields else {}).get("key") or "pain_point_direction") + choice = str(answers.get(field_key) or "").strip() + if not choice: + return JsonResponse({"detail": "请选择一个痛点方向"}, status=400) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = apply_pain_point_direction( + conversation, payload, choice + ) elif payload.get("interaction") == "selling_point_gate": mode = str(answers.get("selling_point_mode") or "").strip().lower() selling_point = str(answers.get("selling_point") or "").strip() @@ -2180,6 +2272,54 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;" "从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。" ) + elif payload.get("interaction") == "click_swap_sku_gate": + sequence = str(answers.get("sku_sequence") or "").strip() + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = _store_click_swap_sequence(conversation, sequence) + elif payload.get("interaction") == "person_source_gate": + source = str(answers.get("person_source") or "").strip() + if source == "platform_generate": + try: + generating = submit_generated_person_reference( + conversation=conversation, + user=request.user, + ) + except ValueError as exc: + # 生图没提交成功,把闸门放回去供用户换方式或重试。 + payload["submitted"] = False + payload["answers"] = {} + card.payload = payload + card.save(update_fields=["payload", "updated_at"]) + return JsonResponse({"detail": str(exc)}, status=400) + return JsonResponse({ + "conversation_id": str(conversation.id), + "agent_status": conversation.agent_status, + "messages": [CreationMessageSerializer(generating).data], + }, status=202) + + # 上传/模特库的人物立即进入实体锁定,不等 Celery turn 开始才保存。 + # 这样即使用户刷新,后续 60s 两段也仍能取到同一张人物图。 + person_refs = [ + ref for ref in refs + if isinstance(ref, dict) and ref.get("type") in {"character", "model"} and ref.get("id") + ] + pin_refs(conversation, person_refs) + memory = dict(conversation.memory or {}) + memory["person_source"] = source + memory["person_source_ready"] = True + memory["person_source_pending"] = False + conversation.memory = memory + conversation.status = CreationConversation.Status.RUNNING + conversation.save(update_fields=["memory", "status", "updated_at"]) + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = ( + "用户已选定出镜人物,该人物已作为整条视频的固定身份参考。" + "直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。" + ) elif payload.get("phase") == "gate": choice = str(answers.get("_asset_gate") or "").strip() if choice == "send": diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index 7c42880..1ef0fef 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -110,6 +110,8 @@ description: > - **未成年人绝不生成角色资产或画面主体**:`type:"character"` 只能是明确的成年人,禁止婴儿、宝宝、幼儿、儿童、小朋友、未成年人及任何 `0–17 岁` 人物。 婴幼儿/儿童用品也一样:用商品平铺、包装、材质、尺寸、功能细节、成人手部或成年照护者的局部演示表达;不得写儿童出镜、试穿、坐卧、拿着商品或作为镜头主体。 旁白可以说明适用年龄与使用场景,但不能把儿童变成要生成的角色/画面。 +- **审核安全从第一稿完成**:没有用户锁定的人物参考时,角色统一写明确的成年女性、成年男性或成年人,不自创年轻年龄区间,不使用容易产生幼态联想的人设、称呼或音色。服装保持日常得体,机位优先平视、自然俯拍或尊重主体的正面构图,不强调身体局部。 +- **输出只写正向可拍内容**:如果输入含有不适合直接出片的情节,先在内部保留其情绪功能和真实商品卖点,改成成年人之间积极、友善的日常互动。不要在 JSON、`visual_prompt`、`visual`、旁白或对白中复述原措辞,也不要罗列平台风险类别、禁用词、否定式免责声明或审核说明。 - **`visual` 固定使用导演层级**(四栏,逐栏写满,缺栏视为不合格): `【本镜任务】` 这一段要完成的情绪 / 信息变化 → `【光线氛围】` 光源方向与性质(窗光 / 顶光 / 台灯 / 逆光)、色温冷暖、明暗对比、整体色调 → diff --git a/core/backend/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md index 6021651..3174261 100644 --- a/core/backend/skills/ecommerce-video-script/references/checklist.md +++ b/core/backend/skills/ecommerce-video-script/references/checklist.md @@ -47,6 +47,8 @@ - [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。 - [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。 - [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。 +- [ ] **人物审核安全**:所有画面主体均明确为成年人;没有自创年轻年龄区间、幼态化称呼或音色;服装和构图日常得体,不强调身体局部。 +- [ ] **正向描述检查**:产物只写改写后的可拍内容,没有复述原始风险情节,没有罗列平台风险类别、禁用词、否定式免责声明或审核说明。 ## C. 旁白红线扫描(逐镜) diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index 178cbea..12ab113 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -72,6 +72,7 @@ 只在视频镜头里与人物同框,避免人物图把真实包装改掉。 - **儿童用品不生成儿童角色**:任何未成年人(婴儿、宝宝、幼儿、儿童、小朋友、0–17 岁)都不能作为 `character` 或视频的画面主体。 这类商品改用商品平铺、材质/包装/尺寸细节和成人手部或成年照护者局部演示;适用年龄只写在旁白和商品信息中,不把儿童写成要生图的人物。 +- **人物描述默认审核友好**:没有锁定参考人物时,只写明确成年人,不自创年轻年龄区间或幼态化称呼;服装日常得体,镜头以平视、自然俯拍和尊重主体的正面构图为主。遇到不适合直接出片的输入,先在内部改成积极、友善的日常互动,产物只保留改写后的可拍内容,不复述原措辞,也不写风险词清单或否定式免责声明。 - `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`、**至少 1 个 `scene`**。 - **场景与镜头一一对应(可复用)**:**每个 segment 的 `entity_refs` 必须恰好引用一个 `scene`**——它就是这一镜画面所处的环境。多镜同环境就复用同一个 scene id(如全程宿舍 → 只一个「宿舍书桌」scene,4 镜都引用它);真正换了地点才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(无明确环境则复用主场景)。这样下游「场景」基础资产能成图、且同环境背景一致。 - `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 8167ea1..55c1caa 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -1262,10 +1262,11 @@ export const adminApi = { { method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) } ); }, - tasks(params?: { status?: string; task_type?: string; team?: string; anomaly?: string; page?: number; page_size?: number }) { + tasks(params?: { status?: string; task_type?: string; category?: "omni_create"; team?: string; anomaly?: string; page?: number; page_size?: number }) { const qs = new URLSearchParams(); if (params?.status) qs.set("status", params.status); if (params?.task_type) qs.set("task_type", params.task_type); + if (params?.category) qs.set("category", params.category); if (params?.team) qs.set("team", params.team); if (params?.anomaly) qs.set("anomaly", params.anomaly); if (params?.page) qs.set("page", String(params.page)); diff --git a/core/frontend/src/components/omni-param-bar.tsx b/core/frontend/src/components/omni-param-bar.tsx index d33e8c5..a23a993 100644 --- a/core/frontend/src/components/omni-param-bar.tsx +++ b/core/frontend/src/components/omni-param-bar.tsx @@ -24,6 +24,19 @@ function withCurrent(values: string[], current: string) { return current && !values.includes(current) ? [current, ...values] : values; } +/** 同一秒数允许「8秒 / 8 秒」等历史格式共存,不因展示格式触发参数变更。 */ +export function normalizeDurationValue(value: string): string { + const raw = String(value || "").trim(); + const seconds = raw.match(/\d+(?:\.\d+)?/)?.[0]; + if (seconds) return `seconds:${Number(seconds)}`; + return `label:${raw.replace(/\s+/g, "").toLowerCase()}`; +} + +function includesDuration(values: string[], current: string): boolean { + const normalized = normalizeDurationValue(current); + return values.some((value) => normalizeDurationValue(value) === normalized); +} + function modelOptionLabel(config: ModelConfig): string { return (config.display_name || config.name || "").trim(); } @@ -187,7 +200,7 @@ export function OmniParamBar({ const seconds = Number(String(duration || "").replace(/\D/g, "")); const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model); // 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。 - if (duration && duration !== "智能时长" && !nextDur.includes(duration) && !isPlannedSegmentedDuration) { + if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) { onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长")); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 00b9cdd..1fed823 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -2612,6 +2612,29 @@ cursor: not-allowed; } +/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */ +.omni-chat-choice-actions { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +.omni-chat-choice-actions button { + width: 100%; + min-height: 40px; + padding: 9px 12px; + text-align: left; + line-height: 1.55; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.omni-chat-choice-actions button:hover:not(:disabled) { + border-color: var(--heat-40); + color: var(--heat); + background: var(--heat-12); +} + .omni-gate-answered { margin: 0; font-size: 12px; diff --git a/core/frontend/src/routes/admin/admin-tasks.tsx b/core/frontend/src/routes/admin/admin-tasks.tsx index dfd5291..c54ab6f 100644 --- a/core/frontend/src/routes/admin/admin-tasks.tsx +++ b/core/frontend/src/routes/admin/admin-tasks.tsx @@ -53,6 +53,12 @@ function attemptKind(attempt: AdminTaskDetail["attempts"][number]) { return "首次调用"; } +function taskTypeLabel(task: AdminTask) { + return task.task_category === "omni_create" + ? `全能创作 · ${task.task_type}` + : task.task_type; +} + export function AdminTasksPage({ notify }: { notify: Notify }) { const [tasks, setTasks] = useState([]); const [count, setCount] = useState(0); @@ -60,6 +66,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { const [page, setPage] = useState(1); const [tab, setTab] = useState(""); const [anomalyOnly, setAnomalyOnly] = useState(false); + const [omniOnly, setOmniOnly] = useState(false); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [busy, setBusy] = useState(false); @@ -67,7 +74,13 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { const load = useCallback(async () => { setLoading(true); try { - const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page, page_size: PAGE_SIZE }); + const res = await adminApi.tasks({ + status: tab || undefined, + category: omniOnly ? "omni_create" : undefined, + anomaly: anomalyOnly ? "1" : undefined, + page, + page_size: PAGE_SIZE, + }); if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; } setTasks(res.results); setCount(res.count); @@ -77,7 +90,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, anomalyOnly, page]); + }, [tab, anomalyOnly, omniOnly, page]); useEffect(() => { void load(); }, [load]); @@ -160,6 +173,9 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { + {loading ? ( @@ -175,7 +191,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) { {tasks.map((t) => ( - {t.task_type} + {taskTypeLabel(t)} {t.team_name || } {t.model_name || } {statusPill(t.status)} diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 96db6ea..c6cb34a 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -43,7 +43,7 @@ const VIDEO_PRESETS: PresetItem[] = [ { 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" }, + { name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" }, { name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" }, ]; @@ -398,6 +398,7 @@ export function OmniCreatePage({ disabled={starting || uploading} onClick={() => { const text = prompt.trim(); + const creationBrief = text || selectedCase?.starter || ""; if (!text && !selectedCase && pendingRefs.length === 0) { onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设"); return; @@ -407,7 +408,7 @@ export function OmniCreatePage({ // 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0) void api .createCreation({ - title: (text || selectedCase?.title || "未命名创作").slice(0, 20), + title: (creationBrief || selectedCase?.title || "未命名创作").slice(0, 20), mode: outputMode, preset: selectedCase?.name || "", params: { @@ -420,7 +421,7 @@ export function OmniCreatePage({ }) .then((conversation) => { // 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑 - navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs, firstUploads: sessionUploads }); + navigate("omniSession", { conversationId: conversation.id, firstMessage: creationBrief, firstRefs: pendingRefs, firstUploads: sessionUploads }); }) .catch((error) => { const status = (error as { status?: number }).status; diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index d5a7a75..f0b7864 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -23,7 +23,7 @@ import { X, } from "lucide-react"; import { api } from "../api"; -import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar"; +import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar"; import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants"; import { MediaLightbox } from "../components/overlays"; import type { @@ -153,8 +153,33 @@ function numberedReplyOptions(text: string): ReplyOption[] { })); } +function replyGuidanceText(text: string): string { + const paragraphs = (text || "") + .split(/\n+/) + .map((part) => part.trim()) + .filter(Boolean); + const source = paragraphs.at(-1) || text || ""; + const sentences = source + .split(/(?<=[。!?!?])/) + .map((part) => part.trim()) + .filter(Boolean); + return (sentences.slice(-2).join("") || source).slice(-240); +} + function contextualReplyOptions(text: string): ReplyOption[] { - if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(text)) { + // 只判断回复末尾真正交给用户决定的内容。创作描述本身经常同时出现商品、人物、 + // 场景和颜色;扫描整段会把正文里的普通名词误判成下一步操作。 + const guidance = replyGuidanceText(text); + const asksForDetail = /(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|(?:细节|重点|卖点).{0,12}(?:突出|强调)/i.test(guidance); + const asksForColorOrder = /配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色/i.test(guidance); + if (asksForDetail && asksForColorOrder) { + return [ + { label: "补充突出细节", text: "我想补充需要额外突出的细节" }, + { label: "调整配色顺序", text: "我想调整配色的展示顺序" }, + { label: "按当前描述继续", text: "没有其他调整,按当前描述继续" }, + ]; + } + if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(guidance)) { return [ { label: "1", text: "选择第 1 位人物出镜" }, { label: "2", text: "选择第 2 位人物出镜" }, @@ -163,42 +188,46 @@ function contextualReplyOptions(text: string): ReplyOption[] { } // 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」—— // 这会让用户误以为刚上传的参考没有生效。 - if (/实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i.test(text)) { + const productReference = /实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i; + const asksForProductReference = + /(?:上传|提供|补充|补一张|发一张|给我|需要|最好|建议).{0,24}(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计)/i.test(guidance) + || /(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计).{0,24}(?:上传|提供|补充|发来|参考|需要)/i.test(guidance); + if (productReference.test(guidance) && asksForProductReference) { return [ { label: "上传商品实物图", text: "我上传商品实物图", action: "upload" }, { label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" }, { label: "换一个商品", text: "我想换一个商品来创作" }, ]; } - if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) { + if (/颜色|色号|色彩|配色|SKU|款式|几种|展示顺序/i.test(guidance)) { return [ { label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" }, { label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" }, { label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" }, ]; } - if (/商品|产品|主推|哪款/i.test(text)) { + if (/(?:哪款|哪个|什么).{0,8}(?:商品|产品)|(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)/i.test(guidance)) { return [ { label: "发商品列表", text: "把商品列表发给我选" }, { label: "我直接说商品名", text: "我直接告诉你商品名" }, { label: "你来推荐", text: "你根据当前需求推荐一款" }, ]; } - if (/人物|角色|模特|出镜/i.test(text)) { + if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) { return [ { label: "上传人物图", text: "我上传人物参考图", action: "upload" }, { label: "由你设定角色", text: "你先帮我设定一个合适的角色" }, { label: "不需要人物", text: "这条先不需要人物出镜" }, ]; } - if (/场景|地点|背景|在哪/i.test(text)) { + if (/(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)/i.test(guidance)) { return [ { label: "上传场景图", text: "我上传场景参考图", action: "upload" }, { label: "你来推荐场景", text: "你按商品和预设推荐场景" }, { label: "用干净日常场景", text: "先用干净自然的日常场景" }, ]; } - if (/卖点|功能|效果|优惠|价格/i.test(text)) { + if (/(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)/i.test(guidance)) { return [ { label: "补充真实卖点", text: "我来补充商品真实卖点" }, { label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" }, @@ -745,6 +774,59 @@ function strategyField(payload: Record, ...keys: string[]): str return ""; } +const STRATEGY_TEXT_SECTION_ALIASES: Record = { + 目标受众: "target", + 目标人群: "target", + 这条视频给谁看: "target", + 给谁看: "target", + 用户为什么相信: "trust", + 为什么相信: "trust", + 内容逻辑: "trust", + 可信依据: "trust", + 信任依据: "trust", + 希望用户相信什么: "belief", + 希望相信: "belief", + 核心卖点: "belief", + 核心主张: "belief", + 创作方向: "direction", + 视觉调性: "direction", + 视觉风格: "direction", + 表达方向: "direction", +}; + +/** 兼容历史上漏调 write_strategy、已经落库成普通文字的策略消息。 */ +function strategyPayloadFromText(text: string): Record | null { + const sections: Record<"target" | "trust" | "belief" | "direction", string[]> = { + target: [], trust: [], belief: [], direction: [], + }; + let current: keyof typeof sections | "" = ""; + for (const rawLine of String(text || "").split("\n")) { + const line = rawLine.replace(/^\s*(?:[-*#>]+\s*)?/, "").replace(/\*\*/g, "").trim(); + if (!line) continue; + const match = line.match(/^([^::\n]{2,36})\s*[::]\s*(.*)$/); + if (match) { + const heading = match[1].replace(/[((].*$/, "").replace(/\s+/g, "").trim(); + const key = STRATEGY_TEXT_SECTION_ALIASES[heading]; + if (key) { + current = key; + const content = match[2].trim(); + if (content) sections[key].push(content); + continue; + } + if (["创作策略", "策略理解", "创作策略理解"].includes(heading)) { + current = ""; + continue; + } + } + if (!current || /^(?:你看|请确认|如果你|是否需要|可以再)/.test(line)) continue; + sections[current].push(line); + } + const payload = Object.fromEntries( + Object.entries(sections).map(([key, lines]) => [key, lines.join("\n").trim()]), + ) as Record; + return Object.values(payload).every(Boolean) ? payload : null; +} + function StrategyCard({ payload }: { payload: Record }) { const items: Array<[string, string]> = [ ["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")], @@ -1043,6 +1125,7 @@ function ElicitCard({ disabled, onSubmit, onChatAnswer, + onPersonSourceAction, }: { message: CreationMessage; disabled: boolean; @@ -1051,6 +1134,8 @@ function ElicitCard({ onSubmit: (answers: Record, refs: CreationRef[]) => void; /** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */ onChatAnswer: (text: string) => void; + /** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */ + onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void; }) { const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean); const submitted = Boolean(message.payload.submitted); @@ -1105,6 +1190,51 @@ function ElicitCard({ ); } + if (interaction === "person_source_gate") { + const selected = String(saved.person_source || ""); + const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label; + return ( +
+ + + +
+ + {!submitted ? ( +
+ + + +
+ ) : ( +

+ {selectedLabel ? `已选择:${selectedLabel}` : "人物来源已确定"} +

+ )} +
+
+ ); + } + if (interaction === "selling_point_gate") { const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : ""; const savedMode = String(saved.selling_point_mode || ""); @@ -1314,6 +1444,12 @@ function ElicitCard({ if (interaction === "chat") { const question = message.text || fields[0]?.label || "这项你想怎么定?"; + const choiceField = fields.find( + (field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0, + ); + const savedChoice = choiceField ? String(saved[choiceField.key] || "") : ""; + const savedChoiceLabel = choiceField?.options?.find((option) => option.value === savedChoice)?.label; + const savedChoiceText = savedChoiceLabel || savedChoice; const submitChatAnswer = (event: FormEvent) => { event.preventDefault(); const text = chatAnswer.trim(); @@ -1332,6 +1468,20 @@ function ElicitCard({ {!submitted ? (
+ {choiceField ? ( +
+ {(choiceField.options || []).map((option) => ( + + ))} +
+ ) : null}
+ ) : savedChoiceText ? ( +

已选择:{savedChoiceText}

) : null} @@ -1485,9 +1637,13 @@ function ConfirmCard({ const payloadParams = asStringMap(message.payload.params); const snapshot = { ...sessionParams, ...payloadParams }; const [draft, setDraft] = useState(snapshot); + const [initialDuration] = useState(snapshot.duration || ""); const cardIsVideo = message.payload.kind !== "image" && isVideo; const durationChanged = - cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration; + cardIsVideo + && Boolean(draft.duration) + && Boolean(initialDuration) + && normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration); const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, "")); const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60; const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value })); @@ -2055,6 +2211,11 @@ export function OmniSessionPage({ const fileInputRef = useRef(null); // 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。 const quickUploadReplyRef = useRef(null); + // 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。 + const personSourceRequestRef = useRef<{ + messageId: string; + source: "local_upload" | "model_library"; + } | null>(null); const composerRef = useRef(null); const [streaming, setStreaming] = useState(false); const [liveText, setLiveText] = useState(""); @@ -2716,6 +2877,36 @@ export function OmniSessionPage({ }, [mentionMenuOpen]); const insertMention = (ref: CreationRef) => { + const personRequest = personSourceRequestRef.current; + if (personRequest) { + if (ref.type !== "model" && ref.type !== "character") { + notify("info", "请选择一位模特或角色"); + return; + } + personSourceRequestRef.current = null; + setMentionMenuOpen(false); + setMessages((prev) => + prev.map((item) => + item.id === personRequest.messageId + ? { + ...item, + payload: { + ...item.payload, + submitted: true, + answers: { person_source: "model_library" }, + }, + } + : item + ) + ); + void send({ + kind: "elicit_answer", + reply_to: personRequest.messageId, + answers: { person_source: "model_library" }, + refs: [ref], + }); + return; + } // 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图 if (pendingRefs.some((r) => r.id === ref.id)) { setMentionMenuOpen(false); @@ -2782,6 +2973,41 @@ export function OmniSessionPage({ void send({ kind: "elicit_answer", reply_to: message.id, answers, refs }); }} onChatAnswer={(text) => void send({ kind: "text", text })} + onPersonSourceAction={(source) => { + if (source === "local_upload") { + personSourceRequestRef.current = { messageId: message.id, source }; + quickUploadReplyRef.current = null; + setMentionMenuOpen(false); + setUploadMenuOpen(false); + fileInputRef.current?.click(); + return; + } + if (source === "model_library") { + personSourceRequestRef.current = { messageId: message.id, source }; + void openMentions("", "model"); + return; + } + setMessages((prev) => + prev.map((item) => + item.id === message.id + ? { + ...item, + payload: { + ...item.payload, + submitted: true, + answers: { person_source: source }, + }, + } + : item + ) + ); + void send({ + kind: "elicit_answer", + reply_to: message.id, + answers: { person_source: source }, + refs: [], + }); + }} /> ); case "strategy": @@ -2844,6 +3070,12 @@ export function OmniSessionPage({ const rawBody = stripMentionText(message.text, refs); const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody; const pending = message.id === pendingUserId; + const legacyStrategy = message.role === "assistant" && message.kind === "text" + ? strategyPayloadFromText(body) + : null; + if (legacyStrategy) { + return ; + } const showReplyGuide = message.role === "assistant" && message.kind === "text" @@ -2875,6 +3107,7 @@ export function OmniSessionPage({ onSubmit={(text) => void send({ kind: "text", text })} onUpload={(option) => { if (streaming || uploading) return; + personSourceRequestRef.current = null; quickUploadReplyRef.current = option; fileInputRef.current?.click(); }} @@ -3029,6 +3262,7 @@ export function OmniSessionPage({ disabled={uploading || streaming} onClick={() => { if (uploading || streaming) return; + personSourceRequestRef.current = null; quickUploadReplyRef.current = null; setUploadMenuOpen((open) => !open); setMentionMenuOpen(false); @@ -3042,6 +3276,7 @@ export function OmniSessionPage({