优化全能创作
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
@@ -201,3 +204,186 @@ def pin_refs(conversation: CreationConversation, refs: list[dict]) -> list[dict]
|
||||
conversation.pinned_refs = merged
|
||||
conversation.save(update_fields=["pinned_refs", "updated_at"])
|
||||
return merged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Agent 在途锁(一账号同时只整理一份方案)
|
||||
|
||||
AGENT_LOCK_TTL_SECONDS = 20 * 60
|
||||
AGENT_STALE_MINUTES = 20
|
||||
AGENT_BUSY_DETAIL = "当前已有对话正在整理方案,请等待完成后再试"
|
||||
|
||||
|
||||
def agent_lock_key(team_id) -> str:
|
||||
return f"omni:agent:{team_id}"
|
||||
|
||||
|
||||
def release_agent_lock(team_id, conversation_id=None) -> None:
|
||||
"""释放团队级 agent 锁。传 conversation_id 时只在锁仍指向该会话时删,避免误伤。"""
|
||||
key = agent_lock_key(team_id)
|
||||
if conversation_id is None:
|
||||
cache.delete(key)
|
||||
return
|
||||
if str(cache.get(key) or "") == str(conversation_id):
|
||||
cache.delete(key)
|
||||
|
||||
|
||||
def acquire_agent_lock(team_id, conversation_id) -> bool:
|
||||
"""cache.add 原子占坑。成功返回 True;已有占用返回 False。"""
|
||||
return bool(cache.add(agent_lock_key(team_id), str(conversation_id), timeout=AGENT_LOCK_TTL_SECONDS))
|
||||
|
||||
|
||||
def cleanup_stale_agent_planning(team) -> int:
|
||||
"""把超时仍卡在 planning 的会话清回 idle,并尝试释放对应 Redis 锁。"""
|
||||
from django.db.models import Q
|
||||
|
||||
cutoff = timezone.now() - timedelta(minutes=AGENT_STALE_MINUTES)
|
||||
stale = list(
|
||||
CreationConversation.objects.filter(
|
||||
team=team,
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
is_deleted=False,
|
||||
).filter(
|
||||
# started_at 为空也当过期(异常写入)
|
||||
Q(agent_started_at__lt=cutoff) | Q(agent_started_at__isnull=True)
|
||||
)[:20]
|
||||
)
|
||||
cleared = 0
|
||||
now = timezone.now()
|
||||
for conv in stale:
|
||||
updated = CreationConversation.objects.filter(
|
||||
pk=conv.pk,
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
).update(
|
||||
agent_status=CreationConversation.AgentStatus.IDLE,
|
||||
agent_started_at=None,
|
||||
updated_at=now,
|
||||
)
|
||||
if updated:
|
||||
release_agent_lock(team.id, conv.id)
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
|
||||
def find_planning_conversation(team, *, exclude_id=None):
|
||||
"""返回团队里仍在 planning 的会话(已先清过期)。"""
|
||||
cleanup_stale_agent_planning(team)
|
||||
qs = CreationConversation.objects.filter(
|
||||
team=team,
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
is_deleted=False,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
qs = qs.exclude(pk=exclude_id)
|
||||
return qs.order_by("-agent_started_at", "-last_active_at").first()
|
||||
|
||||
|
||||
def team_agent_busy(team, *, exclude_id=None) -> bool:
|
||||
"""DB planning 或 Redis 锁任一占用 → 忙。exclude_id 用于同一会话续跑自检。"""
|
||||
if find_planning_conversation(team, exclude_id=exclude_id) is not None:
|
||||
return True
|
||||
holder = cache.get(agent_lock_key(team.id))
|
||||
if not holder:
|
||||
return False
|
||||
if exclude_id is not None and str(holder) == str(exclude_id):
|
||||
return False
|
||||
# 锁还在但持有会话已不在 planning → 孤儿锁,清掉放行
|
||||
still = CreationConversation.objects.filter(
|
||||
id=holder,
|
||||
team=team,
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
is_deleted=False,
|
||||
).exists()
|
||||
if not still:
|
||||
release_agent_lock(team.id, holder)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def begin_agent_planning(conversation: CreationConversation) -> bool:
|
||||
"""占 Redis 锁并把会话标成 planning。失败(冲突)返回 False,调用方回 409。"""
|
||||
team_id = conversation.team_id
|
||||
if team_agent_busy(conversation.team, exclude_id=conversation.id):
|
||||
return False
|
||||
if not acquire_agent_lock(team_id, conversation.id):
|
||||
# 锁被别人占了;若锁指向自己(重入),允许续写状态
|
||||
holder = cache.get(agent_lock_key(team_id))
|
||||
if str(holder or "") != str(conversation.id):
|
||||
return False
|
||||
now = timezone.now()
|
||||
CreationConversation.objects.filter(pk=conversation.pk).update(
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
agent_started_at=now,
|
||||
last_active_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
conversation.agent_status = CreationConversation.AgentStatus.PLANNING
|
||||
conversation.agent_started_at = now
|
||||
conversation.last_active_at = now
|
||||
clear_agent_cancel(conversation.id)
|
||||
return True
|
||||
|
||||
|
||||
def finish_agent_planning(
|
||||
conversation: CreationConversation,
|
||||
*,
|
||||
awaiting_user: bool = False,
|
||||
) -> None:
|
||||
"""Celery finally:写回 agent_status 并释放锁。"""
|
||||
status = (
|
||||
CreationConversation.AgentStatus.AWAITING_USER
|
||||
if awaiting_user
|
||||
else CreationConversation.AgentStatus.IDLE
|
||||
)
|
||||
now = timezone.now()
|
||||
CreationConversation.objects.filter(pk=conversation.pk).update(
|
||||
agent_status=status,
|
||||
agent_started_at=None,
|
||||
last_active_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
conversation.agent_status = status
|
||||
conversation.agent_started_at = None
|
||||
release_agent_lock(conversation.team_id, conversation.id)
|
||||
# 正常结束也清掉取消标记,避免下一轮误判
|
||||
clear_agent_cancel(conversation.id)
|
||||
|
||||
|
||||
def agent_cancel_key(conversation_id) -> str:
|
||||
return f"omni:agent:cancel:{conversation_id}"
|
||||
|
||||
|
||||
def clear_agent_cancel(conversation_id) -> None:
|
||||
cache.delete(agent_cancel_key(conversation_id))
|
||||
|
||||
|
||||
def is_agent_cancel_requested(conversation_id) -> bool:
|
||||
"""Celery tool round 之间读:用户点了终止则 True。"""
|
||||
return bool(cache.get(agent_cancel_key(conversation_id)))
|
||||
|
||||
|
||||
def request_agent_cancel(conversation: CreationConversation) -> bool:
|
||||
"""用户终止整理方案:打 Redis 取消标,立刻 idle + 释放团队锁。
|
||||
|
||||
仅当当前仍是 planning 时成功。Celery 在下一轮工具间隙看到标后停跑,
|
||||
已落库消息保留。返回 True=已受理, False=当时不是 planning(调用方回 409)。
|
||||
"""
|
||||
# 先打标,再收态 —— worker 在长 LLM 调用回来后也能看见
|
||||
cache.set(agent_cancel_key(conversation.id), "1", timeout=AGENT_LOCK_TTL_SECONDS)
|
||||
updated = CreationConversation.objects.filter(
|
||||
pk=conversation.pk,
|
||||
agent_status=CreationConversation.AgentStatus.PLANNING,
|
||||
).update(
|
||||
agent_status=CreationConversation.AgentStatus.IDLE,
|
||||
agent_started_at=None,
|
||||
last_active_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
if not updated:
|
||||
# 已经不是 planning(竞态完成/别人清了);仍留标一会儿无害,清掉避免脏状态
|
||||
clear_agent_cancel(conversation.id)
|
||||
conversation.refresh_from_db(fields=["agent_status", "agent_started_at"])
|
||||
return False
|
||||
conversation.agent_status = CreationConversation.AgentStatus.IDLE
|
||||
conversation.agent_started_at = None
|
||||
release_agent_lock(conversation.team_id, conversation.id)
|
||||
return True
|
||||
|
||||
@@ -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(1–3 条核心支撑),"
|
||||
"不要只写 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 = []
|
||||
|
||||
@@ -135,23 +135,28 @@ def _guard_asset_reference(asset: Asset, label: str) -> None:
|
||||
平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考。
|
||||
判据是 Asset.source,**不是**「在不在资产库里」—— 按库免审等于把审核架空:
|
||||
用户传一张图进库、再从自由创作引用出去,就绕过了整套人像审核。
|
||||
|
||||
未送审时在这里短等审核(上传链路已优先审完);不再甩「已提交审核」话术进对话。
|
||||
"""
|
||||
from apps.assets.review import poll_asset_review, reference_review_state, submit_asset_for_review
|
||||
from apps.assets.review import poll_asset_review, reference_review_state, wait_upload_review
|
||||
|
||||
state = reference_review_state(asset)
|
||||
if state == "processing":
|
||||
poll_asset_review(asset) # 实时刷一次,别让用户干等下一轮轮询
|
||||
poll_asset_review(asset)
|
||||
state = reference_review_state(asset)
|
||||
if state == "allowed":
|
||||
return
|
||||
name = label or asset.name or "未命名"
|
||||
if state == "processing":
|
||||
raise ValueError(f"素材「{name}」正在审核中,请稍后再引用")
|
||||
if state == "failed":
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
||||
# 从没送过审(资产库上传不自动送审):这里补送一次,用户等审核结果即可,不必回去手动点
|
||||
submit_asset_for_review(asset, force=True)
|
||||
raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用")
|
||||
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
||||
waited = wait_upload_review(asset, timeout_s=20.0)
|
||||
if waited in ("active", "allowed"):
|
||||
return
|
||||
if waited == "failed":
|
||||
err = (asset.review_error or "").strip()
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
||||
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
||||
|
||||
|
||||
def build_content_items(
|
||||
|
||||
@@ -66,7 +66,7 @@ def _search_products(team, q: str, limit: int) -> list[dict]:
|
||||
if q:
|
||||
queryset = queryset.filter(title__icontains=q)
|
||||
out = []
|
||||
for product in queryset.order_by("-created_at")[:limit]:
|
||||
for product in queryset.order_by("-created_at", "-id")[:limit]:
|
||||
out.append(_ref("product", product.id, product.title, _product_cover_url(product)))
|
||||
return out
|
||||
|
||||
@@ -76,7 +76,7 @@ def _search_models(team, q: str, limit: int) -> list[dict]:
|
||||
if q:
|
||||
queryset = queryset.filter(name__icontains=q)
|
||||
out = []
|
||||
for model in queryset.select_related("portrait_asset")[:limit]:
|
||||
for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]:
|
||||
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
|
||||
return out
|
||||
|
||||
@@ -95,7 +95,7 @@ def _search_assets(team, q: str, limit: int, categories: tuple[str, ...], type_:
|
||||
if q:
|
||||
queryset = queryset.filter(name__icontains=q)
|
||||
out = []
|
||||
for asset in queryset.order_by("-created_at")[:limit]:
|
||||
for asset in queryset.order_by("-created_at", "-id")[:limit]:
|
||||
out.append(_ref(type_, asset.id, asset.name, _asset_preview_url(asset)))
|
||||
return out
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated manually for omni agent async planning
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0036_disable_deepseek_text"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="creationconversation",
|
||||
name="agent_status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("idle", "空闲"),
|
||||
("planning", "整理方案中"),
|
||||
("awaiting_user", "等待用户"),
|
||||
],
|
||||
default="idle",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="creationconversation",
|
||||
name="agent_started_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="creationconversation",
|
||||
index=models.Index(fields=["team", "agent_status"], name="ai_creation_team_ag_idx"),
|
||||
),
|
||||
]
|
||||
@@ -300,6 +300,11 @@ class CreationConversation(TeamOwnedModel):
|
||||
COMPLETED = "completed", "已完成"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
class AgentStatus(models.TextChoices):
|
||||
IDLE = "idle", "空闲"
|
||||
PLANNING = "planning", "整理方案中"
|
||||
AWAITING_USER = "awaiting_user", "等待用户"
|
||||
|
||||
title = models.CharField(max_length=120, default="未命名创作")
|
||||
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.VIDEO)
|
||||
preset = models.CharField(max_length=64, blank=True, default="") # "" = 自由创作
|
||||
@@ -310,6 +315,11 @@ class CreationConversation(TeamOwnedModel):
|
||||
# 记忆:{summary, artifacts:[{msg_id,asset_id,prompt,kind}], turn_count}
|
||||
memory = models.JSONField(default=dict, blank=True)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.RUNNING)
|
||||
# Agent 编排态(与 status=会话生命周期正交)。planning 时整团队只能有一个在途 turn。
|
||||
agent_status = models.CharField(
|
||||
max_length=16, choices=AgentStatus.choices, default=AgentStatus.IDLE,
|
||||
)
|
||||
agent_started_at = models.DateTimeField(null=True, blank=True)
|
||||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
purged_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -320,6 +330,7 @@ class CreationConversation(TeamOwnedModel):
|
||||
models.Index(fields=["team", "-last_active_at"]),
|
||||
models.Index(fields=["team", "status", "-last_active_at"]),
|
||||
models.Index(fields=["team", "is_deleted", "purged_at"]),
|
||||
models.Index(fields=["team", "agent_status"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -119,11 +119,13 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
model = CreationConversation
|
||||
fields = [
|
||||
"id", "title", "mode", "preset", "params", "status",
|
||||
"agent_status", "agent_started_at",
|
||||
"message_count", "cover_url",
|
||||
"last_active_at", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id", "status", "message_count", "cover_url",
|
||||
"id", "status", "agent_status", "agent_started_at",
|
||||
"message_count", "cover_url",
|
||||
"last_active_at", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -144,3 +144,35 @@ def drain_asset_reviews_task(self) -> str:
|
||||
drain_asset_reviews_task.apply_async(countdown=45)
|
||||
return "ok"
|
||||
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def run_creation_agent_turn_task(
|
||||
self,
|
||||
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:
|
||||
"""全能创作 · Agent 整理方案(tool loop)在 worker 内跑,离开页面也不中断。
|
||||
失败自收成 ERROR + idle(见 run_creation_agent_turn),故 max_retries=0。
|
||||
|
||||
队列由 CREATION_AGENT_TASK_QUEUE 决定(本地常用 airshelf.local,避免与未部署该任务的
|
||||
远程 worker 共用 celery 队列时被偷走 → NotRegistered / 永久 planning)。
|
||||
"""
|
||||
from apps.ai.creation_agent import run_creation_agent_turn
|
||||
|
||||
return run_creation_agent_turn(
|
||||
conversation_id=conversation_id,
|
||||
user_id=str(user_id),
|
||||
text=text or "",
|
||||
refs=refs or [],
|
||||
model_config_id=model_config_id,
|
||||
record_user_message=record_user_message,
|
||||
force_creative_turn=force_creative_turn,
|
||||
continuation_instruction=continuation_instruction or "",
|
||||
)
|
||||
|
||||
@@ -476,7 +476,7 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": f"就用{product.title}吧"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
@@ -521,7 +521,7 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": "你来定"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
@@ -570,7 +570,7 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": "改成 10 秒吧"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
@@ -591,7 +591,7 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "elicit_answer", "reply_to": str(card.id), "answers": {"tone": "warm"}},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
card.refresh_from_db()
|
||||
self.assertTrue(card.payload["submitted"])
|
||||
@@ -616,7 +616,7 @@ class SendEndpointTests(TestCase):
|
||||
"answers": {"product": str(product.id)}},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
pinned = self.conversation.pinned_refs or []
|
||||
@@ -640,7 +640,7 @@ class SendEndpointTests(TestCase):
|
||||
"answers": {"product": product.title}},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
self.conversation.refresh_from_db()
|
||||
pinned = self.conversation.pinned_refs or []
|
||||
@@ -697,23 +697,14 @@ class SendEndpointTests(TestCase):
|
||||
"answers": {"_asset_gate": "skip"}},
|
||||
format="json",
|
||||
)
|
||||
events = _events(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in response.streaming_content
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
self.assertEqual(self.conversation.messages.filter(role="user").count(), before_users)
|
||||
self.assertFalse(any(
|
||||
event.get("type") == "message" and event["message"]["role"] == "user"
|
||||
for event in events
|
||||
))
|
||||
self.assertTrue(fake.calls)
|
||||
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertIn("generate_image", tool_names)
|
||||
self.assertNotIn("有需要再说", "".join(
|
||||
event["message"].get("text", "")
|
||||
for event in events if event.get("type") == "message"
|
||||
message.text for message in self.conversation.messages.filter(role="assistant")
|
||||
))
|
||||
|
||||
def test_gate_emits_conversational_question_not_direct_pick_card(self):
|
||||
@@ -727,18 +718,15 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": "我想做一条带货视频"},
|
||||
format="json",
|
||||
)
|
||||
events = _events(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in response.streaming_content
|
||||
)
|
||||
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
elicit = list(self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT))
|
||||
self.assertEqual(len(elicit), 1)
|
||||
payload = elicit[0]["message"]["payload"]
|
||||
payload = elicit[0].payload or {}
|
||||
self.assertEqual(payload["interaction"], "chat")
|
||||
self.assertEqual(payload["phase"], "gate")
|
||||
self.assertEqual(payload["fields"][0]["key"], "_asset_gate")
|
||||
self.assertEqual(payload["pending_fields"][0]["key"], "product")
|
||||
self.assertIn("商品库列表", elicit[0]["message"]["text"])
|
||||
self.assertIn("商品库列表", elicit[0].text)
|
||||
|
||||
def test_typing_yes_in_chat_triggers_pick_card(self):
|
||||
"""在「要不要发列表」闸门挂起时,用户回「需要」直接弹出 pick 卡片。"""
|
||||
@@ -772,20 +760,16 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": "需要"},
|
||||
format="json",
|
||||
)
|
||||
events = _events(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in response.streaming_content
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
card.refresh_from_db()
|
||||
self.assertTrue(card.payload["submitted"])
|
||||
self.assertEqual(card.payload["answers"], {"_asset_gate": "send"})
|
||||
pick_cards = [
|
||||
e["message"] for e in events
|
||||
if e.get("type") == "message" and e["message"]["kind"] == "elicit" and e["message"]["payload"].get("phase") == "pick"
|
||||
message for message in self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT)
|
||||
if (message.payload or {}).get("phase") == "pick"
|
||||
]
|
||||
self.assertEqual(len(pick_cards), 1)
|
||||
self.assertEqual(pick_cards[0]["payload"]["fields"][0]["type"], "asset")
|
||||
self.assertEqual(pick_cards[0].payload["fields"][0]["type"], "asset")
|
||||
|
||||
def test_typing_product_name_in_chat_answers_gate(self):
|
||||
"""在闸门挂起时,用户直接输入商品名,直接绑定商品并推进。"""
|
||||
@@ -812,7 +796,7 @@ class SendEndpointTests(TestCase):
|
||||
{"kind": "text", "text": f"就推{product.title}吧"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
self.assertIn(response.status_code, (200, 202))
|
||||
|
||||
card.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
|
||||
@@ -6,7 +6,16 @@ from apps.accounts.models import Team, TeamMember, User
|
||||
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
|
||||
from .creation import append_message, finish_generating_message, pin_refs, sync_generating_messages
|
||||
from .creation import (
|
||||
append_message,
|
||||
begin_agent_planning,
|
||||
finish_generating_message,
|
||||
is_agent_cancel_requested,
|
||||
pin_refs,
|
||||
request_agent_cancel,
|
||||
sync_generating_messages,
|
||||
team_agent_busy,
|
||||
)
|
||||
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
|
||||
|
||||
|
||||
@@ -236,3 +245,39 @@ class CreationConversationAPITests(TestCase):
|
||||
conversation.refresh_from_db()
|
||||
self.assertTrue(conversation.is_deleted)
|
||||
self.assertEqual(self.client.get(f"/api/ai/creations/{conversation.id}/").status_code, 404)
|
||||
|
||||
|
||||
class CreationAgentCancelTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="omni-cancel", password="p")
|
||||
self.team = Team.objects.create(name="Omni Cancel", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
|
||||
self.conversation = CreationConversation.objects.create(
|
||||
team=self.team, created_by=self.user, title="取消测试", mode="video"
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def test_request_agent_cancel_releases_lock_and_sets_flag(self):
|
||||
self.assertTrue(begin_agent_planning(self.conversation))
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.PLANNING)
|
||||
self.assertTrue(team_agent_busy(self.team))
|
||||
|
||||
self.assertTrue(request_agent_cancel(self.conversation))
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE)
|
||||
self.assertTrue(is_agent_cancel_requested(self.conversation.id))
|
||||
self.assertFalse(team_agent_busy(self.team))
|
||||
|
||||
def test_cancel_api_only_when_planning(self):
|
||||
idle = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json")
|
||||
self.assertEqual(idle.status_code, 409)
|
||||
|
||||
self.assertTrue(begin_agent_planning(self.conversation))
|
||||
ok = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json")
|
||||
self.assertEqual(ok.status_code, 200, getattr(ok, "data", ok.content))
|
||||
self.assertEqual(ok.json()["agent_status"], "idle")
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE)
|
||||
self.assertFalse(team_agent_busy(self.team))
|
||||
|
||||
@@ -113,14 +113,19 @@ class AssetReferenceBuildTests(TestCase):
|
||||
built = self._build(asset)
|
||||
self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc")
|
||||
|
||||
def test_unreviewed_upload_is_blocked_and_submitted(self):
|
||||
def test_unreviewed_upload_waits_and_allows_when_active(self):
|
||||
asset = _asset(self.team, source=Asset.Source.UPLOAD)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as submit:
|
||||
with patch("apps.assets.review.wait_upload_review", return_value="active") as wait:
|
||||
built = self._build(asset)
|
||||
wait.assert_called_once()
|
||||
self.assertEqual(built["image_n"], 1)
|
||||
|
||||
def test_unreviewed_upload_still_processing_is_blocked(self):
|
||||
asset = _asset(self.team, source=Asset.Source.UPLOAD)
|
||||
with patch("apps.assets.review.wait_upload_review", return_value="processing"):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._build(asset)
|
||||
self.assertIn("已提交审核", str(ctx.exception))
|
||||
submit.assert_called_once()
|
||||
self.assertTrue(submit.call_args.kwargs["force"]) # 上传素材不看类目白名单,一律登记
|
||||
self.assertIn("还在审核中", str(ctx.exception))
|
||||
|
||||
def test_failed_review_is_blocked(self):
|
||||
asset = _asset(self.team, source=Asset.Source.UPLOAD, review_status="failed")
|
||||
|
||||
+180
-50
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
import threading
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Count, Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
@@ -16,23 +18,30 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.serializers import AssetFileSerializer, AssetSerializer
|
||||
from apps.common.api import ServerSentEventRenderer, TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
from apps.common.celery_health import require_worker, require_worker_task
|
||||
from apps.products.models import Product
|
||||
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
from .creation import append_message, sync_generating_messages
|
||||
from .creation import (
|
||||
AGENT_BUSY_DETAIL,
|
||||
append_message,
|
||||
begin_agent_planning,
|
||||
cleanup_stale_agent_planning,
|
||||
finish_agent_planning,
|
||||
request_agent_cancel,
|
||||
sync_generating_messages,
|
||||
team_agent_busy,
|
||||
)
|
||||
from .creation_agent import (
|
||||
_ASSET_CARD_LABELS,
|
||||
_message_payload,
|
||||
_sse,
|
||||
apply_confirm_params,
|
||||
apply_session_params,
|
||||
is_greeting,
|
||||
stream_creation_agent,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
)
|
||||
from .tasks import run_creation_agent_turn_task
|
||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
||||
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
||||
from .serializers import (
|
||||
@@ -1327,6 +1336,24 @@ class FreeVideoUploadView(APIView):
|
||||
)
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
||||
from apps.assets.review import wait_upload_review
|
||||
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "failed":
|
||||
detail = (asset.review_error or "素材未通过审核,请换一张图再试").strip()
|
||||
return Response({"detail": detail, "review_status": "failed", "asset_id": str(asset.id)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if review_status == "processing":
|
||||
return Response(
|
||||
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
# active / allowed:进库,可供引用
|
||||
if not asset.in_library:
|
||||
asset.in_library = True
|
||||
asset.save(update_fields=["in_library", "updated_at"])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
@@ -1337,6 +1364,8 @@ class FreeVideoUploadView(APIView):
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
"review_status": "active" if review_status in ("active", "allowed") else review_status,
|
||||
"in_library": True,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -1383,6 +1412,11 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
|
||||
|
||||
|
||||
|
||||
def _agent_busy_response():
|
||||
return JsonResponse({"detail": AGENT_BUSY_DETAIL}, status=409)
|
||||
|
||||
|
||||
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""全能创作会话 CRUD(契约 §3)。
|
||||
|
||||
@@ -1403,6 +1437,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return CreationConversationDetailSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
# 离开「整理方案」页再新建会话也不行 —— 团队同时只能有一个 agent turn
|
||||
team = self.get_team()
|
||||
if team_agent_busy(team):
|
||||
return _agent_busy_response()
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
|
||||
if self.action == "retrieve":
|
||||
@@ -1442,6 +1483,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
conversation = self._synced_conversation()
|
||||
# poll 也会打 retrieve;顺手清超时 planning,避免 worker 挂掉后前端无限「整理方案」
|
||||
team = getattr(conversation, "team", None)
|
||||
if team is not None:
|
||||
cleared = cleanup_stale_agent_planning(team)
|
||||
if cleared:
|
||||
conversation.refresh_from_db()
|
||||
serializer = self.get_serializer(conversation)
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -1458,18 +1505,43 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
|
||||
return Response(CreationMessageSerializer(queryset, many=True).data)
|
||||
|
||||
@action(
|
||||
detail=True, methods=["post"], url_path="send",
|
||||
renderer_classes=[ServerSentEventRenderer],
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_path="cancel")
|
||||
def cancel(self, request, pk=None):
|
||||
"""终止进行中的整理方案(agent planning)。
|
||||
|
||||
仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status: idle}`。
|
||||
非 planning → **409**。打 Redis 取消标供 Celery 在工具轮间隙停跑,立刻释放
|
||||
`omni:agent:{team_id}` 锁;已落库消息保留。不影响 confirm 出片/出图。
|
||||
"""
|
||||
conversation = self.get_object()
|
||||
if conversation.agent_status != CreationConversation.AgentStatus.PLANNING:
|
||||
return JsonResponse(
|
||||
{"detail": "当前没有正在整理的方案可终止"},
|
||||
status=409,
|
||||
)
|
||||
if not request_agent_cancel(conversation):
|
||||
return JsonResponse(
|
||||
{"detail": "当前没有正在整理的方案可终止"},
|
||||
status=409,
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": CreationConversation.AgentStatus.IDLE,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="send")
|
||||
def send(self, request, pk=None):
|
||||
"""发一条消息 → SSE 流(契约 §3)。
|
||||
"""发一条消息(契约 §3)。
|
||||
|
||||
kind=text 普通发言,text + refs
|
||||
kind=elicit_answer 回答追问卡,reply_to + answers
|
||||
kind=confirm 点确认闸门 → **不跑模型**,直接按方案卡存的 video_prompt 出片
|
||||
kind=text / elicit_answer → 入队 Celery 跑 agent,立刻 **202**
|
||||
`{conversation_id, agent_status}`;前端靠 poll 拉消息。
|
||||
kind=confirm → 同步 JSON 201(出片/出图),与 agent 编排无关。
|
||||
闸门「发列表」等短路径 → 同步 JSON 200(已落库消息),不占 agent 锁。
|
||||
|
||||
响应 text/event-stream。**必须挂 ServerSentEventRenderer,否则 DRF 内容协商直接 406。**
|
||||
团队同时只允许一个 planning;冲突 **409** + 中文 detail。
|
||||
"""
|
||||
conversation = self.get_object()
|
||||
# 模型/比例/分辨率/时长在新建会话时锁定,发送和确认出片都按当时那套,
|
||||
@@ -1520,17 +1592,16 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
|
||||
def _gate_pick_stream():
|
||||
yield _sse({"type": "message", "message": _message_payload(user_msg)})
|
||||
yield _sse({"type": "message", "message": _message_payload(pick)})
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
_gate_pick_stream(), content_type="text/event-stream"
|
||||
)
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [
|
||||
CreationMessageSerializer(user_msg).data,
|
||||
CreationMessageSerializer(pick).data,
|
||||
],
|
||||
}, status=200)
|
||||
|
||||
# 2. 用户说「你帮我选/你定/随便」
|
||||
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text):
|
||||
@@ -1675,6 +1746,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 出片没提交成功 → 把闸门放回去,用户可以改完再确认
|
||||
card.payload = {**(card.payload or {}), "submitted": False}
|
||||
card.save(update_fields=["payload", "updated_at"])
|
||||
# 审核类提示只走 toast,不要落成对话气泡(上传时应已审完)
|
||||
reviewish = any(
|
||||
key in error
|
||||
for key in ("审核", "已提交审核", "通过后即可引用", "上传素材")
|
||||
)
|
||||
if reviewish:
|
||||
return JsonResponse({"detail": error, "message": None}, status=400)
|
||||
failure = append_message(
|
||||
conversation, role="assistant",
|
||||
kind=CreationMessage.Kind.ERROR, text=error,
|
||||
@@ -1731,16 +1809,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
},
|
||||
)
|
||||
|
||||
def _gate_send_stream():
|
||||
yield _sse({"type": "message", "message": _message_payload(pick)})
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
_gate_send_stream(), content_type="text/event-stream"
|
||||
)
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [CreationMessageSerializer(pick).data],
|
||||
}, status=200)
|
||||
elif choice == "auto":
|
||||
pending = payload.get("pending_fields") or []
|
||||
primary_field = pending[0] if pending else {}
|
||||
@@ -1792,7 +1867,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
elif not text and not refs:
|
||||
return JsonResponse({"detail": "消息不能为空"}, status=400)
|
||||
|
||||
model_config = None
|
||||
# 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送)
|
||||
if (
|
||||
conversation.agent_status == CreationConversation.AgentStatus.PLANNING
|
||||
or team_agent_busy(conversation.team)
|
||||
):
|
||||
return _agent_busy_response()
|
||||
|
||||
model_config_id = None
|
||||
requested = request.data.get("model_config_id")
|
||||
if requested:
|
||||
model_config = (
|
||||
@@ -1800,21 +1882,69 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
|
||||
.first()
|
||||
)
|
||||
if model_config is not None:
|
||||
model_config_id = str(model_config.id)
|
||||
|
||||
stream = stream_creation_agent(
|
||||
conversation=conversation,
|
||||
user=request.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,
|
||||
turn_kwargs = {
|
||||
"conversation_id": str(conversation.id),
|
||||
"user_id": str(request.user.id),
|
||||
"text": text,
|
||||
"refs": refs,
|
||||
"model_config_id": model_config_id,
|
||||
"record_user_message": record_user_message,
|
||||
"force_creative_turn": force_creative_turn,
|
||||
"continuation_instruction": continuation_instruction,
|
||||
}
|
||||
inline = bool(getattr(settings, "CREATION_AGENT_INLINE", False))
|
||||
queue = (getattr(settings, "CREATION_AGENT_TASK_QUEUE", "celery") or "celery").strip() or "celery"
|
||||
if not inline:
|
||||
# 共用远程 broker 时 broadcast inspect 常只回 K8s 节点(未注册本任务) → 误 503。
|
||||
# 本机专用队列 airshelf.local 不依赖「远程已注册」闸;仍靠本机 worker -Q 保证消费。
|
||||
if queue in {"celery", "airshelf.quick"}:
|
||||
require_worker_task("apps.ai.tasks.run_creation_agent_turn_task")
|
||||
if not begin_agent_planning(conversation):
|
||||
return _agent_busy_response()
|
||||
try:
|
||||
if inline:
|
||||
# 本机 inline:不入共享 broker,避免远程 K8s worker 偷走未注册任务
|
||||
def _run_inline(kwargs=turn_kwargs):
|
||||
try:
|
||||
from apps.ai.creation_agent import run_creation_agent_turn
|
||||
|
||||
run_creation_agent_turn(**kwargs)
|
||||
except Exception: # noqa: BLE001 — 线程内必须自收,否则永久 planning
|
||||
logger.exception("creation agent inline turn failed")
|
||||
try:
|
||||
from apps.ai.models import CreationConversation
|
||||
from apps.ai.creation import finish_agent_planning as _finish
|
||||
|
||||
conv = CreationConversation.objects.filter(
|
||||
id=kwargs["conversation_id"]
|
||||
).first()
|
||||
if conv is not None:
|
||||
_finish(conv, awaiting_user=False)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("creation agent inline: failed to finish planning")
|
||||
|
||||
transaction.on_commit(
|
||||
lambda: threading.Thread(
|
||||
target=_run_inline, name="creation-agent-inline", daemon=True
|
||||
).start()
|
||||
)
|
||||
else:
|
||||
run_creation_agent_turn_task.apply_async(kwargs=turn_kwargs, queue=queue)
|
||||
except Exception:
|
||||
# 入队失败要把锁和 planning 态收回,否则整团队卡死
|
||||
finish_agent_planning(conversation, awaiting_user=False)
|
||||
raise
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": CreationConversation.AgentStatus.PLANNING,
|
||||
},
|
||||
status=202,
|
||||
)
|
||||
response = StreamingHttpResponse(stream, content_type="text/event-stream")
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
|
||||
return response
|
||||
|
||||
|
||||
class MentionSearchView(APIView):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
全部 best-effort:审核未启用/出错都不影响主流程(生图/采用照常)。
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
@@ -233,6 +234,30 @@ def ensure_review_drain_loop() -> None:
|
||||
logger.warning("kick asset review drain loop failed", exc_info=True)
|
||||
|
||||
|
||||
|
||||
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||
"""上传素材:立刻送审并等到终态。
|
||||
|
||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
||||
审核未配置时不能拦上传,直接当 allowed。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
return "allowed"
|
||||
submit_asset_for_review(asset, force=True)
|
||||
asset.refresh_from_db()
|
||||
status = asset.review_status or ""
|
||||
if status in ("active", "failed"):
|
||||
return status
|
||||
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||
while time.monotonic() < deadline:
|
||||
status = poll_asset_review(asset) or ""
|
||||
if status in ("active", "failed"):
|
||||
return status
|
||||
time.sleep(max(0.4, float(interval_s)))
|
||||
asset.refresh_from_db()
|
||||
return asset.review_status or "processing"
|
||||
|
||||
|
||||
def poll_asset_review(asset: Asset) -> str:
|
||||
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
|
||||
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
|
||||
|
||||
Reference in New Issue
Block a user