Files
life-echo/api/app/features/memoir/service.py
Kevin 53d9e003af feat(api): 叙事 prompt、职业上下文、读路径章节、WS 解耦与错误脱敏
- 回忆录:事实边界补充允许清单;传记文体示例与 JSON 叙事要求对齐
- default 职业提示 occupation_context;cadre/military 退休语境
- GET 章节读路径零写入,prepare_chapter_read_view + markdown_for_response
- 文本归一抽到 core/text_normalize;移除弃用 reply 策略与 recompose_chapters_for_story
- ConversationService:WS 连接/用户段落/结束对话;对外错误固定文案
- 测试:HTTP 脱敏契约、章节读视图、occupation 与 background_voice
2026-04-01 11:55:52 +08:00

329 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
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 获取证据(含向量时与 Celery 的 FTS-only 路径不同)。"""
if self._memory is None:
return {
"relevant_chunks": [],
"relevant_summaries": [],
"relevant_facts": [],
"timeline_hints": [],
"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)
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"}