大量优化修改扣积分规则

This commit is contained in:
Azmat@qq.com
2026-09-04 17:20:22 +08:00
parent 3e904479d9
commit 15718a60bf
41 changed files with 1590 additions and 429 deletions
+27 -3
View File
@@ -4,6 +4,7 @@ from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.model_routing import model_metadata_errors, provider_metadata_errors
from apps.billing.points_rules import normalize_points_pricing_for_save, points_pricing_errors
from apps.ai.models import AIModelAttempt, AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
@@ -247,12 +248,35 @@ class AdminModelConfigSerializer(serializers.ModelSerializer):
def validate(self, attrs):
attrs = super().validate(attrs)
if self.instance is None or "metadata" in attrs or "capability" in attrs:
if self.instance is None or "metadata" in attrs or "capability" in attrs or "unit_price" in attrs:
capability = attrs.get("capability", getattr(self.instance, "capability", ""))
metadata = attrs.get("metadata", getattr(self.instance, "metadata", {}))
errors = model_metadata_errors(capability, metadata)
if not isinstance(metadata, dict):
metadata = {}
metadata = normalize_points_pricing_for_save(capability, metadata)
# 图片/文本:表单 unit_price 与挂牌积分同步
if "unit_price" in attrs and capability in {"image", "text", "vision"}:
from decimal import Decimal
try:
pts = int(Decimal(str(attrs.get("unit_price") or 0)))
except Exception:
pts = 0
pricing = dict(metadata.get("points_pricing") or {})
if capability == "image":
pricing.update({"mode": "per_image", "points_per_image": max(pts, 0)})
else:
pricing.update({"mode": "per_call", "points_per_call": max(pts, 0)})
metadata["points_pricing"] = pricing
attrs["metadata"] = metadata
# 视频有挂牌秒价时,把展示用 unit_price 写成最低档秒积分,列表更好读
if capability == "video":
tiers = (metadata.get("points_pricing") or {}).get("tiers") or []
secs = [t.get("points_per_second") for t in tiers if isinstance(t, dict) and t.get("points_per_second") is not None]
if secs:
attrs["unit_price"] = min(int(x) for x in secs)
errors = list(model_metadata_errors(capability, metadata)) + list(points_pricing_errors(capability, metadata))
if errors:
raise serializers.ValidationError({"metadata": list(errors)})
raise serializers.ValidationError({"metadata": errors})
return attrs
+18 -2
View File
@@ -13,6 +13,7 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.model_catalog import invalidate_model_catalog_cache
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
@@ -45,6 +46,14 @@ logger = logging.getLogger(__name__)
# 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。
_ADMIN_TASK_POLL_LIMIT = 20
_ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING)
# 任务监控「生成中」Tab:含已创建/已预留/已提交/轮询/后处理(与自由创作 IN_FLIGHT 对齐)
_ADMIN_TASK_INFLIGHT_STATUSES = (
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
)
def _team_qs():
@@ -463,7 +472,9 @@ def admin_tasks(request):
.order_by("-created_at")
)
st = request.query_params.get("status")
if st in dict(AITask.Status.choices):
if st in {"generating", "running", "in_flight"}:
qs = qs.filter(status__in=_ADMIN_TASK_INFLIGHT_STATUSES)
elif st in dict(AITask.Status.choices):
qs = qs.filter(status=st)
tt = request.query_params.get("task_type")
if tt in dict(AITask.Type.choices):
@@ -772,11 +783,12 @@ def admin_provider_detail(request, provider_id):
return Response(AdminModelProviderSerializer(ModelProvider.objects.annotate(model_count_anno=Count("models", distinct=True)).get(id=obj.id)).data)
@api_view(["GET", "POST"])
@permission_classes([IsPlatformAdmin])
def admin_models(request):
if request.method == "GET":
qs = ModelConfig.objects.select_related("provider").order_by("provider__name", "capability", "created_at")
qs = ModelConfig.objects.select_related("provider").order_by("-is_default", "status", "capability", "provider__name", "created_at")
prov = request.query_params.get("provider")
if prov:
qs = qs.filter(provider_id=prov)
@@ -787,6 +799,7 @@ def admin_models(request):
serializer = AdminModelConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
obj = serializer.save()
invalidate_model_catalog_cache()
log_admin_action(request, "model.create", target_type="model_config", target_id=obj.id, target_name=f"{obj.provider_id}:{obj.name}")
return Response(AdminModelConfigSerializer(obj).data, status=status.HTTP_201_CREATED)
@@ -800,10 +813,12 @@ def admin_model_detail(request, model_id):
if request.method == "DELETE":
log_admin_action(request, "model.delete", target_type="model_config", target_id=obj.id, target_name=obj.name)
obj.delete()
invalidate_model_catalog_cache()
return Response(status=status.HTTP_204_NO_CONTENT)
serializer = AdminModelConfigSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
invalidate_model_catalog_cache()
log_admin_action(request, "model.update", target_type="model_config", target_id=obj.id, target_name=obj.name)
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
@@ -821,6 +836,7 @@ def admin_model_set_default(request, model_id):
ModelConfig.objects.filter(capability=obj.capability).exclude(id=obj.id).update(is_default=False)
obj.is_default = True
obj.save(update_fields=["is_default", "updated_at"])
invalidate_model_catalog_cache()
log_admin_action(request, "model.set_default", target_type="model_config", target_id=obj.id, target_name=f"{obj.capability}:{obj.name}")
return Response(AdminModelConfigSerializer(ModelConfig.objects.select_related("provider").get(id=obj.id)).data)
+136 -11
View File
@@ -66,6 +66,32 @@ IMAGE_MODEL_BY_LABEL = {
# 「智能时长」= 交给我们定,取一个口播讲得完又不烧钱的中间值
SMART_DURATION = 15
# 全能创作 video_prompt 写作规范:从专业创作 / 一键成片(口播脚本)抽硬规则,
# 适配「整段出片指令」而不是 ScriptDraft JSON。只要求口径接近,不改工具形态。
_OMNI_VIDEO_PROMPT_RULES = """
【出片脚本写法 · 对齐专业口播】
表现形式默认**口播带货**,像真人在讲刚遇到的一件事,不要详情页朗读或主播念稿。
video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,建议结构:
- 片头写清:总时长、画幅、整体光线/色调、口播语气(口语、有停顿与立场)
- 然后按「0-3s / 3-8s / …」逐段写,每段同时写清这五项(不能省):
1. 景别(大特写/特写/近景/中景/全景;一条片里至少切两次景别)
2. 机位(平视/俯拍/仰拍/过肩/桌面视角)
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
5. 信息变化(这几秒画面上多了/变了什么)
- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒,
15 秒约 75–85 字;太短撑不满,太长会赶。
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。
- CTA 像跟朋友说话;禁止小黄车/立即购买/闭眼入等平台指令腔。
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。
"""
class AgentError(Exception):
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
@@ -353,8 +379,9 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"name": "write_plan",
"description": (
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
"确认后平台直接按 video_prompt 出片,你不会再有插话机会 —— 所以 video_prompt "
"必须是完整、可独立执行的成片指令。先调 write_strategy 再调它。"
"确认后平台直接按 video_prompt 出片,你不会再有插话机会。"
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
"先调 write_strategy 再调它。"
),
"parameters": {
"type": "object",
@@ -383,9 +410,11 @@ def tool_schemas(context: AgentContext) -> list[dict]:
"video_prompt": {
"type": "string",
"description": (
"交给出片模型的完整指令:秒级分镜(每镜画面/动作/机位/光线)、口播原文、"
"风格锚点、一致性要求。已 @ 的素材会自动作为参考图附上,"
"不要在这里重复描述它们的长相。**不要写字幕相关要求。**"
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
"(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
"已 @ 素材会自动作参考图,勿重描长相。"
),
},
},
@@ -464,9 +493,68 @@ def _coerce_fields(raw) -> list[dict]:
return fields
def _normalize_model_label(value: str) -> str:
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
def image_model_name(params: dict) -> str | None:
"""出图模型 label → 供应商模型名;目录优先,认不出返回 None 让下游用默认。"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return None
mapped = IMAGE_MODEL_BY_LABEL.get(label)
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
return hit.name if hit else None
def video_model_name(params: dict) -> str:
"""会话参数里的模型 label → 火山模型名。认不出就回落 2.5(最长 30 秒那档)。"""
return VIDEO_MODEL_BY_LABEL.get(str(params.get("model") or ""), DEFAULT_VIDEO_MODEL)
"""会话参数里的模型 label → 供应商模型名。
先认历史写死映射,再按 ModelConfig.display_name / name 查目录 —— 后台新加模型不用改代码。
"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return DEFAULT_VIDEO_MODEL
mapped = VIDEO_MODEL_BY_LABEL.get(label)
if not mapped:
norm = _normalize_model_label(label)
for k, v in VIDEO_MODEL_BY_LABEL.items():
if _normalize_model_label(k) == norm:
mapped = v
break
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
if hit is None:
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name__icontains=label) | Q(name__icontains=label))
.order_by("created_at")
.first()
)
return hit.name if hit else DEFAULT_VIDEO_MODEL
def video_duration(params: dict) -> int:
@@ -531,8 +619,9 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
mode="image",
count=count,
ratio=params.get("ratio") or None,
image_model=IMAGE_MODEL_BY_LABEL.get(str(params.get("model") or ""), params.get("model") or None),
image_model=image_model_name(params) or params.get("model") or None,
reference_image_ids=reference_image_ids or None,
feature="omni_create",
)
context.generations_used += 1
return (
@@ -587,6 +676,27 @@ def estimate_video_credits(context: AgentContext) -> int:
return 0
def estimate_image_credits(context: AgentContext) -> int:
"""出图确认卡预计积分:挂牌单价(含团队系数)逐张取整后再 × 张数,与 enqueue 逐任务预留同口径。"""
from apps.billing.pricing import quote_flat
from apps.ai.services import resolve_image_model, get_default_model
params = context.conversation.params or {}
model_name = image_model_name(params) or str(params.get("model") or "").strip() or None
model_config = resolve_image_model(model_name) if model_name else None
if model_config is None:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
return 0
count = _image_count(params, None)
try:
per = quote_flat(model_config, units=1, team=context.team)
return int(per.points) * count
except Exception: # noqa: BLE001
logger.warning("omni create: image estimate failed", exc_info=True)
return 0
def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
@@ -764,7 +874,18 @@ def build_system_prompt(context: AgentContext) -> str:
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
]
if not context.is_video:
if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try:
dur = video_duration(params)
except Exception: # noqa: BLE001
dur = SMART_DURATION
lo = max(1, int(dur * 5.0))
hi = max(lo, min(85, int(dur * 5.7)))
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。")
lines.append("- 写方案时必须调用 write_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。")
else:
lines.extend([
"",
"【出图】",
@@ -1106,20 +1227,24 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
if not prompt:
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
credits = estimate_image_credits(context)
confirm = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={
"kind": "image",
"label": "开始生成",
"estimated_credits": 0,
"estimated_credits": credits,
"prompt": prompt,
"submitted": False,
"params": snapshot_session_params(context.conversation),
"param_options": confirm_param_options(False),
},
)
events = [{"type": "message", "message": _message_payload(confirm)}]
events = [
{"type": "message", "message": _message_payload(confirm)},
{"type": "credits", "estimated": credits},
]
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
return {"payload": {"error": f"未知工具 {name}"}}, False
+158
View File
@@ -0,0 +1,158 @@
"""从已落库脚本本地拆出角色/场景 —— 不调模型、不扣积分。
脚本生成时已要求结构化 entities;这里负责:
1. 读 ScriptVersion.metadata / content 里的 entities
2. 只保留 character / scene(商品不抽)
3. 至少保证 1 角色 + 1 场景(缺则按分镜补默认)
4. 回填 project.metadata + 每镜 entity_refs + entities_extracted
"""
from __future__ import annotations
import json
import re
from typing import Any
def _as_list(value: Any) -> list:
return value if isinstance(value, list) else []
def _load_draft_entities(script) -> list[dict]:
meta = script.metadata if isinstance(script.metadata, dict) else {}
ents = _as_list(meta.get("entities"))
if ents:
return [e for e in ents if isinstance(e, dict)]
raw = (script.content or "").strip()
if not raw:
return []
try:
data = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(data, dict):
return []
return [e for e in _as_list(data.get("entities")) if isinstance(e, dict)]
def _normalize_entities(raw: list[dict]) -> list[dict]:
out: list[dict] = []
seen: set[str] = set()
for i, ent in enumerate(raw):
etype = str(ent.get("type") or "").strip().lower()
if etype in {"person", "cast", "role"}:
etype = "character"
if etype in {"location", "bg", "background"}:
etype = "scene"
if etype not in {"character", "scene"}:
continue
eid = str(ent.get("id") or f"{'c' if etype == 'character' else 's'}{i + 1}").strip()
while eid in seen:
eid = f"{eid}_{i}"
seen.add(eid)
name = str(ent.get("name") or eid).strip() or eid
visual = str(ent.get("visual_prompt") or ent.get("prompt") or "").strip()
if not visual:
visual = (
f"{name},全身出镜,自然光,9:16竖屏"
if etype == "character"
else f"{name},环境空镜,干净构图,16:9横屏"
)
out.append(
{
"id": eid,
"type": etype,
"name": name,
"visual_prompt": visual,
"ref_index": len(out) + 1,
}
)
return out
def _default_character(segments) -> dict:
# 优先用第一镜画面里像「人」的描述,否则通用主角
hint = ""
for seg in segments:
text = f"{getattr(seg, 'visual_prompt', '') or ''} {getattr(seg, 'narration', '') or ''}"
if re.search(r"女|男|人|主播|模特|客户|宝妈|姐姐|哥哥", text):
hint = (getattr(seg, "visual_prompt", None) or text).strip()
break
visual = hint[:80] if hint else "电商短视频出镜主角,全身,自然妆容,干净背景,9:16竖屏"
return {
"id": "c1",
"type": "character",
"name": "主角",
"visual_prompt": visual,
"ref_index": 1,
}
def _default_scene(segments) -> dict:
hint = ""
for seg in segments:
text = (getattr(seg, "visual_prompt", None) or "").strip()
if text:
hint = text
break
visual = hint[:80] if hint else "干净室内场景,柔和光线,适合电商口播,16:9横屏"
return {
"id": "s1",
"type": "scene",
"name": "主场景",
"visual_prompt": visual,
"ref_index": 1,
}
def _ensure_min_entities(entities: list[dict], segments) -> list[dict]:
has_char = any(e["type"] == "character" for e in entities)
has_scene = any(e["type"] == "scene" for e in entities)
if not has_char:
entities.append(_default_character(segments))
if not has_scene:
entities.append(_default_scene(segments))
# 重排 ref_index
for i, e in enumerate(entities):
e["ref_index"] = i + 1
return entities
def _backfill_segment_refs(segments, entities: list[dict]) -> None:
valid = {e["id"] for e in entities}
char_ids = [e["id"] for e in entities if e["type"] == "character"]
scene_ids = [e["id"] for e in entities if e["type"] == "scene"]
default_refs = (char_ids[:1] + scene_ids[:1]) or list(valid)[:2]
for seg in segments:
refs = [r for r in (seg.entity_refs or []) if r in valid]
speaker = (seg.speaker or "").strip()
if speaker and speaker in valid and speaker not in refs:
refs.append(speaker)
if not refs:
refs = list(default_refs)
if refs != (seg.entity_refs or []):
seg.entity_refs = refs
seg.save(update_fields=["entity_refs", "updated_at"])
def materialize_script_entities(*, project, script) -> list[dict]:
"""本地拆角色/场景并落库。返回规范化后的 entities 列表。"""
from django.db import transaction
from apps.ai.script_agent import _map_entities_to_project_metadata
segments = list(script.segments.order_by("sort_order"))
entities = _ensure_min_entities(_normalize_entities(_load_draft_entities(script)), segments)
with transaction.atomic():
_map_entities_to_project_metadata(project, entities)
md = dict(project.metadata or {})
md["entities_extracted"] = True
md["entities_extract_mode"] = "local"
project.metadata = md
project.save(update_fields=["metadata", "updated_at"])
_backfill_segment_refs(segments, entities)
# 同步写回脚本 metadata.entities,方便下次读
sm = dict(script.metadata or {})
sm["entities"] = entities
script.metadata = sm
script.save(update_fields=["metadata", "updated_at"])
return entities
+12 -18
View File
@@ -25,7 +25,7 @@ from django.utils import timezone
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
from apps.assets.storage import TosStorage
from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount
from apps.billing.pricing import settle_video_from_payload, quote_video_estimate, video_quote_payload, video_reserve_amount
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from .models import AITask, ModelConfig
@@ -530,7 +530,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
references=built["snapshots"],
team=team,
)
reserve_amount = video_reserve_amount(quote.points)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
request_payload = {
"feature": feature,
@@ -546,9 +546,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
"generate_audio": generate_audio,
"search_mode": search_mode,
"estimated_tokens": tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
# 计价快照:挂牌秒价/团队系数下单时钉死,结算只认这些
**video_quote_payload(quote),
"references": built["snapshots"],
"model_routing_v1": True,
}
@@ -646,7 +645,7 @@ def start_pending_free_video(task: AITask) -> AITask:
references=built["snapshots"],
team=task.team,
)
reserve_amount = video_reserve_amount(quote.points)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
with transaction.atomic():
locked = (
@@ -671,6 +670,7 @@ def start_pending_free_video(task: AITask) -> AITask:
next_payload["references"] = built["snapshots"]
next_payload["estimated_tokens"] = tokens
next_payload["review_pending"] = False
next_payload.update(video_quote_payload(quote))
locked.request_payload = next_payload
locked.estimated_cost = quote.points
locked.status = AITask.Status.RESERVED
@@ -991,21 +991,15 @@ def finalize_free_video(*, task: AITask) -> AITask:
total_tokens = int(usage.get("total_tokens") or 0)
except (TypeError, ValueError):
total_tokens = 0
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
resolution = payload.get("resolution") or "720p"
if total_tokens > 0:
from decimal import Decimal
settle = quote_video_actual(
actual_model, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution,
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
if settle.meta.get("rule") == "missing_usage":
actual, base_cost = locked.estimated_cost, locked.base_cost
else:
actual, base_cost = settle.points, settle.base_cost_yuan
payload["actual_tokens"] = total_tokens
if total_tokens > 0:
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
else:
actual, base_cost = locked.estimated_cost, locked.base_cost
seed_out = response.get("seed")
if seed_out is not None:
payload["seed_used"] = seed_out
+14
View File
@@ -0,0 +1,14 @@
"""公开模型目录缓存(前端下拉 /api/ai/models/)。
后台增删改模型或设默认时失效,避免停用模型仍出现在创作页。
"""
from __future__ import annotations
from django.core.cache import cache
MODEL_CATALOG_CACHE_KEY = "ai_models_catalog_v1"
MODEL_CATALOG_CACHE_TTL = 60 # 秒;失效以写路径为准,TTL 兜底
def invalidate_model_catalog_cache() -> None:
cache.delete(MODEL_CATALOG_CACHE_KEY)
+11 -1
View File
@@ -1612,7 +1612,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
dialogue=seg.get("dialogue") or [],
product_points=[],
)
_map_entities_to_project_metadata(project, draft.get("entities", []))
ents = draft.get("entities") or []
_map_entities_to_project_metadata(project, ents)
# 出稿已带角色+场景时,直接视为已提取(资产页免再点付费提取)
if any((e or {}).get("type") == "character" for e in ents if isinstance(e, dict)) and any(
(e or {}).get("type") == "scene" for e in ents if isinstance(e, dict)
):
md = dict(project.metadata or {})
md["entities_extracted"] = True
md["entities_extract_mode"] = "from_script"
project.metadata = md
project.save(update_fields=["metadata", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
+50 -73
View File
@@ -1146,16 +1146,13 @@ def get_inflight_extraction(project):
def submit_extract_entities(*, project, user) -> AITask:
"""提交独立实体提取步(**异步**)。读已定稿(或最新)脚本 → 校验 + 建 RESERVED 任务 + 预留额度(秒级),
慢活(豆包思考模型流式抽取,可达数十秒)交给 Celery worker(run_extract_entities_task)跑。
"""从已定稿脚本**本地**拆出角色/场景(不调模型、不扣积分)。
这样 Web 层(gunicorn/nginx)不被数十秒的模型请求占住 → 不再 502。
**防重复扣费**:本项目已有在途提取任务时直接复用它,不新建/不二次预扣
(用户刷新后手痒重点、或并发点击都安全 —— 提取是实体唯一权威来源,本就只需跑一次)。
校验失败(无脚本/无分镜/无模型/额度不足)抛 ValueError 由端点转 400;运行期失败由 worker 记进
task.error_message,前端轮询 extract-status 读取。返回 AITask。"""
脚本生成时已带结构化 entities;这里只做规范化 + 最少 1 角色/1 场景兜底 + 落库。
仍返回一条 SUCCEEDED 的 ENTITY_EXTRACTION 任务,方便前端轮询/审计口径不变。
"""
from apps.ai.entity_local import materialize_script_entities
from apps.projects.models import ScriptVersion
from apps.ai.tasks import extract_entities_task
script = (
ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first()
@@ -1163,56 +1160,34 @@ def submit_extract_entities(*, project, user) -> AITask:
)
if script is None:
raise ValueError("请先生成并定稿脚本,再提取角色 / 场景")
segments = list(script.segments.order_by("sort_order"))
if not segments:
if not script.segments.exists():
raise ValueError("脚本没有分镜,无法提取")
inflight = get_inflight_extraction(project)
if inflight is not None:
return inflight # 已有提取在跑:复用,绝不二次预扣 / 重复出活
entities = materialize_script_entities(project=project, script=script)
model_config = _resolve_extract_model_config()
if model_config is None:
# AITask.model_config 非空;本地提取不调模型,但仍需一条配置挂审计任务
raise ValueError("没有可用的文本模型")
product = project.product
if product is not None:
sp = "、".join([p.title for p in product.selling_points.all()[:5]])
prod_line = f"名称:{product.title}\n品类:{product.category or '未填'}\n卖点:{sp or '未填'}"
else:
prod_line = "(无商品信息)"
seg_lines = [
f"镜{i} role={s.role or ''} narration={(s.narration or '').strip()} visual={(s.visual_prompt or '').strip()}"
for i, s in enumerate(segments)
]
user_msg = (
f"商品信息:\n{prod_line}\n\n分镜脚本(共 {len(segments)} 镜,index 从 0 开始):\n" + "\n".join(seg_lines)
task = AITask.objects.create(
team=project.team,
created_by=user,
project=project,
task_type=AITask.Type.ENTITY_EXTRACTION,
status=AITask.Status.SUCCEEDED,
model_config=model_config,
idempotency_key=f"entity_extraction:local:{project.id}:{uuid.uuid4()}",
request_payload={
"mode": "local",
"script_id": str(script.id),
"entity_count": len(entities),
},
response_payload={"entities": entities, "mode": "local"},
estimated_cost=Decimal("0"),
actual_cost=Decimal("0"),
base_cost=Decimal("0"),
completed_at=timezone.now(),
)
# skill 正文(领域功力)+ 写死的输出契约兜底(保证「只输出 JSON」永远在,即便 skill 丢失)
system = _load_skill_system_prompt("ecommerce-entity-extract") + _EXTRACT_OUTPUT_CONTRACT
messages = [{"role": "system", "content": system}, {"role": "user", "content": user_msg}]
try:
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.ENTITY_EXTRACTION,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"messages": messages,
"script_id": str(script.id),
"model_routing_v1": True,
},
)
except Exception as exc: # 余额不足等预扣失败
raise ValueError("额度不足,无法提取(请先充值)") from exc
# 真实平台成本由每条 AIModelAttempt 按实际模型累加,避免 Fallback 后仍记主模型旧成本。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
extract_entities_task.delay(str(task.id))
return task
@@ -3181,7 +3156,7 @@ def submit_video_segment(
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount
est_tokens, quote = quote_video_estimate(
model_config,
@@ -3204,7 +3179,7 @@ def submit_video_segment(
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
quote=quote,
reserve_amount=video_reserve_amount(quote.points),
reserve_amount=video_reserve_amount(quote.points, rule=quote.meta.get("rule")),
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
@@ -3213,8 +3188,7 @@ def submit_video_segment(
"ratio": aspect_ratio,
"resolution": resolution,
"estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
"price_multiplier": quote.meta.get("price_multiplier", "1"),
**video_quote_payload(quote),
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
"model_routing_v1": True,
@@ -3375,34 +3349,26 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
# usage 缺失(异常响应)回落预估价,不阻断出片。
from apps.billing.pricing import quote_video_actual
reservation = locked_task.credit_reservation
payload = locked_task.request_payload or {}
payload = dict(locked_task.request_payload or {})
try:
usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
except (TypeError, ValueError):
usage_tokens = 0
if usage_tokens > 0:
settle = quote_video_actual(
actual_model,
tokens=usage_tokens,
with_video_ref=False,
resolution=str(payload.get("resolution") or "720p"),
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
settle = settle_video_from_payload(actual_model, payload=payload, tokens=usage_tokens)
if settle.meta.get("rule") == "missing_usage":
actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
else:
actual_points, base_cost = settle.points, settle.base_cost_yuan
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
locked_task.request_payload = payload
if actual_points > reservation.amount:
logger.warning(
"video segment task %s actual %s exceeds reserved %s, clamped",
locked_task.id, actual_points, reservation.amount,
)
actual_points = reservation.amount
else:
actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
if usage_tokens > 0 and settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
locked_task.request_payload = payload
locked_task.status = AITask.Status.SUCCEEDED
locked_task.response_payload = response
locked_task.actual_cost = actual_points
@@ -3527,7 +3493,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, dispatch: bool = True) -> list[AITask]:
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -3641,6 +3607,8 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
for index in range(count):
quote = quote_flat(model_config, team=team)
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
if feature:
request_payload["feature"] = str(feature)
if use_model_routing:
request_payload["model_routing_v1"] = True
if tryon_classification is not None:
@@ -3899,13 +3867,22 @@ def run_standalone_image_task(*, task_id: str) -> None:
asset_meta["batch_id"] = str(payload["batch_id"])
if payload.get("model_entity_id"):
asset_meta["model_entity_id"] = str(payload["model_entity_id"])
# 全能创作出图只留在会话里,不进图片创作最近列表 / 资产库。
is_omni = str(payload.get("feature") or "") == "omni_create"
if is_omni:
asset_meta["feature"] = "omni_create"
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task,
metadata=asset_meta,
# 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library;
# 商品/创作等图片趴成图保持原有自动入库行为。
in_library=asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT),
# 全能创作同视频:不自动入库。
in_library=(
False
if is_omni
else asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT)
),
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
@@ -273,6 +273,7 @@ class GenerateImageTests(CreationAgentBaseTests):
self.assertEqual(kwargs["reference_image_ids"], [str(self.product_asset.id)])
self.assertEqual(kwargs["ratio"], "1:1") # 会话级参数直接用,不再问用户
self.assertEqual(kwargs["count"], 1)
self.assertEqual(kwargs["feature"], "omni_create")
def test_session_image_count_overrides_model_count(self):
self.conversation.params = {"ratio": "1:1", "count": "2 张"}
+14 -15
View File
@@ -23,7 +23,7 @@ from django.utils import timezone
from apps.assets.models import Asset, Model
from apps.billing.models import CreditAccount
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount
from apps.products.models import Product
from .free_video import (
@@ -1053,7 +1053,7 @@ def _legacy_start_pending_replace_shots(task):
references=built["snapshots"],
team=task.team,
)
reserve_amount = video_reserve_amount(quote.points)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
with transaction.atomic():
locked = (
@@ -1083,6 +1083,7 @@ def _legacy_start_pending_replace_shots(task):
next_payload["estimated_tokens"] = tokens
next_payload["review_pending"] = False
next_payload["duration"] = billed_duration
next_payload.update(video_quote_payload(quote))
locked.request_payload = next_payload
locked.estimated_cost = quote.points
locked.status = AITask.Status.RESERVED
@@ -1262,7 +1263,6 @@ def complete_replace_shots(task):
"""全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。"""
from decimal import Decimal
from apps.billing.pricing import quote_video_actual
from apps.billing.services.ledger import charge_reserved_credit, release_credit
from .free_video import _notify_failure, _store_free_video_media
@@ -1289,18 +1289,18 @@ def complete_replace_shots(task):
total_tokens += int(item.get("tokens") or 0)
except (TypeError, ValueError):
pass
resolution = payload.get("resolution") or "720p"
if total_tokens > 0:
settle = quote_video_actual(
locked.model_config, tokens=total_tokens, with_video_ref=True,
resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
# 挂牌秒价按快照扣;无挂牌才按 tokens true-up
if "with_ref_video" not in payload:
payload["with_ref_video"] = True # 替换片默认带视频参考
settle = settle_video_from_payload(locked.model_config, payload=payload, tokens=total_tokens)
if settle.meta.get("rule") == "missing_usage":
actual, base_cost = locked.estimated_cost, locked.base_cost
else:
actual, base_cost = settle.points, settle.base_cost_yuan
payload["actual_tokens"] = total_tokens
if total_tokens > 0:
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
else:
actual, base_cost = locked.estimated_cost, locked.base_cost
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=locked.id)
if locked.status != AITask.Status.POSTPROCESSING:
@@ -1584,7 +1584,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
references=references,
team=team,
)
reserve_amount = video_reserve_amount(quote.points)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
account = CreditAccount.objects.filter(team=team).first()
available = (account.balance - account.reserved_balance) if account else Decimal("0")
if available < reserve_amount:
@@ -1610,8 +1610,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
"generate_audio": True,
"search_mode": "off",
"estimated_tokens": tokens,
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
**video_quote_payload(quote),
"references": references,
"model_routing_v1": True,
"review_pending": True,
+6 -10
View File
@@ -14,7 +14,7 @@ from django.utils import timezone
from apps.ai.models import AITask
from apps.assets.models import Asset
from apps.billing.models import CreditReservation
from apps.billing.pricing import quote_video_actual
from apps.billing.pricing import settle_video_from_payload
from apps.projects.models import VideoSegment, VideoSegmentVersion
@@ -108,16 +108,12 @@ def _platform_cost(response: dict, *, task: AITask, actual_model, with_video_ref
total_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
except (TypeError, ValueError):
total_tokens = 0
if total_tokens <= 0:
payload = dict(task.request_payload or {})
if "with_ref_video" not in payload:
payload["with_ref_video"] = with_video_ref
settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
if settle.meta.get("rule") == "missing_usage":
return 0, task.base_cost
payload = task.request_payload or {}
settle = quote_video_actual(
actual_model,
tokens=total_tokens,
with_video_ref=with_video_ref,
resolution=str(payload.get("resolution") or "720p"),
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
return total_tokens, settle.base_cost_yuan
+25
View File
@@ -1248,6 +1248,7 @@ class FreeVideoUploadView(APIView):
class ModelConfigViewSet(ReadOnlyModelViewSet):
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致。
# DeepSeek 已停用,下拉里不再出现。
# 列表短且高频(创作页下拉),整页结果缓存;后台改模型走 invalidate_model_catalog_cache。
queryset = (
ModelConfig.objects.select_related("provider")
.filter(status=ModelConfig.Status.ACTIVE)
@@ -1259,6 +1260,30 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]
def list(self, request, *args, **kwargs):
from django.core.cache import cache
from apps.ai.model_catalog import MODEL_CATALOG_CACHE_KEY, MODEL_CATALOG_CACHE_TTL
# 缓存「无 search/ordering/capability、首页」目录。page_size=200 是创作页常规用法。
qp = request.query_params
page = str(qp.get("page") or "1")
page_size = str(qp.get("page_size") or "")
is_plain = (
not any(qp.get(k) for k in ("search", "ordering", "capability"))
and page in {"1", ""}
and page_size in {"", "200"}
)
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
if is_plain:
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
response = super().list(request, *args, **kwargs)
if is_plain and response.status_code == 200:
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
return response
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
+207
View File
@@ -0,0 +1,207 @@
"""老板挂牌积分规则(不谈供应商成本)。
存在 ModelConfig.metadata["points_pricing"]:
· image → mode=per_image, points_per_image
· text → mode=per_call, points_per_call
· video → mode=per_second, tiers=[{resolution, points_per_second, points_per_second_with_ref?}]
能力(可选分辨率/时长)在 metadata["capabilities"]。
未配置 points_pricing 时由 pricing.quote_* 回落旧口径(unit_price / token 价表)。
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
def _dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _int_points(value: Any) -> int | None:
if value is None or value is False:
return None
try:
n = int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
except Exception: # noqa: BLE001
return None
return n if n >= 0 else None
def points_pricing_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("points_pricing"))
def capabilities_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("capabilities"))
def has_points_pricing(model_config) -> bool:
pricing = points_pricing_of(model_config)
mode = str(pricing.get("mode") or "").strip()
if mode == "per_image":
return _int_points(pricing.get("points_per_image")) is not None
if mode == "per_call":
return _int_points(pricing.get("points_per_call")) is not None
if mode == "per_second":
tiers = pricing.get("tiers")
return isinstance(tiers, list) and any(isinstance(t, dict) for t in tiers)
# 兼容:只填了数字字段
return (
_int_points(pricing.get("points_per_image")) is not None
or _int_points(pricing.get("points_per_call")) is not None
)
def image_points_per_unit(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_image"))
if n is not None:
return n
# 回落 unit_price(积分/张)
return _int_points(getattr(model_config, "unit_price", None))
def text_points_per_call(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_call"))
if n is not None:
return n
return _int_points(getattr(model_config, "unit_price", None))
def video_tier(model_config, *, resolution: str, with_ref_video: bool = False) -> dict | None:
pricing = points_pricing_of(model_config)
tiers = pricing.get("tiers")
if not isinstance(tiers, list):
return None
resolution = str(resolution or "").strip().lower()
hit = None
for raw in tiers:
if not isinstance(raw, dict):
continue
if str(raw.get("resolution") or "").strip().lower() == resolution:
hit = raw
break
return hit
def video_points_per_second(model_config, *, resolution: str, with_ref_video: bool = False) -> int | None:
tier = video_tier(model_config, resolution=resolution, with_ref_video=with_ref_video)
if not tier:
return None
if with_ref_video:
n = _int_points(tier.get("points_per_second_with_ref"))
if n is not None:
return n
return _int_points(tier.get("points_per_second"))
def quote_points_per_second(*, points_per_second: int, duration: int) -> Decimal:
seconds = max(int(duration or 0), 0)
pts = max(int(points_per_second) * seconds, 1) if seconds > 0 else 0
return Decimal(pts)
def quote_points_units(*, points_per_unit: int, units: int) -> Decimal:
count = max(int(units or 0), 0)
if count <= 0:
return Decimal("0")
return Decimal(max(int(points_per_unit) * count, 1))
def normalize_points_pricing_for_save(capability: str, metadata: dict | None) -> dict:
"""整理 metadata:补齐 points_pricing.mode,并让 unit_price 与挂牌积分对齐(兼容旧读法)。"""
root = dict(metadata or {})
pricing = dict(_dict(root.get("points_pricing")))
caps = dict(_dict(root.get("capabilities")))
capability = str(capability or "")
if capability == "image":
pts = _int_points(pricing.get("points_per_image"))
if pts is not None:
pricing["mode"] = "per_image"
pricing["points_per_image"] = pts
root["points_pricing"] = pricing
return root
if capability in {"text", "vision"}:
pts = _int_points(pricing.get("points_per_call"))
if pts is not None:
pricing["mode"] = "per_call"
pricing["points_per_call"] = pts
root["points_pricing"] = pricing
return root
if capability == "video":
tiers_in = pricing.get("tiers") if isinstance(pricing.get("tiers"), list) else []
tiers: list[dict] = []
resolutions: list[str] = []
for raw in tiers_in:
if not isinstance(raw, dict):
continue
res = str(raw.get("resolution") or "").strip()
pps = _int_points(raw.get("points_per_second"))
if not res or pps is None:
continue
row = {"resolution": res, "points_per_second": pps}
with_ref = _int_points(raw.get("points_per_second_with_ref"))
if with_ref is not None:
row["points_per_second_with_ref"] = with_ref
tiers.append(row)
if res not in resolutions:
resolutions.append(res)
if tiers:
pricing["mode"] = "per_second"
pricing["tiers"] = tiers
# 能力分辨率与价表对齐,前端下拉只出有价的档
if not caps.get("resolutions"):
caps["resolutions"] = resolutions
else:
# 保留已配能力,但确保价表分辨率都在里面
existing = [str(x) for x in (caps.get("resolutions") or [])]
for res in resolutions:
if res not in existing:
existing.append(res)
caps["resolutions"] = existing
root["points_pricing"] = pricing
root["capabilities"] = caps
return root
root["points_pricing"] = pricing
return root
def points_pricing_errors(capability: str, metadata: Any) -> tuple[str, ...]:
"""可选校验:填了 points_pricing 就要完整。"""
root = _dict(metadata)
pricing = _dict(root.get("points_pricing"))
if not pricing:
return ()
errors: list[str] = []
mode = str(pricing.get("mode") or "").strip()
if capability == "image":
if _int_points(pricing.get("points_per_image")) is None and mode in {"", "per_image"}:
if "points_per_image" in pricing:
errors.append("points_pricing.points_per_image 必须是 ≥0 的整数积分")
elif capability in {"text", "vision"}:
if "points_per_call" in pricing and _int_points(pricing.get("points_per_call")) is None:
errors.append("points_pricing.points_per_call 必须是 ≥0 的整数积分")
elif capability == "video":
tiers = pricing.get("tiers")
if tiers is None:
return tuple(errors)
if not isinstance(tiers, list) or not tiers:
errors.append("视频 points_pricing.tiers 至少配置一档分辨率积分")
else:
for i, raw in enumerate(tiers):
if not isinstance(raw, dict):
errors.append(f"tiers[{i}] 必须是对象")
continue
if not str(raw.get("resolution") or "").strip():
errors.append(f"tiers[{i}].resolution 不能为空")
if _int_points(raw.get("points_per_second")) is None:
errors.append(f"tiers[{i}].points_per_second 必须是 ≥0 的整数积分")
if "points_per_second_with_ref" in raw and _int_points(raw.get("points_per_second_with_ref")) is None:
errors.append(f"tiers[{i}].points_per_second_with_ref 必须是 ≥0 的整数积分")
return tuple(errors)
+150 -14
View File
@@ -10,8 +10,9 @@
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。
· 视频:metadata.pricing 是火山**成本价表**(元/百万tokens,见 apps/ai/video_pricing.py),
用户价 = ¥成本 × video_margin_multiplier → 积分。按真实 usage.total_tokens 结算。
· 视频:优先 metadata.points_pricing 挂牌(分辨率积分/秒 × 时长;可配含视频参考价);
未挂牌才走 metadata.pricing 火山成本表 × 毛利 → 积分,并按 usage.total_tokens true-up。
挂牌任务预留=精确积分、结算认下单快照;token 任务预留仍 × video_reserve_buffer。
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
¥成本先 quantize 到 0.01(video_pricing.calculate_cost 现状)→ ×毛利 ×汇率 → ROUND_HALF_UP
@@ -100,19 +101,38 @@ def _pricing_meta(model_config) -> dict:
def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
"""文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。"""
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
"""文本(次)/ 图像(张)。优先 metadata.points_pricing 挂牌;否则 unit_price;≤0 回落默认积分。team 传入则乘团队价格系数。"""
from apps.billing.points_rules import image_points_per_unit, text_points_per_call
capability = str(getattr(model_config, "capability", "") or "")
listed = None
if capability == "image":
listed = image_points_per_unit(model_config)
elif capability in {"text", "vision"}:
listed = text_points_per_call(model_config)
listed_from_hang = listed is not None and listed > 0
if listed_from_hang:
unit_points = Decimal(listed)
else:
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
if unit_points <= 0:
is_image = str(getattr(model_config, "capability", "")) == "image"
is_image = capability == "image"
unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS
unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
multiplier = team_price_multiplier(team)
points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier)
# 挂牌:image=per_image / text=per_call;未挂牌仍记 flat(unit_price 或默认)
if listed_from_hang and capability == "image":
rule = "per_image"
elif listed_from_hang and capability in {"text", "vision"}:
rule = "per_call"
else:
rule = "flat"
return Quote(
points=points,
base_cost_yuan=base_per_unit * units,
meta={"rule": "flat", "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
meta={"rule": rule, "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
)
@@ -176,25 +196,141 @@ def quote_video_from_cost(cost_yuan: Decimal, *, tokens: int = 0, multiplier: De
def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]:
"""预估:apps/ai/video_pricing 的 ¥成本 × 毛利 → 积分 → ×团队当前系数。返回 (tokens, Quote)。"""
from apps.ai.video_pricing import estimate_video_cost
"""预估视频积分。优先老板挂牌(分辨率×秒);未配置则回落 token×成本×毛利。返回 (tokens占位, Quote)。"""
from apps.ai.video_pricing import estimate_video_cost, has_video_reference
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
with_ref = has_video_reference(references)
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_ref)
multiplier = team_price_multiplier(team)
if pps is not None:
list_points = quote_points_per_second(points_per_second=pps, duration=duration)
points = apply_team_price(list_points, multiplier)
return 0, Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": duration,
"with_ref_video": with_ref,
"points_per_second": pps,
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
tokens, cost_yuan = estimate_video_cost(
model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references
)
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=team_price_multiplier(team))
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def quote_video_actual(model_config, *, tokens: int, with_video_ref: bool, resolution: str, multiplier: Decimal | None = None) -> Quote:
"""按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。
multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值。"""
def quote_video_actual(
model_config,
*,
tokens: int,
with_video_ref: bool,
resolution: str,
multiplier: Decimal | None = None,
duration: int | None = None,
listed_points_per_second: int | None = None,
) -> Quote:
"""结算视频积分。优先老板挂牌秒价(与预估同口径);未挂牌才按 tokens×成本表。
multiplier / listed_points_per_second 必须用**下单快照**,中途改价不影响在途任务。"""
from apps.ai.video_pricing import tokens_to_cost
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
multiplier = multiplier if multiplier is not None else Decimal("1")
pps = listed_points_per_second
if pps is None:
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_video_ref)
if pps is not None and duration is not None and int(duration) > 0:
list_points = quote_points_per_second(points_per_second=int(pps), duration=int(duration))
points = apply_team_price(list_points, multiplier)
return Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": int(duration),
"with_ref_video": with_video_ref,
"points_per_second": int(pps),
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution)
return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def video_reserve_amount(points: Decimal) -> Decimal:
"""视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。"""
def video_reserve_amount(points: Decimal, *, rule: str | None = None) -> Decimal:
"""视频预留额。挂牌秒价=精确积分(无 buffer);token 估价才 × buffer(真实 tokens 可能略超预估)。"""
points = Decimal(str(points)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if rule == "points_per_second":
return max(points, Decimal("1")) if points > 0 else Decimal("0")
buffer = get_billing_config().video_reserve_buffer
return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
def video_quote_payload(quote: Quote) -> dict:
"""下单时写入 request_payload 的计价快照,结算只认这些字段。"""
meta = quote.meta or {}
out = {
"pricing_rule": meta.get("rule") or "",
"price_multiplier": meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": meta.get("rate", ""),
}
if meta.get("rule") == "points_per_second":
out["listed_points_per_second"] = int(meta.get("points_per_second") or 0)
out["with_ref_video"] = bool(meta.get("with_ref_video"))
if meta.get("duration") is not None:
out["billing_duration"] = int(meta["duration"])
return out
def settle_video_from_payload(model_config, *, payload: dict, tokens: int = 0) -> Quote:
"""统一视频结算:挂牌任务按快照秒价×时长扣;token 任务按 usage true-up。
挂牌不依赖 tokens——用量缺失也能按后台填的积分扣到位。"""
payload = payload or {}
multiplier = Decimal(str(payload.get("price_multiplier") or "1"))
resolution = str(payload.get("resolution") or "720p")
duration = payload.get("billing_duration")
if duration in (None, ""):
duration = payload.get("duration")
try:
duration_i = int(duration or 0) or None
except (TypeError, ValueError):
duration_i = None
with_ref = payload.get("with_ref_video")
if with_ref is None:
with_ref = any((r or {}).get("type") == "video" for r in (payload.get("references") or []))
listed = payload.get("listed_points_per_second")
try:
listed_i = int(listed) if listed not in (None, "") else None
except (TypeError, ValueError):
listed_i = None
rule = str(payload.get("pricing_rule") or "")
# 挂牌快照在:即使 usage.tokens=0 也按秒价结算(与预估同数)
if rule == "points_per_second" or listed_i is not None:
return quote_video_actual(
model_config,
tokens=int(tokens or 0),
with_video_ref=bool(with_ref),
resolution=resolution,
multiplier=multiplier,
duration=duration_i,
listed_points_per_second=listed_i,
)
if int(tokens or 0) <= 0:
# 无 usage 且无挂牌:由调用方回落 estimated_cost
return Quote(points=Decimal("0"), base_cost_yuan=Decimal("0"), meta={"rule": "missing_usage"})
# 无挂牌快照 → 纯 token 结算(即便模型后来配了挂牌也不改在途口径)
from apps.ai.video_pricing import tokens_to_cost
cost_yuan = tokens_to_cost(
model_config, int(tokens), with_video_ref=bool(with_ref), resolution=resolution,
)
return quote_video_from_cost(cost_yuan, tokens=int(tokens), multiplier=multiplier)
+93
View File
@@ -487,3 +487,96 @@ class TeamPriceMultiplierTests(TestCase):
client = APIClient()
client.force_authenticate(self.user)
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
class ListedPointsPricingTests(TestCase):
"""老板挂牌秒价:预留精确、结算认快照,不跟 tokens/中途改价跑偏。"""
def setUp(self):
from apps.billing.pricing import invalidate_billing_config_cache
invalidate_billing_config_cache()
self.user = User.objects.create_user(username="listed-pts", password="p")
self.team = Team.objects.create(name="listed", owner=self.user, price_multiplier=Decimal("1.00"))
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance=Decimal("10000"), reserved_balance=Decimal("0"))
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
self.model = ModelConfig.objects.create(
provider=provider,
name="doubao-seedance-listed",
display_name="Listed Mini",
capability=ModelConfig.Capability.VIDEO,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={
"capabilities": {"resolutions": ["480p", "720p"], "durations": [4, 5]},
"points_pricing": {
"mode": "per_second",
"tiers": [
{"resolution": "720p", "points_per_second": 10, "points_per_second_with_ref": 15},
{"resolution": "480p", "points_per_second": 6},
],
},
},
)
def test_estimate_and_exact_reserve(self):
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
_tokens, quote = quote_video_estimate(
self.model, aspect_ratio="16:9", resolution="720p", duration=5, references=[], team=self.team,
)
self.assertEqual(quote.meta["rule"], "points_per_second")
self.assertEqual(quote.points, Decimal("50")) # 10 * 5
self.assertEqual(video_reserve_amount(quote.points, rule=quote.meta["rule"]), Decimal("50"))
# token 路径仍乘 buffer(默认 1.10)
self.assertEqual(video_reserve_amount(Decimal("50"), rule="video_tokens"), Decimal("55"))
def test_settle_snapshot_ignores_model_price_change_and_tokens(self):
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload
_tokens, quote = quote_video_estimate(
self.model,
aspect_ratio="16:9",
resolution="720p",
duration=5,
references=[{"type": "video", "url": "http://x"}],
team=self.team,
)
# 含视频参考 → 15 * 5 = 75
self.assertEqual(quote.points, Decimal("75"))
payload = {
"resolution": "720p",
"duration": 5,
**video_quote_payload(quote),
"references": [{"type": "video"}],
}
# 中途把挂牌改成天价
meta = dict(self.model.metadata)
meta["points_pricing"] = {
"mode": "per_second",
"tiers": [{"resolution": "720p", "points_per_second": 999, "points_per_second_with_ref": 999}],
}
self.model.metadata = meta
self.model.save(update_fields=["metadata"])
settle = settle_video_from_payload(self.model, payload=payload, tokens=9_999_999)
self.assertEqual(settle.points, Decimal("75"))
self.assertEqual(settle.meta["rule"], "points_per_second")
settle0 = settle_video_from_payload(self.model, payload=payload, tokens=0)
self.assertEqual(settle0.points, Decimal("75"))
def test_flat_image_uses_points_per_image(self):
from apps.billing.pricing import quote_flat
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
image = ModelConfig.objects.create(
provider=provider,
name="seedream-listed",
capability=ModelConfig.Capability.IMAGE,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={"points_pricing": {"mode": "per_image", "points_per_image": 33}},
)
quote = quote_flat(image, units=2, team=self.team)
self.assertEqual(quote.points, Decimal("66"))
self.assertEqual(quote.meta.get("rule"), "per_image")
@@ -106,6 +106,15 @@ def adopt_script_version(project: Project, script: ScriptVersion) -> None:
if not script.is_adopted:
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
# 定稿即本地拆角色/场景(不调模型、不扣费),资产页无需再付费「提取」
try:
from apps.ai.entity_local import materialize_script_entities
materialize_script_entities(project=project, script=script)
except Exception: # noqa: BLE001 — 拆实体失败不挡定稿进资产;用户仍可点「提取」重试
import logging
logging.getLogger(__name__).exception("local entity materialize on adopt failed for project %s", project.id)
sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
@@ -1362,3 +1362,5 @@ class QuickCreateCoordinatorTests(TestCase):
+41 -93
View File
@@ -198,123 +198,71 @@ class ProjectApiTests(TestCase):
)
self.assertEqual(response.status_code, 404)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_streams_and_persists(self, provider_cls):
"""提取走流式通道(豆包 2.0 Pro 思考模型):思考事件丢弃、只收正文 JSON →
落库 cast/scenes + 每镜 entity_refs + entities_extracted 标记,计费一次。"""
import json as _json
extracted = {
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市女性,利落短发"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小客厅,暖白自然光"},
],
"segments": [
{"index": 0, "entity_refs": ["c1", "s1"]},
{"index": 1, "entity_refs": ["c1", "s1"]},
],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": "先通读分镜"}, # 思考事件:必须被丢弃,不进正文
{"type": "delta", "text": "```json\n" + _json.dumps(extracted, ensure_ascii=False)},
{"type": "delta", "text": "\n```"},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
def test_extract_entities_local_from_script_metadata(self):
"""本地提取:读脚本 metadata.entities,不调模型、不扣积分,落 cast/scenes + entities_extracted。"""
from apps.billing.models import CreditLedger
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-local")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
project=project,
title="脚本",
content="{}",
is_adopted=True,
metadata={
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女性"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "小客厅"},
]
},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1")
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0", entity_refs=[])
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1", entity_refs=[])
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
# 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion)
self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215")
provider.chat_completion.assert_not_called()
# 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填
self.assertEqual(response.data["status"], "succeeded")
self.assertEqual(response.data["mode"], "local")
project.refresh_from_db()
self.assertIn("女主", project.metadata.get("cast", []))
self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
self.assertTrue(project.metadata.get("entities_extracted"))
self.assertEqual(project.metadata.get("entities_extract_mode"), "local")
segs = list(script.segments.order_by("sort_order"))
self.assertEqual(segs[0].entity_refs, ["c1", "s1"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 0)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls):
"""极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON,
不再像旧非流式那样拿到空串报「未返回有效 JSON」。"""
import json as _json
extracted = {
"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家男性"}],
"segments": [{"index": 0, "entity_refs": ["c1"]}],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": _json.dumps(extracted, ensure_ascii=False)},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P2")
def test_extract_entities_local_fills_defaults_when_missing(self):
"""脚本没带 entities 时,本地补最少 1 角色 + 1 场景。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-default")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
project=project, title="脚本", content="{}", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(
script_version=script, sort_order=0, narration="女主介绍产品", visual_prompt="明亮客厅口播",
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
project.refresh_from_db()
self.assertIn("男主", project.metadata.get("cast", []))
self.assertTrue(project.metadata.get("cast"))
self.assertTrue(project.metadata.get("scenes"))
self.assertTrue(project.metadata.get("entities_extracted"))
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_inflight_guard_reuses_task(self, provider_cls):
"""已有在途提取任务时再次提交 → 复用同一任务,不新建、不二次预扣、不重跑模型
(用户刷新后手痒重点 / 并发点击都不会重复扣费)。"""
from apps.ai.services import submit_extract_entities
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P3")
def test_extract_status_after_local_success(self):
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P-status")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
inflight = AITask.objects.create(
team=self.team, created_by=self.user, project=project, model_config=self.model,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:inflight:1",
)
before = AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count()
task = submit_extract_entities(project=project, user=self.user)
self.assertEqual(str(task.id), str(inflight.id)) # 复用在途任务
self.assertEqual( # 没新建任务
AITask.objects.filter(project=project, task_type=AITask.Type.ENTITY_EXTRACTION).count(), before
)
provider_cls.return_value.chat_completion_stream.assert_not_called() # 没重跑模型
# 没二次预扣额度(复用直接 return,根本没走到 create_ai_task)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.RESERVE).count(), 0)
def test_extract_status_reports_running_then_safe_failure(self):
"""extract-status:在途→running=True;失败→安全错误对象,原始任务错误不透给前端。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P4")
t = AITask.objects.create(
team=self.team, created_by=self.user, project=project, model_config=self.model,
task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUBMITTED,
idempotency_key="entity_extraction:status:1",
project=project, title="脚本", content="{}", is_adopted=True,
metadata={"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "x"},
{"id": "s1", "type": "scene", "name": "厨房", "visual_prompt": "y"}]},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="a", visual_prompt="b")
self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
res = self.client.get(f"/api/projects/{project.id}/extract-status/")
self.assertEqual(res.status_code, 200)
self.assertTrue(res.data["running"])
t.status = AITask.Status.FAILED
t.error_message = "提取结果解析失败(模型未返回有效 JSON),请重试"
t.save(update_fields=["status", "error_message"])
res = self.client.get(f"/api/projects/{project.id}/extract-status/")
self.assertFalse(res.data["running"])
self.assertEqual(res.data["status"], "failed")
self.assertEqual(res.data["error"]["code"], "unknown")
self.assertNotIn("有效 JSON", res.data["error_message"])
self.assertEqual(res.data["status"], "succeeded")
self.assertTrue(any(e.get("name") == "男主" for e in res.data["entities"]))
@patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.get_image_provider")
+13 -14
View File
@@ -928,29 +928,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="extract-entities")
def extract_entities_action(self, request, pk=None):
"""从已定稿脚本提取角色 / 场景实体(独立步,进资产阶段时调)——**异步**:豆包思考模型流式抽取较慢,
Web 层只建任务秒回(不再 502),慢活交 worker(run_extract_entities_task)。前端拿 task_id 轮询
extract-status 取结果。已有在途提取则复用(防刷新后重点重复扣费)。落库覆盖 project.metadata +
每镜 entity_refs;**商品不提**(参考图层用真实主图)。"""
require_worker() # 异步提取依赖 worker 兜底执行,没 worker 直接拒绝(否则任务永远 RESERVED)
"""从已定稿脚本**本地**拆出角色 / 场景(不调模型、不扣积分)。
读脚本自带 entities,缺则按分镜补最少 1 角色 + 1 场景;商品不提。"""
project = self.get_object()
try:
task = submit_extract_entities(project=project, user=request.user)
except ValueError as exc: # 无脚本 / 无分镜 / 无模型 / 额度不足,立即反馈
message = str(exc)
internal_kind = (
"user_credit_insufficient" if "额度不足" in message
else "model_unavailable" if "模型" in message
else "invalid_input"
)
except ValueError as exc:
public_error = classify_generation_error(
exc, operation="entity_extract", internal_kind=internal_kind
exc, operation="entity_extract", internal_kind="invalid_input"
)
return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
status=status.HTTP_400_BAD_REQUEST,
)
return Response({"task_id": str(task.id), "status": task.status})
project.refresh_from_db()
entities = (task.response_payload or {}).get("entities") or (project.metadata or {}).get("script_entities") or []
return Response({
"task_id": str(task.id),
"status": task.status,
"mode": "local",
"entities": entities,
})
@action(detail=True, methods=["get"], url_path="extract-status")
def extract_status_action(self, request, pk=None):