"""Memoir service — 回忆录编排(章节生成、状态流转);通过 MemoryService 获取 evidence。""" import asyncio from typing import List, Optional from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from app.agents.state_schema import narrative_coverage_state from app.core.logging import get_logger from app.core.storage_purge import delete_object_storage_keys_best_effort from app.features.memoir import repo from app.features.memoir.asset_resolver import ( collect_asset_ids_for_chapter, collect_asset_ids_for_chapters, strip_image_placeholders, ) from app.features.memoir.asset_urls import signed_urls_for_asset_ids from app.features.memoir.chapter_markdown_compose import ( materialize_chapter_markdown_from_loaded_chapter, ) from app.features.memoir.cover_eligibility import primary_chapter_memoir_image from app.features.memoir.helpers import chapter_to_dict, chapter_to_list_dict from app.features.memoir.memoir_images.settings import MemoirImageSettings from app.features.memoir.models import Book, Chapter, ChapterStoryLink from app.features.memoir.reading_segment_materialize import ( chapter_meets_minimum_display, ) from app.features.memory.service import MemoryService from app.ports.storage import ObjectStorage logger = get_logger(__name__) def prepare_chapter_read_view(chapter: Chapter) -> tuple[Chapter, str | None]: """ 读路径:不写入 DB。返回 (chapter, markdown_for_response)。 None 表示序列化使用 ORM 上的 canonical_markdown。 """ has_story_links = bool(getattr(chapter, "story_links", None)) has_snapshot = chapter.reading_segments_json is not None dirty = chapter.markdown_compose_dirty is True if not dirty: return chapter, None if has_snapshot: return chapter, None if has_story_links: md = materialize_chapter_markdown_from_loaded_chapter(chapter) m = (md or "").strip() return chapter, m if m else None return chapter, None async def get_or_create_book(user_id: str, db: AsyncSession): """Get the user's current book or return None.""" return await repo.get_current_book(user_id, db) class MemoirService: def __init__( self, db: AsyncSession, memory_service: Optional[MemoryService] = None, *, object_storage: ObjectStorage | None = None, ): self._db = db self._memory = memory_service self._object_storage = object_storage async def get_evidence(self, user_id: str, query: str, *, top_k: int = 10) -> dict: """通过 MemoryService → HybridRetriever 获取证据(向量 chunks,与 Celery 叙事路径一致)。""" if self._memory is None: return { "relevant_chunks": [], "relevant_summaries": [], "relevant_facts": [], "relevant_stories": [], } bundle = await self._memory.retrieve(user_id, query, top_k=top_k) return bundle.model_dump() async def get_current_book(self, user_id: str) -> dict: book = await repo.get_current_book(user_id, self._db) if not book: return {"message": "No book found"} return { "id": book.id, "title": book.title, "total_pages": book.total_pages, "total_words": book.total_words, "cover_image_url": book.cover_image_url, "has_update": book.has_update, "last_update_chapter_id": book.last_update_chapter_id, } async def clear_book_update(self, user_id: str) -> dict: book = await repo.get_current_book(user_id, self._db) if not book: return {"status": "ok", "message": "No book found"} book.has_update = False await self._db.commit() return {"status": "ok"} async def update_book(self, book_id: str, user_id: str, title: str) -> dict: book = await self._db.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") if book.user_id != user_id: raise HTTPException(status_code=403, detail="无权更新此回忆录") book.title = title await self._db.commit() await self._db.refresh(book) return { "id": book.id, "title": book.title, "total_pages": book.total_pages, "total_words": book.total_words, "cover_image_url": book.cover_image_url, "has_update": book.has_update, "last_update_chapter_id": book.last_update_chapter_id, } async def export_pdf(self, user_id: str, book_id: str) -> dict: from app.features.memoir.pdf_service import pdf_service book = await self._db.get(Book, book_id) if not book: raise HTTPException(status_code=404, detail="Book not found") if book.user_id != user_id: raise HTTPException(status_code=403, detail="无权导出此回忆录") stmt = ( select(Chapter) .where(Chapter.user_id == user_id, Chapter.is_active == True) .options( joinedload(Chapter.images), joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), ) .order_by(Chapter.order_index) ) result = await self._db.execute(stmt) chapters_raw = list(result.unique().scalars().all()) chapters: List[Chapter] = [] for ch in chapters_raw: ch2, md_override = prepare_chapter_read_view(ch) if chapter_meets_minimum_display( ch2, canonical_markdown_override=md_override ): chapters.append(ch2) 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", } async def get_chapters( self, user_id: str, is_new: bool | None = None ) -> List[dict]: chapters = await repo.get_chapters_for_memoir_list( user_id, self._db, is_new_only=is_new ) if not chapters: return [] 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) all_chapters: List[dict] = [] for ch in chapters: ch, md_override = prepare_chapter_read_view(ch) if not chapter_meets_minimum_display( ch, canonical_markdown_override=md_override ): continue all_chapters.append( chapter_to_list_dict( ch, asset_url_map=asset_map, markdown_for_response=md_override, ) ) return all_chapters async def get_chapter(self, chapter_id: str, user_id: str) -> dict: chapter = await repo.get_chapter_by_id(chapter_id, self._db) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权访问此章节") if not chapter.is_active: raise HTTPException(status_code=404, detail="Chapter not found") chapter, md_override = prepare_chapter_read_view(chapter) if not chapter_meets_minimum_display( chapter, canonical_markdown_override=md_override ): raise HTTPException(status_code=404, detail="Chapter not found") 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, markdown_for_response=md_override, ) async def disable_chapter(self, chapter_id: str, user_id: str) -> dict: chapter = await repo.get_chapter_by_id(chapter_id, self._db) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权操作此章节") cos_keys = await repo.collect_cos_storage_keys_for_chapter(self._db, chapter) chapter.is_active = False await self._db.commit() delete_object_storage_keys_best_effort( self._object_storage, cos_keys, log_prefix=f"chapter_soft_delete id={chapter_id}", ) return {"status": "ok", "message": "章节已清除"} async def set_chapter_story_order( self, chapter_id: str, user_id: str, story_ids: list[str] ) -> dict: chapter = await self._db.get(Chapter, chapter_id) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权操作此章节") if not chapter.is_active: raise HTTPException(status_code=404, detail="Chapter not found") try: await repo.replace_chapter_story_links_async( self._db, chapter_id=chapter_id, user_id=user_id, story_ids=story_ids, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc ch = await repo.get_chapter_with_story_links_for_compose(chapter_id, self._db) if not ch: raise HTTPException(status_code=404, detail="Chapter not found") if not ch.story_links: md = "" else: md = materialize_chapter_markdown_from_loaded_chapter(ch) await repo.append_chapter_compose_version_async(self._db, ch, md) await self._db.commit() return {"status": "ok", "chapter_id": chapter_id, "story_count": len(story_ids)} async def get_memoir_state(self, user_id: str) -> dict: from app.features.memoir.state_service import get_or_create_state state = await get_or_create_state(user_id, self._db) return state.model_dump() async def get_next_question_context(self, user_id: str) -> dict: from app.features.memoir.state_service import get_or_create_state state = await get_or_create_state(user_id, self._db) narrative_state = narrative_coverage_state(state) return { "current_stage": state.current_stage, "empty_slots": narrative_state.empty_slots_for_current_stage(), "covered_stages": state.covered_stages, } async def check_and_trigger_cover_generation(self, user_id: str) -> dict: """ 有正文、尚无 cover_asset、且封面 MemoirImage 未 completed 时, 派发 generate_chapter_cover(由 intent/asset 闭环完成)。 """ from app.tasks.chapter_cover_enqueue import try_enqueue_generate_chapter_cover img_settings = MemoirImageSettings.from_env() if not img_settings.enabled: return {"triggered": []} chapters = await repo.get_chapters_for_memoir_list( user_id, self._db, active_only=True, is_new_only=None ) triggered: List[str] = [] for ch in chapters: if not ch.category or not getattr(ch, "story_links", None): continue if getattr(ch, "cover_asset_id", None): continue md = (ch.canonical_markdown or "").strip() body = strip_image_placeholders(md).strip() if md else "" if not body: continue cover_rec = primary_chapter_memoir_image(ch) if cover_rec and (cover_rec.status or "").strip() == "completed": continue enqueued = await asyncio.to_thread( try_enqueue_generate_chapter_cover, ch.id, "http" ) if enqueued: triggered.append(ch.id) logger.info("触发生成章节封面(asset): chapter={}", ch.id) return {"triggered": triggered} async def mark_memoir_read(self, user_id: str) -> dict: stmt = select(Chapter).where(Chapter.user_id == user_id, Chapter.is_new == True) result = await self._db.execute(stmt) for chapter in result.scalars().all(): chapter.is_new = False stmt_book = ( select(Book).where(Book.user_id == user_id).order_by(Book.updated_at.desc()) ) result_book = await self._db.execute(stmt_book) book = result_book.scalar_one_or_none() if book: book.has_update = False await self._db.commit() return {"status": "ok"}