feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化
- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏 - 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定 - 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n - 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user