重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复
本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括: 1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。 2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。 3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。 4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。 5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。 6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。 7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
304
api/app/tasks/chapter_cover_tasks.py
Normal file
304
api/app/tasks/chapter_cover_tasks.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Chapter 封面生成 Celery 任务。
|
||||
|
||||
从 chapter_cover_intents 原子 claim intent,或创建新 intent 后生成封面,
|
||||
写入 assets,绑定到 chapters.cover_asset_id。封面不回写进正文 markdown。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from celery import shared_task
|
||||
from PIL import Image
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.core.db import get_sync_db
|
||||
from app.core.dependencies import get_image_generator
|
||||
from app.core.redis_lock import acquire_redis_lock, release_redis_lock
|
||||
from app.features.asset.models import Asset
|
||||
from app.features.memoir.chapter_cover import (
|
||||
aggregate_cover_prompt_from_chapter,
|
||||
aggregate_cover_prompt_from_stories,
|
||||
)
|
||||
from app.features.memoir.memoir_images.storage import TencentCosStorageService
|
||||
from app.features.memoir.models import Chapter, ChapterCoverIntent, ChapterStoryLink
|
||||
from app.ports.image_gen import TaskStatus
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CHAPTER_COVER_LOCK_TTL_SECONDS = 1800
|
||||
CHAPTER_COVER_CLAIM_TTL_SECONDS = 1800
|
||||
|
||||
|
||||
def _build_cover_cos_key(user_id: str, chapter_id: str, prompt: str) -> str:
|
||||
short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10]
|
||||
return f"chapters/{user_id}/{chapter_id}/cover-{short_hash}.png"
|
||||
|
||||
|
||||
def _normalize_image_bytes(image_bytes: bytes) -> bytes:
|
||||
from io import BytesIO
|
||||
|
||||
with Image.open(BytesIO(image_bytes)) as image:
|
||||
output = BytesIO()
|
||||
if image.mode in {"RGBA", "LA"}:
|
||||
normalized = image
|
||||
elif image.mode == "P":
|
||||
normalized = image.convert("RGBA")
|
||||
else:
|
||||
normalized = image.convert("RGB")
|
||||
normalized.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _build_cover_prompt(prompt_brief: str) -> str:
|
||||
"""从 intent.prompt_brief 构建出图 prompt。"""
|
||||
from app.agents.memoir.prompts import IMAGE_PLACEHOLDER_TEMPLATE
|
||||
|
||||
base = IMAGE_PLACEHOLDER_TEMPLATE
|
||||
if prompt_brief and prompt_brief.strip():
|
||||
return f"{base}。{prompt_brief.strip()}"
|
||||
return f"{base}。章节封面"
|
||||
|
||||
|
||||
def _chapter_cover_claimable_clause(now: datetime):
|
||||
cutoff = now - timedelta(seconds=CHAPTER_COVER_CLAIM_TTL_SECONDS)
|
||||
return or_(
|
||||
ChapterCoverIntent.status.in_(["pending", "failed"]),
|
||||
and_(
|
||||
ChapterCoverIntent.status == "processing",
|
||||
or_(
|
||||
ChapterCoverIntent.claimed_at.is_(None),
|
||||
ChapterCoverIntent.claimed_at < cutoff,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_chapter_cover_brief(chapter: Chapter) -> str:
|
||||
prompt_brief = ""
|
||||
stories = []
|
||||
if getattr(chapter, "story_links", None):
|
||||
for link in sorted(
|
||||
chapter.story_links, key=lambda l: getattr(l, "order_index", 0)
|
||||
):
|
||||
story = getattr(link, "story", None)
|
||||
if story:
|
||||
stories.append(story)
|
||||
prompt_brief = aggregate_cover_prompt_from_stories(
|
||||
stories,
|
||||
chapter_title=chapter.title or "",
|
||||
chapter_category=chapter.category or "",
|
||||
)
|
||||
if prompt_brief:
|
||||
return prompt_brief
|
||||
md = (chapter.canonical_markdown or "").strip()
|
||||
excerpt = md[:200] if md else ""
|
||||
return aggregate_cover_prompt_from_chapter(
|
||||
chapter_title=chapter.title or "",
|
||||
chapter_category=chapter.category or "",
|
||||
markdown_excerpt=excerpt,
|
||||
)
|
||||
|
||||
|
||||
def _claim_chapter_cover_intent_sync(db, chapter: Chapter, claim_token: str):
|
||||
now = datetime.now(timezone.utc)
|
||||
claimable = _chapter_cover_claimable_clause(now)
|
||||
candidate_id = db.execute(
|
||||
select(ChapterCoverIntent.id)
|
||||
.where(ChapterCoverIntent.chapter_id == chapter.id)
|
||||
.where(claimable)
|
||||
.order_by(
|
||||
ChapterCoverIntent.updated_at.desc(), ChapterCoverIntent.created_at.desc()
|
||||
)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if candidate_id:
|
||||
claimed = db.execute(
|
||||
update(ChapterCoverIntent)
|
||||
.where(ChapterCoverIntent.id == candidate_id)
|
||||
.where(_chapter_cover_claimable_clause(now))
|
||||
.values(
|
||||
status="processing",
|
||||
claim_token=claim_token,
|
||||
claimed_at=now,
|
||||
updated_at=now,
|
||||
error=None,
|
||||
attempt_count=func.coalesce(ChapterCoverIntent.attempt_count, 0) + 1,
|
||||
)
|
||||
)
|
||||
if (claimed.rowcount or 0) != 1:
|
||||
db.rollback()
|
||||
return None
|
||||
intent = db.get(ChapterCoverIntent, candidate_id)
|
||||
db.commit()
|
||||
return intent
|
||||
|
||||
cutoff = now - timedelta(seconds=CHAPTER_COVER_CLAIM_TTL_SECONDS)
|
||||
fresh_processing = db.execute(
|
||||
select(ChapterCoverIntent.id)
|
||||
.where(ChapterCoverIntent.chapter_id == chapter.id)
|
||||
.where(ChapterCoverIntent.status == "processing")
|
||||
.where(ChapterCoverIntent.claimed_at.is_not(None))
|
||||
.where(ChapterCoverIntent.claimed_at >= cutoff)
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if fresh_processing:
|
||||
return None
|
||||
|
||||
intent = ChapterCoverIntent(
|
||||
id=str(uuid.uuid4()),
|
||||
chapter_id=chapter.id,
|
||||
prompt_brief=_build_chapter_cover_brief(chapter),
|
||||
status="processing",
|
||||
claim_token=claim_token,
|
||||
claimed_at=now,
|
||||
attempt_count=1,
|
||||
)
|
||||
db.add(intent)
|
||||
db.flush()
|
||||
db.commit()
|
||||
return intent
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def generate_chapter_cover(self, chapter_id: str):
|
||||
"""
|
||||
为 chapter 生成封面。
|
||||
从 chapter_cover_intents 原子认领 intent,或创建新 intent 后生成,
|
||||
写入 assets 并绑定到 chapters.cover_asset_id。
|
||||
"""
|
||||
lock_key = f"lock:chapter-images:{chapter_id}"
|
||||
lock_handle = acquire_redis_lock(
|
||||
lock_key, ttl_seconds=CHAPTER_COVER_LOCK_TTL_SECONDS
|
||||
)
|
||||
if lock_handle is None:
|
||||
logger.info("generate_chapter_cover: chapter=%s, reason=locked", chapter_id)
|
||||
return {"status": "locked"}
|
||||
|
||||
claim_token = uuid.uuid4().hex
|
||||
intent = None
|
||||
try:
|
||||
with get_sync_db() as db:
|
||||
stmt = (
|
||||
select(Chapter)
|
||||
.where(Chapter.id == chapter_id)
|
||||
.options(
|
||||
joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story),
|
||||
)
|
||||
)
|
||||
chapter = db.execute(stmt).unique().scalar_one_or_none()
|
||||
if not chapter:
|
||||
logger.info(
|
||||
"generate_chapter_cover: chapter=%s, reason=not_found", chapter_id
|
||||
)
|
||||
return {"status": "no_chapter"}
|
||||
|
||||
if getattr(chapter, "cover_asset_id", None):
|
||||
logger.info(
|
||||
"generate_chapter_cover: chapter=%s, reason=has_cover_asset",
|
||||
chapter_id,
|
||||
)
|
||||
return {"status": "already_has_asset"}
|
||||
|
||||
intent = _claim_chapter_cover_intent_sync(db, chapter, claim_token)
|
||||
if not intent:
|
||||
logger.info(
|
||||
"generate_chapter_cover: chapter=%s, reason=no_claimable_intent",
|
||||
chapter_id,
|
||||
)
|
||||
return {"status": "no_intent"}
|
||||
|
||||
try:
|
||||
image_generator = get_image_generator()
|
||||
storage = TencentCosStorageService.from_env()
|
||||
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
||||
|
||||
settings = MemoirImageSettings.from_env()
|
||||
prompt_final = _build_cover_prompt(intent.prompt_brief or "")
|
||||
|
||||
result = image_generator.generate(
|
||||
prompt_final,
|
||||
settings.default_size,
|
||||
settings.default_style,
|
||||
)
|
||||
if result.status != TaskStatus.COMPLETED or not result.image_url:
|
||||
raise RuntimeError(result.error or "Image generation failed")
|
||||
|
||||
image_bytes = _normalize_image_bytes(
|
||||
image_generator.download_image(result.image_url)
|
||||
)
|
||||
cos_key = _build_cover_cos_key(chapter.user_id, chapter_id, prompt_final)
|
||||
url = storage.upload_bytes(image_bytes, cos_key, "image/png")
|
||||
|
||||
asset_id = str(uuid.uuid4())
|
||||
with get_sync_db() as db:
|
||||
intent_db = db.get(ChapterCoverIntent, intent.id)
|
||||
if (
|
||||
not intent_db
|
||||
or (intent_db.status or "").strip() != "processing"
|
||||
or (intent_db.claim_token or "").strip() != claim_token
|
||||
):
|
||||
logger.info(
|
||||
"generate_chapter_cover: skip persist intent=%s status=%s claim=%s",
|
||||
intent.id,
|
||||
getattr(intent_db, "status", None),
|
||||
getattr(intent_db, "claim_token", None),
|
||||
)
|
||||
return {"status": "superseded_or_cancelled"}
|
||||
|
||||
asset = Asset(
|
||||
id=asset_id,
|
||||
asset_type="chapter_cover",
|
||||
storage_key=cos_key,
|
||||
url=url,
|
||||
provider=settings.provider,
|
||||
style_profile=settings.default_style,
|
||||
prompt_final=prompt_final,
|
||||
status="completed",
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
|
||||
intent_db.asset_id = asset_id
|
||||
intent_db.status = "completed"
|
||||
intent_db.claim_token = None
|
||||
intent_db.claimed_at = None
|
||||
intent_db.error = None
|
||||
intent_db.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
chapter_db = db.get(Chapter, chapter_id)
|
||||
chapter_db.cover_asset_id = asset_id
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"generate_chapter_cover: chapter=%s, asset=%s, url=%s",
|
||||
chapter_id,
|
||||
asset_id,
|
||||
url,
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
except Exception as exc:
|
||||
if intent is not None:
|
||||
with get_sync_db() as db:
|
||||
intent_db = db.get(ChapterCoverIntent, intent.id)
|
||||
if (
|
||||
intent_db
|
||||
and (intent_db.claim_token or "").strip() == claim_token
|
||||
):
|
||||
intent_db.status = "failed"
|
||||
intent_db.claim_token = None
|
||||
intent_db.claimed_at = None
|
||||
intent_db.error = str(exc)
|
||||
intent_db.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"generate_chapter_cover failed: chapter=%s, error=%s", chapter_id, exc
|
||||
)
|
||||
raise self.retry(exc=exc)
|
||||
finally:
|
||||
release_redis_lock(lock_handle)
|
||||
Reference in New Issue
Block a user