Files
life-echo/api/app/features/memoir/helpers.py
Kevin a3f61fcc0f feat(api+app): 对话阶段化、回忆录流水线与客户端会话体验
- DB: segments 用户输入文本(Alembic 0002)
- Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整
- Memoir: 忠实度检查 agent,叙事与分类等链路更新
- Core: agent 日志、Alembic 启动、LangChain/日志/配置等
- Story: time_hints;Memory 检索与相关测试
- Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n
- Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
2026-03-26 12:13:36 +08:00

183 lines
6.7 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:
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) -> str:
"""正文真源canonical_markdown。"""
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
) -> 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)
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) -> 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)
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 [],
}