update:优化章节上下文构建系统和大纲查找逻辑,优先使用outline_id直接关联

This commit is contained in:
xiamuceer-j
2026-02-12 12:40:12 +08:00
parent 26be04c32a
commit 76cf695c85
2 changed files with 858 additions and 470 deletions
+361 -318
View File
@@ -20,6 +20,7 @@ from app.models.project import Project
from app.models.outline import Outline
from app.models.character import Character
from app.models.career import Career, CharacterCareer
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember
from app.models.generation_history import GenerationHistory
from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask
@@ -476,199 +477,6 @@ async def check_prerequisites(db: AsyncSession, chapter: Chapter) -> tuple[bool,
return True, "", previous_chapters
async def build_smart_chapter_context(
db: AsyncSession,
project_id: str,
current_chapter_number: int,
user_id: str
) -> dict:
"""
智能构建章节生成上下文(支持海量章节场景)
策略:
1. 故事骨架:每50章采样1章(标题+摘要)
2. 相关历史:通过chapter_summary记忆语义检索15个最相关章节
3. 近期概要:最近30章的简要摘要(200字/章)
4. 最近完整:最近3章的完整内容
Args:
db: 数据库会话
project_id: 项目ID
current_chapter_number: 当前章节序号
user_id: 用户ID
Returns:
包含各部分上下文的字典
"""
context_parts = {
'story_skeleton': '', # 故事骨架
'relevant_history': '', # 相关历史章节
'recent_summary': '', # 近期概要
'recent_full': '', # 最近完整内容
'stats': {} # 统计信息
}
try:
# 1. 获取所有已完成的前置章节(只取ID和序号)
all_chapters_result = await db.execute(
select(Chapter.id, Chapter.chapter_number, Chapter.title)
.where(Chapter.project_id == project_id)
.where(Chapter.chapter_number < current_chapter_number)
.where(Chapter.content != None)
.where(Chapter.content != "")
.order_by(Chapter.chapter_number)
)
all_chapters_info = all_chapters_result.all()
total_previous = len(all_chapters_info)
if total_previous == 0:
logger.info("📚 这是第一章,无需构建前置上下文")
return context_parts
logger.info(f"📚 开始构建智能上下文:共{total_previous}章前置内容")
# 2. 构建故事骨架(每50章采样)
skeleton_chapters = []
if total_previous > 50:
sample_interval = 50
skeleton_indices = list(range(0, total_previous, sample_interval))
for idx in skeleton_indices:
chapter_info = all_chapters_info[idx]
# 获取章节摘要(优先从chapter_summary记忆获取)
summary_result = await db.execute(
select(StoryMemory.content)
.where(StoryMemory.project_id == project_id)
.where(StoryMemory.chapter_id == chapter_info.id)
.where(StoryMemory.memory_type == 'chapter_summary')
.limit(1)
)
summary_row = summary_result.scalar_one_or_none()
summary = summary_row if summary_row else "(无摘要)"
skeleton_chapters.append({
'number': chapter_info.chapter_number,
'title': chapter_info.title,
'summary': summary
})
context_parts['story_skeleton'] = "【故事骨架】\n" + "\n".join([
f"{ch['number']}章《{ch['title']}》:{ch['summary']}"
for ch in skeleton_chapters
])
logger.info(f" ✅ 故事骨架:采样{len(skeleton_chapters)}章(每50章1个)")
# 3. 语义检索相关历史章节(使用chapter_summary记忆)
# 获取当前章节的大纲作为查询
current_outline_result = await db.execute(
select(Outline.content)
.where(Outline.project_id == project_id)
.where(Outline.order_index == current_chapter_number)
)
current_outline = current_outline_result.scalar_one_or_none()
if current_outline and total_previous > 3:
# 使用记忆服务进行语义检索
relevant_memories = await memory_service.search_memories(
user_id=user_id,
project_id=project_id,
query=current_outline,
memory_types=['chapter_summary'],
limit=15, # 检索15个最相关的章节
min_importance=0.0 # 不过滤重要性,依赖语义相关度
)
if relevant_memories:
relevant_chapters_text = []
for mem in relevant_memories:
# 获取章节信息
chapter_result = await db.execute(
select(Chapter.chapter_number, Chapter.title)
.where(Chapter.id == mem['metadata'].get('chapter_id'))
)
chapter_info = chapter_result.first()
if chapter_info:
relevant_chapters_text.append(
f"{chapter_info.chapter_number}章《{chapter_info.title}》:{mem['content']} "
f"(相关度:{mem['similarity']:.2f})"
)
context_parts['relevant_history'] = "【相关历史章节】\n" + "\n".join(relevant_chapters_text)
logger.info(f" ✅ 相关历史:语义检索到{len(relevant_chapters_text)}")
# 4. 近期概要(最近30章,每章200字摘要)
recent_summary_count = min(30, total_previous)
recent_for_summary = all_chapters_info[-recent_summary_count:] if total_previous > 3 else []
if recent_for_summary and len(recent_for_summary) > 3: # 至少要有3章才做摘要
recent_summaries = []
for chapter_info in recent_for_summary[:-3]: # 排除最后3章(它们会完整展示)
# 优先获取chapter_summary记忆
summary_result = await db.execute(
select(StoryMemory.content)
.where(StoryMemory.project_id == project_id)
.where(StoryMemory.chapter_id == chapter_info.id)
.where(StoryMemory.memory_type == 'chapter_summary')
.limit(1)
)
summary = summary_result.scalar_one_or_none()
if summary:
recent_summaries.append(
f"{chapter_info.chapter_number}章《{chapter_info.title}》:{summary}"
)
if recent_summaries:
context_parts['recent_summary'] = "【近期章节概要】\n" + "\n".join(recent_summaries)
logger.info(f" ✅ 近期概要:{len(recent_summaries)}章摘要")
# 5. 最近完整内容(最近3章)
recent_full_count = min(3, total_previous)
recent_full_chapters = all_chapters_info[-recent_full_count:]
# 获取完整内容
recent_full_texts = []
for chapter_info in recent_full_chapters:
chapter_result = await db.execute(
select(Chapter.content)
.where(Chapter.id == chapter_info.id)
)
content = chapter_result.scalar_one_or_none()
if content:
recent_full_texts.append(
f"=== 第{chapter_info.chapter_number}章:{chapter_info.title} ===\n{content}"
)
context_parts['recent_full'] = "【最近章节完整内容】\n" + "\n\n".join(recent_full_texts)
logger.info(f" ✅ 最近完整:{len(recent_full_texts)}章全文")
# 6. 统计信息
context_parts['stats'] = {
'total_previous': total_previous,
'skeleton_samples': len(skeleton_chapters),
'relevant_history': len(relevant_memories) if current_outline and total_previous > 3 else 0,
'recent_summaries': len(recent_summaries) if recent_for_summary and len(recent_for_summary) > 3 else 0,
'recent_full': len(recent_full_texts)
}
# 计算总长度
total_length = sum([
len(context_parts['story_skeleton']),
len(context_parts['relevant_history']),
len(context_parts['recent_summary']),
len(context_parts['recent_full'])
])
context_parts['stats']['total_length'] = total_length
logger.info(f"📊 智能上下文构建完成:总长度 {total_length} 字符")
except Exception as e:
logger.error(f"❌ 构建智能上下文失败: {str(e)}", exc_info=True)
return context_parts
async def build_characters_info_with_careers(
db: AsyncSession,
project_id: str,
@@ -711,11 +519,75 @@ async def build_characters_info_with_careers(
if not character_ids:
return '暂无角色信息'
# 构建全局角色名称映射(用于关系显示)
all_chars_result = await db.execute(
select(Character.id, Character.name).where(Character.project_id == project_id)
)
all_char_name_map = {row.id: row.name for row in all_chars_result.all()}
character_careers_result = await db.execute(
select(CharacterCareer).where(CharacterCareer.character_id.in_(character_ids))
)
character_careers = character_careers_result.scalars().all()
# 获取所有角色的关系(一次性查询)
from sqlalchemy import or_
rels_result = await db.execute(
select(CharacterRelationship).where(
CharacterRelationship.project_id == project_id,
or_(
CharacterRelationship.character_from_id.in_(character_ids),
CharacterRelationship.character_to_id.in_(character_ids)
)
)
)
all_relationships = rels_result.scalars().all()
# 按角色ID分组关系
char_rels_map: dict[str, list] = {cid: [] for cid in character_ids}
for r in all_relationships:
if r.character_from_id in char_rels_map:
char_rels_map[r.character_from_id].append(r)
if r.character_to_id in char_rels_map:
char_rels_map[r.character_to_id].append(r)
# 获取所有组织及其成员关系(一次性查询)
orgs_result = await db.execute(
select(Organization).where(Organization.project_id == project_id)
)
all_orgs = orgs_result.scalars().all()
# 构建组织ID到组织名称的映射(通过关联的Character记录)
org_name_map = {} # org_id -> org_name
char_id_to_org = {} # character_id -> Organization(用于组织实体补充详情)
for org in all_orgs:
org_name_map[org.id] = all_char_name_map.get(org.character_id, '未知组织')
char_id_to_org[org.character_id] = org
# 获取所有组织的成员关系(一次性查询)
org_ids = [org.id for org in all_orgs]
all_org_members = []
if org_ids:
all_org_members_result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id.in_(org_ids)
)
)
all_org_members = all_org_members_result.scalars().all()
# 按组织ID分组成员(用于组织实体显示成员列表)
org_members_map: dict[str, list] = {oid: [] for oid in org_ids}
for m in all_org_members:
if m.organization_id in org_members_map:
org_members_map[m.organization_id].append(m)
# 获取涉及当前非组织角色的成员关系
non_org_char_ids = [c.id for c in characters if not c.is_organization]
char_org_map: dict[str, list] = {cid: [] for cid in non_org_char_ids}
for m in all_org_members:
if m.character_id in char_org_map:
char_org_map[m.character_id].append(m)
# 构建角色ID到职业信息的映射
char_career_map = {}
for cc in character_careers:
@@ -741,9 +613,52 @@ async def build_characters_info_with_careers(
# 构建角色信息字符串
characters_info_parts = []
for c in characters:
# 基本信息
# 基本信息(含存活状态标记)
entity_type = '组织' if c.is_organization else '角色'
base_info = f"- {c.name}({entity_type}, {c.role_type})"
status_marker = ""
char_status = getattr(c, 'status', None) or 'active'
if char_status != 'active':
STATUS_MARKERS = {
'deceased': '💀已死亡',
'missing': '❓已失踪',
'retired': '📤已退场',
'destroyed': '💀已覆灭'
}
status_marker = f" [{STATUS_MARKERS.get(char_status, char_status)}]"
base_info = f"- {c.name}({entity_type}, {c.role_type}){status_marker}"
# 组织实体:补充组织详情
org_detail_str = ""
if c.is_organization and c.id in char_id_to_org:
org = char_id_to_org[c.id]
org_detail_parts = []
if c.organization_type:
org_detail_parts.append(f"类型:{c.organization_type}")
if c.organization_purpose:
purpose_preview = c.organization_purpose[:60] if len(c.organization_purpose) > 60 else c.organization_purpose
org_detail_parts.append(f"宗旨:{purpose_preview}")
if org.power_level is not None:
org_detail_parts.append(f"势力等级:{org.power_level}")
if org.location:
org_detail_parts.append(f"据点:{org.location}")
if org.motto:
org_detail_parts.append(f"口号:{org.motto}")
if org.member_count:
org_detail_parts.append(f"成员数:{org.member_count}")
if org_detail_parts:
org_detail_str = f" | {', '.join(org_detail_parts)}"
# 显示组织的核心成员列表(最多5个)
if org.id in org_members_map and org_members_map[org.id]:
member_parts = []
for m in sorted(org_members_map[org.id], key=lambda x: -(x.rank or 0))[:5]:
m_name = all_char_name_map.get(m.character_id, '未知')
m_desc = f"{m_name}({m.position})"
if m.status and m.status != 'active':
m_desc += f"[{m.status}]"
member_parts.append(m_desc)
if member_parts:
org_detail_str += f" | 成员: {', '.join(member_parts)}"
# 职业信息
career_info_str = ""
@@ -764,6 +679,55 @@ async def build_characters_info_with_careers(
sub_list.append(f"{sub['name']}({stage_desc})")
career_info_str += f" | 副职业: {', '.join(sub_list)}"
# 心理状态(由章节分析自动更新)
state_str = ""
if c.current_state:
state_preview = c.current_state[:50] if len(c.current_state) > 50 else c.current_state
state_str = f" | 当前状态: {state_preview}"
if c.state_updated_chapter:
state_str += f"(第{c.state_updated_chapter}章)"
# 组织成员信息(非组织角色才显示所属组织)
org_str = ""
if not c.is_organization and c.id in char_org_map and char_org_map[c.id]:
org_parts = []
for m in char_org_map[c.id][:3]: # 最多显示3个组织
o_name = org_name_map.get(m.organization_id, '未知组织')
o_desc = f"{o_name}({m.position})"
if m.loyalty is not None and m.loyalty != 50:
o_desc += f"[忠诚度:{m.loyalty}]"
if m.status and m.status != 'active':
o_desc += f"[{m.status}]"
org_parts.append(o_desc)
if org_parts:
org_str = f" | 所属组织: {', '.join(org_parts)}"
# 关系信息
rel_str = ""
if c.id in char_rels_map and char_rels_map[c.id]:
rel_parts = []
seen_pairs = set() # 避免重复显示同一对关系
for r in char_rels_map[c.id][:5]: # 最多显示5个关系
# 确定对方角色名
if r.character_from_id == c.id:
other_name = all_char_name_map.get(r.character_to_id, '未知')
else:
other_name = all_char_name_map.get(r.character_from_id, '未知')
pair_key = tuple(sorted([c.id, r.character_from_id if r.character_from_id != c.id else r.character_to_id]))
if pair_key in seen_pairs:
continue
seen_pairs.add(pair_key)
rel_name = r.relationship_name or '关联'
rel_desc = f"{other_name}({rel_name})"
if r.intimacy_level is not None and r.intimacy_level != 50:
rel_desc += f"[亲密度:{r.intimacy_level}]"
rel_parts.append(rel_desc)
if rel_parts:
rel_str = f" | 关系: {', '.join(rel_parts)}"
# 性格描述
personality_str = ""
if c.personality:
@@ -771,7 +735,7 @@ async def build_characters_info_with_careers(
personality_str = f": {personality_preview}"
# 组合完整信息
full_info = base_info + career_info_str + personality_str
full_info = base_info + org_detail_str + career_info_str + state_str + org_str + rel_str + personality_str
characters_info_parts.append(full_info)
return "\n".join(characters_info_parts)
@@ -903,6 +867,63 @@ async def analyze_chapter_background(
)
logger.info(f"📋 后台分析 - 已获取{len(existing_foreshadows)}个已埋入伏笔用于匹配(含智能回收标记)")
# 获取项目角色信息(根据大纲/展开规划筛选本章相关角色)
filter_character_names = None
# 1-N模式:从expansion_plan中提取character_focus
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
focus_names = plan.get('character_focus', [])
if focus_names:
filter_character_names = focus_names
logger.info(f"📋 从expansion_plan提取角色焦点: {filter_character_names}")
except (json.JSONDecodeError, Exception):
pass
# 1-1模式:从outline.structure中提取characters
if not filter_character_names and chapter.outline_id:
try:
outline_result = await db_session.execute(
select(Outline).where(Outline.id == chapter.outline_id)
)
chapter_outline = outline_result.scalar_one_or_none()
if chapter_outline and chapter_outline.structure:
structure = json.loads(chapter_outline.structure)
raw_characters = structure.get('characters', [])
if raw_characters:
filter_character_names = [
c['name'] if isinstance(c, dict) else c
for c in raw_characters
]
logger.info(f"📋 从outline.structure提取角色: {filter_character_names}")
except (json.JSONDecodeError, Exception):
pass
# 查询角色(根据筛选名单或全部)
characters_query = select(Character).where(Character.project_id == project_id)
if filter_character_names:
characters_query = characters_query.where(Character.name.in_(filter_character_names))
characters_result = await db_session.execute(characters_query)
project_characters = characters_result.scalars().all()
# 如果筛选后无角色,降级为全部角色
if not project_characters and filter_character_names:
logger.warning(f"⚠️ 筛选后无匹配角色,降级为全部角色")
characters_result = await db_session.execute(
select(Character).where(Character.project_id == project_id)
)
project_characters = characters_result.scalars().all()
filter_character_names = None
characters_info = await build_characters_info_with_careers(
db=db_session,
project_id=project_id,
characters=project_characters,
filter_character_names=filter_character_names
)
logger.info(f"📋 后台分析 - 已获取{len(project_characters)}个角色信息用于分析")
# 定义重试回调函数,用于在重试时更新任务状态
async def on_retry_callback(attempt: int, max_retries: int, wait_time: int, error_reason: str):
"""重试时更新任务状态,让前端能感知到重试进度"""
@@ -924,7 +945,7 @@ async def analyze_chapter_background(
except Exception as callback_error:
logger.warning(f"⚠️ 更新重试状态失败: {callback_error}")
# 3. 使用PlotAnalyzer分析章节(传入已有伏笔列表和重试回调)
# 3. 使用PlotAnalyzer分析章节(传入已有伏笔列表、角色信息和重试回调)
analyzer = PlotAnalyzer(ai_service)
analysis_result = await analyzer.analyze_chapter(
chapter_number=chapter.chapter_number,
@@ -932,7 +953,8 @@ async def analyze_chapter_background(
content=chapter.content,
word_count=chapter.word_count or len(chapter.content),
existing_foreshadows=existing_foreshadows,
on_retry=on_retry_callback
on_retry=on_retry_callback,
characters_info=characters_info
)
if not analysis_result:
@@ -1131,6 +1153,72 @@ async def analyze_chapter_background(
else:
logger.debug("📋 分析结果中无角色状态信息,跳过职业更新")
# 👤 更新角色心理状态和关系(根据分析结果)
if analysis_result.get('character_states'):
try:
from app.services.character_state_update_service import CharacterStateUpdateService
logger.info(f"👤 开始根据分析结果更新角色状态、关系和组织成员...")
async with write_lock:
state_update_result = await CharacterStateUpdateService.update_from_analysis(
db=db_session,
project_id=project_id,
character_states=analysis_result.get('character_states', []),
chapter_id=chapter_id,
chapter_number=chapter.chapter_number
)
total_state_changes = (
state_update_result['state_updated_count'] +
state_update_result['relationship_created_count'] +
state_update_result['relationship_updated_count'] +
state_update_result.get('org_updated_count', 0)
)
if total_state_changes > 0:
logger.info(
f"✅ 角色状态更新: 心理状态{state_update_result['state_updated_count']}个, "
f"新建关系{state_update_result['relationship_created_count']}个, "
f"更新关系{state_update_result['relationship_updated_count']}个, "
f"组织变动{state_update_result.get('org_updated_count', 0)}"
)
if state_update_result['changes']:
for change in state_update_result['changes'][:8]:
logger.info(f" - {change}")
else:
logger.info("️ 本章节无角色状态、关系或组织变化")
except Exception as state_error:
# 角色状态更新失败不应影响整个分析流程
logger.error(f"⚠️ 更新角色状态、关系和组织失败: {str(state_error)}", exc_info=True)
# 🏛️ 更新组织自身状态(根据分析结果)
if analysis_result.get('organization_states'):
try:
from app.services.character_state_update_service import CharacterStateUpdateService
logger.info(f"🏛️ 开始根据分析结果更新组织自身状态...")
async with write_lock:
org_state_result = await CharacterStateUpdateService.update_organization_states(
db=db_session,
project_id=project_id,
organization_states=analysis_result.get('organization_states', []),
chapter_number=chapter.chapter_number
)
if org_state_result['updated_count'] > 0:
logger.info(
f"✅ 组织状态更新: {org_state_result['updated_count']}个组织"
)
if org_state_result['changes']:
for change in org_state_result['changes'][:5]:
logger.info(f" - {change}")
else:
logger.info("️ 本章节无组织自身状态变化")
except Exception as org_state_error:
# 组织状态更新失败不应影响整个分析流程
logger.error(f"⚠️ 更新组织自身状态失败: {str(org_state_error)}", exc_info=True)
# 🔮 自动更新伏笔状态(根据分析结果)
if analysis_result.get('foreshadows'):
try:
@@ -1319,13 +1407,21 @@ async def generate_chapter_content_stream(
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 项目大纲模式: {outline_mode}")
# 获取对应的大纲
outline_result = await db_session.execute(
select(Outline)
.where(Outline.project_id == current_chapter.project_id)
.where(Outline.order_index == current_chapter.chapter_number)
.execution_options(populate_existing=True)
)
# 获取对应的大纲(优先使用 chapter.outline_id 直接关联)
if current_chapter.outline_id:
outline_result = await db_session.execute(
select(Outline)
.where(Outline.id == current_chapter.outline_id)
.execution_options(populate_existing=True)
)
else:
# 回退到按序号查找
outline_result = await db_session.execute(
select(Outline)
.where(Outline.project_id == current_chapter.project_id)
.where(Outline.order_index == current_chapter.chapter_number)
.execution_options(populate_existing=True)
)
outline = outline_result.scalar_one_or_none()
# 获取写作风格
@@ -1429,6 +1525,7 @@ async def generate_chapter_content_stream(
genre=project.genre or '未设定',
narrative_perspective=chapter_perspective,
previous_chapter_content=chapter_context.continuation_point,
previous_chapter_summary=chapter_context.previous_chapter_summary or '(无上一章摘要)',
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
@@ -1476,9 +1573,10 @@ async def generate_chapter_content_stream(
genre=project.genre or '未设定',
narrative_perspective=chapter_perspective,
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
previous_chapter_summary=previous_summary,
story_skeleton=chapter_context.story_skeleton or '',
recent_chapters_context=chapter_context.recent_chapters_context or '',
relevant_memories=chapter_context.relevant_memories or ''
)
logger.debug(f"创建第{current_chapter.chapter_number}章提示词: {base_prompt}")
@@ -1495,7 +1593,10 @@ async def generate_chapter_content_stream(
target_word_count=target_word_count,
genre=project.genre or '未设定',
narrative_perspective=chapter_perspective,
characters_info=chapter_context.chapter_characters or '暂无角色信息'
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
relevant_memories=chapter_context.relevant_memories or '暂无相关记忆'
)
logger.debug(f"创建第一章提示词: {base_prompt}")
@@ -2658,49 +2759,19 @@ async def generate_single_chapter_for_batch(
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 批量生成 - 项目大纲模式: {outline_mode}")
# 获取对应的大纲
outline_result = await db_session.execute(
select(Outline)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline = outline_result.scalar_one_or_none()
# 获取角色信息(包含职业信息)
characters_result = await db_session.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
# 📝 根据大纲模式智能筛选相关角色(批量生成)
filter_character_names = None
if outline_mode == 'one-to-one':
# 1-1模式:从outline.structure中提取characters字段
if outline and outline.structure:
try:
structure = json.loads(outline.structure)
filter_character_names = structure.get('characters', [])
if filter_character_names:
logger.info(f"📋 批量生成 - 1-1模式:从structure提取角色列表 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 批量生成 - outline.structure解析失败,使用全部角色")
# 获取对应的大纲(优先使用 chapter.outline_id 直接关联)
if chapter.outline_id:
outline_result = await db_session.execute(
select(Outline).where(Outline.id == chapter.outline_id)
)
else:
# 1-n模式:从chapter.expansion_plan中提取character_focus字段
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
filter_character_names = plan.get('character_focus', [])
if filter_character_names:
logger.info(f"📋 批量生成 - 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 批量生成 - expansion_plan解析失败,使用全部角色")
characters_info = await build_characters_info_with_careers(
db=db_session,
project_id=chapter.project_id,
characters=characters,
filter_character_names=filter_character_names
)
# 回退到按序号查找
outline_result = await db_session.execute(
select(Outline)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline = outline_result.scalar_one_or_none()
# 获取写作风格
style_content = ""
@@ -2753,52 +2824,8 @@ async def generate_single_chapter_for_batch(
logger.info(f" - 相关记忆: {chapter_context.context_stats.get('memory_count', 0)}")
logger.info(f" - 总上下文长度: {chapter_context.context_stats.get('total_length', 0)} 字符")
# 📋 根据大纲模式构建差异化的章节大纲上下文
chapter_outline_content = ""
if outline_mode == 'one-to-one':
# 一对一模式:使用大纲的 content
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
logger.info(f"✏️ 批量生成 - 一对一模式:使用大纲内容")
else:
# 一对多模式:优先使用 expansion_plan 的详细规划
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
chapter_outline_content = f"""【本章详细规划】
剧情摘要:{plan.get('plot_summary', '')}
关键事件:
{chr(10).join(f'- {event}' for event in plan.get('key_events', []))}
角色焦点:{', '.join(plan.get('character_focus', []))}
情感基调:{plan.get('emotional_tone', '未设定')}
叙事目标:{plan.get('narrative_goal', '未设定')}
冲突类型:{plan.get('conflict_type', '未设定')}"""
# 可选:附加章节 summary
if chapter.summary and chapter.summary.strip():
chapter_outline_content += f"\n\n【章节补充说明】\n{chapter.summary}"
# 可选:附加大纲的背景信息(限制长度)
if outline:
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:
logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容")
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
else:
# 没有expansion_plan,使用大纲内容
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
logger.warning(f"⚠️ 批量生成 - 一对多模式但无expansion_plan,使用大纲内容")
# 🚀 根据大纲模式选择提示词模板(批量生成)
# 统一使用 context_builder 构建的 chapter_context 结果,与单章生成保持一致
if outline_mode == 'one-to-one':
# 1-1模式
if chapter_context.continuation_point:
@@ -2817,7 +2844,8 @@ async def generate_single_chapter_for_batch(
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
relevant_memories=chapter_context.relevant_memories or '暂无相关记忆'
relevant_memories=chapter_context.relevant_memories or '暂无相关记忆',
previous_chapter_summary=chapter_context.previous_chapter_summary or ''
)
else:
# 第一章
@@ -2837,17 +2865,16 @@ async def generate_single_chapter_for_batch(
relevant_memories=chapter_context.relevant_memories or '暂无相关记忆'
)
else:
# 1-n模式:使用原有的完整模板
# 1-n模式:使用 context_builder 构建的结果,与单章生成保持一致
if chapter_context.continuation_point:
# 有前置内容,使用 WITH_CONTEXT 模板
# 优先使用 context_builder 的摘要,其次使用传入的 previous_summary_context
final_prev_summary = "(无上一章摘要,请根据锚点续写)"
if previous_summary_context:
if chapter_context.previous_chapter_summary:
final_prev_summary = chapter_context.previous_chapter_summary
elif 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_ONE_TO_MANY_NEXT", user_id, db_session)
base_prompt = PromptService.format_prompt(
@@ -2855,15 +2882,16 @@ async def generate_single_chapter_for_batch(
project_title=project.title,
chapter_number=chapter.chapter_number,
chapter_title=chapter.title,
chapter_outline=chapter_outline_content,
chapter_outline=chapter_context.chapter_outline,
target_word_count=target_word_count,
continuation_point=chapter_context.continuation_point,
genre=project.genre or '未设定',
narrative_perspective=project.narrative_perspective or '第三人称',
characters_info=characters_info or '暂无角色信息',
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
previous_chapter_summary=final_prev_summary,
story_skeleton=chapter_context.story_skeleton or '',
recent_chapters_context=chapter_context.recent_chapters_context or '',
relevant_memories=chapter_context.relevant_memories or ''
)
else:
@@ -2874,11 +2902,14 @@ async def generate_single_chapter_for_batch(
project_title=project.title,
chapter_number=chapter.chapter_number,
chapter_title=chapter.title,
chapter_outline=chapter_outline_content,
chapter_outline=chapter_context.chapter_outline,
target_word_count=target_word_count,
genre=project.genre or '未设定',
narrative_perspective=project.narrative_perspective or '第三人称',
characters_info=characters_info or '暂无角色信息'
characters_info=chapter_context.chapter_characters or '暂无角色信息',
chapter_careers=chapter_context.chapter_careers or '暂无职业信息',
foreshadow_reminders=chapter_context.foreshadow_reminders or '暂无需要关注的伏笔',
relevant_memories=chapter_context.relevant_memories or '暂无相关记忆'
)
# 应用写作风格
@@ -3050,12 +3081,18 @@ async def regenerate_chapter_stream(
filter_character_names = None
if outline_mode == 'one-to-one':
# 1-1模式:从outline.structure中提取characters字段
outline_result_temp = await temp_db.execute(
select(Outline.structure)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
# 1-1模式:从outline.structure中提取characters字段(优先使用 outline_id
if chapter.outline_id:
outline_result_temp = await temp_db.execute(
select(Outline.structure)
.where(Outline.id == chapter.outline_id)
)
else:
outline_result_temp = await temp_db.execute(
select(Outline.structure)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline_structure = outline_result_temp.scalar_one_or_none()
if outline_structure:
try:
@@ -3083,12 +3120,18 @@ async def regenerate_chapter_stream(
filter_character_names=filter_character_names
)
# 获取章节大纲
outline_result = await temp_db.execute(
select(Outline)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
# 获取章节大纲(优先使用 chapter.outline_id 直接关联)
if chapter.outline_id:
outline_result = await temp_db.execute(
select(Outline).where(Outline.id == chapter.outline_id)
)
else:
# 回退到按序号查找
outline_result = await temp_db.execute(
select(Outline)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline = outline_result.scalar_one_or_none()
# 获取写作风格
+496 -151
View File
@@ -13,6 +13,7 @@ from app.models.character import Character
from app.models.career import Career, CharacterCareer
from app.models.memory import StoryMemory
from app.models.foreshadow import Foreshadow
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember
from app.logger import get_logger
logger = get_logger(__name__)
@@ -24,14 +25,15 @@ class OneToManyContext:
1-N模式章节上下文数据结构
采用RTCO框架的分层设计:
- P0-核心:大纲、衔接锚点、字数要求
- P1-重要:角色、情感基调、风格
- P2-参考:记忆、故事骨架、伏笔提醒
- P0-核心:大纲(含最近10章规划)、衔接锚点(500字+摘要)、字数要求
- P1-重要:角色(完整版含关系/组织/职业)、职业详情、情感基调
- P2-参考:记忆(始终启用,相关度>0.6、伏笔提醒
"""
# === P0-核心信息 ===
chapter_outline: str = "" # 本章大纲(从expansion_plan构建)
continuation_point: Optional[str] = None # 衔接锚点
recent_chapters_context: Optional[str] = None # 最近10章expansion_plan摘要
continuation_point: Optional[str] = None # 衔接锚点(统一500字)
previous_chapter_summary: Optional[str] = None # 上一章剧情摘要
previous_chapter_events: Optional[List[str]] = None # 上一章关键事件
target_word_count: int = 3000
@@ -49,13 +51,12 @@ class OneToManyContext:
theme: str = ""
# === P1-重要信息 ===
chapter_characters: str = "" # 从character_focus筛选的角色
chapter_characters: str = "" # 完整版角色信息(含年龄、外貌、背景、关系、组织)
chapter_careers: Optional[str] = None # 独立的职业详情(含完整阶段体系)
emotional_tone: str = ""
style_instruction: str = ""
# === P2-参考信息 ===
relevant_memories: Optional[str] = None
story_skeleton: Optional[str] = None # 50章+启用
relevant_memories: Optional[str] = None # 始终启用(相关度>0.6
foreshadow_reminders: Optional[str] = None
# === 元信息 ===
@@ -64,9 +65,10 @@ class OneToManyContext:
def get_total_context_length(self) -> int:
"""计算总上下文长度"""
total = 0
for field_name in ['chapter_outline', 'continuation_point', 'chapter_characters',
'relevant_memories', 'story_skeleton', 'style_instruction',
'foreshadow_reminders', 'previous_chapter_summary']:
for field_name in ['chapter_outline', 'recent_chapters_context', 'continuation_point',
'chapter_characters', 'chapter_careers',
'relevant_memories', 'foreshadow_reminders',
'previous_chapter_summary']:
value = getattr(self, field_name, None)
if value:
total += len(value)
@@ -102,6 +104,7 @@ class OneToOneContext:
# === P1-重要信息 ===
continuation_point: Optional[str] = None # 上一章最后500字
previous_chapter_summary: Optional[str] = None # 上一章剧情摘要
chapter_characters: str = "" # 从structure.characters获取
chapter_careers: Optional[str] = None # 本章涉及的职业完整信息
@@ -115,8 +118,9 @@ class OneToOneContext:
def get_total_context_length(self) -> int:
"""计算总上下文长度"""
total = 0
for field_name in ['chapter_outline', 'continuation_point', 'chapter_characters',
'chapter_careers', 'foreshadow_reminders', 'relevant_memories']:
for field_name in ['chapter_outline', 'continuation_point', 'previous_chapter_summary',
'chapter_characters', 'chapter_careers', 'foreshadow_reminders',
'relevant_memories']:
value = getattr(self, field_name, None)
if value:
total += len(value)
@@ -129,23 +133,20 @@ class OneToManyContextBuilder:
"""
1-N模式上下文构建器
实现动态裁剪逻辑,根据章节序号自动调整上下文复杂度
- 第1章:无前置上下文,仅提供大纲和角色
- 第2-10章:上一章结尾300字 + 涉及角色
- 第11-50章:上一章结尾500字 + 相关记忆3条
- 第51章+:上一章结尾500字 + 故事骨架 + 智能记忆5条
上下文构建策略
- 章节大纲:本章expansion_plan + 最近10章expansion_plan摘要
- 衔接锚点:统一上一章末尾500字 + 摘要
- 角色信息:完整版(含年龄、外貌、背景、关系、组织、职业)
- 职业详情:独立的chapter_careers字段,含完整阶段体系
- 相关记忆:始终启用(相关度>0.6)
- 伏笔提醒:始终启用
"""
# 配置常量
ENDING_LENGTH_SHORT = 300 # 1-10章:短衔接
ENDING_LENGTH_NORMAL = 500 # 11章+:标准衔接
MEMORY_COUNT_LIGHT = 3 # 11-50章:轻量记忆
MEMORY_COUNT_FULL = 5 # 51章+:完整记忆
SKELETON_THRESHOLD = 50 # 启用故事骨架的章节阈值
SKELETON_SAMPLE_INTERVAL = 10 # 故事骨架采样间隔
MEMORY_IMPORTANCE_THRESHOLD = 0.7 # 记忆重要性阈值
STYLE_MAX_LENGTH = 200 # 风格描述最大长度
MAX_CONTEXT_LENGTH = 3000 # 总上下文最大字符数
ENDING_LENGTH = 500 # 统一衔接长度500字
MEMORY_COUNT = 10 # 记忆条数
MEMORY_SIMILARITY_THRESHOLD = 0.6 # 记忆相关度阈值
RECENT_CHAPTERS_COUNT = 10 # 最近章节规划数量
def __init__(self, memory_service=None, foreshadow_service=None):
"""
@@ -178,7 +179,7 @@ class OneToManyContextBuilder:
outline: 大纲对象(可选)
user_id: 用户ID
db: 数据库会话
style_content: 写作风格内容(可选)
style_content: 写作风格内容(可选,不再使用,保留参数兼容性
target_word_count: 目标字数
temp_narrative_perspective: 临时叙事视角(可选,覆盖项目默认)
@@ -211,59 +212,47 @@ class OneToManyContextBuilder:
# === P0-核心信息(始终构建)===
context.chapter_outline = self._build_chapter_outline_1n(chapter, outline)
# === 衔接锚点(根据章节调整长度,增强版含摘要和事件)===
# === 最近10章expansion_plan摘要 ===
if chapter_number > 1:
context.recent_chapters_context = await self._build_recent_chapters_context(
chapter, project.id, db
)
logger.info(f" ✅ 最近章节规划: {len(context.recent_chapters_context or '')}字符")
# === 衔接锚点(统一500字 + 摘要)===
if chapter_number == 1:
context.continuation_point = None
context.previous_chapter_summary = None
context.previous_chapter_events = None
logger.info(" ✅ 第1章无需衔接锚点")
elif chapter_number <= 10:
ending_info = await self._get_last_ending_enhanced(
chapter, db, self.ENDING_LENGTH_SHORT
)
context.continuation_point = ending_info.get('ending_text')
context.previous_chapter_summary = ending_info.get('summary')
context.previous_chapter_events = ending_info.get('key_events')
logger.info(f" ✅ 衔接锚点(短): {len(context.continuation_point or '')}字符")
else:
ending_info = await self._get_last_ending_enhanced(
chapter, db, self.ENDING_LENGTH_NORMAL
chapter, db, self.ENDING_LENGTH
)
context.continuation_point = ending_info.get('ending_text')
context.previous_chapter_summary = ending_info.get('summary')
context.previous_chapter_events = ending_info.get('key_events')
logger.info(f" ✅ 衔接锚点(标准): {len(context.continuation_point or '')}字符")
logger.info(f" ✅ 衔接锚点: {len(context.continuation_point or '')}字符")
# === P1-重要信息 ===
context.chapter_characters = await self._build_chapter_characters_1n(
# 角色信息(完整版:含年龄、外貌、背景、关系、组织、职业)+ 独立职业详情
characters_info, careers_info = await self._build_chapter_characters_1n(
chapter, project, outline, db
)
context.chapter_characters = characters_info
context.chapter_careers = careers_info
context.emotional_tone = self._extract_emotional_tone(chapter, outline)
logger.info(f" ✅ 角色信息: {len(context.chapter_characters)}字符")
logger.info(f" ✅ 职业信息: {len(context.chapter_careers or '')}字符")
# 写作风格(摘要化)
if style_content:
context.style_instruction = self._summarize_style(style_content)
# === P2-参考信息(条件触发)===
if chapter_number > 10 and self.memory_service:
memory_limit = (
self.MEMORY_COUNT_LIGHT if chapter_number <= 50
else self.MEMORY_COUNT_FULL
)
context.relevant_memories = await self._get_relevant_memories(
# === P2-参考信息(始终启用)===
if self.memory_service:
context.relevant_memories = await self._get_relevant_memories_enhanced(
user_id, project.id, chapter_number,
context.chapter_outline,
limit=memory_limit
context.chapter_outline, db
)
logger.info(f" ✅ 相关记忆: {len(context.relevant_memories or '')}字符")
# 故事骨架(50章+
if chapter_number > self.SKELETON_THRESHOLD:
context.story_skeleton = await self._build_story_skeleton(
project.id, chapter_number, db
)
logger.info(f" ✅ 故事骨架: {len(context.story_skeleton or '')}字符")
# === P2-伏笔提醒===
if self.foreshadow_service:
context.foreshadow_reminders = await self._get_foreshadow_reminders(
@@ -279,8 +268,9 @@ class OneToManyContextBuilder:
"has_continuation": context.continuation_point is not None,
"continuation_length": len(context.continuation_point or ""),
"characters_length": len(context.chapter_characters),
"careers_length": len(context.chapter_careers or ""),
"recent_context_length": len(context.recent_chapters_context or ""),
"memories_length": len(context.relevant_memories or ""),
"skeleton_length": len(context.story_skeleton or ""),
"foreshadow_length": len(context.foreshadow_reminders or ""),
"total_length": context.get_total_context_length()
}
@@ -321,16 +311,21 @@ class OneToManyContextBuilder:
project: Project,
outline: Optional[Outline],
db: AsyncSession
) -> str:
"""构建1-N模式的角色信息(从expansion_plan提取character_focus"""
) -> tuple[str, Optional[str]]:
"""构建1-N模式的角色信息(完整版:含年龄、外貌、背景、关系、组织、职业)+ 独立职业详情"""
from sqlalchemy import or_
# 获取所有角色
characters_result = await db.execute(
select(Character).where(Character.project_id == project.id)
)
characters = characters_result.scalars().all()
all_characters = characters_result.scalars().all()
if not characters:
return "暂无角色信息"
if not all_characters:
return "暂无角色信息", None
# 构建全局角色名称映射(用于关系查询)
all_char_map = {c.id: c.name for c in all_characters}
# 从expansion_plan中提取角色焦点
filter_character_names = None
@@ -342,26 +337,338 @@ class OneToManyContextBuilder:
pass
# 筛选角色
characters = all_characters
if filter_character_names:
characters = [c for c in characters if c.name in filter_character_names]
characters = [c for c in all_characters if c.name in filter_character_names]
if not characters:
return "暂无相关角色"
return "暂无相关角色", None
# 构建精简的角色信息
char_lines = []
for c in characters[:10]:
role_type = "主角" if c.role_type == "protagonist" else (
"反派" if c.role_type == "antagonist" else "配角"
# 限制最多10个角色
characters = characters[:10]
character_ids = [c.id for c in characters]
# === 批量查询关系数据 ===
rels_result = await db.execute(
select(CharacterRelationship).where(
CharacterRelationship.project_id == project.id,
or_(
CharacterRelationship.character_from_id.in_(character_ids),
CharacterRelationship.character_to_id.in_(character_ids)
)
)
personality_brief = ""
if c.personality:
personality_brief = c.personality[:50]
if len(c.personality) > 50:
personality_brief += "..."
char_lines.append(f"- {c.name}({role_type}): {personality_brief}")
)
all_rels = rels_result.scalars().all()
return "\n".join(char_lines)
# 按角色ID分组关系
char_rels_map: Dict[str, List] = {cid: [] for cid in character_ids}
for r in all_rels:
if r.character_from_id in char_rels_map:
char_rels_map[r.character_from_id].append(r)
if r.character_to_id in char_rels_map:
char_rels_map[r.character_to_id].append(r)
# === 批量查询组织成员数据 ===
non_org_ids = [c.id for c in characters if not c.is_organization]
org_memberships_map: Dict[str, List] = {cid: [] for cid in non_org_ids}
if non_org_ids:
member_result = await db.execute(
select(OrganizationMember, Character.name).join(
Organization, OrganizationMember.organization_id == Organization.id
).join(
Character, Organization.character_id == Character.id
).where(OrganizationMember.character_id.in_(non_org_ids))
)
for m, org_name in member_result.all():
if m.character_id in org_memberships_map:
org_memberships_map[m.character_id].append((m, org_name))
# === 批量查询职业关联数据(CharacterCareer===
char_career_result = await db.execute(
select(CharacterCareer).where(CharacterCareer.character_id.in_(character_ids))
)
all_char_careers = char_career_result.scalars().all()
# 收集所有职业ID
career_ids = set()
for cc in all_char_careers:
career_ids.add(cc.career_id)
# 也加入 main_career_id
for c in characters:
if not c.is_organization and c.main_career_id:
career_ids.add(c.main_career_id)
careers_map: Dict[str, Career] = {}
if career_ids:
careers_result = await db.execute(
select(Career).where(Career.id.in_(list(career_ids)))
)
careers_map = {c.id: c for c in careers_result.scalars().all()}
# 构建角色ID到职业关联的映射
char_career_relations: Dict[str, Dict[str, List]] = {}
for cc in all_char_careers:
if cc.character_id not in char_career_relations:
char_career_relations[cc.character_id] = {'main': [], 'sub': []}
if cc.career_type == 'main':
char_career_relations[cc.character_id]['main'].append(cc)
else:
char_career_relations[cc.character_id]['sub'].append(cc)
# === 查询组织角色的成员列表 ===
org_chars = [c for c in characters if c.is_organization]
org_members_map: Dict[str, List] = {}
if org_chars:
org_char_ids = [c.id for c in org_chars]
orgs_result = await db.execute(
select(Organization).where(Organization.character_id.in_(org_char_ids))
)
orgs = orgs_result.scalars().all()
if orgs:
org_id_to_char_id = {o.id: o.character_id for o in orgs}
org_ids = [o.id for o in orgs]
members_result = await db.execute(
select(OrganizationMember, Character.name).join(
Character, OrganizationMember.character_id == Character.id
).where(OrganizationMember.organization_id.in_(org_ids))
)
for m, member_name in members_result.all():
char_id = org_id_to_char_id.get(m.organization_id)
if char_id:
if char_id not in org_members_map:
org_members_map[char_id] = []
org_members_map[char_id].append((m, member_name))
# === 构建完整版角色信息 ===
characters_info_parts = []
for c in characters:
entity_type = '组织' if c.is_organization else '角色'
role_type_map = {
'protagonist': '主角',
'antagonist': '反派',
'supporting': '配角'
}
role_type = role_type_map.get(c.role_type, c.role_type or '配角')
info_lines = [f"{c.name}】({entity_type}, {role_type})"]
# 详细属性
if c.age:
info_lines.append(f" 年龄: {c.age}")
if c.gender:
info_lines.append(f" 性别: {c.gender}")
if c.appearance:
appearance_preview = c.appearance[:100] if len(c.appearance) > 100 else c.appearance
info_lines.append(f" 外貌: {appearance_preview}")
if c.personality:
personality_preview = c.personality[:100] if len(c.personality) > 100 else c.personality
info_lines.append(f" 性格: {personality_preview}")
if c.background:
background_preview = c.background[:150] if len(c.background) > 150 else c.background
info_lines.append(f" 背景: {background_preview}")
# 职业信息
if c.id in char_career_relations:
career_rel = char_career_relations[c.id]
if career_rel['main']:
for cc in career_rel['main']:
career = careers_map.get(cc.career_id)
if career:
try:
stages = json.loads(career.stages) if isinstance(career.stages, str) else career.stages
stage_name = f'{cc.current_stage}'
for stage in (stages or []):
if stage.get('level') == cc.current_stage:
stage_name = stage.get('name', stage_name)
break
except (json.JSONDecodeError, AttributeError, TypeError):
stage_name = f'{cc.current_stage}'
info_lines.append(f" 主职业: {career.name} ({cc.current_stage}/{career.max_stage}阶 - {stage_name})")
if career_rel['sub']:
for cc in career_rel['sub']:
career = careers_map.get(cc.career_id)
if career:
try:
stages = json.loads(career.stages) if isinstance(career.stages, str) else career.stages
stage_name = f'{cc.current_stage}'
for stage in (stages or []):
if stage.get('level') == cc.current_stage:
stage_name = stage.get('name', stage_name)
break
except (json.JSONDecodeError, AttributeError, TypeError):
stage_name = f'{cc.current_stage}'
info_lines.append(f" 副职业: {career.name} ({cc.current_stage}/{career.max_stage}阶 - {stage_name})")
elif not c.is_organization and c.main_career_id:
career = careers_map.get(c.main_career_id)
if career:
stage = c.main_career_stage or 1
info_lines.append(f" 主职业: {career.name}(第{stage}阶段)")
# 角色关系
if not c.is_organization and c.id in char_rels_map:
rels = char_rels_map[c.id]
if rels:
rel_parts = []
for r in rels:
if r.character_from_id == c.id:
target_name = all_char_map.get(r.character_to_id, "未知")
else:
target_name = all_char_map.get(r.character_from_id, "未知")
rel_name = r.relationship_name or "相关"
rel_parts.append(f"{target_name}{rel_name}")
info_lines.append(f" 关系网络: {''.join(rel_parts)}")
# 组织归属
if not c.is_organization and c.id in org_memberships_map:
memberships = org_memberships_map[c.id]
if memberships:
org_parts = [f"{org_name}{m.position}" for m, org_name in memberships[:2]]
info_lines.append(f" 组织归属: {''.join(org_parts)}")
# 组织特有信息
if c.is_organization:
if c.organization_type:
info_lines.append(f" 组织类型: {c.organization_type}")
if c.organization_purpose:
info_lines.append(f" 组织目的: {c.organization_purpose[:100]}")
if c.id in org_members_map:
members = org_members_map[c.id]
if members:
member_parts = [f"{name}{m.position}" for m, name in members[:5]]
info_lines.append(f" 组织成员: {''.join(member_parts)}")
characters_info_parts.append("\n".join(info_lines))
characters_result_str = "\n\n".join(characters_info_parts)
logger.info(f" ✅ [1-N完整版] 构建了 {len(characters_info_parts)} 个角色信息,总长度: {len(characters_result_str)} 字符")
# === 构建独立职业详情 ===
careers_info_parts = []
if careers_map:
for career_id, career in careers_map.items():
career_lines = [f"{career.name} ({career.type}职业)"]
if career.description:
career_lines.append(f" 描述: {career.description}")
if career.category:
career_lines.append(f" 分类: {career.category}")
try:
stages = json.loads(career.stages) if isinstance(career.stages, str) else career.stages
if stages:
career_lines.append(f" 阶段体系: (共{career.max_stage}阶)")
for stage in stages:
level = stage.get('level', '?')
name = stage.get('name', '未命名')
desc = stage.get('description', '')
career_lines.append(f" {level}阶-{name}: {desc}")
except (json.JSONDecodeError, AttributeError, TypeError):
career_lines.append(f" 阶段体系: 共{career.max_stage}")
if career.special_abilities:
career_lines.append(f" 特殊能力: {career.special_abilities}")
careers_info_parts.append("\n".join(career_lines))
careers_result_str = None
if careers_info_parts:
careers_result_str = "\n\n".join(careers_info_parts)
logger.info(f" ✅ [1-N完整版] 构建了 {len(careers_map)} 个职业详情,总长度: {len(careers_result_str)} 字符")
return characters_result_str, careers_result_str
async def _build_recent_chapters_context(
self,
chapter: Chapter,
project_id: str,
db: AsyncSession
) -> Optional[str]:
"""构建最近10章的expansion_plan摘要"""
try:
result = await db.execute(
select(Chapter.chapter_number, Chapter.title, Chapter.expansion_plan, Chapter.summary)
.where(Chapter.project_id == project_id)
.where(Chapter.chapter_number < chapter.chapter_number)
.order_by(Chapter.chapter_number.desc())
.limit(self.RECENT_CHAPTERS_COUNT)
)
recent_chapters = result.all()
if not recent_chapters:
return None
# 按章节号正序排列
recent_chapters = sorted(recent_chapters, key=lambda x: x[0])
lines = ["【最近章节规划】"]
for ch_num, ch_title, expansion_plan, summary in recent_chapters:
if expansion_plan:
try:
plan = json.loads(expansion_plan)
plot_summary = plan.get('plot_summary', '')
key_events = plan.get('key_events', [])
events_str = ''.join(key_events[:3]) if key_events else ''
line = f"{ch_num}章《{ch_title}》:{plot_summary}"
if events_str:
line += f"(关键事件:{events_str}"
lines.append(line)
except json.JSONDecodeError:
if summary:
lines.append(f"{ch_num}章《{ch_title}》:{summary[:100]}")
elif summary:
lines.append(f"{ch_num}章《{ch_title}》:{summary[:100]}")
if len(lines) <= 1:
return None
return "\n".join(lines)
except Exception as e:
logger.error(f"❌ 构建最近章节上下文失败: {str(e)}")
return None
async def _get_relevant_memories_enhanced(
self,
user_id: str,
project_id: str,
chapter_number: int,
chapter_outline: str,
db: AsyncSession
) -> Optional[str]:
"""获取相关记忆(始终启用,相关度>0.6)"""
if not self.memory_service:
return None
try:
query_text = chapter_outline[:500].replace('\n', ' ')
relevant_memories = await self.memory_service.search_memories(
user_id=user_id,
project_id=project_id,
query=query_text,
limit=15,
min_importance=0.0
)
# 过滤相关度>0.6
filtered_memories = [
mem for mem in relevant_memories
if mem.get('similarity', 0) > self.MEMORY_SIMILARITY_THRESHOLD
]
if not filtered_memories:
return None
memory_lines = ["【相关记忆】"]
for mem in filtered_memories[:self.MEMORY_COUNT]:
similarity = mem.get('similarity', 0)
content = mem.get('content', '')[:100]
memory_lines.append(f"- (相关度:{similarity:.2f}) {content}")
return "\n".join(memory_lines) if len(memory_lines) > 1 else None
except Exception as e:
logger.error(f"❌ 获取相关记忆失败: {str(e)}")
return None
async def _get_last_ending_enhanced(
self,
@@ -379,11 +686,13 @@ class OneToManyContextBuilder:
if chapter.chapter_number <= 1:
return result_info
# 查询上一章
# 查询上一章:不假设序号连续,取 chapter_number < 当前章 中最大的
result = await db.execute(
select(Chapter)
.where(Chapter.project_id == chapter.project_id)
.where(Chapter.chapter_number == chapter.chapter_number - 1)
.where(Chapter.chapter_number < chapter.chapter_number)
.order_by(Chapter.chapter_number.desc())
.limit(1)
)
prev_chapter = result.scalar_one_or_none()
@@ -475,7 +784,12 @@ class OneToManyContextBuilder:
chapter_outline: str,
limit: int = 3
) -> Optional[str]:
"""获取与本章最相关的记忆"""
"""
获取与本章最相关的记忆
注意:伏笔相关信息统一由 _get_foreshadow_reminders() 通过 foreshadow_service 提供,
此方法只负责获取故事记忆,不再从旧的 memory_service 获取伏笔信息。
"""
if not self.memory_service:
return None
@@ -488,80 +802,33 @@ class OneToManyContextBuilder:
min_importance=self.MEMORY_IMPORTANCE_THRESHOLD
)
foreshadows = await self._get_due_foreshadows(
user_id, project_id, chapter_number,
lookahead=5
)
return self._format_memories(relevant, foreshadows, max_length=500)
return self._format_memories(relevant, max_length=500)
except Exception as e:
logger.error(f"❌ 获取相关记忆失败: {str(e)}")
return None
async def _get_due_foreshadows(
self,
user_id: str,
project_id: str,
chapter_number: int,
lookahead: int = 5
) -> List[Dict[str, Any]]:
"""获取即将需要回收的伏笔"""
if not self.memory_service:
return []
try:
foreshadows = await self.memory_service.find_unresolved_foreshadows(
user_id, project_id, chapter_number
)
due_foreshadows = []
for fs in foreshadows:
meta = fs.get('metadata', {})
fs_chapter = meta.get('chapter_number', 0)
if chapter_number - fs_chapter >= lookahead:
due_foreshadows.append({
'chapter': fs_chapter,
'content': fs.get('content', '')[:60],
'importance': meta.get('importance', 0.5)
})
return due_foreshadows[:2]
except Exception as e:
logger.error(f"❌ 获取待回收伏笔失败: {str(e)}")
return []
def _format_memories(
self,
relevant: List[Dict[str, Any]],
foreshadows: List[Dict[str, Any]],
max_length: int = 500
) -> str:
"""格式化记忆为简洁文本"""
lines = []
"""格式化记忆为简洁文本(纯记忆,不含伏笔)"""
if not relevant:
return None
lines = ["【相关记忆】"]
current_length = 0
if foreshadows:
lines.append("【待回收伏笔】")
for fs in foreshadows[:2]:
text = f"- 第{fs['chapter']}章埋下:{fs['content']}"
if current_length + len(text) > max_length:
break
lines.append(text)
current_length += len(text)
for mem in relevant:
content = mem.get('content', '')[:80]
text = f"- {content}"
if current_length + len(text) > max_length:
break
lines.append(text)
current_length += len(text)
if relevant and current_length < max_length:
lines.append("【相关记忆】")
for mem in relevant:
content = mem.get('content', '')[:80]
text = f"- {content}"
if current_length + len(text) > max_length:
break
lines.append(text)
current_length += len(text)
return "\n".join(lines) if lines else None
return "\n".join(lines) if len(lines) > 1 else None
async def _get_foreshadow_reminders(
self,
@@ -764,12 +1031,15 @@ class OneToOneContextBuilder:
logger.info(f" ✅ P0-大纲信息: {len(context.chapter_outline)}字符")
# === P1-重要信息 ===
# 1. 获取上一章内容的最后500字
# 1. 获取上一章内容的最后500字和上一章摘要
if chapter_number > 1:
# 查找前一章:不假设序号连续,取 chapter_number < 当前章 中最大的
prev_chapter_result = await db.execute(
select(Chapter)
.where(Chapter.project_id == chapter.project_id)
.where(Chapter.chapter_number == chapter_number - 1)
.where(Chapter.chapter_number < chapter_number)
.order_by(Chapter.chapter_number.desc())
.limit(1)
)
prev_chapter = prev_chapter_result.scalar_one_or_none()
@@ -780,11 +1050,33 @@ class OneToOneContextBuilder:
else:
context.continuation_point = content[-500:]
logger.info(f" ✅ P1-上一章内容(最后500字): {len(context.continuation_point)}字符")
# 获取上一章摘要(优先从记忆系统获取,其次使用章节摘要)
summary_result = await db.execute(
select(StoryMemory.content)
.where(StoryMemory.project_id == chapter.project_id)
.where(StoryMemory.chapter_id == prev_chapter.id)
.where(StoryMemory.memory_type == 'chapter_summary')
.limit(1)
)
summary_mem = summary_result.scalar_one_or_none()
if summary_mem:
context.previous_chapter_summary = summary_mem[:300]
logger.info(f" ✅ P1-上一章摘要(记忆): {len(context.previous_chapter_summary)}字符")
elif prev_chapter.summary:
context.previous_chapter_summary = prev_chapter.summary[:300]
logger.info(f" ✅ P1-上一章摘要(章节): {len(context.previous_chapter_summary)}字符")
else:
context.previous_chapter_summary = None
logger.info(f" ⚠️ P1-上一章摘要: 无")
else:
context.continuation_point = None
context.previous_chapter_summary = None
logger.info(f" ⚠️ P1-上一章内容: 无")
else:
context.continuation_point = None
context.previous_chapter_summary = None
logger.info(f" ✅ P1-第1章无需上一章内容")
# 2. 根据structure中的characters获取角色信息(含职业)
@@ -792,7 +1084,12 @@ class OneToOneContextBuilder:
if outline and outline.structure:
try:
structure = json.loads(outline.structure)
character_names = structure.get('characters', [])
raw_characters = structure.get('characters', [])
# characters可能是字符串列表或字典列表,统一提取为名称字符串列表
character_names = [
c['name'] if isinstance(c, dict) else c
for c in raw_characters
]
logger.info(f" 📋 从structure提取角色: {character_names}")
except json.JSONDecodeError:
pass
@@ -853,7 +1150,7 @@ class OneToOneContextBuilder:
min_importance=0.0
)
# 降低相关度阈值0.4,提高召回率
# 过滤相关度阈值0.6
filtered_memories = [
mem for mem in relevant_memories
if mem.get('similarity', 0) > 0.6
@@ -867,7 +1164,7 @@ class OneToOneContextBuilder:
memory_lines.append(f"- (相关度:{similarity:.2f}) {content}")
context.relevant_memories = "\n".join(memory_lines)
logger.info(f" ✅ P2-相关记忆: {len(filtered_memories)}条 (相关度>0.4, 共搜索{len(relevant_memories)}条)")
logger.info(f" ✅ P2-相关记忆: {len(filtered_memories)}条 (相关度>0.6, 共搜索{len(relevant_memories)}条)")
else:
context.relevant_memories = None
logger.info(f" ⚠️ P2-相关记忆: 无符合条件的记忆 (共搜索到{len(relevant_memories)}条)")
@@ -885,6 +1182,7 @@ class OneToOneContextBuilder:
"chapter_number": chapter_number,
"has_previous_content": context.continuation_point is not None,
"previous_content_length": len(context.continuation_point or ""),
"previous_summary_length": len(context.previous_chapter_summary or ""),
"outline_length": len(context.chapter_outline),
"characters_length": len(context.chapter_characters),
"careers_length": len(context.chapter_careers or ""),
@@ -1093,14 +1391,61 @@ class OneToOneContextBuilder:
# 副职业也只显示引用
info_lines.append(f" - {career.name} ({cc.current_stage}/{career.max_stage}阶 - {stage_name})")
# === 角色关系信息 ===
if not c.is_organization:
from sqlalchemy import or_
rels_result = await db.execute(
select(CharacterRelationship).where(
CharacterRelationship.project_id == project_id,
or_(
CharacterRelationship.character_from_id == c.id,
CharacterRelationship.character_to_id == c.id
)
)
)
rels = rels_result.scalars().all()
if rels:
related_ids = set()
for r in rels:
related_ids.add(r.character_from_id)
related_ids.add(r.character_to_id)
related_ids.discard(c.id)
if related_ids:
names_result = await db.execute(
select(Character.id, Character.name).where(Character.id.in_(related_ids))
)
name_map = {row.id: row.name for row in names_result}
rel_parts = []
for r in rels:
if r.character_from_id == c.id:
target_name = name_map.get(r.character_to_id, "未知")
else:
target_name = name_map.get(r.character_from_id, "未知")
rel_name = r.relationship_name or "相关"
rel_parts.append(f"{target_name}{rel_name}")
info_lines.append(f" 关系网络: {''.join(rel_parts)}")
# === 组织特有信息 ===
if c.is_organization:
if c.organization_type:
info_lines.append(f" 组织类型: {c.organization_type}")
if c.organization_purpose:
info_lines.append(f" 组织目的: {c.organization_purpose[:100]}")
if c.organization_members:
info_lines.append(f" 组织成员: {c.organization_members[:100]}")
# 从 OrganizationMember 表动态查询组织成员
org_result = await db.execute(
select(Organization).where(Organization.character_id == c.id)
)
org = org_result.scalar_one_or_none()
if org:
members_result = await db.execute(
select(OrganizationMember, Character.name).join(
Character, OrganizationMember.character_id == Character.id
).where(OrganizationMember.organization_id == org.id)
)
members = members_result.all()
if members:
member_parts = [f"{name}{m.position}" for m, name in members]
info_lines.append(f" 组织成员: {''.join(member_parts)[:100]}")
# 组合完整信息
full_info = "\n".join(info_lines)