Files
cmshoppe/app/ai.py
T

749 lines
25 KiB
Python

"""AI generation helpers backed by configurable HTTP model endpoints."""
import base64
from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed
import copy
import json
import mimetypes
import os
import threading
import time
import urllib.error
import urllib.request
import uuid
from . import appconfig, db, diagnostics, image_paths
from . import prompts as prompt_module
class AIError(RuntimeError):
"""Raised when AI generation cannot complete."""
_RESOLUTION_SIZES = {
"512": (512, 512),
"1k": (1024, 1024),
"2k": (2048, 2048),
"4k": (4096, 4096),
}
def gen_title(
title_prompt,
old_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,
[
{"role": "system", "content": str(title_prompt or "").strip()},
{
"role": "user",
"content": "旧标题:\n%s\n\n请只返回新标题,不要解释。" % str(old_title or ""),
},
],
)
attempts = _attempt_count(ai_cfg, retry)
_notify_step(on_step, "title_request")
data = _call_with_retry(
model,
payload,
cfg,
attempts,
request_kind="json",
)
_notify_step(on_step, "title_parse_response")
text = _extract_text(data).strip()
if not text:
raise AIError("AI 返回为空标题")
return text
def gen_cover(
cover_prompt,
old_cover_path,
out_path,
resolution=None,
jpg_quality=None,
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)
if not out_path:
raise AIError("缺少新封面输出路径")
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,
cfg,
attempts,
request_kind="multipart",
content_type=content_type,
)
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."""
runtime = dict(ai_cfg or {})
config = _runtime_config(runtime)
generation_cfg = appconfig.ai_config(config)
generation_cfg.update(
{
key: value
for key, value in runtime.items()
if key in {
"title_concurrency",
"image_concurrency",
"retry",
"jpg_quality",
"resolution",
}
}
)
db_path = runtime.get("db_path")
models_path = runtime.get("models_path", appconfig.AI_MODELS_PATH)
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)
eligible = [
task for task in list(tasks)
if getattr(task, "stage", None) == "collected"
]
summary = {
"ok": True,
"total": len(eligible),
"title_done": 0,
"cover_done": 0,
"failed": 0,
"cancelled": False,
}
_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)))
) as executor:
futures = {}
for task in eligible:
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,
title_prompt,
getattr(task, "old_title", "") or "",
retry=generation_cfg.get("retry"),
config=config,
models_path=models_path,
on_step=step_callback(task, "title"),
)
] = task
for future in as_completed(futures):
task = futures[future]
if should_stop():
summary["cancelled"] = True
_cancel_pending(futures)
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
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 = [
task for task in eligible
if task.id in title_results
]
with ThreadPoolExecutor(
max_workers=max(1, int(generation_cfg.get("image_concurrency", 1)))
) as executor:
futures = {}
for task in cover_tasks:
if should_stop():
summary["cancelled"] = True
break
new_title = title_results[task.id]
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),
)
_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():
summary["cancelled"] = True
_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:
on_task_update(
task.id,
{
"stage": "generated",
"status": "success",
"new_title": new_title,
"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
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)
model = appconfig.get_model(name, path=models_path)
if model.get("category") != category:
raise AIError("模型 %s 不是 %s 类别" % (name, category))
if not model.get("enabled", True):
raise AIError("模型已禁用: %s" % name)
missing = [
field
for field in ("url", "model", "api_key")
if not str(model.get(field, "")).strip()
]
if missing:
raise AIError("模型 %s 缺少字段: %s" % (name, ", ".join(missing)))
return model
def _runtime_config(runtime):
if runtime.get("config") is not None:
return runtime["config"]
config = appconfig.load_config()
ai_updates = {
key: value
for key, value in runtime.items()
if key in {
"default_text_model",
"default_image_model",
"title_concurrency",
"image_concurrency",
"retry",
"jpg_quality",
"resolution",
"resolution_timeouts",
}
}
if ai_updates:
config = copy.deepcopy(config)
config.setdefault("ai", {}).update(ai_updates)
return config
def _prompt_value(prompt_values, name):
if isinstance(prompt_values, dict):
return str(
prompt_values.get(name)
or prompt_values.get(f"{name}_prompt")
or ""
)
return ""
def _prompt_context(task, new_title, account_by_alias):
return {
"old_title": getattr(task, "old_title", ""),
"new_title": new_title,
"item_id": getattr(task, "item_id", ""),
"account_name": _account_name(task, account_by_alias),
"alias": getattr(task, "alias", ""),
}
def _account_name(task, account_by_alias):
alias = str(getattr(task, "alias", "") or "").strip()
account = account_by_alias.get(alias)
if account is not None:
return getattr(account, "account_name", "") or alias
return getattr(task, "account_name", "") or alias
def _new_cover_path(task, account_by_alias, image_root):
alias = str(getattr(task, "alias", "") or "").strip()
account = account_by_alias.get(alias)
return image_paths.task_image_path(image_root, task, account, "new")
def _mark_generate_failed(task, exc, db_path, on_task_update):
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):
for future in futures:
if not future.done():
future.cancel()
def _emit_generation_progress(on_progress, summary):
if on_progress is None:
return
payload = dict(summary)
try:
on_progress(payload)
except TypeError:
on_progress(
payload.get("title_done", 0),
payload.get("cover_done", 0),
payload.get("failed", 0),
)
def _attempt_count(ai_cfg, retry):
retry_count = ai_cfg.get("retry", 2) if retry is None else retry
return max(1, int(retry_count) + 1)
def _read_timeout(model, config):
return int(model.get("timeout_seconds") or appconfig.response_timeout(config))
def _connect_timeout(model):
return int(model.get("connect_timeout_seconds") or 30)
def _headers(model, content_type):
return {
"Authorization": "Bearer " + model["api_key"],
"Content-Type": content_type,
}
def _call_with_retry(model, body, config, attempts, request_kind, content_type=None):
last_exc = None
for index in range(attempts):
try:
return _call_once(model, body, config, request_kind, content_type=content_type)
except Exception as exc:
last_exc = exc
if index + 1 >= attempts:
break
time.sleep(min(2.0, 0.4 * (index + 1)))
raise AIError(
"AI 调用失败(已尝试 %s 次): %s"
% (attempts, _redact(str(last_exc), model))
) from last_exc
def _call_once(model, body, config, request_kind, content_type=None):
if request_kind == "json":
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
content_type = "application/json"
else:
data = body
request = urllib.request.Request(
appconfig.model_request_url(model),
data=data,
headers=_headers(model, content_type),
method="POST",
)
timeout = max(_connect_timeout(model), _read_timeout(model, config))
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = _safe_http_error(exc, model)
raise AIError("HTTP %s: %s" % (exc.code, detail)) from exc
except urllib.error.URLError as exc:
raise AIError(_redact(str(exc.reason), model)) from exc
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise AIError("AI 返回不是有效 JSON") from exc
def _safe_http_error(exc, model):
try:
body = exc.read(2048).decode("utf-8", errors="replace")
except Exception:
body = ""
return _redact(body or str(exc), model)
def _chat_payload(model, messages):
payload = {
"model": model["model"],
"messages": messages,
}
payload.update(copy.deepcopy(model.get("extra_body", {})))
return payload
def _image_chat_payload(model, cover_prompt, old_cover_path, resolution):
prompt = "%s\n\n目标分辨率:%s。" % (str(cover_prompt or "").strip(), resolution)
content = [
{"type": "text", "text": prompt.strip()},
{
"type": "image_url",
"image_url": {"url": _image_data_url(old_cover_path)},
},
]
return _chat_payload(model, [{"role": "user", "content": content}])
def _image_edit_body(model, cover_prompt, old_cover_path, resolution):
fields = {
"model": model["model"],
"prompt": str(cover_prompt or ""),
"size": _resolution_size_text(resolution),
}
fields.update(copy.deepcopy(model.get("extra_body", {})))
files = {
"image": (
os.path.basename(old_cover_path),
open(old_cover_path, "rb").read(),
mimetypes.guess_type(old_cover_path)[0] or "application/octet-stream",
)
}
return _multipart_body(fields, files)
def _multipart_body(fields, files):
boundary = "----cmshopee-%s" % uuid.uuid4().hex
chunks = []
for name, value in fields.items():
chunks.extend(
[
("--%s\r\n" % boundary).encode("utf-8"),
('Content-Disposition: form-data; name="%s"\r\n\r\n' % name).encode("utf-8"),
str(value).encode("utf-8"),
b"\r\n",
]
)
for name, file_info in files.items():
filename, data, content_type = file_info
chunks.extend(
[
("--%s\r\n" % boundary).encode("utf-8"),
(
'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
% (name, filename)
).encode("utf-8"),
("Content-Type: %s\r\n\r\n" % content_type).encode("utf-8"),
data,
b"\r\n",
]
)
chunks.append(("--%s--\r\n" % boundary).encode("utf-8"))
return b"".join(chunks), "multipart/form-data; boundary=%s" % boundary
def _image_data_url(path):
mime = mimetypes.guess_type(path)[0] or "image/jpeg"
with open(path, "rb") as fh:
encoded = base64.b64encode(fh.read()).decode("ascii")
return "data:%s;base64,%s" % (mime, encoded)
def _extract_text(data):
if isinstance(data, dict):
for key in ("output_text", "text", "content"):
value = data.get(key)
if isinstance(value, str):
return value
choices = data.get("choices")
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
if isinstance(first.get("text"), str):
return first["text"]
message = first.get("message") or {}
content = message.get("content")
return _content_text(content)
return ""
def _content_text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if not isinstance(item, dict):
continue
value = item.get("text") or item.get("content")
if isinstance(value, str):
parts.append(value)
return "".join(parts)
return ""
def _extract_image_bytes(data, model, config):
image_ref = _find_image_ref(data)
if not image_ref:
raise AIError("AI 返回中没有图片数据")
if image_ref.startswith("data:"):
return _decode_data_url(image_ref)
if _looks_base64(image_ref):
return base64.b64decode(image_ref)
return _download_image(image_ref, model, config)
def _find_image_ref(value):
if isinstance(value, dict):
for key in ("b64_json", "base64", "image_base64", "image", "url"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
return candidate.strip()
image_url = value.get("image_url")
if isinstance(image_url, str):
return image_url
if isinstance(image_url, dict):
candidate = image_url.get("url")
if isinstance(candidate, str):
return candidate
for key in ("data", "choices", "output", "content", "images"):
candidate = _find_image_ref(value.get(key))
if candidate:
return candidate
message = value.get("message")
if message is not None:
candidate = _find_image_ref(message)
if candidate:
return candidate
elif isinstance(value, list):
for item in value:
candidate = _find_image_ref(item)
if candidate:
return candidate
return None
def _decode_data_url(value):
if "," not in value:
raise AIError("图片 data URL 格式错误")
return base64.b64decode(value.split(",", 1)[1])
def _looks_base64(value):
compact = value.strip()
if compact.startswith(("http://", "https://")):
return False
if len(compact) < 32:
return False
allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r")
return all(char in allowed for char in compact)
def _download_image(url, model, config):
request = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(request, timeout=_read_timeout(model, config)) as response:
return response.read()
except Exception as exc:
raise AIError("下载 AI 图片失败: %s" % _redact(str(exc), model)) from exc
def _save_jpeg(image_bytes, out_path, resolution, jpg_quality):
try:
from PIL import Image
except ImportError as exc:
raise AIError("缺少 Pillow,无法保存 AI 图片") from exc
import io
out_path = os.path.abspath(str(out_path))
directory = os.path.dirname(out_path)
if directory:
os.makedirs(directory, exist_ok=True)
size = _resolution_size(resolution)
try:
with Image.open(io.BytesIO(image_bytes)) as image:
image = image.convert("RGB")
if size:
image = image.resize(size, Image.LANCZOS)
image.save(out_path, "JPEG", quality=jpg_quality, optimize=True)
except Exception as exc:
raise AIError("AI 图片保存失败: %s" % exc) from exc
return out_path
def _resolution_size(resolution):
return _RESOLUTION_SIZES.get(str(resolution))
def _resolution_size_text(resolution):
size = _resolution_size(resolution)
if not size:
return str(resolution)
return "%sx%s" % size
def _jpg_quality(value):
value = int(value)
return min(100, max(1, value))
def _redact(text, model):
result = str(text)
for secret in (model.get("api_key"),):
if secret:
result = result.replace(str(secret), "***")
return result