Files
life-echo/api/app/features/memoir/service.py
Kevin 8af37e5e8e 修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
2026-03-20 16:43:02 +08:00

340 lines
14 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.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"}