fix:
1. 修复安卓部分机型顶部安全区遮挡回忆录标题的问题; 2. 降低封面图生成阈值和展示逻辑,独立封面图未生成时,使用正文图; 3. 去掉“嗯。”生硬回答,去掉不合理段首承接词; 4. 新增章节封面所需最少插图数的配置项
This commit is contained in:
@@ -273,6 +273,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
||||
MEMOIR_IMAGE_PROVIDER=liblib
|
||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(默认 1=有一张正文图即可)
|
||||
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
||||
STORY_IMAGE_MIN_BODY_CHARS=400
|
||||
# 叙事模型输出相对口述过短则回退为口述原文
|
||||
|
||||
@@ -235,6 +235,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
||||
MEMOIR_IMAGE_PROVIDER=liblib
|
||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面)
|
||||
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||
# Story 正文至少多少字才生成主图 intent / 调图(0=不限制)
|
||||
STORY_IMAGE_MIN_BODY_CHARS=800
|
||||
# 叙事模型输出相对口述过短则回退为口述原文
|
||||
|
||||
@@ -168,6 +168,8 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
|
||||
MEMOIR_IMAGE_PROVIDER=liblib
|
||||
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
|
||||
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
|
||||
# 章节正文内至少多少张 asset:// 插图才生成/展示章节封面(≥1 即有一张图可出封面)
|
||||
MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER=1
|
||||
# 可选,Liblib 返回图片域名不在默认白名单时(逗号分隔)
|
||||
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud
|
||||
|
||||
|
||||
@@ -9,14 +9,17 @@ def chat_output_rules() -> str:
|
||||
"`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:"
|
||||
"策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**"
|
||||
"(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);"
|
||||
"若需停顿或语气,用口语里的「嗯」「唉」或省略号自然写出,**不要**用括号包装动作或旁白;"
|
||||
"**禁止**以「嗯。」**起头**(含「嗯。」后立刻接任何正文——一律不得用这种停顿起手)、禁止单独成泡只有「嗯。」——生硬、像生冷打字机;"
|
||||
"若需停顿或语气,优先用省略号、或把承接半句直接钉在对方原词上;可用「唉」等;**避免**每条消息都以「好。」「对。」单独打头再接一大段(易像程式客服);"
|
||||
"**不要**用括号包装动作或旁白;"
|
||||
"思考过程或任何元注释同样**绝不可**出现在对用户说的话中;"
|
||||
"主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);"
|
||||
"课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);"
|
||||
"推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」"
|
||||
"等阶段总结或程序性过渡,而非贴着对方上一轮话头半句并肩地往下长);"
|
||||
"强行搭话式「这让我想起…」接**与当前画面不沾边**的自己的故事或常识,制造虚假亲密;"
|
||||
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「嗯」「好」「明白」等),须至少有半句贴着对方原词的承接;"
|
||||
"采访腔(「我注意到」「我想了解」);尤其在用户长段倾诉或情绪很重时,**勿**整条回复仅单个语气词(孤立的「好」「明白」等),须至少有半句贴着对方原词的承接;"
|
||||
"连续多轮都以「好,……」「对,……」式**同一套路起句**(发语词后接泛共情),须主动轮换——尽量**直接**从对方刚说的物象、人或半句并肩起笔;"
|
||||
"书面评介腔(「值得一提的是」「总的来说」「从某种意义上」);"
|
||||
"空话铺垫(「这确实是个好问题」类);**以核对为名**重复对方已明确说过的基础信息(如「所以您是……对吗」「刚才您说的是……吗」),"
|
||||
"对方已交代清楚的事实应直接当作前提,在其上深化、延伸或关联提问;"
|
||||
@@ -31,6 +34,7 @@ def chat_voice_style() -> str:
|
||||
"语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——"
|
||||
"不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。"
|
||||
"接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。"
|
||||
"起句尽量从对方**原词或具体画面**带入;**不要**用「嗯。」开场(**含**「嗯。」后立刻接正文),也不要「好。」「对。」单独一顿再接长句当习惯起手。"
|
||||
"用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。"
|
||||
"不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。"
|
||||
"追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。"
|
||||
|
||||
@@ -438,7 +438,7 @@ def get_guided_conversation_prompt(
|
||||
|
||||
### 第一步:先接住——让对方觉得你真的听进了情绪与细节
|
||||
- 用对方刚说的**那个具体细节**回应,不要写成泛泛的"听起来很好"。
|
||||
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
|
||||
- **节奏**:用户刚说完**一大段**或字里行间**情绪很重**(委屈、哽咽、后怕、赌气、突然抒情)时,**禁止**整条回复就只有孤立的一个「嗯。」「嗯」「好」「明白」——会显得敷衍、支持感不够;承接仍要**短**,但至少要**半句并肩**或**钉住对方原词的一点点展开**(例如「是哦,讲到那儿换谁都会堵得慌」「那种……光听你描述就挺沉的」),不必写成工整小结再接问。若用 `[SPLIT]`:**前一泡也须有质感**,不能单字凑数,后一泡再追问或继续陪聊。
|
||||
- **跟随—沉浸**:长段叙述后,可插入**极短**一两句**并肩式画面或体感**(像朋友旁听时轻轻嘬一口气),打破纯「一问一答」节拍;须**贴着对方刚讲的物象/动作**,可用「那种…」「换我可能也会…」等**泛指**,**禁止**宣称自己身上发生的具体人名地名事件,**禁止**用「这让我想起…」硬接无关轶事抢戏。
|
||||
- **留白**:用户抛出**强烈情绪**或**金句式人生总结**(如「爱是流动的」一类)时,允许**本轮不接任何问题**,只作更深一层的、贴着原句的共情,让情绪**淌一小会儿**;若用 `[SPLIT]`,第一泡可以**只有共情不讲题**,但仍须是**有内容的短句**(贴原词或并肩),第二泡仍可不问,或只在末尾留极轻的一句勾子,勿赶着交卷。
|
||||
- 好的接法:借用对方话里的意象往下走一步,例如对方说"烤红薯",你可以说"那种外面焦焦的、掰开冒热气的感觉"。
|
||||
@@ -467,11 +467,13 @@ def get_guided_conversation_prompt(
|
||||
- 不要每轮都像第一次见面。
|
||||
|
||||
## 语言与文笔(隐性执行,勿念给用户听)
|
||||
- **句首习惯**:**禁止**「嗯。」起头(**含**「嗯。」后立刻接正文,一律不要);**禁止**单独成泡只有「嗯。」。「好。」「对。」也少当每轮固定发语词;更像真人时**直接**咬对方原词往下长——短停顿用省略号或半句并肩即可。
|
||||
- 长短句掺着来;能少说一个字就不堆「很、特别、真的」。
|
||||
- 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。
|
||||
- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。
|
||||
|
||||
## 绝对不要做的
|
||||
- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。
|
||||
- 不要为了赶大纲无视用户刚露出来的情绪。
|
||||
- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。
|
||||
- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。
|
||||
|
||||
@@ -62,6 +62,19 @@ def strip_parenthetical_asides_for_chat(text: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
|
||||
def strip_leading_en_period_ack_for_chat(text: str) -> str:
|
||||
"""
|
||||
去掉段首生硬的「嗯。」(可重复),即使后面还有正文;只剥字符串开头,不误伤句中「嗯。」。
|
||||
支持全角/半角句号。
|
||||
"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return s
|
||||
# 允许多次「嗯。」/「嗯嗯。」叠在段首;句号仅匹配全角 。、. 与 ASCII `.`
|
||||
s2 = re.sub(r"^(?:嗯+(?:。|.|\.)+\s*)+", "", s)
|
||||
return s2.strip()
|
||||
|
||||
|
||||
def segments_from_llm_response(
|
||||
response_text: str,
|
||||
*,
|
||||
@@ -76,13 +89,22 @@ def segments_from_llm_response(
|
||||
text = strip_parenthetical_asides_for_chat(text)
|
||||
if not text:
|
||||
return []
|
||||
primary = [p.strip() for p in text.split("[SPLIT]") if p.strip()]
|
||||
primary = [
|
||||
strip_leading_en_period_ack_for_chat(p)
|
||||
for p in text.split("[SPLIT]")
|
||||
if strip_leading_en_period_ack_for_chat(p).strip()
|
||||
]
|
||||
if len(primary) > 1:
|
||||
return primary[:max_segments]
|
||||
blob = primary[0] if primary else text
|
||||
blob = primary[0] if primary else strip_leading_en_period_ack_for_chat(text)
|
||||
blob = strip_leading_en_period_ack_for_chat(blob)
|
||||
if "\n" not in blob:
|
||||
return [blob]
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n+", blob) if p.strip()]
|
||||
paras = [
|
||||
strip_leading_en_period_ack_for_chat(p)
|
||||
for p in re.split(r"\n\s*\n+", blob)
|
||||
if strip_leading_en_period_ack_for_chat(p).strip()
|
||||
]
|
||||
if len(paras) < 2:
|
||||
return [blob]
|
||||
paras = [p for p in paras if len(p) >= min_paragraph_chars]
|
||||
|
||||
@@ -264,6 +264,8 @@ class Settings(BaseSettings):
|
||||
memoir_image_style_default: str = "watercolor"
|
||||
memoir_image_size_default: str = "1280x720"
|
||||
memoir_image_download_hosts: str = ""
|
||||
# 章节 canonical_markdown 中至少含多少张 asset:// 正文插图才生成/展示章节封面(≥ 该值即满足;0 表示不以此条件拦截)
|
||||
memoir_min_inline_images_for_chapter_cover: int = Field(default=1, ge=0, le=100)
|
||||
# Story 正文至少多少字才创建主图 intent / 调图(0 表示不限制)
|
||||
story_image_min_body_chars: int = 400
|
||||
# generate_story_image 入队去重(Redis SET NX,秒)
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.features.memoir.asset_resolver import parse_asset_refs
|
||||
from app.core.config import settings
|
||||
from app.features.memoir.asset_resolver import (
|
||||
parse_asset_refs,
|
||||
strip_image_placeholders,
|
||||
)
|
||||
from app.features.memoir.memoir_images.schema import (
|
||||
IMAGE_STATUS_FAILED,
|
||||
IMAGE_STATUS_PENDING,
|
||||
)
|
||||
|
||||
# 正文内  数量需 **大于** 此值才生成/展示章节封面(与故事头图、正文配图任务独立)
|
||||
MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER = 3
|
||||
|
||||
|
||||
def chapter_has_story_links(chapter: Any) -> bool:
|
||||
return any(
|
||||
@@ -21,18 +22,47 @@ def chapter_has_story_links(chapter: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def count_chapter_inline_body_images(chapter: Any) -> int:
|
||||
"""统计章节 canonical_markdown 中正文插图(asset:// 图片引用)次数。"""
|
||||
md = getattr(chapter, "canonical_markdown", None) or ""
|
||||
return len(parse_asset_refs(md))
|
||||
def effective_chapter_markdown_for_cover_gates(chapter: Any) -> str:
|
||||
"""
|
||||
用于封面闸门计数:优先 DB canonical;若为空且已挂 stories,则用内存物化串
|
||||
(与列表/详情在 compose_dirty 时的临时正文对齐,避免「有图但 canonical 未落库」导致永不出封面)。
|
||||
"""
|
||||
md = (getattr(chapter, "canonical_markdown", None) or "").strip()
|
||||
if md:
|
||||
return md
|
||||
if chapter_has_story_links(chapter):
|
||||
from app.features.memoir.chapter_markdown_compose import (
|
||||
materialize_chapter_markdown_from_loaded_chapter,
|
||||
)
|
||||
|
||||
try:
|
||||
alt = (
|
||||
materialize_chapter_markdown_from_loaded_chapter(chapter) or ""
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
return alt
|
||||
return ""
|
||||
|
||||
|
||||
def chapter_eligible_for_cover_by_inline_body_image_count(chapter: Any) -> bool:
|
||||
"""仅当正文内插图数量 > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER 时才生成/展示章节封面。"""
|
||||
return (
|
||||
count_chapter_inline_body_images(chapter)
|
||||
> MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER
|
||||
def count_chapter_inline_body_images(
|
||||
chapter: Any, *, markdown: str | None = None
|
||||
) -> int:
|
||||
"""统计 asset:// 插图次数;未传 markdown 时用 effective_chapter_markdown_for_cover_gates。"""
|
||||
source = (
|
||||
markdown
|
||||
if markdown is not None
|
||||
else effective_chapter_markdown_for_cover_gates(chapter)
|
||||
)
|
||||
return len(parse_asset_refs(source))
|
||||
|
||||
|
||||
def chapter_eligible_for_cover_by_inline_body_image_count(
|
||||
chapter: Any, *, markdown: str | None = None
|
||||
) -> bool:
|
||||
"""正文内 asset:// 数量 ≥ 配置阈值时允许封面;markdown 非 None 时仅用该串计数。"""
|
||||
min_required = int(settings.memoir_min_inline_images_for_chapter_cover)
|
||||
return count_chapter_inline_body_images(chapter, markdown=markdown) >= min_required
|
||||
|
||||
|
||||
def primary_chapter_memoir_image(chapter: Any) -> Any | None:
|
||||
@@ -45,17 +75,18 @@ def primary_chapter_memoir_image(chapter: Any) -> Any | None:
|
||||
|
||||
|
||||
def chapter_needs_cover_enqueue(chapter) -> bool:
|
||||
"""尚无 cover_asset、有正文、且正文内 asset 插图多于阈值时,可派发 generate_chapter_cover。"""
|
||||
"""尚无 cover_asset、有正文、且正文内 asset 插图达到 env 阈值时,可派发 generate_chapter_cover。"""
|
||||
if not chapter:
|
||||
return False
|
||||
if not chapter_has_story_links(chapter):
|
||||
return False
|
||||
if getattr(chapter, "cover_asset_id", None):
|
||||
return False
|
||||
md = (getattr(chapter, "canonical_markdown", None) or "").strip()
|
||||
if not md:
|
||||
view = effective_chapter_markdown_for_cover_gates(chapter)
|
||||
body = strip_image_placeholders(view).strip()
|
||||
if not body:
|
||||
return False
|
||||
return chapter_eligible_for_cover_by_inline_body_image_count(chapter)
|
||||
return chapter_eligible_for_cover_by_inline_body_image_count(chapter, markdown=view)
|
||||
|
||||
|
||||
def chapter_has_cover_to_generate(chapter) -> bool:
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.features.memoir.asset_resolver import resolve_asset_refs_in_markdown
|
||||
from app.features.memoir.asset_resolver import (
|
||||
collect_asset_ids_from_markdown,
|
||||
resolve_asset_refs_in_markdown,
|
||||
)
|
||||
from app.features.memoir.cover_eligibility import (
|
||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||
chapter_has_story_links,
|
||||
primary_chapter_memoir_image,
|
||||
)
|
||||
from app.features.memoir.memoir_images.schema import (
|
||||
@@ -89,36 +93,82 @@ def is_image_permanently_unavailable(rec) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _markdown_for_cover_asset_gate(
|
||||
ch: Chapter, *, markdown_for_response: str | None = None
|
||||
) -> str:
|
||||
"""用于封面闸门与首张 asset 回落:canonical / override、物化 stories、分段快照 body 合并(去重 asset 计数由 parse 完成)。"""
|
||||
md = _chapter_markdown(ch, override=markdown_for_response)
|
||||
parts: list[str] = []
|
||||
if (md or "").strip():
|
||||
parts.append(md.strip())
|
||||
elif chapter_has_story_links(ch):
|
||||
from app.features.memoir.chapter_markdown_compose import (
|
||||
materialize_chapter_markdown_from_loaded_chapter,
|
||||
)
|
||||
|
||||
alt = (materialize_chapter_markdown_from_loaded_chapter(ch) or "").strip()
|
||||
if alt:
|
||||
parts.append(alt)
|
||||
# 仅走 reading_segments 时:DB canonical 可能未写回或不含 asset://,从快照段补图
|
||||
for row in getattr(ch, "reading_segments_json", None) or []:
|
||||
b = (row.get("body_markdown") or "").strip()
|
||||
if b:
|
||||
parts.append(b)
|
||||
return "\n\n".join(parts) if parts else (md or "")
|
||||
|
||||
|
||||
def _synthetic_cover_asset_dict(url: str, *, description: str) -> dict:
|
||||
"""列表/详情用:无 MemoirImage 行时,用 COS 签名 URL 拼一条 completed 封面 dict。"""
|
||||
return {
|
||||
"placeholder": "",
|
||||
"description": description,
|
||||
"index": 0,
|
||||
"status": IMAGE_STATUS_COMPLETED,
|
||||
"prompt": None,
|
||||
"url": url,
|
||||
"storage_key": None,
|
||||
"provider": None,
|
||||
"style": None,
|
||||
"size": None,
|
||||
"error": None,
|
||||
"retryable": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def chapter_cover_to_dict(
|
||||
ch: Chapter, asset_url_map: dict[str, str] | None = None
|
||||
ch: Chapter,
|
||||
asset_url_map: dict[str, str] | None = None,
|
||||
*,
|
||||
markdown_for_response: str | None = None,
|
||||
) -> dict | None:
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(ch):
|
||||
view_md = _markdown_for_cover_asset_gate(
|
||||
ch, markdown_for_response=markdown_for_response
|
||||
)
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(ch, markdown=view_md):
|
||||
return None
|
||||
asset_url_map = asset_url_map or {}
|
||||
|
||||
# 1) 独立章节封面(Celery generate_chapter_cover 写入的 cover_asset_id)
|
||||
aid = getattr(ch, "cover_asset_id", None)
|
||||
if aid and asset_url_map.get(str(aid)):
|
||||
return _synthetic_cover_asset_dict(
|
||||
asset_url_map[str(aid)], description="章节封面"
|
||||
)
|
||||
|
||||
# 2) 尚无独立封面时:用正文里首张 asset:// 的 URL 作卡片封面(故事主图已在正文内)
|
||||
for asset_id in collect_asset_ids_from_markdown(view_md):
|
||||
u = asset_url_map.get(str(asset_id))
|
||||
if u:
|
||||
return _synthetic_cover_asset_dict(u, description="章节封面")
|
||||
|
||||
# 3) 兼容旧数据:章节级 MemoirImage 首行
|
||||
m = primary_chapter_memoir_image(ch)
|
||||
if m and is_image_permanently_unavailable(m):
|
||||
m = None
|
||||
if m:
|
||||
return memoir_image_to_dict(m)
|
||||
asset_url_map = asset_url_map or {}
|
||||
aid = getattr(ch, "cover_asset_id", None)
|
||||
if aid and asset_url_map.get(str(aid)):
|
||||
url = asset_url_map[str(aid)]
|
||||
return {
|
||||
"placeholder": "",
|
||||
"description": "章节封面",
|
||||
"index": 0,
|
||||
"status": IMAGE_STATUS_COMPLETED,
|
||||
"prompt": None,
|
||||
"url": url,
|
||||
"storage_key": None,
|
||||
"provider": None,
|
||||
"style": None,
|
||||
"size": None,
|
||||
"error": None,
|
||||
"retryable": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -139,7 +189,9 @@ def chapter_to_list_dict(
|
||||
markdown_for_response: str | None = None,
|
||||
) -> dict:
|
||||
"""列表视图:与详情字段对齐的最小子集。"""
|
||||
cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map)
|
||||
cover = chapter_cover_to_dict(
|
||||
ch, asset_url_map=asset_url_map, markdown_for_response=markdown_for_response
|
||||
)
|
||||
cover_normalized = first_normalized_image_for_api(cover)
|
||||
canonical_raw = _chapter_markdown(ch, override=markdown_for_response)
|
||||
wcount = len(canonical_raw.strip()) if canonical_raw else 0
|
||||
@@ -170,7 +222,9 @@ def chapter_to_dict(
|
||||
asset_url_map = asset_url_map or {}
|
||||
resolve = lambda aid: asset_url_map.get(aid) # noqa: E731
|
||||
|
||||
cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map)
|
||||
cover = chapter_cover_to_dict(
|
||||
ch, asset_url_map=asset_url_map, markdown_for_response=markdown_for_response
|
||||
)
|
||||
cover_normalized = first_normalized_image_for_api(cover)
|
||||
# 正文真源:优先 canonical_markdown
|
||||
canonical_md = _chapter_markdown(ch, override=markdown_for_response)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.features.memoir.cover_eligibility import (
|
||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||
chapter_has_story_links,
|
||||
chapter_needs_cover_enqueue,
|
||||
effective_chapter_markdown_for_cover_gates,
|
||||
primary_chapter_memoir_image,
|
||||
)
|
||||
from app.features.memoir.models import Chapter, ChapterStoryLink
|
||||
@@ -42,14 +43,13 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
|
||||
return False
|
||||
if getattr(chapter, "cover_asset_id", None):
|
||||
return False
|
||||
md = (chapter.canonical_markdown or "").strip()
|
||||
body = md or ""
|
||||
if not body.strip():
|
||||
return False
|
||||
body = strip_image_placeholders(body).strip()
|
||||
view = effective_chapter_markdown_for_cover_gates(chapter)
|
||||
body = strip_image_placeholders(view).strip()
|
||||
if not body:
|
||||
return False
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(chapter):
|
||||
if not chapter_eligible_for_cover_by_inline_body_image_count(
|
||||
chapter, markdown=view
|
||||
):
|
||||
return False
|
||||
cover_rec = primary_chapter_memoir_image(chapter)
|
||||
if cover_rec and (cover_rec.status or "").strip() == "completed":
|
||||
@@ -58,7 +58,7 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
|
||||
|
||||
|
||||
def _chapter_eligible_for_pipeline_enqueue(chapter: Chapter | None) -> bool:
|
||||
"""尚无 cover_asset、正文插图数 > 3(与 HTTP 闸门共用 chapter_needs_cover_enqueue 核心)。"""
|
||||
"""尚无 cover_asset、正文插图数达 MEMOIR_MIN_INLINE_IMAGES_FOR_CHAPTER_COVER(与 HTTP 闸门共用)。"""
|
||||
return bool(chapter_needs_cover_enqueue(chapter))
|
||||
|
||||
|
||||
|
||||
57
api/tests/test_chapter_cover_fallback_inline.py
Normal file
57
api/tests/test_chapter_cover_fallback_inline.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""章节封面:无 cover_asset_id 时用正文首张 asset:// 作列表封面。"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.features.memoir.helpers import chapter_cover_to_dict
|
||||
|
||||
|
||||
def test_cover_falls_back_to_first_inline_asset_url() -> None:
|
||||
ch = MagicMock(spec=[])
|
||||
ch.canonical_markdown = "正文\n\n"
|
||||
ch.cover_asset_id = None
|
||||
ch.story_links = []
|
||||
ch.images = []
|
||||
m = chapter_cover_to_dict(
|
||||
ch,
|
||||
asset_url_map={"img-1": "https://cos.example.com/signed-1"},
|
||||
markdown_for_response=None,
|
||||
)
|
||||
assert m is not None
|
||||
assert m["url"] == "https://cos.example.com/signed-1"
|
||||
assert m["status"] == "completed"
|
||||
|
||||
|
||||
def test_cover_from_reading_segments_when_canonical_has_no_asset() -> None:
|
||||
"""分段快照里有 asset://,章节 canonical 未带图时仍能出封面 URL。"""
|
||||
ch = MagicMock(spec=[])
|
||||
ch.canonical_markdown = "只有文字没有图" * 20
|
||||
ch.cover_asset_id = None
|
||||
ch.story_links = []
|
||||
ch.images = []
|
||||
ch.reading_segments_json = [
|
||||
{"story_id": "s1", "body_markdown": ""}
|
||||
]
|
||||
m = chapter_cover_to_dict(
|
||||
ch,
|
||||
asset_url_map={"seg-1": "https://cos.example.com/seg"},
|
||||
markdown_for_response=None,
|
||||
)
|
||||
assert m is not None
|
||||
assert m["url"] == "https://cos.example.com/seg"
|
||||
|
||||
|
||||
def test_cover_prefers_cover_asset_id_over_inline() -> None:
|
||||
ch = MagicMock(spec=[])
|
||||
ch.canonical_markdown = ""
|
||||
ch.cover_asset_id = "cover-99"
|
||||
ch.story_links = []
|
||||
ch.images = []
|
||||
m = chapter_cover_to_dict(
|
||||
ch,
|
||||
asset_url_map={
|
||||
"inline-1": "https://cos.example.com/inline",
|
||||
"cover-99": "https://cos.example.com/cover",
|
||||
},
|
||||
markdown_for_response=None,
|
||||
)
|
||||
assert m["url"] == "https://cos.example.com/cover"
|
||||
54
api/tests/test_cover_eligibility_effective_markdown.py
Normal file
54
api/tests/test_cover_eligibility_effective_markdown.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""封面闸门:canonical 未落库时须用物化正文计数 asset://。"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.features.memoir.cover_eligibility import (
|
||||
chapter_eligible_for_cover_by_inline_body_image_count,
|
||||
chapter_needs_cover_enqueue,
|
||||
count_chapter_inline_body_images,
|
||||
effective_chapter_markdown_for_cover_gates,
|
||||
)
|
||||
|
||||
|
||||
def test_effective_markdown_falls_back_to_materialize_when_canonical_empty() -> None:
|
||||
ch = MagicMock()
|
||||
ch.canonical_markdown = ""
|
||||
ch.story_links = [MagicMock()]
|
||||
with patch(
|
||||
"app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter",
|
||||
return_value="正文\n\n",
|
||||
):
|
||||
assert "asset://" in effective_chapter_markdown_for_cover_gates(ch)
|
||||
|
||||
|
||||
def test_count_uses_effective_when_canonical_empty() -> None:
|
||||
ch = MagicMock()
|
||||
ch.canonical_markdown = ""
|
||||
ch.story_links = [MagicMock()]
|
||||
with patch(
|
||||
"app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter",
|
||||
return_value="",
|
||||
):
|
||||
assert count_chapter_inline_body_images(ch) == 1
|
||||
|
||||
|
||||
def test_eligible_with_explicit_markdown_override() -> None:
|
||||
ch = MagicMock()
|
||||
ch.canonical_markdown = ""
|
||||
assert chapter_eligible_for_cover_by_inline_body_image_count(
|
||||
ch, markdown=""
|
||||
)
|
||||
|
||||
|
||||
def test_needs_cover_enqueue_uses_materialized_body() -> None:
|
||||
ch = MagicMock()
|
||||
ch.canonical_markdown = ""
|
||||
ch.cover_asset_id = None
|
||||
ch.story_links = [MagicMock(story=MagicMock())]
|
||||
link = ch.story_links[0]
|
||||
link.story = MagicMock()
|
||||
with patch(
|
||||
"app.features.memoir.chapter_markdown_compose.materialize_chapter_markdown_from_loaded_chapter",
|
||||
return_value="故事\n\n",
|
||||
):
|
||||
assert chapter_needs_cover_enqueue(ch) is True
|
||||
@@ -3,6 +3,7 @@
|
||||
from app.agents.chat.reply_limits import (
|
||||
nonempty_segments_or_fallback,
|
||||
segments_from_llm_response,
|
||||
strip_leading_en_period_ack_for_chat,
|
||||
strip_markdown_for_chat,
|
||||
strip_parenthetical_asides_for_chat,
|
||||
)
|
||||
@@ -58,3 +59,15 @@ def test_segments_strip_parentheticals_before_split():
|
||||
|
||||
def test_strip_parenthetical_multiple_passes():
|
||||
assert strip_parenthetical_asides_for_chat("a(一)b(二)c") == "abc"
|
||||
|
||||
|
||||
def test_strip_leading_en_period_ack():
|
||||
assert strip_leading_en_period_ack_for_chat("嗯。后面正文") == "后面正文"
|
||||
assert strip_leading_en_period_ack_for_chat("嗯嗯。后面") == "后面"
|
||||
assert strip_leading_en_period_ack_for_chat(" 嗯。 第二句") == "第二句"
|
||||
assert strip_leading_en_period_ack_for_chat("句中嗯。不打头") == "句中嗯。不打头"
|
||||
|
||||
|
||||
def test_segments_strip_leading_en_ack():
|
||||
assert segments_from_llm_response("嗯。只有一句", max_segments=3) == ["只有一句"]
|
||||
assert segments_from_llm_response("嗯。A[SPLIT]嗯。B", max_segments=3) == ["A", "B"]
|
||||
|
||||
@@ -18,8 +18,13 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import {
|
||||
getScreenHeaderLayoutMetrics,
|
||||
ScreenHeader,
|
||||
} from '@/components/screen-header';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
import {
|
||||
MarkdownRenderer,
|
||||
ReadingMarkdownHorizontalRuleInColumn,
|
||||
@@ -353,6 +358,8 @@ function ReadingSettingsModal({
|
||||
export default function ChapterScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { largeText } = useAppSettings();
|
||||
const typography = useTypography();
|
||||
const { width } = useWindowDimensions();
|
||||
const { t } = useTranslation('memoir');
|
||||
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
||||
@@ -429,8 +436,13 @@ export default function ChapterScreen() {
|
||||
const useReadingSegments =
|
||||
Array.isArray(readingSegments) && readingSegments.length > 0;
|
||||
|
||||
/** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */
|
||||
const headerOccupiedHeight = Math.max(insets.top, 12) + 56;
|
||||
/** 与 ScreenHeader(reading、useSafeArea)实际总高度一致,避免章节标题被顶栏或安全区遮挡 */
|
||||
const headerOccupiedHeight = getScreenHeaderLayoutMetrics(insets, {
|
||||
useSafeArea: true,
|
||||
variant: 'reading',
|
||||
largeText,
|
||||
typography,
|
||||
}).totalHeight;
|
||||
|
||||
const handleDeletePress = () => {
|
||||
Alert.alert(
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useTypography } from '@/core/typography-context';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
import type { TypographyTokens } from '@/core/typography-context';
|
||||
|
||||
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
||||
const BACK_HIT_MIN = 48;
|
||||
const BACK_HIT_MIN_LARGE = 56;
|
||||
@@ -18,6 +20,52 @@ const BACK_EXTRA_HIT_LEFT = 4;
|
||||
|
||||
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
||||
|
||||
export type ScreenHeaderLayoutOpts = {
|
||||
useSafeArea: boolean;
|
||||
variant: ScreenHeaderVariant;
|
||||
largeText: boolean;
|
||||
typography: TypographyTokens;
|
||||
};
|
||||
|
||||
/**
|
||||
* 与组件内布局一致的总高度(含顶部安全区内边距),供绝对定位顶栏下的 ScrollView paddingTop 等使用。
|
||||
*/
|
||||
export function getScreenHeaderLayoutMetrics(
|
||||
insets: { top: number },
|
||||
opts: ScreenHeaderLayoutOpts,
|
||||
) {
|
||||
const barPaddingBottom = opts.largeText ? 18 : 16;
|
||||
const titleRowPaddingV = opts.largeText ? 8 : 4;
|
||||
const backTouchMin = opts.largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
|
||||
const { variant, typography, largeText } = opts;
|
||||
const titleFontSize =
|
||||
variant === 'chat'
|
||||
? largeText
|
||||
? typography.headingMedium
|
||||
: typography.headingSmall
|
||||
: Math.max(typography.titleLarge, typography.headingSmall);
|
||||
const titleLineMin =
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
|
||||
(largeText ? 14 : 10)
|
||||
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
|
||||
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
|
||||
const titleRowOuterHeight = titleRowMinHeight + 2 * titleRowPaddingV;
|
||||
const paddingTop = opts.useSafeArea ? Math.max(insets.top, 12) : 12;
|
||||
const totalHeight = paddingTop + titleRowOuterHeight + barPaddingBottom;
|
||||
return {
|
||||
barPaddingBottom,
|
||||
titleRowPaddingV,
|
||||
titleFontSize,
|
||||
titleLineMin,
|
||||
titleRowMinHeight,
|
||||
titleRowOuterHeight,
|
||||
paddingTop,
|
||||
totalHeight,
|
||||
backTouchMin,
|
||||
};
|
||||
}
|
||||
|
||||
const VARIANT_COLORS = {
|
||||
default: {
|
||||
title: undefined, // use theme foreground
|
||||
@@ -76,7 +124,6 @@ export function ScreenHeader({
|
||||
const typography = useTypography();
|
||||
const colors = VARIANT_COLORS[variant];
|
||||
|
||||
const backTouchMin = largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
|
||||
const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE;
|
||||
|
||||
const handleBack = onBack ?? (() => router.back());
|
||||
@@ -88,27 +135,26 @@ export function ScreenHeader({
|
||||
* 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。
|
||||
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。
|
||||
*/
|
||||
const barPaddingBottom = largeText ? 18 : 16;
|
||||
const titleRowPaddingV = largeText ? 8 : 4;
|
||||
const titleFontSize =
|
||||
variant === 'chat'
|
||||
? largeText
|
||||
? typography.headingMedium
|
||||
: typography.headingSmall
|
||||
: Math.max(typography.titleLarge, typography.headingSmall);
|
||||
const titleLineMin =
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
|
||||
(largeText ? 14 : 10)
|
||||
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
|
||||
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
|
||||
const {
|
||||
barPaddingBottom,
|
||||
titleRowPaddingV,
|
||||
titleFontSize,
|
||||
titleRowMinHeight,
|
||||
paddingTop: headerPaddingTop,
|
||||
backTouchMin,
|
||||
} = getScreenHeaderLayoutMetrics(insets, {
|
||||
useSafeArea,
|
||||
variant,
|
||||
largeText,
|
||||
typography,
|
||||
});
|
||||
|
||||
const containerStyle = {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
paddingHorizontal: Math.max(ScreenGutter, 16),
|
||||
paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12,
|
||||
paddingTop: headerPaddingTop,
|
||||
paddingBottom: barPaddingBottom,
|
||||
...(bgColor && { backgroundColor: bgColor }),
|
||||
...(absolute && {
|
||||
|
||||
Reference in New Issue
Block a user