Fix dynamic memoir image limits

This commit is contained in:
Kevin
2026-03-11 14:07:02 +08:00
parent 822aefe54b
commit f7d95c4c9a
5 changed files with 214 additions and 17 deletions

View File

@@ -23,7 +23,7 @@ def parse_image_placeholders(content: str, max_images: int) -> list[dict[str, An
"start_offset": match.start(),
}
)
if len(items) >= max_images:
if max_images is not None and len(items) >= max_images:
break
return items

View File

@@ -2,32 +2,52 @@ import os
from dataclasses import dataclass
DEFAULT_LIBLIB_TEMPLATE_UUID = "5d7e67009b344550bc1aa6ccbfa1d7f4"
DEFAULT_MAX_IMAGES_PER_CHAPTER = 2
DEFAULT_CHARS_PER_EXTRA_IMAGE = 1500
DEFAULT_MAX_IMAGES_CAP = 8
DEFAULT_IMAGE_PROVIDER = "liblib"
DEFAULT_IMAGE_STYLE = "watercolor"
DEFAULT_IMAGE_SIZE = "1280x720"
DEFAULT_POLL_INTERVAL_SECONDS = 3
DEFAULT_MAX_ATTEMPTS = 60
@dataclass(frozen=True)
class MemoirImageSettings:
enabled: bool
max_per_chapter: int
provider: str
default_style: str
default_size: str
poll_interval_seconds: int
max_attempts: int
liblib_template_uuid: str
enabled: bool = False
max_per_chapter: int = DEFAULT_MAX_IMAGES_PER_CHAPTER
chars_per_extra_image: int = DEFAULT_CHARS_PER_EXTRA_IMAGE
max_images_cap: int = DEFAULT_MAX_IMAGES_CAP
provider: str = DEFAULT_IMAGE_PROVIDER
default_style: str = DEFAULT_IMAGE_STYLE
default_size: str = DEFAULT_IMAGE_SIZE
poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS
max_attempts: int = DEFAULT_MAX_ATTEMPTS
liblib_template_uuid: str = DEFAULT_LIBLIB_TEMPLATE_UUID
@classmethod
def from_env(cls) -> "MemoirImageSettings":
return cls(
enabled=os.getenv("MEMOIR_IMAGE_ENABLED", "").lower() in {"1", "true", "yes"},
max_per_chapter=_get_int_env("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", "1280x720"),
poll_interval_seconds=_get_int_env("MEMOIR_IMAGE_POLL_INTERVAL", 3),
max_attempts=_get_int_env("MEMOIR_IMAGE_MAX_ATTEMPTS", 60),
max_per_chapter=_get_int_env("MEMOIR_IMAGE_MAX_PER_CHAPTER", DEFAULT_MAX_IMAGES_PER_CHAPTER),
chars_per_extra_image=_get_int_env("MEMOIR_IMAGE_CHARS_PER_EXTRA", DEFAULT_CHARS_PER_EXTRA_IMAGE),
max_images_cap=_get_int_env("MEMOIR_IMAGE_MAX_CAP", DEFAULT_MAX_IMAGES_CAP),
provider=os.getenv("MEMOIR_IMAGE_PROVIDER", DEFAULT_IMAGE_PROVIDER),
default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", DEFAULT_IMAGE_STYLE),
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", DEFAULT_IMAGE_SIZE),
poll_interval_seconds=_get_int_env("MEMOIR_IMAGE_POLL_INTERVAL", DEFAULT_POLL_INTERVAL_SECONDS),
max_attempts=_get_int_env("MEMOIR_IMAGE_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS),
liblib_template_uuid=os.getenv("LIBLIB_TEMPLATE_UUID") or DEFAULT_LIBLIB_TEMPLATE_UUID,
)
def effective_max_images(self, content_length: int) -> int:
"""根据正文字数动态计算单章允许的最大图片数。"""
base_max = max(self.max_per_chapter, 0)
effective_cap = max(self.max_images_cap, base_max)
safe_length = max(content_length, 0)
extra = safe_length // self.chars_per_extra_image if self.chars_per_extra_image > 0 else 0
return min(base_max + extra, effective_cap)
def _get_int_env(name: str, default: int) -> int:
value = os.getenv(name, str(default))

View File

@@ -168,6 +168,34 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool:
)
def _select_placeholders_for_effective_max(
placeholders: list[dict],
existing_images: list[dict] | None,
effective_max: int,
) -> list[dict]:
existing_placeholders = {
item.get("placeholder")
for item in normalize_image_assets(existing_images)
if item.get("placeholder")
}
existing_count_in_content = sum(
1 for item in placeholders if item.get("placeholder") in existing_placeholders
)
remaining_new_slots = max(0, effective_max - existing_count_in_content)
selected: list[dict] = []
for item in placeholders:
if item.get("placeholder") in existing_placeholders:
selected.append(item)
continue
if remaining_new_slots <= 0:
continue
selected.append(item)
remaining_new_slots -= 1
return [{**item, "index": index} for index, item in enumerate(selected)]
def initialize_chapter_images(chapter) -> list[dict]:
"""Parse IMAGE placeholders from chapter content and build pending image assets."""
settings = MemoirImageSettings.from_env()
@@ -177,7 +205,13 @@ def initialize_chapter_images(chapter) -> list[dict]:
return chapter.images
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
placeholders = parse_image_placeholders(chapter.content, settings.max_per_chapter)
effective_max = settings.effective_max_images(len(chapter.content or ""))
all_placeholders = parse_image_placeholders(chapter.content, max_images=None)
placeholders = _select_placeholders_for_effective_max(
placeholders=all_placeholders,
existing_images=chapter.images,
effective_max=effective_max,
)
style = prompt_service.CATEGORY_STYLE_MAP.get(chapter.category, settings.default_style)
chapter.images = _merge_chapter_image_assets(
existing_images=chapter.images,
@@ -188,8 +222,10 @@ def initialize_chapter_images(chapter) -> list[dict]:
now_iso=datetime.now(timezone.utc).isoformat(),
)
logger.info(
"章节图片初始化完成: chapter=%s, placeholders=%d, images=%d, statuses=%s",
"章节图片初始化完成: chapter=%s, effective_max=%d, total_placeholders=%d, selected_placeholders=%d, images=%d, statuses=%s",
chapter.id,
effective_max,
len(all_placeholders),
len(placeholders),
len(chapter.images or []),
[item.get("status") for item in (chapter.images or [])],

View File

@@ -149,3 +149,135 @@ class MemoirImageBootstrapTest(unittest.TestCase):
self.assertEqual(len(assets), 1)
self.assertEqual(assets[0]["status"], "failed")
self.assertEqual(assets[0]["error"], "invalid image status: mystery")
def test_initialize_chapter_images_preserves_existing_completed_assets_beyond_effective_max(self):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"category": "childhood",
"content": (
"{{IMAGE:南方小镇的青石板路}}\n"
"{{IMAGE:奶奶坐在院子里的藤椅上}}\n"
"{{IMAGE:门前的老槐树}}"
),
"images": [
{
"index": 0,
"placeholder": "{{IMAGE:南方小镇的青石板路}}",
"description": "南方小镇的青石板路",
"status": "completed",
"url": "https://cos.example.com/1.png",
},
{
"index": 1,
"placeholder": "{{IMAGE:奶奶坐在院子里的藤椅上}}",
"description": "奶奶坐在院子里的藤椅上",
"status": "completed",
"url": "https://cos.example.com/2.png",
},
{
"index": 2,
"placeholder": "{{IMAGE:门前的老槐树}}",
"description": "门前的老槐树",
"status": "completed",
"url": "https://cos.example.com/3.png",
},
],
},
)()
with unittest.mock.patch.dict(
os.environ,
{
"MEMOIR_IMAGE_ENABLED": "true",
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "99999",
"MEMOIR_IMAGE_MAX_CAP": "8",
},
clear=False,
):
assets = initialize_chapter_images(chapter)
self.assertEqual(len(assets), 3)
self.assertEqual(
[asset["placeholder"] for asset in assets],
[
"{{IMAGE:南方小镇的青石板路}}",
"{{IMAGE:奶奶坐在院子里的藤椅上}}",
"{{IMAGE:门前的老槐树}}",
],
)
self.assertTrue(all(asset["status"] == "completed" for asset in assets))
def test_initialize_chapter_images_increases_limit_for_long_content(self):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"category": "childhood",
"content": (
("很长的正文" * 800)
+ "\n{{IMAGE:南方小镇的青石板路}}"
+ "\n{{IMAGE:奶奶坐在院子里的藤椅上}}"
+ "\n{{IMAGE:门前的老槐树}}"
+ "\n{{IMAGE:夏夜的晒谷场}}"
),
"images": [],
},
)()
with unittest.mock.patch.dict(
os.environ,
{
"MEMOIR_IMAGE_ENABLED": "true",
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000",
"MEMOIR_IMAGE_MAX_CAP": "8",
},
clear=False,
):
assets = initialize_chapter_images(chapter)
self.assertEqual(len(assets), 4)
self.assertTrue(all(asset["status"] == "pending" for asset in assets))
def test_initialize_chapter_images_caps_dynamic_limit_at_max_images_cap(self):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"category": "childhood",
"content": (
("很长的正文" * 1600)
+ "\n{{IMAGE:图1}}"
+ "\n{{IMAGE:图2}}"
+ "\n{{IMAGE:图3}}"
+ "\n{{IMAGE:图4}}"
+ "\n{{IMAGE:图5}}"
+ "\n{{IMAGE:图6}}"
),
"images": [],
},
)()
with unittest.mock.patch.dict(
os.environ,
{
"MEMOIR_IMAGE_ENABLED": "true",
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "2",
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "1000",
"MEMOIR_IMAGE_MAX_CAP": "4",
},
clear=False,
):
assets = initialize_chapter_images(chapter)
self.assertEqual(len(assets), 4)
self.assertEqual([asset["description"] for asset in assets], ["图1", "图2", "图3", "图4"])

View File

@@ -13,6 +13,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
os.environ,
{
"MEMOIR_IMAGE_MAX_PER_CHAPTER": "not-an-int",
"MEMOIR_IMAGE_CHARS_PER_EXTRA": "bad-extra",
"MEMOIR_IMAGE_MAX_CAP": "bad-cap",
"MEMOIR_IMAGE_POLL_INTERVAL": "bad",
"MEMOIR_IMAGE_MAX_ATTEMPTS": "oops",
},
@@ -22,6 +24,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
settings = MemoirImageSettings.from_env()
self.assertEqual(settings.max_per_chapter, 2)
self.assertEqual(settings.chars_per_extra_image, 1500)
self.assertEqual(settings.max_images_cap, 8)
self.assertEqual(settings.poll_interval_seconds, 3)
self.assertEqual(settings.max_attempts, 60)
@@ -31,3 +35,8 @@ class MemoirImageSettingsTest(unittest.TestCase):
settings = MemoirImageSettings.from_env()
self.assertEqual(settings.liblib_template_uuid, DEFAULT_LIBLIB_TEMPLATE_UUID)
def test_effective_max_images_never_drops_below_base_max_per_chapter(self):
settings = MemoirImageSettings(enabled=True, max_per_chapter=2, max_images_cap=1)
self.assertEqual(settings.effective_max_images(0), 2)