refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减
- 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
This commit is contained in:
@@ -4,13 +4,12 @@ Celery 任务模块
|
||||
|
||||
from .celery_app import celery_app
|
||||
from .chapter_cover_tasks import generate_chapter_cover
|
||||
from .memoir_tasks import process_memoir_segments, generate_chapter_images
|
||||
from .memoir_tasks import process_memoir_segments
|
||||
from .story_image_tasks import generate_story_image
|
||||
|
||||
__all__ = [
|
||||
"celery_app",
|
||||
"process_memoir_segments",
|
||||
"generate_chapter_images",
|
||||
"generate_chapter_cover",
|
||||
"generate_story_image",
|
||||
]
|
||||
|
||||
@@ -13,13 +13,14 @@ from sqlalchemy.orm import joinedload
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_sync_db
|
||||
from app.core.logging import get_logger
|
||||
from app.features.memoir.asset_resolver import strip_legacy_image_placeholders
|
||||
from app.features.memoir.asset_resolver import strip_image_placeholders
|
||||
from app.features.memoir.cover_eligibility import (
|
||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||
chapter_has_story_links,
|
||||
chapter_needs_cover_enqueue,
|
||||
primary_chapter_memoir_image,
|
||||
)
|
||||
from app.features.memoir.models import Chapter
|
||||
from app.features.memoir.models import Chapter, ChapterStoryLink
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -35,7 +36,9 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
|
||||
"""与 MemoirService.check_and_trigger_cover_generation 循环条件一致。"""
|
||||
if not chapter:
|
||||
return False
|
||||
if not chapter.category or chapter.status == "empty":
|
||||
if not chapter.category:
|
||||
return False
|
||||
if not chapter_has_story_links(chapter):
|
||||
return False
|
||||
if getattr(chapter, "cover_asset_id", None):
|
||||
return False
|
||||
@@ -43,7 +46,7 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
|
||||
body = md or ""
|
||||
if not body.strip():
|
||||
return False
|
||||
body = strip_legacy_image_placeholders(body).strip()
|
||||
body = strip_image_placeholders(body).strip()
|
||||
if not body:
|
||||
return False
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(chapter):
|
||||
@@ -66,6 +69,7 @@ def _load_chapter_for_enqueue_sync(chapter_id: str) -> Chapter | None:
|
||||
.where(Chapter.id == chapter_id)
|
||||
.options(
|
||||
joinedload(Chapter.images),
|
||||
joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story),
|
||||
)
|
||||
)
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
@@ -16,21 +16,20 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.core.db import get_sync_db
|
||||
from app.core.dependencies import get_image_generator
|
||||
from app.core.logging import get_logger
|
||||
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.cover_eligibility import (
|
||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||
chapter_has_story_links,
|
||||
)
|
||||
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
|
||||
@@ -82,28 +81,17 @@ def _chapter_cover_claimable_clause(now: datetime):
|
||||
|
||||
|
||||
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(
|
||||
for link in sorted(
|
||||
chapter.story_links, key=lambda link_row: link_row.order_index or 0
|
||||
):
|
||||
story = getattr(link, "story", None)
|
||||
if story:
|
||||
stories.append(story)
|
||||
return aggregate_cover_prompt_from_stories(
|
||||
stories,
|
||||
chapter_title=chapter.title or "",
|
||||
chapter_category=chapter.category or "",
|
||||
markdown_excerpt=excerpt,
|
||||
)
|
||||
|
||||
|
||||
@@ -179,7 +167,7 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
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)
|
||||
logger.debug("generate_chapter_cover: chapter=%s, reason=locked", chapter_id)
|
||||
return {"status": "locked"}
|
||||
|
||||
claim_token = uuid.uuid4().hex
|
||||
@@ -195,20 +183,26 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
)
|
||||
chapter = db.execute(stmt).unique().scalar_one_or_none()
|
||||
if not chapter:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s, reason=not_found", chapter_id
|
||||
)
|
||||
return {"status": "no_chapter"}
|
||||
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(chapter):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s, reason=insufficient_inline_body_images",
|
||||
chapter_id,
|
||||
)
|
||||
return {"status": "insufficient_inline_body_images"}
|
||||
if not chapter_has_story_links(chapter):
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s, reason=no_story_links",
|
||||
chapter_id,
|
||||
)
|
||||
return {"status": "no_story_links"}
|
||||
|
||||
if getattr(chapter, "cover_asset_id", None):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s, reason=has_cover_asset",
|
||||
chapter_id,
|
||||
)
|
||||
@@ -216,7 +210,7 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
|
||||
intent = _claim_chapter_cover_intent_sync(db, chapter, claim_token)
|
||||
if not intent:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s, reason=no_claimable_intent",
|
||||
chapter_id,
|
||||
)
|
||||
@@ -252,7 +246,7 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
or (intent_db.status or "").strip() != "processing"
|
||||
or (intent_db.claim_token or "").strip() != claim_token
|
||||
):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_chapter_cover: skip persist intent=%s status=%s claim=%s",
|
||||
intent.id,
|
||||
getattr(intent_db, "status", None),
|
||||
@@ -286,10 +280,17 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"generate_chapter_cover: chapter=%s, asset=%s, url=%s",
|
||||
"generate_chapter_cover: chapter=%s, asset=%s",
|
||||
chapter_id,
|
||||
asset_id,
|
||||
)
|
||||
logger.debug(
|
||||
"generate_chapter_cover: chapter=%s asset=%s url=%s cos_key=%s prompt_final=%s",
|
||||
chapter_id,
|
||||
asset_id,
|
||||
url,
|
||||
cos_key,
|
||||
prompt_final,
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
except Exception as exc:
|
||||
@@ -309,6 +310,6 @@ def generate_chapter_cover(self, chapter_id: str):
|
||||
logger.warning(
|
||||
"generate_chapter_cover failed: chapter=%s, error=%s", chapter_id, exc
|
||||
)
|
||||
raise self.retry(exc=exc)
|
||||
raise self.retry(exc=exc) from exc
|
||||
finally:
|
||||
release_redis_lock(lock_handle)
|
||||
|
||||
@@ -3,64 +3,47 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.core.logging import get_logger
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Set
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Set
|
||||
|
||||
import redis
|
||||
from celery import shared_task
|
||||
from PIL import Image
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_sync_db
|
||||
from app.features.conversation.models import Segment
|
||||
from app.features.memoir.models import (
|
||||
Book,
|
||||
Chapter,
|
||||
MemoirImage,
|
||||
MemoirState,
|
||||
)
|
||||
from app.features.user.models import User
|
||||
from app.core.dependencies import get_llm_provider
|
||||
from app.agents.state_schema import MemoirStateSchema, SlotData, default_state
|
||||
from app.agents.memoir import MemoirOrchestrator
|
||||
from app.agents.chat.prompts_profile import format_user_profile_context
|
||||
from app.agents.memoir import MemoirOrchestrator
|
||||
from app.agents.state_schema import MemoirStateSchema, SlotData, default_state
|
||||
from app.core.db import get_sync_db
|
||||
from app.core.dependencies import get_llm_provider
|
||||
from app.core.logging import get_logger
|
||||
from app.features.conversation.models import Segment
|
||||
from app.features.memoir.cover_eligibility import (
|
||||
chapter_needs_cover_enqueue,
|
||||
)
|
||||
from app.features.memoir.memoir_images.parser import (
|
||||
build_initial_image_assets,
|
||||
parse_image_placeholders,
|
||||
)
|
||||
import hashlib
|
||||
from app.core.dependencies import get_image_generator
|
||||
from app.agents.image_prompt import ImagePromptOrchestrator
|
||||
from app.features.memoir.memoir_images.schema import (
|
||||
completed_image_assets,
|
||||
IMAGE_STATUS_COMPLETED,
|
||||
IMAGE_STATUS_FAILED,
|
||||
IMAGE_STATUS_PENDING,
|
||||
IMAGE_STATUS_PROCESSING,
|
||||
normalize_image_assets,
|
||||
)
|
||||
from app.features.memoir.memoir_images.serializers import (
|
||||
image_dict_to_row_kwargs,
|
||||
memoir_image_to_dict,
|
||||
)
|
||||
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
||||
from app.ports.image_gen import TaskStatus
|
||||
from app.features.memoir.memoir_images.storage import (
|
||||
TencentCosStorageService,
|
||||
CosUploadError,
|
||||
)
|
||||
from app.features.memoir.cover_eligibility import (
|
||||
chapter_needs_cover_enqueue,
|
||||
cover_memoir_image_pending_or_failed,
|
||||
from app.features.memoir.models import (
|
||||
Book,
|
||||
MemoirImage,
|
||||
MemoirState,
|
||||
)
|
||||
from app.features.memoir.story_pipeline_sync import (
|
||||
run_story_pipeline_for_category_batch,
|
||||
)
|
||||
from app.features.user.models import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_REDIS_CLIENTS: dict[bool, redis.Redis] = {}
|
||||
@@ -101,20 +84,6 @@ def _release_chapter_lock(user_id: str, stage: str):
|
||||
r.delete(lock_key)
|
||||
|
||||
|
||||
def _acquire_chapter_image_lock(chapter_id: str, timeout: int = 600) -> bool:
|
||||
"""获取章节补图分布式锁,避免同一章节重复补图。"""
|
||||
r = _get_redis_client()
|
||||
lock_key = f"lock:chapter-images:{chapter_id}"
|
||||
return r.set(lock_key, "1", nx=True, ex=timeout)
|
||||
|
||||
|
||||
def _release_chapter_image_lock(chapter_id: str):
|
||||
"""释放章节补图分布式锁。"""
|
||||
r = _get_redis_client()
|
||||
lock_key = f"lock:chapter-images:{chapter_id}"
|
||||
r.delete(lock_key)
|
||||
|
||||
|
||||
def _update_task_status_sync(
|
||||
user_id: str, task_id: str, status: str, result: Dict = None
|
||||
):
|
||||
@@ -139,7 +108,7 @@ def _update_task_status_sync(
|
||||
r.hset(key, task_id, json.dumps(task_info))
|
||||
r.expire(key, 3600) # 1小时过期
|
||||
|
||||
logger.info(f"任务状态已更新: task_id={task_id}, status={status}")
|
||||
logger.debug("任务状态已更新: task_id=%s status=%s", task_id, status)
|
||||
except Exception as e:
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
|
||||
@@ -240,25 +209,6 @@ def _select_placeholders_for_effective_max(
|
||||
return [{**item, "index": index} for index, item in enumerate(selected)]
|
||||
|
||||
|
||||
def initialize_chapter_images(_chapter):
|
||||
"""兼容旧调用:封面由 generate_chapter_cover 处理。"""
|
||||
logger.info("initialize_chapter_images: 封面由 generate_chapter_cover 处理,跳过")
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes:
|
||||
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 _coerce_state(model: MemoirState) -> MemoirStateSchema:
|
||||
"""将数据库模型转换为 Schema"""
|
||||
return MemoirStateSchema.model_validate(
|
||||
@@ -372,8 +322,6 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
except Exception as e:
|
||||
logger.warning("Memory ingest 跳过: %s", e)
|
||||
|
||||
# 获取用户状态和资料
|
||||
state = _get_or_create_state_sync(user_id, db)
|
||||
llm = _get_llm()
|
||||
image_settings = MemoirImageSettings.from_env()
|
||||
|
||||
@@ -391,75 +339,71 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
|
||||
story_dispatch_ids: Set[str] = set()
|
||||
|
||||
def _process_category(
|
||||
chapter_category: str,
|
||||
category_segments: List,
|
||||
state: MemoirStateSchema,
|
||||
profile: str,
|
||||
birth_year,
|
||||
llm,
|
||||
):
|
||||
"""stories-first:路由 + 写 story,物化 chapter。"""
|
||||
nonlocal story_dispatch_ids
|
||||
chapter, needs_cover, disp = run_story_pipeline_for_category_batch(
|
||||
db,
|
||||
user_id=user_id,
|
||||
chapter_category=chapter_category,
|
||||
category_segments=category_segments,
|
||||
state=state,
|
||||
user_profile=profile,
|
||||
user_birth_year=birth_year,
|
||||
llm=llm,
|
||||
)
|
||||
story_dispatch_ids |= disp
|
||||
db.flush()
|
||||
db.refresh(chapter)
|
||||
|
||||
needs_cover_enqueue = (
|
||||
image_settings.enabled and chapter_needs_cover_enqueue(chapter)
|
||||
)
|
||||
|
||||
stmt_book = (
|
||||
select(Book)
|
||||
.where(Book.user_id == user_id)
|
||||
.order_by(Book.updated_at.desc())
|
||||
)
|
||||
result_book = db.execute(stmt_book)
|
||||
book = result_book.scalar_one_or_none()
|
||||
if not book:
|
||||
book = Book(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title="我的回忆录",
|
||||
total_pages=0,
|
||||
total_words=0,
|
||||
cover_image_url=None,
|
||||
)
|
||||
db.add(book)
|
||||
book.has_update = True
|
||||
book.last_update_chapter_id = chapter.id
|
||||
|
||||
return chapter, needs_cover_enqueue
|
||||
|
||||
def _raise_retry():
|
||||
raise self.retry(countdown=10)
|
||||
|
||||
memoir_orchestrator = MemoirOrchestrator()
|
||||
chapters_to_enqueue, _ = memoir_orchestrator.run(
|
||||
segments=segments,
|
||||
prepared = memoir_orchestrator.prepare_batches(
|
||||
segments=list(segments),
|
||||
llm=llm,
|
||||
user_profile=user_profile,
|
||||
user_birth_year=user_birth_year,
|
||||
get_or_create_state=lambda: _get_or_create_state_sync(user_id, db),
|
||||
update_slot=lambda stage, slot_name, snippet, seg_ids: (
|
||||
_update_slot_sync(user_id, stage, slot_name, snippet, seg_ids, db)
|
||||
),
|
||||
acquire_lock=lambda stage: _acquire_chapter_lock(user_id, stage),
|
||||
release_lock=lambda stage: _release_chapter_lock(user_id, stage),
|
||||
process_category=_process_category,
|
||||
raise_retry=_raise_retry,
|
||||
)
|
||||
|
||||
chapters_to_enqueue: Set[str] = set()
|
||||
for (
|
||||
chapter_category,
|
||||
category_segments,
|
||||
) in prepared.category_to_segments.items():
|
||||
if not _acquire_chapter_lock(user_id, chapter_category):
|
||||
logger.warning(
|
||||
"章节锁竞争: category=%s, 延迟重试",
|
||||
chapter_category,
|
||||
)
|
||||
raise self.retry(countdown=10)
|
||||
try:
|
||||
chapter, needs_cover, disp = run_story_pipeline_for_category_batch(
|
||||
db,
|
||||
user_id=user_id,
|
||||
chapter_category=chapter_category,
|
||||
category_segments=category_segments,
|
||||
state=prepared.state,
|
||||
user_profile=user_profile,
|
||||
user_birth_year=user_birth_year,
|
||||
llm=llm,
|
||||
)
|
||||
story_dispatch_ids |= disp
|
||||
db.flush()
|
||||
db.refresh(chapter)
|
||||
|
||||
needs_cover_enqueue = (
|
||||
image_settings.enabled and chapter_needs_cover_enqueue(chapter)
|
||||
)
|
||||
|
||||
stmt_book = (
|
||||
select(Book)
|
||||
.where(Book.user_id == user_id)
|
||||
.order_by(Book.updated_at.desc())
|
||||
)
|
||||
result_book = db.execute(stmt_book)
|
||||
book = result_book.scalar_one_or_none()
|
||||
if not book:
|
||||
book = Book(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title="我的回忆录",
|
||||
total_pages=0,
|
||||
total_words=0,
|
||||
cover_image_url=None,
|
||||
)
|
||||
db.add(book)
|
||||
book.has_update = True
|
||||
book.last_update_chapter_id = chapter.id
|
||||
|
||||
if chapter and needs_cover_enqueue:
|
||||
chapters_to_enqueue.add(chapter.id)
|
||||
finally:
|
||||
_release_chapter_lock(user_id, chapter_category)
|
||||
|
||||
# 标记段落为已处理
|
||||
for seg in segments:
|
||||
seg.processed = True
|
||||
@@ -503,7 +447,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
_update_task_status_sync(user_id, task_id, "failure", {"error": str(e)})
|
||||
|
||||
# 重试
|
||||
raise self.retry(exc=e)
|
||||
raise self.retry(exc=e) from e
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
@@ -580,161 +524,4 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"章节生成失败: {e}")
|
||||
raise self.retry(exc=e)
|
||||
|
||||
|
||||
def build_cos_key(user_id: str, chapter_id: str, index: int | str, prompt: str) -> str:
|
||||
short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10]
|
||||
index_part = "cover" if index in (-1, "cover") else str(index)
|
||||
return f"memoirs/{user_id}/{chapter_id}/{index_part}-{short_hash}.png"
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def generate_chapter_images(self, chapter_id: str):
|
||||
"""异步补图:仅处理章节级 MemoirImage(pending/failed)。正文配图走 story_image_tasks。"""
|
||||
lock_acquired = False
|
||||
provider = None
|
||||
with get_sync_db() as db:
|
||||
try:
|
||||
stmt = (
|
||||
select(Chapter)
|
||||
.where(Chapter.id == chapter_id)
|
||||
.options(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"}
|
||||
cover_to_generate = cover_memoir_image_pending_or_failed(chapter)
|
||||
if not cover_to_generate:
|
||||
logger.info(
|
||||
"章节补图跳过: chapter=%s, reason=no_pending_cover", chapter_id
|
||||
)
|
||||
return {"status": "no_images"}
|
||||
|
||||
settings = MemoirImageSettings.from_env()
|
||||
if not settings.enabled:
|
||||
logger.info("章节补图跳过: chapter=%s, reason=disabled", chapter_id)
|
||||
return {"status": "disabled"}
|
||||
|
||||
lock_acquired = _acquire_chapter_image_lock(chapter_id)
|
||||
if not lock_acquired:
|
||||
logger.info("章节补图跳过: chapter=%s, reason=locked", chapter_id)
|
||||
return {"status": "locked"}
|
||||
|
||||
prompt_orchestrator = ImagePromptOrchestrator(_get_llm(), settings)
|
||||
image_generator = get_image_generator()
|
||||
storage = TencentCosStorageService.from_env()
|
||||
logger.info(
|
||||
"章节封面补图开始: chapter=%s, cover=%s",
|
||||
chapter_id,
|
||||
bool(cover_to_generate),
|
||||
)
|
||||
retryable_failures: list[str] = []
|
||||
permanent_failures: list[str] = []
|
||||
|
||||
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)
|
||||
|
||||
# 封面图(正文来自 canonical_markdown)
|
||||
if cover_to_generate:
|
||||
current_item = memoir_image_to_dict(cover_to_generate) or {}
|
||||
current_item.setdefault("placeholder", "")
|
||||
current_item.setdefault("description", "")
|
||||
current_item["status"] = IMAGE_STATUS_PROCESSING
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
try:
|
||||
raw_md = (
|
||||
getattr(chapter, "canonical_markdown", None) or ""
|
||||
).strip()
|
||||
context_excerpt = " ".join(raw_md.split("\n")[:5])[:200]
|
||||
prompt_data = prompt_orchestrator.build_cover_prompt(
|
||||
chapter_title=chapter.title,
|
||||
chapter_category=chapter.category or "",
|
||||
context_excerpt=context_excerpt,
|
||||
)
|
||||
result = image_generator.generate(
|
||||
prompt_data["prompt"],
|
||||
prompt_data["size"],
|
||||
prompt_data["style"],
|
||||
)
|
||||
if result.status != TaskStatus.COMPLETED or not result.image_url:
|
||||
raise RuntimeError(result.error or "Image generation failed")
|
||||
image_bytes = _normalize_image_bytes_for_storage(
|
||||
image_generator.download_image(result.image_url)
|
||||
)
|
||||
key = build_cos_key(
|
||||
chapter.user_id, chapter.id, "cover", 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"]
|
||||
current_item["style"] = prompt_data["style"]
|
||||
current_item["size"] = prompt_data["size"]
|
||||
current_item["status"] = IMAGE_STATUS_COMPLETED
|
||||
current_item["error"] = None
|
||||
current_item["retryable"] = None
|
||||
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"章节封面图生成成功: chapter=%s, url=%s",
|
||||
chapter_id,
|
||||
current_item["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
failure_msg = f"cover, error={exc}"
|
||||
if isinstance(exc, CosUploadError) and not exc.retryable:
|
||||
permanent_failures.append(failure_msg)
|
||||
logger.error(
|
||||
"封面图上传不可重试,清理: chapter=%s, %s",
|
||||
chapter_id,
|
||||
failure_msg,
|
||||
)
|
||||
db.delete(cover_to_generate)
|
||||
db.commit()
|
||||
else:
|
||||
current_item = memoir_image_to_dict(cover_to_generate) or {}
|
||||
current_item["status"] = IMAGE_STATUS_FAILED
|
||||
current_item["error"] = str(exc)
|
||||
current_item["retryable"] = True
|
||||
current_item["updated_at"] = datetime.now(
|
||||
timezone.utc
|
||||
).isoformat()
|
||||
retryable_failures.append(failure_msg)
|
||||
logger.warning(
|
||||
"封面图生成失败(可重试): chapter=%s, %s",
|
||||
chapter_id,
|
||||
failure_msg,
|
||||
)
|
||||
_apply_item_to_memoir_image(cover_to_generate, current_item)
|
||||
db.commit()
|
||||
|
||||
if retryable_failures:
|
||||
raise RuntimeError(
|
||||
f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}"
|
||||
)
|
||||
return {"status": "success"}
|
||||
except Exception as exc:
|
||||
logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc)
|
||||
raise self.retry(exc=exc)
|
||||
finally:
|
||||
if provider:
|
||||
provider.close()
|
||||
if lock_acquired:
|
||||
_release_chapter_image_lock(chapter_id)
|
||||
raise self.retry(exc=e) from e
|
||||
|
||||
@@ -160,7 +160,7 @@ def generate_story_image(self, story_id: str):
|
||||
lock_key = f"lock:story-image:{story_id}"
|
||||
lock_handle = acquire_redis_lock(lock_key, ttl_seconds=STORY_IMAGE_LOCK_TTL_SECONDS)
|
||||
if lock_handle is None:
|
||||
logger.info("generate_story_image: story=%s, reason=locked", story_id)
|
||||
logger.debug("generate_story_image: story=%s, reason=locked", story_id)
|
||||
return {"status": "locked"}
|
||||
|
||||
claim_token = uuid.uuid4().hex
|
||||
@@ -170,7 +170,7 @@ def generate_story_image(self, story_id: str):
|
||||
with get_sync_db() as db:
|
||||
row = _claim_story_image_intent_sync(db, story_id, claim_token)
|
||||
if not row:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_story_image: story=%s, reason=no_claimable_intent",
|
||||
story_id,
|
||||
)
|
||||
@@ -213,7 +213,7 @@ def generate_story_image(self, story_id: str):
|
||||
or (intent_db.status or "").strip() != "processing"
|
||||
or (intent_db.claim_token or "").strip() != claim_token
|
||||
):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_story_image: skip persist intent=%s status=%s claim=%s",
|
||||
intent.id,
|
||||
getattr(intent_db, "status", None),
|
||||
@@ -249,12 +249,14 @@ def generate_story_image(self, story_id: str):
|
||||
# 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上
|
||||
if not target_vid or target_vid != current_vid:
|
||||
db.commit()
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"generate_story_image: stale intent skip backfill story=%s "
|
||||
"intent_ver=%s current=%s",
|
||||
"intent_ver=%s current=%s url=%s asset=%s",
|
||||
story_id,
|
||||
target_vid,
|
||||
current_vid,
|
||||
url,
|
||||
asset_id,
|
||||
)
|
||||
return {"status": "success_stale", "asset_id": asset_id}
|
||||
|
||||
@@ -297,10 +299,17 @@ def generate_story_image(self, story_id: str):
|
||||
_enqueue_chapter_recompose_for_story(story_id)
|
||||
|
||||
logger.info(
|
||||
"generate_story_image: story=%s, asset=%s, url=%s",
|
||||
"generate_story_image: story=%s, asset=%s",
|
||||
story_id,
|
||||
asset_id,
|
||||
)
|
||||
logger.debug(
|
||||
"generate_story_image: story=%s asset=%s url=%s cos_key=%s prompt_final=%s",
|
||||
story_id,
|
||||
asset_id,
|
||||
url,
|
||||
cos_key,
|
||||
prompt_final,
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
except Exception as exc:
|
||||
|
||||
Reference in New Issue
Block a user