Files
life-echo/api/app/features/memoir/helpers.py
yangshilin 9af2060259 fix:
1. 修复安卓部分机型顶部安全区遮挡回忆录标题的问题;
2. 降低封面图生成阈值和展示逻辑,独立封面图未生成时,使用正文图;
3. 去掉“嗯。”生硬回答,去掉不合理段首承接词;
4. 新增章节封面所需最少插图数的配置项
2026-04-16 20:42:54 +08:00

249 lines
9.1 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.
"""回忆录序列化与图片归一化辅助(供 MemoirService 使用)。"""
from app.core.config import settings
from app.core.logging import get_logger
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 (
IMAGE_STATUS_COMPLETED,
IMAGE_STATUS_FAILED,
completed_image_assets,
normalize_image_assets,
)
from app.features.memoir.memoir_images.serializers import memoir_image_to_dict
from app.features.memoir.memoir_images.settings import MemoirImageSettings
from app.features.memoir.memoir_images.storage import (
CosDownloadUrlError,
TencentCosStorageService,
mark_image_delivery_unavailable,
normalize_cos_url,
resolve_image_storage_key,
)
from app.features.memoir.models import Chapter
from app.features.memoir.reading_segment_materialize import (
resolve_reading_segments_for_chapter_detail,
)
logger = get_logger(__name__)
def first_normalized_image_for_api(img: dict | None) -> dict | None:
"""单条图片经 schema 归一化后仍可能为空(例如非法状态且无可用字段),勿直接 [0]。"""
if not img:
return None
out = normalize_image_assets_for_api([img])
return out[0] if out else None
def normalize_image_assets_for_api(images: list[dict] | None) -> list[dict]:
bucket = settings.tencent_cos_bucket or ""
region = settings.tencent_cos_region or ""
base_url = settings.tencent_cos_base_url or ""
storage = TencentCosStorageService.from_settings(settings)
img_settings = MemoirImageSettings.from_settings(settings)
source_assets = normalize_image_assets(images)
if not img_settings.enabled:
source_assets = completed_image_assets(source_assets)
normalized_assets: list[dict] = []
for item in source_assets:
asset = dict(item)
normalized_url = normalize_cos_url(
asset.get("url"), bucket=bucket, region=region, base_url=base_url
)
storage_key = resolve_image_storage_key(asset)
if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key:
try:
asset["url"] = storage.get_download_url(storage_key)
except CosDownloadUrlError as exc:
logger.warning(
"章节图片签名失败: key={}, retryable={}, error={}",
storage_key,
exc.retryable,
exc,
)
asset = mark_image_delivery_unavailable(asset)
except Exception as exc:
logger.warning("章节图片签名失败: key={}, error={}", storage_key, exc)
asset = mark_image_delivery_unavailable(asset)
else:
asset["url"] = normalized_url
asset.pop("storage_key", None)
normalized_assets.append(asset)
return normalized_assets
def is_image_permanently_unavailable(rec) -> bool:
if not rec:
return False
status = getattr(rec, "status", None) or ""
retryable = getattr(rec, "retryable", None)
url = getattr(rec, "url", None)
storage_key = getattr(rec, "storage_key", None)
if status == IMAGE_STATUS_FAILED and retryable is False:
return True
if status == IMAGE_STATUS_COMPLETED and not url and not storage_key:
return True
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,
*,
markdown_for_response: str | None = None,
) -> dict | None:
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)
return None
def _chapter_markdown(ch: Chapter, *, override: str | None = None) -> str:
"""正文:优先读路径临时物化串,否则 canonical_markdown。"""
if override is not None and str(override).strip():
return str(override).strip()
md = getattr(ch, "canonical_markdown", None)
if md and str(md).strip():
return str(md).strip()
return ""
def chapter_to_list_dict(
ch: Chapter,
asset_url_map: dict[str, str] | None = None,
*,
markdown_for_response: str | None = None,
) -> dict:
"""列表视图:与详情字段对齐的最小子集。"""
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
return {
"id": ch.id,
"title": ch.title,
"category": ch.category,
"order_index": ch.order_index,
"status": getattr(ch, "status", None) or "draft",
"summary": getattr(ch, "summary", None) or "",
"canonical_markdown": canonical_raw,
"cover_asset": cover_normalized,
"images": [],
"word_count": wcount,
"updated_at": ch.updated_at.isoformat() if ch.updated_at else None,
"is_new": getattr(ch, "is_new", False),
"source_segments": getattr(ch, "source_segments", None) or [],
}
def chapter_to_dict(
ch: Chapter,
asset_url_map: dict[str, str] | None = None,
*,
markdown_for_response: str | None = None,
) -> dict:
"""详情视图stories-first 契约。asset_url_map 用于解析 asset:// 与 cover_asset_id。"""
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, 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)
canonical_md = resolve_asset_refs_in_markdown(canonical_md, resolve)
reading_segments = resolve_reading_segments_for_chapter_detail(
ch, asset_url_map=asset_url_map
)
return {
"id": ch.id,
"title": ch.title,
"canonical_markdown": canonical_md,
"order_index": ch.order_index,
"status": ch.status,
"category": ch.category,
"images": [],
"cover_asset": cover_normalized,
"reading_segments": reading_segments,
"updated_at": ch.updated_at.isoformat() if ch.updated_at else None,
"is_new": ch.is_new,
"source_segments": ch.source_segments or [],
}