feat(generate): confirm product status scope
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import QEvent, QRectF, QSize
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
|
||||
from ... import product_status
|
||||
from .. import file_manager
|
||||
from ..models import GenerateTaskTableModel
|
||||
from ..widgets import *
|
||||
@@ -1732,16 +1733,33 @@ class GenerateTab(QWidget):
|
||||
return
|
||||
generate_mode = self._current_generate_mode()
|
||||
generate_cover = appconfig.generate_mode_includes_cover(generate_mode)
|
||||
tasks = [
|
||||
task for task in self.model.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
if not tasks:
|
||||
base_candidates = self._generation_candidates(generate_mode)
|
||||
if not base_candidates:
|
||||
if generate_mode == "cover":
|
||||
self._set_status("当前筛选结果没有可生成的封面;请确认商品已完成采集且尚未生成新封面。")
|
||||
else:
|
||||
self._set_status("当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或调整生成内容")
|
||||
return
|
||||
preview_plan = product_status.build_generation_plan(
|
||||
base_candidates,
|
||||
generate_mode,
|
||||
)
|
||||
generation_scope = self._choose_generation_scope(preview_plan)
|
||||
if generation_scope is None:
|
||||
self._set_status("已取消 AI 生成")
|
||||
return
|
||||
current_plan = product_status.build_generation_plan(
|
||||
self._generation_candidates(generate_mode),
|
||||
generate_mode,
|
||||
generation_scope,
|
||||
)
|
||||
if current_plan["fingerprint"] != preview_plan["fingerprint"]:
|
||||
self._set_status("当前任务数据已变化,请重新开始生成")
|
||||
return
|
||||
tasks = current_plan["execution_tasks"]
|
||||
if not tasks:
|
||||
self._set_status("当前筛选结果没有状态正常的可生成任务;请先完成采集或选择生成所有状态的商品")
|
||||
return
|
||||
component_totals = ai.generation_component_totals(
|
||||
tasks,
|
||||
generate_mode=generate_mode,
|
||||
@@ -1756,6 +1774,10 @@ class GenerateTab(QWidget):
|
||||
db_path=self.db_path,
|
||||
config=self.config,
|
||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||
generation_scope=generation_scope,
|
||||
product_status_counts=current_plan["status_counts"],
|
||||
status_scope_excluded=current_plan["scope_excluded"],
|
||||
generation_plan_fingerprint=current_plan["fingerprint"],
|
||||
)
|
||||
worker.progress.connect(self._on_generate_progress)
|
||||
worker.row_updated.connect(self._on_generate_row_updated)
|
||||
@@ -1790,6 +1812,54 @@ class GenerateTab(QWidget):
|
||||
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
|
||||
thread.start()
|
||||
|
||||
def _generation_candidates(self, generate_mode):
|
||||
return [
|
||||
task
|
||||
for task in self.model.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
|
||||
def _choose_generation_scope(self, plan):
|
||||
status_counts = dict(plan.get("status_counts") or {})
|
||||
normal_count = status_counts.get(product_status.STATUS_NORMAL, 0)
|
||||
unlisted_count = status_counts.get(product_status.STATUS_UNLISTED, 0)
|
||||
reviewing_count = status_counts.get(product_status.STATUS_REVIEWING, 0)
|
||||
unknown_count = status_counts.get(product_status.STATUS_UNKNOWN, 0)
|
||||
total = len(plan.get("base_candidates") or [])
|
||||
non_normal_count = total - normal_count
|
||||
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Question)
|
||||
box.setWindowTitle("选择生成范围")
|
||||
box.setText("请选择本轮要生成的商品范围。")
|
||||
box.setInformativeText(
|
||||
"真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n"
|
||||
"生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。"
|
||||
"状态未知商品请优先回到①重新采集确认。".format(
|
||||
total=total,
|
||||
normal=normal_count,
|
||||
unlisted=unlisted_count,
|
||||
reviewing=reviewing_count,
|
||||
unknown=unknown_count,
|
||||
)
|
||||
)
|
||||
normal_button = box.addButton("只生成状态正常的商品", QMessageBox.AcceptRole)
|
||||
normal_button.setObjectName("generateNormalOnlyButton")
|
||||
all_button = box.addButton("生成所有状态的商品", QMessageBox.DestructiveRole)
|
||||
all_button.setObjectName("generateAllStatusesButton")
|
||||
all_button.setStyleSheet("color: #cf222e; font-weight: 600;")
|
||||
all_button.setEnabled(non_normal_count > 0)
|
||||
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||
cancel_button.setObjectName("generateScopeCancelButton")
|
||||
box.setDefaultButton(normal_button)
|
||||
box.setEscapeButton(cancel_button)
|
||||
box.exec()
|
||||
if box.clickedButton() is normal_button:
|
||||
return product_status.SCOPE_NORMAL_ONLY
|
||||
if all_button.isEnabled() and box.clickedButton() is all_button:
|
||||
return product_status.SCOPE_ALL
|
||||
return None
|
||||
|
||||
def stop_generate(self, checked=False):
|
||||
if self.generate_worker is not None:
|
||||
self.generate_worker.cancel()
|
||||
|
||||
+36
-1
@@ -678,6 +678,10 @@ class GenerateWorker(BaseWorker):
|
||||
db_path=None,
|
||||
config=None,
|
||||
diagnostic_log_dir=None,
|
||||
generation_scope="all",
|
||||
product_status_counts=None,
|
||||
status_scope_excluded=0,
|
||||
generation_plan_fingerprint=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.tasks = list(tasks)
|
||||
@@ -685,6 +689,15 @@ class GenerateWorker(BaseWorker):
|
||||
self.db_path = db_path
|
||||
self.config = config
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self.generation_scope = product_status.normalize_scope(generation_scope)
|
||||
self.product_status_counts = {
|
||||
status: int((product_status_counts or {}).get(status, 0) or 0)
|
||||
for status in product_status.VALID_PRODUCT_STATUSES
|
||||
}
|
||||
self.status_scope_excluded = max(0, int(status_scope_excluded or 0))
|
||||
self.generation_plan_fingerprint = (
|
||||
str(generation_plan_fingerprint or "") or None
|
||||
)
|
||||
self._run_id = None
|
||||
self._account_by_alias = {}
|
||||
self._task_positions = {}
|
||||
@@ -713,6 +726,12 @@ class GenerateWorker(BaseWorker):
|
||||
task for task in self.tasks
|
||||
if ai.is_generatable_task(task, generate_mode=generate_mode)
|
||||
]
|
||||
if self.generation_scope == product_status.SCOPE_NORMAL_ONLY:
|
||||
eligible = [
|
||||
task
|
||||
for task in eligible
|
||||
if product_status.is_normal(getattr(task, "product_status", None))
|
||||
]
|
||||
component_totals = ai.generation_component_totals(
|
||||
eligible,
|
||||
generate_mode=generate_mode,
|
||||
@@ -757,10 +776,18 @@ class GenerateWorker(BaseWorker):
|
||||
title_concurrency=ai_cfg.get("title_concurrency", 1),
|
||||
started_at=self._run_started_at_text,
|
||||
)
|
||||
start_message += ";生成范围:{scope};按范围排除{excluded}条".format(
|
||||
scope=(
|
||||
"仅状态正常"
|
||||
if self.generation_scope == product_status.SCOPE_NORMAL_ONLY
|
||||
else "所有状态"
|
||||
),
|
||||
excluded=self.status_scope_excluded,
|
||||
)
|
||||
self._log_run_event(start_message)
|
||||
try:
|
||||
summary = ai.generate_batch(
|
||||
self.tasks,
|
||||
eligible,
|
||||
self.prompt_values,
|
||||
ai_cfg={
|
||||
"config": self.config,
|
||||
@@ -810,6 +837,10 @@ class GenerateWorker(BaseWorker):
|
||||
summary["billing_error"] = dict(self._billing_error)
|
||||
summary["ok"] = False
|
||||
summary["cancelled"] = True
|
||||
summary["generation_scope"] = self.generation_scope
|
||||
summary["product_status_counts"] = dict(self.product_status_counts)
|
||||
summary["status_scope_excluded"] = self.status_scope_excluded
|
||||
summary["generation_plan_fingerprint"] = self.generation_plan_fingerprint
|
||||
summary["run_id"] = self._run_id
|
||||
summary["batch_ids"] = batch_ids
|
||||
status = "failed" if summary.get("billing_error") or summary.get("error") else ("cancelled" if summary.get("cancelled") else "done")
|
||||
@@ -1131,6 +1162,10 @@ class GenerateWorker(BaseWorker):
|
||||
"image_concurrency": ai_cfg.get("image_concurrency"),
|
||||
"generate_cover": ai_cfg.get("generate_cover", False),
|
||||
"backend": ai_cfg.get("backend", "direct"),
|
||||
"generation_scope": self.generation_scope,
|
||||
"product_status_counts": dict(self.product_status_counts),
|
||||
"status_scope_excluded": self.status_scope_excluded,
|
||||
"generation_plan_fingerprint": self.generation_plan_fingerprint,
|
||||
},
|
||||
path=self.db_path,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
@@ -101,7 +103,37 @@ def partition_tasks(tasks) -> dict:
|
||||
return grouped
|
||||
|
||||
|
||||
def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict:
|
||||
"""Build a frozen, status-aware generation plan without mutating tasks."""
|
||||
|
||||
base_candidates = _deduplicate_tasks(tasks)
|
||||
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
||||
for task in base_candidates:
|
||||
status_counts[_task_status(task)] += 1
|
||||
|
||||
normalized_scope = normalize_scope(scope)
|
||||
if normalized_scope == SCOPE_ALL:
|
||||
execution_tasks = list(base_candidates)
|
||||
else:
|
||||
execution_tasks = [
|
||||
task for task in base_candidates if is_normal(_task_status(task))
|
||||
]
|
||||
|
||||
return {
|
||||
"base_candidates": base_candidates,
|
||||
"status_counts": status_counts,
|
||||
"execution_tasks": execution_tasks,
|
||||
"scope": normalized_scope,
|
||||
"scope_excluded": len(base_candidates) - len(execution_tasks),
|
||||
"fingerprint": _generation_fingerprint(base_candidates, generate_mode),
|
||||
}
|
||||
|
||||
|
||||
def normalize_collect_scope(value) -> str:
|
||||
return normalize_scope(value)
|
||||
|
||||
|
||||
def normalize_scope(value) -> str:
|
||||
value = str(value or "").strip().lower()
|
||||
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
|
||||
|
||||
@@ -114,6 +146,51 @@ def collect_skip_reason(status) -> str:
|
||||
return f"{status_label(status)},按本轮范围略过"
|
||||
|
||||
|
||||
def _deduplicate_tasks(tasks) -> list:
|
||||
seen = set()
|
||||
unique = []
|
||||
for task in list(tasks or []):
|
||||
task_id = _task_value(task, "id")
|
||||
key = ("id", str(task_id)) if task_id is not None else ("object", id(task))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(task)
|
||||
return unique
|
||||
|
||||
|
||||
def _generation_fingerprint(tasks, generate_mode) -> str:
|
||||
snapshots = [
|
||||
{
|
||||
"task_id": _task_value(task, "id"),
|
||||
"updated_at": _task_value(task, "updated_at"),
|
||||
"product_status": _task_status(task),
|
||||
"new_title": _task_value(task, "new_title"),
|
||||
"new_cover_path": _task_value(task, "new_cover_path"),
|
||||
"generate_mode": str(generate_mode or ""),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
encoded = json.dumps(
|
||||
snapshots,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _task_status(task) -> str:
|
||||
return normalize_status(_task_value(task, "product_status"))
|
||||
|
||||
|
||||
def _task_value(task, name, default=None):
|
||||
if isinstance(task, dict):
|
||||
return task.get(name, default)
|
||||
return getattr(task, name, default)
|
||||
|
||||
|
||||
def _alert_note(title: str, description: str) -> str:
|
||||
parts = []
|
||||
if title:
|
||||
|
||||
Reference in New Issue
Block a user