feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化

- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏
- 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定
- 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n
- 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
This commit is contained in:
Kevin
2026-03-26 16:28:33 +08:00
parent d990399112
commit 1374f6e8f5
15 changed files with 708 additions and 198 deletions

View File

@@ -146,6 +146,7 @@ class ConversationService:
except Exception:
pass
latest_message = history[-1].get("content", "")[:50] if history else None
has_user_message = any((msg.get("role") == "human") for msg in history)
result.append(
{
"id": conv.id,
@@ -155,6 +156,7 @@ class ConversationService:
"latestMessageTime": _latest_message_time_ms(conv, history),
"unreadCount": 0,
"isDefaultAssistant": conv.summary is None,
"hasUserMessage": has_user_message,
}
)
return result

View File

@@ -8,6 +8,9 @@ PDF 导出可单独物化「## 标题 + 正文」版本。
from typing import Any
from app.features.memoir.markdown_sanitize import sanitize_story_for_chapter_compose
from app.features.memoir.reading_segment_materialize import (
story_meets_minimum_chapter_length,
)
def _gather_title_body_pairs(chapter: Any) -> list[tuple[str, str]]:
@@ -37,7 +40,7 @@ def compose_ordered_stories_to_markdown(ordered: list[tuple[str, str]]) -> str:
if not raw:
continue
cleaned = sanitize_story_for_chapter_compose(raw, title)
if cleaned:
if cleaned and story_meets_minimum_chapter_length(cleaned):
bodies.append(cleaned)
return "\n\n---\n\n".join(bodies)
@@ -53,6 +56,8 @@ def compose_ordered_stories_to_pdf_markdown(ordered: list[tuple[str, str]]) -> s
body = sanitize_story_for_chapter_compose(raw, title)
if not body:
continue
if not story_meets_minimum_chapter_length(body):
continue
parts.append(f"## {t}\n\n{body}")
return "\n\n---\n\n".join(parts)

View File

@@ -1,16 +1,75 @@
"""章节阅读片段物化:与 canonical 同一生成时机写入 reading_segments_json无签名 URL"""
"""章节阅读片段物化与「可读字数」门槛(单一事实源)。
**字数阈值** ``MIN_STORY_CHARS_IN_CHAPTER``(当前 300对 Markdown 去图片/链接噪声后
用 ``story_plain_text_char_count`` 估算字符数,用于:
- **单篇故事**:是否写入 ``reading_segments_json``、是否参与 ``chapter_markdown_compose`` 拼接;
- **章节**``chapter_meets_minimum_display`` / ``chapter_body_meets_minimum_for_display`` 是否对
用户展示(列表/详情/PDF 见 ``MemoirService``)。
**物化**``build_reading_segments_snapshot`` 与 canonical 同路径写入 ``reading_segments_json``(无签名 URL
**API**``hydrate_reading_segments_from_snapshot`` 解析快照(含签名 URL旧快照亦按当前阈值过滤。
``resolve_reading_segments_for_chapter_detail`` 仅读已物化快照。
其它引用:``repo.append_chapter_compose``、``helpers.chapter_to_dict``(经 ``resolve_reading_segments…``)、
``chapter_markdown_compose``(故事拼接)。
"""
from __future__ import annotations
import re
from typing import Any
from app.features.memoir.asset_resolver import (
collect_asset_ids_from_markdown,
resolve_asset_refs_in_markdown,
strip_asset_image_refs_from_markdown,
strip_image_placeholders,
)
from app.features.memoir.markdown_sanitize import sanitize_story_for_chapter_compose
from app.features.memoir.models import Chapter
# 故事收录章节、章节对读者展示:共用最小可读字数(与 story_plain_text_char_count 一致)
MIN_STORY_CHARS_IN_CHAPTER = 300
_WS_COLLAPSE = re.compile(r"\s+")
def story_plain_text_char_count(markdown: str) -> int:
"""估算 Markdown 正文可读字符数(中英按字计),用于故事/章节字数门槛。"""
if not markdown or not str(markdown).strip():
return 0
t = strip_image_placeholders(markdown)
t = strip_asset_image_refs_from_markdown(t)
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t)
t = re.sub(r"!\[([^\]]*)\]\([^)]+\)", "", t)
t = re.sub(r"`+([^`]+)`+", r"\1", t)
t = re.sub(r"^#{1,6}\s+", "", t, flags=re.MULTILINE)
# 剩余强调符等不计入「字数」
t = re.sub(r"[*_#`]", "", t)
t = _WS_COLLAPSE.sub("", t)
return len(t)
def story_meets_minimum_chapter_length(markdown: str) -> bool:
"""单篇故事正文是否达到收录章节的阈值物化快照、hydrate 过滤)。"""
return story_plain_text_char_count(markdown) >= MIN_STORY_CHARS_IN_CHAPTER
def chapter_body_meets_minimum_for_display(canonical_markdown: str) -> bool:
"""章节 canonical 是否达到对读者展示的最小可读字数(与单篇故事阈值一致)。"""
return (
story_plain_text_char_count(canonical_markdown or "")
>= MIN_STORY_CHARS_IN_CHAPTER
)
def chapter_meets_minimum_display(ch: Any) -> bool:
"""基于章节当前 canonical_markdown物化后判断是否可对读者展示。"""
md = getattr(ch, "canonical_markdown", None) or ""
return chapter_body_meets_minimum_for_display(str(md))
def _primary_story_intent_asset_id(story: Any) -> str | None:
for it in getattr(story, "image_intents", None) or []:
@@ -88,6 +147,8 @@ def build_reading_segments_snapshot(ch: Chapter) -> list[dict[str, Any]]:
body = sanitize_story_for_chapter_compose(raw, title)
if not body:
continue
if not story_meets_minimum_chapter_length(body):
continue
primary_aid = _primary_story_intent_asset_id(st)
inline_ids = set(collect_asset_ids_from_markdown(body))
cover: dict | None = None
@@ -115,7 +176,11 @@ def hydrate_reading_segments_from_snapshot(
rows = getattr(ch, "reading_segments_json", None) or []
out: list[dict[str, Any]] = []
for row in rows:
body = resolve_asset_refs_in_markdown(row["body_markdown"], resolve)
raw_body = row.get("body_markdown") or ""
# 与物化时一致;旧库快照亦按当前阈值过滤
if not story_meets_minimum_chapter_length(raw_body):
continue
body = resolve_asset_refs_in_markdown(raw_body, resolve)
ci = row.get("cover_asset")
if ci:
img_raw = _cover_dict_from_snapshot_row(ci, asset_url_map)

View File

@@ -28,6 +28,9 @@ from app.features.memoir.helpers import (
)
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
@@ -144,7 +147,12 @@ class MemoirService:
.order_by(Chapter.order_index)
)
result = await self._db.execute(stmt)
chapters = list(result.unique().scalars().all())
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(
@@ -170,6 +178,9 @@ 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):
continue
await self._cleanup_unavailable_images(ch)
all_chapters.append(chapter_to_list_dict(ch, asset_url_map=asset_map))
return all_chapters
@@ -184,6 +195,8 @@ class MemoirService:
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)
)