Files
life-echo/api/app/features/memoir/memoir_images/prompting.py

215 lines
7.9 KiB
Python

import json
from app.core.logging import get_logger
import re
from typing import Any, Optional
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:
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)
)
raw_response = response.content
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=%s, title=%s, error=%s",
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:
response = self.llm.invoke(
"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.\n"
+ json.dumps(llm_input, ensure_ascii=False)
)
parsed = json.loads(extract_json_payload(response.content))
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=%s, title=%s, error=%s",
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}"