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
This commit is contained in:
Kevin
2026-04-01 11:49:33 +08:00
parent a5473e8fe2
commit 53d9e003af
28 changed files with 598 additions and 397 deletions

View File

@@ -21,11 +21,7 @@ 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.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 (
@@ -37,6 +33,29 @@ 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)
@@ -67,29 +86,6 @@ class MemoirService:
bundle = await self._memory.retrieve(user_id, query, top_k=top_k)
return bundle.model_dump()
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:
@@ -152,8 +148,10 @@ class MemoirService:
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):
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)
@@ -180,11 +178,18 @@ class MemoirService:
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):
ch, md_override = prepare_chapter_read_view(ch)
if not chapter_meets_minimum_display(
ch, canonical_markdown_override=md_override
):
continue
await self._cleanup_unavailable_images(ch)
all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map))
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:
@@ -195,14 +200,19 @@ class MemoirService:
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):
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)
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)