Files
life-echo/api/app/tasks/story_image_tasks.py

298 lines
10 KiB
Python
Raw Normal View History

重构回忆录为 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 映射等回归测试,并加入图片占位符退役设计文档。
2026-03-20 10:30:07 +08:00
"""
Story 主插图生成 Celery 任务
story_image_intents 原子 claim intent生成图片写入 assets更新 intent
不读取正文占位符
"""
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 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.memoir_images.storage import TencentCosStorageService
from app.features.story.backfill import backfill_image_into_markdown
from app.features.story.models import Story, StoryImageIntent, StoryVersion
from app.ports.image_gen import TaskStatus
from app.core.logging import get_logger
logger = get_logger(__name__)
STORY_IMAGE_LOCK_TTL_SECONDS = 1800
STORY_IMAGE_CLAIM_TTL_SECONDS = 1800
def _build_story_image_cos_key(
user_id: str, story_id: str, intent_id: str, prompt: str
) -> str:
short_hash = hashlib.sha1(prompt.encode("utf-8")).hexdigest()[:10]
return f"stories/{user_id}/{story_id}/{intent_id}-{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_story_image_prompt(
prompt_brief: str,
story_title: str = "",
story_stage: str | None = None,
style_profile: str | None = None,
) -> 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()}"
fallback = "".join(filter(None, [story_title, story_stage or ""])) or "人生故事"
return f"{base}{fallback}"
def _story_image_claimable_clause(now: datetime):
cutoff = now - timedelta(seconds=STORY_IMAGE_CLAIM_TTL_SECONDS)
return or_(
StoryImageIntent.status.in_(["pending", "failed"]),
and_(
StoryImageIntent.status == "processing",
or_(
StoryImageIntent.claimed_at.is_(None),
StoryImageIntent.claimed_at < cutoff,
),
),
)
def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
now = datetime.now(timezone.utc)
claimable = _story_image_claimable_clause(now)
candidate_id = db.execute(
select(StoryImageIntent.id)
.where(StoryImageIntent.story_id == story_id)
.where(StoryImageIntent.intent_role == "primary")
.where(claimable)
.order_by(
StoryImageIntent.updated_at.desc(), StoryImageIntent.created_at.desc()
)
.limit(1)
).scalar_one_or_none()
if not candidate_id:
return None
claimed = db.execute(
update(StoryImageIntent)
.where(StoryImageIntent.id == candidate_id)
.where(_story_image_claimable_clause(now))
.values(
status="processing",
claim_token=claim_token,
claimed_at=now,
updated_at=now,
error=None,
attempt_count=func.coalesce(StoryImageIntent.attempt_count, 0) + 1,
)
)
if (claimed.rowcount or 0) != 1:
db.rollback()
return None
row = (
db.execute(
select(StoryImageIntent, Story)
.join(Story, StoryImageIntent.story_id == Story.id)
.where(StoryImageIntent.id == candidate_id)
)
.unique()
.first()
)
db.commit()
return row
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def generate_story_image(self, story_id: str):
"""
story 生成主插图
story_image_intents 原子认领 primary intent生成后写入 assets 并更新 intent
"""
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)
return {"status": "locked"}
claim_token = uuid.uuid4().hex
intent = None
story = None
try:
with get_sync_db() as db:
row = _claim_story_image_intent_sync(db, story_id, claim_token)
if not row:
logger.info(
"generate_story_image: story=%s, reason=no_claimable_intent",
story_id,
)
return {"status": "no_intent"}
intent, story = row
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_story_image_prompt(
intent.prompt_brief or "",
story_title=story.title or "",
story_stage=story.stage,
style_profile=intent.style_profile or settings.default_style,
)
result = image_generator.generate(
prompt_final,
settings.default_size,
intent.style_profile or 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_story_image_cos_key(
story.user_id, story_id, intent.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(StoryImageIntent, 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_story_image: 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="story_image",
storage_key=cos_key,
url=url,
provider=settings.provider,
style_profile=intent.style_profile or settings.default_style,
prompt_final=prompt_final,
status="completed",
)
db.add(asset)
db.flush()
story_db = db.get(Story, story_id)
target_vid = intent_db.story_version_id or story_db.current_version_id
current_vid = story_db.current_version_id
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)
db.flush()
# 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上
if not target_vid or target_vid != current_vid:
db.commit()
logger.info(
"generate_story_image: stale intent skip backfill story=%s "
"intent_ver=%s current=%s",
story_id,
target_vid,
current_vid,
)
return {"status": "success_stale", "asset_id": asset_id}
ver = db.get(StoryVersion, target_vid)
if not ver:
db.commit()
return {"status": "success_no_snapshot", "asset_id": asset_id}
base_md = ver.markdown_snapshot or ""
backfilled_md = backfill_image_into_markdown(
base_md,
asset_id=asset_id,
caption=intent_db.caption or "主插图",
source_span=intent_db.source_span,
)
max_stmt = select(func.max(StoryVersion.version_no)).where(
StoryVersion.story_id == story_id
)
max_no = db.execute(max_stmt).scalar()
version_no = (max_no or 0) + 1
new_ver = StoryVersion(
id=str(uuid.uuid4()),
story_id=story_id,
version_no=version_no,
markdown_snapshot=backfilled_md,
change_summary="主插图回填",
actor_type="system",
source_type="image_backfill",
parent_version_id=story_db.current_version_id,
)
db.add(new_ver)
db.flush()
story_db.current_version_id = new_ver.id
story_db.canonical_markdown = backfilled_md
db.commit()
logger.info(
"generate_story_image: story=%s, asset=%s, url=%s",
story_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(StoryImageIntent, intent.id)
if (
intent_db
and (intent_db.status or "").strip() != "completed"
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_story_image failed: story=%s, error=%s", story_id, exc)
raise self.retry(exc=exc)
finally:
release_redis_lock(lock_handle)