把“章节正文 + 图片”从 chapters 单表/JSON 结构,重构为“章节 chapter + 段落 section + 图片 memoir_images 独立表”的新数据模型,同时联动修改接口、PDF 导出、异步任务、迁移脚本、测试,以及修复 Android 端聊天列表显示问题。 (#9)

* refactor: 表结构重构,新增段落section和图片image新表

* fix: fix android app import error

* refactor: 重构文件名

* fix: 优化提示词

* fix: 消息气泡显示位置异常问题

---------

Co-authored-by: yangshilin <2157598560@qq.com>
This commit is contained in:
Sully
2026-03-13 11:12:10 +08:00
committed by GitHub
parent 1cb804fa37
commit 2eb066dbec
19 changed files with 1280 additions and 624 deletions

View File

@@ -12,11 +12,11 @@ from datetime import datetime, timezone
import redis
from celery import shared_task
from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, joinedload
from database.database import SessionLocal
from database.models import Book, Chapter, Segment, MemoirState, User
from database.models import Book, Chapter, ChapterSection, MemoirImage, Segment, MemoirState, User
from services.llm_service import llm_service
from agents.state_schema import MemoirStateSchema, SlotData, default_state
from agents.prompts.memory_prompts import (
@@ -31,7 +31,11 @@ from agents.prompts.memory_prompts import (
from agents.prompts.profile_prompts import format_user_profile_context
import hashlib
from services.memoir_images.parser import build_initial_image_assets, parse_image_placeholders
from services.memoir_images.parser import (
build_initial_image_assets,
parse_image_placeholders,
split_narrative_to_sections,
)
from services.memoir_images.json_payload import extract_json_payload
from services.memoir_images.prompting import MemoirImagePromptService
from services.memoir_images.provider import LiblibImageProvider
@@ -43,6 +47,7 @@ from services.memoir_images.schema import (
IMAGE_STATUS_PROCESSING,
normalize_image_assets,
)
from services.memoir_images.serializers import image_dict_to_row_kwargs, memoir_image_to_dict
from services.memoir_images.settings import MemoirImageSettings
from services.memoir_images.storage import TencentCosStorageService, CosUploadError
@@ -173,6 +178,38 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool:
)
def _memoir_image_from_asset(
chapter_id: str,
section_id: str | None,
order_index: int,
image_asset: dict,
) -> MemoirImage:
"""从单条图片 dict 构建 MemoirImage 行(用于写入 memoir_images 表)。"""
kwargs = image_dict_to_row_kwargs(image_asset)
return MemoirImage(
id=str(uuid.uuid4()).replace("-", "")[:32],
chapter_id=chapter_id,
section_id=section_id,
order_index=order_index,
**kwargs,
)
def _section_has_image_to_generate(section) -> bool:
"""章节段落是否有待生成的配图(从 image_record / image_id 关联的 memoir_images 读取)。"""
r = getattr(section, "image_record", None)
if not r:
return False
status = (getattr(r, "status") or "").strip()
return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED)
def _chapter_has_any_section_images_to_generate(chapter) -> bool:
if not chapter or not getattr(chapter, "sections", None):
return False
return any(_section_has_image_to_generate(s) for s in chapter.sections)
def _select_placeholders_for_effective_max(
placeholders: list[dict],
existing_images: list[dict] | None,
@@ -201,41 +238,128 @@ def _select_placeholders_for_effective_max(
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()
if not settings.enabled:
chapter.images = completed_image_assets(chapter.images)
logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false")
return chapter.images
def _save_narrative_to_sections(db: Session, chapter, narrative: str, title: str, category: str, order_index: int, source_segments: list, user_id: str):
"""
将带占位符的 narrative 拆成 chapter_sections 并写入;为每段占位符创建 pending 配图。
已有 section 与图片不删除,仅追加新内容。封面图先空着,不自动设置。
chapter 可为已有章节或 None会新建。返回 chapter。
"""
now_iso = datetime.now(timezone.utc).isoformat()
if chapter is None:
chapter = Chapter(
id=str(uuid.uuid4()),
user_id=user_id,
title=title,
order_index=order_index,
status="completed",
category=category,
cover_image=None,
is_new=True,
source_segments=source_segments or [],
)
db.add(chapter)
db.flush()
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
effective_max = settings.effective_max_images(len(chapter.content or ""))
all_placeholders = parse_image_placeholders(chapter.content, max_images=None)
# 已有 sections 不删除,只追加新内容
existing_sections = (
db.execute(
select(ChapterSection)
.where(ChapterSection.chapter_id == chapter.id)
.order_by(ChapterSection.order_index)
)
.scalars().all()
)
if existing_sections:
existing_content = "\n\n".join(
(s.content or "").strip() for s in existing_sections if (s.content or "").strip()
)
if existing_content and narrative.startswith(existing_content):
new_part = narrative[len(existing_content):].lstrip()
else:
new_part = (narrative or "").strip()
if not new_part:
chapter.title = title
chapter.is_new = True
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
return chapter
narrative_to_parse = new_part
order_base = max(s.order_index for s in existing_sections) + 1
else:
narrative_to_parse = (narrative or "").strip()
order_base = 0
segments = split_narrative_to_sections(narrative_to_parse)
if not segments:
sec = ChapterSection(
id=str(uuid.uuid4()),
chapter_id=chapter.id,
order_index=order_base,
content=(narrative_to_parse or "").strip() or "",
image_id=None,
)
db.add(sec)
db.flush()
chapter.title = title
chapter.is_new = True
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
return chapter
settings = MemoirImageSettings.from_env()
prompt_service = MemoirImagePromptService(llm=None, settings=settings) if settings.enabled else None
effective_max = settings.effective_max_images(len(narrative_to_parse)) if settings.enabled else 0
all_placeholders = [s["placeholder_info"] for s in segments if s.get("placeholder_info")]
placeholders = _select_placeholders_for_effective_max(
placeholders=all_placeholders,
existing_images=chapter.images,
existing_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,
placeholders=placeholders,
provider=settings.provider,
style=style,
size=settings.default_size,
now_iso=datetime.now(timezone.utc).isoformat(),
)
logger.info(
"章节图片初始化完成: 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 [])],
)
return chapter.images
) if settings.enabled else []
selected_placeholder_set = {p.get("placeholder") for p in placeholders}
# 按顺序创建 section保证每个 section 的 content 与 image 一一对应order_index 严格递增)
for i, seg in enumerate(segments):
order_idx = order_base + i
content = (seg.get("content") or "").strip()
ph = seg.get("placeholder_info")
image_asset = None
if ph and settings.enabled and ph.get("placeholder") in selected_placeholder_set:
style = prompt_service.CATEGORY_STYLE_MAP.get(category, settings.default_style) if prompt_service else settings.default_style
image_asset = build_initial_image_assets(
[ph],
settings.provider,
style,
settings.default_size,
now_iso,
)[0]
sec = ChapterSection(
id=str(uuid.uuid4()),
chapter_id=chapter.id,
order_index=order_idx,
content=content,
image_id=None,
)
db.add(sec)
db.flush()
if image_asset:
# 本段配图与当前 section 绑定memoir_images.order_index = section.order_index + 1封面 0 预留)
mi = _memoir_image_from_asset(chapter.id, sec.id, order_idx + 1, image_asset)
db.add(mi)
db.flush()
sec.image_id = mi.id
db.flush()
# 封面图先空着,不自动用首图做封面
chapter.title = title
chapter.is_new = True
chapter.source_segments = list(set((chapter.source_segments or []) + (source_segments or [])))
return chapter
def initialize_chapter_images(_chapter):
"""
兼容旧调用:若章节已改为 sections 存储,则图片初始化已在 _save_narrative_to_sections 中完成,直接返回。
"""
logger.info("initialize_chapter_images: 已由 _save_narrative_to_sections 处理 section 配图,跳过")
return []
def _normalize_image_bytes_for_storage(image_bytes: bytes) -> bytes:
@@ -464,14 +588,18 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
combined_text = "\n\n".join(segment_texts)
source_ids = [seg.id for seg in category_segments]
# 查找 active 章节(被清除的章节不继续更新,而是创建新的)
stmt_chapter = select(Chapter).where(
Chapter.user_id == user_id,
Chapter.category == chapter_category,
Chapter.is_active == True,
# 查找 active 章节(被清除的章节不继续更新,而是创建新的),并预加载 sections
stmt_chapter = (
select(Chapter)
.where(
Chapter.user_id == user_id,
Chapter.category == chapter_category,
Chapter.is_active == True,
)
.options(joinedload(Chapter.sections))
)
result_chapter = db.execute(stmt_chapter)
chapter = result_chapter.scalar_one_or_none()
chapter = result_chapter.unique().scalar_one_or_none()
# 获取 slot snippets
slot_snippets = {
@@ -480,9 +608,13 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
if value.snippet
}
# 生成标题和内容
# 生成标题和内容;已有章节的正文从 sections 拼接
title = chapter.title if chapter else f"{chapter_category} 回忆"
existing_content = chapter.content if chapter else ""
existing_content = ""
if chapter and getattr(chapter, "sections", None):
existing_content = "\n\n".join(
s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip()
)
narrative = combined_text
if llm:
@@ -529,34 +661,22 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
# 入库前:占位符位置用正则匹配后拼上固定模板
narrative = inject_image_placeholder_template(narrative)
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
# 更新或创建章节
if chapter:
chapter.content = narrative
chapter.title = title
chapter.is_new = True
chapter.source_segments = list({*(chapter.source_segments or []), *source_ids})
else:
# 根据 stage 计算正确的排序索引
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
chapter = Chapter(
id=str(uuid.uuid4()),
user_id=user_id,
title=title,
content=narrative,
order_index=calculated_order_index,
status="completed",
category=chapter_category,
images=[],
is_new=True,
source_segments=source_ids,
)
db.add(chapter)
# 写入 sections拆段 + 每段配图占位),新建或覆盖该章下所有 sections
chapter = _save_narrative_to_sections(
db,
chapter,
narrative,
title=title,
category=chapter_category,
order_index=calculated_order_index,
source_segments=source_ids,
user_id=user_id,
)
db.flush()
initialize_chapter_images(chapter)
if image_settings.enabled and chapter_has_images_to_generate(chapter.images):
db.refresh(chapter)
if image_settings.enabled and _chapter_has_any_section_images_to_generate(chapter):
chapters_to_enqueue.add(chapter.id)
# 更新 Book
@@ -628,17 +748,24 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
try:
llm = llm_service.get_llm()
# 查找 active 章节(被清除的章节不继续更新,而是创建新的)
stmt = select(Chapter).where(
Chapter.user_id == user_id,
Chapter.category == stage,
Chapter.is_active == True,
# 查找 active 章节并预加载 sections
stmt = (
select(Chapter)
.where(
Chapter.user_id == user_id,
Chapter.category == stage,
Chapter.is_active == True,
)
.options(joinedload(Chapter.sections))
)
result = db.execute(stmt)
chapter = result.scalar_one_or_none()
existing_content = chapter.content if chapter else ""
chapter = result.unique().scalar_one_or_none()
existing_content = ""
if chapter and getattr(chapter, "sections", None):
existing_content = "\n\n".join(
s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip()
)
if llm:
prompt = get_narrative_prompt(
stage=stage,
@@ -666,27 +793,18 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
# 入库前:占位符位置用正则匹配后拼上固定模板
narrative = inject_image_placeholder_template(narrative)
if chapter:
chapter.content = narrative
chapter.is_new = True
else:
# 根据 stage 计算正确的排序索引
calculated_order_index = STAGE_TO_ORDER.get(stage, 999)
chapter = Chapter(
id=str(uuid.uuid4()),
user_id=user_id,
title=f"{stage} 回忆",
content=narrative,
order_index=calculated_order_index,
status="completed",
category=stage,
images=[],
is_new=True,
source_segments=[],
)
db.add(chapter)
calculated_order_index = STAGE_TO_ORDER.get(stage, 999)
title = chapter.title if chapter else f"{stage} 回忆"
chapter = _save_narrative_to_sections(
db,
chapter,
narrative,
title=title,
category=stage,
order_index=calculated_order_index,
source_segments=[],
user_id=user_id,
)
db.commit()
return {"status": "success"}
@@ -705,20 +823,33 @@ def build_cos_key(user_id: str, chapter_id: str, index: int, prompt: str) -> str
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def generate_chapter_images(self, chapter_id: str):
"""Async task to generate images for a chapter's pending image assets."""
"""Async task to generate images for a chapter's sections (each section has at most one image)."""
db = SessionLocal()
lock_acquired = False
provider = None
try:
chapter = db.get(Chapter, chapter_id)
if not chapter or not chapter.images:
logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images")
stmt = (
select(Chapter)
.where(Chapter.id == chapter_id)
.options(
joinedload(Chapter.sections).joinedload(ChapterSection.image_record),
joinedload(Chapter.images),
)
)
chapter = db.execute(stmt).unique().scalar_one_or_none()
if not chapter:
logger.info("章节补图跳过: chapter=%s, reason=not_found", chapter_id)
return {"status": "no_chapter"}
sections = getattr(chapter, "sections", None) or []
sections_with_pending = [
(idx, s) for idx, s in enumerate(sections) if _section_has_image_to_generate(s)
]
if not sections_with_pending:
logger.info("章节补图跳过: chapter=%s, reason=no_pending_images", chapter_id)
return {"status": "no_images"}
settings = MemoirImageSettings.from_env()
if not settings.enabled:
chapter.images = completed_image_assets(chapter.images)
db.commit()
logger.info("章节补图跳过: chapter=%s, reason=disabled", chapter_id)
return {"status": "disabled"}
@@ -730,42 +861,45 @@ def generate_chapter_images(self, chapter_id: str):
prompt_service = MemoirImagePromptService(llm_service.get_llm(), settings)
provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid)
storage = TencentCosStorageService.from_env()
images = normalize_image_assets(chapter.images)
pending_count = sum(
1 for item in images if item.get("status") in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}
)
logger.info(
"章节补图开始: chapter=%s, total_images=%d, pending_images=%d",
"章节补图开始: chapter=%s, pending_sections=%d",
chapter_id,
len(images),
pending_count,
len(sections_with_pending),
)
retryable_failures: list[str] = []
permanent_failures: list[str] = []
for index, item in enumerate(images):
if item.get("status") == IMAGE_STATUS_COMPLETED and (item.get("storage_key") or item.get("url")):
continue
if item.get("status") == IMAGE_STATUS_FAILED and item.get("retryable") is False:
continue
if item.get("status") not in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}:
continue
def _apply_item_to_memoir_image(rec: MemoirImage, d: dict):
rec.placeholder = d.get("placeholder")
rec.description = d.get("description")
rec.status = (d.get("status") or "pending").strip() or "pending"
rec.prompt = d.get("prompt")
rec.url = d.get("url")
rec.storage_key = d.get("storage_key")
rec.provider = d.get("provider")
rec.style = d.get("style")
rec.size = d.get("size")
rec.error = d.get("error")
rec.retryable = d.get("retryable")
rec.updated_at = datetime.now(timezone.utc)
current_item = dict(item)
for sec_index, section in sections_with_pending:
item = memoir_image_to_dict(section.image_record) if section.image_record else {}
current_item = dict(item) if item else {}
current_item.setdefault("placeholder", "")
current_item.setdefault("description", "")
current_item["status"] = IMAGE_STATUS_PROCESSING
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
images[index] = current_item
chapter.images = images
_apply_item_to_memoir_image(section.image_record, current_item)
db.commit()
try:
context_lines = (chapter.content or "").split("\n")
context_excerpt = " ".join(context_lines[:5])[:200]
context_lines = (section.content or "").strip().split("\n")[:5]
context_excerpt = " ".join(context_lines)[:200]
prompt_data = prompt_service.build_prompt(
chapter_title=chapter.title,
chapter_category=chapter.category or "",
description=item.get("description", ""),
description=current_item.get("description", ""),
context_excerpt=context_excerpt,
)
job = provider.submit_generation(
@@ -780,7 +914,7 @@ def generate_chapter_images(self, chapter_id: str):
max_attempts=settings.max_attempts,
)
image_bytes = _normalize_image_bytes_for_storage(provider.download_image(job))
key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"])
key = build_cos_key(chapter.user_id, chapter.id, sec_index, prompt_data["prompt"])
current_item["storage_key"] = key
current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png")
current_item["prompt"] = prompt_data["prompt"]
@@ -790,15 +924,15 @@ def generate_chapter_images(self, chapter_id: str):
current_item["error"] = None
current_item["retryable"] = None
logger.info(
"章节补图成功: chapter=%s, index=%s, url=%s",
"章节补图成功: chapter=%s, section_index=%s, url=%s",
chapter_id,
current_item.get("index"),
sec_index,
current_item["url"],
)
except Exception as exc:
current_item["status"] = IMAGE_STATUS_FAILED
current_item["error"] = str(exc)
failure_msg = f"index={current_item.get('index')}, error={exc}"
failure_msg = f"section_index={sec_index}, error={exc}"
if isinstance(exc, CosUploadError) and not exc.retryable:
current_item["retryable"] = False
permanent_failures.append(failure_msg)
@@ -809,10 +943,10 @@ def generate_chapter_images(self, chapter_id: str):
logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg)
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
images[index] = current_item
chapter.images = images
_apply_item_to_memoir_image(section.image_record, current_item)
db.commit()
# 封面图先空着,不自动用首张完成图做封面
if retryable_failures:
raise RuntimeError(
f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}"
@@ -821,7 +955,6 @@ def generate_chapter_images(self, chapter_id: str):
raise PermanentImageGenerationError(
f"章节补图存在不可重试失败项: chapter={chapter_id}, failures={'; '.join(permanent_failures)}"
)
return {"status": "success"}
except PermanentImageGenerationError as exc:
logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc)