Files
life-echo/api/app/features/memoir/service.py
Kevin 41518bda11 聊天和回忆录证据检索都走 pgvector,去掉 Postgres FTS/content_tsv,新迁移删掉 content_tsv 列(部署要先 alembic upgrade)。
Embedding 端口增加 is_available(),聊天和回忆录日志用统一方式表示向量是否真能调用。

记忆整理(compaction)支持 Beat 定期扫用户;

事实抽取提示与 subject 归一化,减少同一人多种称呼;
2026-04-03 11:43:16 +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 获取证据(向量 chunks与 Celery 叙事路径一致)。"""
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"}