解决已发现问题
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?"
|
||||
# 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。
|
||||
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*(?:秒|s)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TOTAL_DURATION_RE = re.compile(
|
||||
r"(?:总时长|成片时长|时长)\s*[::]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
|
||||
@@ -516,9 +737,11 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
|
||||
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
|
||||
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
|
||||
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
|
||||
- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。
|
||||
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
|
||||
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
|
||||
- 平台安全优先于戏剧冲突:不要撰写或照抄暴力伤害、血腥、裸体或性暗示、违法犯罪、危险挑战、政治敏感、仇恨歧视、真实名人/IP 模仿等内容。若用户原意涉及上述内容,保留原来的情绪转折和商品卖点,改成成年人之间安全、非暴力、无违法的日常误会、压力或竞争,并采用原创人物与场景。儿童只能出现在安全的日常家庭场景,绝不参与危险、成人或营销煽动情节。
|
||||
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
|
||||
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
|
||||
- 平台安全优先于戏剧冲突:若原意不适合直接出片,只保留情绪转折和真实商品卖点,改成成年人之间的日常误会、压力或良性竞争,并采用原创人物与场景;不要在输出中复述被替换掉的原情节。
|
||||
- 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。
|
||||
"""
|
||||
|
||||
@@ -553,12 +776,10 @@ PRODUCT_REALITY_GUARD = (
|
||||
# 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型
|
||||
# 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。
|
||||
VIDEO_PLATFORM_SAFETY_GUARD = (
|
||||
"【平台安全出片约束·最高优先级】全片仅呈现安全、合法、适合公开传播的原创商业内容。"
|
||||
"不出现或暗示暴力伤害、血腥、自残、裸体、性内容、违法犯罪、赌博、毒品、武器、危险挑战、"
|
||||
"政治敏感、仇恨歧视、真实名人或受保护 IP 模仿。若原剧情有冲突,只用成年人之间安全、"
|
||||
"非暴力的日常误会、压力或竞争来表达,并以沟通或轻松反转收束。儿童仅可出现在安全的日常家庭场景,"
|
||||
"不得参与危险、成人或煽动性情节。商品功效只写已知事实和可见使用体验,不作医疗、治愈、"
|
||||
"绝对化或虚构权威承诺。"
|
||||
"【平台安全出片约束·最高优先级】全片采用健康、友善、合法且适合公开传播的原创商业表达。"
|
||||
"出镜人物均明确为二十二岁以上成年人,着装与镜头语言自然得体,构图保持尊重。"
|
||||
"所有情节通过日常互动、积极沟通和轻松表达推进,并以正向结果收束。"
|
||||
"商品只呈现已知事实、正常用途和可观察的使用体验,所有描述保持客观克制。"
|
||||
)
|
||||
_VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(
|
||||
@@ -566,7 +787,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|"
|
||||
r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待"
|
||||
),
|
||||
"强烈情绪冲突以安全、非暴力方式化解",
|
||||
"强烈情绪冲突通过沟通与日常误会化解",
|
||||
),
|
||||
(
|
||||
re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"),
|
||||
@@ -574,7 +795,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
),
|
||||
(
|
||||
re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"),
|
||||
"不涉及违法或危险行为的日常情节",
|
||||
"合规、稳妥的日常情节",
|
||||
),
|
||||
(
|
||||
re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"),
|
||||
@@ -584,6 +805,18 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"),
|
||||
"采用原创人物设定与表演",
|
||||
),
|
||||
(
|
||||
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁\s*(?:软甜)?少女音"),
|
||||
"二十二岁以上成年女性的清甜自然声线",
|
||||
),
|
||||
(
|
||||
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁"),
|
||||
"二十二岁以上",
|
||||
),
|
||||
(re.compile(r"少女"), "年轻成年女性"),
|
||||
(re.compile(r"甜妹"), "甜美风成年女性"),
|
||||
(re.compile(r"低角度轻微仰拍"), "略低于视线的正面拍摄"),
|
||||
(re.compile(r"低角度鱼眼机位"), "正面鱼眼机位"),
|
||||
)
|
||||
|
||||
|
||||
@@ -774,31 +1007,75 @@ def default_reply_options(
|
||||
]
|
||||
if has_context:
|
||||
text = str(assistant_text or "")
|
||||
if re.search(r"颜色|色号|色彩|SKU|款式|几种", text, re.IGNORECASE):
|
||||
# 创作描述常同时出现商品、人物、场景、颜色。快捷回复只看末尾真正交给用户
|
||||
# 决定的部分,避免正文里的普通名词抢走最后一句的意图。
|
||||
paragraphs = [part.strip() for part in re.split(r"\n+", text) if part.strip()]
|
||||
source = paragraphs[-1] if paragraphs else text
|
||||
sentences = [part.strip() for part in re.split(r"(?<=[。!?!?])", source) if part.strip()]
|
||||
guidance = "".join(sentences[-2:])[-240:] if sentences else source[-240:]
|
||||
asks_for_detail = bool(re.search(
|
||||
r"(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|"
|
||||
r"(?:细节|重点|卖点).{0,12}(?:突出|强调)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
asks_for_color_order = bool(re.search(
|
||||
r"配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
if asks_for_detail and asks_for_color_order:
|
||||
return [
|
||||
{"label": "补充突出细节", "text": "我想补充需要额外突出的细节"},
|
||||
{"label": "调整配色顺序", "text": "我想调整配色的展示顺序"},
|
||||
{"label": "按当前描述继续", "text": "没有其他调整,按当前描述继续"},
|
||||
]
|
||||
if re.search(r"颜色|色号|色彩|配色|SKU|款式|几种|展示顺序", guidance, re.IGNORECASE):
|
||||
return [
|
||||
{"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
|
||||
{"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"},
|
||||
{"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
|
||||
]
|
||||
if re.search(r"商品|产品|主推|哪款", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪款|哪个|什么).{0,8}(?:商品|产品)|"
|
||||
r"(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|"
|
||||
r"(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "发商品列表", "text": "把商品列表发给我选"},
|
||||
{"label": "我直接说商品名", "text": "我直接告诉你商品名"},
|
||||
{"label": "你来推荐", "text": "你根据当前需求推荐一款"},
|
||||
]
|
||||
if re.search(r"人物|角色|模特|出镜", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
|
||||
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "上传人物图", "text": "我上传人物参考图"},
|
||||
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
|
||||
{"label": "不需要人物", "text": "这条先不需要人物出镜"},
|
||||
]
|
||||
if re.search(r"场景|地点|背景|在哪", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|"
|
||||
r"(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "上传场景图", "text": "我上传场景参考图"},
|
||||
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
|
||||
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
|
||||
]
|
||||
if re.search(r"卖点|功能|效果|优惠|价格", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|"
|
||||
r"(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
|
||||
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
|
||||
@@ -965,6 +1242,10 @@ def apply_restart_intent(conversation: CreationConversation) -> 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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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://,写实人脸传直链会被拒。"""
|
||||
|
||||
@@ -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:近景;平视;女主抬眼")
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -110,6 +110,8 @@ description: >
|
||||
- **未成年人绝不生成角色资产或画面主体**:`type:"character"` 只能是明确的成年人,禁止婴儿、宝宝、幼儿、儿童、小朋友、未成年人及任何 `0–17 岁` 人物。
|
||||
婴幼儿/儿童用品也一样:用商品平铺、包装、材质、尺寸、功能细节、成人手部或成年照护者的局部演示表达;不得写儿童出镜、试穿、坐卧、拿着商品或作为镜头主体。
|
||||
旁白可以说明适用年龄与使用场景,但不能把儿童变成要生成的角色/画面。
|
||||
- **审核安全从第一稿完成**:没有用户锁定的人物参考时,角色统一写明确的成年女性、成年男性或成年人,不自创年轻年龄区间,不使用容易产生幼态联想的人设、称呼或音色。服装保持日常得体,机位优先平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
|
||||
- **输出只写正向可拍内容**:如果输入含有不适合直接出片的情节,先在内部保留其情绪功能和真实商品卖点,改成成年人之间积极、友善的日常互动。不要在 JSON、`visual_prompt`、`visual`、旁白或对白中复述原措辞,也不要罗列平台风险类别、禁用词、否定式免责声明或审核说明。
|
||||
- **`visual` 固定使用导演层级**(四栏,逐栏写满,缺栏视为不合格):
|
||||
`【本镜任务】` 这一段要完成的情绪 / 信息变化 →
|
||||
`【光线氛围】` 光源方向与性质(窗光 / 顶光 / 台灯 / 逆光)、色温冷暖、明暗对比、整体色调 →
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
|
||||
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
|
||||
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
|
||||
- [ ] **人物审核安全**:所有画面主体均明确为成年人;没有自创年轻年龄区间、幼态化称呼或音色;服装和构图日常得体,不强调身体局部。
|
||||
- [ ] **正向描述检查**:产物只写改写后的可拍内容,没有复述原始风险情节,没有罗列平台风险类别、禁用词、否定式免责声明或审核说明。
|
||||
|
||||
## C. 旁白红线扫描(逐镜)
|
||||
|
||||
|
||||
@@ -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 递增,供下游三视图/参考图对齐)。
|
||||
|
||||
Reference in New Issue
Block a user