feat(product-suite): persist current generation rounds

This commit is contained in:
chengma
2026-07-16 23:27:25 +08:00
parent c146c0b41d
commit f3defdeb95
11 changed files with 806 additions and 16 deletions
+230 -2
View File
@@ -49,6 +49,7 @@ class ImageStudioProject:
target_detail_count: int
draft_prompt: Optional[str]
suite_settings_json: str
current_generation_round_key: Optional[str]
status: str
created_at: str
updated_at: str
@@ -91,6 +92,8 @@ class ImageStudioJob:
points_cost: Optional[int]
points_balance: Optional[int]
call_id: Optional[str]
generation_round_key: Optional[str]
generation_slot_index: Optional[int]
created_at: str
updated_at: str
submitted_at: Optional[str]
@@ -108,6 +111,23 @@ class ImageStudioSelection:
updated_at: str
@dataclass(frozen=True)
class ImageStudioGenerationRound:
project_id: int
generation_round_key: Optional[str]
created_at: Optional[str]
updated_at: Optional[str]
job_count: int
slot_count: int
succeeded_count: int
failed_count: int
cancelled_count: int
active_count: int
retry_count: int
is_current: bool
is_legacy: bool
class ImageStudioError(RuntimeError):
"""Raised when the AI image studio service cannot complete an operation."""
@@ -1059,11 +1079,24 @@ def create_job(
task_key=None,
generation_source="cmhub",
provider="cmhub",
generation_round_key=None,
generation_slot_index=None,
path=None,
conn=None,
):
now = _now()
task_key = str(task_key or _task_key(project_id))
if generation_round_key is not None:
generation_round_key = str(generation_round_key).strip()
if not generation_round_key:
raise db.DbError("AI工场生成轮次标识不能为空")
if generation_slot_index is not None:
try:
generation_slot_index = int(generation_slot_index)
except (TypeError, ValueError) as exc:
raise db.DbError("AI工场生成槽位编号无效") from exc
if generation_slot_index < 0:
raise db.DbError("AI工场生成槽位编号不能小于0")
with _connection(conn, path) as database:
try:
with database:
@@ -1072,8 +1105,9 @@ def create_job(
"""
INSERT INTO image_studio_jobs
(project_id, source_asset_id, generation_source, provider,
job_type, task_key, status, prompt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
job_type, task_key, status, prompt, generation_round_key,
generation_slot_index, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
""",
(
int(project_id),
@@ -1083,6 +1117,8 @@ def create_job(
str(job_type),
task_key,
str(prompt or ""),
generation_round_key,
generation_slot_index,
now,
now,
),
@@ -1120,6 +1156,198 @@ def list_jobs(project_id, *, statuses=None, path=None, conn=None):
return _fetch_all(database, sql, params, ImageStudioJob)
def get_current_generation_round(project_id, path=None, conn=None):
project = get_project(project_id, path=path, conn=conn)
if project is None:
return None
value = project.current_generation_round_key
return str(value).strip() if value else None
def set_current_generation_round(project_id, generation_round_key, path=None, conn=None):
project_id = int(project_id)
round_key = None
if generation_round_key is not None:
round_key = str(generation_round_key).strip()
if not round_key:
raise db.DbError("当前生成轮次标识不能为空")
with _connection(conn, path) as database:
with database:
project = get_project(project_id, conn=database)
if project is None:
raise db.DbError("AI工场商品项目不存在或已删除")
if round_key is not None:
row = database.execute(
"""
SELECT 1 FROM image_studio_jobs
WHERE project_id = ? AND generation_round_key = ?
LIMIT 1
""",
(project_id, round_key),
).fetchone()
if row is None:
raise db.DbError("当前生成轮次不属于该商品项目")
database.execute(
"""
UPDATE image_studio_projects
SET current_generation_round_key = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(round_key, _now(), project_id),
)
return get_project(project_id, conn=database)
def promote_generation_round_if_success(project_id, generation_round_key, path=None, conn=None):
project_id = int(project_id)
round_key = str(generation_round_key or "").strip()
if not round_key:
raise db.DbError("当前生成轮次标识不能为空")
with _connection(conn, path) as database:
with database:
project = get_project(project_id, conn=database)
if project is None:
raise db.DbError("AI工场商品项目不存在或已删除")
success = database.execute(
"""
SELECT 1 FROM image_studio_jobs
WHERE project_id = ?
AND generation_round_key = ?
AND status = 'succeeded'
LIMIT 1
""",
(project_id, round_key),
).fetchone()
if success is None:
return False
database.execute(
"""
UPDATE image_studio_projects
SET current_generation_round_key = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(round_key, _now(), project_id),
)
return True
def list_generation_rounds(project_id, *, limit=None, offset=0, path=None, conn=None):
project_id = int(project_id)
try:
offset = max(0, int(offset or 0))
except (TypeError, ValueError) as exc:
raise db.DbError("生成轮次偏移量无效") from exc
if limit is not None:
try:
limit = int(limit)
except (TypeError, ValueError) as exc:
raise db.DbError("生成轮次数量无效") from exc
if limit <= 0:
return []
current_round_key = get_current_generation_round(project_id, path=path, conn=conn)
sql = """
SELECT generation_round_key,
MIN(created_at) AS created_at,
MAX(updated_at) AS updated_at,
COUNT(*) AS job_count,
COUNT(DISTINCT generation_slot_index) AS slot_count,
SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded_count,
SUM(CASE WHEN status IN ('failed', 'expired') THEN 1 ELSE 0 END) AS failed_count,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_count,
SUM(CASE WHEN status IN ('pending', 'submitted', 'running') THEN 1 ELSE 0 END) AS active_count
FROM image_studio_jobs
WHERE project_id = ?
GROUP BY generation_round_key
ORDER BY CASE WHEN generation_round_key IS NULL THEN 1 ELSE 0 END,
MAX(id) DESC
"""
params = [project_id]
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
elif offset:
sql += " LIMIT -1 OFFSET ?"
params.append(offset)
with _connection(conn, path) as database:
rows = database.execute(sql, params).fetchall()
rounds = []
for row in rows:
round_key = row["generation_round_key"]
job_count = int(row["job_count"] or 0)
slot_count = int(row["slot_count"] or 0)
rounds.append(
ImageStudioGenerationRound(
project_id=project_id,
generation_round_key=round_key,
created_at=row["created_at"],
updated_at=row["updated_at"],
job_count=job_count,
slot_count=slot_count,
succeeded_count=int(row["succeeded_count"] or 0),
failed_count=int(row["failed_count"] or 0),
cancelled_count=int(row["cancelled_count"] or 0),
active_count=int(row["active_count"] or 0),
retry_count=max(0, job_count - slot_count) if round_key else 0,
is_current=bool(round_key and round_key == current_round_key),
is_legacy=round_key is None,
)
)
return rounds
def list_generation_round_current_jobs(project_id, generation_round_key, path=None, conn=None):
project_id = int(project_id)
if generation_round_key is None:
sql = """
SELECT * FROM image_studio_jobs
WHERE project_id = ? AND generation_round_key IS NULL
ORDER BY created_at ASC, id ASC
"""
params = (project_id,)
else:
round_key = str(generation_round_key).strip()
if not round_key:
raise db.DbError("生成轮次标识不能为空")
sql = """
SELECT jobs.*
FROM image_studio_jobs AS jobs
INNER JOIN (
SELECT generation_slot_index, MAX(id) AS latest_id
FROM image_studio_jobs
WHERE project_id = ? AND generation_round_key = ?
GROUP BY generation_slot_index
) AS latest ON latest.latest_id = jobs.id
WHERE jobs.project_id = ? AND jobs.generation_round_key = ?
ORDER BY jobs.generation_slot_index ASC, jobs.id ASC
"""
params = (project_id, round_key, project_id, round_key)
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, ImageStudioJob)
def list_generation_round_attempts(project_id, generation_round_key, path=None, conn=None):
project_id = int(project_id)
if generation_round_key is None:
sql = """
SELECT * FROM image_studio_jobs
WHERE project_id = ? AND generation_round_key IS NULL
ORDER BY created_at ASC, id ASC
"""
params = (project_id,)
else:
round_key = str(generation_round_key).strip()
if not round_key:
raise db.DbError("生成轮次标识不能为空")
sql = """
SELECT * FROM image_studio_jobs
WHERE project_id = ? AND generation_round_key = ?
ORDER BY generation_slot_index ASC, id ASC
"""
params = (project_id, round_key)
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, ImageStudioJob)
def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None):
now = _now()
with _connection(conn, path) as database: