diff --git a/backend/app/services/chapter_context_service.py b/backend/app/services/chapter_context_service.py
index 805bde0..2966d1a 100644
--- a/backend/app/services/chapter_context_service.py
+++ b/backend/app/services/chapter_context_service.py
@@ -12,6 +12,7 @@ from app.models.outline import Outline
from app.models.character import Character
from app.models.career import Career, CharacterCareer
from app.models.memory import StoryMemory
+from app.models.foreshadow import Foreshadow
from app.logger import get_logger
logger = get_logger(__name__)
@@ -25,12 +26,14 @@ class ChapterContext:
采用RTCO框架的分层设计:
- P0-核心(必须):大纲、衔接点、字数要求
- P1-重要(按需):角色、情感基调、风格
- - P2-参考(条件触发):记忆、故事骨架、MCP资料
+ - P2-参考(条件触发):记忆、故事骨架、MCP资料、伏笔提醒
"""
# === P0-核心信息(必须包含)===
chapter_outline: str = "" # 本章大纲
- continuation_point: Optional[str] = None # 衔接锚点(上一章结尾)
+ continuation_point: Optional[str] = None # 衔接锚点(增强版:含上一章摘要和结尾)
+ previous_chapter_summary: Optional[str] = None # 🔧 新增:上一章剧情摘要
+ previous_chapter_events: Optional[List[str]] = None # 🔧 新增:上一章关键事件
target_word_count: int = 3000 # 目标字数
min_word_count: int = 2500 # 最小字数
max_word_count: int = 4000 # 最大字数
@@ -54,6 +57,7 @@ class ChapterContext:
relevant_memories: Optional[str] = None # 相关记忆(精简版)
story_skeleton: Optional[str] = None # 故事骨架(50章+启用)
mcp_references: Optional[str] = None # MCP参考资料
+ foreshadow_reminders: Optional[str] = None # 伏笔提醒(新增)
# === 元信息 ===
context_stats: Dict[str, Any] = field(default_factory=dict) # 统计信息
@@ -62,7 +66,8 @@ class ChapterContext:
"""计算总上下文长度"""
total = 0
for field_name in ['chapter_outline', 'continuation_point', 'chapter_characters',
- 'relevant_memories', 'story_skeleton', 'style_instruction']:
+ 'relevant_memories', 'story_skeleton', 'style_instruction',
+ 'foreshadow_reminders', 'previous_chapter_summary']:
value = getattr(self, field_name, None)
if value:
total += len(value)
@@ -91,14 +96,16 @@ class ChapterContextBuilder:
STYLE_MAX_LENGTH = 200 # 风格描述最大长度
MAX_CONTEXT_LENGTH = 3000 # 总上下文最大字符数
- def __init__(self, memory_service=None):
+ def __init__(self, memory_service=None, foreshadow_service=None):
"""
初始化构建器
Args:
memory_service: 记忆服务实例(可选,用于检索相关记忆)
+ foreshadow_service: 伏笔服务实例(可选,用于获取伏笔提醒)
"""
self.memory_service = memory_service
+ self.foreshadow_service = foreshadow_service
async def build(
self,
@@ -155,20 +162,28 @@ class ChapterContextBuilder:
chapter, outline, project.outline_mode
)
- # === 衔接锚点(根据章节调整长度)===
+ # === 衔接锚点(根据章节调整长度,增强版含摘要和事件)===
if chapter_number == 1:
context.continuation_point = None
+ context.previous_chapter_summary = None
+ context.previous_chapter_events = None
logger.info(" ✅ 第1章无需衔接锚点")
elif chapter_number <= 10:
- context.continuation_point = await self._get_last_ending(
+ ending_info = await self._get_last_ending_enhanced(
chapter, db, self.ENDING_LENGTH_SHORT
)
- logger.info(f" ✅ 衔接锚点(短): {len(context.continuation_point or '')}字符")
+ context.continuation_point = ending_info.get('ending_text')
+ context.previous_chapter_summary = ending_info.get('summary')
+ context.previous_chapter_events = ending_info.get('key_events')
+ logger.info(f" ✅ 衔接锚点(短): {len(context.continuation_point or '')}字符, 摘要: {len(context.previous_chapter_summary or '')}字符")
else:
- context.continuation_point = await self._get_last_ending(
+ ending_info = await self._get_last_ending_enhanced(
chapter, db, self.ENDING_LENGTH_NORMAL
)
- logger.info(f" ✅ 衔接锚点(标准): {len(context.continuation_point or '')}字符")
+ context.continuation_point = ending_info.get('ending_text')
+ context.previous_chapter_summary = ending_info.get('summary')
+ context.previous_chapter_events = ending_info.get('key_events')
+ logger.info(f" ✅ 衔接锚点(标准): {len(context.continuation_point or '')}字符, 摘要: {len(context.previous_chapter_summary or '')}字符")
# === P1-重要信息 ===
context.chapter_characters = await self._build_chapter_characters(
@@ -200,6 +215,14 @@ class ChapterContextBuilder:
)
logger.info(f" ✅ 故事骨架: {len(context.story_skeleton or '')}字符")
+ # === P2-伏笔提醒(新增)===
+ if self.foreshadow_service:
+ context.foreshadow_reminders = await self._get_foreshadow_reminders(
+ project.id, chapter_number, db
+ )
+ if context.foreshadow_reminders:
+ logger.info(f" ✅ 伏笔提醒: {len(context.foreshadow_reminders)}字符")
+
# === 统计信息 ===
context.context_stats = {
"chapter_number": chapter_number,
@@ -208,6 +231,7 @@ class ChapterContextBuilder:
"characters_length": len(context.chapter_characters),
"memories_length": len(context.relevant_memories or ""),
"skeleton_length": len(context.story_skeleton or ""),
+ "foreshadow_length": len(context.foreshadow_reminders or ""),
"total_length": context.get_total_context_length()
}
@@ -263,7 +287,7 @@ class ChapterContextBuilder:
max_length: int
) -> Optional[str]:
"""
- 获取上一章结尾内容作为衔接锚点
+ 获取上一章结尾内容作为衔接锚点(旧版本,保留兼容性)
Args:
chapter: 当前章节
@@ -294,6 +318,105 @@ class ChapterContextBuilder:
return content[-max_length:]
+ async def _get_last_ending_enhanced(
+ self,
+ chapter: Chapter,
+ db: AsyncSession,
+ max_length: int
+ ) -> Dict[str, Any]:
+ """
+ 获取增强版衔接锚点(含上一章摘要和关键事件)
+
+ 🔧 新增功能:
+ 1. 提取上一章结尾文本
+ 2. 获取上一章剧情摘要(从记忆或expansion_plan)
+ 3. 提取上一章关键事件
+
+ Args:
+ chapter: 当前章节
+ db: 数据库会话
+ max_length: 最大长度
+
+ Returns:
+ 包含 ending_text, summary, key_events 的字典
+ """
+ result_info = {
+ 'ending_text': None,
+ 'summary': None,
+ 'key_events': []
+ }
+
+ if chapter.chapter_number <= 1:
+ return result_info
+
+ # 查询上一章
+ result = await db.execute(
+ select(Chapter)
+ .where(Chapter.project_id == chapter.project_id)
+ .where(Chapter.chapter_number == chapter.chapter_number - 1)
+ )
+ prev_chapter = result.scalar_one_or_none()
+
+ if not prev_chapter:
+ return result_info
+
+ # 1. 提取结尾内容
+ if prev_chapter.content:
+ content = prev_chapter.content.strip()
+ if len(content) <= max_length:
+ result_info['ending_text'] = content
+ else:
+ result_info['ending_text'] = content[-max_length:]
+
+ # 2. 获取上一章摘要
+ # 优先从记忆中获取 chapter_summary
+ summary_result = await db.execute(
+ select(StoryMemory.content)
+ .where(StoryMemory.project_id == chapter.project_id)
+ .where(StoryMemory.chapter_id == prev_chapter.id)
+ .where(StoryMemory.memory_type == 'chapter_summary')
+ .limit(1)
+ )
+ summary_mem = summary_result.scalar_one_or_none()
+
+ if summary_mem:
+ result_info['summary'] = summary_mem[:300] # 限制长度
+ elif prev_chapter.summary:
+ # 回退到章节的summary字段
+ result_info['summary'] = prev_chapter.summary[:300]
+ elif prev_chapter.expansion_plan:
+ # 再回退到expansion_plan中的plot_summary
+ try:
+ plan = json.loads(prev_chapter.expansion_plan)
+ result_info['summary'] = plan.get('plot_summary', '')[:300]
+ except json.JSONDecodeError:
+ pass
+
+ # 3. 提取上一章关键事件
+ if prev_chapter.expansion_plan:
+ try:
+ plan = json.loads(prev_chapter.expansion_plan)
+ key_events = plan.get('key_events', [])
+ if key_events:
+ result_info['key_events'] = key_events[:5] # 最多5个事件
+ except json.JSONDecodeError:
+ pass
+
+ # 如果没有从expansion_plan获取到,尝试从记忆中获取
+ if not result_info['key_events']:
+ events_result = await db.execute(
+ select(StoryMemory.content)
+ .where(StoryMemory.project_id == chapter.project_id)
+ .where(StoryMemory.chapter_id == prev_chapter.id)
+ .where(StoryMemory.memory_type == 'plot_point')
+ .limit(5)
+ )
+ event_mems = events_result.scalars().all()
+ if event_mems:
+ result_info['key_events'] = [e[:100] for e in event_mems]
+
+ return result_info
+
async def _build_chapter_characters(
self,
chapter: Chapter,
@@ -564,6 +687,48 @@ class ChapterContextBuilder:
return "\n".join(lines) if lines else None
+ async def _get_foreshadow_reminders(
+ self,
+ project_id: str,
+ chapter_number: int,
+ db: AsyncSession
+ ) -> Optional[str]:
+ """
+ 获取伏笔提醒信息用于章节生成
+
+ 策略:
+ 1. 获取计划在本章或之前回收但未回收的伏笔(超期提醒)
+ 2. 获取已埋入且接近需要回收的伏笔(提前提醒)
+ 3. 获取本章计划埋入的伏笔(埋入提醒)
+
+ Args:
+ project_id: 项目ID
+ chapter_number: 当前章节号
+ db: 数据库会话
+
+ Returns:
+ 格式化的伏笔提醒文本
+ """
+ if not self.foreshadow_service:
+ return None
+
+ try:
+ context_result = await self.foreshadow_service.build_chapter_context(
+ db=db,
+ project_id=project_id,
+ chapter_number=chapter_number,
+ include_pending=True,
+ include_overdue=True,
+ lookahead=5
+ )
+
+ context_text = context_result.get("context_text", "")
+ return context_text if context_text else None
+
+ except Exception as e:
+ logger.error(f"❌ 获取伏笔提醒失败: {str(e)}")
+ return None
+
async def _build_story_skeleton(
self,
project_id: str,
diff --git a/backend/app/services/plot_expansion_service.py b/backend/app/services/plot_expansion_service.py
index 3d9320b..b7fbe7c 100644
--- a/backend/app/services/plot_expansion_service.py
+++ b/backend/app/services/plot_expansion_service.py
@@ -163,7 +163,7 @@ class PlotExpansionService:
batch_size: int,
progress_callback: Optional[callable]
) -> List[Dict[str, Any]]:
- """分批生成章节规划"""
+ """分批生成章节规划(增强差异化版本)"""
# 计算批次数
total_batches = (target_chapter_count + batch_size - 1) // batch_size
logger.info(f"分批生成计划: 总共{target_chapter_count}章,分{total_batches}批,每批{batch_size}章")
@@ -184,6 +184,9 @@ class PlotExpansionService:
all_chapter_plans = []
+ # 🔧 收集所有已使用的关键事件,用于防止重复
+ used_key_events = set()
+
for batch_num in range(total_batches):
# 计算当前批次的章节数
remaining_chapters = target_chapter_count - len(all_chapter_plans)
@@ -196,20 +199,41 @@ class PlotExpansionService:
if progress_callback:
await progress_callback(batch_num + 1, total_batches, current_start_index, current_batch_size)
- # 构建当前批次的提示词(包含已生成章节的上下文)
+ # 🔧 增强的上下文构建(包含完整的差异化信息)
previous_context = ""
if all_chapter_plans:
+ # 构建完整的已生成章节摘要(包含关键事件)
previous_summaries = []
- for ch in all_chapter_plans[-3:]: # 只显示最近3章
+ for ch in all_chapter_plans: # 显示所有已生成章节
+ key_events_str = "、".join(ch.get('key_events', [])[:3]) if ch.get('key_events') else "无"
previous_summaries.append(
- f"第{ch['sub_index']}节《{ch['title']}》: {ch['plot_summary'][:100]}..."
+ f"第{ch['sub_index']}节《{ch['title']}》:\n"
+ f" - 剧情:{ch.get('plot_summary', '')[:150]}\n"
+ f" - 关键事件:{key_events_str}\n"
+ f" - 结尾方式:{ch.get('ending_type', '未知')}"
)
+
+ # 提取所有已使用的关键事件
+ all_used_events = []
+ for ch in all_chapter_plans:
+ all_used_events.extend(ch.get('key_events', []))
+ used_events_str = "、".join(all_used_events[-20:]) if all_used_events else "暂无"
+
previous_context = f"""
- 【已生成章节概要】(接续生成,注意衔接)
- {chr(10).join(previous_summaries)}
-
- ⚠️ 当前是第{current_start_index}-{current_start_index + current_batch_size - 1}节(共{target_chapter_count}节中的一部分)
- """
+【🔴 已生成章节完整信息(必须参考以确保差异化)】
+{chr(10).join(previous_summaries)}
+
+【🔴 已使用的关键事件(本批次不可重复使用)】
+{used_events_str}
+
+【🔴 差异化强制要求】
+⚠️ 当前是第{current_start_index}-{current_start_index + current_batch_size - 1}节(共{target_chapter_count}节中的第{batch_num + 1}批)
+⚠️ 每个新章节必须有完全不同的:
+ 1. 开场场景(不同地点/时间/人物状态)
+ 2. 核心事件(不与已生成章节的关键事件重复)
+ 3. 结尾悬念(不同类型的钩子)
+⚠️ 新章节的key_events不得与上面【已使用的关键事件】中的任何事件相同或相似
+"""
# 获取自定义提示词模板
template = await PromptService.get_template("OUTLINE_EXPAND_MULTI", project.user_id, db)
# 格式化提示词
@@ -501,7 +525,7 @@ class PlotExpansionService:
ai_response: str,
outline_id: str
) -> List[Dict[str, Any]]:
- """解析AI的展开响应(使用统一的JSON清洗方法)"""
+ """解析AI的展开响应(使用统一的JSON清洗方法,增强差异化字段)"""
try:
# 使用统一的JSON清洗方法
cleaned_text = self.ai_service._clean_json_response(ai_response)
@@ -513,11 +537,30 @@ class PlotExpansionService:
if not isinstance(chapter_plans, list):
chapter_plans = [chapter_plans]
- # 为每个章节规划添加outline_id
- for plan in chapter_plans:
+ # 为每个章节规划添加outline_id和差异化标识
+ for idx, plan in enumerate(chapter_plans):
plan["outline_id"] = outline_id
+
+ # 🔧 确保有 ending_type 字段(用于差异化追踪)
+ if "ending_type" not in plan:
+ # 根据叙事目标推断结尾类型
+ narrative_goal = plan.get("narrative_goal", "")
+ if "悬念" in narrative_goal or "疑问" in narrative_goal:
+ plan["ending_type"] = "悬念"
+ elif "冲突" in narrative_goal or "对抗" in narrative_goal:
+ plan["ending_type"] = "冲突升级"
+ elif "转折" in narrative_goal:
+ plan["ending_type"] = "情节转折"
+ elif "情感" in narrative_goal or "情绪" in narrative_goal:
+ plan["ending_type"] = "情感收尾"
+ else:
+ plan["ending_type"] = f"自然过渡-{idx + 1}"
+
+ # 🔧 确保 key_events 是列表且非空
+ if not plan.get("key_events"):
+ plan["key_events"] = [f"章节{idx + 1}核心事件"]
- logger.info(f"✅ 成功解析 {len(chapter_plans)} 个章节规划")
+ logger.info(f"✅ 成功解析 {len(chapter_plans)} 个章节规划(含差异化标识)")
return chapter_plans
except json.JSONDecodeError as e:
@@ -533,6 +576,7 @@ class PlotExpansionService:
"emotional_tone": "未知",
"narrative_goal": "需要重新生成",
"conflict_type": "未知",
+ "ending_type": "未知",
"estimated_words": 3000
}]
except Exception as e:
@@ -547,6 +591,7 @@ class PlotExpansionService:
"emotional_tone": "未知",
"narrative_goal": "需要重新生成",
"conflict_type": "未知",
+ "ending_type": "未知",
"estimated_words": 3000
}]
diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py
index 47d6b0c..077a412 100644
--- a/backend/app/services/prompt_service.py
+++ b/backend/app/services/prompt_service.py
@@ -561,7 +561,14 @@ class PromptService:
上一章结尾:
「{continuation_point}」
-⚠️ 要求:从此处自然续写,不得重复上述内容
+【🔴 上一章已完成剧情(禁止重复!)】
+{previous_chapter_summary}
+
+⚠️ 严重警告:
+1. 上述"已完成剧情"和"衔接锚点"是**已经写过的**内容
+2. 本章必须推进到**新的情节点**,绝对不能重新叙述已经发生的事件
+3. 如果锚点是对话结束,请描写对话后的动作或场景转换,不要重复对话
+4. 如果锚点是场景描写,请直接开始人物行动,不要重复描写环境
@@ -569,6 +576,11 @@ class PromptService:
{characters_info}
+
+【🎯 伏笔提醒 - 需关注】
+{foreshadow_reminders}
+
+
【相关记忆 - 参考】
{relevant_memories}
@@ -585,13 +597,20 @@ class PromptService:
✅ 自然承接上一章结尾,不重复已发生事件
✅ 保持角色性格、说话方式一致
✅ 字数控制在目标范围内
+✅ 如有伏笔提醒,请在本章中适当埋入或回收相应伏笔
+
+【🔴 反重复特别指令】
+✅ 检查本章开篇是否与"衔接锚点"内容重复
+✅ 检查本章情节是否与"上一章已完成剧情"重复
+✅ 确保本章推进到了大纲中规划的新事件
【禁止事项】
❌ 输出章节标题、序号等元信息
❌ 使用"总之"、"综上所述"等AI常见总结语
❌ 在结尾处使用开放式反问
❌ 添加作者注释或创作说明
-❌ 重复叙述上一章已发生的事件
+❌ 重复叙述上一章已发生的事件(包括环境描写、心理活动)
+❌ 在开篇使用"接上回"、"书接上文"等套话