1652 lines
66 KiB
Python
1652 lines
66 KiB
Python
"""为模型路由浏览器验收创建可删除的 QA 任务。
|
||
|
||
只允许在 DEBUG 环境且显式设置 MODEL_ROUTING_BROWSER_QA=1 时运行。脚本不会调用真实
|
||
Provider 或对象存储,也不会使用现有团队积分;它创建不可登录的临时用户和团队,真实走
|
||
AITask、预留积分、统一路由执行器、尝试日志及最终结算/释放流程。
|
||
|
||
用法(从 core/backend 目录运行):
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-reference
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-tryon
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-platform
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-base
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-model-triview
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-project-triview
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-storyboard
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-entity
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-script
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-voice
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-video
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py setup-free-video
|
||
MODEL_ROUTING_BROWSER_QA=1 python ../../core/qa/model-routing-browser-qa.py cleanup
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from copy import deepcopy
|
||
from decimal import Decimal
|
||
from io import BytesIO
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import sys
|
||
from unittest.mock import Mock, patch
|
||
|
||
|
||
BACKEND_DIR = Path(__file__).resolve().parents[1] / "backend"
|
||
sys.path.insert(0, str(BACKEND_DIR))
|
||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
|
||
|
||
import django # noqa: E402
|
||
|
||
django.setup()
|
||
|
||
import requests # noqa: E402
|
||
from django.conf import settings # noqa: E402
|
||
from django.test import override_settings # noqa: E402
|
||
|
||
from apps.accounts.models import Team, TeamMember, User # noqa: E402
|
||
from apps.ai.models import AITask, ModelConfig, ModelProvider # noqa: E402
|
||
from apps.ai.script_agent import regenerate_segment_via_agent, stream_script_agent # noqa: E402
|
||
from apps.ai.services import ( # noqa: E402
|
||
_storyboard_shot_worker,
|
||
enqueue_standalone_images,
|
||
generate_base_asset,
|
||
generate_model_triview,
|
||
generate_person_triview,
|
||
run_extract_entities_task,
|
||
run_base_asset_task,
|
||
run_model_triview_task,
|
||
run_standalone_image_task,
|
||
run_triview_task,
|
||
poll_storyboard,
|
||
poll_video_segment,
|
||
submit_video_segment,
|
||
submit_extract_entities,
|
||
synthesize_project_voiceover,
|
||
)
|
||
from apps.assets.models import Asset, AssetFile, Model # noqa: E402
|
||
from apps.assets.storage import StoredObject # noqa: E402
|
||
from apps.billing.models import CreditAccount, CreditLedger # noqa: E402
|
||
from apps.products.models import Product # noqa: E402
|
||
from apps.projects.models import ( # noqa: E402
|
||
BaseAssetGroup,
|
||
Project,
|
||
ScriptSegment,
|
||
ScriptVersion,
|
||
StoryboardShot,
|
||
VideoSegment,
|
||
)
|
||
|
||
|
||
QA_USERNAME = "__routing_browser_qa__"
|
||
QA_TEAM_NAME = "__Routing Browser QA__"
|
||
QA_PROVIDER_NAMES = (
|
||
"qa-routing-primary",
|
||
"qa-routing-fallback-1",
|
||
"qa-routing-fallback-2",
|
||
)
|
||
|
||
|
||
def _assert_safe() -> None:
|
||
if not settings.DEBUG or os.environ.get("MODEL_ROUTING_BROWSER_QA") != "1":
|
||
raise SystemExit(
|
||
"拒绝运行:仅允许在 DEBUG 环境,并需显式设置 MODEL_ROUTING_BROWSER_QA=1。"
|
||
)
|
||
|
||
|
||
def cleanup() -> dict[str, int]:
|
||
tasks = AITask.objects.filter(team__name=QA_TEAM_NAME).count()
|
||
teams = Team.objects.filter(name=QA_TEAM_NAME).count()
|
||
users = User.objects.filter(username=QA_USERNAME).count()
|
||
providers = ModelProvider.objects.filter(name__in=QA_PROVIDER_NAMES).count()
|
||
|
||
# 项目通过 PROTECT 引用商品;先删精确 QA 团队下的项目,再让团队级联清理商品与其余数据。
|
||
Project.objects.filter(team__name=QA_TEAM_NAME).delete()
|
||
Team.objects.filter(name=QA_TEAM_NAME).delete()
|
||
ModelProvider.objects.filter(name__in=QA_PROVIDER_NAMES).delete()
|
||
User.objects.filter(username=QA_USERNAME).delete()
|
||
return {"tasks": tasks, "teams": teams, "users": users, "providers": providers}
|
||
|
||
|
||
def _image_metadata(*, priority: int) -> dict:
|
||
return {
|
||
"routing": {
|
||
"fallback_on_failure": True,
|
||
"fallback_candidate": True,
|
||
},
|
||
"capabilities": {
|
||
"operations": ["image_generate", "image_edit"],
|
||
"features": [],
|
||
"reference_modes": ["none", "single", "multiple"],
|
||
"max_reference_images": 9,
|
||
"aspect_ratios": ["1:1", "3:4", "4:5", "9:16", "16:9"],
|
||
},
|
||
"pricing": {"base_cost_yuan": "0.10"},
|
||
"qa": {"browser_routing_priority": priority},
|
||
}
|
||
|
||
|
||
def _create_model(provider_name: str, priority: int, model_name: str) -> ModelConfig:
|
||
provider = ModelProvider.objects.create(
|
||
name=provider_name,
|
||
display_name=f"QA Provider {priority}",
|
||
status=ModelProvider.Status.ACTIVE,
|
||
base_url="http://127.0.0.1.invalid/v1",
|
||
api_key="",
|
||
metadata={"routing": {"fallback_priority": priority}, "qa": {"browser_routing": True}},
|
||
)
|
||
return ModelConfig.objects.create(
|
||
provider=provider,
|
||
name=model_name,
|
||
display_name=f"QA Image {priority}",
|
||
capability=ModelConfig.Capability.IMAGE,
|
||
endpoint="images/generations",
|
||
unit_price=Decimal("20"),
|
||
status=ModelConfig.Status.ACTIVE,
|
||
metadata=_image_metadata(priority=priority),
|
||
)
|
||
|
||
|
||
def _create_text_model(provider_name: str, priority: int, model_name: str) -> ModelConfig:
|
||
provider = ModelProvider.objects.create(
|
||
name=provider_name,
|
||
display_name=f"QA Provider {priority}",
|
||
status=ModelProvider.Status.ACTIVE,
|
||
base_url="http://127.0.0.1.invalid/v1",
|
||
api_key="",
|
||
metadata={"routing": {"fallback_priority": priority}, "qa": {"browser_routing": True}},
|
||
)
|
||
return ModelConfig.objects.create(
|
||
provider=provider,
|
||
name=model_name,
|
||
display_name=f"QA Script {priority}",
|
||
capability=ModelConfig.Capability.TEXT,
|
||
endpoint="chat/completions",
|
||
unit_price=Decimal("10"),
|
||
status=ModelConfig.Status.ACTIVE,
|
||
metadata={
|
||
"routing": {"fallback_on_failure": True, "fallback_candidate": True},
|
||
"capabilities": {
|
||
"operations": ["chat"],
|
||
"features": ["streaming", "structured_output"],
|
||
},
|
||
"pricing": {"base_cost_yuan": "0.10"},
|
||
"qa": {"browser_routing_priority": priority},
|
||
},
|
||
)
|
||
|
||
|
||
def _create_audio_model(provider_name: str, priority: int, model_name: str) -> ModelConfig:
|
||
provider = ModelProvider.objects.create(
|
||
name=provider_name,
|
||
display_name=f"QA Provider {priority}",
|
||
status=ModelProvider.Status.ACTIVE,
|
||
base_url="http://127.0.0.1.invalid/v1",
|
||
api_key="",
|
||
metadata={"routing": {"fallback_priority": priority}, "qa": {"browser_routing": True}},
|
||
)
|
||
return ModelConfig.objects.create(
|
||
provider=provider,
|
||
name=model_name,
|
||
display_name=f"QA Voice {priority}",
|
||
capability=ModelConfig.Capability.AUDIO,
|
||
endpoint="audio/speech",
|
||
unit_price=Decimal("10"),
|
||
status=ModelConfig.Status.ACTIVE,
|
||
metadata={
|
||
"routing": {"fallback_on_failure": True, "fallback_candidate": True},
|
||
"capabilities": {
|
||
"operations": ["tts"],
|
||
"features": [],
|
||
"languages": ["zh-CN"],
|
||
"voice_map": {"BV700_streaming": "alloy"},
|
||
"max_chars": 10000,
|
||
"speed_range": [0.5, 2.0],
|
||
"output_formats": ["mp3"],
|
||
},
|
||
"pricing": {
|
||
"chars_per_unit": 500,
|
||
"points_per_unit": 10,
|
||
"min_units": 1,
|
||
"base_cost_yuan_per_unit": "0.10",
|
||
},
|
||
"qa": {"browser_routing_priority": priority},
|
||
},
|
||
)
|
||
|
||
|
||
def _create_video_model(provider_name: str, priority: int, model_name: str) -> ModelConfig:
|
||
provider = ModelProvider.objects.create(
|
||
name=provider_name,
|
||
display_name=f"QA Provider {priority}",
|
||
status=ModelProvider.Status.ACTIVE,
|
||
base_url="http://127.0.0.1.invalid/v1",
|
||
api_key="",
|
||
metadata={"routing": {"fallback_priority": priority}, "qa": {"browser_routing": True}},
|
||
)
|
||
return ModelConfig.objects.create(
|
||
provider=provider,
|
||
name=model_name,
|
||
display_name=f"QA Video {priority}",
|
||
capability=ModelConfig.Capability.VIDEO,
|
||
endpoint="videos",
|
||
unit_price=Decimal("1"),
|
||
status=ModelConfig.Status.ACTIVE,
|
||
metadata={
|
||
"routing": {"fallback_on_failure": True, "fallback_candidate": True},
|
||
"capabilities": {
|
||
"operations": ["video_generate"],
|
||
"features": ["generate_audio"],
|
||
"max_reference_images": 9,
|
||
"max_reference_videos": 3,
|
||
"max_reference_audios": 3,
|
||
"aspect_ratios": ["9:16", "16:9"],
|
||
"resolutions": ["480p", "720p"],
|
||
"durations": [4, 15],
|
||
},
|
||
"pricing": {
|
||
"unit": "cny_per_million_tokens",
|
||
"default": {"no_ref_video": 23, "with_ref_video": 14},
|
||
},
|
||
"qa": {"browser_routing_priority": priority},
|
||
},
|
||
)
|
||
|
||
|
||
def _ledger_counts(task: AITask) -> dict[str, int]:
|
||
return {
|
||
kind: CreditLedger.objects.filter(task=task, ledger_type=kind).count()
|
||
for kind in (
|
||
CreditLedger.Type.RESERVE,
|
||
CreditLedger.Type.CHARGE,
|
||
CreditLedger.Type.RELEASE,
|
||
)
|
||
}
|
||
|
||
|
||
def setup(*, with_references: bool = False, tryon: bool = False, platform: bool = False) -> dict:
|
||
cleanup()
|
||
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
|
||
primary = _create_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-image")
|
||
fallback_1 = _create_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-image-1")
|
||
fallback_2 = _create_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-image-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
scenario = {"name": "first_success"}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider.extract_first_media_url.side_effect = lambda response: response["data"][0]["url"]
|
||
provider_mocks[model.id] = provider
|
||
|
||
current = scenario["name"]
|
||
route_name = current.removeprefix("reference_").removeprefix("tryon_").removeprefix("platform_")
|
||
should_fail = route_name == "all_failed" or (
|
||
route_name == "fallback_success" and model.id == primary.id
|
||
)
|
||
if model.id not in qa_model_ids:
|
||
should_fail = True
|
||
provider.image_generation.side_effect = (
|
||
requests.ConnectionError(f"QA controlled failure: {current}")
|
||
if should_fail
|
||
else None
|
||
)
|
||
provider.image_generation.return_value = {
|
||
"data": [{"url": "http://qa.invalid/generated.png"}],
|
||
"usage": {"images": 1},
|
||
}
|
||
provider.image_edit.side_effect = provider.image_generation.side_effect
|
||
provider.image_edit.return_value = provider.image_generation.return_value
|
||
return provider
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["image"]["retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
|
||
stored = StoredObject(
|
||
bucket="qa-browser",
|
||
object_key="qa/generated.png",
|
||
content_type="image/png",
|
||
size_bytes=8,
|
||
)
|
||
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_image_provider", side_effect=provider_for),
|
||
patch(
|
||
"apps.ai.services.VolcanoArkProvider.media_to_bytes",
|
||
return_value=(BytesIO(b"qa-image"), "image/png"),
|
||
),
|
||
patch("apps.ai.services.TosStorage") as storage,
|
||
):
|
||
storage.return_value.upload_fileobj.return_value = stored
|
||
reference_ids: list[str] = []
|
||
tryon_product = None
|
||
tryon_model = None
|
||
if with_references or tryon or platform:
|
||
for index in range(2):
|
||
reference = Asset.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
name=f"QA 参考图 {index + 1}",
|
||
asset_type=Asset.Type.IMAGE,
|
||
source=Asset.Source.UPLOAD,
|
||
category=Asset.Category.UPLOAD,
|
||
)
|
||
AssetFile.objects.create(
|
||
asset=reference,
|
||
object_key=f"qa/reference-{index + 1}.png",
|
||
bucket="qa-browser",
|
||
content_type="image/png",
|
||
preview_url=f"http://qa.invalid/reference-{index + 1}.png",
|
||
is_primary=True,
|
||
)
|
||
reference_ids.append(str(reference.id))
|
||
|
||
if tryon:
|
||
product_asset = Asset.objects.get(id=reference_ids[0])
|
||
product_asset.category = Asset.Category.PRODUCT_IMAGE
|
||
product_asset.save(update_fields=["category"])
|
||
tryon_product = Product.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
title="QA 模特上身图商品",
|
||
category="服饰内衣",
|
||
cover_asset=product_asset,
|
||
)
|
||
tryon_model = Asset.objects.get(id=reference_ids[1])
|
||
tryon_model.source = Asset.Source.AI_GENERATED
|
||
tryon_model.category = Asset.Category.PERSON
|
||
tryon_model.save(update_fields=["source", "category"])
|
||
|
||
platform_product = None
|
||
if platform:
|
||
product_asset = Asset.objects.get(id=reference_ids[0])
|
||
product_asset.category = Asset.Category.PRODUCT_IMAGE
|
||
product_asset.save(update_fields=["category"])
|
||
platform_product = Product.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
title="QA 平台套图商品",
|
||
category="服饰内衣",
|
||
cover_asset=product_asset,
|
||
)
|
||
|
||
prefix = "platform_" if platform else ("tryon_" if tryon else ("reference_" if with_references else ""))
|
||
label = "平台套图" if platform else ("模特上身图" if tryon else ("多参考图" if with_references else "无参考图"))
|
||
for short_name, description in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
name = f"{prefix}{short_name}"
|
||
prompt = f"QA 浏览器验收({label}):{description}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
task = enqueue_standalone_images(
|
||
team=team,
|
||
user=user,
|
||
prompt=prompt,
|
||
mode="cover" if platform else ("model" if tryon else "image"),
|
||
count=1,
|
||
ratio="1:1",
|
||
image_model=f"{primary.provider.name}:{primary.name}",
|
||
reference_image_ids=reference_ids if with_references and not tryon else [],
|
||
product_id=(
|
||
str(platform_product.id)
|
||
if platform_product is not None
|
||
else (str(tryon_product.id) if tryon_product is not None else None)
|
||
),
|
||
model_id=str(tryon_model.id) if tryon_model is not None else None,
|
||
platform_id="taobao" if platform else None,
|
||
dispatch=False,
|
||
)[0]
|
||
run_standalone_image_task(task_id=str(task.id))
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"reference_images": list(
|
||
task.model_attempts.values_list("request_summary__reference_images", flat=True)
|
||
),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_base() -> dict:
|
||
"""创建商品、人物、场景三类基础资产的受控成功/Fallback/全失败任务。"""
|
||
cleanup()
|
||
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
|
||
primary = _create_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-image")
|
||
fallback_1 = _create_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-image-1")
|
||
fallback_2 = _create_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-image-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
cover = Asset.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
name="QA 商品主图",
|
||
asset_type=Asset.Type.IMAGE,
|
||
source=Asset.Source.UPLOAD,
|
||
category=Asset.Category.PRODUCT_IMAGE,
|
||
)
|
||
AssetFile.objects.create(
|
||
asset=cover,
|
||
object_key="qa/base-product.png",
|
||
bucket="qa-browser",
|
||
content_type="image/png",
|
||
preview_url="http://qa.invalid/base-product.png",
|
||
is_primary=True,
|
||
)
|
||
product = Product.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
title="QA 基础资产商品",
|
||
category="服饰内衣",
|
||
cover_asset=cover,
|
||
)
|
||
project = Project.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
product=product,
|
||
name="QA 基础资产项目",
|
||
)
|
||
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider.extract_first_media_url.side_effect = lambda response: response["data"][0]["url"]
|
||
provider_mocks[model.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or (
|
||
current.endswith("fallback_success") and model.id == primary.id
|
||
)
|
||
if model.id not in qa_model_ids:
|
||
should_fail = True
|
||
failure = requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
response = {"data": [{"url": "http://qa.invalid/generated.png"}], "usage": {"images": 1}}
|
||
provider.image_generation.side_effect = failure
|
||
provider.image_generation.return_value = response
|
||
provider.image_edit.side_effect = failure
|
||
provider.image_edit.return_value = response
|
||
return provider
|
||
|
||
def store_media(**kwargs):
|
||
return Asset.objects.create(
|
||
team=kwargs["team"],
|
||
created_by=kwargs["user"],
|
||
name=kwargs["name"],
|
||
asset_type=kwargs["asset_type"],
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=kwargs["category"],
|
||
origin_task=kwargs["task"],
|
||
)
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["image"]["retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_image_provider", side_effect=provider_for),
|
||
patch("apps.ai.services._store_generated_media", side_effect=store_media),
|
||
patch("apps.ai.tasks.generate_base_asset_task.delay"),
|
||
patch("apps.assets.review.submit_asset_for_review"),
|
||
):
|
||
for kind in (
|
||
BaseAssetGroup.Kind.PRODUCT,
|
||
BaseAssetGroup.Kind.PERSON,
|
||
BaseAssetGroup.Kind.SCENE,
|
||
):
|
||
for short_name, description in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
name = f"base_{kind}_{short_name}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
task = generate_base_asset(
|
||
project=project,
|
||
user=user,
|
||
kind=kind,
|
||
prompt=f"QA 浏览器验收({kind} 基础资产):{description}",
|
||
label=f"QA-{kind}-{short_name}",
|
||
)
|
||
run_base_asset_task(task_id=str(task.id))
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"reference_images": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__reference_images", flat=True
|
||
)
|
||
),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_model_triview(*, project_scope: bool = False) -> dict:
|
||
"""创建团队模特或项目角色三视图的受控成功/Fallback/全失败任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
primary = _create_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-image")
|
||
fallback_1 = _create_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-image-1")
|
||
fallback_2 = _create_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-image-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
portrait = Asset.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
name="QA 模特形象",
|
||
asset_type=Asset.Type.IMAGE,
|
||
source=Asset.Source.UPLOAD,
|
||
category=Asset.Category.MODEL_PORTRAIT,
|
||
in_library=False,
|
||
)
|
||
AssetFile.objects.create(
|
||
asset=portrait,
|
||
object_key="qa/model-portrait.png",
|
||
bucket="qa-browser",
|
||
content_type="image/png",
|
||
preview_url="http://qa.invalid/model-portrait.png",
|
||
is_primary=True,
|
||
)
|
||
model = None
|
||
project = None
|
||
if project_scope:
|
||
product = Product.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
title="QA 项目角色三视图商品",
|
||
)
|
||
project = Project.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
product=product,
|
||
name="QA 项目角色三视图",
|
||
)
|
||
else:
|
||
model = Model.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
name="QA 团队模特",
|
||
portrait_asset=portrait,
|
||
)
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model_config.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider.extract_first_media_url.side_effect = lambda response: response["data"][0]["url"]
|
||
provider_mocks[model_config.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or (
|
||
current.endswith("fallback_success") and model_config.id == primary.id
|
||
)
|
||
if model_config.id not in qa_model_ids:
|
||
should_fail = True
|
||
provider.image_edit.side_effect = (
|
||
requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
)
|
||
provider.image_edit.return_value = {
|
||
"data": [{"url": "http://qa.invalid/model-triview.png"}],
|
||
"usage": {"images": 1},
|
||
}
|
||
return provider
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["image"]["retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
stored = StoredObject(
|
||
bucket="qa-browser",
|
||
object_key="qa/model-triview.png",
|
||
content_type="image/png",
|
||
size_bytes=8,
|
||
)
|
||
|
||
def store_media(**kwargs):
|
||
return Asset.objects.create(
|
||
team=kwargs["team"],
|
||
created_by=kwargs["user"],
|
||
name=kwargs["name"],
|
||
asset_type=kwargs["asset_type"],
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=kwargs["category"],
|
||
origin_task=kwargs["task"],
|
||
)
|
||
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_image_provider", side_effect=provider_for),
|
||
patch("apps.ai.tasks.generate_model_triview_task.delay"),
|
||
patch("apps.ai.tasks.generate_triview_task.delay"),
|
||
patch("apps.ai.services._store_generated_media", side_effect=store_media),
|
||
patch(
|
||
"apps.ai.services.VolcanoArkProvider.media_to_bytes",
|
||
return_value=(BytesIO(b"qa-image"), "image/png"),
|
||
),
|
||
patch("apps.ai.services.TosStorage") as storage,
|
||
patch("apps.assets.review.submit_asset_for_review"),
|
||
):
|
||
storage.return_value.upload_fileobj.return_value = stored
|
||
for short_name, description in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
prefix = "project_triview" if project_scope else "model_triview"
|
||
name = f"{prefix}_{short_name}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
if project_scope:
|
||
task = generate_person_triview(
|
||
project=project,
|
||
user=user,
|
||
portrait_asset=portrait,
|
||
)
|
||
run_triview_task(task_id=str(task.id))
|
||
else:
|
||
task, task_created = generate_model_triview(model=model, user=user)
|
||
if not task_created:
|
||
raise RuntimeError("QA 模特三视图任务被错误复用")
|
||
run_model_triview_task(task_id=str(task.id))
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"reference_images": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__reference_images", flat=True
|
||
)
|
||
),
|
||
"aspect_ratios": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__aspect_ratio", flat=True
|
||
)
|
||
),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_storyboard() -> dict:
|
||
"""创建单个故事板分镜的受控成功/Fallback/全失败任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
primary = _create_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-image")
|
||
fallback_1 = _create_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-image-1")
|
||
fallback_2 = _create_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-image-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
product = Product.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
title="QA 故事板商品",
|
||
)
|
||
project = Project.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
product=product,
|
||
name="QA 故事板项目",
|
||
)
|
||
script = ScriptVersion.objects.create(
|
||
project=project,
|
||
title="QA 故事板脚本",
|
||
content="结构化脚本",
|
||
is_adopted=True,
|
||
)
|
||
segment = ScriptSegment.objects.create(
|
||
script_version=script,
|
||
sort_order=0,
|
||
visual_prompt="QA 人物展示商品",
|
||
)
|
||
shot = StoryboardShot.objects.create(
|
||
project=project,
|
||
script_segment=segment,
|
||
sort_order=0,
|
||
status=StoryboardShot.Status.QUEUED,
|
||
)
|
||
refs = [
|
||
{"url": "http://qa.invalid/storyboard-person.png", "label": "女主", "type": "person"},
|
||
{"url": "http://qa.invalid/storyboard-product.png", "label": "商品", "type": "product"},
|
||
]
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model_config.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider.extract_first_media_url.side_effect = lambda response: response["data"][0]["url"]
|
||
provider_mocks[model_config.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or (
|
||
current.endswith("fallback_success") and model_config.id == primary.id
|
||
)
|
||
if model_config.id not in qa_model_ids:
|
||
should_fail = True
|
||
provider.image_edit.side_effect = (
|
||
requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
)
|
||
provider.image_edit.return_value = {
|
||
"data": [{"url": "http://qa.invalid/storyboard.png"}],
|
||
"usage": {"images": 1},
|
||
}
|
||
return provider
|
||
|
||
def store_media(**kwargs):
|
||
return Asset.objects.create(
|
||
team=kwargs["team"],
|
||
created_by=kwargs["user"],
|
||
name=kwargs["name"],
|
||
asset_type=kwargs["asset_type"],
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=kwargs["category"],
|
||
origin_task=kwargs["task"],
|
||
)
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["image"]["retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy, STORYBOARD_MAX_PARALLEL=1),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_image_provider", side_effect=provider_for),
|
||
patch("apps.ai.services._storyboard_reference_images", return_value=refs),
|
||
patch(
|
||
"apps.ai.services.build_storyboard_frame_prompt_refs",
|
||
return_value="QA 带编号参考图故事板提示词",
|
||
),
|
||
patch("apps.ai.services._store_generated_media", side_effect=store_media),
|
||
patch("apps.assets.review.submit_asset_for_review"),
|
||
patch("apps.ai.services.notify_generation_failure"),
|
||
patch("threading.Thread"),
|
||
):
|
||
for short_name, description in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
name = f"storyboard_{short_name}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
StoryboardShot.objects.filter(id=shot.id).update(
|
||
status=StoryboardShot.Status.QUEUED,
|
||
error_message="",
|
||
)
|
||
poll_storyboard(project=project, user=user)
|
||
task = AITask.objects.filter(
|
||
project=project,
|
||
task_type=AITask.Type.STORYBOARD,
|
||
request_payload__storyboard_shot=str(shot.id),
|
||
).latest("created_at")
|
||
_storyboard_shot_worker(str(task.id), str(shot.id), str(user.id))
|
||
task.refresh_from_db()
|
||
shot.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"reference_images": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__reference_images", flat=True
|
||
)
|
||
),
|
||
"aspect_ratios": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__aspect_ratio", flat=True
|
||
)
|
||
),
|
||
"shot_versions": shot.versions.count(),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_entity() -> dict:
|
||
"""创建实体提取的受控成功/Fallback/全失败任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
primary = _create_text_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-script")
|
||
fallback_1 = _create_text_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-script-1")
|
||
fallback_2 = _create_text_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-script-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
product = Product.objects.create(team=team, created_by=user, title="QA 实体提取商品")
|
||
project = Project.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
product=product,
|
||
name="QA 实体提取项目",
|
||
)
|
||
script = ScriptVersion.objects.create(
|
||
project=project,
|
||
title="QA 实体提取脚本",
|
||
content="结构化脚本",
|
||
is_adopted=True,
|
||
)
|
||
ScriptSegment.objects.create(
|
||
script_version=script,
|
||
sort_order=0,
|
||
narration="女主在客厅展示商品",
|
||
visual_prompt="女主站在客厅",
|
||
)
|
||
valid_events = [
|
||
{
|
||
"type": "delta",
|
||
"text": (
|
||
'{"entities":['
|
||
'{"id":"c1","type":"character","name":"女主","visual_prompt":"都市女主"},'
|
||
'{"id":"s1","type":"scene","name":"客厅","visual_prompt":"现代客厅"}'
|
||
'],"segments":[{"index":0,"entity_refs":["c1","s1"]}]}'
|
||
),
|
||
},
|
||
{"type": "done"},
|
||
]
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model_config.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider_mocks[model_config.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or (
|
||
current.endswith("fallback_success") and model_config.id == primary.id
|
||
)
|
||
if model_config.id not in qa_model_ids:
|
||
should_fail = True
|
||
provider.chat_completion_stream.side_effect = (
|
||
requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
)
|
||
provider.chat_completion_stream.return_value = valid_events
|
||
return provider
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["text"]["retry_delays"] = [0, 0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services._resolve_extract_model_config", return_value=primary),
|
||
patch("apps.ai.services.get_text_provider", side_effect=provider_for),
|
||
patch("apps.ai.tasks.extract_entities_task.delay"),
|
||
):
|
||
for short_name, description in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
name = f"entity_{short_name}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
task = submit_extract_entities(project=project, user=user)
|
||
run_extract_entities_task(task_id=str(task.id))
|
||
task.refresh_from_db()
|
||
project.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"streaming": list(
|
||
task.model_attempts.values_list("request_summary__streaming", flat=True)
|
||
),
|
||
"structured_output": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__structured_output", flat=True
|
||
)
|
||
),
|
||
"entities_extracted": bool((project.metadata or {}).get("entities_extracted")),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_script() -> dict:
|
||
"""创建单镜同步优化与整稿流式生成各自的成功/Fallback/全失败任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
primary = _create_text_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-script")
|
||
fallback_1 = _create_text_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-script-1")
|
||
fallback_2 = _create_text_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-script-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
|
||
product = Product.objects.create(team=team, created_by=user, title="QA 脚本路由商品")
|
||
project = Project.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
product=product,
|
||
name="QA 脚本路由项目",
|
||
)
|
||
base_draft = {
|
||
"hook": "QA 钩子",
|
||
"tone": "自然",
|
||
"aspect_ratio": "9:16",
|
||
"total_duration": 30,
|
||
"segment_count": 2,
|
||
"entities": [],
|
||
"segments": [
|
||
{
|
||
"index": 0,
|
||
"duration": 15,
|
||
"role": "钩子",
|
||
"narration": "旧口播0",
|
||
"visual": "旧画面0",
|
||
"speaker": None,
|
||
"product_exposure": "",
|
||
"entity_refs": [],
|
||
"dialogue": [],
|
||
},
|
||
{
|
||
"index": 1,
|
||
"duration": 15,
|
||
"role": "CTA",
|
||
"narration": "旧口播1",
|
||
"visual": "旧画面1",
|
||
"speaker": None,
|
||
"product_exposure": "",
|
||
"entity_refs": [],
|
||
"dialogue": [],
|
||
},
|
||
],
|
||
}
|
||
base = ScriptVersion.objects.create(
|
||
project=project,
|
||
title="QA 基准稿",
|
||
content=json.dumps(base_draft, ensure_ascii=False),
|
||
metadata={"hook": "QA 钩子", "entities": [], "total_duration": 30},
|
||
)
|
||
ScriptSegment.objects.create(
|
||
script_version=base,
|
||
sort_order=0,
|
||
duration_seconds=15,
|
||
narration="旧口播0",
|
||
visual_prompt="旧画面0",
|
||
)
|
||
target = ScriptSegment.objects.create(
|
||
script_version=base,
|
||
sort_order=1,
|
||
duration_seconds=15,
|
||
narration="旧口播1",
|
||
visual_prompt="旧画面1",
|
||
)
|
||
revised = deepcopy(base_draft)
|
||
revised["segments"][1]["narration"] = "QA 优化口播"
|
||
revised["segments"][1]["visual"] = "QA 优化画面"
|
||
raw = json.dumps(revised, ensure_ascii=False)
|
||
events = [
|
||
{"type": "reasoning", "text": "QA 分析"},
|
||
{"type": "delta", "text": raw},
|
||
{"type": "done"},
|
||
]
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model_config.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider_mocks[model_config.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or (
|
||
current.endswith("fallback_success") and model_config.id == primary.id
|
||
)
|
||
if model_config.id not in qa_model_ids:
|
||
should_fail = True
|
||
error = requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
provider.chat_completion.side_effect = error
|
||
provider.chat_completion.return_value = {"usage": {"total_tokens": 42}}
|
||
provider.extract_text.return_value = raw
|
||
provider.chat_completion_stream.side_effect = error
|
||
provider.chat_completion_stream.return_value = events
|
||
return provider
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["text"]["retry_delays"] = [0, 0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_text_provider", side_effect=provider_for),
|
||
):
|
||
for entry, description in (("segment", "单镜同步优化"), ("stream", "整稿流式生成")):
|
||
for short_name, outcome in (
|
||
("first_success", "主模型首次成功"),
|
||
("fallback_success", "主模型重试后 Fallback 成功"),
|
||
("all_failed", "全部候选失败并释放预留"),
|
||
):
|
||
name = f"script_{entry}_{short_name}"
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
before_versions = ScriptVersion.objects.filter(project=project).count()
|
||
try:
|
||
if entry == "segment":
|
||
regenerate_segment_via_agent(
|
||
project=project,
|
||
user=user,
|
||
model_config=primary,
|
||
segment=target,
|
||
instruction="更有吸引力",
|
||
)
|
||
else:
|
||
list(
|
||
stream_script_agent(
|
||
project=project,
|
||
user=user,
|
||
model_config=primary,
|
||
mode="auto",
|
||
user_prompt="生成一版 QA 脚本",
|
||
aspect_ratio="9:16",
|
||
total_duration=30,
|
||
)
|
||
)
|
||
except Exception:
|
||
pass
|
||
task_type = (
|
||
AITask.Type.SCRIPT_OPTIMIZATION
|
||
if entry == "segment"
|
||
else AITask.Type.SCRIPT_GENERATION
|
||
)
|
||
task = AITask.objects.filter(project=project, task_type=task_type).latest("created_at")
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": f"{description}:{outcome}",
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"streaming": list(
|
||
task.model_attempts.values_list("request_summary__streaming", flat=True)
|
||
),
|
||
"structured_output": list(
|
||
task.model_attempts.values_list(
|
||
"request_summary__structured_output", flat=True
|
||
)
|
||
),
|
||
"new_versions": (
|
||
ScriptVersion.objects.filter(project=project).count() - before_versions
|
||
),
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_voice() -> dict:
|
||
"""创建当前直连锁定与未来 OpenAI 兼容候选的配音受控任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("1000"), reserved_balance=Decimal("0"))
|
||
primary = _create_audio_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-voice")
|
||
fallback_1 = _create_audio_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-voice-1")
|
||
fallback_2 = _create_audio_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-voice-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
product = Product.objects.create(team=team, created_by=user, title="QA 配音商品")
|
||
project = Project.objects.create(team=team, created_by=user, product=product, name="QA 配音项目")
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.get(model_config.id)
|
||
if provider is None:
|
||
provider = Mock()
|
||
provider.configured = True
|
||
provider_mocks[model_config.id] = provider
|
||
current = scenario["name"]
|
||
should_fail = current.endswith("all_failed") or current.endswith("locked_failed") or (
|
||
current.endswith("fallback_success") and model_config.id == primary.id
|
||
)
|
||
if model_config.id not in qa_model_ids:
|
||
should_fail = True
|
||
provider.synthesize.side_effect = (
|
||
requests.ConnectionError(f"QA controlled failure: {current}") if should_fail else None
|
||
)
|
||
provider.synthesize.return_value = (b"qa-mp3", 1200)
|
||
return provider
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["audio"]["retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
stored = Mock(object_key="qa-voice.mp3", bucket="qa", content_type="audio/mpeg", size_bytes=6)
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_audio_provider", side_effect=provider_for),
|
||
patch("apps.ai.services.TosStorage.upload_fileobj", return_value=stored),
|
||
):
|
||
for name, description, outbound in (
|
||
("voice_locked_first_success", "当前直连首次成功", False),
|
||
("voice_locked_failed", "当前直连失败只重试不外切", False),
|
||
("voice_fallback_success", "未来打开开关后动态 Fallback 成功", True),
|
||
("voice_all_failed", "未来候选全部失败并释放预留", True),
|
||
):
|
||
routing = dict((primary.metadata or {}).get("routing") or {})
|
||
routing["fallback_on_failure"] = outbound
|
||
primary.metadata = {**(primary.metadata or {}), "routing": routing}
|
||
primary.save(update_fields=["metadata", "updated_at"])
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
before_assets = Asset.objects.filter(team=team, asset_type=Asset.Type.AUDIO).count()
|
||
try:
|
||
synthesize_project_voiceover(
|
||
project=project,
|
||
user=user,
|
||
items=[{"index": 0, "text": "QA 配音旁白"}],
|
||
voice_type="BV700_streaming",
|
||
speed_ratio=1.0,
|
||
)
|
||
except Exception:
|
||
pass
|
||
task = AITask.objects.filter(project=project, task_type=AITask.Type.VOICEOVER).latest("created_at")
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"models": list(task.model_attempts.values_list("model_name", flat=True)),
|
||
"operations": list(task.model_attempts.values_list("operation", flat=True)),
|
||
"new_assets": Asset.objects.filter(team=team, asset_type=Asset.Type.AUDIO).count()
|
||
- before_assets,
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {
|
||
"balance": str(account.balance),
|
||
"reserved_balance": str(account.reserved_balance),
|
||
},
|
||
}
|
||
|
||
|
||
def setup_video() -> dict:
|
||
"""创建视频片段提交、Fallback、状态未知与全部失败的受控任务。"""
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("10000"), reserved_balance=Decimal("0"))
|
||
primary = _create_video_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-video")
|
||
fallback_1 = _create_video_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-video-1")
|
||
fallback_2 = _create_video_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-video-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
product = Product.objects.create(team=team, created_by=user, title="QA 视频商品")
|
||
project = Project.objects.create(team=team, created_by=user, product=product, name="QA 视频片段项目")
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.setdefault(model_config.id, Mock())
|
||
current = scenario["name"]
|
||
provider.create_video_task.side_effect = None
|
||
provider.create_video_task.return_value = {
|
||
"id": f"qa-{current}-{model_config.id}",
|
||
"status": "queued",
|
||
}
|
||
if current == "video_state_unknown" and model_config.id == primary.id:
|
||
provider.create_video_task.side_effect = requests.ReadTimeout("QA response lost")
|
||
elif current == "video_all_failed" or (
|
||
current == "video_fallback_success" and model_config.id == primary.id
|
||
) or model_config.id not in qa_model_ids:
|
||
provider.create_video_task.side_effect = requests.ConnectionError(
|
||
f"QA controlled failure: {current}"
|
||
)
|
||
provider.poll_video_task.return_value = {
|
||
"status": "succeeded",
|
||
"usage": {"total_tokens": 300000},
|
||
"content": {"video_url": "https://qa.invalid/video.mp4"},
|
||
}
|
||
provider.extract_first_media_url.return_value = "https://qa.invalid/video.mp4"
|
||
return provider
|
||
|
||
def store_media(**kwargs):
|
||
return Asset.objects.create(
|
||
team=kwargs["team"],
|
||
created_by=kwargs["user"],
|
||
name=kwargs["name"],
|
||
asset_type=Asset.Type.VIDEO,
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=Asset.Category.VIDEO_CLIP,
|
||
origin_task=kwargs["task"],
|
||
)
|
||
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["video"]["submit_retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.services.get_default_model", return_value=primary),
|
||
patch("apps.ai.services.get_video_provider", side_effect=provider_for),
|
||
patch("apps.ai.services._video_reference_images", return_value=[]),
|
||
patch("apps.ai.services.build_video_segment_prompt", return_value="QA 视频提示词"),
|
||
patch("apps.ai.services._store_generated_media", side_effect=store_media),
|
||
patch("apps.ai.services.notify_generation_failure"),
|
||
):
|
||
for index, (name, description) in enumerate(
|
||
(
|
||
("video_first_success", "视频片段首次提交成功并成片"),
|
||
("video_fallback_success", "视频片段原模型重试后动态 Fallback 并成片"),
|
||
("video_state_unknown", "视频片段提交状态未知且禁止重提"),
|
||
("video_all_failed", "视频片段所有候选明确失败并释放预留"),
|
||
)
|
||
):
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
segment = VideoSegment.objects.create(
|
||
project=project,
|
||
sort_order=index,
|
||
target_duration_seconds=15,
|
||
)
|
||
before_assets = Asset.objects.filter(team=team, category=Asset.Category.VIDEO_CLIP).count()
|
||
try:
|
||
submit_video_segment(video_segment=segment, user=user, prompt="QA")
|
||
poll_video_segment(video_segment=segment, user=user)
|
||
except Exception:
|
||
pass
|
||
task = AITask.objects.filter(
|
||
project=project,
|
||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||
request_payload__video_segment_id=str(segment.id),
|
||
).latest("created_at")
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"models": list(task.model_attempts.values_list("model_name", flat=True)),
|
||
"provider_task_id": task.provider_task_id,
|
||
"new_assets": Asset.objects.filter(
|
||
team=team, category=Asset.Category.VIDEO_CLIP
|
||
).count()
|
||
- before_assets,
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {"balance": str(account.balance), "reserved_balance": str(account.reserved_balance)},
|
||
}
|
||
|
||
|
||
def setup_free_video() -> dict:
|
||
"""创建自由创作视频多素材提交与动态 Fallback 的受控任务。"""
|
||
from apps.ai.free_video import finalize_free_video, submit_free_video
|
||
|
||
cleanup()
|
||
user = User.objects.create(username=QA_USERNAME, status=User.Status.ACTIVE)
|
||
user.set_unusable_password()
|
||
user.save(update_fields=["password"])
|
||
team = Team.objects.create(name=QA_TEAM_NAME, owner=user)
|
||
TeamMember.objects.create(
|
||
team=team,
|
||
user=user,
|
||
role=TeamMember.Role.OWNER,
|
||
status=TeamMember.Status.ACTIVE,
|
||
)
|
||
CreditAccount.objects.create(team=team, balance=Decimal("10000"), reserved_balance=Decimal("0"))
|
||
primary = _create_video_model(QA_PROVIDER_NAMES[0], -300, "qa-primary-free-video")
|
||
fallback_1 = _create_video_model(QA_PROVIDER_NAMES[1], -200, "qa-fallback-free-video-1")
|
||
fallback_2 = _create_video_model(QA_PROVIDER_NAMES[2], -100, "qa-fallback-free-video-2")
|
||
qa_model_ids = {primary.id, fallback_1.id, fallback_2.id}
|
||
scenario = {"name": ""}
|
||
provider_mocks: dict[object, Mock] = {}
|
||
|
||
def provider_for(model_config: ModelConfig) -> Mock:
|
||
provider = provider_mocks.setdefault(model_config.id, Mock())
|
||
current = scenario["name"]
|
||
provider.create_video_task.side_effect = None
|
||
provider.create_video_task.return_value = {
|
||
"id": f"qa-{current}-{model_config.id}",
|
||
"status": "queued",
|
||
}
|
||
if current == "free_video_state_unknown" and model_config.id == primary.id:
|
||
provider.create_video_task.side_effect = requests.ReadTimeout("QA response lost")
|
||
elif current == "free_video_all_failed" or (
|
||
current == "free_video_fallback_success" and model_config.id == primary.id
|
||
) or model_config.id not in qa_model_ids:
|
||
provider.create_video_task.side_effect = requests.ConnectionError(
|
||
f"QA controlled failure: {current}"
|
||
)
|
||
provider.poll_video_task.return_value = {
|
||
"status": "succeeded",
|
||
"usage": {"total_tokens": 30000},
|
||
"content": {"video_url": "https://qa.invalid/free-video.mp4"},
|
||
}
|
||
provider.extract_first_media_url.return_value = "https://qa.invalid/free-video.mp4"
|
||
return provider
|
||
|
||
def store_media(*, task, media):
|
||
del media
|
||
return Asset.objects.create(
|
||
team=task.team,
|
||
created_by=task.created_by,
|
||
name="QA 自由创作视频",
|
||
asset_type=Asset.Type.VIDEO,
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=Asset.Category.FREE_CREATE,
|
||
origin_task=task,
|
||
)
|
||
|
||
references = [
|
||
{"url": "https://qa.invalid/ref.png", "type": "image", "label": "图片"},
|
||
{"url": "https://qa.invalid/ref.mp4", "type": "video", "label": "视频", "duration": 1},
|
||
{"url": "https://qa.invalid/ref.mp3", "type": "audio", "label": "音频"},
|
||
]
|
||
policy = deepcopy(settings.MODEL_ROUTING_POLICY)
|
||
policy["video"]["submit_retry_delays"] = [0]
|
||
policy["jitter_ratio"] = 0
|
||
created: list[dict] = []
|
||
with (
|
||
override_settings(MODEL_ROUTING_POLICY=policy),
|
||
patch("apps.ai.free_video.FREE_VIDEO_MODELS", {primary.name}),
|
||
patch("apps.ai.services.get_video_provider", side_effect=provider_for),
|
||
patch("apps.ai.tasks.poll_free_video_task.apply_async"),
|
||
patch("apps.ai.free_video._store_free_video_media", side_effect=store_media),
|
||
patch("apps.ai.free_video._notify_failure"),
|
||
):
|
||
for name, description in (
|
||
("free_video_first_success", "自由创作多素材首次提交成功并成片"),
|
||
("free_video_fallback_success", "自由创作多素材原模型重试后 Fallback 并成片"),
|
||
("free_video_state_unknown", "自由创作提交状态未知且禁止重提"),
|
||
("free_video_all_failed", "自由创作所有候选明确失败并释放预留"),
|
||
):
|
||
scenario["name"] = name
|
||
provider_mocks.clear()
|
||
before_assets = Asset.objects.filter(team=team, category=Asset.Category.FREE_CREATE).count()
|
||
task = submit_free_video(
|
||
team=team,
|
||
user=user,
|
||
params={
|
||
"prompt": "QA 多素材自由创作",
|
||
"mode": "universal",
|
||
"model": primary.name,
|
||
"aspect_ratio": "16:9",
|
||
"resolution": "480p",
|
||
"duration": 4,
|
||
"generate_audio": True,
|
||
"references": references,
|
||
},
|
||
)
|
||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||
task = finalize_free_video(task=task)
|
||
task.refresh_from_db()
|
||
created.append(
|
||
{
|
||
"scenario": name,
|
||
"description": description,
|
||
"task_id": str(task.id),
|
||
"status": task.status,
|
||
"attempts": task.model_attempts.count(),
|
||
"models": list(task.model_attempts.values_list("model_name", flat=True)),
|
||
"provider_task_id": task.provider_task_id,
|
||
"new_assets": Asset.objects.filter(
|
||
team=team, category=Asset.Category.FREE_CREATE
|
||
).count()
|
||
- before_assets,
|
||
"ledger": _ledger_counts(task),
|
||
}
|
||
)
|
||
|
||
account = CreditAccount.objects.get(team=team)
|
||
return {
|
||
"team": QA_TEAM_NAME,
|
||
"tasks": created,
|
||
"account": {"balance": str(account.balance), "reserved_balance": str(account.reserved_balance)},
|
||
}
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument(
|
||
"action",
|
||
choices=(
|
||
"setup",
|
||
"setup-reference",
|
||
"setup-tryon",
|
||
"setup-platform",
|
||
"setup-base",
|
||
"setup-model-triview",
|
||
"setup-project-triview",
|
||
"setup-storyboard",
|
||
"setup-entity",
|
||
"setup-script",
|
||
"setup-voice",
|
||
"setup-video",
|
||
"setup-free-video",
|
||
"cleanup",
|
||
),
|
||
)
|
||
args = parser.parse_args()
|
||
_assert_safe()
|
||
if args.action == "setup":
|
||
result = setup()
|
||
elif args.action == "setup-reference":
|
||
result = setup(with_references=True)
|
||
elif args.action == "setup-tryon":
|
||
result = setup(tryon=True)
|
||
elif args.action == "setup-platform":
|
||
result = setup(platform=True)
|
||
elif args.action == "setup-base":
|
||
result = setup_base()
|
||
elif args.action == "setup-model-triview":
|
||
result = setup_model_triview()
|
||
elif args.action == "setup-project-triview":
|
||
result = setup_model_triview(project_scope=True)
|
||
elif args.action == "setup-storyboard":
|
||
result = setup_storyboard()
|
||
elif args.action == "setup-entity":
|
||
result = setup_entity()
|
||
elif args.action == "setup-script":
|
||
result = setup_script()
|
||
elif args.action == "setup-voice":
|
||
result = setup_voice()
|
||
elif args.action == "setup-video":
|
||
result = setup_video()
|
||
elif args.action == "setup-free-video":
|
||
result = setup_free_video()
|
||
else:
|
||
result = {"cleaned": cleanup()}
|
||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|