feat: complete T-533 incremental generation

This commit is contained in:
chengma
2026-07-06 17:57:39 +08:00
parent be182b91cf
commit 46995b301d
8 changed files with 434 additions and 72 deletions
+94 -34
View File
@@ -230,18 +230,57 @@ def _task_attempt_count(task, field):
return 0
def is_generatable_task(task):
"""判断任务是否能由② AI生成执行或重试。"""
def _has_generated_title(task):
return bool(str(getattr(task, "new_title", "") or "").strip())
def _has_generated_cover(task):
return bool(str(getattr(task, "new_cover_path", "") or "").strip())
def generation_needs(task, generate_cover=False):
"""Return which AI components still need generation for this task."""
stage = str(getattr(task, "stage", "") or "")
status = str(getattr(task, "status", "") or "")
if status in {"running", "skipped"}:
return False
if stage == "collected":
return True
if stage == "generated" and status == "failed":
return _task_attempt_count(task, "apply_attempts") == 0
return False
return {"title": False, "cover": False}
if stage not in {"collected", "generated"}:
return {"title": False, "cover": False}
if stage == "generated" and _task_attempt_count(task, "apply_attempts") > 0:
return {"title": False, "cover": False}
needs_title = not _has_generated_title(task)
needs_cover = bool(generate_cover) and not _has_generated_cover(task)
return {"title": needs_title, "cover": needs_cover}
def is_generatable_task(task, generate_cover=False):
"""判断任务是否能由② AI生成执行或重试。"""
needs = generation_needs(task, generate_cover=generate_cover)
return bool(needs["title"] or needs["cover"])
def generation_component_totals(tasks, generate_cover=False):
"""Count task and component gaps for a generation run."""
eligible = [
task for task in list(tasks)
if is_generatable_task(task, generate_cover=generate_cover)
]
title_total = 0
cover_total = 0
for task in eligible:
needs = generation_needs(task, generate_cover=generate_cover)
if needs["title"]:
title_total += 1
if needs["cover"]:
cover_total += 1
return {
"total": len(eligible),
"title_total": title_total,
"cover_total": cover_total,
}
def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None):
@@ -278,14 +317,27 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
should_stop = should_stop or (lambda: False)
eligible = [
task for task in list(tasks)
if is_generatable_task(task)
if is_generatable_task(task, generate_cover=generate_cover)
]
needs_by_task = {
getattr(task, "id", None): generation_needs(task, generate_cover=generate_cover)
for task in eligible
}
title_tasks = [
task for task in eligible
if needs_by_task.get(getattr(task, "id", None), {}).get("title")
]
cover_candidates = [
task for task in eligible
if needs_by_task.get(getattr(task, "id", None), {}).get("cover")
]
summary = {
"ok": True,
"total": len(eligible),
"title_total": len(title_tasks),
"title_done": 0,
"cover_done": 0,
"cover_total": len(eligible) if generate_cover else 0,
"cover_total": len(cover_candidates) if generate_cover else 0,
"generated_done": 0,
"failed": 0,
"cancelled": False,
@@ -331,7 +383,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
max_workers=max(1, int(generation_cfg.get("title_concurrency", 1)))
) as executor:
futures = {}
for task in eligible:
for task in title_tasks:
if should_stop():
summary["cancelled"] = True
break
@@ -361,29 +413,31 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
summary["title_done"] += 1
set_step(task, "title_done")
_emit_generation_event(on_event, task, "title", "title_done", "success")
if not generate_cover:
set_step(task, "db_write")
_emit_generation_event(on_event, task, "title", "db_write", "start")
db.set_generated(task.id, new_title, None, path=db_path)
set_step(task, "db_write")
_emit_generation_event(on_event, task, "title", "db_write", "start")
existing_cover_path = getattr(task, "new_cover_path", None)
db.set_generated(task.id, new_title, existing_cover_path, path=db_path)
needs_cover = needs_by_task.get(getattr(task, "id", None), {}).get("cover")
if not needs_cover:
summary["generated_done"] += 1
if on_task_update is not None:
on_task_update(
task.id,
{
"stage": "generated",
"status": "success",
"new_title": new_title,
"new_cover_path": None,
},
)
_emit_generation_event(
on_event,
task,
"title",
"db_write",
"success",
detail="仅生成标题",
if on_task_update is not None:
on_task_update(
task.id,
{
"stage": "generated",
"status": "success",
"new_title": new_title,
"new_cover_path": existing_cover_path,
},
)
_emit_generation_event(
on_event,
task,
"title",
"db_write",
"success",
detail="标题已保存,等待封面" if needs_cover else "仅生成标题",
)
except CancelledError:
summary["cancelled"] = True
_emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning")
@@ -401,9 +455,15 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
summary["ok"] = False
return summary
for task in eligible:
task_id = getattr(task, "id", None)
if task_id not in title_results and _has_generated_title(task):
title_results[task_id] = str(getattr(task, "new_title") or "")
_emit_generation_event(on_event, task, "title", "title_submit", "skipped", detail="已有标题")
cover_tasks = [
task for task in eligible
if task.id in title_results
task for task in cover_candidates
if getattr(task, "id", None) in title_results
]
with ThreadPoolExecutor(
max_workers=max(1, int(generation_cfg.get("image_concurrency", 1)))
+19 -10
View File
@@ -270,6 +270,7 @@ class GenerateTab(QWidget):
def _on_generate_cover_toggled(self, checked):
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
if self._save_generate_cover_setting(show_status=True):
self.refresh_tasks()
return
self.generate_cover_checkbox.blockSignals(True)
self.generate_cover_checkbox.setChecked(previous)
@@ -470,11 +471,17 @@ class GenerateTab(QWidget):
generate_cover = bool(self.generate_cover_checkbox.isChecked())
tasks = [
task for task in self.model.tasks
if ai.is_generatable_task(task)
if ai.is_generatable_task(task, generate_cover=generate_cover)
]
if not tasks:
self._set_status("当前筛选结果没有待生成或生成失败可重试任务;请先在①导入采集完成旧数据采集")
self._set_status(
"当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或勾选“生成封面图片(成本较高)”补封面"
)
return
component_totals = ai.generation_component_totals(
tasks,
generate_cover=generate_cover,
)
prompt_values = {
"title": self.title_prompt_edit.toPlainText(),
"cover": self.cover_prompt_edit.toPlainText(),
@@ -502,9 +509,10 @@ class GenerateTab(QWidget):
self._update_generate_progress(
{
"total": len(tasks),
"title_total": component_totals["title_total"],
"title_done": 0,
"cover_done": 0,
"cover_total": len(tasks) if generate_cover else 0,
"cover_total": component_totals["cover_total"] if generate_cover else 0,
"generated_done": 0,
"failed": 0,
"generate_cover": generate_cover,
@@ -681,15 +689,16 @@ class GenerateTab(QWidget):
def _update_generate_progress(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
title_total = max(0, int(payload.get("title_total", total) or 0))
title_done = max(0, int(payload.get("title_done", 0) or 0))
cover_done = max(0, int(payload.get("cover_done", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
failed = max(0, int(payload.get("failed", 0) or 0))
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
self.title_progress_label.setText(f"标题 {title_done}/{total}")
self.title_progress_label.setText(f"标题 {title_done}/{title_total}")
self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}")
self.failed_progress_label.setText(f"失败 {failed}")
self._set_progress_bar(self.title_progress_bar, title_done, total)
self._set_progress_bar(self.title_progress_bar, title_done, title_total)
self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
def _reset_cmhub_balance_label(self):
@@ -736,12 +745,13 @@ class GenerateTab(QWidget):
def _generate_progress_text(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
title_total = max(0, int(payload.get("title_total", total) or 0))
cover_total = self._cover_total_for_progress(payload, total)
return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format(
return "标题{title}/{title_total} · 图片{cover}/{cover_total} · 失败{failed}".format(
title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0),
cover_total=cover_total,
total=payload.get("total", 0),
title_total=title_total,
failed=payload.get("failed", 0),
)
@@ -896,7 +906,8 @@ class GenerateTab(QWidget):
if selected_status in (None, "all"):
return True
if selected_status == "to_generate":
return ai.is_generatable_task(task) and task.status != "failed"
generate_cover = bool(self.generate_cover_checkbox.isChecked())
return ai.is_generatable_task(task, generate_cover=generate_cover) and task.status != "failed"
if selected_status == "generated":
return task.stage == "generated"
if selected_status == "applied":
@@ -906,5 +917,3 @@ class GenerateTab(QWidget):
if selected_status == "skipped":
return task.status == "skipped"
return True
+18 -8
View File
@@ -37,10 +37,16 @@ class GenerateWorker(BaseWorker):
if str(account.alias).strip()
}
self._account_by_alias = account_by_alias
ai_cfg = appconfig.ai_config(self.config)
generate_cover = bool(ai_cfg.get("generate_cover", False))
eligible = [
task for task in self.tasks
if ai.is_generatable_task(task)
if ai.is_generatable_task(task, generate_cover=generate_cover)
]
component_totals = ai.generation_component_totals(
eligible,
generate_cover=generate_cover,
)
self._eligible_total = len(eligible)
self._task_positions = {
getattr(task, "id", None): index
@@ -48,17 +54,18 @@ class GenerateWorker(BaseWorker):
}
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
ai_cfg = appconfig.ai_config(self.config)
generate_cover = bool(ai_cfg.get("generate_cover", False))
if generate_cover:
start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
start_message = "[开始] 本轮生成 {total} 条:标题{title_total},图片{cover_total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
total=len(eligible),
title_total=component_totals["title_total"],
cover_total=component_totals["cover_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=ai_cfg.get("image_concurrency", 1),
)
else:
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format(
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题{title_total};标题并发{title_concurrency}".format(
total=len(eligible),
title_total=component_totals["title_total"],
title_concurrency=ai_cfg.get("title_concurrency", 1),
)
self._log_run_event(start_message)
@@ -85,9 +92,10 @@ class GenerateWorker(BaseWorker):
"ok": False,
"error": error,
"total": len(eligible),
"title_total": component_totals["title_total"],
"title_done": 0,
"cover_done": 0,
"cover_total": len(eligible) if generate_cover else 0,
"cover_total": component_totals["cover_total"] if generate_cover else 0,
"generated_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
@@ -160,6 +168,8 @@ class GenerateWorker(BaseWorker):
if phase == "title":
if result == "start" and step == "title_submit":
return f"[标题] {self._task_progress_label(task)} 开始生成"
if result == "skipped":
return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文"
if result == "success" and step == "title_done":
return f"[标题] {self._task_progress_label(task)} 成功"
if result == "success" and step == "db_write":
@@ -251,12 +261,13 @@ class GenerateWorker(BaseWorker):
return f"[完成] AI 生成完成:{progress}"
def _summary_text(self, summary):
title_total = summary.get("title_total", summary.get("total", 0))
cover_total = summary.get("cover_total", summary.get("total", 0))
return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format(
title=summary.get("title_done", 0),
cover=summary.get("cover_done", 0),
cover_total=cover_total,
total=summary.get("total", 0),
total=title_total,
failed=summary.get("failed", 0),
)
@@ -1994,4 +2005,3 @@ class AIModelTestWorker(BaseWorker):
def _elapsed_ms(self, started):
return _elapsed_ms(started)