"""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.memoir.prompts import ( CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER, ) 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_legacy_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, is_image_permanently_unavailable, ) from app.features.memoir.memoir_images.settings import MemoirImageSettings from app.features.memoir.models import Book, Chapter, ChapterStoryLink from app.features.memory.service import MemoryService from app.ports.storage import ObjectStorage logger = get_logger(__name__) 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 获取检索证据(章节生成时优先使用)。""" if self._memory is None: return { "relevant_chunks": [], "relevant_summaries": [], "relevant_facts": [], "timeline_hints": [], } return await self._memory.retrieve(user_id, query, top_k=top_k) async def _cleanup_unavailable_images(self, ch: Chapter) -> None: cleaned = False for rec in getattr(ch, "images", None) or []: if rec and is_image_permanently_unavailable(rec): logger.info("清理不可用配图: chapter=%s, image=%s", ch.id, rec.id) await self._db.delete(rec) cleaned = True if cleaned: await self._db.commit() await self._db.refresh(ch) 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 = list(result.unique().scalars().all()) 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_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: chapter_by_category[ch.category] = ch all_chapters: List[dict] = [] for category in CHAPTER_ORDER: ch = chapter_by_category.pop(category, None) if ch: await self._cleanup_unavailable_images(ch) all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map)) else: if is_new is True: continue all_chapters.append( { "id": f"placeholder_{category}", "title": CHAPTER_CATEGORIES[category], "category": category, "order_index": STAGE_TO_ORDER.get(category, 999), "status": "empty", "summary": "", "canonical_markdown": "", "content": "", "cover_asset": None, "cover_image": None, "images": [], "sections": [], "word_count": 0, "updated_at": None, "is_new": False, "source_segments": [], } ) for ch in chapter_by_category.values(): await self._cleanup_unavailable_images(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: 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") await self._cleanup_unavailable_images(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 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 regenerate_chapter(self, chapter_id: str, user_id: 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="无权操作此章节") n = await repo.count_chapter_story_links(self._db, chapter_id) if n > 0: raise HTTPException( status_code=400, detail="该章节由故事编排驱动,请更新故事正文或调整故事顺序,不支持在此处整章再生。", ) # TODO: 非 story-backed 章节的 LLM 重新整理 return {"status": "ok", "message": "Chapter regeneration triggered"} 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) return { "current_stage": state.current_stage, "empty_slots": 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、且 legacy 封面 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_with_sections( user_id, self._db, active_only=True, is_new_only=None ) triggered: List[str] = [] for ch in chapters: if not ch.category or ch.status == "empty": continue if getattr(ch, "cover_asset_id", None): continue md = (ch.canonical_markdown or "").strip() body = strip_legacy_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=%s", 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"}