2026-03-10 16:00:25 +08:00
|
|
|
|
import json
|
2026-03-11 11:26:42 +08:00
|
|
|
|
import re
|
2026-03-10 16:00:25 +08:00
|
|
|
|
from typing import Any, Optional
|
|
|
|
|
|
|
2026-04-02 12:00:00 +08:00
|
|
|
|
from app.core.config import settings
|
2026-04-08 15:37:09 +08:00
|
|
|
|
from app.core.json_utils import extract_json_payload
|
2026-03-26 12:13:36 +08:00
|
|
|
|
from app.core.langchain_llm import invoke_json_object
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.core.logging import get_logger
|
|
|
|
|
|
|
2026-03-10 16:00:25 +08:00
|
|
|
|
from .settings import MemoirImageSettings
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger = get_logger(__name__)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
_CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
|
|
|
|
|
|
2026-03-10 16:00:25 +08:00
|
|
|
|
|
|
|
|
|
|
class MemoirImagePromptService:
|
|
|
|
|
|
CATEGORY_STYLE_MAP = {
|
|
|
|
|
|
"childhood": "watercolor",
|
|
|
|
|
|
"family": "watercolor",
|
|
|
|
|
|
"career_early": "realistic",
|
|
|
|
|
|
"career_achievement": "realistic",
|
|
|
|
|
|
"career_challenge": "realistic",
|
|
|
|
|
|
"beliefs": "editorial illustration",
|
|
|
|
|
|
"summary": "editorial illustration",
|
|
|
|
|
|
}
|
2026-03-11 11:26:42 +08:00
|
|
|
|
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",
|
|
|
|
|
|
}
|
2026-03-10 16:00:25 +08:00
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
style = self.CATEGORY_STYLE_MAP.get(
|
|
|
|
|
|
chapter_category, self.settings.default_style
|
|
|
|
|
|
)
|
2026-03-10 16:00:25 +08:00
|
|
|
|
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:
|
2026-03-11 13:18:20 +08:00
|
|
|
|
raw_response = None
|
2026-03-10 16:00:25 +08:00
|
|
|
|
try:
|
2026-03-26 12:13:36 +08:00
|
|
|
|
prompt_text = (
|
2026-03-10 16:00:25 +08:00
|
|
|
|
"Return JSON only with keys prompt, style, size. "
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"Convert the memoir scene into an image-generation prompt. "
|
|
|
|
|
|
"The API uses response_format=json_object.\n"
|
2026-03-10 16:00:25 +08:00
|
|
|
|
+ json.dumps(llm_input, ensure_ascii=False)
|
|
|
|
|
|
)
|
2026-03-26 12:13:36 +08:00
|
|
|
|
raw_response = invoke_json_object(
|
|
|
|
|
|
self.llm,
|
|
|
|
|
|
prompt_text,
|
|
|
|
|
|
max_tokens=512,
|
|
|
|
|
|
agent="MemoirImagePromptService.build_prompt",
|
|
|
|
|
|
)
|
2026-03-11 13:46:07 +08:00
|
|
|
|
parsed = json.loads(extract_json_payload(raw_response))
|
2026-03-10 16:00:25 +08:00
|
|
|
|
return {
|
2026-03-19 14:36:14 +08:00
|
|
|
|
"prompt": _ensure_style_in_prompt(
|
|
|
|
|
|
parsed["prompt"], parsed.get("style", style)
|
|
|
|
|
|
),
|
2026-03-10 16:00:25 +08:00
|
|
|
|
"style": parsed.get("style", style),
|
|
|
|
|
|
"size": parsed.get("size", self.settings.default_size),
|
|
|
|
|
|
"prompt_context": prompt_context,
|
|
|
|
|
|
}
|
2026-03-11 11:26:42 +08:00
|
|
|
|
except Exception as exc:
|
2026-04-02 12:00:00 +08:00
|
|
|
|
if settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise
|
2026-03-11 11:26:42 +08:00
|
|
|
|
logger.warning(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"图片 prompt 生成回退到默认模板: chapter_category={}, title={}, error={}",
|
2026-03-11 11:26:42 +08:00
|
|
|
|
chapter_category,
|
|
|
|
|
|
chapter_title,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
)
|
2026-04-02 12:00:00 +08:00
|
|
|
|
elif settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"MemoirImagePromptService.build_prompt requires LLM when "
|
|
|
|
|
|
"image_prompt_fallback_disabled is True"
|
|
|
|
|
|
)
|
2026-03-10 16:00:25 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-11 11:26:42 +08:00
|
|
|
|
"prompt": _ensure_style_in_prompt(
|
|
|
|
|
|
self._build_fallback_prompt(
|
|
|
|
|
|
chapter_category=chapter_category,
|
|
|
|
|
|
description=description,
|
|
|
|
|
|
context_excerpt=context_excerpt,
|
|
|
|
|
|
style=style,
|
|
|
|
|
|
),
|
|
|
|
|
|
style,
|
|
|
|
|
|
),
|
2026-03-10 16:00:25 +08:00
|
|
|
|
"style": style,
|
|
|
|
|
|
"size": self.settings.default_size,
|
|
|
|
|
|
"prompt_context": prompt_context,
|
|
|
|
|
|
}
|
2026-03-11 11:26:42 +08:00
|
|
|
|
|
2026-03-19 09:11:54 +08:00
|
|
|
|
def build_cover_prompt(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chapter_title: str,
|
|
|
|
|
|
chapter_category: str,
|
|
|
|
|
|
context_excerpt: str,
|
|
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
"""生成章节封面图的 image-generation prompt。"""
|
2026-04-02 12:00:00 +08:00
|
|
|
|
excerpt = (context_excerpt or "").strip()
|
|
|
|
|
|
if settings.image_prompt_fallback_disabled and not excerpt:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"Chapter cover prompt requires non-empty context_excerpt when "
|
|
|
|
|
|
"image_prompt_fallback_disabled is True"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-19 14:36:14 +08:00
|
|
|
|
style = self.CATEGORY_STYLE_MAP.get(
|
|
|
|
|
|
chapter_category, self.settings.default_style
|
|
|
|
|
|
)
|
2026-03-19 09:11:54 +08:00
|
|
|
|
prompt_context = f"{chapter_category}: {chapter_title}"
|
|
|
|
|
|
|
|
|
|
|
|
llm_input = {
|
|
|
|
|
|
"chapter_title": chapter_title,
|
|
|
|
|
|
"chapter_category": chapter_category,
|
2026-04-02 12:00:00 +08:00
|
|
|
|
"context_excerpt": excerpt,
|
2026-03-19 09:11:54 +08:00
|
|
|
|
"default_style": style,
|
|
|
|
|
|
"default_size": self.settings.default_size,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if self.llm:
|
|
|
|
|
|
try:
|
2026-03-26 12:13:36 +08:00
|
|
|
|
prompt_text = (
|
2026-03-19 09:11:54 +08:00
|
|
|
|
"Return JSON only with keys prompt, style, size. "
|
|
|
|
|
|
"Create an image-generation prompt for a memoir chapter COVER. "
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"Emphasize: hero composition, evocative scene, chapter cover aesthetic. "
|
|
|
|
|
|
"The API uses response_format=json_object.\n"
|
2026-03-19 09:11:54 +08:00
|
|
|
|
+ json.dumps(llm_input, ensure_ascii=False)
|
|
|
|
|
|
)
|
2026-03-26 12:13:36 +08:00
|
|
|
|
raw = invoke_json_object(
|
|
|
|
|
|
self.llm,
|
|
|
|
|
|
prompt_text,
|
|
|
|
|
|
max_tokens=512,
|
|
|
|
|
|
agent="MemoirImagePromptService.build_cover_prompt",
|
|
|
|
|
|
)
|
|
|
|
|
|
parsed = json.loads(extract_json_payload(raw))
|
2026-03-19 09:11:54 +08:00
|
|
|
|
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:
|
2026-04-02 12:00:00 +08:00
|
|
|
|
if settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise
|
2026-03-19 09:11:54 +08:00
|
|
|
|
logger.warning(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"封面 prompt 生成回退到默认模板: chapter_category={}, title={}, error={}",
|
2026-03-19 09:11:54 +08:00
|
|
|
|
chapter_category,
|
|
|
|
|
|
chapter_title,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
)
|
2026-04-02 12:00:00 +08:00
|
|
|
|
elif settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"MemoirImagePromptService.build_cover_prompt requires LLM when "
|
|
|
|
|
|
"image_prompt_fallback_disabled is True"
|
|
|
|
|
|
)
|
2026-03-19 09:11:54 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"prompt": _ensure_style_in_prompt(
|
|
|
|
|
|
self._build_cover_fallback_prompt(
|
|
|
|
|
|
chapter_category=chapter_category,
|
2026-04-02 12:00:00 +08:00
|
|
|
|
context_excerpt=excerpt,
|
2026-03-19 09:11:54 +08:00
|
|
|
|
style=style,
|
|
|
|
|
|
),
|
|
|
|
|
|
style,
|
|
|
|
|
|
),
|
|
|
|
|
|
"style": style,
|
|
|
|
|
|
"size": self.settings.default_size,
|
|
|
|
|
|
"prompt_context": prompt_context,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 12:00:00 +08:00
|
|
|
|
def build_story_primary_prompt(
|
|
|
|
|
|
self,
|
|
|
|
|
|
story_title: str,
|
|
|
|
|
|
story_stage: str | None,
|
|
|
|
|
|
prompt_brief: str,
|
|
|
|
|
|
style_profile: str | None,
|
|
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
"""生成 story 主插图的 image-generation prompt(LLM / fallback 策略与章节配图一致)。
|
|
|
|
|
|
|
|
|
|
|
|
`story_stage` 与 `Story.stage` 一致:通常为章节 category(如 career_early),也可能为
|
|
|
|
|
|
访谈五阶段名(childhood/career/…);二者都参与默认画风推断。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.agents.stage_constants import STAGE_TO_DEFAULT_CATEGORY
|
|
|
|
|
|
|
|
|
|
|
|
brief = (prompt_brief or "").strip()
|
|
|
|
|
|
if settings.image_prompt_fallback_disabled and not brief:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"Story image prompt requires non-empty prompt_brief when "
|
|
|
|
|
|
"image_prompt_fallback_disabled is True"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
stage_key = (story_stage or "").strip()
|
|
|
|
|
|
if stage_key in self.CATEGORY_STYLE_MAP:
|
|
|
|
|
|
cat = stage_key
|
|
|
|
|
|
else:
|
|
|
|
|
|
cat = STAGE_TO_DEFAULT_CATEGORY.get(stage_key, "summary")
|
|
|
|
|
|
explicit_style = (style_profile or "").strip()
|
|
|
|
|
|
style = explicit_style or self.CATEGORY_STYLE_MAP.get(
|
|
|
|
|
|
cat, self.settings.default_style
|
|
|
|
|
|
)
|
|
|
|
|
|
prompt_context = f"story:{stage_key}:{story_title}"
|
|
|
|
|
|
|
|
|
|
|
|
llm_input = {
|
|
|
|
|
|
"story_title": story_title,
|
|
|
|
|
|
"story_stage": stage_key,
|
|
|
|
|
|
"prompt_brief": brief,
|
|
|
|
|
|
"default_style": style,
|
|
|
|
|
|
"default_size": self.settings.default_size,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if self.llm:
|
|
|
|
|
|
try:
|
|
|
|
|
|
prompt_text = (
|
|
|
|
|
|
"Return JSON only with keys prompt, style, size. "
|
|
|
|
|
|
"Convert into an image-generation prompt for the PRIMARY hero illustration "
|
|
|
|
|
|
"of a personal memoir story (one focal scene, emotionally resonant). "
|
|
|
|
|
|
"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_story_primary_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:
|
|
|
|
|
|
if settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"story 主图 prompt 生成回退到默认模板: stage={}, title={}, error={}",
|
|
|
|
|
|
story_stage,
|
|
|
|
|
|
story_title,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
)
|
|
|
|
|
|
elif settings.image_prompt_fallback_disabled:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
"MemoirImagePromptService.build_story_primary_prompt requires LLM when "
|
|
|
|
|
|
"image_prompt_fallback_disabled is True"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
from .image_placeholder_template import IMAGE_PLACEHOLDER_TEMPLATE
|
|
|
|
|
|
|
|
|
|
|
|
if brief:
|
|
|
|
|
|
body = brief
|
|
|
|
|
|
else:
|
|
|
|
|
|
joined = ",".join(
|
|
|
|
|
|
filter(
|
|
|
|
|
|
None,
|
|
|
|
|
|
[(story_title or "").strip(), stage_key or None],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
body = joined or "人生故事"
|
|
|
|
|
|
prompt_line = f"{IMAGE_PLACEHOLDER_TEMPLATE}。{body}"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"prompt": _ensure_style_in_prompt(prompt_line, style),
|
|
|
|
|
|
"style": style,
|
|
|
|
|
|
"size": self.settings.default_size,
|
|
|
|
|
|
"prompt_context": prompt_context,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 09:11:54 +08:00
|
|
|
|
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."
|
|
|
|
|
|
)
|
2026-03-19 14:14:13 +08:00
|
|
|
|
details = (context_excerpt or "").strip()[:500]
|
2026-03-19 09:11:54 +08:00
|
|
|
|
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."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def _build_fallback_prompt(
|
|
|
|
|
|
self,
|
|
|
|
|
|
chapter_category: str,
|
|
|
|
|
|
description: str,
|
|
|
|
|
|
context_excerpt: str,
|
|
|
|
|
|
style: str,
|
|
|
|
|
|
) -> str:
|
2026-03-19 14:36:14 +08:00
|
|
|
|
subject = self.CATEGORY_FALLBACK_SUBJECT_MAP.get(
|
|
|
|
|
|
chapter_category, "memoir scene"
|
|
|
|
|
|
)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
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."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-19 14:36:14 +08:00
|
|
|
|
details = ". ".join(
|
|
|
|
|
|
part.strip() for part in (description, context_excerpt) if part.strip()
|
|
|
|
|
|
)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
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}"
|