优化脚本 就该模型

This commit is contained in:
Azmat@qq.com
2026-08-21 15:08:24 +08:00
parent c2d3a786a3
commit 158c66ac25
12 changed files with 273 additions and 11 deletions
+55
View File
@@ -515,6 +515,61 @@ class AdminTaskMonitorTests(TestCase):
task = self._mk_stuck_reserved("k-reap-perm")
self.assertEqual(self.nc.post(f"/api/admin/tasks/{task.id}/reap/").status_code, 403)
def _mk_inflight_video(self, *, key: str, provider_task_id: str = "ark-poll-1"):
return self.AITask.objects.create(
team=self.team,
model_config=self.mc,
task_type=self.AITask.Type.FREE_VIDEO,
status=self.AITask.Status.POLLING,
estimated_cost="1.0",
actual_cost="0",
idempotency_key=key,
provider_task_id=provider_task_id,
)
def test_poll_inflight_free_video(self):
task = self._mk_inflight_video(key="k-poll-fv")
def fake_finalize(*, task):
task.status = self.AITask.Status.SUCCEEDED
task.save(update_fields=["status", "updated_at"])
return task
with patch("apps.ai.free_video.finalize_free_video", side_effect=fake_finalize) as mock_fin:
r = self.ac.post("/api/admin/tasks/poll/", {}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["polled"], 1)
self.assertEqual(r.data["statuses"][str(task.id)], "succeeded")
mock_fin.assert_called_once()
task.refresh_from_db()
self.assertEqual(task.status, self.AITask.Status.SUCCEEDED)
def test_poll_skips_terminal_and_missing_provider_id(self):
self.AITask.objects.create(
team=self.team, model_config=self.mc, task_type=self.AITask.Type.FREE_VIDEO,
status=self.AITask.Status.POLLING, estimated_cost="1.0", actual_cost="0",
idempotency_key="k-poll-nopid",
)
with patch("apps.ai.free_video.finalize_free_video") as mock_fin:
r = self.ac.post("/api/admin/tasks/poll/", {}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["polled"], 0)
mock_fin.assert_not_called()
def test_poll_task_ids_filter(self):
keep = self._mk_inflight_video(key="k-poll-keep", provider_task_id="ark-keep")
skip = self._mk_inflight_video(key="k-poll-skip", provider_task_id="ark-skip")
with patch("apps.ai.free_video.finalize_free_video", side_effect=lambda *, task: task) as mock_fin:
r = self.ac.post("/api/admin/tasks/poll/", {"task_ids": [str(keep.id)]}, format="json")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["polled"], 1)
self.assertIn(str(keep.id), r.data["statuses"])
self.assertNotIn(str(skip.id), r.data["statuses"])
mock_fin.assert_called_once()
def test_poll_requires_admin(self):
self.assertEqual(self.nc.post("/api/admin/tasks/poll/", {}, format="json").status_code, 403)
class AdminBillingTests(TestCase):
"""Phase 7:计费审计(流水浏览/手动调额)+ 4 层额度策略(CRUD + 拦截生效)+ 权限。"""
+2
View File
@@ -21,6 +21,7 @@ from .views import (
admin_task_reap,
admin_task_retry,
admin_tasks,
admin_tasks_poll,
admin_prompt_template_detail,
admin_prompt_templates,
admin_quality_word_detail,
@@ -54,6 +55,7 @@ urlpatterns = [
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
path("tasks/", admin_tasks, name="admin-tasks"),
path("tasks/poll/", admin_tasks_poll, name="admin-tasks-poll"),
path("tasks/<uuid:task_id>/", admin_task_detail, name="admin-task-detail"),
path("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
path("tasks/<uuid:task_id>/reap/", admin_task_reap, name="admin-task-reap"),
+59
View File
@@ -1,5 +1,6 @@
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
import logging
from decimal import Decimal, ROUND_HALF_UP
from django.db.models import Count, F, Q
@@ -39,6 +40,12 @@ from .serializers import (
QualityWordSerializer,
)
logger = logging.getLogger(__name__)
# 后台「刷新状态」只向供应商拉取已提交的异步视频任务;单次上限避免请求拖死。
_ADMIN_TASK_POLL_LIMIT = 20
_ADMIN_TASK_POLL_STATUSES = (AITask.Status.SUBMITTED, AITask.Status.POLLING)
def _team_qs():
return (
@@ -410,6 +417,31 @@ def admin_asset_reviews_poll(request):
return Response({"polled": len(statuses), "statuses": statuses})
def _refresh_inflight_task(task: AITask, *, operator) -> AITask:
"""对单条在途任务向供应商拉一次最新态。无远端 ID / 非视频异步任务则原样返回。"""
if not task.provider_task_id:
return task
if task.task_type == AITask.Type.FREE_VIDEO:
from apps.ai.free_video import finalize_free_video
return finalize_free_video(task=task)
if task.task_type == AITask.Type.VIDEO_SEGMENT:
from apps.ai.services import poll_video_segment
from apps.projects.models import VideoSegment
segment_id = (task.request_payload or {}).get("video_segment_id")
if not segment_id:
return task
segment = VideoSegment.objects.filter(id=segment_id).first()
if segment is None:
return task
user = task.created_by or getattr(task.team, "owner", None) or operator
poll_video_segment(video_segment=segment, user=user)
task.refresh_from_db()
return task
return task
# ─────────────────────────── AI 任务监控 + 成本异常 ───────────────────────────
@@ -440,6 +472,33 @@ def admin_tasks(request):
return paginator.get_paginated_response(AdminTaskSerializer(page, many=True).data)
@api_view(["POST"])
@permission_classes([IsPlatformAdmin])
def admin_tasks_poll(request):
"""向供应商刷新在途视频任务状态。给 task_ids 则只拉这些,否则拉全平台 submitted/polling。
图片/脚本等同步任务没有远端 task id,点刷新后前端会再拉一次列表,worker 已落库的终态会一并更新。
"""
ids = request.data.get("task_ids")
qs = (
AITask.objects.select_related("team", "model_config", "model_config__provider", "project")
.filter(status__in=_ADMIN_TASK_POLL_STATUSES)
.exclude(provider_task_id="")
.order_by("-updated_at")
)
if ids:
qs = qs.filter(id__in=ids)
statuses = {}
for task in qs[:_ADMIN_TASK_POLL_LIMIT]:
try:
refreshed = _refresh_inflight_task(task, operator=request.user)
statuses[str(refreshed.id)] = refreshed.status
except Exception: # noqa: BLE001 — 单条失败不阻断整页刷新
logger.warning("admin poll task %s failed", task.id, exc_info=True)
statuses[str(task.id)] = task.status
return Response({"polled": len(statuses), "statuses": statuses})
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_task_detail(request, task_id):
+67 -3
View File
@@ -306,6 +306,59 @@ _OUTPUT_PROTOCOL = """
"""
# 创作方向不放在前端文案里,而是和输出协议一起作为运行时最高优先级约束。
# 它解决的不是 JSON 合不合法,而是「脚本合格却像商品详情页朗读」的问题。
_CREATIVE_DIRECTION = """
---
## 出片感与转化感(硬性创作方向)
你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个
**正在发生的具体瞬间**。先选一个最能代表目标人群的「时间 + 地点 + 小麻烦/小欲望」,
全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。
### 每一镜都必须有推进,禁止平铺直叙
- 钩子镜:前 15 个字就抛出反常、尴尬、选择或结果;禁止「大家好」「今天分享」
「给你们推荐」「这款很好用」「值得买」等任何寒暄或泛推荐开场。
- 痛点镜:拍得到的细节,而非抽象感受。例如「开会前刘海粘成一绺」而不是「头发很油」。
- 卖点镜:只证明一个卖点,必须写清「原本卡在哪里 → 手怎么使用商品 → 眼前有什么变化」。
不准只报参数、堆形容词或重复商品名。
- CTA 镜:回到前面那个具体情境,给一个自然的选择/动作;不要硬喊「赶紧下单」。
### 画面与台词必须互相提供新信息
- 每个 15 秒场只安排**一个连续的核心动作链**,例如「拆开 → 倒入 → 颜色变化」,
不要在同一段塞进无关剧情、三个卖点和多次场景跳转。
- 台词说人物的判断、感受或转折;画面证明这句话。台词不要逐字复述画面。
- 至少有一个可见证据:包装/质地/声音/前后状态/动作结果。没有可见证据的卖点不写。
- 用像真实人在当下会说的短句,可以有停顿、转折和个人立场;禁止「不仅…而且…」
「全面升级」「高品质」「性价比很高」「闭眼入」等详情页腔。
输出前自问:遮住商品名后,这是不是仍然像一个真实的人在讲自己刚遇到的一件事?
如果像广告口号,重写得更具体、更有立场。
### visual 必须写成「导演说明书」,不是一句画面摘要
每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令:
```
【本镜任务】这一段要让观众看懂的变化或悬念。
【声音】只写可发生的同期声 / 人声状态;没有配乐就明确写「无配乐,仅同期声」。
【画面内容】
0-3s:景别;构图;运镜;人物/商品动作;情绪或信息变化。
3-8s:景别;构图;运镜;手与商品的空间关系;可见细节。
8-12s:景别;构图;运镜;动作结果或卖点证据。
12-15s:景别;构图;运镜;反应/悬念/自然收束。
```
每条秒级分镜都要明确写出 **景别、构图、运镜、动作、信息变化** 五项中的至少四项。
同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。
"""
# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。
_GENERIC_HOOK_PHRASES = (
"大家好", "今天", "给大家", "给你们", "给姐妹", "分享一下", "推荐一下",
"这款", "好物分享", "真的好用", "值得买", "闭眼入", "宝子们",
)
# --------------------------------------------------------------------------- #
# 提示词构建(3 模式)
# --------------------------------------------------------------------------- #
@@ -417,7 +470,7 @@ def build_agent_messages(
total = DEFAULT_TOTAL_DURATION
else:
total = coerce_total_duration(total_duration)
system = load_ecommerce_skill(fmt, structure) + _OUTPUT_PROTOCOL
system = load_ecommerce_skill(fmt, structure) + _CREATIVE_DIRECTION + _OUTPUT_PROTOCOL
suggested = plan_segment_durations(total, fmt)
shot_n = len(suggested)
@@ -436,8 +489,9 @@ def build_agent_messages(
speech_floor = narration_floor(SEGMENT_DURATION_MAX)
speech_cap = narration_limit(SEGMENT_DURATION_MAX)
beats_line = (
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 写成多行,"
f"格式`0-3s:景别,谁在做什么`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:"
"先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;"
f"每条格式`0-3s:景别;构图;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
"每条写清手/商品/容器的空间关系和真实用法"
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。"
"禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n"
@@ -689,6 +743,16 @@ def assert_shot_density(draft: dict, presentation_format: str = DEFAULT_PRESENTA
raise ValueError(f"第 {index + 1} 镜旁白太短,口播至少要说到 {floor} 字才能撑满 {duration} 秒")
def assert_script_has_a_hook(draft: dict) -> None:
"""拦住最常见的模板开场,逼模型从一个具体瞬间切入而不是先介绍商品。"""
hook = str(draft.get("hook") or "")
if _compact_len(hook) < 6:
raise ValueError("开场钩子太弱,请用一个具体场景、反差或问题重写前3秒")
opening = hook[:18]
if any(phrase in opening for phrase in _GENERIC_HOOK_PHRASES):
raise ValueError("开场像泛泛推荐,请直接从具体场景、反差或问题切入")
def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None:
"""15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。"""
need = min_beats_for_duration(duration)
@@ -21,6 +21,7 @@ from apps.ai.script_agent import (
_product_facts,
allowed_structures,
assert_product_facts_used,
assert_script_has_a_hook,
assert_shot_density,
build_agent_messages,
coerce_combo,
@@ -167,6 +168,14 @@ class ProductFactTests(SimpleTestCase):
)
class HookQualityTests(SimpleTestCase):
def test_generic_recommendation_hook_is_rejected(self):
with self.assertRaises(ValueError):
assert_script_has_a_hook({"hook": "大家好,今天给大家推荐一款很好用的茶"})
def test_specific_scene_hook_passes(self):
assert_script_has_a_hook({"hook": "下午三点开会前,我的刘海又粘成一绺了"})
class ProductFactsLookupTests(SimpleTestCase):
def _project(self, points):
class _QS(list):
@@ -356,6 +365,17 @@ class ShotDensityTests(SimpleTestCase):
)
self.assertIn("按秒拆分镜", str(ctx.exception))
def test_director_treatment_keeps_timed_beats_parseable(self):
visual = (
"【本镜任务】让观众看到茶汤从等待变成可以入口的过程\n"
"【声音】无配乐,仅同期声:热水注入杯中的水声\n"
"【画面内容】\n" + self._VISUAL
)
beats = parse_visual_beats(visual)
self.assertEqual(len(beats), 4)
self.assertEqual(beats[0][0], 0)
self.assertEqual(beats[-1][1], 15)
def test_beats_array_flattens_into_visual(self):
import json