Files
life-echo/api/app/features/memoir/service.py
Kevin 1374f6e8f5 feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化
- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏
- 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定
- 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n
- 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
2026-03-26 16:28:33 +08:00

317 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.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,
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.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__)
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 获取证据(含向量时与 Celery 的 FTS-only 路径不同)。"""
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={}, image={}", ch.id, rec.id)
await self._db.delete(rec)
cleaned = True
if cleaned:
await self._db.commit()
await self._db.refresh(ch)
async def _ensure_chapter_materialized(self, chapter: Chapter) -> Chapter:
has_story_links = bool(getattr(chapter, "story_links", None))
has_snapshot = chapter.reading_segments_json is not None
if not has_story_links or (has_snapshot and not chapter.markdown_compose_dirty):
return chapter
markdown = materialize_chapter_markdown_from_loaded_chapter(chapter)
await repo.append_chapter_compose_version_async(self._db, chapter, markdown)
await self._db.commit()
refreshed = await repo.get_chapter_by_id(chapter.id, self._db)
return refreshed or chapter
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 = await self._ensure_chapter_materialized(ch)
if chapter_meets_minimum_display(ch2):
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 = await self._ensure_chapter_materialized(ch)
if not chapter_meets_minimum_display(ch):
continue
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")
chapter = await self._ensure_chapter_materialized(chapter)
await self._cleanup_unavailable_images(chapter)
if not chapter_meets_minimum_display(chapter):
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)
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)
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、且封面 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"}