import json import re from typing import Any, Optional from app.core.langchain_llm import invoke_json_object from app.core.logging import get_logger from .json_payload import extract_json_payload from .settings import MemoirImageSettings logger = get_logger(__name__) _CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]") class MemoirImagePromptService: CATEGORY_STYLE_MAP = { "childhood": "watercolor", "family": "watercolor", "career_early": "realistic", "career_achievement": "realistic", "career_challenge": "realistic", "beliefs": "editorial illustration", "summary": "editorial illustration", } CATEGORY_FALLBACK_SUBJECT_MAP = { "childhood": "childhood memory", "education": "school memory", "career_early": "early career memory", "career_achievement": "career achievement memory", "career_challenge": "career challenge memory", "family": "family memory", "beliefs": "reflective life memory", "summary": "memoir summary scene", } 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: raw_response = None try: prompt_text = ( "Return JSON only with keys prompt, style, size. " "Convert the memoir scene into an image-generation prompt. " "The API uses response_format=json_object.\n" + json.dumps(llm_input, ensure_ascii=False) ) raw_response = invoke_json_object( self.llm, prompt_text, max_tokens=512, agent="MemoirImagePromptService.build_prompt", ) parsed = json.loads(extract_json_payload(raw_response)) return { "prompt": _ensure_style_in_prompt( parsed["prompt"], parsed.get("style", style) ), "style": parsed.get("style", style), "size": parsed.get("size", self.settings.default_size), "prompt_context": prompt_context, } except Exception as exc: logger.warning( "图片 prompt 生成回退到默认模板: chapter_category={}, title={}, error={}", chapter_category, chapter_title, exc, ) return { "prompt": _ensure_style_in_prompt( self._build_fallback_prompt( chapter_category=chapter_category, description=description, context_excerpt=context_excerpt, style=style, ), style, ), "style": style, "size": self.settings.default_size, "prompt_context": prompt_context, } def build_cover_prompt( self, chapter_title: str, chapter_category: str, context_excerpt: str, ) -> dict[str, str]: """生成章节封面图的 image-generation prompt。""" 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, "context_excerpt": context_excerpt, "default_style": style, "default_size": self.settings.default_size, } if self.llm: try: prompt_text = ( "Return JSON only with keys prompt, style, size. " "Create an image-generation prompt for a memoir chapter COVER. " "Emphasize: hero composition, evocative scene, chapter cover aesthetic. " "The API uses response_format=json_object.\n" + json.dumps(llm_input, ensure_ascii=False) ) raw = invoke_json_object( self.llm, prompt_text, max_tokens=512, agent="MemoirImagePromptService.build_cover_prompt", ) parsed = json.loads(extract_json_payload(raw)) return { "prompt": _ensure_style_in_prompt( parsed["prompt"], parsed.get("style", style) ), "style": parsed.get("style", style), "size": parsed.get("size", self.settings.default_size), "prompt_context": prompt_context, } except Exception as exc: logger.warning( "封面 prompt 生成回退到默认模板: chapter_category={}, title={}, error={}", chapter_category, chapter_title, exc, ) return { "prompt": _ensure_style_in_prompt( self._build_cover_fallback_prompt( chapter_category=chapter_category, context_excerpt=context_excerpt, style=style, ), style, ), "style": style, "size": self.settings.default_size, "prompt_context": prompt_context, } def _build_cover_fallback_prompt( self, chapter_category: str, context_excerpt: str, style: str, ) -> str: subject = self.CATEGORY_FALLBACK_SUBJECT_MAP.get( chapter_category, "memoir scene" ) if _contains_cjk(context_excerpt): return ( f"A {style} chapter cover illustration of a {subject}, " "hero composition, evocative scene, emotionally resonant, " "cinematic framing, natural lighting, no text overlay." ) details = (context_excerpt or "").strip()[:500] if not details: details = "A personal life story scene with authentic emotional detail" return ( f"A {style} chapter cover illustration of a {subject}. " f"Scene hint: {details}. " "Hero composition, evocative scene, cinematic framing, no text overlay." ) def _build_fallback_prompt( self, chapter_category: str, description: str, context_excerpt: str, style: str, ) -> str: subject = self.CATEGORY_FALLBACK_SUBJECT_MAP.get( chapter_category, "memoir scene" ) if _contains_cjk(description) or _contains_cjk(context_excerpt): return ( f"A {style} illustration of a {subject}, emotionally resonant, cinematic composition, " "authentic everyday details, natural lighting, expressive environment, no text overlay." ) details = ". ".join( part.strip() for part in (description, context_excerpt) if part.strip() ) if not details: details = "A personal life story scene with authentic emotional detail" return ( f"A {style} illustration of a {subject}. " f"Scene details: {details}. " "Cinematic composition, authentic emotions, natural lighting, no text overlay." ) def _contains_cjk(value: str) -> bool: return bool(_CJK_RE.search(value or "")) def _ensure_style_in_prompt(prompt: str, style: str) -> str: cleaned_prompt = (prompt or "").strip() cleaned_style = (style or "").strip() if not cleaned_style: return cleaned_prompt if cleaned_style.lower() in cleaned_prompt.lower(): return cleaned_prompt if not cleaned_prompt: return cleaned_style return f"{cleaned_style}, {cleaned_prompt}"