From 5f25deb28925812e6f37de8919adcb77a31c5aeb Mon Sep 17 00:00:00 2001 From: xiamuceer-j Date: Mon, 19 Jan 2026 17:24:37 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E6=96=B0=E5=A2=9E=E4=BC=8F=E7=AC=94?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E8=BF=BD=E8=B8=AA=E3=80=81AI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=85=B3=E8=81=94=E5=9B=9E=E6=94=B6=E5=8F=8A?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E7=94=9F=E6=88=90=E6=97=B6=E7=9A=84=E4=BC=8F?= =?UTF-8?q?=E7=AC=94=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ...260119_1005_951919659e0f_添加伏笔管理表.py | 78 + backend/app/api/chapters.py | 269 ++- backend/app/api/foreshadows.py | 381 +++++ backend/app/api/memories.py | 51 +- backend/app/api/outlines.py | 80 +- backend/app/main.py | 3 +- backend/app/models/__init__.py | 4 +- backend/app/models/foreshadow.py | 178 ++ backend/app/schemas/foreshadow.py | 196 +++ backend/app/services/foreshadow_service.py | 1516 +++++++++++++++++ backend/app/services/plot_analyzer.py | 121 +- frontend/src/App.tsx | 2 + frontend/src/pages/Chapters.tsx | 24 +- frontend/src/pages/Foreshadows.tsx | 1021 +++++++++++ frontend/src/pages/ProjectDetail.tsx | 7 + frontend/src/pages/ProjectWizardNew.tsx | 2 +- frontend/src/services/api.ts | 85 +- frontend/src/types/index.ts | 140 ++ 19 files changed, 4068 insertions(+), 91 deletions(-) create mode 100644 backend/alembic/sqlite/versions/20260119_1005_951919659e0f_添加伏笔管理表.py create mode 100644 backend/app/api/foreshadows.py create mode 100644 backend/app/models/foreshadow.py create mode 100644 backend/app/schemas/foreshadow.py create mode 100644 backend/app/services/foreshadow_service.py create mode 100644 frontend/src/pages/Foreshadows.tsx diff --git a/.gitignore b/.gitignore index 4259454..689519a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ env/ *.swo *~ .DS_Store +.kilocode/ # Environment variables .env diff --git a/backend/alembic/sqlite/versions/20260119_1005_951919659e0f_添加伏笔管理表.py b/backend/alembic/sqlite/versions/20260119_1005_951919659e0f_添加伏笔管理表.py new file mode 100644 index 0000000..9622e25 --- /dev/null +++ b/backend/alembic/sqlite/versions/20260119_1005_951919659e0f_添加伏笔管理表.py @@ -0,0 +1,78 @@ +"""添加伏笔管理表 + +Revision ID: 951919659e0f +Revises: 7899f8d4d839 +Create Date: 2026-01-19 10:05:40.794044 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '951919659e0f' +down_revision: Union[str, None] = '7899f8d4d839' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('foreshadows', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False, comment='伏笔标题'), + sa.Column('content', sa.Text(), nullable=False, comment='伏笔详细内容/描述'), + sa.Column('hint_text', sa.Text(), nullable=True, comment='埋伏笔时的暗示文本(原文摘录或概述)'), + sa.Column('resolution_text', sa.Text(), nullable=True, comment='回收伏笔时的揭示文本(原文摘录或概述)'), + sa.Column('source_type', sa.String(length=20), nullable=True, comment='来源类型: analysis=分析提取, manual=手动添加'), + sa.Column('source_memory_id', sa.String(length=100), nullable=True, comment='来源记忆ID(如从分析结果同步)'), + sa.Column('source_analysis_id', sa.String(length=36), nullable=True, comment='来源分析任务ID'), + sa.Column('plant_chapter_id', sa.String(length=36), nullable=True, comment='埋入章节ID'), + sa.Column('plant_chapter_number', sa.Integer(), nullable=True, comment='埋入章节号(冗余存储便于查询)'), + sa.Column('target_resolve_chapter_id', sa.String(length=36), nullable=True, comment='计划回收章节ID'), + sa.Column('target_resolve_chapter_number', sa.Integer(), nullable=True, comment='计划回收章节号'), + sa.Column('actual_resolve_chapter_id', sa.String(length=36), nullable=True, comment='实际回收章节ID'), + sa.Column('actual_resolve_chapter_number', sa.Integer(), nullable=True, comment='实际回收章节号'), + sa.Column('status', sa.String(length=20), nullable=True, comment='\n 伏笔状态:\n - pending: 待埋入(已规划但未写入章节)\n - planted: 已埋入(已在章节中埋下)\n - resolved: 已回收(已在章节中回收)\n - partially_resolved: 部分回收(长线伏笔可能分多次回收)\n - abandoned: 已废弃(决定不再使用此伏笔)\n '), + sa.Column('is_long_term', sa.Boolean(), nullable=True, comment='是否长线伏笔(跨多章的重要伏笔)'), + sa.Column('importance', sa.Float(), nullable=True, comment='重要性评分 0.0-1.0'), + sa.Column('strength', sa.Integer(), nullable=True, comment='伏笔强度 1-10(影响读者多强烈)'), + sa.Column('subtlety', sa.Integer(), nullable=True, comment='隐藏度 1-10(越高越隐蔽)'), + sa.Column('urgency', sa.Integer(), nullable=True, comment='紧急度: 0=不紧急, 1=需关注, 2=急需回收'), + sa.Column('related_characters', sa.JSON(), nullable=True, comment="关联角色名列表: ['角色1', '角色2']"), + sa.Column('related_foreshadow_ids', sa.JSON(), nullable=True, comment='关联的其他伏笔ID列表(伏笔链)'), + sa.Column('tags', sa.JSON(), nullable=True, comment="标签列表: ['身世', '悬念', '反转']"), + sa.Column('category', sa.String(length=50), nullable=True, comment='分类: identity(身世), mystery(悬念), item(物品), relationship(关系), event(事件)'), + sa.Column('notes', sa.Text(), nullable=True, comment='创作备注(仅作者可见)'), + sa.Column('resolution_notes', sa.Text(), nullable=True, comment='回收方式说明'), + sa.Column('auto_remind', sa.Boolean(), nullable=True, comment='是否在章节生成时自动提醒'), + sa.Column('remind_before_chapters', sa.Integer(), nullable=True, comment='提前几章开始提醒回收'), + sa.Column('include_in_context', sa.Boolean(), nullable=True, comment='是否包含在生成上下文中'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.Column('planted_at', sa.DateTime(), nullable=True, comment='埋入时间'), + sa.Column('resolved_at', sa.DateTime(), nullable=True, comment='回收时间'), + sa.ForeignKeyConstraint(['actual_resolve_chapter_id'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['plant_chapter_id'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['target_resolve_chapter_id'], ['chapters.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('foreshadows', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_foreshadows_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_foreshadows_status'), ['status'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('foreshadows', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_foreshadows_status')) + batch_op.drop_index(batch_op.f('ix_foreshadows_project_id')) + + op.drop_table('foreshadows') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 923e328..3c290c5 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -43,6 +43,7 @@ from app.services.ai_service import AIService from app.services.prompt_service import prompt_service, PromptService, WritingStyleManager from app.services.plot_analyzer import PlotAnalyzer from app.services.memory_service import memory_service +from app.services.foreshadow_service import foreshadow_service from app.services.chapter_regenerator import ChapterRegenerator from app.logger import get_logger from app.api.settings import get_user_ai_service @@ -284,45 +285,58 @@ async def update_chapter( project.current_words = project.current_words - old_word_count + new_word_count # 如果内容被清空,清理相关数据 - if not chapter.content or chapter.content.strip() == "": - chapter.status = "draft" - - # 清理分析任务 - analysis_tasks_result = await db.execute( - select(AnalysisTask).where(AnalysisTask.chapter_id == chapter_id) - ) - analysis_tasks = analysis_tasks_result.scalars().all() - for task in analysis_tasks: - await db.delete(task) - - # 清理分析结果 - plot_analysis_result = await db.execute( - select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) - ) - plot_analyses = plot_analysis_result.scalars().all() - for analysis in plot_analyses: - await db.delete(analysis) - - # 清理故事记忆(关系数据库) - story_memories_result = await db.execute( - select(StoryMemory).where(StoryMemory.chapter_id == chapter_id) - ) - story_memories = story_memories_result.scalars().all() - for memory in story_memories: - await db.delete(memory) - - # 清理向量数据库中的记忆数据 - try: - await memory_service.delete_chapter_memories( - user_id=user_id, - project_id=chapter.project_id, - chapter_id=chapter_id + if not chapter.content or chapter.content.strip() == "": + chapter.status = "draft" + + # 清理分析任务 + analysis_tasks_result = await db.execute( + select(AnalysisTask).where(AnalysisTask.chapter_id == chapter_id) ) - logger.info(f"✅ 已清理章节 {chapter_id[:8]} 的向量记忆数据") - except Exception as e: - logger.warning(f"⚠️ 清理向量记忆数据失败: {str(e)}") - - logger.info(f"🗑️ 章节 {chapter_id[:8]} 内容已清空,已清理分析和记忆数据") + analysis_tasks = analysis_tasks_result.scalars().all() + for task in analysis_tasks: + await db.delete(task) + + # 清理分析结果 + plot_analysis_result = await db.execute( + select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) + ) + plot_analyses = plot_analysis_result.scalars().all() + for analysis in plot_analyses: + await db.delete(analysis) + + # 清理故事记忆(关系数据库) + story_memories_result = await db.execute( + select(StoryMemory).where(StoryMemory.chapter_id == chapter_id) + ) + story_memories = story_memories_result.scalars().all() + for memory in story_memories: + await db.delete(memory) + + # 清理向量数据库中的记忆数据 + try: + await memory_service.delete_chapter_memories( + user_id=user_id, + project_id=chapter.project_id, + chapter_id=chapter_id + ) + logger.info(f"✅ 已清理章节 {chapter_id[:8]} 的向量记忆数据") + except Exception as e: + logger.warning(f"⚠️ 清理向量记忆数据失败: {str(e)}") + + # 🔮 清理章节相关的分析伏笔数据 + try: + foreshadow_result = await foreshadow_service.delete_chapter_foreshadows( + db=db, + project_id=chapter.project_id, + chapter_id=chapter_id, + only_analysis_source=True # 只删除分析来源的伏笔,保留手动创建的 + ) + if foreshadow_result['deleted_count'] > 0: + logger.info(f"🔮 已清理章节 {chapter_id[:8]} 的 {foreshadow_result['deleted_count']} 个伏笔数据") + except Exception as e: + logger.warning(f"⚠️ 清理伏笔数据失败: {str(e)}") + + logger.info(f"🗑️ 章节 {chapter_id[:8]} 内容已清空,已清理分析、记忆和伏笔数据") await db.commit() await db.refresh(chapter) @@ -397,6 +411,20 @@ async def delete_chapter( logger.warning(f"⚠️ 清理向量记忆数据失败: {str(e)}") # 不阻断删除流程,继续执行 + # 🔮 清理与该章节相关的伏笔数据(仅分析来源的伏笔) + try: + foreshadow_result = await foreshadow_service.delete_chapter_foreshadows( + db=db, + project_id=chapter.project_id, + chapter_id=chapter_id, + only_analysis_source=True # 只删除分析来源的伏笔,保留手动创建的 + ) + if foreshadow_result['deleted_count'] > 0: + logger.info(f"🔮 已清理章节 {chapter_id[:8]} 的 {foreshadow_result['deleted_count']} 个伏笔数据") + except Exception as e: + logger.warning(f"⚠️ 清理伏笔数据失败: {str(e)}") + # 不阻断删除流程,继续执行 + # 删除章节(关系数据库中的记忆会被级联删除) await db.delete(chapter) await db.commit() @@ -861,13 +889,22 @@ async def analyze_chapter_background( task.progress = 20 await db_session.commit() - # 3. 使用PlotAnalyzer分析章节 + # 获取已埋入的伏笔列表(用于回收匹配,传入当前章节号以启用智能标记) + existing_foreshadows = await foreshadow_service.get_planted_foreshadows_for_analysis( + db=db_session, + project_id=project_id, + current_chapter_number=chapter.chapter_number # 传入当前章节号以启用智能标记 + ) + logger.info(f"📋 后台分析 - 已获取{len(existing_foreshadows)}个已埋入伏笔用于匹配(含智能回收标记)") + + # 3. 使用PlotAnalyzer分析章节(传入已有伏笔列表) analyzer = PlotAnalyzer(ai_service) analysis_result = await analyzer.analyze_chapter( chapter_number=chapter.chapter_number, title=chapter.title, content=chapter.content, - word_count=chapter.word_count or len(chapter.content) + word_count=chapter.word_count or len(chapter.content), + existing_foreshadows=existing_foreshadows ) if not analysis_result: @@ -953,7 +990,20 @@ async def analyze_chapter_background( task.progress = 80 await db_session.commit() - # 5. 提取记忆并保存到向量数据库(传入章节内容用于计算位置) + # 5. 清理旧的分析伏笔(重新分析时需要先清理) + try: + async with write_lock: + clean_result = await foreshadow_service.clean_chapter_analysis_foreshadows( + db=db_session, + project_id=project_id, + chapter_id=chapter_id + ) + if clean_result['cleaned_count'] > 0: + logger.info(f"🧹 重新分析前清理了 {clean_result['cleaned_count']} 个旧伏笔") + except Exception as clean_error: + logger.warning(f"⚠️ 清理旧伏笔失败(继续分析): {str(clean_error)}") + + # 6. 提取记忆并保存到向量数据库(传入章节内容用于计算位置) memories = analyzer.extract_memories_from_analysis( analysis=analysis_result, chapter_id=chapter_id, @@ -1053,6 +1103,33 @@ async def analyze_chapter_background( else: logger.debug("📋 分析结果中无角色状态信息,跳过职业更新") + # 🔮 自动更新伏笔状态(根据分析结果) + if analysis_result.get('foreshadows'): + try: + logger.info(f"🔮 开始根据分析结果自动更新伏笔状态...") + async with write_lock: + foreshadow_stats = await foreshadow_service.auto_update_from_analysis( + db=db_session, + project_id=project_id, + chapter_id=chapter_id, + chapter_number=chapter.chapter_number, + analysis_foreshadows=analysis_result.get('foreshadows', []) + ) + + if foreshadow_stats['planted_count'] > 0 or foreshadow_stats['resolved_count'] > 0: + logger.info( + f"✅ 伏笔自动更新: 埋入{foreshadow_stats['planted_count']}个, " + f"回收{foreshadow_stats['resolved_count']}个" + ) + else: + logger.info("ℹ️ 本章节无新的伏笔状态变化") + + except Exception as foreshadow_error: + # 伏笔更新失败不应影响整个分析流程 + logger.error(f"⚠️ 自动更新伏笔失败: {str(foreshadow_error)}", exc_info=True) + else: + logger.debug("📋 分析结果中无伏笔信息,跳过伏笔自动更新") + # 最终更新任务状态(写操作,需要锁)- 增加重试机制 update_success = False for retry in range(3): @@ -1293,9 +1370,9 @@ async def generate_chapter_content_stream( else: logger.info("未指定写作风格,使用原始提示词") - # 🚀 使用新的优化上下文构建器 - logger.info(f"🔧 使用优化的章节上下文构建器(V2)") - context_builder = ChapterContextBuilder() + # 🚀 使用新的优化上下文构建器(含伏笔服务) + logger.info(f"🔧 使用优化的章节上下文构建器(V2 + 伏笔提醒)") + context_builder = ChapterContextBuilder(foreshadow_service=foreshadow_service) chapter_context = await context_builder.build( chapter=current_chapter, project=project, @@ -1350,9 +1427,12 @@ async def generate_chapter_content_stream( if current_chapter.summary and current_chapter.summary.strip(): chapter_outline_content += f"\n\n【章节补充说明】\n{current_chapter.summary}" - # 可选:附加大纲的背景信息 + # 可选:附加大纲的背景信息(限制长度,避免喧宾夺主) if outline: - chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}" + outline_bg = outline.content + if len(outline_bg) > 200: + outline_bg = outline_bg[:200] + "..." + chapter_outline_content += f"\n\n【大纲节点背景】\n{outline_bg}" logger.info(f"✏️ 一对多模式:使用expansion_plan详细规划({len(chapter_outline_content)}字符)") except json.JSONDecodeError as e: @@ -1366,6 +1446,18 @@ async def generate_chapter_content_stream( # 🚀 使用 V2 优化模板构建提示词 if chapter_context.continuation_point: # 有前置内容,使用 WITH_CONTEXT 模板 + + # 尝试从context中提取上一章摘要 + previous_summary = "(无上一章摘要,请根据锚点续写)" + if chapter_context.context_stats.get('recent_summaries', 0) > 0: + # 简单的提取逻辑,实际可能需要更精确的解析 + # 但在这里,context_stats并没有直接存储内容。 + # 我们利用ChapterContext对象中可能存在的summary信息,或者直接从recent_summary文本中截取最后一段 + if hasattr(chapter_context, 'recent_summary') and chapter_context.recent_summary: + lines = chapter_context.recent_summary.strip().split('\n') + if lines: + previous_summary = lines[-1] + template = await PromptService.get_template("CHAPTER_GENERATION_V2_WITH_CONTEXT", current_user_id, db_session) base_prompt = PromptService.format_prompt( template, @@ -1380,6 +1472,8 @@ async def generate_chapter_content_stream( genre=project.genre or '未设定', narrative_perspective=chapter_perspective, characters_info=characters_info or '暂无角色信息', + foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔', + previous_chapter_summary=previous_summary, # P2 参考参数(动态裁剪后的) story_skeleton=chapter_context.story_skeleton or '', relevant_memories=chapter_context.relevant_memories or '' @@ -1494,6 +1588,20 @@ async def generate_chapter_content_stream( logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字") + # 🔮 章节生成后自动标记计划在本章埋入的伏笔 + try: + plant_result = await foreshadow_service.auto_plant_pending_foreshadows( + db=db_session, + project_id=project.id, + chapter_id=chapter_id, + chapter_number=current_chapter.chapter_number, + chapter_content=full_content + ) + if plant_result.get('planted_count', 0) > 0: + logger.info(f"🔮 自动标记伏笔已埋入: {plant_result['planted_count']}个") + except Exception as plant_error: + logger.warning(f"⚠️ 自动标记伏笔埋入失败: {str(plant_error)}") + # 创建分析任务 analysis_task = AnalysisTask( chapter_id=chapter_id, @@ -2266,6 +2374,9 @@ async def execute_batch_generation_in_order( task.started_at = datetime.now() await db_session.commit() + # 维护上一章的摘要,用于传递给下一章(防重复上下文) + last_generated_summary = None + # 按顺序生成每个章节 for idx, chapter_id in enumerate(task.chapter_ids, 1): # 检查任务是否被取消 @@ -2314,7 +2425,8 @@ async def execute_batch_generation_in_order( raise Exception(f"前置条件不满足: {error_msg}") # 生成章节内容(复用现有流式生成逻辑的核心部分),传递model参数 - await generate_single_chapter_for_batch( + # 并获取生成后的摘要(如果生成函数支持返回) + generated_summary = await generate_single_chapter_for_batch( db_session=db_session, chapter=chapter, user_id=user_id, @@ -2322,9 +2434,15 @@ async def execute_batch_generation_in_order( target_word_count=task.target_word_count, ai_service=ai_service, write_lock=write_lock, - custom_model=custom_model + custom_model=custom_model, + previous_summary_context=last_generated_summary ) + # 更新上一章摘要,供下一章使用 + if generated_summary: + last_generated_summary = f"第{chapter.chapter_number}章《{chapter.title}》:{generated_summary}" + logger.info(f"📝 已更新上一章摘要上下文: {last_generated_summary[:50]}...") + logger.info(f"✅ 章节生成完成: 第{chapter.chapter_number}章") # 如果启用同步分析 @@ -2499,11 +2617,15 @@ async def generate_single_chapter_for_batch( target_word_count: int, ai_service: AIService, write_lock: Lock, - custom_model: Optional[str] = None -): + custom_model: Optional[str] = None, + previous_summary_context: Optional[str] = None +) -> Optional[str]: """ 为批量生成执行单个章节的生成(非流式) 复用现有生成逻辑的核心部分 + + Returns: + 生成章节的摘要(前200字) """ # 获取项目信息 project_result = await db_session.execute( @@ -2584,9 +2706,9 @@ async def generate_single_chapter_for_batch( if style.user_id is None or style.user_id == user_id: style_content = style.prompt_content or "" - # 🚀 使用新的优化上下文构建器 - logger.info(f"🔧 批量生成 - 使用优化的章节上下文构建器(V2)") - context_builder = ChapterContextBuilder() + # 🚀 使用新的优化上下文构建器(含伏笔服务) + logger.info(f"🔧 批量生成 - 使用优化的章节上下文构建器(V2 + 伏笔提醒)") + context_builder = ChapterContextBuilder(foreshadow_service=foreshadow_service) chapter_context = await context_builder.build( chapter=chapter, project=project, @@ -2631,9 +2753,12 @@ async def generate_single_chapter_for_batch( if chapter.summary and chapter.summary.strip(): chapter_outline_content += f"\n\n【章节补充说明】\n{chapter.summary}" - # 可选:附加大纲的背景信息 + # 可选:附加大纲的背景信息(限制长度) if outline: - chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}" + outline_bg = outline.content + if len(outline_bg) > 200: + outline_bg = outline_bg[:200] + "..." + chapter_outline_content += f"\n\n【大纲节点背景】\n{outline_bg}" logger.info(f"✏️ 批量生成 - 一对多模式:使用expansion_plan详细规划") except json.JSONDecodeError as e: @@ -2647,6 +2772,18 @@ async def generate_single_chapter_for_batch( # 🚀 使用 V2 优化模板构建提示词(批量生成) if chapter_context.continuation_point: # 有前置内容,使用 WITH_CONTEXT 模板 + + # 确定上一章摘要:优先使用传入的 previous_summary_context(批量生成的上一章), + # 否则尝试从 chapter_context 中获取 + final_prev_summary = "(无上一章摘要,请根据锚点续写)" + + if previous_summary_context: + final_prev_summary = previous_summary_context + elif hasattr(chapter_context, 'recent_summary') and chapter_context.recent_summary: + lines = chapter_context.recent_summary.strip().split('\n') + if lines: + final_prev_summary = lines[-1] + template = await PromptService.get_template("CHAPTER_GENERATION_V2_WITH_CONTEXT", user_id, db_session) base_prompt = PromptService.format_prompt( template, @@ -2661,6 +2798,8 @@ async def generate_single_chapter_for_batch( genre=project.genre or '未设定', narrative_perspective=project.narrative_perspective or '第三人称', characters_info=characters_info or '暂无角色信息', + foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔', + previous_chapter_summary=final_prev_summary, # P2 参考参数(动态裁剪后的) story_skeleton=chapter_context.story_skeleton or '', relevant_memories=chapter_context.relevant_memories or '' @@ -2741,6 +2880,26 @@ async def generate_single_chapter_for_batch( await db_session.refresh(chapter) logger.info(f"✅ 单章节生成完成: 第{chapter.chapter_number}章,共 {new_word_count} 字") + + # 生成简短摘要返回 + summary_preview = full_content[:300].replace('\n', ' ') if full_content else "" + + # 🔮 批量生成后自动标记计划在本章埋入的伏笔 + try: + async with write_lock: + plant_result = await foreshadow_service.auto_plant_pending_foreshadows( + db=db_session, + project_id=chapter.project_id, + chapter_id=chapter.id, + chapter_number=chapter.chapter_number, + chapter_content=full_content + ) + if plant_result.get('planted_count', 0) > 0: + logger.info(f"🔮 批量生成 - 自动标记伏笔已埋入: {plant_result['planted_count']}个") + except Exception as plant_error: + logger.warning(f"⚠️ 批量生成 - 自动标记伏笔埋入失败: {str(plant_error)}") + + return summary_preview diff --git a/backend/app/api/foreshadows.py b/backend/app/api/foreshadows.py new file mode 100644 index 0000000..e9dcb32 --- /dev/null +++ b/backend/app/api/foreshadows.py @@ -0,0 +1,381 @@ +"""伏笔管理API路由""" +from fastapi import APIRouter, Depends, HTTPException, Request, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional, List + +from app.database import get_db +from app.api.common import verify_project_access +from app.services.foreshadow_service import foreshadow_service +from app.schemas.foreshadow import ( + ForeshadowCreate, + ForeshadowUpdate, + ForeshadowResponse, + ForeshadowListResponse, + ForeshadowStatsResponse, + PlantForeshadowRequest, + ResolveForeshadowRequest, + SyncFromAnalysisRequest, + SyncFromAnalysisResponse, + ForeshadowContextResponse +) +from app.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/api/foreshadows", tags=["foreshadows"]) + + +@router.get("/projects/{project_id}", response_model=ForeshadowListResponse) +async def get_project_foreshadows( + project_id: str, + request: Request, + status: Optional[str] = Query(None, description="状态筛选: pending/planted/resolved/abandoned"), + category: Optional[str] = Query(None, description="分类筛选"), + source_type: Optional[str] = Query(None, description="来源筛选: analysis/manual"), + is_long_term: Optional[bool] = Query(None, description="是否长线伏笔"), + page: int = Query(1, ge=1, description="页码"), + limit: int = Query(50, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db) +): + """ + 获取项目所有伏笔 + + 支持按状态、分类、来源筛选,支持分页 + """ + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(project_id, user_id, db) + + result = await foreshadow_service.get_project_foreshadows( + db=db, + project_id=project_id, + status=status, + category=category, + source_type=source_type, + is_long_term=is_long_term, + page=page, + limit=limit + ) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 获取伏笔列表失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取伏笔列表失败: {str(e)}") + + +@router.get("/projects/{project_id}/stats", response_model=ForeshadowStatsResponse) +async def get_foreshadow_stats( + project_id: str, + request: Request, + current_chapter: Optional[int] = Query(None, ge=1, description="当前章节号(用于计算超期)"), + db: AsyncSession = Depends(get_db) +): + """获取项目伏笔统计""" + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(project_id, user_id, db) + + stats = await foreshadow_service.get_stats(db, project_id, current_chapter) + return stats + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 获取伏笔统计失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取伏笔统计失败: {str(e)}") + + +@router.get("/projects/{project_id}/context/{chapter_number}", response_model=ForeshadowContextResponse) +async def get_chapter_foreshadow_context( + project_id: str, + chapter_number: int, + request: Request, + include_pending: bool = Query(True, description="包含待埋入伏笔"), + include_overdue: bool = Query(True, description="包含超期伏笔"), + lookahead: int = Query(5, ge=1, le=20, description="向前看几章"), + db: AsyncSession = Depends(get_db) +): + """ + 获取章节生成的伏笔上下文 + + 用于在章节生成时提供伏笔提醒 + """ + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(project_id, user_id, db) + + context = await foreshadow_service.build_chapter_context( + db=db, + project_id=project_id, + chapter_number=chapter_number, + include_pending=include_pending, + include_overdue=include_overdue, + lookahead=lookahead + ) + + return context + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 获取伏笔上下文失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取伏笔上下文失败: {str(e)}") + + +@router.get("/projects/{project_id}/pending-resolve") +async def get_pending_resolve_foreshadows( + project_id: str, + request: Request, + current_chapter: int = Query(..., ge=1, description="当前章节号"), + lookahead: int = Query(5, ge=1, le=20, description="向前看几章"), + db: AsyncSession = Depends(get_db) +): + """获取待回收伏笔列表(用于章节生成提醒)""" + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(project_id, user_id, db) + + foreshadows = await foreshadow_service.get_pending_resolve_foreshadows( + db=db, + project_id=project_id, + current_chapter=current_chapter, + lookahead=lookahead + ) + + return { + "total": len(foreshadows), + "items": [f.to_dict() for f in foreshadows] + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 获取待回收伏笔失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取待回收伏笔失败: {str(e)}") + + +@router.get("/{foreshadow_id}", response_model=ForeshadowResponse) +async def get_foreshadow( + foreshadow_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """获取单个伏笔详情""" + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + # 验证权限 + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + return foreshadow.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 获取伏笔详情失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取伏笔详情失败: {str(e)}") + + +@router.post("", response_model=ForeshadowResponse) +async def create_foreshadow( + data: ForeshadowCreate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 创建伏笔(手动添加) + + 创建一个新的自定义伏笔 + """ + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(data.project_id, user_id, db) + + foreshadow = await foreshadow_service.create_foreshadow(db, data) + return foreshadow.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 创建伏笔失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建伏笔失败: {str(e)}") + + +@router.put("/{foreshadow_id}", response_model=ForeshadowResponse) +async def update_foreshadow( + foreshadow_id: str, + data: ForeshadowUpdate, + request: Request, + db: AsyncSession = Depends(get_db) +): + """更新伏笔""" + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + updated = await foreshadow_service.update_foreshadow(db, foreshadow_id, data) + return updated.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 更新伏笔失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"更新伏笔失败: {str(e)}") + + +@router.delete("/{foreshadow_id}") +async def delete_foreshadow( + foreshadow_id: str, + request: Request, + db: AsyncSession = Depends(get_db) +): + """删除伏笔""" + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + await foreshadow_service.delete_foreshadow(db, foreshadow_id) + + return {"message": "伏笔删除成功", "id": foreshadow_id} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 删除伏笔失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"删除伏笔失败: {str(e)}") + + +@router.post("/{foreshadow_id}/plant", response_model=ForeshadowResponse) +async def plant_foreshadow( + foreshadow_id: str, + data: PlantForeshadowRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 标记伏笔为已埋入 + + 将伏笔状态从pending改为planted,记录埋入章节 + """ + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + updated = await foreshadow_service.mark_as_planted(db, foreshadow_id, data) + return updated.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 标记伏笔埋入失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"标记伏笔埋入失败: {str(e)}") + + +@router.post("/{foreshadow_id}/resolve", response_model=ForeshadowResponse) +async def resolve_foreshadow( + foreshadow_id: str, + data: ResolveForeshadowRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 标记伏笔为已回收 + + 将伏笔状态改为resolved或partially_resolved + """ + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + updated = await foreshadow_service.mark_as_resolved(db, foreshadow_id, data) + return updated.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 标记伏笔回收失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"标记伏笔回收失败: {str(e)}") + + +@router.post("/{foreshadow_id}/abandon", response_model=ForeshadowResponse) +async def abandon_foreshadow( + foreshadow_id: str, + request: Request, + reason: Optional[str] = Query(None, description="废弃原因"), + db: AsyncSession = Depends(get_db) +): + """ + 标记伏笔为已废弃 + + 决定不再使用此伏笔 + """ + try: + foreshadow = await foreshadow_service.get_foreshadow(db, foreshadow_id) + + if not foreshadow: + raise HTTPException(status_code=404, detail="伏笔不存在") + + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(foreshadow.project_id, user_id, db) + + updated = await foreshadow_service.mark_as_abandoned(db, foreshadow_id, reason) + return updated.to_dict() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 标记伏笔废弃失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"标记伏笔废弃失败: {str(e)}") + + +@router.post("/projects/{project_id}/sync-from-analysis", response_model=SyncFromAnalysisResponse) +async def sync_foreshadows_from_analysis( + project_id: str, + data: SyncFromAnalysisRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 从分析结果同步伏笔 + + 从章节分析结果中提取伏笔信息,同步到伏笔管理表 + """ + try: + user_id = getattr(request.state, 'user_id', None) + await verify_project_access(project_id, user_id, db) + + result = await foreshadow_service.sync_from_analysis(db, project_id, data) + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ 同步伏笔失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"同步伏笔失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/memories.py b/backend/app/api/memories.py index b2b7ee1..a7cd221 100644 --- a/backend/app/api/memories.py +++ b/backend/app/api/memories.py @@ -1,7 +1,7 @@ """记忆管理API - 提供记忆的查询、分析等接口""" from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, desc +from sqlalchemy import select, and_, desc, delete from typing import List, Optional from app.database import get_db from app.models.memory import StoryMemory, PlotAnalysis @@ -9,6 +9,7 @@ from app.models.chapter import Chapter from app.models.project import Project from app.services.memory_service import memory_service from app.services.plot_analyzer import get_plot_analyzer +from app.services.foreshadow_service import foreshadow_service from app.services.ai_service import create_user_ai_service from app.models.settings import Settings from app.logger import get_logger @@ -71,13 +72,23 @@ async def analyze_chapter( max_tokens=settings.max_tokens ) - # 执行剧情分析 + # 获取已埋入的伏笔列表(用于回收匹配) + existing_foreshadows = await foreshadow_service.get_planted_foreshadows_for_analysis( + db=db, + project_id=project_id + ) + logger.info(f"📋 已获取{len(existing_foreshadows)}个已埋入伏笔用于分析匹配") + + # 执行剧情分析(传入已有伏笔列表) analyzer = get_plot_analyzer(ai_service) analysis_result = await analyzer.analyze_chapter( chapter_number=chapter.chapter_number, title=chapter.title, content=chapter.content, - word_count=chapter.word_count or len(chapter.content) + word_count=chapter.word_count or len(chapter.content), + user_id=user_id, + db=db, + existing_foreshadows=existing_foreshadows ) if not analysis_result: @@ -116,16 +127,14 @@ async def analyze_chapter( word_count=chapter.word_count ) - # 检查是否已存在分析记录 - existing = await db.execute( + # 检查是否已存在分析记录,如有则删除 + existing_result = await db.execute( select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) ) - if existing.scalar_one_or_none(): - # 删除旧记录 - await db.execute( - select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id) - ) - await db.delete(existing.scalar_one()) + existing_analysis = existing_result.scalar_one_or_none() + if existing_analysis: + await db.delete(existing_analysis) + await db.flush() db.add(plot_analysis) await db.commit() @@ -169,13 +178,31 @@ async def analyze_chapter( await db.commit() + # 【新增】自动更新伏笔状态 + foreshadow_stats = {"planted_count": 0, "resolved_count": 0, "created_count": 0} + analysis_foreshadows = analysis_result.get('foreshadows', []) + + if analysis_foreshadows: + try: + foreshadow_stats = await foreshadow_service.auto_update_from_analysis( + db=db, + project_id=project_id, + chapter_id=chapter_id, + chapter_number=chapter.chapter_number, + analysis_foreshadows=analysis_foreshadows + ) + logger.info(f"📊 伏笔自动更新: 埋入{foreshadow_stats['planted_count']}个, 回收{foreshadow_stats['resolved_count']}个") + except Exception as fs_error: + logger.error(f"⚠️ 伏笔自动更新失败(不影响分析结果): {str(fs_error)}") + logger.info(f"✅ 章节分析完成: 保存{saved_count}条记忆") return { "success": True, "message": f"分析完成,提取了{saved_count}条记忆", "analysis": plot_analysis.to_dict(), - "memories_count": saved_count + "memories_count": saved_count, + "foreshadow_stats": foreshadow_stats } except HTTPException: diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 8c9bd8a..11d5c15 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -35,6 +35,8 @@ from app.services.ai_service import AIService from app.services.prompt_service import prompt_service, PromptService from app.services.memory_service import memory_service from app.services.plot_expansion_service import PlotExpansionService +from app.services.foreshadow_service import foreshadow_service +from app.services.memory_service import memory_service from app.logger import get_logger from app.api.settings import get_user_ai_service from app.utils.sse_response import SSEResponse, create_sse_response, WizardProgressTracker @@ -261,7 +263,7 @@ async def delete_outline( request: Request, db: AsyncSession = Depends(get_db) ): - """删除大纲,同时删除该大纲对应的所有章节""" + """删除大纲,同时删除该大纲对应的所有章节和相关的伏笔数据""" result = await db.execute( select(Outline).where(Outline.id == outline_id) ) @@ -279,6 +281,7 @@ async def delete_outline( # 获取要删除的章节并计算总字数 deleted_word_count = 0 + deleted_foreshadow_count = 0 if project.outline_mode == 'one-to-one': # one-to-one模式:通过chapter_number获取对应章节 chapters_result = await db.execute( @@ -290,6 +293,33 @@ async def delete_outline( chapters_to_delete = chapters_result.scalars().all() deleted_word_count = sum(ch.word_count or 0 for ch in chapters_to_delete) + # 🔮 清理章节相关的伏笔数据和向量记忆 + for chapter in chapters_to_delete: + try: + # 清理向量数据库中的记忆数据 + await memory_service.delete_chapter_memories( + user_id=user_id, + project_id=project_id, + chapter_id=chapter.id + ) + logger.info(f"✅ 已清理章节 {chapter.id[:8]} 的向量记忆数据") + except Exception as e: + logger.warning(f"⚠️ 清理章节 {chapter.id[:8]} 向量记忆失败: {str(e)}") + + try: + # 清理伏笔数据(分析来源的伏笔) + foreshadow_result = await foreshadow_service.delete_chapter_foreshadows( + db=db, + project_id=project_id, + chapter_id=chapter.id, + only_analysis_source=True + ) + deleted_foreshadow_count += foreshadow_result.get('deleted_count', 0) + if foreshadow_result.get('deleted_count', 0) > 0: + logger.info(f"🔮 已清理章节 {chapter.id[:8]} 的 {foreshadow_result['deleted_count']} 个伏笔数据") + except Exception as e: + logger.warning(f"⚠️ 清理章节 {chapter.id[:8]} 伏笔数据失败: {str(e)}") + # 删除章节 delete_result = await db.execute( delete(Chapter).where( @@ -298,7 +328,7 @@ async def delete_outline( ) ) deleted_chapters_count = delete_result.rowcount - logger.info(f"一对一模式:删除大纲 {outline_id}(序号{outline.order_index}),同时删除了第{outline.order_index}章({deleted_chapters_count}个章节,{deleted_word_count}字)") + logger.info(f"一对一模式:删除大纲 {outline_id}(序号{outline.order_index}),同时删除了第{outline.order_index}章({deleted_chapters_count}个章节,{deleted_word_count}字,{deleted_foreshadow_count}个伏笔)") else: # one-to-many模式:通过outline_id获取关联章节 chapters_result = await db.execute( @@ -307,12 +337,39 @@ async def delete_outline( chapters_to_delete = chapters_result.scalars().all() deleted_word_count = sum(ch.word_count or 0 for ch in chapters_to_delete) + # 🔮 清理章节相关的伏笔数据和向量记忆 + for chapter in chapters_to_delete: + try: + # 清理向量数据库中的记忆数据 + await memory_service.delete_chapter_memories( + user_id=user_id, + project_id=project_id, + chapter_id=chapter.id + ) + logger.info(f"✅ 已清理章节 {chapter.id[:8]} 的向量记忆数据") + except Exception as e: + logger.warning(f"⚠️ 清理章节 {chapter.id[:8]} 向量记忆失败: {str(e)}") + + try: + # 清理伏笔数据(分析来源的伏笔) + foreshadow_result = await foreshadow_service.delete_chapter_foreshadows( + db=db, + project_id=project_id, + chapter_id=chapter.id, + only_analysis_source=True + ) + deleted_foreshadow_count += foreshadow_result.get('deleted_count', 0) + if foreshadow_result.get('deleted_count', 0) > 0: + logger.info(f"🔮 已清理章节 {chapter.id[:8]} 的 {foreshadow_result['deleted_count']} 个伏笔数据") + except Exception as e: + logger.warning(f"⚠️ 清理章节 {chapter.id[:8]} 伏笔数据失败: {str(e)}") + # 删除章节 delete_result = await db.execute( delete(Chapter).where(Chapter.outline_id == outline_id) ) deleted_chapters_count = delete_result.rowcount - logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节({deleted_word_count}字)") + logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节({deleted_word_count}字,{deleted_foreshadow_count}个伏笔)") # 更新项目字数 if deleted_word_count > 0: @@ -353,7 +410,8 @@ async def delete_outline( return { "message": "大纲删除成功", - "deleted_chapters": deleted_chapters_count + "deleted_chapters": deleted_chapters_count, + "deleted_foreshadows": deleted_foreshadow_count } @@ -614,6 +672,12 @@ async def _generate_new_outline( # 全新生成模式:删除旧大纲和关联的所有章节 logger.info(f"全新生成:删除项目 {project.id} 的旧大纲和章节(outline_mode: {project.outline_mode})") + # 清理伏笔数据 + try: + await foreshadow_service.clear_project_foreshadows_for_reset(db, project.id) + except Exception as e: + logger.warning(f"清理伏笔数据失败(不影响主流程): {str(e)}") + from sqlalchemy import delete as sql_delete # 先获取所有旧章节并计算总字数 @@ -1601,9 +1665,15 @@ async def new_outline_generator( logger.info(f"🔄 重试生成完成,累计{len(ai_content)}字符") # 全新生成模式:删除旧大纲和关联的所有章节 - yield await tracker.saving("清理旧大纲和章节...", 0.2) + yield await tracker.saving("清理旧大纲、章节和伏笔...", 0.2) logger.info(f"全新生成:删除项目 {project_id} 的旧大纲和章节(outline_mode: {project.outline_mode})") + # 清理伏笔数据 + try: + await foreshadow_service.clear_project_foreshadows_for_reset(db, project_id) + except Exception as e: + logger.warning(f"清理伏笔数据失败(不影响主流程): {str(e)}") + from sqlalchemy import delete as sql_delete # 先获取所有旧章节并计算总字数 diff --git a/backend/app/main.py b/backend/app/main.py index 9f0f462..e35be8b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -130,7 +130,7 @@ from app.api import ( wizard_stream, relationships, organizations, auth, users, settings, writing_styles, memories, mcp_plugins, admin, inspiration, prompt_templates, - changelog, careers + changelog, careers, foreshadows ) app.include_router(auth.router, prefix="/api") @@ -149,6 +149,7 @@ app.include_router(relationships.router, prefix="/api") app.include_router(organizations.router, prefix="/api") app.include_router(writing_styles.router, prefix="/api") app.include_router(memories.router) # 记忆管理API (已包含/api前缀) +app.include_router(foreshadows.router) # 伏笔管理API (已包含/api前缀) app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API app.include_router(changelog.router, prefix="/api") # 更新日志API diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bdec837..8acb79f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.user import User, UserPassword from app.models.regeneration_task import RegenerationTask from app.models.career import Career, CharacterCareer from app.models.prompt_template import PromptTemplate +from app.models.foreshadow import Foreshadow __all__ = [ "Project", @@ -40,5 +41,6 @@ __all__ = [ "RegenerationTask", "Career", "CharacterCareer", - "PromptTemplate" + "PromptTemplate", + "Foreshadow" ] \ No newline at end of file diff --git a/backend/app/models/foreshadow.py b/backend/app/models/foreshadow.py new file mode 100644 index 0000000..05df515 --- /dev/null +++ b/backend/app/models/foreshadow.py @@ -0,0 +1,178 @@ +"""伏笔管理数据模型 - 独立管理小说伏笔的埋入和回收""" +from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, Float, JSON, Boolean +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.database import Base +import uuid + + +class Foreshadow(Base): + """ + 伏笔管理表 - 独立管理小说伏笔 + + 支持以下功能: + 1. 从章节分析结果自动同步伏笔 + 2. 用户手动添加自定义伏笔 + 3. 关联埋入章节和计划回收章节 + 4. 长线伏笔管理 + 5. 章节生成时的伏笔提醒 + """ + __tablename__ = "foreshadows" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) + + # === 伏笔内容 === + title = Column(String(200), nullable=False, comment="伏笔标题") + content = Column(Text, nullable=False, comment="伏笔详细内容/描述") + hint_text = Column(Text, comment="埋伏笔时的暗示文本(原文摘录或概述)") + resolution_text = Column(Text, comment="回收伏笔时的揭示文本(原文摘录或概述)") + + # === 来源信息 === + source_type = Column(String(20), default='manual', comment="来源类型: analysis=分析提取, manual=手动添加") + source_memory_id = Column(String(100), comment="来源记忆ID(如从分析结果同步)") + source_analysis_id = Column(String(36), comment="来源分析任务ID") + + # === 章节关联 === + # 埋入章节 + plant_chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), comment="埋入章节ID") + plant_chapter_number = Column(Integer, comment="埋入章节号(冗余存储便于查询)") + + # 计划回收章节 + target_resolve_chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), comment="计划回收章节ID") + target_resolve_chapter_number = Column(Integer, comment="计划回收章节号") + + # 实际回收章节 + actual_resolve_chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), comment="实际回收章节ID") + actual_resolve_chapter_number = Column(Integer, comment="实际回收章节号") + + # === 状态管理 === + status = Column(String(20), default='pending', index=True, comment=""" + 伏笔状态: + - pending: 待埋入(已规划但未写入章节) + - planted: 已埋入(已在章节中埋下) + - resolved: 已回收(已在章节中回收) + - partially_resolved: 部分回收(长线伏笔可能分多次回收) + - abandoned: 已废弃(决定不再使用此伏笔) + """) + + is_long_term = Column(Boolean, default=False, comment="是否长线伏笔(跨多章的重要伏笔)") + + # === 重要性和优先级 === + importance = Column(Float, default=0.5, comment="重要性评分 0.0-1.0") + strength = Column(Integer, default=5, comment="伏笔强度 1-10(影响读者多强烈)") + subtlety = Column(Integer, default=5, comment="隐藏度 1-10(越高越隐蔽)") + urgency = Column(Integer, default=0, comment="紧急度: 0=不紧急, 1=需关注, 2=急需回收") + + # === 关联信息 === + related_characters = Column(JSON, comment="关联角色名列表: ['角色1', '角色2']") + related_foreshadow_ids = Column(JSON, comment="关联的其他伏笔ID列表(伏笔链)") + tags = Column(JSON, comment="标签列表: ['身世', '悬念', '反转']") + category = Column(String(50), comment="分类: identity(身世), mystery(悬念), item(物品), relationship(关系), event(事件)") + + # === 备注和说明 === + notes = Column(Text, comment="创作备注(仅作者可见)") + resolution_notes = Column(Text, comment="回收方式说明") + + # === AI辅助设置 === + auto_remind = Column(Boolean, default=True, comment="是否在章节生成时自动提醒") + remind_before_chapters = Column(Integer, default=5, comment="提前几章开始提醒回收") + include_in_context = Column(Boolean, default=True, comment="是否包含在生成上下文中") + + # === 时间戳 === + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + planted_at = Column(DateTime, comment="埋入时间") + resolved_at = Column(DateTime, comment="回收时间") + + def __repr__(self): + return f"" + + def to_dict(self): + """转换为字典格式""" + return { + "id": self.id, + "project_id": self.project_id, + "title": self.title, + "content": self.content, + "hint_text": self.hint_text, + "resolution_text": self.resolution_text, + "source_type": self.source_type, + "source_memory_id": self.source_memory_id, + "plant_chapter_id": self.plant_chapter_id, + "plant_chapter_number": self.plant_chapter_number, + "target_resolve_chapter_id": self.target_resolve_chapter_id, + "target_resolve_chapter_number": self.target_resolve_chapter_number, + "actual_resolve_chapter_id": self.actual_resolve_chapter_id, + "actual_resolve_chapter_number": self.actual_resolve_chapter_number, + "status": self.status, + "is_long_term": self.is_long_term, + "importance": self.importance, + "strength": self.strength, + "subtlety": self.subtlety, + "urgency": self.urgency, + "related_characters": self.related_characters or [], + "related_foreshadow_ids": self.related_foreshadow_ids or [], + "tags": self.tags or [], + "category": self.category, + "notes": self.notes, + "resolution_notes": self.resolution_notes, + "auto_remind": self.auto_remind, + "remind_before_chapters": self.remind_before_chapters, + "include_in_context": self.include_in_context, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "planted_at": self.planted_at.isoformat() if self.planted_at else None, + "resolved_at": self.resolved_at.isoformat() if self.resolved_at else None, + } + + def to_context_string(self) -> str: + """ + 转换为上下文字符串(用于章节生成提示) + """ + parts = [] + + # 基本信息 + parts.append(f"伏笔「{self.title}」") + + # 埋入信息 + if self.plant_chapter_number: + parts.append(f"(第{self.plant_chapter_number}章埋下)") + + # 内容摘要 + content_preview = self.content[:100] if len(self.content) > 100 else self.content + parts.append(f": {content_preview}") + + # 计划回收 + if self.target_resolve_chapter_number: + parts.append(f" [计划第{self.target_resolve_chapter_number}章回收]") + + # 关联角色 + if self.related_characters: + parts.append(f" 涉及: {', '.join(self.related_characters[:3])}") + + return "".join(parts) + + def get_urgency_level(self, current_chapter: int) -> int: + """ + 计算当前紧急度 + + Args: + current_chapter: 当前章节号 + + Returns: + 0=不紧急, 1=需关注, 2=急需回收, 3=已超期 + """ + if self.status != 'planted' or not self.target_resolve_chapter_number: + return 0 + + chapters_remaining = self.target_resolve_chapter_number - current_chapter + + if chapters_remaining < 0: + return 3 # 已超期 + elif chapters_remaining <= 2: + return 2 # 急需回收 + elif chapters_remaining <= self.remind_before_chapters: + return 1 # 需关注 + else: + return 0 # 不紧急 \ No newline at end of file diff --git a/backend/app/schemas/foreshadow.py b/backend/app/schemas/foreshadow.py new file mode 100644 index 0000000..eb6f7b8 --- /dev/null +++ b/backend/app/schemas/foreshadow.py @@ -0,0 +1,196 @@ +"""伏笔管理 Pydantic Schema""" +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class ForeshadowStatus(str, Enum): + """伏笔状态枚举""" + PENDING = "pending" # 待埋入 + PLANTED = "planted" # 已埋入 + RESOLVED = "resolved" # 已回收 + PARTIALLY_RESOLVED = "partially_resolved" # 部分回收 + ABANDONED = "abandoned" # 已废弃 + + +class ForeshadowSourceType(str, Enum): + """伏笔来源类型""" + ANALYSIS = "analysis" # 分析提取 + MANUAL = "manual" # 手动添加 + + +class ForeshadowCategory(str, Enum): + """伏笔分类""" + IDENTITY = "identity" # 身世 + MYSTERY = "mystery" # 悬念 + ITEM = "item" # 物品 + RELATIONSHIP = "relationship" # 关系 + EVENT = "event" # 事件 + ABILITY = "ability" # 能力 + PROPHECY = "prophecy" # 预言 + + +class ForeshadowBase(BaseModel): + """伏笔基础信息""" + title: str = Field(..., min_length=1, max_length=200, description="伏笔标题") + content: str = Field(..., min_length=1, description="伏笔详细内容/描述") + hint_text: Optional[str] = Field(None, description="埋伏笔时的暗示文本") + resolution_text: Optional[str] = Field(None, description="回收伏笔时的揭示文本") + + # 章节关联 + plant_chapter_number: Optional[int] = Field(None, ge=1, description="计划埋入章节号") + target_resolve_chapter_number: Optional[int] = Field(None, ge=1, description="计划回收章节号") + + # 状态 + is_long_term: bool = Field(False, description="是否长线伏笔") + + # 重要性 + importance: float = Field(0.5, ge=0.0, le=1.0, description="重要性评分 0.0-1.0") + strength: int = Field(5, ge=1, le=10, description="伏笔强度 1-10") + subtlety: int = Field(5, ge=1, le=10, description="隐藏度 1-10") + + # 关联信息 + related_characters: Optional[List[str]] = Field(None, description="关联角色名列表") + tags: Optional[List[str]] = Field(None, description="标签列表") + category: Optional[str] = Field(None, description="分类") + + # 备注 + notes: Optional[str] = Field(None, description="创作备注") + resolution_notes: Optional[str] = Field(None, description="回收方式说明") + + # AI辅助设置 + auto_remind: bool = Field(True, description="是否自动提醒") + remind_before_chapters: int = Field(5, ge=1, le=20, description="提前几章提醒") + include_in_context: bool = Field(True, description="是否包含在生成上下文中") + + +class ForeshadowCreate(ForeshadowBase): + """创建伏笔请求""" + project_id: str = Field(..., description="项目ID") + + +class ForeshadowUpdate(BaseModel): + """更新伏笔请求""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = Field(None, min_length=1) + hint_text: Optional[str] = None + resolution_text: Optional[str] = None + + plant_chapter_number: Optional[int] = Field(None, ge=1) + target_resolve_chapter_number: Optional[int] = Field(None, ge=1) + + status: Optional[ForeshadowStatus] = None + is_long_term: Optional[bool] = None + + importance: Optional[float] = Field(None, ge=0.0, le=1.0) + strength: Optional[int] = Field(None, ge=1, le=10) + subtlety: Optional[int] = Field(None, ge=1, le=10) + urgency: Optional[int] = Field(None, ge=0, le=3) + + related_characters: Optional[List[str]] = None + related_foreshadow_ids: Optional[List[str]] = None + tags: Optional[List[str]] = None + category: Optional[str] = None + + notes: Optional[str] = None + resolution_notes: Optional[str] = None + + auto_remind: Optional[bool] = None + remind_before_chapters: Optional[int] = Field(None, ge=1, le=20) + include_in_context: Optional[bool] = None + + +class ForeshadowResponse(ForeshadowBase): + """伏笔响应""" + id: str + project_id: str + + source_type: Optional[str] = None + source_memory_id: Optional[str] = None + source_analysis_id: Optional[str] = None + + plant_chapter_id: Optional[str] = None + target_resolve_chapter_id: Optional[str] = None + actual_resolve_chapter_id: Optional[str] = None + actual_resolve_chapter_number: Optional[int] = None + + status: str = "pending" + urgency: int = 0 + + related_foreshadow_ids: Optional[List[str]] = None + + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + planted_at: Optional[datetime] = None + resolved_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ForeshadowListResponse(BaseModel): + """伏笔列表响应""" + total: int + items: List[ForeshadowResponse] + stats: Optional[dict] = None + + +class ForeshadowStatsResponse(BaseModel): + """伏笔统计响应""" + total: int + pending: int + planted: int + resolved: int + partially_resolved: int + abandoned: int + long_term_count: int + overdue_count: int # 超期未回收数量 + + +class PlantForeshadowRequest(BaseModel): + """标记伏笔埋入请求""" + chapter_id: str = Field(..., description="埋入章节ID") + chapter_number: int = Field(..., ge=1, description="埋入章节号") + hint_text: Optional[str] = Field(None, description="暗示文本") + + +class ResolveForeshadowRequest(BaseModel): + """标记伏笔回收请求""" + chapter_id: str = Field(..., description="回收章节ID") + chapter_number: int = Field(..., ge=1, description="回收章节号") + resolution_text: Optional[str] = Field(None, description="揭示文本") + is_partial: bool = Field(False, description="是否部分回收") + + +class SyncFromAnalysisRequest(BaseModel): + """从分析同步伏笔请求""" + chapter_ids: Optional[List[str]] = Field(None, description="指定章节ID列表,为空则同步全部") + overwrite_existing: bool = Field(False, description="是否覆盖已存在的伏笔") + auto_set_planted: bool = Field(True, description="自动设置为已埋入状态") + + +class SyncFromAnalysisResponse(BaseModel): + """从分析同步伏笔响应""" + synced_count: int + skipped_count: int + new_foreshadows: List[ForeshadowResponse] + skipped_reasons: List[dict] + + +class ForeshadowContextRequest(BaseModel): + """获取章节伏笔上下文请求""" + chapter_number: int = Field(..., ge=1, description="章节号") + include_pending: bool = Field(True, description="包含待埋入伏笔") + include_overdue: bool = Field(True, description="包含超期伏笔") + lookahead: int = Field(5, ge=1, le=20, description="向前看几章") + + +class ForeshadowContextResponse(BaseModel): + """伏笔上下文响应""" + chapter_number: int + context_text: str + pending_plant: List[ForeshadowResponse] # 本章待埋入 + pending_resolve: List[ForeshadowResponse] # 即将需要回收 + overdue: List[ForeshadowResponse] # 超期未回收 + recently_planted: List[ForeshadowResponse] # 最近埋入(可铺垫) \ No newline at end of file diff --git a/backend/app/services/foreshadow_service.py b/backend/app/services/foreshadow_service.py new file mode 100644 index 0000000..c9f346e --- /dev/null +++ b/backend/app/services/foreshadow_service.py @@ -0,0 +1,1516 @@ +"""伏笔管理服务 - 处理伏笔的CRUD和业务逻辑""" +from typing import List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_, desc, func, delete, update +from datetime import datetime +import uuid + +from app.models.foreshadow import Foreshadow +from app.models.chapter import Chapter +from app.models.memory import PlotAnalysis, StoryMemory +from app.schemas.foreshadow import ( + ForeshadowCreate, ForeshadowUpdate, + PlantForeshadowRequest, ResolveForeshadowRequest, + SyncFromAnalysisRequest +) +from app.logger import get_logger + +logger = get_logger(__name__) + + +class ForeshadowService: + """伏笔管理服务""" + + async def get_project_foreshadows( + self, + db: AsyncSession, + project_id: str, + status: Optional[str] = None, + category: Optional[str] = None, + source_type: Optional[str] = None, + is_long_term: Optional[bool] = None, + page: int = 1, + limit: int = 50 + ) -> Dict[str, Any]: + """ + 获取项目伏笔列表 + + Args: + db: 数据库会话 + project_id: 项目ID + status: 状态筛选 + category: 分类筛选 + source_type: 来源筛选 + is_long_term: 是否长线伏笔 + page: 页码 + limit: 每页数量 + + Returns: + 包含列表和统计的字典 + """ + try: + # 构建查询条件 + conditions = [Foreshadow.project_id == project_id] + + if status: + conditions.append(Foreshadow.status == status) + if category: + conditions.append(Foreshadow.category == category) + if source_type: + conditions.append(Foreshadow.source_type == source_type) + if is_long_term is not None: + conditions.append(Foreshadow.is_long_term == is_long_term) + + # 查询总数 + count_query = select(func.count(Foreshadow.id)).where(and_(*conditions)) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 查询列表 + query = ( + select(Foreshadow) + .where(and_(*conditions)) + .order_by( + Foreshadow.plant_chapter_number.asc().nulls_last(), + desc(Foreshadow.importance), + desc(Foreshadow.created_at) + ) + .offset((page - 1) * limit) + .limit(limit) + ) + + result = await db.execute(query) + foreshadows = result.scalars().all() + + # 获取统计 + stats = await self.get_stats(db, project_id) + + return { + "total": total, + "items": [f.to_dict() for f in foreshadows], + "stats": stats + } + + except Exception as e: + logger.error(f"❌ 获取伏笔列表失败: {str(e)}") + raise + + async def get_foreshadow( + self, + db: AsyncSession, + foreshadow_id: str + ) -> Optional[Foreshadow]: + """获取单个伏笔""" + result = await db.execute( + select(Foreshadow).where(Foreshadow.id == foreshadow_id) + ) + return result.scalar_one_or_none() + + async def create_foreshadow( + self, + db: AsyncSession, + data: ForeshadowCreate + ) -> Foreshadow: + """ + 创建伏笔 + + Args: + db: 数据库会话 + data: 创建数据 + + Returns: + 创建的伏笔对象 + """ + try: + foreshadow = Foreshadow( + id=str(uuid.uuid4()), + project_id=data.project_id, + title=data.title, + content=data.content, + hint_text=data.hint_text, + resolution_text=data.resolution_text, + source_type="manual", + plant_chapter_number=data.plant_chapter_number, + target_resolve_chapter_number=data.target_resolve_chapter_number, + status="pending", + is_long_term=data.is_long_term, + importance=data.importance, + strength=data.strength, + subtlety=data.subtlety, + urgency=0, + related_characters=data.related_characters, + tags=data.tags, + category=data.category, + notes=data.notes, + resolution_notes=data.resolution_notes, + auto_remind=data.auto_remind, + remind_before_chapters=data.remind_before_chapters, + include_in_context=data.include_in_context + ) + + db.add(foreshadow) + await db.commit() + await db.refresh(foreshadow) + + logger.info(f"✅ 创建伏笔成功: {foreshadow.title}") + return foreshadow + + except Exception as e: + await db.rollback() + logger.error(f"❌ 创建伏笔失败: {str(e)}") + raise + + async def update_foreshadow( + self, + db: AsyncSession, + foreshadow_id: str, + data: ForeshadowUpdate + ) -> Optional[Foreshadow]: + """ + 更新伏笔 + + Args: + db: 数据库会话 + foreshadow_id: 伏笔ID + data: 更新数据 + + Returns: + 更新后的伏笔对象 + """ + try: + foreshadow = await self.get_foreshadow(db, foreshadow_id) + if not foreshadow: + return None + + # 更新字段 + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if hasattr(foreshadow, key): + setattr(foreshadow, key, value) + + await db.commit() + await db.refresh(foreshadow) + + logger.info(f"✅ 更新伏笔成功: {foreshadow.title}") + return foreshadow + + except Exception as e: + await db.rollback() + logger.error(f"❌ 更新伏笔失败: {str(e)}") + raise + + async def delete_foreshadow( + self, + db: AsyncSession, + foreshadow_id: str + ) -> bool: + """删除伏笔""" + try: + foreshadow = await self.get_foreshadow(db, foreshadow_id) + if not foreshadow: + return False + + await db.delete(foreshadow) + await db.commit() + + logger.info(f"✅ 删除伏笔成功: {foreshadow.title}") + return True + + except Exception as e: + await db.rollback() + logger.error(f"❌ 删除伏笔失败: {str(e)}") + raise + + async def mark_as_planted( + self, + db: AsyncSession, + foreshadow_id: str, + data: PlantForeshadowRequest + ) -> Optional[Foreshadow]: + """ + 标记伏笔为已埋入 + + Args: + db: 数据库会话 + foreshadow_id: 伏笔ID + data: 埋入信息 + + Returns: + 更新后的伏笔对象 + """ + try: + foreshadow = await self.get_foreshadow(db, foreshadow_id) + if not foreshadow: + return None + + foreshadow.status = "planted" + foreshadow.plant_chapter_id = data.chapter_id + foreshadow.plant_chapter_number = data.chapter_number + foreshadow.planted_at = datetime.now() + + if data.hint_text: + foreshadow.hint_text = data.hint_text + + await db.commit() + await db.refresh(foreshadow) + + logger.info(f"✅ 伏笔已标记为埋入: {foreshadow.title} (第{data.chapter_number}章)") + return foreshadow + + except Exception as e: + await db.rollback() + logger.error(f"❌ 标记伏笔埋入失败: {str(e)}") + raise + + async def mark_as_resolved( + self, + db: AsyncSession, + foreshadow_id: str, + data: ResolveForeshadowRequest + ) -> Optional[Foreshadow]: + """ + 标记伏笔为已回收 + + Args: + db: 数据库会话 + foreshadow_id: 伏笔ID + data: 回收信息 + + Returns: + 更新后的伏笔对象 + """ + try: + foreshadow = await self.get_foreshadow(db, foreshadow_id) + if not foreshadow: + return None + + if data.is_partial: + foreshadow.status = "partially_resolved" + else: + foreshadow.status = "resolved" + + foreshadow.actual_resolve_chapter_id = data.chapter_id + foreshadow.actual_resolve_chapter_number = data.chapter_number + foreshadow.resolved_at = datetime.now() + + if data.resolution_text: + foreshadow.resolution_text = data.resolution_text + + await db.commit() + await db.refresh(foreshadow) + + logger.info(f"✅ 伏笔已标记为回收: {foreshadow.title} (第{data.chapter_number}章)") + return foreshadow + + except Exception as e: + await db.rollback() + logger.error(f"❌ 标记伏笔回收失败: {str(e)}") + raise + + async def mark_as_abandoned( + self, + db: AsyncSession, + foreshadow_id: str, + reason: Optional[str] = None + ) -> Optional[Foreshadow]: + """标记伏笔为已废弃""" + try: + foreshadow = await self.get_foreshadow(db, foreshadow_id) + if not foreshadow: + return None + + foreshadow.status = "abandoned" + if reason: + foreshadow.notes = f"{foreshadow.notes or ''}\n[废弃原因] {reason}".strip() + + await db.commit() + await db.refresh(foreshadow) + + logger.info(f"✅ 伏笔已标记为废弃: {foreshadow.title}") + return foreshadow + + except Exception as e: + await db.rollback() + logger.error(f"❌ 标记伏笔废弃失败: {str(e)}") + raise + + async def sync_from_analysis( + self, + db: AsyncSession, + project_id: str, + data: SyncFromAnalysisRequest + ) -> Dict[str, Any]: + """ + 从章节分析结果同步伏笔 + + Args: + db: 数据库会话 + project_id: 项目ID + data: 同步请求数据 + + Returns: + 同步结果 + """ + try: + synced_count = 0 + skipped_count = 0 + new_foreshadows = [] + skipped_reasons = [] + + # 获取分析结果 + query = select(PlotAnalysis).where(PlotAnalysis.project_id == project_id) + if data.chapter_ids: + query = query.where(PlotAnalysis.chapter_id.in_(data.chapter_ids)) + + result = await db.execute(query) + analyses = result.scalars().all() + + for analysis in analyses: + if not analysis.foreshadows: + continue + + # 获取章节信息 + chapter_result = await db.execute( + select(Chapter).where(Chapter.id == analysis.chapter_id) + ) + chapter = chapter_result.scalar_one_or_none() + if not chapter: + continue + + for idx, fs_data in enumerate(analysis.foreshadows): + # 生成唯一标识符 + source_memory_id = f"analysis_{analysis.id}_{idx}" + + # 检查是否已存在 + existing = await db.execute( + select(Foreshadow).where( + Foreshadow.source_memory_id == source_memory_id + ) + ) + existing_foreshadow = existing.scalar_one_or_none() + + if existing_foreshadow and not data.overwrite_existing: + skipped_count += 1 + skipped_reasons.append({ + "source_memory_id": source_memory_id, + "reason": "已存在同步记录" + }) + continue + + # 创建或更新伏笔 + fs_content = fs_data.get("content", "") + fs_type = fs_data.get("type", "planted") + fs_strength = fs_data.get("strength", 5) + fs_subtlety = fs_data.get("subtlety", 5) + + # 新增字段解析 + fs_title = fs_data.get("title", "") + if not fs_title: + # 回退:从content截取标题 + fs_title = fs_content[:50] + ("..." if len(fs_content) > 50 else "") + fs_category = fs_data.get("category") + fs_is_long_term = fs_data.get("is_long_term", False) + fs_related_characters = fs_data.get("related_characters", []) + fs_estimated_resolve = fs_data.get("estimated_resolve_chapter") + fs_keyword = fs_data.get("keyword", "") + + # 确定状态 + status = "planted" if (fs_type == "planted" and data.auto_set_planted) else "pending" + if fs_type == "resolved": + status = "resolved" + + if existing_foreshadow: + # 更新现有记录 + existing_foreshadow.title = fs_title + existing_foreshadow.content = fs_content + existing_foreshadow.strength = fs_strength + existing_foreshadow.subtlety = fs_subtlety + existing_foreshadow.status = status + existing_foreshadow.category = fs_category + existing_foreshadow.is_long_term = fs_is_long_term + existing_foreshadow.related_characters = fs_related_characters if fs_related_characters else None + existing_foreshadow.hint_text = fs_keyword if fs_keyword else None + if fs_estimated_resolve and status == "planted": + existing_foreshadow.target_resolve_chapter_number = fs_estimated_resolve + await db.flush() + new_foreshadows.append(existing_foreshadow.to_dict()) + else: + # 创建新记录 + foreshadow = Foreshadow( + id=str(uuid.uuid4()), + project_id=project_id, + title=fs_title, + content=fs_content, + hint_text=fs_keyword if fs_keyword else None, + source_type="analysis", + source_memory_id=source_memory_id, + source_analysis_id=analysis.id, + plant_chapter_id=chapter.id if status == "planted" else None, + plant_chapter_number=chapter.chapter_number if status == "planted" else None, + planted_at=datetime.now() if status == "planted" else None, + target_resolve_chapter_number=fs_estimated_resolve if (status == "planted" and fs_estimated_resolve) else None, + status=status, + is_long_term=fs_is_long_term, + importance=min(fs_strength / 10.0, 1.0), + strength=fs_strength, + subtlety=fs_subtlety, + category=fs_category, + related_characters=fs_related_characters if fs_related_characters else None, + auto_remind=True, + remind_before_chapters=5, + include_in_context=True + ) + + # 如果是回收的伏笔 + if fs_type == "resolved": + foreshadow.actual_resolve_chapter_id = chapter.id + foreshadow.actual_resolve_chapter_number = chapter.chapter_number + foreshadow.resolved_at = datetime.now() + if fs_data.get("reference_chapter"): + foreshadow.plant_chapter_number = fs_data.get("reference_chapter") + + db.add(foreshadow) + await db.flush() + new_foreshadows.append(foreshadow.to_dict()) + + synced_count += 1 + + await db.commit() + + logger.info(f"✅ 伏笔同步完成: 同步{synced_count}个, 跳过{skipped_count}个") + + return { + "synced_count": synced_count, + "skipped_count": skipped_count, + "new_foreshadows": new_foreshadows, + "skipped_reasons": skipped_reasons + } + + except Exception as e: + await db.rollback() + logger.error(f"❌ 同步伏笔失败: {str(e)}") + raise + + async def get_pending_resolve_foreshadows( + self, + db: AsyncSession, + project_id: str, + current_chapter: int, + lookahead: int = 5 + ) -> List[Foreshadow]: + """ + 获取即将需要回收的伏笔 + + Args: + db: 数据库会话 + project_id: 项目ID + current_chapter: 当前章节号 + lookahead: 向前看几章 + + Returns: + 待回收伏笔列表 + """ + try: + # 查询已埋入且计划在接下来几章回收的伏笔 + query = ( + select(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.status == "planted", + Foreshadow.target_resolve_chapter_number != None, + Foreshadow.target_resolve_chapter_number <= current_chapter + lookahead, + Foreshadow.auto_remind == True + ) + ) + .order_by(Foreshadow.target_resolve_chapter_number) + ) + + result = await db.execute(query) + return list(result.scalars().all()) + + except Exception as e: + logger.error(f"❌ 获取待回收伏笔失败: {str(e)}") + return [] + + async def get_overdue_foreshadows( + self, + db: AsyncSession, + project_id: str, + current_chapter: int + ) -> List[Foreshadow]: + """ + 获取超期未回收的伏笔 + + Args: + db: 数据库会话 + project_id: 项目ID + current_chapter: 当前章节号 + + Returns: + 超期伏笔列表 + """ + try: + query = ( + select(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.status == "planted", + Foreshadow.target_resolve_chapter_number != None, + Foreshadow.target_resolve_chapter_number < current_chapter + ) + ) + .order_by(Foreshadow.target_resolve_chapter_number) + ) + + result = await db.execute(query) + return list(result.scalars().all()) + + except Exception as e: + logger.error(f"❌ 获取超期伏笔失败: {str(e)}") + return [] + + async def get_must_resolve_foreshadows( + self, + db: AsyncSession, + project_id: str, + chapter_number: int + ) -> List[Foreshadow]: + """ + 获取本章必须回收的伏笔(target_resolve_chapter_number == chapter_number) + + 这些伏笔是用户明确指定在本章回收的,必须在本章完成回收 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_number: 当前章节号 + + Returns: + 必须回收的伏笔列表 + """ + try: + query = ( + select(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.status == "planted", + Foreshadow.target_resolve_chapter_number == chapter_number + ) + ) + .order_by(desc(Foreshadow.importance)) + ) + + result = await db.execute(query) + return list(result.scalars().all()) + + except Exception as e: + logger.error(f"❌ 获取本章必须回收伏笔失败: {str(e)}") + return [] + + async def get_foreshadows_to_plant( + self, + db: AsyncSession, + project_id: str, + chapter_number: int + ) -> List[Foreshadow]: + """ + 获取计划在本章埋入的伏笔 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_number: 章节号 + + Returns: + 待埋入伏笔列表 + """ + try: + query = ( + select(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.status == "pending", + Foreshadow.plant_chapter_number == chapter_number + ) + ) + .order_by(desc(Foreshadow.importance)) + ) + + result = await db.execute(query) + return list(result.scalars().all()) + + except Exception as e: + logger.error(f"❌ 获取待埋入伏笔失败: {str(e)}") + return [] + + async def build_chapter_context( + self, + db: AsyncSession, + project_id: str, + chapter_number: int, + include_pending: bool = True, + include_overdue: bool = True, + lookahead: int = 5 + ) -> Dict[str, Any]: + """ + 构建章节生成的伏笔上下文(智能分层提醒策略) + + 核心策略: + 1. 本章必须回收的伏笔 → 明确要求回收 + 2. 超期伏笔 → 强调需要尽快回收 + 3. 即将回收的伏笔 → 仅作为背景信息,明确禁止提前回收 + 4. 远期伏笔 → 不发送,防止干扰 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_number: 章节号 + include_pending: 包含待埋入伏笔 + include_overdue: 包含超期伏笔 + lookahead: 向前看几章(用于背景提醒,非强制回收) + + Returns: + 伏笔上下文信息 + """ + try: + lines = [] + to_plant = [] + must_resolve = [] # 本章必须回收 + overdue = [] # 超期待回收 + upcoming = [] # 即将回收(仅参考) + + # 1. 获取本章必须回收的伏笔(target_resolve_chapter_number == chapter_number) + must_resolve = await self.get_must_resolve_foreshadows(db, project_id, chapter_number) + if must_resolve: + lines.append("【🎯 本章必须回收的伏笔 - 请务必在本章完成回收】") + for f in must_resolve: + lines.append(f"- ID:{f.id[:8]} | {f.title}") + lines.append(f" 埋入章节:第{f.plant_chapter_number}章") + lines.append(f" 伏笔内容:{f.content[:100]}{'...' if len(f.content) > 100 else ''}") + if f.resolution_notes: + lines.append(f" 回收提示:{f.resolution_notes}") + lines.append("") + + # 2. 超期伏笔(已过目标回收章节但未回收) + if include_overdue: + overdue = await self.get_overdue_foreshadows(db, project_id, chapter_number) + if overdue: + lines.append("【⚠️ 超期待回收伏笔 - 请尽快回收】") + for f in overdue[:3]: + overdue_chapters = chapter_number - (f.target_resolve_chapter_number or 0) + lines.append(f"- ID:{f.id[:8]} | {f.title} [已超期{overdue_chapters}章]") + lines.append(f" 埋入章节:第{f.plant_chapter_number}章,原计划第{f.target_resolve_chapter_number}章回收") + lines.append(f" 伏笔内容:{f.content[:80]}...") + lines.append("") + + # 3. 即将需要回收的伏笔(仅作为背景参考,明确禁止提前回收) + upcoming_raw = await self.get_pending_resolve_foreshadows( + db, project_id, chapter_number, lookahead + ) + # 过滤:排除本章必须回收的和超期的,只保留未来章节的 + upcoming = [f for f in upcoming_raw + if (f.target_resolve_chapter_number or 0) > chapter_number] + + if upcoming: + lines.append("【📋 近期待回收伏笔(仅供参考,请勿在本章回收)】") + lines.append("⚠️ 以下伏笔尚未到回收时机,本章请勿提前回收,仅作为剧情背景了解") + for f in upcoming[:5]: + remaining = (f.target_resolve_chapter_number or 0) - chapter_number + lines.append(f"- {f.title}(计划第{f.target_resolve_chapter_number}章回收,还有{remaining}章)") + lines.append("") + + # 4. 本章待埋入伏笔 + if include_pending: + to_plant = await self.get_foreshadows_to_plant(db, project_id, chapter_number) + if to_plant: + lines.append("【✨ 本章计划埋入伏笔】") + for f in to_plant: + content_preview = f.content[:80] if len(f.content) > 80 else f.content + lines.append(f"- {f.title}") + lines.append(f" 伏笔内容:{content_preview}") + if f.hint_text: + lines.append(f" 埋入提示:{f.hint_text}") + lines.append("") + + context_text = "\n".join(lines) if lines else "" + + return { + "chapter_number": chapter_number, + "context_text": context_text, + "pending_plant": [f.to_dict() for f in to_plant], + "must_resolve": [f.to_dict() for f in must_resolve], + "pending_resolve": [f.to_dict() for f in upcoming], + "overdue": [f.to_dict() for f in overdue], + "recently_planted": [] # 可扩展 + } + + except Exception as e: + logger.error(f"❌ 构建伏笔上下文失败: {str(e)}") + return { + "chapter_number": chapter_number, + "context_text": "", + "pending_plant": [], + "must_resolve": [], + "pending_resolve": [], + "overdue": [], + "recently_planted": [] + } + + async def get_stats( + self, + db: AsyncSession, + project_id: str, + current_chapter: Optional[int] = None + ) -> Dict[str, int]: + """ + 获取伏笔统计 + + Args: + db: 数据库会话 + project_id: 项目ID + current_chapter: 当前章节号(用于计算超期) + + Returns: + 统计信息字典 + """ + try: + # 各状态统计 + stats_query = ( + select( + Foreshadow.status, + func.count(Foreshadow.id).label('count') + ) + .where(Foreshadow.project_id == project_id) + .group_by(Foreshadow.status) + ) + + result = await db.execute(stats_query) + status_counts = {row.status: row.count for row in result} + + # 总数 + total = sum(status_counts.values()) + + # 长线伏笔数量 + long_term_query = ( + select(func.count(Foreshadow.id)) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.is_long_term == True + ) + ) + ) + long_term_result = await db.execute(long_term_query) + long_term_count = long_term_result.scalar() or 0 + + # 超期数量 + overdue_count = 0 + if current_chapter: + overdue = await self.get_overdue_foreshadows(db, project_id, current_chapter) + overdue_count = len(overdue) + + return { + "total": total, + "pending": status_counts.get("pending", 0), + "planted": status_counts.get("planted", 0), + "resolved": status_counts.get("resolved", 0), + "partially_resolved": status_counts.get("partially_resolved", 0), + "abandoned": status_counts.get("abandoned", 0), + "long_term_count": long_term_count, + "overdue_count": overdue_count + } + + except Exception as e: + logger.error(f"❌ 获取伏笔统计失败: {str(e)}") + return { + "total": 0, + "pending": 0, + "planted": 0, + "resolved": 0, + "partially_resolved": 0, + "abandoned": 0, + "long_term_count": 0, + "overdue_count": 0 + } + + async def get_planted_foreshadows_for_analysis( + self, + db: AsyncSession, + project_id: str, + current_chapter_number: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取用于分析时注入的已埋入伏笔列表(智能过滤版) + + 策略: + 1. 只返回 status='planted' 的伏笔 + 2. 如果指定了当前章节号,会标记哪些伏笔应该在本章回收 + 3. 区分"可回收"和"不可回收"伏笔,帮助AI正确识别 + + Args: + db: 数据库会话 + project_id: 项目ID + current_chapter_number: 当前章节号(可选,用于智能标记) + + Returns: + 伏笔列表(带回收标记) + """ + try: + query = ( + select(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.status == "planted" + ) + ) + .order_by(Foreshadow.plant_chapter_number) + ) + + result = await db.execute(query) + foreshadows = result.scalars().all() + + formatted_list = [] + for f in foreshadows: + item = { + "id": f.id, + "title": f.title, + "content": f.content, + "plant_chapter_number": f.plant_chapter_number, + "target_resolve_chapter_number": f.target_resolve_chapter_number, + "category": f.category, + "related_characters": f.related_characters or [], + "is_long_term": f.is_long_term + } + + # 智能标记回收状态 + if current_chapter_number and f.target_resolve_chapter_number: + if f.target_resolve_chapter_number == current_chapter_number: + item["resolve_status"] = "must_resolve_now" # 本章必须回收 + item["resolve_hint"] = "本章必须回收此伏笔" + elif f.target_resolve_chapter_number < current_chapter_number: + item["resolve_status"] = "overdue" # 已超期 + item["resolve_hint"] = f"已超期{current_chapter_number - f.target_resolve_chapter_number}章,应尽快回收" + else: + item["resolve_status"] = "not_yet" # 尚未到期 + item["resolve_hint"] = f"计划第{f.target_resolve_chapter_number}章回收,请勿提前回收" + else: + item["resolve_status"] = "no_plan" # 无明确计划 + item["resolve_hint"] = "无明确回收计划,根据剧情自然回收" + + formatted_list.append(item) + + return formatted_list + + except Exception as e: + logger.error(f"❌ 获取已埋入伏笔失败: {str(e)}") + return [] + + async def delete_chapter_foreshadows( + self, + db: AsyncSession, + project_id: str, + chapter_id: str, + only_analysis_source: bool = True + ) -> Dict[str, Any]: + """ + 删除与指定章节相关的伏笔 + + 当章节内容被清空或重新生成时调用,清理残留数据 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_id: 章节ID + only_analysis_source: 是否只删除来源为 analysis 的伏笔(默认True) + 如果为False,则删除所有与该章节相关的伏笔 + + Returns: + 删除统计信息 + """ + try: + # 1. 先通过 PlotAnalysis 查找该章节的分析ID + # 这是关键:sync_from_analysis 创建的伏笔使用 source_analysis_id 关联 + analysis_query = select(PlotAnalysis.id).where(PlotAnalysis.chapter_id == chapter_id) + analysis_result = await db.execute(analysis_query) + analysis_ids = [row[0] for row in analysis_result.fetchall()] + + logger.debug(f"🔍 找到章节 {chapter_id[:8]} 的分析ID: {len(analysis_ids)} 个") + + # 2. 构建查询条件:查找与该章节相关的伏笔 + # 相关性包括: + # 1. 埋入章节是该章节 (plant_chapter_id) + # 2. 回收章节是该章节 (actual_resolve_chapter_id) + # 3. 来源分析ID对应该章节的分析 (source_analysis_id) + # 4. source_memory_id 包含章节ID (auto_update_from_analysis 创建的) + or_conditions = [ + Foreshadow.plant_chapter_id == chapter_id, + Foreshadow.actual_resolve_chapter_id == chapter_id, + Foreshadow.source_memory_id.like(f"auto_analysis_{chapter_id}%") + ] + + # 如果找到了分析ID,添加 source_analysis_id 匹配条件 + if analysis_ids: + or_conditions.append(Foreshadow.source_analysis_id.in_(analysis_ids)) + + conditions = [ + Foreshadow.project_id == project_id, + or_(*or_conditions) + ] + + # 如果只删除分析来源的伏笔 + if only_analysis_source: + conditions.append(Foreshadow.source_type == "analysis") + + # 查询要删除的伏笔 + query = select(Foreshadow).where(and_(*conditions)) + result = await db.execute(query) + foreshadows_to_delete = result.scalars().all() + + deleted_count = len(foreshadows_to_delete) + deleted_ids = [f.id for f in foreshadows_to_delete] + deleted_titles = [f.title for f in foreshadows_to_delete] + + # 执行删除 + for foreshadow in foreshadows_to_delete: + await db.delete(foreshadow) + + await db.commit() + + if deleted_count > 0: + logger.info(f"🗑️ 已删除章节 {chapter_id[:8]} 相关的 {deleted_count} 个伏笔") + for title in deleted_titles[:5]: # 只打印前5个 + logger.debug(f" - {title}") + if deleted_count > 5: + logger.debug(f" ... 还有 {deleted_count - 5} 个") + + return { + "deleted_count": deleted_count, + "deleted_ids": deleted_ids, + "deleted_titles": deleted_titles + } + + except Exception as e: + await db.rollback() + logger.error(f"❌ 删除章节伏笔失败: {str(e)}") + raise + + async def clean_chapter_analysis_foreshadows( + self, + db: AsyncSession, + project_id: str, + chapter_id: str + ) -> Dict[str, Any]: + """ + 清理章节分析产生的伏笔(用于重新分析前的清理) + + 只清理 source_type='analysis' 且 source_memory_id 包含该章节ID 的伏笔 + 保留手动创建的伏笔 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_id: 章节ID + + Returns: + 清理统计信息 + """ + try: + # 查找该章节分析产生的伏笔 + query = select(Foreshadow).where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.source_type == "analysis", + or_( + Foreshadow.source_memory_id.like(f"analysis_%_{chapter_id}%"), + Foreshadow.source_memory_id.like(f"auto_analysis_{chapter_id}%"), + Foreshadow.plant_chapter_id == chapter_id + ) + ) + ) + + result = await db.execute(query) + foreshadows_to_clean = result.scalars().all() + + cleaned_count = len(foreshadows_to_clean) + cleaned_ids = [f.id for f in foreshadows_to_clean] + + # 执行删除 + for foreshadow in foreshadows_to_clean: + await db.delete(foreshadow) + + await db.commit() + + if cleaned_count > 0: + logger.info(f"🧹 已清理章节 {chapter_id[:8]} 的 {cleaned_count} 个分析伏笔(准备重新分析)") + + return { + "cleaned_count": cleaned_count, + "cleaned_ids": cleaned_ids + } + + except Exception as e: + await db.rollback() + logger.error(f"❌ 清理章节分析伏笔失败: {str(e)}") + raise + + async def clear_project_foreshadows_for_reset( + self, + db: AsyncSession, + project_id: str + ) -> Dict[str, int]: + """ + 全新生成时清理项目伏笔 + + 1. 删除所有 source_type='analysis' 的伏笔 + 2. 重置所有 source_type='manual' 的伏笔为 pending 状态 + + Args: + db: 数据库会话 + project_id: 项目ID + + Returns: + 清理统计 + """ + try: + # 1. 删除分析产生的伏笔 + delete_query = delete(Foreshadow).where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.source_type == "analysis" + ) + ) + delete_result = await db.execute(delete_query) + deleted_count = delete_result.rowcount + + # 2. 重置手动创建的伏笔 + # 将 planted/resolved/partially_resolved 状态重置为 pending + # 清空章节关联信息 + update_query = ( + update(Foreshadow) + .where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.source_type == "manual", + Foreshadow.status.in_(["planted", "resolved", "partially_resolved"]) + ) + ) + .values( + status="pending", + plant_chapter_id=None, + plant_chapter_number=None, + actual_resolve_chapter_id=None, + actual_resolve_chapter_number=None, + planted_at=None, + resolved_at=None, + target_resolve_chapter_id=None, + target_resolve_chapter_number=None + ) + ) + update_result = await db.execute(update_query) + reset_count = update_result.rowcount + + await db.commit() + + logger.info(f"🧹 项目 {project_id} 伏笔清理完成: 删除 {deleted_count} 个分析伏笔, 重置 {reset_count} 个手动伏笔") + + return { + "deleted_count": deleted_count, + "reset_count": reset_count + } + + except Exception as e: + await db.rollback() + logger.error(f"❌ 清理项目伏笔失败: {str(e)}") + raise + + async def auto_update_from_analysis( + self, + db: AsyncSession, + project_id: str, + chapter_id: str, + chapter_number: int, + analysis_foreshadows: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 根据章节分析结果自动更新伏笔状态 + + 功能: + 1. 自动标记新埋入的伏笔为 planted + 2. 根据 reference_foreshadow_id 自动回收已有伏笔 + 3. 如果没有 reference_foreshadow_id,使用内容匹配备用机制 + 4. 创建新发现的伏笔记录 + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_id: 章节ID + chapter_number: 章节号 + analysis_foreshadows: 分析结果中的伏笔列表 + + Returns: + 更新统计 + """ + try: + stats = { + "planted_count": 0, # 新埋入的伏笔 + "resolved_count": 0, # 回收的伏笔 + "created_count": 0, # 新创建的伏笔记录 + "updated_ids": [], # 更新的伏笔ID + "created_ids": [], # 创建的伏笔ID + "matched_by_content": 0, # 通过内容匹配回收的数量 + "errors": [] # 错误信息 + } + + # 预先获取所有已埋入的伏笔,用于内容匹配 + planted_foreshadows = await self.get_planted_foreshadows_for_analysis(db, project_id) + + for fs_data in analysis_foreshadows: + try: + fs_type = fs_data.get("type", "planted") + reference_id = fs_data.get("reference_foreshadow_id") + + if fs_type == "resolved": + existing = None + matched_by_content = False + + # 策略1: 优先使用 reference_id 精确匹配 + if reference_id: + existing = await self.get_foreshadow(db, reference_id) + if existing and existing.project_id == project_id: + logger.info(f"🎯 通过ID精确匹配伏笔: {existing.title}") + else: + existing = None + logger.warning(f"⚠️ 伏笔ID不存在或不属于该项目: {reference_id}") + + # 策略2: 内容匹配备用机制(当没有reference_id或ID匹配失败时) + if not existing and planted_foreshadows: + existing = await self._match_foreshadow_by_content( + fs_data, planted_foreshadows + ) + if existing: + matched_by_content = True + logger.info(f"🔍 通过内容匹配找到伏笔: {existing.get('title')}") + # 重新获取完整的伏笔对象 + existing = await self.get_foreshadow(db, existing.get('id')) + + # 执行回收 + if existing and existing.status == "planted": + # 更新为已回收状态 + existing.status = "resolved" + existing.actual_resolve_chapter_id = chapter_id + existing.actual_resolve_chapter_number = chapter_number + existing.resolved_at = datetime.now() + + # 更新回收文本 + if fs_data.get("content"): + existing.resolution_text = fs_data.get("content") + + await db.flush() + stats["resolved_count"] += 1 + stats["updated_ids"].append(existing.id) + if matched_by_content: + stats["matched_by_content"] += 1 + logger.info(f"✅ 自动回收伏笔: {existing.title} (ID: {existing.id})") + + # 从待匹配列表中移除已回收的伏笔 + planted_foreshadows = [f for f in planted_foreshadows if f['id'] != existing.id] + elif existing: + logger.warning(f"⚠️ 伏笔状态不是planted,跳过回收: {existing.title} (status: {existing.status})") + else: + # 没有找到匹配的伏笔,记录警告 + fs_title = fs_data.get("title", fs_data.get("content", "")[:30]) + logger.warning(f"⚠️ 未能匹配到伏笔进行回收: {fs_title}") + stats["errors"].append(f"未找到匹配的伏笔: {fs_title}") + + elif fs_type == "planted": + # 【埋入伏笔】创建新的伏笔记录 + fs_title = fs_data.get("title", "") + if not fs_title: + fs_title = fs_data.get("content", "")[:50] + "..." + + # 生成唯一标识符,避免重复创建 + source_memory_id = f"auto_analysis_{chapter_id}_{fs_title[:30]}" + + # 检查是否已存在 + existing_check = await db.execute( + select(Foreshadow).where( + and_( + Foreshadow.project_id == project_id, + Foreshadow.source_memory_id == source_memory_id + ) + ) + ) + existing_fs = existing_check.scalar_one_or_none() + + if existing_fs: + # 已存在,更新信息 + existing_fs.content = fs_data.get("content", existing_fs.content) + existing_fs.strength = fs_data.get("strength", existing_fs.strength) + existing_fs.subtlety = fs_data.get("subtlety", existing_fs.subtlety) + existing_fs.hint_text = fs_data.get("keyword", existing_fs.hint_text) + await db.flush() + logger.info(f"📝 更新已存在伏笔: {fs_title}") + else: + # 创建新伏笔 + new_foreshadow = Foreshadow( + id=str(uuid.uuid4()), + project_id=project_id, + title=fs_title, + content=fs_data.get("content", ""), + hint_text=fs_data.get("keyword"), + source_type="analysis", + source_memory_id=source_memory_id, + plant_chapter_id=chapter_id, + plant_chapter_number=chapter_number, + planted_at=datetime.now(), + target_resolve_chapter_number=fs_data.get("estimated_resolve_chapter"), + status="planted", + is_long_term=fs_data.get("is_long_term", False), + importance=min(fs_data.get("strength", 5) / 10.0, 1.0), + strength=fs_data.get("strength", 5), + subtlety=fs_data.get("subtlety", 5), + category=fs_data.get("category"), + related_characters=fs_data.get("related_characters"), + auto_remind=True, + remind_before_chapters=5, + include_in_context=True + ) + + db.add(new_foreshadow) + await db.flush() + + stats["planted_count"] += 1 + stats["created_count"] += 1 + stats["created_ids"].append(new_foreshadow.id) + logger.info(f"✅ 自动创建伏笔: {fs_title} (ID: {new_foreshadow.id})") + + except Exception as item_error: + error_msg = f"处理伏笔时出错: {str(item_error)}" + stats["errors"].append(error_msg) + logger.error(f"❌ {error_msg}") + + await db.commit() + + logger.info(f"📊 伏笔自动更新完成: 埋入{stats['planted_count']}个, 回收{stats['resolved_count']}个, 创建{stats['created_count']}个") + return stats + + except Exception as e: + await db.rollback() + logger.error(f"❌ 自动更新伏笔失败: {str(e)}") + raise + + async def auto_plant_pending_foreshadows( + self, + db: AsyncSession, + project_id: str, + chapter_id: str, + chapter_number: int, + chapter_content: str + ) -> Dict[str, Any]: + """ + 自动将计划在本章埋入的伏笔标记为已埋入 + + 检查 pending 状态且 plant_chapter_number == chapter_number 的伏笔, + 如果章节内容中包含相关关键词,则自动标记为 planted + + Args: + db: 数据库会话 + project_id: 项目ID + chapter_id: 章节ID + chapter_number: 章节号 + chapter_content: 章节内容 + + Returns: + 更新统计 + """ + try: + stats = { + "checked_count": 0, + "planted_count": 0, + "planted_ids": [] + } + + # 获取计划在本章埋入的伏笔 + pending_foreshadows = await self.get_foreshadows_to_plant( + db, project_id, chapter_number + ) + + stats["checked_count"] = len(pending_foreshadows) + + for fs in pending_foreshadows: + # 简单检查:如果伏笔标题或内容的关键词出现在章节中 + # 或者用户已明确指定本章埋入,则自动标记 + should_plant = False + + # 检查标题关键词 + if fs.title and len(fs.title) >= 4: + # 提取标题中的关键词(取前4-10个字符) + keywords = [fs.title[:min(10, len(fs.title))]] + for kw in keywords: + if kw in chapter_content: + should_plant = True + break + + # 如果明确指定了本章埋入,直接标记 + if fs.plant_chapter_number == chapter_number: + should_plant = True + + if should_plant: + fs.status = "planted" + fs.plant_chapter_id = chapter_id + fs.planted_at = datetime.now() + await db.flush() + + stats["planted_count"] += 1 + stats["planted_ids"].append(fs.id) + logger.info(f"✅ 自动标记伏笔已埋入: {fs.title} (第{chapter_number}章)") + + await db.commit() + + if stats["planted_count"] > 0: + logger.info(f"📊 自动埋入伏笔: 检查{stats['checked_count']}个, 埋入{stats['planted_count']}个") + + return stats + + except Exception as e: + await db.rollback() + logger.error(f"❌ 自动埋入伏笔失败: {str(e)}") + return {"checked_count": 0, "planted_count": 0, "planted_ids": [], "error": str(e)} + + + async def _match_foreshadow_by_content( + self, + resolved_fs_data: Dict[str, Any], + planted_foreshadows: List[Dict[str, Any]], + min_similarity: float = 0.3 + ) -> Optional[Dict[str, Any]]: + """ + 通过内容相似度匹配伏笔(备用机制) + + 匹配策略(按优先级): + 1. 标题完全匹配 + 2. 标题部分匹配(包含关系) + 3. 关键词匹配 + 4. 内容关键词匹配 + 5. 相关角色匹配 + 分类匹配 + + Args: + resolved_fs_data: 分析结果中的回收伏笔数据 + planted_foreshadows: 已埋入的伏笔列表 + min_similarity: 最低相似度阈值 + + Returns: + 最匹配的伏笔对象或None + """ + if not planted_foreshadows: + return None + + resolved_title = resolved_fs_data.get("title", "").strip() + resolved_content = resolved_fs_data.get("content", "").strip() + resolved_keyword = resolved_fs_data.get("keyword", "").strip() + resolved_category = resolved_fs_data.get("category") + resolved_characters = set(resolved_fs_data.get("related_characters", [])) + reference_chapter = resolved_fs_data.get("reference_chapter") + + best_match = None + best_score = 0.0 + + for fs in planted_foreshadows: + score = 0.0 + fs_title = fs.get("title", "").strip() + fs_content = fs.get("content", "").strip() + fs_category = fs.get("category") + fs_characters = set(fs.get("related_characters", [])) + fs_plant_chapter = fs.get("plant_chapter_number") + + # 策略1: 标题完全匹配(最高分) + if resolved_title and fs_title: + if resolved_title == fs_title: + score = 1.0 + elif resolved_title in fs_title or fs_title in resolved_title: + # 标题包含关系 + score = max(score, 0.8) + else: + # 计算标题词重叠 + title_overlap = self._calculate_word_overlap(resolved_title, fs_title) + score = max(score, title_overlap * 0.7) + + # 策略2: 关键词匹配 + if resolved_keyword and fs_content: + if resolved_keyword in fs_content: + score = max(score, 0.75) + + # 策略3: 内容关键词匹配 + if resolved_content and fs_content: + content_overlap = self._calculate_word_overlap(resolved_content, fs_content) + score = max(score, content_overlap * 0.6) + + # 策略4: 引用章节号匹配(如果分析结果中有reference_chapter) + if reference_chapter and fs_plant_chapter: + if reference_chapter == fs_plant_chapter: + score += 0.15 # 加分 + + # 策略5: 分类匹配 + if resolved_category and fs_category: + if resolved_category == fs_category: + score += 0.1 + + # 策略6: 相关角色匹配 + if resolved_characters and fs_characters: + character_overlap = len(resolved_characters & fs_characters) / max(len(resolved_characters | fs_characters), 1) + score += character_overlap * 0.1 + + # 更新最佳匹配 + if score > best_score and score >= min_similarity: + best_score = score + best_match = fs + + if best_match: + logger.info(f"🎯 内容匹配成功: '{resolved_title}' -> '{best_match.get('title')}' (相似度: {best_score:.2f})") + + return best_match + + def _calculate_word_overlap(self, text1: str, text2: str) -> float: + """ + 计算两个文本的词重叠度 + + 使用字符级别的 n-gram 相似度计算 + + Args: + text1: 文本1 + text2: 文本2 + + Returns: + 0-1之间的相似度分数 + """ + if not text1 or not text2: + return 0.0 + + # 使用2-gram和3-gram + def get_ngrams(text: str, n: int) -> set: + text = text.lower().replace(" ", "").replace("\n", "") + if len(text) < n: + return {text} + return {text[i:i+n] for i in range(len(text) - n + 1)} + + # 计算2-gram相似度 + ngrams1_2 = get_ngrams(text1, 2) + ngrams2_2 = get_ngrams(text2, 2) + overlap_2 = len(ngrams1_2 & ngrams2_2) / max(len(ngrams1_2 | ngrams2_2), 1) + + # 计算3-gram相似度 + ngrams1_3 = get_ngrams(text1, 3) + ngrams2_3 = get_ngrams(text2, 3) + overlap_3 = len(ngrams1_3 & ngrams2_3) / max(len(ngrams1_3 | ngrams2_3), 1) + + # 综合评分(3-gram权重更高,因为更精确) + return overlap_2 * 0.4 + overlap_3 * 0.6 + + +# 创建全局服务实例 +foreshadow_service = ForeshadowService() \ No newline at end of file diff --git a/backend/app/services/plot_analyzer.py b/backend/app/services/plot_analyzer.py index 0b44a13..7022af6 100644 --- a/backend/app/services/plot_analyzer.py +++ b/backend/app/services/plot_analyzer.py @@ -32,7 +32,8 @@ class PlotAnalyzer: word_count: int, user_id: str = None, db: AsyncSession = None, - max_retries: int = 3 + max_retries: int = 3, + existing_foreshadows: Optional[List[Dict[str, Any]]] = None ) -> Optional[Dict[str, Any]]: """ 分析单章内容(带重试机制) @@ -45,6 +46,7 @@ class PlotAnalyzer: user_id: 用户ID(用于获取自定义提示词) db: 数据库会话(用于查询自定义提示词) max_retries: 最大重试次数,默认3次 + existing_foreshadows: 已埋入的伏笔列表(用于回收匹配) Returns: 分析结果字典,失败返回None @@ -65,13 +67,17 @@ class PlotAnalyzer: logger.warning(f"⚠️ 获取提示词模板失败,使用默认模板: {str(e)}") template = PromptService.PLOT_ANALYSIS + # 格式化已有伏笔列表 + foreshadows_text = self._format_existing_foreshadows(existing_foreshadows) + # 格式化提示词 prompt = PromptService.format_prompt( template, chapter_number=chapter_number, title=title, word_count=word_count, - content=analysis_content + content=analysis_content, + existing_foreshadows=foreshadows_text ) last_error = None @@ -155,6 +161,117 @@ class PlotAnalyzer: logger.error(f"❌ 第{chapter_number}章分析失败: {last_error}") return None + def _format_existing_foreshadows(self, foreshadows: Optional[List[Dict[str, Any]]]) -> str: + """ + 格式化已有伏笔列表,用于注入到分析提示词中(智能分类版) + + 核心策略: + 1. 必须回收的伏笔 - 明确标注,要求AI识别回收 + 2. 超期的伏笔 - 提醒AI尽快回收 + 3. 未到期的伏笔 - 明确标注禁止提前回收 + + Args: + foreshadows: 伏笔列表,每个包含 id, title, content, plant_chapter_number, resolve_status 等 + + Returns: + 格式化的文本 + """ + if not foreshadows: + return "(暂无已埋入的伏笔)" + + # 按回收状态分类 + must_resolve = [] # 本章必须回收 + overdue = [] # 已超期 + not_yet = [] # 尚未到期 + no_plan = [] # 无明确计划 + + for fs in foreshadows: + status = fs.get('resolve_status', 'no_plan') + if status == 'must_resolve_now': + must_resolve.append(fs) + elif status == 'overdue': + overdue.append(fs) + elif status == 'not_yet': + not_yet.append(fs) + else: + no_plan.append(fs) + + lines = [] + + # 1. 本章必须回收的伏笔(最高优先级) + if must_resolve: + lines.append("=" * 50) + lines.append("【🎯 本章必须回收的伏笔 - 请务必识别回收】") + lines.append("=" * 50) + for i, fs in enumerate(must_resolve, 1): + fs_id = fs.get('id', 'unknown') + fs_title = fs.get('title', '未命名伏笔') + fs_content = fs.get('content', '')[:150] + plant_chapter = fs.get('plant_chapter_number', '?') + + lines.append(f"{i}. 【ID: {fs_id}】{fs_title}") + lines.append(f" ⚠️ 回收要求:必须在本章回收此伏笔") + lines.append(f" 埋入章节:第{plant_chapter}章") + lines.append(f" 伏笔内容:{fs_content}{'...' if len(fs.get('content', '')) > 150 else ''}") + lines.append(f" 回收时请在 reference_foreshadow_id 中填写: {fs_id}") + lines.append("") + + # 2. 超期的伏笔(需要尽快处理) + if overdue: + lines.append("-" * 50) + lines.append("【⚠️ 超期待回收伏笔 - 建议尽快回收】") + lines.append("-" * 50) + for i, fs in enumerate(overdue, 1): + fs_id = fs.get('id', 'unknown') + fs_title = fs.get('title', '未命名伏笔') + fs_content = fs.get('content', '')[:100] + plant_chapter = fs.get('plant_chapter_number', '?') + hint = fs.get('resolve_hint', '') + + lines.append(f"{i}. 【ID: {fs_id}】{fs_title}") + lines.append(f" 状态:{hint}") + lines.append(f" 埋入章节:第{plant_chapter}章") + lines.append(f" 内容:{fs_content}{'...' if len(fs.get('content', '')) > 100 else ''}") + lines.append("") + + # 3. 尚未到期的伏笔(禁止提前回收,仅作参考) + if not_yet: + lines.append("-" * 50) + lines.append("【📋 尚未到期的伏笔 - 仅供参考,请勿在本章回收】") + lines.append("-" * 50) + lines.append("⚠️ 以下伏笔尚未到计划回收时间,请勿提前回收!") + lines.append("") + for i, fs in enumerate(not_yet[:5], 1): # 最多显示5个 + fs_title = fs.get('title', '未命名伏笔') + target_chapter = fs.get('target_resolve_chapter_number', '?') + hint = fs.get('resolve_hint', '') + + lines.append(f"{i}. {fs_title}") + lines.append(f" 计划回收章节:第{target_chapter}章 | {hint}") + lines.append("") + + if len(not_yet) > 5: + lines.append(f" ... 还有 {len(not_yet) - 5} 个未到期伏笔") + lines.append("") + + # 4. 无明确计划的伏笔(可根据剧情自然回收) + if no_plan: + lines.append("-" * 50) + lines.append("【📝 无明确计划的伏笔 - 可根据剧情自然回收】") + lines.append("-" * 50) + for i, fs in enumerate(no_plan[:3], 1): # 最多显示3个 + fs_id = fs.get('id', 'unknown') + fs_title = fs.get('title', '未命名伏笔') + fs_content = fs.get('content', '')[:80] + plant_chapter = fs.get('plant_chapter_number', '?') + + lines.append(f"{i}. 【ID: {fs_id}】{fs_title}") + lines.append(f" 埋入章节:第{plant_chapter}章") + lines.append(f" 内容:{fs_content}{'...' if len(fs.get('content', '')) > 80 else ''}") + lines.append("") + + return "\n".join(lines) if lines else "(暂无已埋入的伏笔)" + def _parse_analysis_response(self, response: str) -> Optional[Dict[str, Any]]: """ 解析AI返回的分析结果(使用统一的JSON清洗方法) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 02d1598..7a86cda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import Organizations from './pages/Organizations'; import Chapters from './pages/Chapters'; import ChapterReader from './pages/ChapterReader'; import ChapterAnalysis from './pages/ChapterAnalysis'; +import Foreshadows from './pages/Foreshadows'; import WritingStyles from './pages/WritingStyles'; import Settings from './pages/Settings'; import MCPPlugins from './pages/MCPPlugins'; @@ -59,6 +60,7 @@ function App() { } /> } /> } /> + } /> } /> } /> {/* } /> */} diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index dbef5a2..3e702eb 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1630,7 +1630,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenEditor(item.id)} > - 编辑内容 + 编辑 , (() => { const task = analysisTasksMap[item.id]; @@ -1650,7 +1650,7 @@ export default function Chapters() { '' } > - {isAnalyzing ? '分析中' : '查看分析'} + {isAnalyzing ? '分析中' : '分析'} ); })(), @@ -1659,7 +1659,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenModal(item.id)} > - 修改信息 + 修改 , ]} > @@ -1716,7 +1716,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenEditor(item.id)} size="small" - title="编辑内容" + title="编辑" /> {(() => { const task = analysisTasksMap[item.id]; @@ -1734,7 +1734,7 @@ export default function Chapters() { title={ !hasContent ? '请先生成章节内容' : isAnalyzing ? '分析中' : - '查看分析' + '分析' } /> ); @@ -1744,7 +1744,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenModal(item.id)} size="small" - title="修改信息" + title="修改" /> )} @@ -1815,7 +1815,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenEditor(item.id)} > - 编辑内容 + 编辑 , (() => { const task = analysisTasksMap[item.id]; @@ -1835,7 +1835,7 @@ export default function Chapters() { '' } > - {isAnalyzing ? '分析中' : '查看分析'} + {isAnalyzing ? '分析中' : '分析'} ); })(), @@ -1844,7 +1844,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenModal(item.id)} > - 修改信息 + 修改 , // 只在 one-to-many 模式下显示删除按钮 ...(currentProject.outline_mode === 'one-to-many' ? [ @@ -1940,7 +1940,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenEditor(item.id)} size="small" - title="编辑内容" + title="编辑" /> {(() => { const task = analysisTasksMap[item.id]; @@ -1958,7 +1958,7 @@ export default function Chapters() { title={ !hasContent ? '请先生成章节内容' : isAnalyzing ? '分析中' : - '查看分析' + '分析' } /> ); @@ -1968,7 +1968,7 @@ export default function Chapters() { icon={} onClick={() => handleOpenModal(item.id)} size="small" - title="修改信息" + title="修改" /> {/* 只在 one-to-many 模式下显示删除按钮 */} {currentProject.outline_mode === 'one-to-many' && ( diff --git a/frontend/src/pages/Foreshadows.tsx b/frontend/src/pages/Foreshadows.tsx new file mode 100644 index 0000000..83dcd14 --- /dev/null +++ b/frontend/src/pages/Foreshadows.tsx @@ -0,0 +1,1021 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { + Card, Table, Button, Tag, Space, Modal, Form, Input, Select, + InputNumber, Switch, message, Tooltip, Popconfirm, Statistic, + Row, Col, Empty, Divider, Badge, Alert, Pagination, Dropdown +} from 'antd'; +import type { MenuProps } from 'antd'; +import { + PlusOutlined, SyncOutlined, EditOutlined, DeleteOutlined, + CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, + BulbOutlined, EyeOutlined, FlagOutlined, WarningOutlined, + ClockCircleOutlined, MoreOutlined, ReloadOutlined, InfoCircleOutlined +} from '@ant-design/icons'; +import { foreshadowApi, chapterApi, characterApi } from '../services/api'; +import type { + Foreshadow, ForeshadowCreate, ForeshadowUpdate, ForeshadowStats, + ForeshadowStatus, ForeshadowCategory, Chapter, Character +} from '../types'; + +const { TextArea } = Input; +const { Option } = Select; + +// 状态配置 +const STATUS_CONFIG: Record = { + pending: { label: '待埋入', color: 'default', icon: }, + planted: { label: '已埋入', color: 'green', icon: }, + resolved: { label: '已回收', color: 'blue', icon: }, + partially_resolved: { label: '部分回收', color: 'orange', icon: }, + abandoned: { label: '已废弃', color: 'default', icon: }, +}; + +// 分类配置 +const CATEGORY_CONFIG: Record = { + identity: { label: '身世', color: 'purple' }, + mystery: { label: '悬念', color: 'magenta' }, + item: { label: '物品', color: 'gold' }, + relationship: { label: '关系', color: 'cyan' }, + event: { label: '事件', color: 'blue' }, + ability: { label: '能力', color: 'green' }, + prophecy: { label: '预言', color: 'volcano' }, +}; + +export default function Foreshadows() { + const { projectId } = useParams<{ projectId: string }>(); + const [loading, setLoading] = useState(false); + const [foreshadows, setForeshadows] = useState([]); + const [stats, setStats] = useState(null); + const [chapters, setChapters] = useState([]); + const [characters, setCharacters] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + // 筛选条件 + const [statusFilter, setStatusFilter] = useState(undefined); + const [categoryFilter, setCategoryFilter] = useState(undefined); + const [sourceFilter, setSourceFilter] = useState(undefined); + + // 模态框状态 + const [editModalVisible, setEditModalVisible] = useState(false); + const [syncModalVisible, setSyncModalVisible] = useState(false); + const [detailModalVisible, setDetailModalVisible] = useState(false); + const [plantModalVisible, setPlantModalVisible] = useState(false); + const [resolveModalVisible, setResolveModalVisible] = useState(false); + + const [currentForeshadow, setCurrentForeshadow] = useState(null); + const [form] = Form.useForm(); + const [plantForm] = Form.useForm(); + const [resolveForm] = Form.useForm(); + const [syncing, setSyncing] = useState(false); + + // 表格容器引用,用于计算滚动高度 + const tableContainerRef = useRef(null); + const [tableScrollY, setTableScrollY] = useState(400); + + // 加载伏笔列表 + const loadForeshadows = useCallback(async () => { + if (!projectId) return; + + setLoading(true); + try { + const response = await foreshadowApi.getProjectForeshadows(projectId, { + status: statusFilter, + category: categoryFilter, + source_type: sourceFilter, + page: currentPage, + limit: pageSize, + }); + + setForeshadows(response.items); + setTotal(response.total); + if (response.stats) { + setStats(response.stats); + } + } catch (error) { + console.error('加载伏笔列表失败:', error); + } finally { + setLoading(false); + } + }, [projectId, statusFilter, categoryFilter, sourceFilter, currentPage, pageSize]); + + // 加载章节列表(用于选择) + const loadChapters = useCallback(async () => { + if (!projectId) return; + try { + const chaptersData = await chapterApi.getChapters(projectId); + setChapters(chaptersData); + } catch (error) { + console.error('加载章节列表失败:', error); + } + }, [projectId]); + + // 加载角色列表(用于关联角色) + const loadCharacters = useCallback(async () => { + if (!projectId) return; + try { + const charactersData = await characterApi.getCharacters(projectId); + setCharacters(charactersData); + } catch (error) { + console.error('加载角色列表失败:', error); + } + }, [projectId]); + + // 加载统计 + const loadStats = useCallback(async () => { + if (!projectId) return; + try { + // 获取当前最大章节号 + const maxChapter = chapters.length > 0 + ? Math.max(...chapters.map(c => c.chapter_number)) + : undefined; + const statsData = await foreshadowApi.getForeshadowStats(projectId, maxChapter); + setStats(statsData); + } catch (error) { + console.error('加载统计失败:', error); + } + }, [projectId, chapters]); + + useEffect(() => { + loadForeshadows(); + loadChapters(); + loadCharacters(); + }, [loadForeshadows, loadChapters, loadCharacters]); + + // 计算表格滚动高度 + useEffect(() => { + const calculateTableHeight = () => { + if (tableContainerRef.current) { + // 获取容器高度,减去表头高度(约55px) + const containerHeight = tableContainerRef.current.clientHeight; + setTableScrollY(Math.max(containerHeight - 55, 200)); + } + }; + + calculateTableHeight(); + window.addEventListener('resize', calculateTableHeight); + + // 延迟再计算一次,确保布局完成 + const timer = setTimeout(calculateTableHeight, 100); + + return () => { + window.removeEventListener('resize', calculateTableHeight); + clearTimeout(timer); + }; + }, [stats]); // stats 变化时重新计算(因为统计卡片高度可能变化) + + useEffect(() => { + if (chapters.length > 0) { + loadStats(); + } + }, [chapters, loadStats]); + + // 创建/编辑伏笔 + const handleSave = async (values: ForeshadowCreate | ForeshadowUpdate) => { + try { + if (currentForeshadow) { + await foreshadowApi.updateForeshadow(currentForeshadow.id, values as ForeshadowUpdate); + message.success('伏笔更新成功'); + } else { + await foreshadowApi.createForeshadow({ + ...values, + project_id: projectId!, + } as ForeshadowCreate); + message.success('伏笔创建成功'); + } + setEditModalVisible(false); + form.resetFields(); + setCurrentForeshadow(null); + loadForeshadows(); + } catch (error) { + console.error('保存伏笔失败:', error); + } + }; + + // 删除伏笔 + const handleDelete = async (id: string) => { + try { + await foreshadowApi.deleteForeshadow(id); + message.success('伏笔删除成功'); + loadForeshadows(); + } catch (error) { + console.error('删除伏笔失败:', error); + } + }; + + // 标记埋入 + const handlePlant = async (values: { chapter_id: string; hint_text?: string }) => { + if (!currentForeshadow) return; + + const chapter = chapters.find(c => c.id === values.chapter_id); + if (!chapter) return; + + try { + await foreshadowApi.plantForeshadow(currentForeshadow.id, { + chapter_id: values.chapter_id, + chapter_number: chapter.chapter_number, + hint_text: values.hint_text, + }); + message.success('伏笔已标记为埋入'); + setPlantModalVisible(false); + plantForm.resetFields(); + setCurrentForeshadow(null); + loadForeshadows(); + } catch (error) { + console.error('标记埋入失败:', error); + } + }; + + // 标记回收 + const handleResolve = async (values: { chapter_id: string; resolution_text?: string; is_partial?: boolean }) => { + if (!currentForeshadow) return; + + const chapter = chapters.find(c => c.id === values.chapter_id); + if (!chapter) return; + + try { + await foreshadowApi.resolveForeshadow(currentForeshadow.id, { + chapter_id: values.chapter_id, + chapter_number: chapter.chapter_number, + resolution_text: values.resolution_text, + is_partial: values.is_partial, + }); + message.success('伏笔已标记为回收'); + setResolveModalVisible(false); + resolveForm.resetFields(); + setCurrentForeshadow(null); + loadForeshadows(); + } catch (error) { + console.error('标记回收失败:', error); + } + }; + + // 标记废弃 + const handleAbandon = async (id: string) => { + try { + await foreshadowApi.abandonForeshadow(id); + message.success('伏笔已标记为废弃'); + loadForeshadows(); + } catch (error) { + console.error('标记废弃失败:', error); + } + }; + + // 从分析同步 + const handleSync = async () => { + if (!projectId) return; + + setSyncing(true); + try { + const result = await foreshadowApi.syncFromAnalysis(projectId, { + auto_set_planted: true, + }); + message.success(`同步完成: 新增${result.synced_count}个伏笔, 跳过${result.skipped_count}个`); + setSyncModalVisible(false); + loadForeshadows(); + } catch (error) { + console.error('同步失败:', error); + } finally { + setSyncing(false); + } + }; + + // 打开编辑模态框 + const openEditModal = (foreshadow?: Foreshadow) => { + setCurrentForeshadow(foreshadow || null); + if (foreshadow) { + // 确保数组类型字段不为null + form.setFieldsValue({ + ...foreshadow, + tags: foreshadow.tags || [], + related_characters: foreshadow.related_characters || [], + }); + } else { + form.resetFields(); + } + setEditModalVisible(true); + }; + + // 打开详情模态框 + const openDetailModal = (foreshadow: Foreshadow) => { + setCurrentForeshadow(foreshadow); + setDetailModalVisible(true); + }; + + // 打开埋入模态框 + const openPlantModal = (foreshadow: Foreshadow) => { + setCurrentForeshadow(foreshadow); + plantForm.resetFields(); + setPlantModalVisible(true); + }; + + // 打开回收模态框 + const openResolveModal = (foreshadow: Foreshadow) => { + setCurrentForeshadow(foreshadow); + resolveForm.resetFields(); + setResolveModalVisible(true); + }; + + // 计算紧急程度 + const getUrgencyBadge = (foreshadow: Foreshadow) => { + if (foreshadow.status !== 'planted' || !foreshadow.target_resolve_chapter_number) { + return null; + } + + const chaptersWithContent = chapters.filter(c => c.content); + const currentMaxChapter = chaptersWithContent.length > 0 + ? Math.max(...chaptersWithContent.map(c => c.chapter_number)) + : 0; + + const remaining = foreshadow.target_resolve_chapter_number - currentMaxChapter; + + if (remaining < 0) { + return ; + } else if (remaining <= 3) { + return ; + } + return null; + }; + + // 状态排序优先级 + const statusOrder: Record = { + planted: 1, // 已埋入优先(需要关注回收) + pending: 2, // 待埋入次之 + partially_resolved: 3, + resolved: 4, + abandoned: 5, + }; + + // 表格列定义 + const columns = [ + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + sorter: (a: Foreshadow, b: Foreshadow) => statusOrder[a.status] - statusOrder[b.status], + render: (status: ForeshadowStatus) => { + const config = STATUS_CONFIG[status]; + return ( + + {config.label} + + ); + }, + }, + { + title: '标题', + dataIndex: 'title', + key: 'title', + ellipsis: true, + sorter: (a: Foreshadow, b: Foreshadow) => a.title.localeCompare(b.title, 'zh-CN'), + render: (title: string, record: Foreshadow) => ( + + + openDetailModal(record)}>{title} + {record.is_long_term && ( + 长线 + )} + + {getUrgencyBadge(record)} + + ), + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 80, + sorter: (a: Foreshadow, b: Foreshadow) => { + const catA = a.category || ''; + const catB = b.category || ''; + return catA.localeCompare(catB, 'zh-CN'); + }, + render: (category?: ForeshadowCategory) => { + if (!category) return '-'; + const config = CATEGORY_CONFIG[category]; + return config ? {config.label} : category; + }, + }, + { + title: '埋入章节', + dataIndex: 'plant_chapter_number', + key: 'plant_chapter_number', + width: 120, + sorter: (a: Foreshadow, b: Foreshadow) => { + const valA = a.plant_chapter_number ?? 999999; + const valB = b.plant_chapter_number ?? 999999; + return valA - valB; + }, + defaultSortOrder: 'ascend' as const, + render: (num?: number) => num ? `第${num}章` : '-', + }, + { + title: '计划回收', + dataIndex: 'target_resolve_chapter_number', + key: 'target_resolve_chapter_number', + width: 120, + sorter: (a: Foreshadow, b: Foreshadow) => { + const valA = a.target_resolve_chapter_number ?? 999999; + const valB = b.target_resolve_chapter_number ?? 999999; + return valA - valB; + }, + render: (num?: number) => num ? `第${num}章` : '-', + }, + { + title: '重要性', + dataIndex: 'importance', + key: 'importance', + width: 100, + sorter: (a: Foreshadow, b: Foreshadow) => a.importance - b.importance, + render: (importance: number) => { + const stars = Math.round(importance * 5); + return '★'.repeat(stars) + '☆'.repeat(5 - stars); + }, + }, + { + title: '来源', + dataIndex: 'source_type', + key: 'source_type', + width: 80, + sorter: (a: Foreshadow, b: Foreshadow) => { + const srcA = a.source_type || ''; + const srcB = b.source_type || ''; + return srcA.localeCompare(srcB); + }, + render: (source?: string) => ( + + {source === 'analysis' ? '分析' : '手动'} + + ), + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_: unknown, record: Foreshadow) => ( + + + + + + + + + {/* 伏笔列表 - 表格内容可滚动,表头固定 */} +
+ , + }} + /> + + + {/* 分页器 - 固定在底部居中 */} +
+ { + setCurrentPage(page); + if (size !== pageSize) { + setPageSize(size); + } + }} + showSizeChanger + showTotal={(total) => `共 ${total} 条`} + showQuickJumper + /> +
+ + {/* 创建/编辑模态框 */} + { + setEditModalVisible(false); + setCurrentForeshadow(null); + form.resetFields(); + }} + onOk={() => form.submit()} + width={800} + destroyOnClose + > +
+ +
+ + + + + + + + + + + + +