重构回忆录为 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user