Fix memoir image delivery and Android rendering

This commit is contained in:
Kevin
2026-03-11 10:06:12 +08:00
parent 0970cb7408
commit a76cf8da18
23 changed files with 537 additions and 51 deletions

View File

@@ -43,7 +43,6 @@ STAGE_TO_ORDER = {
"summary": 7,
}
def get_system_prompt() -> str:
"""获取整理 Agent 的系统提示词"""
return """你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。
@@ -323,4 +322,3 @@ def get_narrative_prompt(
只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。
"""

View File

@@ -1,6 +1,8 @@
"""
章节相关 API 路由
"""
import logging
import os
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -12,11 +14,48 @@ from database.models import Chapter as ChapterModel
from database.models import User as UserModel
from middleware.auth import get_current_user
from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER
from services.memoir_images.storage import (
TencentCosStorageService,
normalize_cos_url,
resolve_image_storage_key,
)
router = APIRouter(prefix="/api/chapters", tags=["chapters"])
logger = logging.getLogger(__name__)
def _normalize_image_assets(images: list[dict] | None) -> list[dict]:
bucket = os.getenv("TENCENT_COS_BUCKET", "")
region = os.getenv("TENCENT_COS_REGION", "")
base_url = os.getenv("TENCENT_COS_BASE_URL", "")
storage = TencentCosStorageService.from_env()
normalized_assets: list[dict] = []
for item in (images or []):
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") == "completed" and storage_key:
try:
asset["url"] = storage.get_download_url(storage_key)
except Exception as exc:
logger.warning("章节图片签名失败: key=%s, error=%s", storage_key, exc)
asset["url"] = normalized_url
else:
asset["url"] = normalized_url
asset.pop("storage_key", None)
normalized_assets.append(asset)
return normalized_assets
def _chapter_to_dict(ch: ChapterModel) -> dict:
normalized_images = _normalize_image_assets(ch.images)
return {
"id": ch.id,
"title": ch.title,
@@ -24,7 +63,7 @@ def _chapter_to_dict(ch: ChapterModel) -> dict:
"order_index": ch.order_index,
"status": ch.status,
"category": ch.category,
"images": ch.images or [],
"images": normalized_images,
"updated_at": ch.updated_at.isoformat() if ch.updated_at else None,
"is_new": ch.is_new,
"source_segments": ch.source_segments or [],
@@ -105,7 +144,7 @@ async def get_chapter(
"order_index": chapter.order_index,
"status": chapter.status,
"category": chapter.category,
"images": chapter.images or [],
"images": _normalize_image_assets(chapter.images),
"updated_at": chapter.updated_at.isoformat() if chapter.updated_at else None,
"is_new": chapter.is_new,
"source_segments": chapter.source_segments or [],
@@ -151,4 +190,3 @@ async def regenerate_chapter(
# TODO: 实现重新整理逻辑
return {"status": "ok", "message": "Chapter regeneration triggered"}

View File

@@ -1,13 +1,16 @@
import re
from typing import Any
PLACEHOLDER_RE = re.compile(r"\{\{\{\{IMAGE:(.*?)\}\}\}\}")
PLACEHOLDER_RE = re.compile(
r"\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}",
re.DOTALL,
)
def parse_image_placeholders(content: str, max_images: int) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for match in PLACEHOLDER_RE.finditer(content or ""):
description = match.group(1).strip()
description = (match.group(1) or match.group(2) or "").strip()
if not description:
continue
items.append(

View File

@@ -20,7 +20,7 @@ class MemoirImageSettings:
max_per_chapter=int(os.getenv("MEMOIR_IMAGE_MAX_PER_CHAPTER", "2")),
provider=os.getenv("MEMOIR_IMAGE_PROVIDER", "liblib"),
default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", "watercolor"),
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1024x1024"),
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1280x720"),
poll_interval_seconds=int(os.getenv("MEMOIR_IMAGE_POLL_INTERVAL", "3")),
max_attempts=int(os.getenv("MEMOIR_IMAGE_MAX_ATTEMPTS", "60")),
liblib_template_uuid=os.getenv(

View File

@@ -1,12 +1,75 @@
import os
from urllib.parse import urlparse, urlunparse
from qcloud_cos import CosConfig, CosS3Client
def normalize_cos_base_url(base_url: str, bucket: str, region: str) -> str:
candidate = (base_url or "").rstrip("/")
if not candidate and bucket and region:
candidate = f"https://{bucket}.cos.{region}.myqcloud.com"
if not candidate:
return ""
parsed = urlparse(candidate)
duplicated_appid_host = f"{bucket}-appid.cos." if bucket else ""
if duplicated_appid_host and parsed.netloc.startswith(duplicated_appid_host):
parsed = parsed._replace(
netloc=parsed.netloc.replace(duplicated_appid_host, f"{bucket}.cos.", 1),
path=parsed.path.rstrip("/"),
)
return urlunparse(parsed).rstrip("/")
return candidate
def normalize_cos_url(url: str | None, bucket: str, region: str, base_url: str | None = None) -> str | None:
if not url:
return url
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return url
normalized_base = normalize_cos_base_url(
base_url or f"{parsed.scheme}://{parsed.netloc}",
bucket=bucket,
region=region,
)
if not normalized_base:
return url
normalized_parsed = urlparse(normalized_base)
return urlunparse(parsed._replace(scheme=normalized_parsed.scheme, netloc=normalized_parsed.netloc))
def resolve_image_storage_key(image: dict | None) -> str | None:
if not image:
return None
explicit_key = image.get("storage_key")
if explicit_key:
return explicit_key
url = image.get("url")
if not url:
return None
parsed = urlparse(url)
if parsed.scheme and parsed.netloc:
key = parsed.path.lstrip("/")
return key or None
return str(url).lstrip("/") or None
class TencentCosStorageService:
def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str, base_url: str):
self.secret_id = secret_id
self.secret_key = secret_key
self.bucket = bucket
self.base_url = base_url.rstrip("/")
self.region = region
self.base_url = normalize_cos_base_url(base_url, bucket=bucket, region=region)
config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
self.client = CosS3Client(config)
@@ -19,6 +82,13 @@ class TencentCosStorageService:
)
return f"{self.base_url}/{key}"
def get_download_url(self, key: str, expires: int = 3600) -> str:
return self.client.get_presigned_download_url(
Bucket=self.bucket,
Key=key,
Expired=expires,
)
@classmethod
def from_env(cls) -> "TencentCosStorageService":
return cls(

View File

@@ -13,6 +13,7 @@ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak,
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from io import BytesIO
from services.memoir_images.storage import TencentCosStorageService, resolve_image_storage_key
logger = logging.getLogger(__name__)
@@ -42,6 +43,23 @@ def split_content_blocks(content: str, images: list[dict]) -> list[dict]:
return blocks
def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]:
storage = TencentCosStorageService.from_env()
prepared_assets: list[dict] = []
for item in images or []:
asset = dict(item)
storage_key = resolve_image_storage_key(asset)
if asset.get("status") == "completed" and storage_key:
try:
asset["url"] = storage.get_download_url(storage_key)
except Exception as exc:
logger.warning(f"PDF 图片签名失败: key={storage_key}, error={exc}")
prepared_assets.append(asset)
return prepared_assets
class PDFService:
"""PDF 生成服务"""
@@ -108,7 +126,7 @@ class PDFService:
story.append(Paragraph(chapter.title, heading_style))
story.append(Spacer(1, 0.2 * inch))
images = getattr(chapter, "images", None) or []
images = _prepare_pdf_image_assets(getattr(chapter, "images", None) or [])
blocks = split_content_blocks(chapter.content, images)
for block in blocks:

View File

@@ -106,7 +106,9 @@ def _merge_chapter_image_assets(
merged_item["size"] = merged_item.get("size") or size
merged_item["created_at"] = merged_item.get("created_at") or now_iso
merged_item["updated_at"] = merged_item.get("updated_at") or now_iso
if merged_item.get("status") == "completed" and not merged_item.get("url"):
if merged_item.get("status") == "completed" and not (
merged_item.get("storage_key") or merged_item.get("url")
):
merged_item["status"] = "failed"
merged_item["error"] = merged_item.get("error") or "missing image url"
else:
@@ -131,6 +133,7 @@ def initialize_chapter_images(chapter) -> list[dict]:
settings = MemoirImageSettings.from_env()
if not settings.enabled:
chapter.images = []
logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false")
return chapter.images
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
@@ -144,6 +147,13 @@ def initialize_chapter_images(chapter) -> list[dict]:
size=settings.default_size,
now_iso=datetime.now(timezone.utc).isoformat(),
)
logger.info(
"章节图片初始化完成: chapter=%s, placeholders=%d, images=%d, statuses=%s",
chapter.id,
len(placeholders),
len(chapter.images or []),
[item.get("status") for item in (chapter.images or [])],
)
return chapter.images
@@ -479,6 +489,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
for chapter_id in sorted(chapters_to_enqueue):
try:
logger.info(f"派发章节补图任务: chapter={chapter_id}")
generate_chapter_images.delay(chapter_id)
except Exception as exc:
logger.warning(f"补图任务派发失败: chapter={chapter_id}, error={exc}")
@@ -599,6 +610,7 @@ def generate_chapter_images(self, chapter_id: str):
try:
chapter = db.get(Chapter, chapter_id)
if not chapter or not chapter.images:
logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images")
return {"status": "no_images"}
settings = MemoirImageSettings.from_env()
@@ -606,9 +618,16 @@ def generate_chapter_images(self, chapter_id: str):
provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid)
storage = TencentCosStorageService.from_env()
images = [dict(item) for item in (chapter.images or [])]
pending_count = sum(1 for item in images if item.get("status") in {"pending", "failed"})
logger.info(
"章节补图开始: chapter=%s, total_images=%d, pending_images=%d",
chapter_id,
len(images),
pending_count,
)
for index, item in enumerate(images):
if item.get("status") == "completed" and item.get("url"):
if item.get("status") == "completed" and (item.get("storage_key") or item.get("url")):
continue
if item.get("status") not in {"pending", "failed"}:
continue
@@ -643,12 +662,19 @@ def generate_chapter_images(self, chapter_id: str):
)
image_bytes = provider.download_image(job)
key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"])
current_item["storage_key"] = key
current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png")
current_item["prompt"] = prompt_data["prompt"]
current_item["style"] = prompt_data["style"]
current_item["size"] = prompt_data["size"]
current_item["status"] = "completed"
current_item["error"] = None
logger.info(
"章节补图成功: chapter=%s, index=%s, url=%s",
chapter_id,
current_item.get("index"),
current_item["url"],
)
except Exception as exc:
current_item["status"] = "failed"
current_item["error"] = str(exc)

View File

@@ -0,0 +1,58 @@
import os
import unittest
from unittest.mock import Mock, patch
from api.routers.chapters import _chapter_to_dict
class ChaptersRouterImagesTest(unittest.TestCase):
@patch("api.routers.chapters.TencentCosStorageService")
@patch.dict(
os.environ,
{
"TENCENT_COS_BUCKET": "life-echo-dev-1319381411",
"TENCENT_COS_REGION": "ap-shanghai",
"TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
},
clear=False,
)
def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls):
storage = Mock()
storage.get_download_url.return_value = "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123"
storage_cls.from_env.return_value = storage
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"content": "{{IMAGE:南方小镇的青石板路}}",
"order_index": 0,
"status": "completed",
"category": "childhood",
"images": [
{
"index": 0,
"placeholder": "{{IMAGE:南方小镇的青石板路}}",
"description": "南方小镇的青石板路",
"status": "completed",
"prompt": "A serene southern China town",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
"storage_key": "memoirs/u1/c1/0-demo.png",
}
],
"updated_at": None,
"is_new": False,
"source_segments": [],
},
)()
payload = _chapter_to_dict(chapter)
self.assertEqual(
payload["images"][0]["url"],
"https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123",
)
self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town")
self.assertNotIn("storage_key", payload["images"][0])

View File

@@ -58,6 +58,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
generate_chapter_images.run("chapter-1")
self.assertEqual(chapter.images[0]["status"], "completed")
self.assertEqual(chapter.images[0]["storage_key"], "memoirs/user-1/chapter-1/0-7e1f860790.png")
self.assertEqual(chapter.images[0]["url"], "https://cos.example.com/memoirs/u1/c1/0.png")
self.assertEqual(chapter.images[0]["prompt"], "A serene southern China town")
db.commit.assert_called()

View File

@@ -66,3 +66,23 @@ class MemoirImageBootstrapTest(unittest.TestCase):
self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png")
self.assertEqual(assets[1]["status"], "pending")
self.assertEqual(assets[1]["description"], "奶奶坐在院子里的藤椅上")
def test_initialize_chapter_images_accepts_double_brace_placeholders(self):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"category": "childhood",
"content": "开头。\n\n{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}\n\n结尾。",
"images": [],
},
)()
with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False):
assets = initialize_chapter_images(chapter)
self.assertEqual(len(assets), 1)
self.assertEqual(assets[0]["status"], "pending")
self.assertEqual(assets[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}")

View File

@@ -43,3 +43,12 @@ class MemoirImageParserTest(unittest.TestCase):
self.assertEqual(assets[0]["status"], "pending")
self.assertEqual(assets[0]["provider"], "liblib")
self.assertEqual(assets[0]["url"], None)
def test_parse_image_placeholders_accepts_double_brace_variant(self):
content = "开头。\n\n{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}\n\n结尾。"
items = parse_image_placeholders(content, max_images=2)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}")
self.assertEqual(items[0]["description"], "1938年初的上海弄堂口冬日萧瑟")

View File

@@ -15,6 +15,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
service = MemoirImagePromptService(llm=None, settings=settings)
@@ -39,6 +40,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
llm = Mock()
llm.invoke.return_value.content = (

View File

@@ -1,7 +1,11 @@
import unittest
from unittest.mock import Mock, patch
from api.services.memoir_images.storage import TencentCosStorageService
from api.services.memoir_images.storage import (
TencentCosStorageService,
normalize_cos_url,
resolve_image_storage_key,
)
class MemoirImageStorageTest(unittest.TestCase):
@@ -28,3 +32,80 @@ class MemoirImageStorageTest(unittest.TestCase):
"https://memoir-1250000000.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
client.put_object.assert_called_once()
@patch("api.services.memoir_images.storage.CosS3Client")
def test_upload_bytes_normalizes_duplicate_appid_suffix_in_base_url(self, client_cls):
client = Mock()
client_cls.return_value = client
storage = TencentCosStorageService(
secret_id="id",
secret_key="key",
region="ap-shanghai",
bucket="life-echo-dev-1319381411",
base_url="https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com",
)
url = storage.upload_bytes(
image_bytes=b"png-bytes",
key="memoirs/u1/c1/0-demo.png",
content_type="image/png",
)
self.assertEqual(
url,
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
client.put_object.assert_called_once()
def test_normalize_cos_url_repairs_existing_duplicate_appid_host(self):
normalized = normalize_cos_url(
"https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
bucket="life-echo-dev-1319381411",
region="ap-shanghai",
)
self.assertEqual(
normalized,
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
@patch("api.services.memoir_images.storage.CosS3Client")
def test_get_download_url_returns_presigned_download_url(self, client_cls):
client = Mock()
client.get_presigned_download_url.return_value = "https://cos.example.com/0.png?q-sign-algorithm=sha1"
client_cls.return_value = client
storage = TencentCosStorageService(
secret_id="id",
secret_key="key",
region="ap-shanghai",
bucket="life-echo-dev-1319381411",
base_url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
)
url = storage.get_download_url("memoirs/u1/c1/0-demo.png", expires=1800)
self.assertEqual(url, "https://cos.example.com/0.png?q-sign-algorithm=sha1")
client.get_presigned_download_url.assert_called_once_with(
Bucket="life-echo-dev-1319381411",
Key="memoirs/u1/c1/0-demo.png",
Expired=1800,
)
def test_resolve_image_storage_key_prefers_explicit_storage_key(self):
key = resolve_image_storage_key(
{
"storage_key": "memoirs/u1/c1/0-demo.png",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/other.png",
}
)
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")
def test_resolve_image_storage_key_derives_key_from_existing_url(self):
key = resolve_image_storage_key(
{
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1"
}
)
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")

View File

@@ -6,7 +6,12 @@ from api.services.pdf_service import PDFService
class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
@patch("api.services.pdf_service.httpx.AsyncClient")
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(self, async_client_cls):
@patch("api.services.pdf_service.TencentCosStorageService")
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(
self,
storage_cls,
async_client_cls,
):
png_bytes = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00"
@@ -20,6 +25,9 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
mock_client.get.return_value = mock_response
async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
storage = MagicMock()
storage.get_download_url.return_value = "https://signed.example.com/0.png?sig=123"
storage_cls.from_env.return_value = storage
service = PDFService()
book = type("BookStub", (), {"title": "我的回忆录"})()
@@ -33,7 +41,8 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"url": "https://cos.example.com/0.png",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png",
"storage_key": "memoirs/u1/c1/0.png",
"status": "completed",
}
],
@@ -44,3 +53,4 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
self.assertGreater(len(pdf_bytes), 100)
self.assertNotIn(b"IMAGE:", pdf_bytes)
mock_client.get.assert_called_once_with("https://signed.example.com/0.png?sig=123")

View File

@@ -35,6 +35,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
get_state_mock.return_value = SimpleNamespace(current_stage="childhood", slots={})