@@ -65,6 +65,8 @@ CREATION_AGENT_MODEL_TIMEOUT_SECONDS = 180
|
||||
CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420
|
||||
# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。
|
||||
MAX_TRUNCATION_RETRIES = 2
|
||||
# 已明确要求推进创作时,模型若只回说明文字而没调工具,最多强制纠正两次。
|
||||
MAX_PROGRESS_TOOL_RETRIES = 2
|
||||
LONG_VIDEO_DURATION_SLACK_SECONDS = 2
|
||||
# 单条用户消息最多触发一次计费生成(契约 §4)
|
||||
MAX_BILLED_GENERATIONS = 1
|
||||
@@ -145,6 +147,16 @@ TRUNCATION_GIVE_UP_NOTICE = (
|
||||
"可以把时长改短一点,或直接点「按这个继续」让我按更紧凑的结构重写一次。"
|
||||
)
|
||||
|
||||
PROGRESS_TOOL_RETRY_INSTRUCTION = (
|
||||
"用户已经明确授权继续创作,本轮不能用说明文字、建议或‘继续完善方案’结束。"
|
||||
"信息不足就调用 ask_user;信息足够就立即调用 write_strategy,并在同一轮继续调用 write_plan,"
|
||||
"最终必须落下可见的视频架构卡。不要输出工具调用以外的解释文字。"
|
||||
)
|
||||
|
||||
PROGRESS_TOOL_GIVE_UP_NOTICE = (
|
||||
"这次没有成功整理出视频方案,已经停止。请把刚才的要求再发一次,我会直接生成方案卡。"
|
||||
)
|
||||
|
||||
|
||||
def long_video_script_covers_requested_duration(
|
||||
raw_duration: int | None,
|
||||
@@ -195,6 +207,17 @@ def set_public_agent_progress(
|
||||
):
|
||||
return
|
||||
history = list(memory.get("agent_progress_history") or [])
|
||||
# reasoning 可能在「理解需求 / 商品 / 角色」之间来回切换。再次回到同一阶段时
|
||||
# 把旧位置移除后放到末尾,既保留当前顺序,也不让加载卡重复刷同一句话。
|
||||
history = [
|
||||
item
|
||||
for item in history
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and str(item.get("phase") or "") == phase
|
||||
and str(item.get("detail_key") or "") == detail_key
|
||||
)
|
||||
]
|
||||
history.append({"phase": phase, "detail_key": detail_key})
|
||||
memory["agent_progress_history"] = history[-12:]
|
||||
memory["agent_progress_phase"] = phase
|
||||
@@ -1446,6 +1469,70 @@ def build_cast_role_briefs(count: int, appearance_prompt: str = "") -> list[str]
|
||||
return [_character_only_appearance_text(item)[:500] for item in briefs[:count]]
|
||||
|
||||
|
||||
_FEMALE_CAST_WORDS = r"女性|女生|女人|女孩|女演员|女模特|女士|女主|妈妈|母亲|女儿|姐姐|妹妹"
|
||||
_MALE_CAST_WORDS = r"男性|男生|男人|男孩|男演员|男模特|男士|男主|爸爸|父亲|儿子|哥哥|弟弟"
|
||||
_CAST_COUNT_WORDS = {"一": 1, "1": 1, "两": 2, "二": 2, "2": 2, "三": 3, "3": 3, "四": 4, "4": 4}
|
||||
|
||||
|
||||
def _cast_gender_from_text(text: str) -> str:
|
||||
"""只读明确的角色性别描述;不从商品或风格词推断。"""
|
||||
female = bool(re.search(_FEMALE_CAST_WORDS, text))
|
||||
male = bool(re.search(_MALE_CAST_WORDS, text))
|
||||
if female == male:
|
||||
return ""
|
||||
return "女性" if female else "男性"
|
||||
|
||||
|
||||
def _shared_cast_gender_from_text(text: str, total: int) -> str:
|
||||
"""仅当文本明确说整组角色同一性别时,才把性别约束复制给每张定妆图。"""
|
||||
matches = re.finditer(
|
||||
rf"(?<!\d)([一1两二2三3四4])\s*(?:位|名|个|人)?\s*(?:成年|年轻|中年|年长)?\s*"
|
||||
rf"({_FEMALE_CAST_WORDS}|{_MALE_CAST_WORDS})",
|
||||
text,
|
||||
)
|
||||
genders = {
|
||||
_cast_gender_from_text(match.group(2))
|
||||
for match in matches
|
||||
if _CAST_COUNT_WORDS.get(match.group(1)) == total
|
||||
}
|
||||
genders.discard("")
|
||||
if len(genders) == 1:
|
||||
return genders.pop()
|
||||
if genders:
|
||||
return ""
|
||||
if total == 2:
|
||||
if re.search(r"母女|姐妹|闺蜜|双女主", text) and not re.search(r"父子|兄弟|双男主|一男一女|男女主", text):
|
||||
return "女性"
|
||||
if re.search(r"父子|兄弟|双男主", text) and not re.search(r"母女|姐妹|闺蜜|双女主|一男一女|男女主", text):
|
||||
return "男性"
|
||||
return ""
|
||||
|
||||
|
||||
def _cast_gender_requirements(
|
||||
conversation: CreationConversation,
|
||||
role_briefs: list[str],
|
||||
appearance_prompt: str,
|
||||
) -> list[str]:
|
||||
"""外观输入优先,其次读取已确认的视频 Prompt;只传性别,不带商品文案。"""
|
||||
total = len(role_briefs)
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
sources = [
|
||||
appearance_prompt,
|
||||
str(memory.get("pending_video_prompt") or ""),
|
||||
]
|
||||
if getattr(conversation, "created_at", None):
|
||||
sources.extend(
|
||||
str(item or "")
|
||||
for item in conversation.messages.filter(role=CreationMessage.Role.USER)
|
||||
.order_by("-seq").values_list("text", flat=True)[:6]
|
||||
)
|
||||
shared = next(
|
||||
(gender for source in sources if (gender := _shared_cast_gender_from_text(source, total))),
|
||||
"",
|
||||
)
|
||||
return [_cast_gender_from_text(brief) or shared for brief in role_briefs]
|
||||
|
||||
|
||||
|
||||
def _build_single_person_reference_prompt(
|
||||
*,
|
||||
@@ -1454,6 +1541,7 @@ def _build_single_person_reference_prompt(
|
||||
context_brief: str,
|
||||
cast_index: int,
|
||||
cast_total: int,
|
||||
gender_requirement: str = "",
|
||||
) -> tuple[str, str, str]:
|
||||
"""返回 (prompt, generating_label, model_name)。"""
|
||||
is_pet = is_pet_preset(conversation.preset)
|
||||
@@ -1486,9 +1574,13 @@ def _build_single_person_reference_prompt(
|
||||
model_name = "平台生成宠物角色"
|
||||
else:
|
||||
role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一"
|
||||
gender_instruction = (
|
||||
f"这位角色必须是成年{gender_requirement},外貌和性别不得改变。"
|
||||
if gender_requirement else ""
|
||||
)
|
||||
prompt = (
|
||||
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
|
||||
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
|
||||
f"只出现一位成年人物。{gender_instruction}正面或轻微三分之四角度,中近景,表情自然,"
|
||||
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
|
||||
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
|
||||
f"{no_product}"
|
||||
@@ -1497,7 +1589,7 @@ def _build_single_person_reference_prompt(
|
||||
prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。"
|
||||
if cast_total > 1:
|
||||
prompt += (
|
||||
f" 这是多人物视频中的角色{cast_index},必须与其他角色在性别或发型或年龄段或服装气质上"
|
||||
f" 这是多人物视频中的角色{cast_index},必须与其他角色在发型、年龄段或服装气质上"
|
||||
"有清晰可辨的差异,便于整片锁脸。"
|
||||
)
|
||||
label = f"正在生成人物参考({cast_index}/{cast_total})"
|
||||
@@ -1542,6 +1634,7 @@ def submit_generated_person_reference(
|
||||
if is_pet_preset(conversation.preset):
|
||||
total = 1
|
||||
role_briefs = build_cast_role_briefs(total, raw_appearance)
|
||||
gender_requirements = _cast_gender_requirements(conversation, role_briefs, raw_appearance)
|
||||
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source"] = "platform_generate"
|
||||
@@ -1566,6 +1659,7 @@ def submit_generated_person_reference(
|
||||
context_brief=context_brief,
|
||||
cast_index=index,
|
||||
cast_total=total,
|
||||
gender_requirement=gender_requirements[index - 1],
|
||||
)
|
||||
tasks = enqueue_standalone_images(
|
||||
team=conversation.team,
|
||||
@@ -1876,6 +1970,8 @@ VIDEO_MODEL_BY_LABEL = {
|
||||
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
|
||||
"Seedance 2.0 Mini": "doubao-seedance-2-0-mini-260615",
|
||||
}
|
||||
FAST_VIDEO_MODEL = "doubao-seedance-2-0-fast-260128"
|
||||
FAST_VIDEO_MODEL_LABEL = "Seedance 2.0 Fast"
|
||||
DEFAULT_VIDEO_MODEL = "doubao-seedance-2-5-260628"
|
||||
IMAGE_MODEL_BY_LABEL = {
|
||||
"Seedream5.0": "volcano",
|
||||
@@ -2588,6 +2684,7 @@ _CONTINUE_INTENT_RE = re.compile(
|
||||
r"^\s*("
|
||||
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
|
||||
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
|
||||
r"推荐|推荐一下|你来推荐|帮我推荐|按你推荐(?:的)?|你来定|你决定|按你来|你看着办|随便|都行|"
|
||||
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
|
||||
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
|
||||
r")[吧啊呀呢。.!!]*\s*$"
|
||||
@@ -2873,6 +2970,7 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||||
current = dict(conversation.params or {})
|
||||
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
|
||||
changed = False
|
||||
model_user_selected = False
|
||||
for key, raw in (answers or {}).items():
|
||||
field = field_by_key.get(str(key)) or {}
|
||||
if field.get("type") == "asset":
|
||||
@@ -2889,9 +2987,17 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||||
value = f"{MAX_VIDEO_DURATION} 秒"
|
||||
current[stored] = value
|
||||
changed = True
|
||||
if stored == "model":
|
||||
model_user_selected = True
|
||||
if changed:
|
||||
conversation.params = current
|
||||
conversation.save(update_fields=["params", "updated_at"])
|
||||
update_fields = ["params", "updated_at"]
|
||||
if model_user_selected and conversation.mode == CreationConversation.Mode.VIDEO:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["video_model_user_selected"] = True
|
||||
conversation.memory = memory
|
||||
update_fields.append("memory")
|
||||
conversation.save(update_fields=update_fields)
|
||||
if is_plot_twist_conversation(conversation):
|
||||
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
|
||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
||||
@@ -2958,11 +3064,17 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
)
|
||||
if changed:
|
||||
conversation.params = current
|
||||
conversation.save(update_fields=["params", "updated_at"])
|
||||
if is_plot_twist_conversation(conversation):
|
||||
update_fields = ["params", "updated_at"]
|
||||
if model_changed:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["video_model_user_selected"] = True
|
||||
conversation.memory = memory
|
||||
update_fields.append("memory")
|
||||
conversation.save(update_fields=update_fields)
|
||||
if duration_changed and is_plot_twist_conversation(conversation):
|
||||
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
|
||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
||||
needs_rebuild = duration_changed or model_changed
|
||||
needs_rebuild = duration_changed
|
||||
if needs_rebuild:
|
||||
# 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。
|
||||
set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True)
|
||||
@@ -3616,17 +3728,20 @@ def resolve_smart_video_duration(
|
||||
params["duration"] = f"{duration} 秒"
|
||||
selected_model = video_model_name(params)
|
||||
switched = False
|
||||
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–180 秒分段的模型。
|
||||
# 总时长不在单段能力表里时,后续会拆成多条 <=30 秒的 2.5 任务。
|
||||
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
|
||||
params["model"] = "Seedance 2.5"
|
||||
memory = dict(conversation.memory or {})
|
||||
model_user_selected = bool(memory.get("video_model_user_selected"))
|
||||
# 默认按目标时长选模型:15 秒及以下优先 Fast,超过 15 秒使用 2.5。
|
||||
# 用户在最终确认卡手动换过模型后尊重其选择;只有能力不支持时才自动回退。
|
||||
preferred_model = FAST_VIDEO_MODEL if duration <= 15 else DEFAULT_VIDEO_MODEL
|
||||
preferred_label = FAST_VIDEO_MODEL_LABEL if duration <= 15 else "Seedance 2.5"
|
||||
if not model_user_selected and selected_model != preferred_model:
|
||||
params["model"] = preferred_label
|
||||
switched = True
|
||||
elif not _model_supports_duration(selected_model, duration):
|
||||
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
|
||||
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
|
||||
params["model"] = "Seedance 2.5"
|
||||
if _model_supports_duration(preferred_model, duration):
|
||||
params["model"] = preferred_label
|
||||
switched = True
|
||||
memory = dict(conversation.memory or {})
|
||||
memory.pop("video_model_user_selected", None)
|
||||
memory["smart_duration_resolved"] = duration
|
||||
if switched:
|
||||
memory["smart_duration_model_switched"] = True
|
||||
@@ -4980,6 +5095,7 @@ def iter_creation_agent_events(
|
||||
turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS
|
||||
extra_body = creation_model_extra_body(model_config, tools)
|
||||
truncation_retries = 0
|
||||
progress_tool_retries = 0
|
||||
for _round in range(MAX_TOOL_ROUNDS):
|
||||
if is_agent_cancel_requested(conversation.id):
|
||||
# 用户终止:干净收束,不落 ERROR,已落库消息保留
|
||||
@@ -5199,6 +5315,27 @@ def iter_creation_agent_events(
|
||||
turn_has_gate = True
|
||||
break
|
||||
|
||||
# 用户已经明确说「推荐 / 继续 / 做方案」,模型却只回一段说明文字时,
|
||||
# 不能把加载状态正常收起后只留“继续完善方案”。把这一轮文本作废并强制重试工具调用,
|
||||
# 直到真正落下追问卡或视频架构卡。
|
||||
if not calls and allow_plan and context.is_video and not fallback_fields:
|
||||
progress_tool_retries += 1
|
||||
if progress_tool_retries <= MAX_PROGRESS_TOOL_RETRIES:
|
||||
if said:
|
||||
messages.append({"role": "assistant", "content": said})
|
||||
messages.append({"role": "user", "content": PROGRESS_TOOL_RETRY_INSTRUCTION})
|
||||
continue
|
||||
clear_public_agent_progress(conversation)
|
||||
failure = append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ERROR,
|
||||
text=PROGRESS_TOOL_GIVE_UP_NOTICE,
|
||||
)
|
||||
yield {"type": "message", "message": _message_payload(failure)}
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
||||
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
||||
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
||||
|
||||
@@ -233,7 +233,17 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
|
||||
if not history or history[-1] != progress:
|
||||
history.append(progress)
|
||||
progress["history"] = history[-12:]
|
||||
# 兼容升级前已经存下的重复历史:同一句只保留最后出现的位置,
|
||||
# 保证当前阶段仍位于列表末尾。
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique_reversed: list[dict[str, str]] = []
|
||||
for item in reversed(history):
|
||||
key = (str(item.get("label") or ""), str(item.get("detail") or ""))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_reversed.append(item)
|
||||
progress["history"] = list(reversed(unique_reversed))[-12:]
|
||||
return progress
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
@@ -55,6 +55,7 @@ from .creation_agent import (
|
||||
creation_agent_timeout_notice,
|
||||
long_video_script_covers_requested_duration,
|
||||
plan_video_segments,
|
||||
resolve_smart_video_duration,
|
||||
is_continue_intent,
|
||||
is_greeting,
|
||||
is_pure_chitchat,
|
||||
@@ -741,6 +742,36 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
|
||||
self.assertEqual(self.conversation.memory.get("person_cast_total"), 2)
|
||||
self.assertEqual(self.conversation.memory.get("person_cast_pending"), 2)
|
||||
|
||||
def test_video_prompt_two_adult_women_applies_to_both_character_images(self):
|
||||
"""用户未额外填写外观时,视频 Prompt 的两位女性约束不能在生图阶段丢失。"""
|
||||
self.conversation.memory = {
|
||||
"pending_video_prompt": "这是一条剧情视频,两位成年女性分别出镜,展示商品使用前后。",
|
||||
}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
tasks = [
|
||||
AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
task_type=AITask.Type.PERSON_IMAGE,
|
||||
model_config=self.model,
|
||||
idempotency_key=f"k-two-women-{index}",
|
||||
)
|
||||
for index in (1, 2)
|
||||
]
|
||||
with patch("apps.ai.services.enqueue_standalone_images", side_effect=[[tasks[0]], [tasks[1]]]) as enqueue:
|
||||
messages = submit_generated_person_reference(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertEqual(enqueue.call_count, 2)
|
||||
for call in enqueue.call_args_list:
|
||||
prompt = call.kwargs["prompt"]
|
||||
self.assertIn("必须是成年女性", prompt)
|
||||
self.assertNotIn("性别或发型", prompt)
|
||||
self.assertNotIn("商品使用前后", prompt)
|
||||
|
||||
|
||||
class SendEndpointTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -1602,6 +1633,48 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.params["duration"], "120 秒")
|
||||
|
||||
def test_short_video_defaults_to_fast_model(self):
|
||||
self.conversation.params = {
|
||||
**self.conversation.params,
|
||||
"duration": "15 秒",
|
||||
"model": "Seedance 2.5",
|
||||
}
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["params", "memory", "updated_at"])
|
||||
|
||||
resolve_smart_video_duration(self.conversation)
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.params["model"], "Seedance 2.0 Fast")
|
||||
|
||||
def test_longer_video_defaults_to_seedance_25(self):
|
||||
self.conversation.params = {
|
||||
**self.conversation.params,
|
||||
"duration": "30 秒",
|
||||
"model": "Seedance 2.0 Fast",
|
||||
}
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["params", "memory", "updated_at"])
|
||||
|
||||
resolve_smart_video_duration(self.conversation)
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.params["model"], "Seedance 2.5")
|
||||
|
||||
def test_short_video_keeps_manually_selected_model(self):
|
||||
self.conversation.params = {
|
||||
**self.conversation.params,
|
||||
"duration": "15 秒",
|
||||
"model": "Seedance 2.5",
|
||||
}
|
||||
self.conversation.memory = {"video_model_user_selected": True}
|
||||
self.conversation.save(update_fields=["params", "memory", "updated_at"])
|
||||
|
||||
resolve_smart_video_duration(self.conversation)
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.params["model"], "Seedance 2.5")
|
||||
|
||||
def test_planning_exposes_safe_progress_not_raw_reasoning(self):
|
||||
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
|
||||
self.conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
@@ -1624,6 +1697,22 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
self.assertIsNone(CreationConversationDetailSerializer(self.conversation).data["agent_progress"])
|
||||
|
||||
def test_planning_progress_deduplicates_revisited_steps(self):
|
||||
self.conversation.agent_status = CreationConversation.AgentStatus.PLANNING
|
||||
self.conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
set_public_agent_progress(self.conversation, "starting")
|
||||
set_public_agent_progress(self.conversation, "reasoning", detail_key="brief")
|
||||
set_public_agent_progress(self.conversation, "reasoning", detail_key="product")
|
||||
set_public_agent_progress(self.conversation, "reasoning", detail_key="brief")
|
||||
set_public_agent_progress(self.conversation, "write_plan")
|
||||
|
||||
data = CreationConversationDetailSerializer(self.conversation).data
|
||||
details = [item["detail"] for item in data["agent_progress"]["history"]]
|
||||
|
||||
self.assertEqual(len(details), len(set(details)))
|
||||
self.assertEqual(details.count("正在理解这次创作的重点和限制"), 1)
|
||||
self.assertEqual(details[-1], "正在安排分段节奏,并锁定角色和商品的一致性")
|
||||
|
||||
def test_shot_timeline_does_not_override_session_duration(self):
|
||||
changed = apply_explicit_video_duration_from_text(
|
||||
self.conversation,
|
||||
@@ -2484,6 +2573,42 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
])
|
||||
self.assertEqual(len(fake.calls), 2)
|
||||
|
||||
def test_recommendation_reply_cannot_finish_without_a_plan_card(self):
|
||||
self._pin_product("补水精华水")
|
||||
self._pin_person()
|
||||
self.conversation.memory = {
|
||||
"selling_point_ready": True,
|
||||
"selling_point_mode": "auto",
|
||||
"product_brief_reviewed": True,
|
||||
}
|
||||
self.conversation.save(update_fields=["memory", "updated_at"])
|
||||
fake = FakeProvider([
|
||||
_text_chunks("可以回复继续完善方案,我再为你整理。"),
|
||||
_tool_chunks("write_strategy", {
|
||||
"target": "通勤护肤人群",
|
||||
"trust": "真实上脸补水过程",
|
||||
"belief": "肤感清爽且方便日常补水",
|
||||
"direction": "自然生活流体验",
|
||||
}),
|
||||
_tool_chunks("write_plan", self._plan_args()),
|
||||
])
|
||||
|
||||
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,
|
||||
))
|
||||
|
||||
messages = [event["message"] for event in events if event.get("type") == "message"]
|
||||
self.assertTrue(any(message["kind"] == "plan" for message in messages))
|
||||
self.assertFalse(any(
|
||||
message["kind"] == "text" and "继续完善方案" in message.get("text", "")
|
||||
for message in messages
|
||||
))
|
||||
self.assertEqual(len(fake.calls), 3)
|
||||
|
||||
def test_multiple_characters_do_not_interrupt_architecture_writing(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
|
||||
characters = [
|
||||
@@ -2960,6 +3085,26 @@ class ConfirmEndpointTests(TestCase):
|
||||
{"kind": "confirm", "reply_to": str(self.card.id)}, format="json",
|
||||
)
|
||||
|
||||
def _pin_person_reference(self):
|
||||
portrait = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="确认参数测试角色",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
category=Asset.Category.PERSON,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=portrait,
|
||||
object_key="confirm-person.jpg",
|
||||
bucket="test",
|
||||
preview_url="https://cdn.example/confirm-person.jpg",
|
||||
is_primary=True,
|
||||
)
|
||||
self.conversation.pinned_refs = [
|
||||
{"type": "character", "id": str(portrait.id), "name": portrait.name}
|
||||
]
|
||||
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
|
||||
|
||||
def test_confirm_twice_is_refused(self):
|
||||
provider = ModelProvider.objects.create(name="fk2", display_name="F", base_url="https://x")
|
||||
model = ModelConfig.objects.create(
|
||||
@@ -3037,7 +3182,8 @@ class ConfirmEndpointTests(TestCase):
|
||||
self.card.refresh_from_db()
|
||||
self.assertTrue(self.card.payload["submitted"])
|
||||
|
||||
def test_confirm_model_change_rebuilds_architecture_before_submitting(self):
|
||||
def test_confirm_model_change_without_duration_change_submits_directly(self):
|
||||
self._pin_person_reference()
|
||||
self.conversation.params = {
|
||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||
"ratio": "1:1", "duration": "8 秒",
|
||||
@@ -3060,13 +3206,14 @@ class ConfirmEndpointTests(TestCase):
|
||||
"resolution": "720p", "ratio": "1:1"}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json().get("regenerate"))
|
||||
submit.assert_not_called()
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertFalse(response.json().get("regenerate", False))
|
||||
submit.assert_called_once()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy")
|
||||
self.assertNotEqual((self.conversation.memory or {}).get("stage"), "strategy")
|
||||
|
||||
def test_confirm_ignores_duration_spacing_when_model_changes(self):
|
||||
def test_confirm_ignores_duration_spacing_and_submits_model_change_directly(self):
|
||||
self._pin_person_reference()
|
||||
self.conversation.params = {
|
||||
"model": "Seedance 2.0 Fast", "resolution": "480p",
|
||||
"ratio": "9:16", "duration": "8秒",
|
||||
@@ -3089,9 +3236,9 @@ class ConfirmEndpointTests(TestCase):
|
||||
"resolution": "720p", "ratio": "9:16"}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json().get("regenerate"))
|
||||
submit.assert_not_called()
|
||||
self.assertEqual(response.status_code, 201, response.content)
|
||||
self.assertFalse(response.json().get("regenerate", False))
|
||||
submit.assert_called_once()
|
||||
|
||||
|
||||
class MemoryCompressionTests(CreationAgentBaseTests):
|
||||
@@ -3618,6 +3765,10 @@ class CreativeIntentTests(SimpleTestCase):
|
||||
"完善方案",
|
||||
"继续创作",
|
||||
"使用这个角色继续创作",
|
||||
"推荐",
|
||||
"你来推荐",
|
||||
"按你来",
|
||||
"你看着办",
|
||||
):
|
||||
self.assertTrue(is_continue_intent(text), text)
|
||||
|
||||
|
||||
@@ -2551,7 +2551,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"params": latest_params,
|
||||
}
|
||||
card.save(update_fields=["payload", "updated_at"])
|
||||
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
|
||||
# 只有改时长会影响既有时间轴。模型、分辨率和比例可直接用于当前出片指令。
|
||||
if needs_rebuild:
|
||||
return JsonResponse({
|
||||
"regenerate": True,
|
||||
|
||||
Reference in New Issue
Block a user