Files
life-echo/api/app/features/memoir/helpers.py
Kevin 53d9e003af 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
2026-04-01 11:55:52 +08:00

195 lines
7.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 resolve_asset_refs_in_markdown
from app.features.memoir.cover_eligibility import (
chapter_eligible_for_cover_by_inline_body_image_count,
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 chapter_cover_to_dict(
ch: Chapter, asset_url_map: dict[str, str] | None = None
) -> dict | None:
if not chapter_eligible_for_cover_by_inline_body_image_count(ch):
return None
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
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)
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)
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 [],
}