feat: add diagnostic logs for generation flows

This commit is contained in:
chengma
2026-06-29 17:49:45 +08:00
parent 588b352eac
commit cd1256bc5e
17 changed files with 1455 additions and 104 deletions
+128 -22
View File
@@ -6,12 +6,13 @@ import copy
import json
import mimetypes
import os
import threading
import time
import urllib.error
import urllib.request
import uuid
from . import appconfig, db
from . import appconfig, db, diagnostics
from . import prompts as prompt_module
from .config import make_slug
@@ -34,12 +35,15 @@ def gen_title(
retry=None,
config=None,
models_path=appconfig.AI_MODELS_PATH,
on_step=None,
):
"""Generate a new product title from a prompt and the old title."""
cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg)
_notify_step(on_step, "load_text_model")
model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
_notify_step(on_step, "title_build_request")
payload = _chat_payload(
model,
[
@@ -51,6 +55,7 @@ def gen_title(
],
)
attempts = _attempt_count(ai_cfg, retry)
_notify_step(on_step, "title_request")
data = _call_with_retry(
model,
payload,
@@ -58,6 +63,7 @@ def gen_title(
attempts,
request_kind="json",
)
_notify_step(on_step, "title_parse_response")
text = _extract_text(data).strip()
if not text:
raise AIError("AI 返回为空标题")
@@ -73,9 +79,11 @@ def gen_cover(
retry=None,
config=None,
models_path=appconfig.AI_MODELS_PATH,
on_step=None,
):
"""Generate a new cover image and save it as a JPEG file."""
_notify_step(on_step, "cover_validate_input")
old_cover_path = os.path.abspath(str(old_cover_path))
if not os.path.exists(old_cover_path):
raise FileNotFoundError("旧封面图片不存在: %s" % old_cover_path)
@@ -84,14 +92,17 @@ def gen_cover(
cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg)
_notify_step(on_step, "load_image_model")
model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
attempts = _attempt_count(ai_cfg, retry)
api_type = model.get("api_type", "auto")
_notify_step(on_step, "cover_build_request")
if api_type == "images_edits":
body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution)
_notify_step(on_step, "cover_request")
data = _call_with_retry(
model,
body,
@@ -102,12 +113,24 @@ def gen_cover(
)
else:
payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution)
_notify_step(on_step, "cover_request")
data = _call_with_retry(model, payload, cfg, attempts, request_kind="json")
_notify_step(on_step, "cover_parse_response")
image_bytes = _extract_image_bytes(data, model, cfg)
_notify_step(on_step, "cover_save")
return _save_jpeg(image_bytes, out_path, resolution, quality)
def _notify_step(callback, step):
if callback is None:
return
try:
callback(step)
except Exception:
pass
def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None):
"""Generate titles first, then covers, and persist each successful task."""
@@ -132,6 +155,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
image_root = runtime.get("image_dir") or appconfig.image_dir(config)
account_by_alias = runtime.get("account_by_alias") or {}
on_task_update = runtime.get("on_task_update")
on_event = runtime.get("on_event")
on_error = runtime.get("on_error")
title_prompt = _prompt_value(prompts, "title")
cover_prompt = _prompt_value(prompts, "cover")
should_stop = should_stop or (lambda: False)
@@ -149,6 +174,22 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
}
_emit_generation_progress(on_progress, summary)
title_results = {}
step_by_task = {}
step_lock = threading.Lock()
def set_step(task, step):
with step_lock:
step_by_task[getattr(task, "id", None)] = str(step)
def get_step(task, fallback):
with step_lock:
return step_by_task.get(getattr(task, "id", None), fallback)
def step_callback(task, phase):
def callback(step):
set_step(task, step)
_emit_generation_event(on_event, task, phase, step, "start")
return callback
with ThreadPoolExecutor(
max_workers=max(1, int(generation_cfg.get("title_concurrency", 1)))
@@ -158,6 +199,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
if should_stop():
summary["cancelled"] = True
break
set_step(task, "title_submit")
_emit_generation_event(on_event, task, "title", "title_submit", "start")
futures[
executor.submit(
gen_title,
@@ -166,6 +209,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
retry=generation_cfg.get("retry"),
config=config,
models_path=models_path,
on_step=step_callback(task, "title"),
)
] = task
for future in as_completed(futures):
@@ -176,12 +220,18 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
try:
title_results[task.id] = future.result()
summary["title_done"] += 1
set_step(task, "title_done")
_emit_generation_event(on_event, task, "title", "title_done", "success")
except CancelledError:
summary["cancelled"] = True
_emit_generation_event(on_event, task, "title", get_step(task, "title_request"), "cancelled", level="warning")
except Exception as exc:
summary["failed"] += 1
summary["ok"] = False
_mark_generate_failed(task, exc, db_path, on_task_update)
step = get_step(task, "title_request")
error = _mark_generate_failed(task, exc, db_path, on_task_update)
_emit_generation_event(on_event, task, "title", step, "failed", detail=error, level="error")
_emit_generation_error(on_error, task, "title", step, exc, error)
_emit_generation_progress(on_progress, summary)
cover_tasks = [
@@ -197,23 +247,38 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
summary["cancelled"] = True
break
new_title = title_results[task.id]
rendered_cover_prompt = prompt_module.render_prompt(
cover_prompt,
_prompt_context(task, new_title, account_by_alias),
)
futures[
executor.submit(
gen_cover,
rendered_cover_prompt,
getattr(task, "old_cover_path", "") or "",
_new_cover_path(task, account_by_alias, image_root),
resolution=generation_cfg.get("resolution"),
jpg_quality=generation_cfg.get("jpg_quality"),
retry=generation_cfg.get("retry"),
config=config,
models_path=models_path,
try:
set_step(task, "cover_prompt_render")
_emit_generation_event(on_event, task, "cover", "cover_prompt_render", "start")
rendered_cover_prompt = prompt_module.render_prompt(
cover_prompt,
_prompt_context(task, new_title, account_by_alias),
)
] = (task, new_title)
_emit_generation_event(on_event, task, "cover", "cover_prompt_render", "success")
set_step(task, "cover_submit")
_emit_generation_event(on_event, task, "cover", "cover_submit", "start")
futures[
executor.submit(
gen_cover,
rendered_cover_prompt,
getattr(task, "old_cover_path", "") or "",
_new_cover_path(task, account_by_alias, image_root),
resolution=generation_cfg.get("resolution"),
jpg_quality=generation_cfg.get("jpg_quality"),
retry=generation_cfg.get("retry"),
config=config,
models_path=models_path,
on_step=step_callback(task, "cover"),
)
] = (task, new_title)
except Exception as exc:
summary["failed"] += 1
summary["ok"] = False
step = get_step(task, "cover_prompt_render")
error = _mark_generate_failed(task, exc, db_path, on_task_update)
_emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error")
_emit_generation_error(on_error, task, "cover", step, exc, error)
_emit_generation_progress(on_progress, summary)
for future in as_completed(futures):
task, new_title = futures[future]
if should_stop():
@@ -221,6 +286,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
_cancel_pending(futures)
try:
new_cover_path = future.result()
set_step(task, "db_write")
_emit_generation_event(on_event, task, "cover", "db_write", "start")
db.set_generated(task.id, new_title, new_cover_path, path=db_path)
summary["cover_done"] += 1
if on_task_update is not None:
@@ -233,19 +300,23 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
"new_cover_path": new_cover_path,
},
)
_emit_generation_event(on_event, task, "cover", "db_write", "success")
except CancelledError:
summary["cancelled"] = True
_emit_generation_event(on_event, task, "cover", get_step(task, "cover_request"), "cancelled", level="warning")
except Exception as exc:
summary["failed"] += 1
summary["ok"] = False
_mark_generate_failed(task, exc, db_path, on_task_update)
step = get_step(task, "cover_request")
error = _mark_generate_failed(task, exc, db_path, on_task_update)
_emit_generation_event(on_event, task, "cover", step, "failed", detail=error, level="error")
_emit_generation_error(on_error, task, "cover", step, exc, error)
_emit_generation_progress(on_progress, summary)
if summary["cancelled"]:
summary["ok"] = False
return summary
def _role_model(category, name, models_path):
if not name:
raise AIError("未配置默认 %s 模型" % category)
@@ -332,10 +403,45 @@ def _new_cover_path(task, account_by_alias, image_root):
def _mark_generate_failed(task, exc, db_path, on_task_update):
error = str(exc) or exc.__class__.__name__
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
db.mark_failed(task.id, "generate", error, path=db_path)
if on_task_update is not None:
on_task_update(task.id, {"status": "failed", "last_error": error})
return error
def _emit_generation_event(callback, task, phase, step, result, detail=None, level="info"):
if callback is None:
return
payload = {
"task": task,
"phase": phase,
"step": str(step),
"result": result,
"level": level,
}
if detail is not None:
payload["detail"] = diagnostics.redact_log_text(detail)
try:
callback(payload)
except Exception:
return
def _emit_generation_error(callback, task, phase, step, exc, error):
if callback is None:
return
payload = {
"task": task,
"phase": phase,
"step": str(step),
"error": diagnostics.redact_log_text(error),
"exception": exc,
}
try:
callback(payload)
except Exception:
return
def _cancel_pending(futures):
@@ -401,7 +507,7 @@ def _call_once(model, body, config, request_kind, content_type=None):
else:
data = body
request = urllib.request.Request(
model["url"],
appconfig.model_request_url(model),
data=data,
headers=_headers(model, content_type),
method="POST",