大量优化修改扣积分规则
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 张"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user