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常见总结语 ❌ 在结尾处使用开放式反问 ❌ 添加作者注释或创作说明 -❌ 重复叙述上一章已发生的事件 +❌ 重复叙述上一章已发生的事件(包括环境描写、心理活动) +❌ 在开篇使用"接上回"、"书接上文"等套话 @@ -783,7 +802,7 @@ class PromptService: ❌ 空泛的描述 """ - # 情节分析提示词 V2(RTCO框架) + # 情节分析提示词 V2(RTCO框架 + 伏笔ID追踪) PLOT_ANALYSIS = """ 你是专业的小说编辑和剧情分析师,擅长深度剖析章节内容。 @@ -791,6 +810,12 @@ class PromptService: 【分析任务】 全面分析第{chapter_number}章《{title}》的剧情要素、钩子、伏笔、冲突和角色发展。 + +【🔴 伏笔追踪任务(重要)】 +系统已提供【已埋入伏笔列表】,当你识别到章节中有回收伏笔时: +1. 必须从列表中找出对应的伏笔ID +2. 在 foreshadows 数组中使用 reference_foreshadow_id 字段关联 +3. 如果无法确定是哪个伏笔,reference_foreshadow_id 填 null @@ -803,6 +828,13 @@ class PromptService: {content} + +【已埋入伏笔列表 - 用于回收匹配】 +以下是本项目中已埋入但尚未回收的伏笔,分析时如发现章节内容回收了某个伏笔,请使用对应的ID: + +{existing_foreshadows} + + 【分析维度】 @@ -820,12 +852,26 @@ class PromptService: - 出现位置(开头/中段/结尾) - **关键词**:【必填】从原文逐字复制8-25字的文本片段,用于精确定位 -**2. 伏笔分析 (Foreshadowing)** +**2. 伏笔分析 (Foreshadowing) - 🔴 支持ID追踪** - 埋下的新伏笔:内容、预期作用、隐藏程度(1-10) -- 回收的旧伏笔:呼应哪一章、回收效果 +- 回收的旧伏笔:【必须】从已埋入伏笔列表中匹配ID - 伏笔质量:巧妙性和合理性 - **关键词**:【必填】从原文逐字复制8-25字 +每个伏笔需要: +- **title**:简洁标题(10-20字,概括伏笔核心) +- **content**:详细描述伏笔内容和预期作用 +- **type**:planted(埋下)或 resolved(回收) +- **strength**:强度1-10(对读者的吸引力) +- **subtlety**:隐藏度1-10(越高越隐蔽) +- **reference_chapter**:回收时引用的原埋入章节号,埋下时为null +- **reference_foreshadow_id**:【回收时必填】被回收伏笔的ID(从已埋入伏笔列表中选择),埋下时为null +- **keyword**:【必填】从原文逐字复制8-25字的定位文本 +- **category**:分类(identity=身世/mystery=悬念/item=物品/relationship=关系/event=事件/ability=能力/prophecy=预言) +- **is_long_term**:是否长线伏笔(跨10章以上回收为true) +- **related_characters**:涉及的角色名列表 +- **estimated_resolve_chapter**:预估回收章节号(埋下时预估,回收时为当前章节) + **3. 冲突分析 (Conflict)** - 冲突类型:人与人/人与己/人与环境/人与社会 - 冲突各方及立场 @@ -921,12 +967,32 @@ class PromptService: ], "foreshadows": [ {{ - "content": "伏笔内容", + "title": "伏笔简洁标题", + "content": "伏笔详细内容和预期作用", "type": "planted", "strength": 7, "subtlety": 8, "reference_chapter": null, - "keyword": "从原文逐字复制的8-25字文本" + "reference_foreshadow_id": null, + "keyword": "从原文逐字复制的8-25字文本", + "category": "mystery", + "is_long_term": false, + "related_characters": ["角色A", "角色B"], + "estimated_resolve_chapter": 15 + }}, + {{ + "title": "回收的伏笔标题", + "content": "伏笔如何被回收的描述", + "type": "resolved", + "strength": 8, + "subtlety": 6, + "reference_chapter": 5, + "reference_foreshadow_id": "abc123-已埋入伏笔的ID", + "keyword": "从原文逐字复制的8-25字文本", + "category": "mystery", + "is_long_term": false, + "related_characters": ["角色A"], + "estimated_resolve_chapter": 10 }} ], "conflict": {{ @@ -998,6 +1064,7 @@ class PromptService: ✅ 逐字复制:keyword必须从原文复制,长度8-25字 ✅ 精确定位:keyword能在原文中精确找到 ✅ 职业变化可选:仅当章节明确描述时填写 +✅ 【伏笔ID追踪】回收伏笔时,必须从【已埋入伏笔列表】中查找匹配的ID填入 reference_foreshadow_id 【评分约束 - 严格执行】 ✅ 严格按评分标准打分,支持小数(如6.5、7.2、8.3) @@ -2336,7 +2403,7 @@ class PromptService: "description": "基于前置章节内容创作新章节(用于第2章及以后)", "parameters": ["project_title", "genre", "chapter_number", "chapter_title", "chapter_outline", "target_word_count", "narrative_perspective", "characters_info", "continuation_point", - "relevant_memories", "story_skeleton"] + "foreshadow_reminders", "relevant_memories", "story_skeleton", "previous_chapter_summary"] }, "CHAPTER_REGENERATION_SYSTEM": { "name": "章节重写系统提示",