From f8283b398e6e91cfb46c61bb87a68edc3f14bdb0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 10 Mar 2026 16:00:25 +0800 Subject: [PATCH] feat(api): add memoir image prompt settings and optimization service Made-with: Cursor --- api/services/memoir_images/prompting.py | 63 ++++++++++++++++++++++++ api/services/memoir_images/settings.py | 25 ++++++++++ api/tests/test_memoir_image_prompting.py | 59 ++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 api/services/memoir_images/prompting.py create mode 100644 api/services/memoir_images/settings.py create mode 100644 api/tests/test_memoir_image_prompting.py diff --git a/api/services/memoir_images/prompting.py b/api/services/memoir_images/prompting.py new file mode 100644 index 0000000..c0c0472 --- /dev/null +++ b/api/services/memoir_images/prompting.py @@ -0,0 +1,63 @@ +import json +from typing import Any, Optional + +from .settings import MemoirImageSettings + + +class MemoirImagePromptService: + CATEGORY_STYLE_MAP = { + "childhood": "watercolor", + "family": "watercolor", + "career_early": "realistic", + "career_achievement": "realistic", + "career_challenge": "realistic", + "beliefs": "editorial illustration", + "summary": "editorial illustration", + } + + def __init__(self, llm: Optional[Any], settings: MemoirImageSettings): + self.llm = llm + self.settings = settings + + def build_prompt( + self, + chapter_title: str, + chapter_category: str, + description: str, + context_excerpt: str, + ) -> dict[str, str]: + style = self.CATEGORY_STYLE_MAP.get(chapter_category, self.settings.default_style) + prompt_context = f"{chapter_category}: {chapter_title}" + + llm_input = { + "chapter_title": chapter_title, + "chapter_category": chapter_category, + "description": description, + "context_excerpt": context_excerpt, + "default_style": style, + "default_size": self.settings.default_size, + } + + if self.llm: + try: + response = self.llm.invoke( + "Return JSON only with keys prompt, style, size. " + "Convert the memoir scene into an image-generation prompt.\n" + + json.dumps(llm_input, ensure_ascii=False) + ) + parsed = json.loads(response.content) + return { + "prompt": parsed["prompt"], + "style": parsed.get("style", style), + "size": parsed.get("size", self.settings.default_size), + "prompt_context": prompt_context, + } + except Exception: + pass + + return { + "prompt": f"{description}\n\nScene context: {context_excerpt}", + "style": style, + "size": self.settings.default_size, + "prompt_context": prompt_context, + } diff --git a/api/services/memoir_images/settings.py b/api/services/memoir_images/settings.py new file mode 100644 index 0000000..bb181ef --- /dev/null +++ b/api/services/memoir_images/settings.py @@ -0,0 +1,25 @@ +import os +from dataclasses import dataclass + + +@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 + + @classmethod + def from_env(cls) -> "MemoirImageSettings": + return cls( + enabled=os.getenv("MEMOIR_IMAGE_ENABLED", "").lower() in {"1", "true", "yes"}, + 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"), + poll_interval_seconds=int(os.getenv("MEMOIR_IMAGE_POLL_INTERVAL", "3")), + max_attempts=int(os.getenv("MEMOIR_IMAGE_MAX_ATTEMPTS", "20")), + ) diff --git a/api/tests/test_memoir_image_prompting.py b/api/tests/test_memoir_image_prompting.py new file mode 100644 index 0000000..a267787 --- /dev/null +++ b/api/tests/test_memoir_image_prompting.py @@ -0,0 +1,59 @@ +import unittest +from unittest.mock import Mock + +from api.services.memoir_images.prompting import MemoirImagePromptService +from api.services.memoir_images.settings import MemoirImageSettings + + +class MemoirImagePromptingTest(unittest.TestCase): + def test_prompt_service_uses_category_style_and_plain_fallback_without_llm(self): + settings = MemoirImageSettings( + enabled=True, + max_per_chapter=2, + provider="liblib", + default_style="watercolor", + default_size="1024x1024", + poll_interval_seconds=3, + max_attempts=20, + ) + service = MemoirImagePromptService(llm=None, settings=settings) + + result = service.build_prompt( + chapter_title="童年的夏天", + chapter_category="childhood", + description="奶奶坐在院子里的藤椅上", + context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", + ) + + self.assertEqual(result["style"], "watercolor") + self.assertEqual(result["size"], "1024x1024") + self.assertIn("奶奶坐在院子里的藤椅上", result["prompt"]) + self.assertIn("childhood", result["prompt_context"]) + + def test_prompt_service_parses_structured_llm_response(self): + settings = MemoirImageSettings( + enabled=True, + max_per_chapter=2, + provider="liblib", + default_style="watercolor", + default_size="1024x1024", + poll_interval_seconds=3, + max_attempts=20, + ) + llm = Mock() + llm.invoke.return_value.content = ( + '{"prompt":"A grandmother in a quiet courtyard, summer cicadas, soft watercolor",' + '"style":"watercolor","size":"1024x1024"}' + ) + service = MemoirImagePromptService(llm=llm, settings=settings) + + result = service.build_prompt( + chapter_title="童年的夏天", + chapter_category="childhood", + description="奶奶坐在院子里的藤椅上", + context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", + ) + + self.assertEqual(result["prompt"], "A grandmother in a quiet courtyard, summer cicadas, soft watercolor") + self.assertEqual(result["style"], "watercolor") + self.assertEqual(result["size"], "1024x1024")