重构回忆录为 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:
Kevin
2026-03-20 10:30:07 +08:00
parent 13e3124b85
commit 7f57f96c25
67 changed files with 4751 additions and 832 deletions

View File

@@ -1,6 +1,5 @@
"""Memoir service — 回忆录编排(章节生成、状态流转);通过 MemoryService 获取 evidence。"""
import uuid
from datetime import datetime, timezone
from typing import List, Optional
@@ -16,14 +15,18 @@ from app.agents.memoir.prompts import (
STAGE_TO_ORDER,
)
from app.features.memoir import repo
from app.features.memoir.asset_resolver import (
collect_asset_ids_for_chapter,
collect_asset_ids_for_chapters,
strip_legacy_image_placeholders,
)
from app.features.memoir.asset_urls import signed_urls_for_asset_ids
from app.features.memoir.helpers import (
chapter_to_dict,
chapter_to_list_dict,
is_image_permanently_unavailable,
)
from app.features.memoir.models import Book, Chapter, ChapterSection, MemoirImage
from app.features.memoir.memoir_images.parser import build_initial_image_assets
from app.features.memoir.memoir_images.serializers import image_dict_to_row_kwargs
from app.features.memoir.memoir_images.prompting import MemoirImagePromptService
from app.features.memoir.models import Book, Chapter, ChapterSection
from app.features.memoir.memoir_images.settings import MemoirImageSettings
from app.features.memory.service import MemoryService
@@ -121,12 +124,19 @@ class MemoirService:
stmt = (
select(Chapter)
.where(Chapter.user_id == user_id, Chapter.is_active == True)
.options(joinedload(Chapter.sections))
.options(
joinedload(Chapter.sections).joinedload(ChapterSection.image_record),
joinedload(Chapter.images),
)
.order_by(Chapter.order_index)
)
result = await self._db.execute(stmt)
chapters = list(result.unique().scalars().all())
pdf_bytes = await pdf_service.generate_pdf(book, chapters)
asset_ids = collect_asset_ids_for_chapters(chapters)
asset_map = await signed_urls_for_asset_ids(self._db, asset_ids)
pdf_bytes = await pdf_service.generate_pdf(
book, chapters, asset_url_map=asset_map
)
return {
"pdf_base64": pdf_bytes.decode("latin1"),
"filename": f"{book.title}.pdf",
@@ -138,6 +148,10 @@ class MemoirService:
chapters = await repo.get_chapters_with_sections(
user_id, self._db, is_new_only=is_new
)
asset_ids: set[str] = set()
for ch in chapters:
asset_ids |= collect_asset_ids_for_chapter(ch)
asset_map = await signed_urls_for_asset_ids(self._db, asset_ids)
chapter_by_category: dict[str, Chapter] = {}
for ch in chapters:
if ch.category and ch.category not in chapter_by_category:
@@ -147,7 +161,7 @@ class MemoirService:
ch = chapter_by_category.pop(category, None)
if ch:
await self._cleanup_unavailable_images(ch)
all_chapters.append(chapter_to_dict(ch))
all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map))
else:
if is_new is True:
continue
@@ -155,13 +169,17 @@ class MemoirService:
{
"id": f"placeholder_{category}",
"title": CHAPTER_CATEGORIES[category],
"content": "",
"category": category,
"order_index": STAGE_TO_ORDER.get(category, 999),
"status": "empty",
"category": category,
"images": [],
"summary": "",
"canonical_markdown": "",
"content": "",
"cover_asset": None,
"cover_image": None,
"images": [],
"sections": [],
"word_count": 0,
"updated_at": None,
"is_new": False,
"source_segments": [],
@@ -169,7 +187,7 @@ class MemoirService:
)
for ch in chapter_by_category.values():
await self._cleanup_unavailable_images(ch)
all_chapters.append(chapter_to_dict(ch))
all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map))
return all_chapters
async def get_chapter(self, chapter_id: str, user_id: str) -> dict:
@@ -181,7 +199,10 @@ class MemoirService:
if not chapter.is_active:
raise HTTPException(status_code=404, detail="Chapter not found")
await self._cleanup_unavailable_images(chapter)
return chapter_to_dict(chapter)
asset_map = await signed_urls_for_asset_ids(
self._db, collect_asset_ids_for_chapter(chapter)
)
return chapter_to_dict(chapter, asset_url_map=asset_map)
async def disable_chapter(self, chapter_id: str, user_id: str) -> dict:
chapter = await self._db.get(Chapter, chapter_id)
@@ -220,10 +241,14 @@ class MemoirService:
async def check_and_trigger_cover_generation(self, user_id: str) -> dict:
"""
检查可生成封面的章节section 配图 > 3 且无已完成封面)
若有则触发生成任务。已有封面的章节不再检查
有正文、尚无 cover_asset、且 legacy 封面 MemoirImage 未 completed 时
派发 generate_chapter_cover由 intent/asset 闭环完成)
"""
from app.tasks.memoir_tasks import generate_chapter_images
from app.tasks.chapter_cover_tasks import generate_chapter_cover
img_settings = MemoirImageSettings.from_env()
if not img_settings.enabled:
return {"triggered": []}
chapters = await repo.get_chapters_with_sections(
user_id, self._db, active_only=True, is_new_only=None
@@ -232,59 +257,33 @@ class MemoirService:
for ch in chapters:
if not ch.category or ch.status == "empty":
continue
sections = getattr(ch, "sections", None) or []
section_image_count = sum(
1 for s in sections if getattr(s, "image_id", None)
if getattr(ch, "cover_asset_id", None):
continue
md = (ch.canonical_markdown or "").strip()
section_blob = "\n\n".join(
(s.content or "").strip()
for s in getattr(ch, "sections", None) or []
if (s.content or "").strip()
)
body = md or strip_legacy_image_placeholders(section_blob).strip()
if not body:
continue
images = getattr(ch, "images", None) or []
cover_rec = next(
(m for m in images if getattr(m, "section_id", None) is None),
None,
)
if section_image_count <= 3:
continue
if (
cover_rec
and (getattr(cover_rec, "status") or "").strip() == "completed"
):
continue
if cover_rec is None:
img_settings = MemoirImageSettings.from_env()
if img_settings.enabled:
now_iso = datetime.now(timezone.utc).isoformat()
cover_ph = {
"placeholder": "{{{{{{{{IMAGE:章节封面}}}}}}}}",
"description": "章节封面",
"index": 0,
}
style = MemoirImagePromptService.CATEGORY_STYLE_MAP.get(
ch.category or "", img_settings.default_style
)
cover_asset = build_initial_image_assets(
[cover_ph],
img_settings.provider,
style,
img_settings.default_size,
now_iso,
)[0]
kwargs = image_dict_to_row_kwargs(cover_asset)
cover_mi = MemoirImage(
id=str(uuid.uuid4()).replace("-", "")[:32],
chapter_id=ch.id,
section_id=None,
order_index=0,
**kwargs,
)
self._db.add(cover_mi)
await self._db.commit()
await self._db.refresh(ch)
logger.info("创建封面占位: chapter=%s", ch.id)
try:
generate_chapter_images.delay(ch.id)
generate_chapter_cover.delay(ch.id)
triggered.append(ch.id)
logger.info("触发生成封面: chapter=%s", ch.id)
logger.info("触发生成章节封面(asset): chapter=%s", ch.id)
except Exception as exc:
logger.warning("封面生成任务派发失败: chapter=%s, error=%s", ch.id, exc)
logger.warning("封面任务派发失败: chapter=%s, error=%s", ch.id, exc)
return {"triggered": triggered}
async def mark_memoir_read(self, user_id: str) -> dict: