update:1.新增统一的JSON清洗和重试方法,避免AI响应json格式错误 2.重构提示词模板命名,优化大纲章节初始化提示词 3.移除布冯冗余代码,提高代码复用性 4.优化系统默认写作风格预设提示词和规则

This commit is contained in:
xiamuceer
2025-12-14 15:21:52 +08:00
parent 86b73e85fb
commit 24b0a09b43
11 changed files with 633 additions and 1851 deletions
+5 -416
View File
@@ -352,412 +352,6 @@ async def create_character(
raise HTTPException(status_code=500, detail=f"创建角色失败: {str(e)}")
@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色")
async def generate_character(
request: CharacterGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成角色卡
根据用户输入的信息,结合项目的世界观、主题等背景,
AI会生成一个完整、详细的角色设定卡片。
生成内容包括:姓名、年龄、性别、性格、外貌、背景故事、人际关系等
"""
# 验证用户权限和项目是否存在
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(request.project_id, user_id, db)
try:
# 获取已存在的角色列表,用于关系网络
existing_chars_result = await db.execute(
select(Character)
.where(Character.project_id == request.project_id)
.order_by(Character.created_at.desc())
)
existing_characters = existing_chars_result.scalars().all()
# 构建现有角色信息摘要(包含组织)
existing_chars_info = ""
character_list = []
organization_list = []
if existing_characters:
for c in existing_characters[:10]: # 最多显示10个
if c.is_organization:
organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]")
else:
character_list.append(f"- {c.name}{c.role_type or '未知'}")
if character_list:
existing_chars_info += "\n已有角色:\n" + "\n".join(character_list)
if organization_list:
existing_chars_info += "\n\n已有组织:\n" + "\n".join(organization_list)
# 构建项目上下文信息
project_context = f"""
项目信息:
- 书名:{project.title}
- 主题:{project.theme or '未设定'}
- 类型:{project.genre or '未设定'}
- 时间背景:{project.world_time_period or '未设定'}
- 地理位置:{project.world_location or '未设定'}
- 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'}
{existing_chars_info}
"""
# 构建用户输入信息
user_input = f"""
用户要求:
- 角色名称:{request.name or '请AI生成'}
- 角色定位:{request.role_type or 'supporting'}protagonist=主角, supporting=配角, antagonist=反派)
- 背景设定:{request.background or '无特殊要求'}
- 其他要求:{request.requirements or ''}
"""
# 获取自定义提示词模板
template = await PromptService.get_template("SINGLE_CHARACTER_GENERATION", user_id, db)
# 格式化提示词
prompt = PromptService.format_prompt(
template,
project_context=project_context,
user_input=user_input
)
# 调用AI生成角色(支持MCP工具)
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色(启用MCP")
logger.info(f" - 角色名:{request.name or 'AI生成'}")
logger.info(f" - 角色定位:{request.role_type}")
logger.info(f" - 背景设定:{request.background or ''}")
logger.info(f" - AI提供商:{user_ai_service.api_provider}")
logger.info(f" - AI模型:{user_ai_service.default_model}")
logger.info(f" - Prompt长度:{len(prompt)} 字符")
logger.info(f" - 用户ID{user_id}")
try:
# 🔧 MCP工具增强:静默检查并收集参考资料
mcp_enhanced_prompt = prompt
if user_id:
try:
from app.services.mcp_tool_service import mcp_tool_service
available_tools = await mcp_tool_service.get_user_enabled_tools(
user_id=user_id,
db_session=db
)
# 只在有工具时才调用
if available_tools:
logger.info(f"🔍 检测到可用MCP工具,尝试收集参考资料...")
result = await user_ai_service.generate_text_with_mcp(
prompt=prompt,
user_id=user_id,
db_session=db,
enable_mcp=True,
max_tool_rounds=1, # 减少为1轮,避免超时
tool_choice="auto",
provider=None,
model=None
)
# 提取内容
if isinstance(result, dict):
ai_response = result.get('content', '')
logger.info(f"✅ AI响应接收完成(MCP增强),长度:{len(ai_response)} 字符")
if result.get('tool_calls_made', 0) > 0:
logger.info(f" - MCP工具调用:{result['tool_calls_made']}")
else:
ai_response = result
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
else:
logger.debug(f"用户 {user_id} 未启用MCP工具,使用基础模式")
# 不使用MCP,直接生成
result = await user_ai_service.generate_text(prompt=prompt)
ai_response = result.get('content', '') if isinstance(result, dict) else result
except Exception as mcp_error:
logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(mcp_error)}")
# 降级:不使用MCP
result = await user_ai_service.generate_text(prompt=prompt)
ai_response = result.get('content', '') if isinstance(result, dict) else result
else:
# 无用户ID,直接使用基础模式
result = await user_ai_service.generate_text(prompt=prompt)
ai_response = result.get('content', '') if isinstance(result, dict) else result
except Exception as ai_error:
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
raise HTTPException(
status_code=500,
detail=f"AI服务调用失败:{str(ai_error)}"
)
# 检查AI响应
if not ai_response or not ai_response.strip():
logger.error("❌ AI返回了空响应")
raise HTTPException(
status_code=500,
detail="AI服务返回空响应。可能原因:1) API配置错误 2) 模型不支持 3) 网络问题。请检查后端日志。"
)
logger.info(f"📝 开始清理AI响应")
# 清理AI响应,移除可能的markdown标记
cleaned_response = ai_response.strip()
original_length = len(cleaned_response)
if cleaned_response.startswith("```json"):
cleaned_response = cleaned_response[7:]
logger.info(" - 移除了 ```json 标记")
if cleaned_response.startswith("```"):
cleaned_response = cleaned_response[3:]
logger.info(" - 移除了 ``` 标记")
if cleaned_response.endswith("```"):
cleaned_response = cleaned_response[:-3]
logger.info(" - 移除了末尾 ``` 标记")
cleaned_response = cleaned_response.strip()
logger.info(f" - 清理前长度:{original_length},清理后长度:{len(cleaned_response)}")
logger.info(f" - 清理后内容预览(前300字符):{cleaned_response[:300]}")
# 解析AI响应
logger.info(f"🔍 开始解析JSON")
try:
character_data = json.loads(cleaned_response)
logger.info(f"✅ JSON解析成功")
logger.info(f" - 解析后的字段:{list(character_data.keys())}")
except json.JSONDecodeError as e:
logger.error(f"❌ JSON解析失败")
logger.error(f" - 错误位置:line {e.lineno}, column {e.colno}")
logger.error(f" - 错误信息:{str(e)}")
logger.error(f" - 完整响应内容(前1000字符):{cleaned_response[:1000]}")
raise HTTPException(
status_code=500,
detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}。响应内容已记录到日志,请查看后端日志排查。"
)
# 转换traits为JSON字符串
traits_json = json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None
# 判断是否为组织
is_organization = character_data.get("is_organization", False)
# 创建角色
character = Character(
project_id=request.project_id,
name=character_data.get("name", request.name or "未命名角色"),
age=str(character_data.get("age", "")),
gender=character_data.get("gender"),
is_organization=is_organization,
role_type=request.role_type or "supporting",
personality=character_data.get("personality", ""),
background=character_data.get("background", ""),
appearance=character_data.get("appearance", ""),
relationships=character_data.get("relationships_text", character_data.get("relationships", "")), # 优先使用文本描述
organization_type=character_data.get("organization_type") if is_organization else None,
organization_purpose=character_data.get("organization_purpose") if is_organization else None,
organization_members=json.dumps(character_data.get("organization_members", []), ensure_ascii=False) if is_organization else None,
traits=traits_json
)
db.add(character)
await db.flush() # 获取character.id
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id}, 是否组织: {is_organization})")
# 如果是组织,自动创建Organization详情记录
if is_organization:
org_check = await db.execute(
select(Organization).where(Organization.character_id == character.id)
)
existing_org = org_check.scalar_one_or_none()
if not existing_org:
organization = Organization(
character_id=character.id,
project_id=request.project_id,
member_count=0,
power_level=character_data.get("power_level", 50),
location=character_data.get("location"),
motto=character_data.get("motto"),
color=character_data.get("color")
)
db.add(organization)
await db.flush()
logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {organization.id})")
else:
logger.info(f"️ 组织详情已存在:{character.name}")
# 处理结构化关系数据(仅针对非组织角色)
if not is_organization:
relationships_data = character_data.get("relationships", [])
if relationships_data and isinstance(relationships_data, list):
logger.info(f"📊 开始处理 {len(relationships_data)} 条关系数据")
created_rels = 0
for rel in relationships_data:
try:
target_name = rel.get("target_character_name")
if not target_name:
logger.debug(f" ⚠️ 关系缺少target_character_name,跳过")
continue
target_result = await db.execute(
select(Character).where(
Character.project_id == request.project_id,
Character.name == target_name
)
)
target_char = target_result.scalar_one_or_none()
if target_char:
# 检查是否已存在相同关系
existing_rel = await db.execute(
select(CharacterRelationship).where(
CharacterRelationship.project_id == request.project_id,
CharacterRelationship.character_from_id == character.id,
CharacterRelationship.character_to_id == target_char.id
)
)
if existing_rel.scalar_one_or_none():
logger.debug(f" ️ 关系已存在:{character.name} -> {target_name}")
continue
relationship = CharacterRelationship(
project_id=request.project_id,
character_from_id=character.id,
character_to_id=target_char.id,
relationship_name=rel.get("relationship_type", "未知关系"),
intimacy_level=rel.get("intimacy_level", 50),
description=rel.get("description", ""),
started_at=rel.get("started_at"),
source="ai"
)
# 匹配预定义关系类型
rel_type_result = await db.execute(
select(RelationshipType).where(
RelationshipType.name == rel.get("relationship_type")
)
)
rel_type = rel_type_result.scalar_one_or_none()
if rel_type:
relationship.relationship_type_id = rel_type.id
db.add(relationship)
created_rels += 1
logger.info(f" ✅ 创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})")
else:
logger.warning(f" ⚠️ 目标角色不存在:{target_name}")
except Exception as rel_error:
logger.warning(f" ❌ 创建关系失败:{str(rel_error)}")
continue
logger.info(f"✅ 成功创建 {created_rels} 条关系记录")
# 处理组织成员关系(仅针对非组织角色)
if not is_organization:
org_memberships = character_data.get("organization_memberships", [])
if org_memberships and isinstance(org_memberships, list):
logger.info(f"🏢 开始处理 {len(org_memberships)} 条组织成员关系")
created_members = 0
for membership in org_memberships:
try:
org_name = membership.get("organization_name")
if not org_name:
logger.debug(f" ⚠️ 组织成员关系缺少organization_name,跳过")
continue
org_char_result = await db.execute(
select(Character).where(
Character.project_id == request.project_id,
Character.name == org_name,
Character.is_organization == True
)
)
org_char = org_char_result.scalar_one_or_none()
if org_char:
# 获取或创建Organization记录
org_result = await db.execute(
select(Organization).where(Organization.character_id == org_char.id)
)
org = org_result.scalar_one_or_none()
if not org:
# 如果组织Character存在但Organization不存在,自动创建
org = Organization(
character_id=org_char.id,
project_id=request.project_id,
member_count=0
)
db.add(org)
await db.flush()
logger.info(f" ️ 自动创建缺失的组织详情:{org_name}")
# 检查是否已存在成员关系
existing_member = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id,
OrganizationMember.character_id == character.id
)
)
if existing_member.scalar_one_or_none():
logger.debug(f" ️ 成员关系已存在:{character.name} -> {org_name}")
continue
# 创建成员关系
member = OrganizationMember(
organization_id=org.id,
character_id=character.id,
position=membership.get("position", "成员"),
rank=membership.get("rank", 0),
loyalty=membership.get("loyalty", 50),
joined_at=membership.get("joined_at"),
status=membership.get("status", "active"),
source="ai"
)
db.add(member)
# 更新组织成员计数
org.member_count += 1
created_members += 1
logger.info(f" ✅ 添加成员:{character.name} -> {org_name} ({membership.get('position')})")
else:
logger.warning(f" ⚠️ 组织不存在:{org_name}")
except Exception as org_error:
logger.warning(f" ❌ 添加组织成员失败:{str(org_error)}")
continue
logger.info(f"✅ 成功创建 {created_members} 条组织成员记录")
# 记录生成历史
history = GenerationHistory(
project_id=request.project_id,
prompt=prompt,
generated_content=json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else ai_response,
model=user_ai_service.default_model
)
db.add(history)
await db.commit()
await db.refresh(character)
logger.info(f"🎉 成功为项目 {request.project_id} 生成角色: {character.name}")
return character
except Exception as e:
logger.error(f"生成角色失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成角色失败: {str(e)}")
@router.post("/generate-stream", summary="AI生成角色(流式)")
async def generate_character_stream(
request: CharacterGenerateRequest,
@@ -894,19 +488,14 @@ async def generate_character_stream(
yield await SSEResponse.send_progress("解析AI响应...", 60)
# 清理AI响应
cleaned_response = ai_response.strip()
if cleaned_response.startswith("```json"):
cleaned_response = cleaned_response[7:]
if cleaned_response.startswith("```"):
cleaned_response = cleaned_response[3:]
if cleaned_response.endswith("```"):
cleaned_response = cleaned_response[:-3]
cleaned_response = cleaned_response.strip()
# ✅ 使用统一的 JSON 清洗方法
try:
cleaned_response = user_ai_service._clean_json_response(ai_response)
character_data = json.loads(cleaned_response)
logger.info(f"✅ 角色JSON解析成功")
except json.JSONDecodeError as e:
logger.error(f"❌ 角色JSON解析失败: {e}")
logger.error(f" 原始响应预览: {ai_response[:200]}")
yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON{str(e)}")
return
+6 -28
View File
@@ -162,26 +162,10 @@ async def generate_options(
content = response.get("content", "")
logger.info(f"AI返回内容长度: {len(content)}")
# 解析JSON
# 解析JSON(使用统一的JSON清洗方法)
try:
# 清理可能的markdown标记
cleaned_content = content.strip()
if cleaned_content.startswith('```json'):
cleaned_content = cleaned_content[7:].lstrip('\n\r')
elif cleaned_content.startswith('```'):
cleaned_content = cleaned_content[3:].lstrip('\n\r')
if cleaned_content.endswith('```'):
cleaned_content = cleaned_content[:-3].rstrip('\n\r')
cleaned_content = cleaned_content.strip()
# 检查JSON是否完整
if not cleaned_content.endswith('}'):
logger.warning(f"⚠️ JSON可能被截断,尝试补全...")
if '"options"' in cleaned_content:
if cleaned_content.count('[') > cleaned_content.count(']'):
cleaned_content += '"]}'
elif cleaned_content.count('{') > cleaned_content.count('}'):
cleaned_content += '}'
# 使用统一的JSON清洗方法
cleaned_content = ai_service._clean_json_response(content)
result = json.loads(cleaned_content)
@@ -305,16 +289,10 @@ async def quick_generate(
content = response.get("content", "")
# 解析JSON
# 解析JSON(使用统一的JSON清洗方法)
try:
cleaned_content = content.strip()
if cleaned_content.startswith('```json'):
cleaned_content = cleaned_content[7:].lstrip('\n\r')
elif cleaned_content.startswith('```'):
cleaned_content = cleaned_content[3:].lstrip('\n\r')
if cleaned_content.endswith('```'):
cleaned_content = cleaned_content[:-3].rstrip('\n\r')
cleaned_content = cleaned_content.strip()
# 使用统一的JSON清洗方法
cleaned_content = ai_service._clean_json_response(content)
result = json.loads(cleaned_content)
+5 -203
View File
@@ -429,199 +429,6 @@ async def remove_organization_member(
logger.info(f"移除成员成功:{member_id}")
return {"message": "成员移除成功", "id": member_id}
@router.post("/generate", response_model=CharacterResponse, summary="AI生成组织")
async def generate_organization(
gen_request: OrganizationGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成组织设定
根据用户输入的信息,结合项目的世界观、主题等背景,
AI会生成一个完整、详细的组织设定。
生成内容包括:组织名称、类型、特性、背景、目的、势力等级等
"""
# 验证用户权限
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(gen_request.project_id, user_id, db)
try:
# 获取已存在的角色和组织列表
existing_chars_result = await db.execute(
select(Character)
.where(Character.project_id == gen_request.project_id)
.order_by(Character.created_at.desc())
)
existing_characters = existing_chars_result.scalars().all()
# 构建现有角色和组织信息摘要
existing_info = ""
character_list = []
organization_list = []
if existing_characters:
for c in existing_characters[:10]: # 最多显示10个
if c.is_organization:
organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]")
else:
character_list.append(f"- {c.name}{c.role_type or '未知'}")
if character_list:
existing_info += "\n已有角色:\n" + "\n".join(character_list)
if organization_list:
existing_info += "\n\n已有组织:\n" + "\n".join(organization_list)
# 构建项目上下文信息
project_context = f"""
项目信息:
- 书名:{project.title}
- 主题:{project.theme or '未设定'}
- 类型:{project.genre or '未设定'}
- 时间背景:{project.world_time_period or '未设定'}
- 地理位置:{project.world_location or '未设定'}
- 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'}
{existing_info}
"""
# 构建用户输入信息
user_input = f"""
用户要求:
- 组织名称:{gen_request.name or '请AI生成'}
- 组织类型:{gen_request.organization_type or '请AI根据世界观决定'}
- 背景设定:{gen_request.background or '无特殊要求'}
- 其他要求:{gen_request.requirements or ''}
"""
# 获取自定义提示词模板
template = await PromptService.get_template("SINGLE_ORGANIZATION_GENERATION", user_id, db)
# 格式化提示词
prompt = PromptService.format_prompt(
template,
project_context=project_context,
user_input=user_input
)
# 调用AI生成组织
logger.info(f"🎯 开始为项目 {gen_request.project_id} 生成组织")
logger.info(f" - 组织名:{gen_request.name or 'AI生成'}")
logger.info(f" - 组织类型:{gen_request.organization_type or 'AI决定'}")
logger.info(f" - 背景设定:{gen_request.background or ''}")
logger.info(f" - AI提供商:{user_ai_service.api_provider}")
logger.info(f" - AI模型:{user_ai_service.default_model}")
logger.info(f" - Prompt长度:{len(prompt)} 字符")
try:
ai_response = await user_ai_service.generate_text(prompt=prompt)
logger.info(f"✅ AI响应接收完成")
except Exception as ai_error:
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
raise HTTPException(
status_code=500,
detail=f"AI服务调用失败:{str(ai_error)}"
)
# generate_text返回的是字典,需要提取content字段
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else str(ai_response)
# 检查AI响应
if not ai_content or not ai_content.strip():
logger.error("❌ AI返回了空响应")
raise HTTPException(
status_code=500,
detail="AI服务返回空响应。请检查AI配置和网络连接。"
)
logger.info(f"📝 开始清理AI响应,长度:{len(ai_content)} 字符")
# 清理AI响应
cleaned_response = ai_content.strip()
if cleaned_response.startswith("```json"):
cleaned_response = cleaned_response[7:]
if cleaned_response.startswith("```"):
cleaned_response = cleaned_response[3:]
if cleaned_response.endswith("```"):
cleaned_response = cleaned_response[:-3]
cleaned_response = cleaned_response.strip()
logger.info(f" - 清理后长度:{len(cleaned_response)}")
# 解析AI响应
logger.info(f"🔍 开始解析JSON")
try:
organization_data = json.loads(cleaned_response)
logger.info(f"✅ JSON解析成功")
logger.info(f" - 解析后的字段:{list(organization_data.keys())}")
except json.JSONDecodeError as e:
logger.error(f"❌ JSON解析失败:{str(e)}")
raise HTTPException(
status_code=500,
detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}"
)
# 创建角色记录(组织也是角色的一种)
character = Character(
project_id=gen_request.project_id,
name=organization_data.get("name", gen_request.name or "未命名组织"),
is_organization=True,
role_type="supporting", # 组织通常作为配角
personality=organization_data.get("personality", ""),
background=organization_data.get("background", ""),
appearance=organization_data.get("appearance", ""),
organization_type=organization_data.get("organization_type"),
organization_purpose=organization_data.get("organization_purpose"),
organization_members=json.dumps(
organization_data.get("organization_members", []),
ensure_ascii=False
),
traits=json.dumps(
organization_data.get("traits", []),
ensure_ascii=False
)
)
db.add(character)
await db.flush()
logger.info(f"✅ 组织角色创建成功:{character.name} (ID: {character.id})")
# 自动创建Organization详情记录
organization = Organization(
character_id=character.id,
project_id=gen_request.project_id,
member_count=0,
power_level=organization_data.get("power_level", 50),
location=organization_data.get("location"),
motto=organization_data.get("motto"),
color=organization_data.get("color")
)
db.add(organization)
await db.flush()
logger.info(f"✅ 组织详情创建成功:{character.name} (Org ID: {organization.id})")
# 记录生成历史
history = GenerationHistory(
project_id=gen_request.project_id,
prompt=prompt,
generated_content=ai_content,
model=user_ai_service.default_model
)
db.add(history)
await db.commit()
await db.refresh(character)
logger.info(f"🎉 成功为项目 {gen_request.project_id} 生成组织: {character.name}")
return character
except Exception as e:
logger.error(f"生成组织失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成组织失败: {str(e)}")
@router.post("/generate-stream", summary="AI生成组织(流式)")
async def generate_organization_stream(
gen_request: OrganizationGenerateRequest,
@@ -718,19 +525,14 @@ async def generate_organization_stream(
yield await SSEResponse.send_progress("解析AI响应...", 60)
# 清理AI响应
cleaned_response = ai_content.strip()
if cleaned_response.startswith("```json"):
cleaned_response = cleaned_response[7:]
if cleaned_response.startswith("```"):
cleaned_response = cleaned_response[3:]
if cleaned_response.endswith("```"):
cleaned_response = cleaned_response[:-3]
cleaned_response = cleaned_response.strip()
# ✅ 使用统一的 JSON 清洗方法
try:
cleaned_response = user_ai_service._clean_json_response(ai_content)
organization_data = json.loads(cleaned_response)
logger.info(f"✅ 组织JSON解析成功")
except json.JSONDecodeError as e:
logger.error(f"❌ 组织JSON解析失败: {e}")
logger.error(f" 原始响应预览: {ai_content[:200]}")
yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON{str(e)}")
return
+18 -394
View File
@@ -463,72 +463,6 @@ async def predict_characters(
logger.error(f"角色预测失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"角色预测失败: {str(e)}")
@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲")
async def generate_outline(
request: OutlineGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成或续写小说大纲 - 智能模式
支持三种模式:
- auto: 自动判断(无大纲→新建,有大纲→续写)
- new: 强制全新生成
- continue: 强制续写模式
"""
# 验证用户权限
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(request.project_id, user_id, db)
try:
# 获取现有大纲(强制从数据库获取最新数据,包括用户手动修改的内容)
existing_result = await db.execute(
select(Outline)
.where(Outline.project_id == request.project_id)
.order_by(Outline.order_index)
.execution_options(populate_existing=True)
)
existing_outlines = existing_result.scalars().all()
# 判断实际执行模式
actual_mode = request.mode
if actual_mode == "auto":
actual_mode = "continue" if existing_outlines else "new"
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
# 模式:全新生成
if actual_mode == "new":
return await _generate_new_outline(
request, project, db, user_ai_service, user_id
)
# 模式:续写
elif actual_mode == "continue":
if not existing_outlines:
raise HTTPException(
status_code=400,
detail="续写模式需要已有大纲,当前项目没有大纲"
)
# 获取用户ID用于记忆检索
user_id = getattr(http_request.state, "user_id", "system")
return await _continue_outline(
request, project, existing_outlines, db, user_ai_service, user_id
)
else:
raise HTTPException(
status_code=400,
detail=f"不支持的模式: {request.mode}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"生成大纲失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"生成大纲失败: {str(e)}")
async def _generate_new_outline(
@@ -621,7 +555,7 @@ async def _generate_new_outline(
mcp_reference_materials = ""
# 使用完整提示词(插入MCP参考资料,支持自定义)
template = await PromptService.get_template("COMPLETE_OUTLINE_GENERATION", user_id, db)
template = await PromptService.get_template("OUTLINE_CREATE", user_id, db)
prompt = PromptService.format_prompt(
template,
title=project.title,
@@ -1085,7 +1019,7 @@ async def _continue_outline(
mcp_reference_materials = ""
# 使用标准续写提示词模板(支持记忆+MCP增强+自定义)
template = await PromptService.get_template("OUTLINE_CONTINUE_GENERATION", user_id, db)
template = await PromptService.get_template("OUTLINE_CONTINUE", user_id, db)
prompt = PromptService.format_prompt(
template,
title=project.title,
@@ -1163,17 +1097,12 @@ async def _continue_outline(
def _parse_ai_response(ai_response: str) -> list:
"""解析AI响应为章节数据列表"""
"""解析AI响应为章节数据列表(使用统一的JSON清洗方法)"""
try:
# 清理响应文本
cleaned_text = ai_response.strip()
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:]
if cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:]
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3]
cleaned_text = cleaned_text.strip()
# 使用统一的JSON清洗方法(从AIService导入)
from app.services.ai_service import AIService
ai_service_temp = AIService()
cleaned_text = ai_service_temp._clean_json_response(ai_response)
outline_data = json.loads(cleaned_text)
@@ -1185,16 +1114,24 @@ def _parse_ai_response(ai_response: str) -> list:
else:
outline_data = [outline_data]
logger.info(f"✅ 成功解析 {len(outline_data)} 个章节数据")
return outline_data
except json.JSONDecodeError as e:
logger.error(f"AI响应解析失败: {e}")
logger.error(f"AI响应解析失败: {e}")
# 返回一个包含原始内容的章节
return [{
"title": "AI生成的大纲",
"content": ai_response[:1000],
"summary": ai_response[:1000]
}]
except Exception as e:
logger.error(f"❌ 解析异常: {str(e)}")
return [{
"title": "解析异常的大纲",
"content": "系统错误",
"summary": "系统错误"
}]
async def _save_outlines(
@@ -1377,7 +1314,7 @@ async def new_outline_generator(
# 使用完整提示词(插入MCP参考资料,支持自定义)
yield await SSEResponse.send_progress("准备AI提示词...", 20)
template = await PromptService.get_template("COMPLETE_OUTLINE_GENERATION", user_id_for_mcp, db)
template = await PromptService.get_template("OUTLINE_CREATE", user_id_for_mcp, db)
prompt = PromptService.format_prompt(
template,
title=project.title,
@@ -1877,7 +1814,7 @@ async def continue_outline_generator(
)
# 使用标准续写提示词模板(支持记忆+MCP增强+自定义)
template = await PromptService.get_template("OUTLINE_CONTINUE_GENERATION", user_id, db)
template = await PromptService.get_template("OUTLINE_CONTINUE", user_id, db)
prompt = PromptService.format_prompt(
template,
title=project.title,
@@ -2322,142 +2259,6 @@ async def create_single_chapter_from_outline(
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}")
@router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章")
async def expand_outline_to_chapters(
outline_id: str,
expansion_request: OutlineExpansionRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
根据单个大纲摘要,通过AI分析生成多个章节规划
流程:
1. 获取大纲信息和上下文(前后大纲)
2. 调用AI分析大纲,生成多章节规划
3. 根据规划创建章节记录(outline_id关联到原大纲)
参数:
- outline_id: 要展开的大纲ID
- expansion_request: 展开配置(章节数量、展开策略等)
返回:
- 展开后的章节列表和规划详情
"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
# 获取大纲
result = await db.execute(
select(Outline).where(Outline.id == outline_id)
)
outline = result.scalar_one_or_none()
if not outline:
raise HTTPException(status_code=404, detail="大纲不存在")
# 验证项目权限并获取项目信息
project = await verify_project_access(outline.project_id, user_id, db)
# 验证项目模式
if project.outline_mode != 'one-to-many':
raise HTTPException(
status_code=400,
detail=f"当前项目为{project.outline_mode}模式,不支持展开功能。请使用一对一创建。"
)
try:
# 创建展开服务实例
expansion_service = PlotExpansionService(user_ai_service)
# 获取项目信息
project_result = await db.execute(
select(Project).where(Project.id == outline.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 分析大纲并生成章节规划
logger.info(f"开始展开大纲 {outline_id},目标章节数: {expansion_request.target_chapter_count}")
chapter_plans = await expansion_service.analyze_outline_for_chapters(
outline=outline,
project=project,
db=db,
target_chapter_count=expansion_request.target_chapter_count,
expansion_strategy=expansion_request.expansion_strategy,
enable_scene_analysis=expansion_request.enable_scene_analysis,
provider=expansion_request.provider,
model=expansion_request.model
)
if not chapter_plans:
raise HTTPException(status_code=500, detail="AI分析失败,未能生成章节规划")
logger.info(f"AI分析完成,生成了 {len(chapter_plans)} 个章节规划")
# 根据规划创建章节记录
if expansion_request.auto_create_chapters:
created_chapters = await expansion_service.create_chapters_from_plans(
outline_id=outline_id,
chapter_plans=chapter_plans,
project_id=outline.project_id,
db=db,
start_chapter_number=None # 自动计算章节序号
)
await db.commit()
# 刷新章节数据
for chapter in created_chapters:
await db.refresh(chapter)
logger.info(f"成功创建 {len(created_chapters)} 个章节记录")
# 构建响应
return OutlineExpansionResponse(
outline_id=outline_id,
outline_title=outline.title,
target_chapter_count=expansion_request.target_chapter_count,
actual_chapter_count=len(chapter_plans),
expansion_strategy=expansion_request.expansion_strategy,
chapter_plans=chapter_plans,
created_chapters=[
{
"id": ch.id,
"chapter_number": ch.chapter_number,
"title": ch.title,
"summary": ch.summary,
"outline_id": ch.outline_id,
"sub_index": ch.sub_index,
"status": ch.status
}
for ch in created_chapters
]
)
else:
# 仅返回章节规划,不创建记录
logger.info(f"仅生成规划,未创建章节记录")
return OutlineExpansionResponse(
outline_id=outline_id,
outline_title=outline.title,
target_chapter_count=expansion_request.target_chapter_count,
actual_chapter_count=len(chapter_plans),
expansion_strategy=expansion_request.expansion_strategy,
chapter_plans=chapter_plans,
created_chapters=None
)
except HTTPException:
raise
except Exception as e:
logger.error(f"大纲展开失败: {str(e)}", exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=f"大纲展开失败: {str(e)}")
@router.post("/{outline_id}/expand-stream", summary="展开单个大纲为多章(SSE流式)")
async def expand_outline_to_chapters_stream(
outline_id: str,
@@ -2585,183 +2386,6 @@ async def get_outline_chapters(
}
@router.post("/batch-expand", response_model=BatchOutlineExpansionResponse, summary="批量展开大纲为多章")
async def batch_expand_outlines(
batch_request: BatchOutlineExpansionRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
批量展开项目中的所有大纲或指定大纲列表
流程:
1. 获取项目中的所有大纲(或指定大纲列表)
2. 逐个分析大纲,生成多章节规划
3. 根据规划批量创建章节记录
参数:
- batch_request: 批量展开配置
返回:
- 所有展开的大纲和章节信息
"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(batch_request.project_id, user_id, db)
try:
# 创建展开服务实例
expansion_service = PlotExpansionService(user_ai_service)
# 获取项目信息
project_result = await db.execute(
select(Project).where(Project.id == batch_request.project_id)
)
project = project_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 获取要展开的大纲列表
if batch_request.outline_ids:
# 展开指定的大纲
outlines_result = await db.execute(
select(Outline)
.where(
Outline.project_id == batch_request.project_id,
Outline.id.in_(batch_request.outline_ids)
)
.order_by(Outline.order_index)
)
else:
# 展开所有大纲
outlines_result = await db.execute(
select(Outline)
.where(Outline.project_id == batch_request.project_id)
.order_by(Outline.order_index)
)
outlines = outlines_result.scalars().all()
if not outlines:
raise HTTPException(status_code=404, detail="没有找到要展开的大纲")
# 批量展开大纲
logger.info(f"开始批量展开 {len(outlines)} 个大纲")
expansion_results = []
total_chapters_created = 0
skipped_outlines = []
for outline in outlines:
try:
# 检查大纲是否已经展开过
existing_chapters_result = await db.execute(
select(Chapter)
.where(Chapter.outline_id == outline.id)
.limit(1)
)
existing_chapter = existing_chapters_result.scalar_one_or_none()
if existing_chapter:
logger.info(f"大纲 {outline.title} (ID: {outline.id}) 已经展开过,跳过")
skipped_outlines.append({
"outline_id": outline.id,
"outline_title": outline.title,
"reason": "已展开"
})
continue
# 分析大纲生成章节规划
chapter_plans = await expansion_service.analyze_outline_for_chapters(
outline=outline,
project=project,
db=db,
target_chapter_count=batch_request.chapters_per_outline,
expansion_strategy=batch_request.expansion_strategy,
enable_scene_analysis=batch_request.enable_scene_analysis,
provider=batch_request.provider,
model=batch_request.model
)
created_chapters = None
if batch_request.auto_create_chapters:
# 创建章节记录
chapters = await expansion_service.create_chapters_from_plans(
outline_id=outline.id,
chapter_plans=chapter_plans,
project_id=outline.project_id,
db=db,
start_chapter_number=None # 自动计算章节序号
)
created_chapters = [
{
"id": ch.id,
"chapter_number": ch.chapter_number,
"title": ch.title,
"summary": ch.summary,
"outline_id": ch.outline_id,
"sub_index": ch.sub_index,
"status": ch.status
}
for ch in chapters
]
total_chapters_created += len(chapters)
expansion_results.append({
"outline_id": outline.id,
"outline_title": outline.title,
"target_chapter_count": batch_request.chapters_per_outline,
"actual_chapter_count": len(chapter_plans),
"expansion_strategy": batch_request.expansion_strategy,
"chapter_plans": chapter_plans,
"created_chapters": created_chapters
})
logger.info(f"大纲 {outline.title} 展开完成,生成 {len(chapter_plans)} 个章节规划")
except Exception as e:
logger.error(f"展开大纲 {outline.id} 失败: {str(e)}", exc_info=True)
expansion_results.append({
"outline_id": outline.id,
"outline_title": outline.title,
"target_chapter_count": batch_request.chapters_per_outline,
"actual_chapter_count": 0,
"expansion_strategy": batch_request.expansion_strategy,
"chapter_plans": [],
"created_chapters": None,
"error": str(e)
})
logger.info(f"批量展开完成: {len(expansion_results)} 个大纲,共生成 {total_chapters_created} 个章节")
# 构建响应
return BatchOutlineExpansionResponse(
project_id=batch_request.project_id,
total_outlines_expanded=len(expansion_results),
total_chapters_created=total_chapters_created,
expansion_results=[
OutlineExpansionResponse(
outline_id=result["outline_id"],
outline_title=result["outline_title"],
target_chapter_count=result["target_chapter_count"],
actual_chapter_count=result["actual_chapter_count"],
expansion_strategy=result["expansion_strategy"],
chapter_plans=result["chapter_plans"],
created_chapters=result.get("created_chapters")
)
for result in expansion_results
]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"批量大纲展开失败: {str(e)}", exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=f"批量大纲展开失败: {str(e)}")
async def batch_expand_outlines_generator(
data: Dict[str, Any],
db: AsyncSession,
+14 -45
View File
@@ -162,26 +162,19 @@ async def world_building_generator(
if chunk_count % 20 == 0:
yield await SSEResponse.send_heartbeat()
# 解析结果
# 解析结果 - 使用统一的JSON清洗方法
yield await SSEResponse.send_progress("解析AI返回结果...", 80)
world_data = {}
try:
cleaned_text = accumulated_text.strip()
# 移除markdown代码块标记
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:].lstrip('\n\r')
elif cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:].lstrip('\n\r')
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
cleaned_text = cleaned_text.strip()
# ✅ 使用 AIService 的统一清洗方法
cleaned_text = user_ai_service._clean_json_response(accumulated_text)
world_data = json.loads(cleaned_text)
logger.info(f"✅ 世界观JSON解析成功")
except json.JSONDecodeError as e:
logger.error(f"世界构建JSON解析失败: {e}")
logger.error(f"世界构建JSON解析失败: {e}")
logger.error(f" 原始内容预览: {accumulated_text[:200]}")
world_data = {
"time_period": "AI返回格式错误,请重试",
"location": "AI返回格式错误,请重试",
@@ -478,17 +471,8 @@ async def characters_generator(
accumulated_text += chunk
yield await SSEResponse.send_chunk(chunk)
# 解析批次结果
cleaned_text = accumulated_text.strip()
# 移除markdown代码块标记
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:].lstrip('\n\r')
elif cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:].lstrip('\n\r')
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
cleaned_text = cleaned_text.strip()
# 解析批次结果 - 使用统一的JSON清洗方法
cleaned_text = user_ai_service._clean_json_response(accumulated_text)
characters_data = json.loads(cleaned_text)
if not isinstance(characters_data, list):
characters_data = [characters_data]
@@ -944,7 +928,7 @@ async def outline_generator(
outline_requirements += "5. 不要在JSON字符串值中使用中文引号(""''),请使用【】或《》标记\n"
# 获取自定义提示词模板
template = await PromptService.get_template("COMPLETE_OUTLINE_GENERATION", user_id, db)
template = await PromptService.get_template("OUTLINE_CREATE", user_id, db)
outline_prompt = PromptService.format_prompt(
template,
title=project.title,
@@ -972,18 +956,11 @@ async def outline_generator(
accumulated_text += chunk
yield await SSEResponse.send_chunk(chunk)
# 解析大纲结果
# 解析大纲结果 - 使用统一的JSON清洗方法
yield await SSEResponse.send_progress("解析大纲...", 40)
cleaned_text = accumulated_text.strip()
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:].lstrip('\n\r')
elif cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:].lstrip('\n\r')
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
cleaned_text = cleaned_text.strip()
try:
cleaned_text = user_ai_service._clean_json_response(accumulated_text)
outline_data = json.loads(cleaned_text)
if not isinstance(outline_data, list):
outline_data = [outline_data]
@@ -1239,22 +1216,14 @@ async def world_building_regenerate_generator(
if chunk_count % 20 == 0:
yield await SSEResponse.send_heartbeat()
# 解析结果
# 解析结果 - 使用统一的JSON清洗方法
yield await SSEResponse.send_progress("解析AI返回结果...", 80)
world_data = {}
try:
cleaned_text = accumulated_text.strip()
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:].lstrip('\n\r')
elif cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:].lstrip('\n\r')
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
cleaned_text = cleaned_text.strip()
cleaned_text = user_ai_service._clean_json_response(accumulated_text)
world_data = json.loads(cleaned_text)
logger.info(f"✅ 世界观重新生成JSON解析成功")
except json.JSONDecodeError as e:
logger.error(f"世界构建JSON解析失败: {e}")
+408 -77
View File
@@ -8,9 +8,15 @@ from app.mcp.adapters import UniversalMCPAdapter, PromptInjectionAdapter
import httpx
import json
import hashlib
import re
import asyncio
logger = get_logger(__name__)
# 全局请求限流器(使用信号量控制并发数)
_global_semaphore = asyncio.Semaphore(5) # 最多5个并发请求
_request_delay = 0.2 # 请求间隔200ms
# 全局HTTP客户端池(按配置复用)
_http_client_pool: Dict[str, httpx.AsyncClient] = {}
_client_pool_lock = False # 简单的锁标志
@@ -308,7 +314,7 @@ class AIService:
max_tokens: int,
system_prompt: Optional[str]
) -> str:
"""使用OpenAI生成文本"""
"""使用OpenAI生成文本(带限流和重试)"""
if not self.openai_http_client:
raise ValueError("OpenAI客户端未初始化,请检查API key配置")
@@ -317,84 +323,118 @@ class AIService:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
try:
logger.info(f"🔵 开始调用OpenAI API(直接HTTP请求)")
logger.info(f" - 模型: {model}")
logger.info(f" - 温度: {temperature}")
logger.info(f" - 最大tokens: {max_tokens}")
logger.info(f" - Prompt长度: {len(prompt)} 字符")
logger.info(f" - 消息数量: {len(messages)}")
# 使用全局信号量限流
async with _global_semaphore:
# 请求间隔
await asyncio.sleep(_request_delay)
url = f"{self.openai_base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.openai_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
logger.debug(f" - 请求URL: {url}")
logger.debug(f" - 请求头: Authorization=Bearer ***")
response = await self.openai_http_client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
logger.info(f"✅ OpenAI API调用成功")
logger.info(f" - 响应ID: {data.get('id', 'N/A')}")
logger.info(f" - 选项数量: {len(data.get('choices', []))}")
logger.debug(f" - 完整API响应: {data}")
if not data.get('choices'):
logger.error("❌ OpenAI返回的choices为空")
raise ValueError("API返回的响应格式错误:choices字段为空")
choice = data['choices'][0]
message = choice.get('message', {})
finish_reason = choice.get('finish_reason')
# DeepSeek R1特殊处理:只使用content(最终答案),忽略reasoning_content(思考过程)
# reasoning_content是AI的思考过程,不是我们需要的JSON结果
content = message.get('content', '')
# 检查是否因达到长度限制而截断
if finish_reason == 'length':
logger.warning(f"⚠️ 响应因达到max_tokens限制而被截断")
logger.warning(f" - 当前max_tokens: {max_tokens}")
logger.warning(f" - 建议: 增加max_tokens参数(推荐2000+")
if content:
logger.info(f" - 返回内容长度: {len(content)} 字符")
logger.info(f" - 完成原因: {finish_reason}")
logger.info(f" - 返回内容预览(前200字符): {content[:200]}")
return content
else:
logger.error("❌ AI返回了空内容")
logger.error(f" - 完整响应: {data}")
logger.error(f" - 完成原因: {finish_reason}")
# 重试机制
max_retries = 3
for attempt in range(max_retries):
try:
if attempt > 0:
wait_time = min(2 ** attempt, 10) # 指数退避
logger.warning(f"⚠️ OpenAI API调用失败,{wait_time}秒后重试(第{attempt + 1}/{max_retries}次)")
await asyncio.sleep(wait_time)
logger.info(f"🔵 开始调用OpenAI API(尝试 {attempt + 1}/{max_retries}")
logger.info(f" - 模型: {model}")
logger.info(f" - 温度: {temperature}")
logger.info(f" - 最大tokens: {max_tokens}")
logger.info(f" - Prompt长度: {len(prompt)} 字符")
logger.info(f" - 消息数量: {len(messages)}")
url = f"{self.openai_base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.openai_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens
}
logger.debug(f" - 请求URL: {url}")
logger.debug(f" - 请求头: Authorization=Bearer ***")
response = await self.openai_http_client.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
logger.info(f"✅ OpenAI API调用成功")
logger.info(f" - 响应ID: {data.get('id', 'N/A')}")
logger.info(f" - 选项数量: {len(data.get('choices', []))}")
logger.debug(f" - 完整API响应: {data}")
if not data.get('choices'):
logger.error("❌ OpenAI返回的choices为空")
raise ValueError("API返回的响应格式错误:choices字段为空")
choice = data['choices'][0]
message = choice.get('message', {})
finish_reason = choice.get('finish_reason')
# DeepSeek R1特殊处理:只使用content(最终答案),忽略reasoning_content(思考过程)
# reasoning_content是AI的思考过程,不是我们需要的JSON结果
content = message.get('content', '')
# 检查是否因达到长度限制而截断
if finish_reason == 'length':
logger.warning(f"⚠️ 响应因达到max_tokens限制而被截断")
logger.warning(f" - 当前max_tokens: {max_tokens}")
logger.warning(f" - 建议: 增加max_tokens参数(推荐2000+")
if content:
logger.info(f" - 返回内容长度: {len(content)} 字符")
logger.info(f" - 完成原因: {finish_reason}")
logger.info(f" - 返回内容预览(前200字符): {content[:200]}")
return content
else:
logger.error("❌ AI返回了空内容")
logger.error(f" - 完整响应: {data}")
logger.error(f" - 完成原因: {finish_reason}")
# 提供更详细的错误信息
if finish_reason == 'length':
raise ValueError(f"AI响应被截断且无有效内容。请增加max_tokens参数(当前: {max_tokens},建议: 2000+")
else:
raise ValueError(f"AI返回了空内容(finish_reason: {finish_reason}),请检查API配置或稍后重试")
# 提供更详细的错误信息
if finish_reason == 'length':
raise ValueError(f"AI响应被截断且无有效内容。请增加max_tokens参数(当前: {max_tokens},建议: 2000+")
else:
raise ValueError(f"AI返回了空内容(finish_reason: {finish_reason}),请检查API配置或稍后重试")
except httpx.HTTPStatusError as e:
logger.error(f"❌ OpenAI API调用失败 (HTTP {e.response.status_code})")
logger.error(f" - 错误信息: {e.response.text}")
logger.error(f" - 模型: {model}")
raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}")
except Exception as e:
logger.error(f"❌ OpenAI API调用失败")
logger.error(f" - 错误类型: {type(e).__name__}")
logger.error(f" - 错误信息: {str(e)}")
logger.error(f" - 模型: {model}")
raise
except httpx.ConnectError as e:
logger.error(f"❌ OpenAI API连接失败 (尝试 {attempt + 1}/{max_retries}): {str(e)}")
if attempt == max_retries - 1:
raise Exception(f"连接失败,已重试{max_retries}次。请检查网络连接或API地址: {str(e)}")
continue
except httpx.HTTPStatusError as e:
logger.error(f"❌ OpenAI API调用失败 (HTTP {e.response.status_code}, 尝试 {attempt + 1}/{max_retries})")
logger.error(f" - 错误信息: {e.response.text}")
# 某些错误不需要重试(如401、403)
if e.response.status_code in [401, 403, 404]:
raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}")
if attempt == max_retries - 1:
raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}")
continue
except httpx.TimeoutException as e:
logger.error(f"❌ OpenAI API超时 (尝试 {attempt + 1}/{max_retries})")
if attempt == max_retries - 1:
raise Exception(f"API请求超时,已重试{max_retries}次: {str(e)}")
continue
except Exception as e:
logger.error(f"❌ OpenAI API调用失败 (尝试 {attempt + 1}/{max_retries})")
logger.error(f" - 错误类型: {type(e).__name__}")
logger.error(f" - 错误信息: {str(e)}")
if attempt == max_retries - 1:
raise
continue
async def _generate_openai_with_tools(
@@ -1044,6 +1084,297 @@ class AIService:
**kwargs
):
yield chunk
# ========== JSON 统一调用和自动重试 ==========
@staticmethod
def _clean_json_response(text: str) -> str:
"""
清洗 AI 返回的 JSON 响应
去除常见的格式问题:
- markdown 代码块标记 (```json ```)
- 前后空白字符
- 注释文字
Args:
text: AI 返回的原始文本
Returns:
清洗后的 JSON 字符串
"""
if not text:
return text
# 去除 markdown 代码块标记
text = re.sub(r'^```json\s*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
text = re.sub(r'^```\s*\n?', '', text, flags=re.MULTILINE)
text = re.sub(r'\n?```\s*$', '', text, flags=re.MULTILINE)
# 去除前后空白
text = text.strip()
# 尝试提取第一个完整的 JSON 对象或数组
# 查找第一个 { 或 [
start_idx = -1
for i, char in enumerate(text):
if char in ('{', '['):
start_idx = i
break
if start_idx == -1:
return text
# 从第一个括号开始提取
text = text[start_idx:]
# 查找匹配的结束括号
bracket_stack = []
end_idx = -1
in_string = False
escape_next = False
for i, char in enumerate(text):
if escape_next:
escape_next = False
continue
if char == '\\':
escape_next = True
continue
if char == '"':
in_string = not in_string
continue
if in_string:
continue
if char in ('{', '['):
bracket_stack.append(char)
elif char == '}':
if bracket_stack and bracket_stack[-1] == '{':
bracket_stack.pop()
if not bracket_stack:
end_idx = i + 1
break
elif char == ']':
if bracket_stack and bracket_stack[-1] == '[':
bracket_stack.pop()
if not bracket_stack:
end_idx = i + 1
break
if end_idx > 0:
return text[:end_idx]
return text
@staticmethod
def _add_json_format_hint(original_prompt: str, failed_response: str, attempt: int) -> str:
"""
重试时添加格式纠正提示
Args:
original_prompt: 原始提示词
failed_response: 上次失败的响应(截断显示)
attempt: 当前尝试次数
Returns:
增强后的提示词
"""
error_preview = failed_response[:300] if failed_response else "无响应"
return f"""{original_prompt}
⚠️ 【第 {attempt} 次重试】上一次返回格式错误,请严格遵守以下规则:
🔴 格式要求(必须严格遵守):
1. 只返回纯 JSON 对象或数组,不要有任何其他文字
2. 不要使用 ```json``` 或 ``` 包裹 JSON
3. 不要添加任何解释、说明或注释
4. 确保 JSON 格式完全正确:
- 所有括号必须匹配 {{}} []
- 所有字符串必须用双引号 ""
- 键值对用冒号分隔 :
- 多个元素用逗号分隔 ,
- 不要有多余的逗号
❌ 上一次的错误返回示例:
{error_preview}...
✅ 请现在重新生成正确的 JSON 格式内容。"""
async def call_with_json_retry(
self,
prompt: str,
system_prompt: Optional[str] = None,
max_retries: int = 3,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
expected_type: Optional[str] = None # "object" 或 "array"
) -> Dict[str, Any] | List[Dict[str, Any]]:
"""
统一的 JSON 调用方法,自动重试和格式修复
这是一个专门用于需要返回 JSON 格式的 AI 调用封装,会自动:
1. 清洗 AI 返回的内容(去除 markdown 标记等)
2. 解析 JSON 并验证格式
3. 失败时自动重试,并在提示词中添加纠正指引
Args:
prompt: 用户提示词
system_prompt: 系统提示词(可选)
max_retries: 最大重试次数,默认 3 次
temperature: 温度参数(可选,使用默认值)
max_tokens: 最大 token 数(可选,使用默认值)
provider: AI 提供商(可选,使用默认值)
model: 模型名称(可选,使用默认值)
expected_type: 期望的 JSON 类型 "object""array"(可选,用于额外验证)
Returns:
解析后的 JSON 对象(dict)或数组(list
Raises:
ValueError: 重试次数用尽仍未获得有效 JSON
Examples:
>>> # 获取 JSON 对象
>>> result = await ai_service.call_with_json_retry(
... prompt="生成一个角色",
... expected_type="object"
... )
>>> print(result["name"])
>>> # 获取 JSON 数组
>>> results = await ai_service.call_with_json_retry(
... prompt="生成3个角色",
... expected_type="array"
... )
>>> print(len(results))
"""
last_error = None
last_response = ""
for attempt in range(1, max_retries + 1):
try:
logger.info(f"🔄 JSON 调用尝试 {attempt}/{max_retries}")
# 第一次使用原始提示词,之后使用增强提示词
current_prompt = prompt if attempt == 1 else self._add_json_format_hint(
prompt, last_response, attempt
)
# 调用 AI 生成内容
if provider == "openai" and self.openai_client:
response = await self._generate_openai(
prompt=current_prompt,
model=model or self.default_model,
temperature=temperature or self.default_temperature,
max_tokens=max_tokens or self.default_max_tokens,
system_prompt=system_prompt
)
elif provider == "anthropic" and self.anthropic_client:
response = await self._generate_anthropic(
prompt=current_prompt,
model=model or self.default_model,
temperature=temperature or self.default_temperature,
max_tokens=max_tokens or self.default_max_tokens,
system_prompt=system_prompt
)
else:
# 使用默认提供商
if self.api_provider == "openai":
response = await self._generate_openai(
prompt=current_prompt,
model=model or self.default_model,
temperature=temperature or self.default_temperature,
max_tokens=max_tokens or self.default_max_tokens,
system_prompt=system_prompt
)
else:
response = await self._generate_anthropic(
prompt=current_prompt,
model=model or self.default_model,
temperature=temperature or self.default_temperature,
max_tokens=max_tokens or self.default_max_tokens,
system_prompt=system_prompt
)
last_response = response
# 清洗响应内容
cleaned = self._clean_json_response(response)
logger.debug(f"清洗后的内容: {cleaned[:200]}...")
# 解析 JSON
try:
data = json.loads(cleaned)
except json.JSONDecodeError as e:
logger.warning(f"⚠️ JSON 解析失败: {e}")
logger.debug(f"原始响应: {response[:500]}")
logger.debug(f"清洗后: {cleaned[:500]}")
raise
# 可选:验证 JSON 类型
if expected_type:
if expected_type == "object" and not isinstance(data, dict):
raise ValueError(f"期望 JSON 对象,但得到 {type(data).__name__}")
elif expected_type == "array" and not isinstance(data, list):
raise ValueError(f"期望 JSON 数组,但得到 {type(data).__name__}")
logger.info(f"✅ JSON 解析成功 (尝试 {attempt}/{max_retries})")
if isinstance(data, dict):
logger.info(f" 返回对象,包含 {len(data)} 个键")
elif isinstance(data, list):
logger.info(f" 返回数组,包含 {len(data)} 个元素")
return data
except json.JSONDecodeError as e:
last_error = e
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: JSON 解析错误")
logger.warning(f" 错误位置: {e.msg} at line {e.lineno} column {e.colno}")
if attempt < max_retries:
logger.info(f" 准备第 {attempt + 1} 次重试...")
continue
else:
logger.error(f"❌ JSON 解析失败,已达到最大重试次数 {max_retries}")
logger.error(f" 最后的响应内容:\n{last_response[:1000]}")
raise ValueError(
f"AI 返回内容无法解析为 JSON,已重试 {max_retries} 次。\n"
f"最后错误: {e}\n"
f"响应预览: {last_response[:200]}..."
)
except ValueError as e:
last_error = e
logger.warning(f"⚠️ 第 {attempt} 次尝试失败: {e}")
if attempt < max_retries:
logger.info(f" 准备第 {attempt + 1} 次重试...")
continue
else:
logger.error(f"❌ 验证失败,已达到最大重试次数 {max_retries}")
raise ValueError(
f"AI 返回的 JSON 格式不符合要求,已重试 {max_retries} 次。\n"
f"错误: {e}"
)
except Exception as e:
logger.error(f"❌ 第 {attempt} 次调用出现未预期错误: {type(e).__name__}: {e}")
if attempt < max_retries:
logger.info(f" 准备第 {attempt + 1} 次重试...")
last_error = e
continue
else:
raise
# 理论上不会到达这里,但以防万一
raise ValueError(f"JSON 调用失败,已重试 {max_retries} 次。最后错误: {last_error}")
# 创建全局AI服务实例
+18 -29
View File
@@ -256,7 +256,7 @@ class AutoCharacterService:
)
try:
# 调用AI分析
# 调用AI分析(使用统一的JSON调用方法)
if enable_mcp and user_id:
result = await self.ai_service.generate_text_with_mcp(
prompt=prompt,
@@ -266,28 +266,21 @@ class AutoCharacterService:
max_tool_rounds=1
)
content = result.get("content", "")
# 使用统一的JSON清洗方法
cleaned = self.ai_service._clean_json_response(content)
analysis = json.loads(cleaned)
else:
result = await self.ai_service.generate_text(prompt=prompt)
content = result.get("content", "") if isinstance(result, dict) else result
# 非MCP调用:使用带自动重试的JSON调用
analysis = await self.ai_service.call_with_json_retry(
prompt=prompt,
max_retries=3
)
# 清理并解析JSON
cleaned = content.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
if cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
analysis = json.loads(cleaned)
logger.info(f" ✅ AI分析完成: needs_new_characters={analysis.get('needs_new_characters')}")
return analysis
except json.JSONDecodeError as e:
logger.error(f" ❌ 角色需求分析JSON解析失败: {e}")
logger.error(f" 响应内容: {content[:500]}")
return {"needs_new_characters": False}
except Exception as e:
logger.error(f" ❌ 角色需求分析失败: {e}")
@@ -328,7 +321,7 @@ class AutoCharacterService:
mcp_references="" # 暂时不使用MCP增强
)
# 调用AI生成
# 调用AI生成(使用统一的JSON调用方法)
try:
if enable_mcp and user_id:
result = await self.ai_service.generate_text_with_mcp(
@@ -339,20 +332,16 @@ class AutoCharacterService:
max_tool_rounds=1
)
content = result.get("content", "")
# 使用统一的JSON清洗方法
cleaned = self.ai_service._clean_json_response(content)
character_data = json.loads(cleaned)
else:
result = await self.ai_service.generate_text(prompt=prompt)
content = result.get("content", "") if isinstance(result, dict) else result
# 非MCP调用:使用带自动重试的JSON调用
character_data = await self.ai_service.call_with_json_retry(
prompt=prompt,
max_retries=3
)
# 解析JSON
cleaned = content.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
if cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
character_data = json.loads(cleaned.strip())
char_name = character_data.get('name', '未知')
logger.info(f" ✅ 角色详情生成成功: {char_name}")
logger.debug(f" 角色数据字段: {list(character_data.keys())}")
+3 -1
View File
@@ -205,7 +205,9 @@ class MCPTestService:
if isinstance(test_arguments, str):
try:
test_arguments = json.loads(test_arguments)
# 使用统一的JSON清洗方法
cleaned_args = ai_service._clean_json_response(test_arguments)
test_arguments = json.loads(cleaned_args)
except json.JSONDecodeError as e:
logger.error(f"❌ 解析AI参数失败: {e}")
return MCPTestResult(
+4 -19
View File
@@ -110,7 +110,7 @@ class PlotAnalyzer:
def _parse_analysis_response(self, response: str) -> Optional[Dict[str, Any]]:
"""
解析AI返回的分析结果
解析AI返回的分析结果(使用统一的JSON清洗方法)
Args:
response: AI返回的文本
@@ -119,13 +119,8 @@ class PlotAnalyzer:
解析后的字典,失败返回None
"""
try:
# 清理响应文本
cleaned = response.strip()
# 移除可能的markdown标记
cleaned = re.sub(r'^```json\s*', '', cleaned)
cleaned = re.sub(r'^```\s*', '', cleaned)
cleaned = re.sub(r'\s*```$', '', cleaned)
# 使用统一的JSON清洗方法
cleaned = self.ai_service._clean_json_response(response)
# 尝试解析JSON
result = json.loads(cleaned)
@@ -137,22 +132,12 @@ class PlotAnalyzer:
logger.warning(f"⚠️ 分析结果缺少字段: {field}")
result[field] = [] if field != 'scores' else {}
logger.info("✅ 成功解析分析结果")
return result
except json.JSONDecodeError as e:
logger.error(f"❌ JSON解析失败: {str(e)}")
logger.error(f" 原始响应(前500字): {response[:500]}")
# 尝试提取JSON部分
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
try:
result = json.loads(json_match.group())
logger.info("✅ 通过正则提取成功解析JSON")
return result
except:
pass
return None
except Exception as e:
logger.error(f"❌ 解析异常: {str(e)}")
+21 -13
View File
@@ -109,7 +109,7 @@ class PlotExpansionService:
context_info = await self._get_outline_context(outline, project.id, db)
# 获取自定义提示词模板
template = await PromptService.get_template("PLOT_EXPANSION_SINGLE_BATCH", project.user_id, db)
template = await PromptService.get_template("OUTLINE_EXPAND_SINGLE", project.user_id, db)
# 格式化提示词
prompt = PromptService.format_prompt(
template,
@@ -209,7 +209,7 @@ class PlotExpansionService:
⚠️ 当前是第{current_start_index}-{current_start_index + current_batch_size - 1}节(共{target_chapter_count}节中的一部分)
"""
# 获取自定义提示词模板
template = await PromptService.get_template("PLOT_EXPANSION_MULTI_BATCH", project.user_id, db)
template = await PromptService.get_template("OUTLINE_EXPAND_MULTI", project.user_id, db)
# 格式化提示词
prompt = PromptService.format_prompt(
template,
@@ -497,17 +497,10 @@ class PlotExpansionService:
ai_response: str,
outline_id: str
) -> List[Dict[str, Any]]:
"""解析AI的展开响应"""
"""解析AI的展开响应(使用统一的JSON清洗方法)"""
try:
# 清理响应文本
cleaned_text = ai_response.strip()
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:]
if cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:]
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3]
cleaned_text = cleaned_text.strip()
# 使用统一的JSON清洗方法
cleaned_text = self.ai_service._clean_json_response(ai_response)
# 解析JSON
chapter_plans = json.loads(cleaned_text)
@@ -520,10 +513,11 @@ class PlotExpansionService:
for plan in chapter_plans:
plan["outline_id"] = outline_id
logger.info(f"✅ 成功解析 {len(chapter_plans)} 个章节规划")
return chapter_plans
except json.JSONDecodeError as e:
logger.error(f"解析AI响应失败: {e}, 响应内容: {ai_response[:500]}")
logger.error(f"解析AI响应失败: {e}, 响应内容: {ai_response[:500]}")
# 返回一个基础规划
return [{
"outline_id": outline_id,
@@ -537,6 +531,20 @@ class PlotExpansionService:
"conflict_type": "未知",
"estimated_words": 3000
}]
except Exception as e:
logger.error(f"❌ 解析异常: {str(e)}")
return [{
"outline_id": outline_id,
"sub_index": 1,
"title": "解析异常的默认章节",
"plot_summary": "系统错误",
"key_events": [],
"character_focus": [],
"emotional_tone": "未知",
"narrative_goal": "需要重新生成",
"conflict_type": "未知",
"estimated_words": 3000
}]
async def _renumber_subsequent_chapters(
+131 -626
View File
@@ -9,75 +9,125 @@ class WritingStyleManager:
# 预设风格配置
PRESET_STYLES = {
"natural": {
"name": "自然流畅",
"description": "像普通人讲故事一样自然,不刻意修饰,有生活气息",
"name": "自然沉浸 (Natural & Immersive)",
"description": "祛除翻译腔,强调生活质感,像呼吸一样自然的叙事",
"prompt_content": """
**自然流畅风格要求:**
- 用简单朴实的语言叙述,避免华丽辞藻
- 像在和朋友聊天一样讲故事
- 保持轻松自然的节奏,不要刻意营造氛围
- 多用短句,少用长句和排比
- 让读者感觉舒服,不要让人觉得在"看文学作品"
### 核心指令:自然沉浸风格
请模拟人类作家在放松状态下的写作,通过以下规则消除“AI味”:
1. **拒绝翻译腔与书面化**
- 严禁使用“一种...的感觉”、“随着...”、“与此同时”等连接词。
- 多用短句和“流水句”,模拟人类视线的移动和思维的跳跃。
- 口语化叙述,但不要滥用语气词,而是通过句子的长短节奏来体现语气。
2. **生活化的颗粒度**
- 描写不要宏大,要聚焦在具体的、微小的生活细节(如:杯子上的水渍、衣服的褶皱)。
- 允许逻辑上的适度“松散”,不要让每句话都像说明书一样严丝合缝。
3. **具体的“展示”**
- 不要写“他很生气”,要写他“把烟头按灭在还没吃完的米饭里”。
- 避免使用抽象的形容词(如:巨大的、美丽的、悲伤的),必须用名词和动词来承载画面。
"""
},
"classical": {
"name": "古典",
"description": "典雅精致的文学风格,注重意境和韵味",
"name": "古典雅致 (Classical & Elegant)",
"description": "白话文与古典韵味的结合,强调留白与炼字",
"prompt_content": """
**古典优雅风格要求:**
- 使用优美典雅的语言,注重文字的韵律感
- 善用比喻、拟人等修辞手法
- 注重意境营造,追求诗意美感
- 可适当引用古诗词或典故(需符合世界观)
- 保持端庄雅致的叙述节奏
### 核心指令:古典雅致风格
请模仿民国时期或古典白话小说的笔触,构建端庄且富有余味的叙事:
1. **炼字与韵律**
- 尽量使用双音节词或四字短语,但严禁堆砌辞藻。
- 注重句子的声调韵律,读起来要有金石之声或流水之韵。
- 适当使用倒装句或定语后置,增加古雅感。
2. **克制的修辞**
- 少用现代的比喻(如“像机器一样”),多用取自自然的比喻(如“如风过林”)。
- **意在言外**:不要把话说透,留三分余地。写景即是写情,不要将情感直接剖白。
3. **禁忌**
- 严禁使用现代科技词汇(除非题材需要)、网络用语或过于西化的句式(如长定语从句)。
- 避免滥用“之乎者也”,追求的是“神似”而非生硬的半文半白。
"""
},
"modern": {
"name": "现代简约",
"description": "简洁明快的现代风格,注重效率和直接表达",
"name": "冷硬现代 (Modern & Hard-boiled)",
"description": "海明威式的冰山理论,节奏极快,零度情感",
"prompt_content": """
**现代简约风格要求:**
- 语言简洁有力,直达重点
- 多用短句和短段落,节奏明快
- 避免冗长描写,注重信息密度
- 使用现代口语化表达
- 情节推进快速,少做环境渲染
### 核心指令:冷硬现代风格
请采用“极简主义”和“零度写作”手法,去除所有矫饰:
1. **冰山理论**
- **只写动作和对话,完全剔除心理描写和形容词堆砌。**
- 不要告诉读者角色感觉如何,通过角色的反应和环境的冷峻反馈来体现。
2. **电影蒙太奇节奏**
- 句子要短、脆、硬。像手术刀一样切开场景。
- 段落之间快速切换,不要用过渡句连接,直接跳切。
3. **高信息密度**
- 删除所有废话。如果一个词删掉不影响理解,就删掉它。
- 多用名词和强动词(Strong Verbs),少用副词(Adverbs)。例如:不要写“他重重地关上门”,写“他摔上了门”。
"""
},
"poetic": {
"name": "诗意抒情",
"description": "富有诗意和情感张力的抒情风格",
"name": "意识流 (Stream of Consciousness)",
"description": "注重感官通感与内心独白,打破现实与幻想的边界",
"prompt_content": """
**诗意抒情风格要求:**
- 注重情感表达和内心描写
- 善用景物描写烘托情绪
- 语言富有韵律和美感
- 细腻刻画人物心理活动
- 营造情感氛围,引发共鸣
### 核心指令:意识流/诗意风格
请侧重于主观感受的流动,而非客观事实的记录:
1. **通感与陌生化**
- 打通五感(如:听到了颜色的声音,闻到了悲伤的气味)。
- 使用“陌生化”的语言,把熟悉的事物写得陌生,迫使读者重新审视。
2. **情绪的具象化**
- **绝对禁止**直接出现“开心”、“痛苦”等抽象词汇。
- 必须寻找“客观对应物”(Objective Correlative),将情绪投射到具体的景物上(如:生锈的铁轨、发霉的橘子)。
3. **流动的句式**
- 句子可以很长,包含多重意象的叠加。
- 允许思维的非线性跳跃,模拟梦境或深层潜意识的逻辑。
"""
},
"concise": {
"name": "精炼利落",
"description": "惜字如金的简练风格,每个字都有意义",
"name": "白描速写 (Sketch & Concise)",
"description": "只有骨架的叙事,强调绝对的精准和功能性",
"prompt_content": """
**精炼利落风格要求:**
- 删除所有冗余描写,每句话都要有作用
- 多用动词,少用形容词和副词
- 对话干脆利落,不拖泥带水
- 环境描写点到为止
- 用最少的字数传达最多的信息
### 核心指令:白描速写风格
请像速写画家一样,只勾勒线条,不涂抹色彩:
1. **功能性第一**
- 每一句话必须推动情节,或者揭示关键信息。
- 如果一句话只是为了渲染气氛,删掉它。
2. **主谓宾结构**
- 尽量使用简单的主谓宾结构,减少修饰语。
- 避免复杂的从句和嵌套结构。
3. **直击核心**
- 对话直接进入主题,去除寒暄和废话。
- 环境描写仅限于对情节有物理影响的物体(如:挡路的石头、藏在桌下的枪)。
"""
},
"vivid": {
"name": "生动形象",
"description": "画面感强烈,让读者如临其境",
"name": "感官特写 (Sensory & Vivid)",
"description": "高分辨率的描写,强调材质、光影和微观细节",
"prompt_content": """
**生动形象风格要求:**
- 注重细节描写,让场景具体可感
- 调动五感(视觉、听觉、触觉、嗅觉、味觉)
- 使用鲜明的比喻和形象化语言
- 让读者能"看到"场景和动作
- 人物表情、动作要具体生动
### 核心指令:感官特写风格
请将镜头推到特写级别(Macro Lens),捕捉常人忽略的细节:
1. **反套路细节**
- 不要写大众化的细节(如:蓝天白云),要写具有**独特性**的细节(如:云层边缘那抹像淤青一样的灰紫色)。
- 关注物体的**质感(Texture)**:粗糙的、粘稠的、冰凉的、颗粒感的。
2. **动态捕捉**
- 不要写静止的画面,要写光影的流变、灰尘的飞舞、肌肉的抽动。
- 让读者产生生理性的反应(如:痛感、饥饿感、窒息感)。
3. **禁用词汇**
- 禁止使用“映入眼帘”、“宛如画卷”等陈词滥调。
- 必须用具体的动词带动感官描写。
"""
}
}
@@ -339,15 +389,23 @@ class PromptService:
4. 所有内容描述中严禁使用任何特殊符号,包括但不限于中文引号、英文引号、方括号、书名号等"""
# 向导大纲生成提示词
COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}小说大纲:
OUTLINE_CREATE = """你是一位经验丰富的小说作家和编剧。请根据以下信息为小说生成**开篇{chapter_count}**的大纲:
【重要说明】
本次任务是为项目初始化生成开头部分的大纲,而不是整本书的完整大纲。这些章节应该:
- 完成故事的**开局设定**和**世界观展示**
- 引入主要角色,建立初始关系
- 埋下核心矛盾和悬念钩子
- 为后续剧情发展打下基础
- **不需要完整的故事闭环**,结尾应该为续写留出空间
基本信息:
- 书名:{title}
- 主题:{theme}
- 类型:{genre}
- 章节数:{chapter_count}
- 开篇章节数:{chapter_count}
- 叙事视角:{narrative_perspective}
- 目标字数:{target_words}
- 全书目标字数:{target_words}
世界观:
- 时间背景:{time_period}
@@ -362,13 +420,14 @@ class PromptService:
其他要求:{requirements}
整体要求:
- 结构完整:起承转合清晰
- 情节连贯:章节之间紧密衔接
- 冲突递进:矛盾逐步升级
- 人物成长:角色有明确的变化弧线
- 节奏把控:有张有弛
- 视角统一:采用{narrative_perspective}视角叙事
开篇大纲要求:
- **开局设定**:前几章完成世界观呈现、主角登场、初始状态建立
- **矛盾引入**:引出核心冲突或故事主线,但不急于展开
- **角色亮相**:主要角色依次登场,展示性格特点和相互关系
- **节奏控制**:开篇不宜过快,给读者适应和代入的时间
- **悬念设置**:埋下伏笔和钩子,为后续续写大纲预留发展空间
- **视角统一**:采用{narrative_perspective}视角叙事
- **留白艺术**:结尾不要收束过紧,要为后续剧情留出足够的发展空间
**重要格式要求:**
1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字
@@ -379,7 +438,7 @@ class PromptService:
[
{{
"chapter_number": 1,
"title": "第一章标题",
"title": "标题",
"summary": "章节概要的详细描述(100-200字),包含主要情节、冲突、转折等",
"scenes": ["场景1描述", "场景2描述", "场景3描述"],
"characters": ["角色1", "角色2"],
@@ -389,7 +448,7 @@ class PromptService:
}},
{{
"chapter_number": 2,
"title": "第二章标题",
"title": "标题",
"summary": "章节概要...",
"scenes": ["场景1", "场景2"],
"characters": ["角色1", "角色2"],
@@ -405,7 +464,7 @@ class PromptService:
3. 所有内容描述中严禁使用任何特殊符号"""
# 大纲续写提示词(记忆增强版)
OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
OUTLINE_CONTINUE = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲:
【项目信息】
- 书名:{title}
@@ -558,7 +617,7 @@ class PromptService:
3. 符合角色性格设定
4. 体现世界观特色
5. 使用{narrative_perspective}视角
6. **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}{max_word_count}字之间**
6. 字数要求:目标{target_word_count}字,不得低于{target_word_count}字,必须严格控制在{target_word_count}{max_word_count}字之间
7. 语言自然流畅,避免AI痕迹
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
@@ -624,7 +683,7 @@ class PromptService:
5. **写作风格**
- 使用{narrative_perspective}视角
- **字数要求:目标{target_word_count}字,不得低于{target_word_count}字,建议控制在{target_word_count}{max_word_count}字之间**
- 字数要求:目标{target_word_count}字,不得低于{target_word_count}字,必须严格控制在{target_word_count}{max_word_count}字之间
- 语言自然流畅,避免AI痕迹
- 体现世界观特色
@@ -641,41 +700,6 @@ class PromptService:
请直接输出章节正文内容,不要包含章节标题和其他说明文字。"""
# 大纲生成提示词
OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成小说大纲:
类型:{genre}
主题:{theme}
目标字数:{target_words}
其他要求:{requirements}
请生成一个完整的章节大纲框架,包含:
1. 合理的章节数量(根据字数)
2. 每章的标题和内容概要
3. 清晰的故事结构(起承转合)
4. 情节的递进和冲突升级
5. 角色的成长弧线
**重要格式要求:**
1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字
2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等)
3. 所有专有名词直接书写,不使用任何符号包裹
请严格按照以下JSON格式返回:
{{
"chapters": [
{{
"order": 1,
"title": "章节标题",
"content": "章节内容概要(150-200字)"
}}
]
}}
再次强调:
1. 只返回纯JSON对象,不要有```json```这样的标记
2. 所有内容描述中严禁使用任何特殊符号
3. 不要有任何额外的文字说明"""
# 单个角色生成提示词
SINGLE_CHARACTER_GENERATION = """你是一位专业的角色设定师。请根据以下信息创建一个立体饱满的小说角色。
@@ -910,7 +934,7 @@ class PromptService:
- 冲突解决进度(0-100%)
### 4. 情感曲线 (Emotional Arc)
- 主导情绪: 紧张/温馨/悲伤/激昂/平静等
- 主导情绪(最多10个字): 紧张/温馨/悲伤/激昂/平静/压抑/欢快/恐惧/期待/失落
- 情感强度(1-10)
- 情绪变化轨迹描述
@@ -975,7 +999,7 @@ class PromptService:
"resolution_progress": 0.3
}},
"emotional_arc": {{
"primary_emotion": "紧张",
"primary_emotion": "紧张焦虑",
"intensity": 8,
"curve": "平静→紧张→高潮→释放",
"secondary_emotions": ["期待", "焦虑"]
@@ -1031,7 +1055,7 @@ class PromptService:
只返回JSON,不要其他说明。"""
# 大纲单批次展开提示词
PLOT_EXPANSION_SINGLE_BATCH = """你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapter_count} 个章节的详细规划。
OUTLINE_EXPAND_SINGLE = """你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapter_count} 个章节的详细规划。
【项目信息】
小说名称:{project_title}
@@ -1125,7 +1149,7 @@ class PromptService:
"""
# 大纲分批展开提示词
PLOT_EXPANSION_MULTI_BATCH = """你是专业的小说情节架构师。请继续分析以下大纲节点,将其展开为第{start_index}-{end_index}节(共{target_chapter_count}个章节)的详细规划。
OUTLINE_EXPAND_MULTI = """你是专业的小说情节架构师。请继续分析以下大纲节点,将其展开为第{start_index}-{end_index}节(共{target_chapter_count}个章节)的详细规划。
【项目信息】
小说名称:{project_title}
@@ -1388,80 +1412,6 @@ class PromptService:
4. 相关领域的人物原型
请查询最关键的1个问题(不要超过1个)。"""
# 大纲展开为多章节的提示词
OUTLINE_EXPANSION = """你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapters} 个章节的详细规划。
【项目信息】
小说名称:{title}
类型:{genre}
主题:{theme}
叙事视角:{narrative_perspective}
【世界观背景】
时间背景:{time_period}
地理位置:{location}
氛围基调:{atmosphere}
世界规则:{rules}
【角色信息】
{characters_info}
【大纲节点】
序号:第 {outline_order}
标题:{outline_title}
内容:{outline_content}
【上下文】
{context_info}
【展开策略】
{strategy_instruction}
【任务要求】
1. 深度分析该大纲的剧情容量和叙事节奏
2. 识别关键剧情点、冲突点和情感转折点
3. 将大纲拆解为 {target_chapters} 个章节,每章需包含:
- sub_index: 子章节序号(1, 2, 3...
- title: 章节标题(体现该章核心冲突或情感)
- plot_summary: 剧情摘要(200-300字,详细描述该章发生的事件)
- key_events: 关键事件列表(3-5个关键剧情点)
- character_focus: 角色焦点(主要涉及的角色名称)
- emotional_tone: 情感基调(如:紧张、温馨、悲伤、激动等)
- narrative_goal: 叙事目标(该章要达成的叙事效果)
- conflict_type: 冲突类型(如:内心挣扎、人际冲突、环境挑战等)
- estimated_words: 预计字数(建议2000-5000字)
{scene_instruction}
4. 确保章节间:
- 衔接自然流畅
- 剧情递进合理
- 节奏张弛有度
- 每章都有明确的叙事价值
**重要格式要求:**
1. 只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字
2. JSON字符串值的内容描述中严禁使用任何特殊符号(包括中文引号、英文引号、方括号、书名号等)
3. 所有专有名词直接书写,不使用任何符号包裹
请严格按照以下JSON数组格式输出:
[
{{
"sub_index": 1,
"title": "章节标题",
"plot_summary": "该章详细剧情摘要(200-300字)...",
"key_events": ["关键事件1", "关键事件2", "关键事件3"],
"character_focus": ["角色A", "角色B"],
"emotional_tone": "情感基调",
"narrative_goal": "叙事目标",
"conflict_type": "冲突类型",
"estimated_words": 3000{scene_field}
}}
]
再次强调:
1. 只返回纯JSON数组,不要有```json```这样的标记
2. 数组中要包含{target_chapters}个章节对象
3. 每个plot_summary必须是200-300字的详细描述
4. 所有内容描述中严禁使用任何特殊符号"""
# 自动角色引入 - 预测性分析提示词(方案A)
AUTO_CHARACTER_ANALYSIS = """你是专业的小说角色设计顾问。请根据即将续写的剧情方向,预测是否需要引入新角色。
@@ -1667,451 +1617,6 @@ class PromptService:
except KeyError as e:
raise ValueError(f"缺少必需的参数: {e}")
@classmethod
def get_denoising_prompt(cls, original_text: str) -> str:
"""获取AI去味提示词"""
return cls.format_prompt(
cls.AI_DENOISING,
original_text=original_text
)
@classmethod
def get_world_building_prompt(cls, title: str, theme: str, genre: str = "", description: str = "") -> str:
"""获取世界构建提示词"""
return cls.format_prompt(
cls.WORLD_BUILDING,
title=title,
theme=theme,
genre=genre or "通用类型",
description=description or "暂无简介"
)
@classmethod
def get_characters_batch_prompt(cls, count: int, time_period: str, location: str,
atmosphere: str, rules: str, theme: str,
genre: str = "", requirements: str = "") -> str:
"""获取批量角色生成提示词"""
return cls.format_prompt(
cls.CHARACTERS_BATCH_GENERATION,
count=count,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
theme=theme,
genre=genre or "通用类型",
requirements=requirements or "无特殊要求"
)
@classmethod
def get_complete_outline_prompt(cls, title: str, theme: str, genre: str,
chapter_count: int, narrative_perspective: str,
target_words: int, time_period: str, location: str,
atmosphere: str, rules: str, characters_info: str,
requirements: str = "",
mcp_references: str = "") -> str:
"""获取向导大纲生成提示词(支持MCP增强)"""
# 格式化MCP参考资料
mcp_text = ""
if mcp_references:
mcp_text = "【📚 MCP工具搜索 - 情节设计参考】\n"
mcp_text += "以下是通过MCP工具搜索到的情节设计参考资料,可用于设计大纲结构和情节发展:\n\n"
mcp_text += mcp_references
mcp_text += "\n"
return cls.format_prompt(
cls.COMPLETE_OUTLINE_GENERATION,
title=title,
theme=theme,
genre=genre,
chapter_count=chapter_count,
narrative_perspective=narrative_perspective,
target_words=target_words,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
mcp_references=mcp_text,
requirements=requirements or "无特殊要求"
)
@classmethod
def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str,
narrative_perspective: str, time_period: str,
location: str, atmosphere: str, rules: str,
characters_info: str, outlines_context: str,
chapter_number: int, chapter_title: str,
chapter_outline: str, style_content: str = "",
target_word_count: int = 3000,
memory_context: dict = None,
mcp_references: str = "",
outline_mode: str = "one-to-many") -> str:
"""
获取章节完整创作提示词
Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
"""
# 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = "\n【🧠 智能记忆系统 - 重要参考】\n"
memory_text += memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
# 格式化MCP参考资料
mcp_text = ""
if mcp_references:
mcp_text = "\n【📚 MCP工具搜索 - 参考资料】\n"
mcp_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
mcp_text += mcp_references
mcp_text += "\n"
# 根据大纲模式添加创作指导
mode_instruction = ""
if outline_mode == 'one-to-one':
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请充分展开大纲中的情节,注重叙事的完整性和丰满度。\n"
else:
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划中的剧情点、角色焦点和情感基调,确保与整体规划保持一致。\n"
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION,
title=title,
theme=theme,
genre=genre,
narrative_perspective=narrative_perspective,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
outlines_context=outlines_context,
chapter_number=chapter_number,
chapter_title=chapter_title,
chapter_outline=chapter_outline,
target_word_count=target_word_count,
max_word_count=max_word_count
)
# 插入记忆上下文和MCP参考资料
insert_text = ""
if memory_text:
insert_text += memory_text
if mcp_text:
insert_text += mcp_text
if insert_text:
base_prompt = base_prompt.replace(
"本章信息:",
insert_text + mode_instruction + "\n\n本章信息:"
)
else:
# 没有记忆和MCP时也要插入模式说明
base_prompt = base_prompt.replace(
"本章信息:",
mode_instruction + "\n\n本章信息:"
)
# 如果有风格要求,应用到提示词中
if style_content:
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
return base_prompt
@classmethod
def get_chapter_generation_with_context_prompt(cls, title: str, theme: str, genre: str,
narrative_perspective: str, time_period: str,
location: str, atmosphere: str, rules: str,
characters_info: str, outlines_context: str,
previous_content: str, chapter_number: int,
chapter_title: str, chapter_outline: str,
style_content: str = "",
target_word_count: int = 3000,
memory_context: dict = None,
mcp_references: str = "",
outline_mode: str = "one-to-many") -> str:
"""
获取章节完整创作提示词(带前置章节上下文和记忆增强)
Args:
style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
"""
# 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
else:
memory_text = "暂无相关记忆"
# 格式化MCP参考资料
if mcp_references:
memory_text += "\n\n【📚 MCP工具搜索 - 参考资料】\n"
memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
memory_text += mcp_references
# 根据大纲模式添加创作指导
mode_instruction = ""
if outline_mode == 'one-to-one':
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请在承接前文的基础上,充分展开大纲中的情节,保持叙事的完整性。\n"
else:
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划(expansion_plan)中的剧情点、角色焦点、情感基调和叙事目标,确保与整体规划保持一致,同时自然衔接前文内容。\n"
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT,
title=title,
theme=theme,
genre=genre,
narrative_perspective=narrative_perspective,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
outlines_context=outlines_context,
previous_content=previous_content,
chapter_number=chapter_number,
chapter_title=chapter_title,
chapter_outline=chapter_outline,
target_word_count=target_word_count,
max_word_count=max_word_count,
memory_context=memory_text
)
# 插入模式说明
base_prompt = base_prompt.replace(
"本章信息:",
mode_instruction + "\n本章信息:"
)
# 如果有风格要求,应用到提示词中
if style_content:
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
return base_prompt
@classmethod
def get_outline_prompt(cls, genre: str, theme: str, target_words: int,
requirements: str = "") -> str:
"""获取大纲生成提示词"""
return cls.format_prompt(
cls.OUTLINE_GENERATION,
genre=genre,
theme=theme,
target_words=target_words,
requirements=requirements or "无特殊要求"
)
@classmethod
def get_outline_continue_prompt(cls, title: str, theme: str, genre: str,
narrative_perspective: str, chapter_count: int,
time_period: str, location: str, atmosphere: str,
rules: str, characters_info: str,
current_chapter_count: int, all_chapters_brief: str,
recent_plot: str, plot_stage_instruction: str,
start_chapter: int, story_direction: str,
requirements: str = "",
memory_context: dict = None,
mcp_references: str = "") -> str:
"""获取大纲续写提示词(支持记忆+MCP增强)"""
end_chapter = start_chapter + chapter_count - 1
# 格式化记忆上下文
memory_text = ""
if memory_context:
memory_text = memory_context.get('recent_context', '')
memory_text += "\n" + memory_context.get('relevant_memories', '')
memory_text += "\n" + memory_context.get('foreshadows', '')
memory_text += "\n" + memory_context.get('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '')
else:
memory_text = "暂无相关记忆(可能是首次续写或记忆库为空)"
# 格式化MCP参考资料
mcp_text = ""
if mcp_references:
mcp_text = "\n\n【📚 MCP工具搜索 - 续写参考资料】\n"
mcp_text += "以下是通过MCP工具搜索到的续写参考资料,可用于丰富情节发展和冲突设计:\n\n"
mcp_text += mcp_references
mcp_text += "\n"
return cls.format_prompt(
cls.OUTLINE_CONTINUE_GENERATION,
title=title,
theme=theme,
genre=genre,
narrative_perspective=narrative_perspective,
chapter_count=chapter_count,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
current_chapter_count=current_chapter_count,
all_chapters_brief=all_chapters_brief,
recent_plot=recent_plot,
plot_stage_instruction=plot_stage_instruction,
start_chapter=start_chapter,
end_chapter=end_chapter,
story_direction=story_direction,
requirements=requirements or "无特殊要求",
memory_context=memory_text,
mcp_references=mcp_text
)
@classmethod
def get_single_character_prompt(cls, project_context: str, user_input: str) -> str:
"""获取单个角色生成提示词"""
return cls.format_prompt(
cls.SINGLE_CHARACTER_GENERATION,
project_context=project_context,
user_input=user_input
)
@classmethod
def get_single_organization_prompt(cls, project_context: str, user_input: str) -> str:
"""获取单个组织生成提示词"""
return cls.format_prompt(
cls.SINGLE_ORGANIZATION_GENERATION,
project_context=project_context,
user_input=user_input
)
@classmethod
def get_outline_expansion_prompt(cls, title: str, genre: str, theme: str,
narrative_perspective: str, time_period: str,
location: str, atmosphere: str, rules: str,
characters_info: str, outline_order: int,
outline_title: str, outline_content: str,
context_info: str, strategy: str = "balanced",
target_chapters: int = 3,
include_scenes: bool = False) -> str:
"""
获取大纲展开为多章节的提示词
Args:
title: 小说名称
genre: 类型
theme: 主题
narrative_perspective: 叙事视角
time_period: 时间背景
location: 地理位置
atmosphere: 氛围基调
rules: 世界规则
characters_info: 角色信息
outline_order: 大纲序号
outline_title: 大纲标题
outline_content: 大纲内容
context_info: 上下文信息
strategy: 展开策略 (balanced/climax/detail)
target_chapters: 目标章节数
include_scenes: 是否包含场景字段
"""
# 根据策略生成指导说明
strategy_instructions = {
"balanced": "采用均衡策略:将大纲内容平均分配到各章节,保持节奏均匀,每章剧情密度相当。",
"climax": "采用高潮重点策略:识别大纲中的高潮部分,为其分配更多章节进行细致展开,其他部分适当精简。",
"detail": "采用细节丰富策略:深挖大纲中的每个细节,为每个关键事件、情感转折都安排足够的叙事空间。"
}
strategy_instruction = strategy_instructions.get(strategy, strategy_instructions["balanced"])
# 场景相关的指令和字段
scene_instruction = ""
scene_field = ""
if include_scenes:
scene_instruction = "\n - scenes: 场景列表(2-4个具体场景描述)"
scene_field = ',\n "scenes": ["场景1", "场景2"]'
return cls.format_prompt(
cls.OUTLINE_EXPANSION,
title=title,
genre=genre,
theme=theme,
narrative_perspective=narrative_perspective,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
outline_order=outline_order,
outline_title=outline_title,
outline_content=outline_content,
context_info=context_info,
strategy_instruction=strategy_instruction,
target_chapters=target_chapters,
scene_instruction=scene_instruction,
scene_field=scene_field
)
@classmethod
def get_plot_analysis_prompt(cls, chapter_number: int, title: str,
content: str, word_count: int) -> str:
"""获取章节剧情分析提示词"""
return cls.format_prompt(
cls.PLOT_ANALYSIS,
chapter_number=chapter_number,
title=title,
content=content,
word_count=word_count
)
@classmethod
def get_plot_expansion_single_batch_prompt(cls, project_title: str, project_genre: str, project_theme: str,
project_narrative_perspective: str, project_world_time_period: str,
project_world_location: str, project_world_atmosphere: str,
characters_info: str, outline_order_index: int, outline_title: str,
outline_content: str, context_info: str, strategy_instruction: str,
target_chapter_count: int, scene_instruction: str, scene_field: str) -> str:
"""获取大纲单批次展开提示词"""
return cls.format_prompt(
cls.PLOT_EXPANSION_SINGLE_BATCH,
project_title=project_title, project_genre=project_genre, project_theme=project_theme,
project_narrative_perspective=project_narrative_perspective, project_world_time_period=project_world_time_period,
project_world_location=project_world_location, project_world_atmosphere=project_world_atmosphere,
characters_info=characters_info, outline_order_index=outline_order_index, outline_title=outline_title,
outline_content=outline_content, context_info=context_info, strategy_instruction=strategy_instruction,
target_chapter_count=target_chapter_count, scene_instruction=scene_instruction, scene_field=scene_field
)
@classmethod
def get_plot_expansion_multi_batch_prompt(cls, project_title: str, project_genre: str, project_theme: str,
project_narrative_perspective: str, project_world_time_period: str,
project_world_location: str, project_world_atmosphere: str,
characters_info: str, outline_order_index: int, outline_title: str,
outline_content: str, context_info: str, previous_context: str,
strategy_instruction: str, start_index: int, end_index: int,
target_chapter_count: int, scene_instruction: str, scene_field: str) -> str:
"""获取大纲分批展开提示词"""
return cls.format_prompt(
cls.PLOT_EXPANSION_MULTI_BATCH,
project_title=project_title, project_genre=project_genre, project_theme=project_theme,
project_narrative_perspective=project_narrative_perspective, project_world_time_period=project_world_time_period,
project_world_location=project_world_location, project_world_atmosphere=project_world_atmosphere,
characters_info=characters_info, outline_order_index=outline_order_index, outline_title=outline_title,
outline_content=outline_content, context_info=context_info, previous_context=previous_context,
strategy_instruction=strategy_instruction, start_index=start_index, end_index=end_index,
target_chapter_count=target_chapter_count, scene_instruction=scene_instruction, scene_field=scene_field
)
@classmethod
async def get_chapter_regeneration_prompt(cls, chapter_number: int, title: str, word_count: int, content: str,
@@ -2410,14 +1915,14 @@ class PromptService:
"description": "生成组织/势力的详细设定",
"parameters": ["project_context", "user_input"]
},
"COMPLETE_OUTLINE_GENERATION": {
"name": "完整大纲生成",
"OUTLINE_CREATE": {
"name": "初始大纲生成",
"category": "大纲生成",
"description": "根据项目信息生成完整的章节大纲",
"parameters": ["title", "theme", "genre", "chapter_count", "narrative_perspective", "target_words",
"time_period", "location", "atmosphere", "rules", "characters_info", "requirements", "mcp_references"]
},
"OUTLINE_CONTINUE_GENERATION": {
"OUTLINE_CONTINUE": {
"name": "大纲续写",
"category": "大纲生成",
"description": "基于已有章节续写大纲",
@@ -2477,7 +1982,7 @@ class PromptService:
"description": "深度分析章节的剧情、钩子、伏笔等",
"parameters": ["chapter_number", "title", "content", "word_count"]
},
"PLOT_EXPANSION_SINGLE_BATCH": {
"OUTLINE_EXPAND_SINGLE": {
"name": "大纲单批次展开",
"category": "情节展开",
"description": "将大纲节点展开为详细章节规划(单批次)",
@@ -2486,7 +1991,7 @@ class PromptService:
"characters_info", "outline_order_index", "outline_title", "outline_content",
"context_info", "strategy_instruction", "target_chapter_count", "scene_instruction", "scene_field"]
},
"PLOT_EXPANSION_MULTI_BATCH": {
"OUTLINE_EXPAND_MULTI": {
"name": "大纲分批展开",
"category": "情节展开",
"description": "将大纲节点展开为详细章节规划(分批)",