把“章节正文 + 图片”从 chapters 单表/JSON 结构,重构为“章节 chapter + 段落 section + 图片 memoir_images 独立表”的新数据模型,同时联动修改接口、PDF 导出、异步任务、迁移脚本、测试,以及修复 Android 端聊天列表显示问题。 (#9)
* refactor: 表结构重构,新增段落section和图片image新表 * fix: fix android app import error * refactor: 重构文件名 * fix: 优化提示词 * fix: 消息气泡显示位置异常问题 --------- Co-authored-by: yangshilin <2157598560@qq.com>
This commit is contained in:
@@ -12,11 +12,11 @@ from datetime import datetime, timezone
|
||||
import redis
|
||||
from celery import shared_task
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from database.database import SessionLocal
|
||||
from database.models import Book, Chapter, Segment, MemoirState, User
|
||||
from database.models import Book, Chapter, ChapterSection, MemoirImage, Segment, MemoirState, User
|
||||
from services.llm_service import llm_service
|
||||
from agents.state_schema import MemoirStateSchema, SlotData, default_state
|
||||
from agents.prompts.memory_prompts import (
|
||||
@@ -31,7 +31,11 @@ from agents.prompts.memory_prompts import (
|
||||
from agents.prompts.profile_prompts import format_user_profile_context
|
||||
import hashlib
|
||||
|
||||
from services.memoir_images.parser import build_initial_image_assets, parse_image_placeholders
|
||||
from services.memoir_images.parser import (
|
||||
build_initial_image_assets,
|
||||
parse_image_placeholders,
|
||||
split_narrative_to_sections,
|
||||
)
|
||||
from services.memoir_images.json_payload import extract_json_payload
|
||||
from services.memoir_images.prompting import MemoirImagePromptService
|
||||
from services.memoir_images.provider import LiblibImageProvider
|
||||
@@ -43,6 +47,7 @@ from services.memoir_images.schema import (
|
||||
IMAGE_STATUS_PROCESSING,
|
||||
normalize_image_assets,
|
||||
)
|
||||
from services.memoir_images.serializers import image_dict_to_row_kwargs, memoir_image_to_dict
|
||||
from services.memoir_images.settings import MemoirImageSettings
|
||||
from services.memoir_images.storage import TencentCosStorageService, CosUploadError
|
||||
|
||||
@@ -173,6 +178,38 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _memoir_image_from_asset(
|
||||
chapter_id: str,
|
||||
section_id: str | None,
|
||||
order_index: int,
|
||||
image_asset: dict,
|
||||
) -> MemoirImage:
|
||||
"""从单条图片 dict 构建 MemoirImage 行(用于写入 memoir_images 表)。"""
|
||||
kwargs = image_dict_to_row_kwargs(image_asset)
|
||||
return MemoirImage(
|
||||
id=str(uuid.uuid4()).replace("-", "")[:32],
|
||||
chapter_id=chapter_id,
|
||||
section_id=section_id,
|
||||
order_index=order_index,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _section_has_image_to_generate(section) -> bool:
|
||||
"""章节段落是否有待生成的配图(从 image_record / image_id 关联的 memoir_images 读取)。"""
|
||||
r = getattr(section, "image_record", None)
|
||||
if not r:
|
||||
return False
|
||||
status = (getattr(r, "status") or "").strip()
|
||||
return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED)
|
||||
|
||||
|
||||
def _chapter_has_any_section_images_to_generate(chapter) -> bool:
|
||||
if not chapter or not getattr(chapter, "sections", None):
|
||||
return False
|
||||
return any(_section_has_image_to_generate(s) for s in chapter.sections)
|
||||
|
||||
|
||||
def _select_placeholders_for_effective_max(
|
||||
placeholders: list[dict],
|
||||
existing_images: list[dict] | None,
|
||||
@@ -201,41 +238,128 @@ def _select_placeholders_for_effective_max(
|
||||
return [{**item, "index": index} for index, item in enumerate(selected)]
|
||||
|
||||
|
||||
def initialize_chapter_images(chapter) -> list[dict]:
|
||||
"""Parse IMAGE placeholders from chapter content and build pending image assets."""
|
||||
settings = MemoirImageSettings.from_env()
|
||||
if not settings.enabled:
|
||||
chapter.images = completed_image_assets(chapter.images)
|
||||
logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false")
|
||||
return chapter.images
|
||||
def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str, category: str, order_index: int, source_segments: list, user_id: str):
|
||||
"""
|
||||
将带占位符的 narrative 拆成 chapter_sections 并写入;为每段占位符创建 pending 配图。
|
||||
已有 section 与图片不删除,仅追加新内容。封面图先空着,不自动设置。
|
||||
chapter 可为已有章节或 None(会新建)。返回 chapter。
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
if chapter is None:
|
||||
chapter = Chapter(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
order_index=order_index,
|
||||
status="completed",
|
||||
category=category,
|
||||
cover_image=None,
|
||||
is_new=True,
|
||||
source_segments=source_segments or [],
|
||||
)
|
||||
db.add(chapter)
|
||||
db.flush()
|
||||
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
|
||||
effective_max = settings.effective_max_images(len(chapter.content or ""))
|
||||
all_placeholders = parse_image_placeholders(chapter.content, max_images=None)
|
||||
# 已有 sections 不删除,只追加新内容
|
||||
existing_sections = (
|
||||
db.execute(
|
||||
select(ChapterSection)
|
||||
.where(ChapterSection.chapter_id == chapter.id)
|
||||
.order_by(ChapterSection.order_index)
|
||||
)
|
||||
.scalars().all()
|
||||
)
|
||||
if existing_sections:
|
||||
existing_content = "\n\n".join(
|
||||
(s.content or "").strip() for s in existing_sections if (s.content or "").strip()
|
||||
)
|
||||
if existing_content and narrative.startswith(existing_content):
|
||||
new_part = narrative[len(existing_content):].lstrip()
|
||||
else:
|
||||
new_part = (narrative or "").strip()
|
||||
if not new_part:
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
|
||||
return chapter
|
||||
narrative_to_parse = new_part
|
||||
order_base = max(s.order_index for s in existing_sections) + 1
|
||||
else:
|
||||
narrative_to_parse = (narrative or "").strip()
|
||||
order_base = 0
|
||||
|
||||
segments = split_narrative_to_sections(narrative_to_parse)
|
||||
if not segments:
|
||||
sec = ChapterSection(
|
||||
id=str(uuid.uuid4()),
|
||||
chapter_id=chapter.id,
|
||||
order_index=order_base,
|
||||
content=(narrative_to_parse or "").strip() or "",
|
||||
image_id=None,
|
||||
)
|
||||
db.add(sec)
|
||||
db.flush()
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
|
||||
return chapter
|
||||
|
||||
settings = MemoirImageSettings.from_env()
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=settings) if settings.enabled else None
|
||||
effective_max = settings.effective_max_images(len(narrative_to_parse)) if settings.enabled else 0
|
||||
all_placeholders = [s["placeholder_info"] for s in segments if s.get("placeholder_info")]
|
||||
placeholders = _select_placeholders_for_effective_max(
|
||||
placeholders=all_placeholders,
|
||||
existing_images=chapter.images,
|
||||
existing_images=[],
|
||||
effective_max=effective_max,
|
||||
)
|
||||
style = prompt_service.CATEGORY_STYLE_MAP.get(chapter.category, settings.default_style)
|
||||
chapter.images = _merge_chapter_image_assets(
|
||||
existing_images=chapter.images,
|
||||
placeholders=placeholders,
|
||||
provider=settings.provider,
|
||||
style=style,
|
||||
size=settings.default_size,
|
||||
now_iso=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
logger.info(
|
||||
"章节图片初始化完成: chapter=%s, effective_max=%d, total_placeholders=%d, selected_placeholders=%d, images=%d, statuses=%s",
|
||||
chapter.id,
|
||||
effective_max,
|
||||
len(all_placeholders),
|
||||
len(placeholders),
|
||||
len(chapter.images or []),
|
||||
[item.get("status") for item in (chapter.images or [])],
|
||||
)
|
||||
return chapter.images
|
||||
) if settings.enabled else []
|
||||
selected_placeholder_set = {p.get("placeholder") for p in placeholders}
|
||||
|
||||
# 按顺序创建 section,保证每个 section 的 content 与 image 一一对应(order_index 严格递增)
|
||||
for i, seg in enumerate(segments):
|
||||
order_idx = order_base + i
|
||||
content = (seg.get("content") or "").strip()
|
||||
ph = seg.get("placeholder_info")
|
||||
image_asset = None
|
||||
if ph and settings.enabled and ph.get("placeholder") in selected_placeholder_set:
|
||||
style = prompt_service.CATEGORY_STYLE_MAP.get(category, settings.default_style) if prompt_service else settings.default_style
|
||||
image_asset = build_initial_image_assets(
|
||||
[ph],
|
||||
settings.provider,
|
||||
style,
|
||||
settings.default_size,
|
||||
now_iso,
|
||||
)[0]
|
||||
|
||||
sec = ChapterSection(
|
||||
id=str(uuid.uuid4()),
|
||||
chapter_id=chapter.id,
|
||||
order_index=order_idx,
|
||||
content=content,
|
||||
image_id=None,
|
||||
)
|
||||
db.add(sec)
|
||||
db.flush()
|
||||
if image_asset:
|
||||
# 本段配图与当前 section 绑定,memoir_images.order_index = section.order_index + 1(封面 0 预留)
|
||||
mi = _memoir_image_from_asset(chapter.id, sec.id, order_idx + 1, image_asset)
|
||||
db.add(mi)
|
||||
db.flush()
|
||||
sec.image_id = mi.id
|
||||
db.flush()
|
||||
# 封面图先空着,不自动用首图做封面
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
|
||||
return chapter
|
||||
|
||||
|
||||
def initialize_chapter_images(_chapter):
|
||||
"""
|
||||
兼容旧调用:若章节已改为 sections 存储,则图片初始化已在 _save_narrative_to_sections 中完成,直接返回。
|
||||
"""
|
||||
logger.info("initialize_chapter_images: 已由 _save_narrative_to_sections 处理 section 配图,跳过")
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes:
|
||||
@@ -464,14 +588,18 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
combined_text = "\n\n".join(segment_texts)
|
||||
source_ids = [seg.id for seg in category_segments]
|
||||
|
||||
# 查找 active 章节(被清除的章节不继续更新,而是创建新的)
|
||||
stmt_chapter = select(Chapter).where(
|
||||
Chapter.user_id == user_id,
|
||||
Chapter.category == chapter_category,
|
||||
Chapter.is_active == True,
|
||||
# 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections
|
||||
stmt_chapter = (
|
||||
select(Chapter)
|
||||
.where(
|
||||
Chapter.user_id == user_id,
|
||||
Chapter.category == chapter_category,
|
||||
Chapter.is_active == True,
|
||||
)
|
||||
.options(joinedload(Chapter.sections))
|
||||
)
|
||||
result_chapter = db.execute(stmt_chapter)
|
||||
chapter = result_chapter.scalar_one_or_none()
|
||||
chapter = result_chapter.unique().scalar_one_or_none()
|
||||
|
||||
# 获取 slot snippets
|
||||
slot_snippets = {
|
||||
@@ -480,9 +608,13 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
if value.snippet
|
||||
}
|
||||
|
||||
# 生成标题和内容
|
||||
# 生成标题和内容;已有章节的正文从 sections 拼接
|
||||
title = chapter.title if chapter else f"{chapter_category} 回忆"
|
||||
existing_content = chapter.content if chapter else ""
|
||||
existing_content = ""
|
||||
if chapter and getattr(chapter, "sections", None):
|
||||
existing_content = "\n\n".join(
|
||||
s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip()
|
||||
)
|
||||
narrative = combined_text
|
||||
|
||||
if llm:
|
||||
@@ -529,34 +661,22 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
|
||||
# 入库前:占位符位置用正则匹配后拼上固定模板
|
||||
narrative = inject_image_placeholder_template(narrative)
|
||||
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
|
||||
|
||||
# 更新或创建章节
|
||||
if chapter:
|
||||
chapter.content = narrative
|
||||
chapter.title = title
|
||||
chapter.is_new = True
|
||||
chapter.source_segments = list({*(chapter.source_segments or []), *source_ids})
|
||||
else:
|
||||
# 根据 stage 计算正确的排序索引
|
||||
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
|
||||
chapter = Chapter(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=narrative,
|
||||
order_index=calculated_order_index,
|
||||
status="completed",
|
||||
category=chapter_category,
|
||||
images=[],
|
||||
is_new=True,
|
||||
source_segments=source_ids,
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
# 写入 sections(拆段 + 每段配图占位),新建或覆盖该章下所有 sections
|
||||
chapter = _save_narrative_to_sections(
|
||||
db,
|
||||
chapter,
|
||||
narrative,
|
||||
title=title,
|
||||
category=chapter_category,
|
||||
order_index=calculated_order_index,
|
||||
source_segments=source_ids,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
initialize_chapter_images(chapter)
|
||||
if image_settings.enabled and chapter_has_images_to_generate(chapter.images):
|
||||
db.refresh(chapter)
|
||||
if image_settings.enabled and _chapter_has_any_section_images_to_generate(chapter):
|
||||
chapters_to_enqueue.add(chapter.id)
|
||||
|
||||
# 更新 Book
|
||||
@@ -628,17 +748,24 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
try:
|
||||
llm = llm_service.get_llm()
|
||||
|
||||
# 查找 active 章节(被清除的章节不继续更新,而是创建新的)
|
||||
stmt = select(Chapter).where(
|
||||
Chapter.user_id == user_id,
|
||||
Chapter.category == stage,
|
||||
Chapter.is_active == True,
|
||||
# 查找 active 章节并预加载 sections
|
||||
stmt = (
|
||||
select(Chapter)
|
||||
.where(
|
||||
Chapter.user_id == user_id,
|
||||
Chapter.category == stage,
|
||||
Chapter.is_active == True,
|
||||
)
|
||||
.options(joinedload(Chapter.sections))
|
||||
)
|
||||
result = db.execute(stmt)
|
||||
chapter = result.scalar_one_or_none()
|
||||
|
||||
existing_content = chapter.content if chapter else ""
|
||||
|
||||
chapter = result.unique().scalar_one_or_none()
|
||||
existing_content = ""
|
||||
if chapter and getattr(chapter, "sections", None):
|
||||
existing_content = "\n\n".join(
|
||||
s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip()
|
||||
)
|
||||
|
||||
if llm:
|
||||
prompt = get_narrative_prompt(
|
||||
stage=stage,
|
||||
@@ -666,27 +793,18 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
|
||||
# 入库前:占位符位置用正则匹配后拼上固定模板
|
||||
narrative = inject_image_placeholder_template(narrative)
|
||||
|
||||
if chapter:
|
||||
chapter.content = narrative
|
||||
chapter.is_new = True
|
||||
else:
|
||||
# 根据 stage 计算正确的排序索引
|
||||
calculated_order_index = STAGE_TO_ORDER.get(stage, 999)
|
||||
chapter = Chapter(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=f"{stage} 回忆",
|
||||
content=narrative,
|
||||
order_index=calculated_order_index,
|
||||
status="completed",
|
||||
category=stage,
|
||||
images=[],
|
||||
is_new=True,
|
||||
source_segments=[],
|
||||
)
|
||||
db.add(chapter)
|
||||
|
||||
calculated_order_index = STAGE_TO_ORDER.get(stage, 999)
|
||||
title = chapter.title if chapter else f"{stage} 回忆"
|
||||
chapter = _save_narrative_to_sections(
|
||||
db,
|
||||
chapter,
|
||||
narrative,
|
||||
title=title,
|
||||
category=stage,
|
||||
order_index=calculated_order_index,
|
||||
source_segments=[],
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -705,20 +823,33 @@ def build_cos_key(user_id: str, chapter_id: str, index: int, prompt: str) -> str
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def generate_chapter_images(self, chapter_id: str):
|
||||
"""Async task to generate images for a chapter's pending image assets."""
|
||||
"""Async task to generate images for a chapter's sections (each section has at most one image)."""
|
||||
db = SessionLocal()
|
||||
lock_acquired = False
|
||||
provider = None
|
||||
try:
|
||||
chapter = db.get(Chapter, chapter_id)
|
||||
if not chapter or not chapter.images:
|
||||
logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images")
|
||||
stmt = (
|
||||
select(Chapter)
|
||||
.where(Chapter.id == chapter_id)
|
||||
.options(
|
||||
joinedload(Chapter.sections).joinedload(ChapterSection.image_record),
|
||||
joinedload(Chapter.images),
|
||||
)
|
||||
)
|
||||
chapter = db.execute(stmt).unique().scalar_one_or_none()
|
||||
if not chapter:
|
||||
logger.info("章节补图跳过: chapter=%s, reason=not_found", chapter_id)
|
||||
return {"status": "no_chapter"}
|
||||
sections = getattr(chapter, "sections", None) or []
|
||||
sections_with_pending = [
|
||||
(idx, s) for idx, s in enumerate(sections) if _section_has_image_to_generate(s)
|
||||
]
|
||||
if not sections_with_pending:
|
||||
logger.info("章节补图跳过: chapter=%s, reason=no_pending_images", chapter_id)
|
||||
return {"status": "no_images"}
|
||||
|
||||
settings = MemoirImageSettings.from_env()
|
||||
if not settings.enabled:
|
||||
chapter.images = completed_image_assets(chapter.images)
|
||||
db.commit()
|
||||
logger.info("章节补图跳过: chapter=%s, reason=disabled", chapter_id)
|
||||
return {"status": "disabled"}
|
||||
|
||||
@@ -730,42 +861,45 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
prompt_service = MemoirImagePromptService(llm_service.get_llm(), settings)
|
||||
provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid)
|
||||
storage = TencentCosStorageService.from_env()
|
||||
images = normalize_image_assets(chapter.images)
|
||||
pending_count = sum(
|
||||
1 for item in images if item.get("status") in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}
|
||||
)
|
||||
logger.info(
|
||||
"章节补图开始: chapter=%s, total_images=%d, pending_images=%d",
|
||||
"章节补图开始: chapter=%s, pending_sections=%d",
|
||||
chapter_id,
|
||||
len(images),
|
||||
pending_count,
|
||||
len(sections_with_pending),
|
||||
)
|
||||
retryable_failures: list[str] = []
|
||||
permanent_failures: list[str] = []
|
||||
|
||||
for index, item in enumerate(images):
|
||||
if item.get("status") == IMAGE_STATUS_COMPLETED and (item.get("storage_key") or item.get("url")):
|
||||
continue
|
||||
if item.get("status") == IMAGE_STATUS_FAILED and item.get("retryable") is False:
|
||||
continue
|
||||
if item.get("status") not in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}:
|
||||
continue
|
||||
def _apply_item_to_memoir_image(rec: MemoirImage, d: dict):
|
||||
rec.placeholder = d.get("placeholder")
|
||||
rec.description = d.get("description")
|
||||
rec.status = (d.get("status") or "pending").strip() or "pending"
|
||||
rec.prompt = d.get("prompt")
|
||||
rec.url = d.get("url")
|
||||
rec.storage_key = d.get("storage_key")
|
||||
rec.provider = d.get("provider")
|
||||
rec.style = d.get("style")
|
||||
rec.size = d.get("size")
|
||||
rec.error = d.get("error")
|
||||
rec.retryable = d.get("retryable")
|
||||
rec.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
current_item = dict(item)
|
||||
for sec_index, section in sections_with_pending:
|
||||
item = memoir_image_to_dict(section.image_record) if section.image_record else {}
|
||||
current_item = dict(item) if item else {}
|
||||
current_item.setdefault("placeholder", "")
|
||||
current_item.setdefault("description", "")
|
||||
current_item["status"] = IMAGE_STATUS_PROCESSING
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
images[index] = current_item
|
||||
chapter.images = images
|
||||
_apply_item_to_memoir_image(section.image_record, current_item)
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
context_lines = (chapter.content or "").split("\n")
|
||||
context_excerpt = " ".join(context_lines[:5])[:200]
|
||||
|
||||
context_lines = (section.content or "").strip().split("\n")[:5]
|
||||
context_excerpt = " ".join(context_lines)[:200]
|
||||
prompt_data = prompt_service.build_prompt(
|
||||
chapter_title=chapter.title,
|
||||
chapter_category=chapter.category or "",
|
||||
description=item.get("description", ""),
|
||||
description=current_item.get("description", ""),
|
||||
context_excerpt=context_excerpt,
|
||||
)
|
||||
job = provider.submit_generation(
|
||||
@@ -780,7 +914,7 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
max_attempts=settings.max_attempts,
|
||||
)
|
||||
image_bytes = _normalize_image_bytes_for_storage(provider.download_image(job))
|
||||
key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"])
|
||||
key = build_cos_key(chapter.user_id, chapter.id, sec_index, prompt_data["prompt"])
|
||||
current_item["storage_key"] = key
|
||||
current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png")
|
||||
current_item["prompt"] = prompt_data["prompt"]
|
||||
@@ -790,15 +924,15 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
current_item["error"] = None
|
||||
current_item["retryable"] = None
|
||||
logger.info(
|
||||
"章节补图成功: chapter=%s, index=%s, url=%s",
|
||||
"章节补图成功: chapter=%s, section_index=%s, url=%s",
|
||||
chapter_id,
|
||||
current_item.get("index"),
|
||||
sec_index,
|
||||
current_item["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
current_item["status"] = IMAGE_STATUS_FAILED
|
||||
current_item["error"] = str(exc)
|
||||
failure_msg = f"index={current_item.get('index')}, error={exc}"
|
||||
failure_msg = f"section_index={sec_index}, error={exc}"
|
||||
if isinstance(exc, CosUploadError) and not exc.retryable:
|
||||
current_item["retryable"] = False
|
||||
permanent_failures.append(failure_msg)
|
||||
@@ -809,10 +943,10 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg)
|
||||
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
images[index] = current_item
|
||||
chapter.images = images
|
||||
_apply_item_to_memoir_image(section.image_record, current_item)
|
||||
db.commit()
|
||||
|
||||
# 封面图先空着,不自动用首张完成图做封面
|
||||
if retryable_failures:
|
||||
raise RuntimeError(
|
||||
f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}"
|
||||
@@ -821,7 +955,6 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
raise PermanentImageGenerationError(
|
||||
f"章节补图存在不可重试失败项: chapter={chapter_id}, failures={'; '.join(permanent_failures)}"
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
except PermanentImageGenerationError as exc:
|
||||
logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc)
|
||||
|
||||
Reference in New Issue
Block a user