优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-11 19:11:27 +08:00
parent dbcac8a44b
commit 02e5dbfb60
25 changed files with 2398 additions and 462 deletions
+360 -31
View File
@@ -548,6 +548,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"name": "write_strategy",
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。"
"仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调。"
),
"parameters": {
@@ -569,6 +570,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"description": (
"写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;"
"打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。"
"usp / points / timeline 是给用户看的卡片正文,必须写满具体文案,禁止空着只交 video_prompt。"
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
"先调 write_strategy 再调它。"
),
@@ -607,7 +609,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
),
},
},
"required": ["usp", "video_prompt"],
"required": ["usp", "points", "video_prompt"],
},
},
})
@@ -1253,14 +1255,181 @@ def _merge_tool_call_deltas(buffer: dict, deltas: list) -> None:
def _parse_arguments(raw: str) -> dict:
try:
parsed = json.loads(raw or "{}")
except ValueError:
"""解析工具 arguments。模型偶发包 markdown 围栏或夹杂前后缀,尽量救回 JSON。"""
text = (raw or "").strip()
if not text:
return {}
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].lstrip()
text = text.strip()
try:
parsed = json.loads(text)
except ValueError:
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
return {}
try:
parsed = json.loads(text[start : end + 1])
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
def stream_creation_agent(
def _pick_str(args: dict, *keys: str) -> str:
"""按候选键取非空字符串;兼容中文别名与嵌套一层 dict。"""
for key in keys:
value = args.get(key)
if isinstance(value, dict):
# 偶发 {"text": "..."} / {"value": "..."}
for nested in ("text", "value", "content", "desc", "description"):
inner = value.get(nested)
if isinstance(inner, str) and inner.strip():
return inner.strip()
continue
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _coerce_points(raw) -> list[str]:
"""points 规整成最多 3 条非空文案。兼容纯字符串、对象数组、dict。"""
if raw is None:
return []
items: list = []
if isinstance(raw, str):
text = raw.strip()
if not text:
return []
# 中文分号/换行拆条
for part in text.replace("\r", "\n").replace("", "\n").split("\n"):
part = part.strip(" ·•-、,,")
if part:
items.append(part)
elif isinstance(raw, dict):
# {"P0": "...", "P1": "..."} 或 {"0": "..."}
for key in sorted(raw.keys(), key=lambda k: str(k)):
val = raw[key]
if isinstance(val, dict):
text = _pick_str(val, "text", "value", "content", "desc", "point", "label")
else:
text = str(val or "").strip()
if text:
items.append(text)
elif isinstance(raw, (list, tuple)):
for item in raw:
if isinstance(item, dict):
text = _pick_str(item, "text", "value", "content", "desc", "point", "label")
else:
text = str(item or "").strip()
if text:
items.append(text)
else:
text = str(raw).strip()
if text:
items.append(text)
# 过滤单字符噪声(字符串被误当成 list 迭代时的残留)
cleaned = [p for p in items if len(p) > 1]
return cleaned[:3]
def _coerce_voice_chars(raw, fallback: list[int] | None = None) -> list[int]:
"""voice_chars 必须是 [下限, 上限];模型常误塞语气文案或单个整数。"""
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
try:
lo, hi = int(raw[0]), int(raw[1])
if lo > 0 and hi >= lo:
return [lo, hi]
except (TypeError, ValueError):
pass
if isinstance(raw, (int, float)) and int(raw) > 0:
n = int(raw)
return [max(1, n - 5), n + 5]
return list(fallback or [])
def _coerce_timeline(raw) -> list[dict]:
items: list[dict] = []
for item in (raw or []) if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
try:
start = float(item.get("start"))
end = float(item.get("end"))
except (TypeError, ValueError):
continue
stage = str(item.get("stage") or "").strip()
if not stage:
continue
entry = {"start": start, "end": end, "stage": stage}
desc = str(item.get("desc") or item.get("description") or "").strip()
if desc:
entry["desc"] = desc
items.append(entry)
return items
def _default_plan_matrix(usp: str, points: list[str]) -> dict:
"""设计稿里的「卖点覆盖矩阵」:有 USP/支撑点就自动铺一版,避免方案卡干瘪。"""
rows = [{"point": "主打卖点 USP", "hits": [1, 3]}]
labels = ["体验卖点 P0", "视觉卖点 P0", "转化卖点 P0"]
for index, point in enumerate(points[:3]):
label = labels[index] if index < len(labels) else f"支撑卖点 P{index}"
# 点名用文案前缀,hits 错落分布到 4 镜
hit = (index % 4) + 1
rows.append({"point": label if not point else f"{label}", "hits": [hit]})
return {"shots": 4, "rows": rows}
def _coerce_strategy_args(args: dict) -> dict:
return {
"target": _pick_str(
args, "target", "audience", "who", "给谁看", "目标人群", "人群",
),
"trust": _pick_str(
args, "trust", "credibility", "为什么相信", "信任", "可信度",
),
"belief": _pick_str(
args, "belief", "希望相信", "想让他信什么", "认知", "takeaway",
),
"direction": _pick_str(
args, "direction", "创作方向", "方向", "style", "路线",
),
}
def _coerce_plan_card_args(args: dict) -> dict:
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
points = _coerce_points(
args.get("points")
if args.get("points") is not None
else args.get("支撑点") or args.get("supports") or args.get("selling_points")
)
# 兼容 point1/point2/point3 展开写法(设计稿 blueprint)
if not points:
for key in ("point1", "point2", "point3", "P0", "P1", "P2"):
text = _pick_str(args, key)
if text:
points.append(text)
points = points[:3]
timeline = _coerce_timeline(args.get("timeline") or args.get("时间轴"))
matrix = args.get("matrix")
if not isinstance(matrix, dict) or not matrix.get("rows"):
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
return {
"usp": usp,
"points": points,
"timeline": timeline,
"matrix": matrix,
"voice_chars": args.get("voice_chars"),
}
def iter_creation_agent_events(
*,
conversation: CreationConversation,
user,
@@ -1270,15 +1439,15 @@ def stream_creation_agent(
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发"""
) -> Iterator[dict]:
"""一条用户消息 → 事件 dict 流(message/delta/tool/done/error)。消息已落库"""
refs = refs or []
fast_greeting = record_user_message and is_greeting(text)
# 打招呼不依赖模型,模型未配置也能即时回应;其他消息保持原有的先校验模型行为。
if not fast_greeting:
model_config = get_creation_chat_model(model_config)
if model_config is None:
yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"})
yield {"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"}
return
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
@@ -1289,7 +1458,7 @@ def stream_creation_agent(
if record_user_message:
user_message = append_message(conversation, role="user", text=text, refs=refs)
if user_message is not None:
yield _sse({"type": "message", "message": _message_payload(user_message)})
yield {"type": "message", "message": _message_payload(user_message)}
# 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。
# 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。
if fast_greeting:
@@ -1301,8 +1470,8 @@ def stream_creation_agent(
if refs else "你好。想做什么,直接说就行。"
),
)
yield _sse({"type": "message", "message": _message_payload(reply)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
# 空会话里的简短应答:短回一句就够。已有商品/方向时走模型,
@@ -1314,8 +1483,8 @@ def stream_creation_agent(
role="assistant",
text="我在。想做什么,直接丢一句想法给我就行。",
)
yield _sse({"type": "message", "message": _message_payload(reply)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
@@ -1328,7 +1497,7 @@ def stream_creation_agent(
conversation, role="assistant",
text=f"有几个引用的素材已经找不到了({names}),我先按其余信息继续。",
)
yield _sse({"type": "message", "message": _message_payload(note)})
yield {"type": "message", "message": _message_payload(note)}
# 压缩放在**建消息之前**:摘要要进这一轮的 system 提示词才有意义。
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
@@ -1347,7 +1516,13 @@ def stream_creation_agent(
messages.append({"role": "user", "content": continuation_instruction.strip()})
tools = tool_schemas(context, allow_plan=allow_plan)
from .creation import is_agent_cancel_requested
for _round in range(MAX_TOOL_ROUNDS):
if is_agent_cancel_requested(conversation.id):
# 用户终止:干净收束,不落 ERROR,已落库消息保留
yield {"type": "cancelled"}
return
text_buffer: list[str] = []
tool_buffer: dict = {}
for chunk in provider.chat_completion_stream(
@@ -1358,11 +1533,11 @@ def stream_creation_agent(
):
kind = chunk.get("type")
if kind == "reasoning":
yield _sse({"type": "reasoning", "text": chunk.get("text", "")})
yield {"type": "reasoning", "text": chunk.get("text", "")}
elif kind == "delta":
piece = chunk.get("text", "")
text_buffer.append(piece)
yield _sse({"type": "delta", "text": piece})
yield {"type": "delta", "text": piece}
elif kind == "tool_call":
_merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls"))
@@ -1407,7 +1582,7 @@ def stream_creation_agent(
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
if said and not asks_user:
bubble = append_message(conversation, role="assistant", text=said)
yield _sse({"type": "message", "message": _message_payload(bubble)})
yield {"type": "message", "message": _message_payload(bubble)}
if not calls:
if fallback_fields:
@@ -1415,7 +1590,7 @@ def stream_creation_agent(
context, "ask_user", {"fields": fallback_fields}, allow_pick=allow_pick
)
for event in result.get("_events", []):
yield _sse(event)
yield event
break
messages.append({
@@ -1430,23 +1605,26 @@ def stream_creation_agent(
stop = False
for index, call in enumerate(calls):
if is_agent_cancel_requested(conversation.id):
yield {"type": "cancelled"}
return
name = call["name"]
args = _parse_arguments(call["arguments"])
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"}
try:
result, stop_after = _dispatch_tool(context, name, args)
except AgentError as exc:
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"}
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=str(exc),
)
yield _sse({"type": "message", "message": _message_payload(failure)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"}
for event in result.get("_events", []):
yield _sse(event)
yield event
messages.append({
"role": "tool",
"tool_call_id": f"call_{index}",
@@ -1456,10 +1634,132 @@ def stream_creation_agent(
if stop:
break
yield _sse({"type": "done"})
yield {"type": "done"}
except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」
logger.exception("creation agent stream failed: %s", exc)
yield _sse({"type": "error", "detail": "生成过程出错了,请再试一次"})
yield {"type": "error", "detail": "生成过程出错了,请再试一次"}
def stream_creation_agent(
*,
conversation: CreationConversation,
user,
text: str,
refs: list[dict] | None = None,
model_config: ModelConfig | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""兼容旧 SSE 消费方(单测 / 调试)。生产路径走 Celery + poll。"""
for event in iter_creation_agent_events(
conversation=conversation,
user=user,
text=text,
refs=refs,
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction,
):
yield _sse(event)
def run_creation_agent_turn(
*,
conversation_id: str,
user_id: str,
text: str = "",
refs: list | None = None,
model_config_id: str | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> str:
"""Celery worker 入口:跑完一轮 tool loop,消息落库;更新 agent_status;释放团队锁。
不复用 CreationMessage.kind=generating / AITask —— 那些是确认后出片/出图用的。
"""
from apps.accounts.models import User
from .creation import finish_agent_planning
conversation = (
CreationConversation.objects.select_related("team", "created_by")
.filter(id=conversation_id)
.first()
)
if conversation is None:
return conversation_id
user = User.objects.filter(id=user_id).first() or conversation.created_by
model_config = None
if model_config_id:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=model_config_id, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
awaiting_user = False
user_cancelled = False
try:
for event in iter_creation_agent_events(
conversation=conversation,
user=user,
text=text or "",
refs=refs or [],
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction or "",
):
if not isinstance(event, dict):
continue
if event.get("type") == "cancelled":
user_cancelled = True
awaiting_user = False
break
if event.get("type") == "message":
message = event.get("message") or {}
kind = message.get("kind") if isinstance(message, dict) else None
if kind in (CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM):
awaiting_user = True
elif event.get("type") == "error":
detail = str(event.get("detail") or "生成过程出错了,请再试一次")
# 循环内多数错误已落 ERROR 消息;这里兜底一条,避免前端只看到卡在 planning
last = conversation.messages.order_by("-seq").first()
if last is None or last.kind != CreationMessage.Kind.ERROR:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=detail,
)
except Exception as exc: # noqa: BLE001 — worker 不能把异常冒成无限 planning
logger.exception("creation agent turn failed: %s", exc)
try:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text="生成过程出错了,请再试一次",
)
except Exception: # noqa: BLE001
logger.exception("creation agent turn: failed to append error message")
awaiting_user = False
finally:
try:
conversation.refresh_from_db(fields=["agent_status", "team_id"])
except Exception: # noqa: BLE001
pass
# 用户已终止时 API 侧多半已 idle+放锁;这里再收一次保证幂等,且不落入 awaiting_user
finish_agent_planning(
conversation,
awaiting_user=(awaiting_user and not user_cancelled),
)
return conversation_id
_TOOL_LABELS = {
@@ -1579,10 +1879,21 @@ def _dispatch_tool(
return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy":
strategy_payload = _coerce_strategy_args(args if isinstance(args, dict) else {})
# 空卡会落成「只有标签没有正文」——拒绝,让模型把四字段写满再调
if not all(strategy_payload.values()):
return {
"payload": {
"error": (
"创作策略卡四字段都不能为空:请填写具体的 target / trust / belief / direction"
"(给谁看、为什么信、希望他信什么、创作方向),不要留空。"
)
}
}, False
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.STRATEGY,
payload={key: str(args.get(key) or "") for key in ("target", "trust", "belief", "direction")},
payload=strategy_payload,
)
# 策略卡只是「我理解对了吗」,不打断 —— 模型接着就该写方案
return {
@@ -1594,11 +1905,29 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if not card["usp"] or not card["points"]:
return {
"payload": {
"error": (
"方案卡缺正文:请填写非空的 usp(主打卖点)和 points(13 条核心支撑),"
"不要只写 video_prompt。卡片上要让用户看见卖点文案。"
)
}
}, False
duration = 15
try:
duration = int(float(str((context.conversation.params or {}).get("duration") or "15").replace("", "").strip() or "15"))
except (TypeError, ValueError):
duration = 15
lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4))
plan_payload = {
"usp": str(args.get("usp") or ""),
"points": [str(p) for p in (args.get("points") or [])][:3],
"timeline": args.get("timeline") or [],
"voice_chars": args.get("voice_chars") or [],
"usp": card["usp"],
"points": card["points"],
"timeline": card["timeline"],
"matrix": card["matrix"],
"voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]),
"ref_count": len(context.conversation.pinned_refs or []),
}
events = []