feature: 新增伏笔管理系统,支持可视化追踪、AI智能关联回收及章节生成时的伏笔提醒

This commit is contained in:
xiamuceer-j
2026-01-19 17:24:37 +08:00
parent 927072d16f
commit 5f25deb289
19 changed files with 4068 additions and 91 deletions
@@ -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 ###
+214 -55
View File
@@ -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
+381
View File
@@ -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)}")
+39 -12
View File
@@ -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:
+75 -5
View File
@@ -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
# 先获取所有旧章节并计算总字数
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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"
]
+178
View File
@@ -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"<Foreshadow(id={self.id[:8]}, title={self.title}, status={self.status})>"
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 # 不紧急
+196
View File
@@ -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] # 最近埋入(可铺垫)
File diff suppressed because it is too large Load Diff
+119 -2
View File
@@ -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清洗方法)