大量优化修改扣积分规则
This commit is contained in:
@@ -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 重试二次扣费)
|
||||
|
||||
Reference in New Issue
Block a user