From 24b0a09b43e9f50360a8c1685bf44b8d808ad339 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Sun, 14 Dec 2025 15:21:52 +0800 Subject: [PATCH] =?UTF-8?q?update:1.=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E7=9A=84JSON=E6=B8=85=E6=B4=97=E5=92=8C=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E6=96=B9=E6=B3=95=EF=BC=8C=E9=81=BF=E5=85=8DAI=E5=93=8D?= =?UTF-8?q?=E5=BA=94json=E6=A0=BC=E5=BC=8F=E9=94=99=E8=AF=AF=202.=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=A8=A1=E6=9D=BF=E5=91=BD?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E4=BC=98=E5=8C=96=E5=A4=A7=E7=BA=B2=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E5=88=9D=E5=A7=8B=E5=8C=96=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=203.=E7=A7=BB=E9=99=A4=E5=B8=83=E5=86=AF=E5=86=97=E4=BD=99?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=8C=E6=8F=90=E9=AB=98=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=80=A7=204.=E4=BC=98=E5=8C=96=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=BB=98=E8=AE=A4=E5=86=99=E4=BD=9C=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=92=8C=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/characters.py | 421 +--------- backend/app/api/inspiration.py | 34 +- backend/app/api/organizations.py | 208 +---- backend/app/api/outlines.py | 412 +--------- backend/app/api/wizard_stream.py | 59 +- backend/app/services/ai_service.py | 485 +++++++++-- .../app/services/auto_character_service.py | 47 +- backend/app/services/mcp_test_service.py | 4 +- backend/app/services/plot_analyzer.py | 23 +- .../app/services/plot_expansion_service.py | 34 +- backend/app/services/prompt_service.py | 757 +++--------------- 11 files changed, 633 insertions(+), 1851 deletions(-) diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index b6ab8d6..30fabc4 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -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 diff --git a/backend/app/api/inspiration.py b/backend/app/api/inspiration.py index 625917c..67ec412 100644 --- a/backend/app/api/inspiration.py +++ b/backend/app/api/inspiration.py @@ -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) diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 20d9745..63e1c4f 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -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 diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 16c4010..9f1bd3f 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -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, diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index 9bc6473..7fec2d6 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -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}") diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 656902c..569a195 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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服务实例 diff --git a/backend/app/services/auto_character_service.py b/backend/app/services/auto_character_service.py index e4cbc89..714abd0 100644 --- a/backend/app/services/auto_character_service.py +++ b/backend/app/services/auto_character_service.py @@ -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())}") diff --git a/backend/app/services/mcp_test_service.py b/backend/app/services/mcp_test_service.py index 098c64b..bb88c99 100644 --- a/backend/app/services/mcp_test_service.py +++ b/backend/app/services/mcp_test_service.py @@ -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( diff --git a/backend/app/services/plot_analyzer.py b/backend/app/services/plot_analyzer.py index 6178de0..f5866c2 100644 --- a/backend/app/services/plot_analyzer.py +++ b/backend/app/services/plot_analyzer.py @@ -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)}") diff --git a/backend/app/services/plot_expansion_service.py b/backend/app/services/plot_expansion_service.py index 8c31b17..07eb5e1 100644 --- a/backend/app/services/plot_expansion_service.py +++ b/backend/app/services/plot_expansion_service.py @@ -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( diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 6bc92be..906b6ba 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -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": "将大纲节点展开为详细章节规划(分批)",