update: 1.新增职业管理模块和角色职业关联 2.章节分析自动更新角色职业状态 3.优化章节生成的角色信息构建 4.批量生成强制开启同步分析 5.章节内容批量生成增加系统提示

This commit is contained in:
xiamuceer
2025-12-22 19:53:31 +08:00
parent 6886d903fe
commit b2dec41464
25 changed files with 4635 additions and 89 deletions
+909
View File
@@ -0,0 +1,909 @@
"""职业管理API"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import json
from typing import AsyncGenerator
from app.database import get_db
from app.utils.sse_response import SSEResponse, create_sse_response
from app.models.career import Career, CharacterCareer
from app.models.character import Character
from app.models.project import Project
from app.schemas.career import (
CareerCreate,
CareerUpdate,
CareerResponse,
CareerListResponse,
CareerGenerateRequest,
CharacterCareerResponse,
CharacterCareerDetail,
SetMainCareerRequest,
AddSubCareerRequest,
UpdateCareerStageRequest,
CareerStage
)
from app.services.ai_service import AIService
from app.logger import get_logger
from app.api.settings import get_user_ai_service
router = APIRouter(prefix="/careers", tags=["职业管理"])
logger = get_logger(__name__)
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""验证用户是否有权访问指定项目"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
@router.get("", response_model=CareerListResponse, summary="获取职业列表")
async def get_careers(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""获取指定项目的所有职业"""
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(project_id, user_id, db)
# 获取总数
count_result = await db.execute(
select(func.count(Career.id)).where(Career.project_id == project_id)
)
total = count_result.scalar_one()
# 获取职业列表
result = await db.execute(
select(Career)
.where(Career.project_id == project_id)
.order_by(Career.type, Career.created_at.desc())
)
careers = result.scalars().all()
# 分类返回
main_careers = []
sub_careers = []
for career in careers:
# 解析JSON字段
stages = json.loads(career.stages) if career.stages else []
attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
career_dict = {
"id": career.id,
"project_id": career.project_id,
"name": career.name,
"type": career.type,
"description": career.description,
"category": career.category,
"stages": stages,
"max_stage": career.max_stage,
"requirements": career.requirements,
"special_abilities": career.special_abilities,
"worldview_rules": career.worldview_rules,
"attribute_bonuses": attribute_bonuses,
"source": career.source,
"created_at": career.created_at,
"updated_at": career.updated_at
}
if career.type == "main":
main_careers.append(career_dict)
else:
sub_careers.append(career_dict)
return CareerListResponse(
total=total,
main_careers=main_careers,
sub_careers=sub_careers
)
@router.post("", response_model=CareerResponse, summary="创建职业")
async def create_career(
career_data: CareerCreate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""手动创建职业"""
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(career_data.project_id, user_id, db)
try:
# 转换stages为JSON字符串
stages_json = json.dumps([stage.model_dump() for stage in career_data.stages], ensure_ascii=False)
attribute_bonuses_json = json.dumps(career_data.attribute_bonuses, ensure_ascii=False) if career_data.attribute_bonuses else None
# 创建职业
career = Career(
project_id=career_data.project_id,
name=career_data.name,
type=career_data.type,
description=career_data.description,
category=career_data.category,
stages=stages_json,
max_stage=career_data.max_stage,
requirements=career_data.requirements,
special_abilities=career_data.special_abilities,
worldview_rules=career_data.worldview_rules,
attribute_bonuses=attribute_bonuses_json,
source=career_data.source
)
db.add(career)
await db.commit()
await db.refresh(career)
logger.info(f"✅ 创建职业成功:{career.name} (ID: {career.id}, 类型: {career.type})")
return CareerResponse(
id=career.id,
project_id=career.project_id,
name=career.name,
type=career.type,
description=career.description,
category=career.category,
stages=career_data.stages,
max_stage=career.max_stage,
requirements=career.requirements,
special_abilities=career.special_abilities,
worldview_rules=career.worldview_rules,
attribute_bonuses=career_data.attribute_bonuses,
source=career.source,
created_at=career.created_at,
updated_at=career.updated_at
)
except Exception as e:
logger.error(f"创建职业失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"创建职业失败: {str(e)}")
@router.get("/generate-system", summary="AI生成新职业(增量式,流式)")
async def generate_career_system(
project_id: str,
main_career_count: int = 3,
sub_career_count: int = 6,
enable_mcp: bool = False,
http_request: Request = None,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用AI生成新职业(增量式,基于已有职业补充,支持SSE流式进度显示)
通过Server-Sent Events返回实时进度信息
"""
async def generate() -> AsyncGenerator[str, None]:
try:
# 验证用户权限和项目是否存在
user_id = getattr(http_request.state, 'user_id', None)
project = await verify_project_access(project_id, user_id, db)
yield await SSEResponse.send_progress("开始生成新职业...", 0)
# 获取已有职业列表
yield await SSEResponse.send_progress("分析已有职业...", 5)
existing_careers_result = await db.execute(
select(Career).where(Career.project_id == project_id)
)
existing_careers = existing_careers_result.scalars().all()
# 构建已有职业摘要
existing_main_careers = []
existing_sub_careers = []
for career in existing_careers:
career_summary = f"- {career.name}{career.category or '未分类'}{career.max_stage}阶)"
if career.description:
career_summary += f": {career.description[:50]}"
if career.type == "main":
existing_main_careers.append(career_summary)
else:
existing_sub_careers.append(career_summary)
existing_careers_text = ""
if existing_main_careers:
existing_careers_text += f"\n已有主职业({len(existing_main_careers)}个):\n" + "\n".join(existing_main_careers)
if existing_sub_careers:
existing_careers_text += f"\n\n已有副职业({len(existing_sub_careers)}个):\n" + "\n".join(existing_sub_careers)
if not existing_careers_text:
existing_careers_text = "\n当前还没有任何职业,这是第一次创建职业体系。"
# 构建项目上下文
yield await SSEResponse.send_progress("分析项目世界观...", 15)
project_context = f"""
项目信息:
- 书名:{project.title}
- 类型:{project.genre or '未设定'}
- 主题:{project.theme or '未设定'}
- 时间背景:{project.world_time_period or '未设定'}
- 地理位置:{project.world_location or '未设定'}
- 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'}
"""
user_requirements = f"""
已有职业情况:{existing_careers_text}
生成要求(增量式):
- 本次新增主职业:{main_career_count}
- 本次新增副职业:{sub_career_count}
- ⚠️ 重要:请生成与已有职业**不重复**的新职业,形成互补体系
- 新职业应填补已有职业体系的空缺,丰富职业多样性
- 主职业必须严格符合世界观规则,体现核心能力体系
- 副职业可以更加自由灵活,包含生产、辅助、特殊类型
"""
yield await SSEResponse.send_progress("构建AI提示词...", 20)
# 构建提示词
prompt = f"""{project_context}
{user_requirements}
请为这个小说项目生成新的补充职业(增量式)。要求:
1. **仔细分析已有职业**,避免生成重复或相似的职业
2. **填补职业体系的空缺**,让职业体系更加完善和多样化
3. 如果已有职业较少,可以生成核心基础职业
4. 如果已有职业较多,可以生成特色化、专精化的职业
返回JSON格式,结构如下:
{{
"main_careers": [
{{
"name": "职业名称",
"description": "职业描述",
"category": "职业分类(如:战斗系、法术系等)",
"stages": [
{{"level": 1, "name": "阶段名称", "description": "阶段描述"}},
{{"level": 2, "name": "阶段名称", "description": "阶段描述"}},
...
],
"max_stage": 10,
"requirements": "职业要求",
"special_abilities": "特殊能力",
"worldview_rules": "世界观规则关联",
"attribute_bonuses": {{"strength": "+10%", "intelligence": "+5%"}}
}}
],
"sub_careers": [
{{
"name": "副职业名称",
"description": "职业描述",
"category": "生产系/辅助系/特殊系",
"stages": [...],
"max_stage": 5,
"requirements": "职业要求",
"special_abilities": "特殊能力"
}}
]
}}
注意事项:
1. **避免重复**:生成的职业名称和定位不能与已有职业重复
2. **互补性**:新职业应与已有职业形成互补,丰富职业体系
3. 主职业的阶段设定要详细,体现明确的成长路径
4. 阶段名称要符合世界观特色
5. 副职业可以相对简化,但要有独特性
6. 所有职业都要符合项目的整体世界观设定
7. 只返回纯JSON,不要添加任何解释文字
"""
yield await SSEResponse.send_progress("调用AI生成新职业...", 30)
logger.info(f"🎯 开始为项目 {project_id} 生成新职业(增量式,已有{len(existing_careers)}个职业)")
try:
# 调用AI生成
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)}")
yield await SSEResponse.send_error(f"AI服务调用失败:{str(ai_error)}")
return
if not ai_response or not ai_response.strip():
yield await SSEResponse.send_error("AI服务返回空响应")
return
yield await SSEResponse.send_progress("解析AI响应...", 50)
# 清洗并解析JSON
try:
cleaned_response = user_ai_service._clean_json_response(ai_response)
career_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
yield await SSEResponse.send_progress("保存主职业...", 60)
# 保存主职业
main_careers_created = []
for idx, career_info in enumerate(career_data.get("main_careers", [])):
try:
stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
attribute_bonuses = career_info.get("attribute_bonuses")
attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
career = Career(
project_id=project_id,
name=career_info.get("name", f"未命名主职业{idx+1}"),
type="main",
description=career_info.get("description"),
category=career_info.get("category"),
stages=stages_json,
max_stage=career_info.get("max_stage", 10),
requirements=career_info.get("requirements"),
special_abilities=career_info.get("special_abilities"),
worldview_rules=career_info.get("worldview_rules"),
attribute_bonuses=attribute_bonuses_json,
source="ai"
)
db.add(career)
await db.flush()
main_careers_created.append(career.name)
logger.info(f" ✅ 创建主职业:{career.name}")
except Exception as e:
logger.error(f" ❌ 创建主职业失败:{str(e)}")
continue
yield await SSEResponse.send_progress("保存副职业...", 80)
# 保存副职业
sub_careers_created = []
for idx, career_info in enumerate(career_data.get("sub_careers", [])):
try:
stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
attribute_bonuses = career_info.get("attribute_bonuses")
attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
career = Career(
project_id=project_id,
name=career_info.get("name", f"未命名副职业{idx+1}"),
type="sub",
description=career_info.get("description"),
category=career_info.get("category"),
stages=stages_json,
max_stage=career_info.get("max_stage", 5),
requirements=career_info.get("requirements"),
special_abilities=career_info.get("special_abilities"),
worldview_rules=career_info.get("worldview_rules"),
attribute_bonuses=attribute_bonuses_json,
source="ai"
)
db.add(career)
await db.flush()
sub_careers_created.append(career.name)
logger.info(f" ✅ 创建副职业:{career.name}")
except Exception as e:
logger.error(f" ❌ 创建副职业失败:{str(e)}")
continue
await db.commit()
total_main = len(existing_main_careers) + len(main_careers_created)
total_sub = len(existing_sub_careers) + len(sub_careers_created)
logger.info(f"🎉 新职业生成完成:新增主职业{len(main_careers_created)}个,新增副职业{len(sub_careers_created)}")
logger.info(f" 职业体系总数:主职业{total_main}个,副职业{total_sub}")
yield await SSEResponse.send_progress(f"新职业生成完成!(主职业{total_main}个,副职业{total_sub}个)", 100, "success")
# 发送结果数据
yield await SSEResponse.send_result({
"main_careers_count": len(main_careers_created),
"sub_careers_count": len(sub_careers_created),
"main_careers": main_careers_created,
"sub_careers": sub_careers_created
})
yield await SSEResponse.send_done()
except HTTPException as he:
logger.error(f"HTTP异常: {he.detail}")
yield await SSEResponse.send_error(he.detail, he.status_code)
except Exception as e:
logger.error(f"生成职业体系失败: {str(e)}")
yield await SSEResponse.send_error(f"生成新职业失败: {str(e)}")
return create_sse_response(generate())
@router.put("/{career_id}", response_model=CareerResponse, summary="更新职业")
async def update_career(
career_id: str,
career_update: CareerUpdate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""更新职业信息"""
result = await db.execute(
select(Career).where(Career.id == career_id)
)
career = result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=404, detail="职业不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(career.project_id, user_id, db)
# 更新字段
update_data = career_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == "stages" and value is not None:
# 转换为JSON字符串
setattr(career, field, json.dumps([stage.model_dump() for stage in value], ensure_ascii=False))
elif field == "attribute_bonuses" and value is not None:
# 转换为JSON字符串
setattr(career, field, json.dumps(value, ensure_ascii=False))
else:
setattr(career, field, value)
await db.commit()
await db.refresh(career)
logger.info(f"✅ 更新职业成功:{career.name} (ID: {career_id})")
# 解析JSON返回
stages = json.loads(career.stages) if career.stages else []
attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
return CareerResponse(
id=career.id,
project_id=career.project_id,
name=career.name,
type=career.type,
description=career.description,
category=career.category,
stages=stages,
max_stage=career.max_stage,
requirements=career.requirements,
special_abilities=career.special_abilities,
worldview_rules=career.worldview_rules,
attribute_bonuses=attribute_bonuses,
source=career.source,
created_at=career.created_at,
updated_at=career.updated_at
)
@router.delete("/{career_id}", summary="删除职业")
async def delete_career(
career_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""删除职业"""
result = await db.execute(
select(Career).where(Career.id == career_id)
)
career = result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=404, detail="职业不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(career.project_id, user_id, db)
# 检查是否有角色使用该职业
char_career_result = await db.execute(
select(func.count(CharacterCareer.id)).where(CharacterCareer.career_id == career_id)
)
usage_count = char_career_result.scalar_one()
if usage_count > 0:
raise HTTPException(
status_code=400,
detail=f"该职业被{usage_count}个角色使用,无法删除。请先移除角色的职业关联。"
)
await db.delete(career)
await db.commit()
logger.info(f"✅ 删除职业成功:{career.name} (ID: {career_id})")
return {"message": "职业删除成功"}
@router.get("/{career_id}", response_model=CareerResponse, summary="获取职业详情")
async def get_career(
career_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""根据ID获取职业详情"""
result = await db.execute(
select(Career).where(Career.id == career_id)
)
career = result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=404, detail="职业不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(career.project_id, user_id, db)
# 解析JSON字段
stages = json.loads(career.stages) if career.stages else []
attribute_bonuses = json.loads(career.attribute_bonuses) if career.attribute_bonuses else None
return CareerResponse(
id=career.id,
project_id=career.project_id,
name=career.name,
type=career.type,
description=career.description,
category=career.category,
stages=stages,
max_stage=career.max_stage,
requirements=career.requirements,
special_abilities=career.special_abilities,
worldview_rules=career.worldview_rules,
attribute_bonuses=attribute_bonuses,
source=career.source,
created_at=career.created_at,
updated_at=career.updated_at
)
# ===== 角色职业关联API =====
@router.get("/character/{character_id}/careers", response_model=CharacterCareerResponse, summary="获取角色的职业信息")
async def get_character_careers(
character_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""获取角色的所有职业信息(主职业和副职业)"""
# 验证角色存在
char_result = await db.execute(
select(Character).where(Character.id == character_id)
)
character = char_result.scalar_one_or_none()
if not character:
raise HTTPException(status_code=404, detail="角色不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character.project_id, user_id, db)
# 获取角色的所有职业关联
result = await db.execute(
select(CharacterCareer, Career)
.join(Career, CharacterCareer.career_id == Career.id)
.where(CharacterCareer.character_id == character_id)
.order_by(CharacterCareer.career_type.desc()) # main排在前
)
career_relations = result.all()
main_career = None
sub_careers = []
for char_career, career in career_relations:
# 解析职业的阶段信息
stages = json.loads(career.stages) if career.stages else []
# 找到当前阶段信息
stage_name = "未知阶段"
stage_description = None
for stage in stages:
if stage.get("level") == char_career.current_stage:
stage_name = stage.get("name", f"{char_career.current_stage}阶段")
stage_description = stage.get("description")
break
career_detail = CharacterCareerDetail(
id=char_career.id,
character_id=char_career.character_id,
career_id=char_career.career_id,
career_name=career.name,
career_type=char_career.career_type,
current_stage=char_career.current_stage,
stage_name=stage_name,
stage_description=stage_description,
stage_progress=char_career.stage_progress,
max_stage=career.max_stage,
started_at=char_career.started_at,
reached_current_stage_at=char_career.reached_current_stage_at,
notes=char_career.notes,
created_at=char_career.created_at,
updated_at=char_career.updated_at
)
if char_career.career_type == "main":
main_career = career_detail
else:
sub_careers.append(career_detail)
return CharacterCareerResponse(
main_career=main_career,
sub_careers=sub_careers
)
@router.post("/character/{character_id}/careers/main", summary="设置角色主职业")
async def set_main_career(
character_id: str,
career_request: SetMainCareerRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""设置或更换角色的主职业"""
# 验证角色存在
char_result = await db.execute(
select(Character).where(Character.id == character_id)
)
character = char_result.scalar_one_or_none()
if not character:
raise HTTPException(status_code=404, detail="角色不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character.project_id, user_id, db)
# 验证职业存在且为主职业类型
career_result = await db.execute(
select(Career).where(
Career.id == career_request.career_id,
Career.project_id == character.project_id
)
)
career = career_result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=404, detail="职业不存在")
if career.type != "main":
raise HTTPException(status_code=400, detail="该职业不是主职业类型,无法设置为主职业")
# 验证阶段有效性
if career_request.current_stage > career.max_stage:
raise HTTPException(
status_code=400,
detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
)
# 检查是否已有主职业
existing_main = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == "main"
)
)
current_main = existing_main.scalar_one_or_none()
if current_main:
# 删除旧的主职业
await db.delete(current_main)
logger.info(f" 移除旧主职业关联: {current_main.career_id}")
# 创建新的主职业关联
char_career = CharacterCareer(
character_id=character_id,
career_id=career_request.career_id,
career_type="main",
current_stage=career_request.current_stage,
stage_progress=0,
started_at=career_request.started_at,
reached_current_stage_at=career_request.started_at
)
db.add(char_career)
await db.commit()
logger.info(f"✅ 设置主职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)")
return {"message": "主职业设置成功", "career_name": career.name}
@router.post("/character/{character_id}/careers/sub", summary="添加角色副职业")
async def add_sub_career(
character_id: str,
career_request: AddSubCareerRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""为角色添加副职业"""
# 验证角色存在
char_result = await db.execute(
select(Character).where(Character.id == character_id)
)
character = char_result.scalar_one_or_none()
if not character:
raise HTTPException(status_code=404, detail="角色不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character.project_id, user_id, db)
# 验证职业存在且为副职业类型
career_result = await db.execute(
select(Career).where(
Career.id == career_request.career_id,
Career.project_id == character.project_id
)
)
career = career_result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=404, detail="职业不存在")
if career.type != "sub":
raise HTTPException(status_code=400, detail="该职业不是副职业类型,无法添加为副职业")
# 验证阶段有效性
if career_request.current_stage > career.max_stage:
raise HTTPException(
status_code=400,
detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
)
# 检查是否已存在
existing_check = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_id == career_request.career_id
)
)
if existing_check.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该角色已拥有此副职业")
# 检查副职业数量限制(可选,这里设置为最多5个)
sub_count_result = await db.execute(
select(func.count(CharacterCareer.id)).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == "sub"
)
)
sub_count = sub_count_result.scalar_one()
if sub_count >= 5:
raise HTTPException(status_code=400, detail="副职业数量已达上限(最多5个)")
# 创建副职业关联
char_career = CharacterCareer(
character_id=character_id,
career_id=career_request.career_id,
career_type="sub",
current_stage=career_request.current_stage,
stage_progress=0,
started_at=career_request.started_at,
reached_current_stage_at=career_request.started_at
)
db.add(char_career)
await db.commit()
logger.info(f"✅ 添加副职业成功:角色{character.name} -> {career.name}(第{career_request.current_stage}阶段)")
return {"message": "副职业添加成功", "career_name": career.name}
@router.put("/character/{character_id}/careers/{career_id}/stage", summary="更新职业阶段")
async def update_career_stage(
character_id: str,
career_id: str,
stage_request: UpdateCareerStageRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""更新角色在某个职业的阶段"""
# 验证角色职业关联存在
result = await db.execute(
select(CharacterCareer, Career, Character)
.join(Career, CharacterCareer.career_id == Career.id)
.join(Character, CharacterCareer.character_id == Character.id)
.where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_id == career_id
)
)
relation_data = result.one_or_none()
if not relation_data:
raise HTTPException(status_code=404, detail="角色职业关联不存在")
char_career, career, character = relation_data
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character.project_id, user_id, db)
# 验证新阶段有效性
if stage_request.current_stage > career.max_stage:
raise HTTPException(
status_code=400,
detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}"
)
# 验证阶段递增规则(不能倒退,除非降级)
if stage_request.current_stage < char_career.current_stage:
logger.warning(f"⚠️ 角色{character.name}的职业{career.name}阶段降低:{char_career.current_stage} -> {stage_request.current_stage}")
# 更新阶段信息
char_career.current_stage = stage_request.current_stage
char_career.stage_progress = stage_request.stage_progress
if stage_request.reached_current_stage_at:
char_career.reached_current_stage_at = stage_request.reached_current_stage_at
if stage_request.notes is not None:
char_career.notes = stage_request.notes
await db.commit()
logger.info(f"✅ 更新职业阶段成功:{character.name}{career.name} -> 第{stage_request.current_stage}阶段")
return {
"message": "职业阶段更新成功",
"career_name": career.name,
"new_stage": stage_request.current_stage
}
@router.delete("/character/{character_id}/careers/{career_id}", summary="删除角色副职业")
async def remove_sub_career(
character_id: str,
career_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""删除角色的副职业"""
# 验证角色职业关联存在
result = await db.execute(
select(CharacterCareer, Character)
.join(Character, CharacterCareer.character_id == Character.id)
.where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_id == career_id
)
)
relation_data = result.one_or_none()
if not relation_data:
raise HTTPException(status_code=404, detail="角色职业关联不存在")
char_career, character = relation_data
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character.project_id, user_id, db)
# 不允许删除主职业
if char_career.career_type == "main":
raise HTTPException(status_code=400, detail="无法删除主职业,只能更换")
await db.delete(char_career)
await db.commit()
logger.info(f"✅ 删除副职业成功:角色{character.name}移除职业{career_id}")
return {"message": "副职业删除成功"}
+341 -39
View File
@@ -14,6 +14,7 @@ from app.models.chapter import Chapter
from app.models.project import Project
from app.models.outline import Outline
from app.models.character import Character
from app.models.career import Career, CharacterCareer
from app.models.generation_history import GenerationHistory
from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask
@@ -665,6 +666,114 @@ async def build_smart_chapter_context(
return context_parts
async def build_characters_info_with_careers(
db: AsyncSession,
project_id: str,
characters: list[Character],
filter_character_names: Optional[list[str]] = None
) -> str:
"""
构建包含职业信息的角色上下文
Args:
db: 数据库会话
project_id: 项目ID
characters: 角色列表
filter_character_names: 可选,筛选特定角色名称列表(用于1-1模式的structure.characters或1-n模式的expansion_plan.character_focus
Returns:
格式化的角色信息字符串,包含职业信息
"""
if not characters:
return '暂无角色信息'
# 如果提供了筛选名单,只保留匹配的角色
if filter_character_names:
filtered_characters = [c for c in characters if c.name in filter_character_names]
if not filtered_characters:
logger.warning(f"筛选后无匹配角色,使用全部角色。筛选名单: {filter_character_names}")
filtered_characters = characters
else:
logger.info(f"根据筛选名单保留 {len(filtered_characters)}/{len(characters)} 个角色: {[c.name for c in filtered_characters]}")
characters = filtered_characters
# 获取所有职业信息(一次性查询,提高效率)
careers_result = await db.execute(
select(Career).where(Career.project_id == project_id)
)
careers_map = {c.id: c for c in careers_result.scalars().all()}
# 获取所有角色的职业关联(一次性查询)
character_ids = [c.id for c in characters]
if not character_ids:
return '暂无角色信息'
character_careers_result = await db.execute(
select(CharacterCareer).where(CharacterCareer.character_id.in_(character_ids))
)
character_careers = character_careers_result.scalars().all()
# 构建角色ID到职业信息的映射
char_career_map = {}
for cc in character_careers:
if cc.character_id not in char_career_map:
char_career_map[cc.character_id] = {'main': None, 'sub': []}
career = careers_map.get(cc.career_id)
if not career:
continue
career_info = {
'name': career.name,
'stage': cc.current_stage,
'max_stage': career.max_stage,
'stage_progress': cc.stage_progress
}
if cc.career_type == 'main':
char_career_map[cc.character_id]['main'] = career_info
else:
char_career_map[cc.character_id]['sub'].append(career_info)
# 构建角色信息字符串
characters_info_parts = []
for c in characters:
# 基本信息
entity_type = '组织' if c.is_organization else '角色'
base_info = f"- {c.name}({entity_type}, {c.role_type})"
# 职业信息
career_info_str = ""
if c.id in char_career_map:
career_data = char_career_map[c.id]
# 主职业
if career_data['main']:
main = career_data['main']
stage_desc = f"{main['stage']}/{main['max_stage']}"
career_info_str += f" | 主职业: {main['name']}({stage_desc})"
# 副职业
if career_data['sub']:
sub_list = []
for sub in career_data['sub']:
stage_desc = f"{sub['stage']}/{sub['max_stage']}"
sub_list.append(f"{sub['name']}({stage_desc})")
career_info_str += f" | 副职业: {', '.join(sub_list)}"
# 性格描述
personality_str = ""
if c.personality:
personality_preview = c.personality[:100] if len(c.personality) > 100 else c.personality
personality_str = f": {personality_preview}"
# 组合完整信息
full_info = base_info + career_info_str + personality_str
characters_info_parts.append(full_info)
return "\n".join(characters_info_parts)
@router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成")
async def check_can_generate(
chapter_id: str,
@@ -716,7 +825,7 @@ async def analyze_chapter_background(
project_id: str,
task_id: str,
ai_service: AIService
):
) -> bool:
"""
后台异步分析章节(支持并发,使用锁保护数据库写入)
@@ -726,6 +835,9 @@ async def analyze_chapter_background(
project_id: 项目ID
task_id: 任务ID
ai_service: AI服务实例
Returns:
bool: True表示分析成功,False表示分析失败
"""
db_session = None
write_lock = await get_db_write_lock(user_id)
@@ -942,6 +1054,37 @@ async def analyze_chapter_background(
)
logger.info(f"✅ 添加{added_count}条记忆到向量库")
# 💼 更新角色职业(根据分析结果)
if analysis_result.get('character_states'):
try:
from app.services.career_update_service import CareerUpdateService
logger.info(f"💼 开始根据分析结果更新角色职业...")
career_update_result = await CareerUpdateService.update_careers_from_analysis(
db=db_session,
project_id=project_id,
character_states=analysis_result.get('character_states', []),
chapter_id=chapter_id,
chapter_number=chapter.chapter_number
)
if career_update_result['updated_count'] > 0:
logger.info(
f"✅ 更新了 {career_update_result['updated_count']} 个角色的职业信息: "
f"{', '.join(career_update_result['updated_characters'])}"
)
if career_update_result['changes']:
for change in career_update_result['changes']:
logger.info(f" - {change}")
else:
logger.info("️ 本章节无角色职业变化")
except Exception as career_error:
# 职业更新失败不应影响整个分析流程
logger.error(f"⚠️ 更新角色职业失败: {str(career_error)}", exc_info=True)
else:
logger.debug("📋 分析结果中无角色状态信息,跳过职业更新")
# 最终更新任务状态(写操作,需要锁)- 增加重试机制
update_success = False
for retry in range(3):
@@ -965,6 +1108,9 @@ async def analyze_chapter_background(
if not update_success:
logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}")
# 返回成功状态
return True
except Exception as e:
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
# 确保任务状态被更新为failed(写操作,需要锁)
@@ -995,6 +1141,10 @@ async def analyze_chapter_background(
await asyncio.sleep(0.1) # 短暂等待后重试
else:
logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}")
# 返回失败状态
return False
finally:
if db_session:
await db_session.close()
@@ -1108,15 +1258,41 @@ async def generate_chapter_content_stream(
for o in all_outlines
])
# 获取角色信息
# 获取角色信息(包含职业信息)
characters_result = await db_session.execute(
select(Character).where(Character.project_id == current_chapter.project_id)
)
characters = characters_result.scalars().all()
characters_info = "\n".join([
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
for c in characters
])
# 📝 根据大纲模式智能筛选相关角色
filter_character_names = None
if outline_mode == 'one-to-one':
# 1-1模式:从outline.structure中提取characters字段
if outline and outline.structure:
try:
structure = json.loads(outline.structure)
filter_character_names = structure.get('characters', [])
if filter_character_names:
logger.info(f"📋 1-1模式:从structure提取角色列表 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ outline.structure解析失败,使用全部角色")
else:
# 1-n模式:从chapter.expansion_plan中提取character_focus字段
if current_chapter.expansion_plan:
try:
plan = json.loads(current_chapter.expansion_plan)
filter_character_names = plan.get('character_focus', [])
if filter_character_names:
logger.info(f"📋 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ expansion_plan解析失败,使用全部角色")
characters_info = await build_characters_info_with_careers(
db=db_session,
project_id=current_chapter.project_id,
characters=characters,
filter_character_names=filter_character_names
)
# 获取写作风格
style_content = ""
@@ -2325,28 +2501,83 @@ async def execute_batch_generation_in_order(
if task.enable_analysis:
logger.info(f"🔍 开始同步分析章节: 第{chapter.chapter_number}")
async with write_lock:
analysis_task = AnalysisTask(
chapter_id=chapter_id,
user_id=user_id,
project_id=task.project_id,
status='pending',
progress=0
)
db_session.add(analysis_task)
await db_session.commit()
await db_session.refresh(analysis_task)
# 分析重试机制(最多3次)
analysis_retry_count = 0
analysis_success = False
last_analysis_error = None
# 同步执行分析(等待完成)
await analyze_chapter_background(
chapter_id=chapter_id,
user_id=user_id,
project_id=task.project_id,
task_id=analysis_task.id,
ai_service=ai_service
)
logger.info(f"✅ 章节分析完成: 第{chapter.chapter_number}")
while analysis_retry_count < 3 and not analysis_success:
try:
if analysis_retry_count > 0:
logger.info(f"🔄 重试分析章节 (第{analysis_retry_count}次): 第{chapter.chapter_number}")
async with write_lock:
analysis_task = AnalysisTask(
chapter_id=chapter_id,
user_id=user_id,
project_id=task.project_id,
status='pending',
progress=0
)
db_session.add(analysis_task)
await db_session.commit()
await db_session.refresh(analysis_task)
# 同步执行分析,直接使用返回值判断成功/失败
analysis_result = await analyze_chapter_background(
chapter_id=chapter_id,
user_id=user_id,
project_id=task.project_id,
task_id=analysis_task.id,
ai_service=ai_service
)
# 直接根据返回值判断
if not analysis_result:
last_analysis_error = "分析函数返回失败"
logger.error(f"❌ 章节分析失败: 第{chapter.chapter_number}")
raise Exception(f"章节分析失败")
# 分析成功
analysis_success = True
logger.info(f"✅ 章节分析成功: 第{chapter.chapter_number}")
except Exception as analysis_error:
last_analysis_error = str(analysis_error)
analysis_retry_count += 1
if analysis_retry_count < 3:
# 还有重试机会,等待后重试
wait_time = min(2 ** analysis_retry_count, 10)
logger.warning(f"⏳ 分析失败,等待 {wait_time} 秒后重试...")
await asyncio.sleep(wait_time)
else:
# 达到最大重试次数,必须终止整个批量任务
logger.error(f"❌ 章节分析失败,已达最大重试次数(3次): 第{chapter.chapter_number}")
# 记录失败信息
failed_info = {
'chapter_id': chapter_id,
'chapter_number': chapter.chapter_number,
'title': chapter.title,
'error': f"分析失败(重试3次): {last_analysis_error}",
'retry_count': 3
}
async with write_lock:
if task.failed_chapters is None:
task.failed_chapters = []
task.failed_chapters.append(failed_info)
# 标记任务失败并终止
task.status = 'failed'
task.error_message = f"{chapter.chapter_number}章分析失败(重试3次): {last_analysis_error}"[:500]
task.completed_at = datetime.now()
task.current_retry_count = 0
await db_session.commit()
logger.error(f"🛑 批量生成中断: 第{chapter.chapter_number}章分析失败")
return # 立即终止整个批量生成任务
# 标记成功
chapter_success = True
@@ -2361,7 +2592,8 @@ async def execute_batch_generation_in_order(
except Exception as e:
last_error = str(e)
logger.error(f"❌ 章节生成失败: {chapter.chapter_number if chapter else '?'}, 错误: {last_error}")
error_msg = f"{chapter.chapter_number if chapter else '?'}出错: {last_error}"
logger.error(f"{error_msg}")
retry_count += 1
@@ -2394,7 +2626,13 @@ async def execute_batch_generation_in_order(
task.current_retry_count = 0
await db_session.commit()
logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}")
# ⚠️ 如果启用了同步分析,任何错误都应该中断任务
# 因为章节生成或分析失败会影响后续章节的职业更新和剧情连贯性
if task.enable_analysis:
logger.error(f"🛑 批量生成中断: 因启用同步分析,任何错误都会中断任务以确保职业信息和剧情连贯性")
else:
logger.error(f"🛑 批量生成终止于第{chapter.chapter_number}")
return
# 全部完成
@@ -2469,15 +2707,41 @@ async def generate_single_chapter_for_batch(
for o in all_outlines
])
# 获取角色信息
# 获取角色信息(包含职业信息)
characters_result = await db_session.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
characters_info = "\n".join([
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
for c in characters
])
# 📝 根据大纲模式智能筛选相关角色(批量生成)
filter_character_names = None
if outline_mode == 'one-to-one':
# 1-1模式:从outline.structure中提取characters字段
if outline and outline.structure:
try:
structure = json.loads(outline.structure)
filter_character_names = structure.get('characters', [])
if filter_character_names:
logger.info(f"📋 批量生成 - 1-1模式:从structure提取角色列表 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 批量生成 - outline.structure解析失败,使用全部角色")
else:
# 1-n模式:从chapter.expansion_plan中提取character_focus字段
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
filter_character_names = plan.get('character_focus', [])
if filter_character_names:
logger.info(f"📋 批量生成 - 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 批量生成 - expansion_plan解析失败,使用全部角色")
characters_info = await build_characters_info_with_careers(
db=db_session,
project_id=chapter.project_id,
characters=characters,
filter_character_names=filter_character_names
)
# 获取写作风格
style_content = ""
@@ -2721,12 +2985,53 @@ async def regenerate_chapter_stream(
)
project = project_result.scalar_one_or_none()
# 获取角色信息
# 获取角色信息(包含职业信息)
characters_result = await temp_db.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
# 📝 根据大纲模式智能筛选相关角色(重新生成)
outline_mode_result = await temp_db.execute(
select(Project.outline_mode).where(Project.id == chapter.project_id)
)
outline_mode = outline_mode_result.scalar_one_or_none() or 'one-to-many'
filter_character_names = None
if outline_mode == 'one-to-one':
# 1-1模式:从outline.structure中提取characters字段
outline_result_temp = await temp_db.execute(
select(Outline.structure)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline_structure = outline_result_temp.scalar_one_or_none()
if outline_structure:
try:
structure = json.loads(outline_structure)
filter_character_names = structure.get('characters', [])
if filter_character_names:
logger.info(f"📋 重新生成 - 1-1模式:从structure提取角色列表 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 重新生成 - outline.structure解析失败,使用全部角色")
else:
# 1-n模式:从chapter.expansion_plan中提取character_focus字段
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
filter_character_names = plan.get('character_focus', [])
if filter_character_names:
logger.info(f"📋 重新生成 - 1-n模式:从expansion_plan提取角色焦点 {filter_character_names}")
except json.JSONDecodeError:
logger.warning(f"⚠️ 重新生成 - expansion_plan解析失败,使用全部角色")
characters_info_with_careers = await build_characters_info_with_careers(
db=temp_db,
project_id=chapter.project_id,
characters=characters,
filter_character_names=filter_character_names
)
# 获取章节大纲
outline_result = await temp_db.execute(
select(Outline)
@@ -2779,10 +3084,7 @@ async def regenerate_chapter_stream(
'time_period': project.world_time_period if project else '未设定',
'location': project.world_location if project else '未设定',
'atmosphere': project.world_atmosphere if project else '未设定',
'characters_info': "\n".join([
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
for c in characters
]) if characters else '暂无角色信息',
'characters_info': characters_info_with_careers,
'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲',
'previous_context': '' # 可以后续扩展添加前置章节上下文
}
+440 -7
View File
@@ -85,7 +85,7 @@ async def get_characters(
)
characters = result.scalars().all()
# 为组织类型的角色填充Organization表的额外字段
# 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = []
for char in characters:
char_dict = {
@@ -110,7 +110,10 @@ async def get_characters(
"power_level": None,
"location": None,
"motto": None,
"color": None
"color": None,
"main_career_id": char.main_career_id,
"main_career_stage": char.main_career_stage,
"sub_careers": json.loads(char.sub_careers) if char.sub_careers else None
}
if char.is_organization:
@@ -156,7 +159,7 @@ async def get_project_characters(
)
characters = result.scalars().all()
# 为组织类型的角色填充Organization表的额外字段
# 为组织类型的角色填充Organization表的额外字段,并添加职业信息
enriched_characters = []
for char in characters:
char_dict = {
@@ -181,7 +184,10 @@ async def get_project_characters(
"power_level": None,
"location": None,
"motto": None,
"color": None
"color": None,
"main_career_id": char.main_career_id,
"main_career_stage": char.main_career_stage,
"sub_careers": json.loads(char.sub_careers) if char.sub_careers else None
}
if char.is_organization:
@@ -232,6 +238,8 @@ async def update_character(
db: AsyncSession = Depends(get_db)
):
"""更新角色信息"""
from app.models.career import CharacterCareer, Career
result = await db.execute(
select(Character).where(Character.id == character_id)
)
@@ -260,6 +268,139 @@ async def update_character(
if 'color' in update_data:
org_fields['color'] = update_data.pop('color')
# 处理主职业和副职业更新
main_career_id = update_data.pop('main_career_id', None)
main_career_stage = update_data.pop('main_career_stage', None)
sub_careers_json = update_data.pop('sub_careers', None)
if main_career_id is not None:
# 验证职业存在
if main_career_id: # 不为空
career_result = await db.execute(
select(Career).where(
Career.id == main_career_id,
Career.project_id == character.project_id,
Career.type == 'main'
)
)
career = career_result.scalar_one_or_none()
if not career:
raise HTTPException(status_code=400, detail="主职业不存在或类型错误")
# 验证阶段有效性
if main_career_stage and main_career_stage > career.max_stage:
raise HTTPException(status_code=400, detail=f"阶段超出范围,该职业最大阶段为{career.max_stage}")
# 更新或创建CharacterCareer关联
char_career_result = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == 'main'
)
)
char_career = char_career_result.scalar_one_or_none()
if char_career:
# 更新现有关联
char_career.career_id = main_career_id
if main_career_stage:
char_career.current_stage = main_career_stage
logger.info(f"更新主职业关联:{character.name} -> {career.name}")
else:
# 创建新关联
char_career = CharacterCareer(
character_id=character_id,
career_id=main_career_id,
career_type='main',
current_stage=main_career_stage or 1,
stage_progress=0
)
db.add(char_career)
logger.info(f"创建主职业关联:{character.name} -> {career.name}")
# 更新Character表的冗余字段
character.main_career_id = main_career_id
character.main_career_stage = main_career_stage or char_career.current_stage
else:
# 清空主职业
char_career_result = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == 'main'
)
)
char_career = char_career_result.scalar_one_or_none()
if char_career:
await db.delete(char_career)
logger.info(f"移除主职业关联:{character.name}")
character.main_career_id = None
character.main_career_stage = None
elif main_career_stage is not None and character.main_career_id:
# 只更新阶段
char_career_result = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == 'main'
)
)
char_career = char_career_result.scalar_one_or_none()
if char_career:
char_career.current_stage = main_career_stage
character.main_career_stage = main_career_stage
logger.info(f"更新主职业阶段:{character.name} -> 阶段{main_career_stage}")
# 处理副职业更新
if sub_careers_json is not None:
# 解析副职业JSON
try:
sub_careers_data = json.loads(sub_careers_json) if isinstance(sub_careers_json, str) else sub_careers_json
except:
sub_careers_data = []
# 删除现有的所有副职业关联
existing_subs = await db.execute(
select(CharacterCareer).where(
CharacterCareer.character_id == character_id,
CharacterCareer.career_type == 'sub'
)
)
for sub_career in existing_subs.scalars():
await db.delete(sub_career)
# 创建新的副职业关联
for sub_data in sub_careers_data[:2]: # 最多2个副职业
career_id = sub_data.get('career_id')
if not career_id:
continue
# 验证副职业存在
career_result = await db.execute(
select(Career).where(
Career.id == career_id,
Career.project_id == character.project_id,
Career.type == 'sub'
)
)
career = career_result.scalar_one_or_none()
if career:
# 创建副职业关联
char_career = CharacterCareer(
character_id=character_id,
career_id=career_id,
career_type='sub',
current_stage=sub_data.get('stage', 1),
stage_progress=0
)
db.add(char_career)
logger.info(f"添加副职业关联:{character.name} -> {career.name}")
# 更新Character表的sub_careers冗余字段
character.sub_careers = sub_careers_json if isinstance(sub_careers_json, str) else json.dumps(sub_careers_data, ensure_ascii=False)
logger.info(f"更新副职业信息:{character.name}")
# 更新 Character 表字段
for field, value in update_data.items():
setattr(character, field, value)
@@ -290,7 +431,51 @@ async def update_character(
await db.refresh(character)
logger.info(f"更新角色/组织成功:{character.name} (ID: {character_id})")
return character
# 构建响应,确保sub_careers是list类型
response_data = {
"id": character.id,
"project_id": character.project_id,
"name": character.name,
"age": character.age,
"gender": character.gender,
"is_organization": character.is_organization,
"role_type": character.role_type,
"personality": character.personality,
"background": character.background,
"appearance": character.appearance,
"relationships": character.relationships,
"organization_type": character.organization_type,
"organization_purpose": character.organization_purpose,
"organization_members": character.organization_members,
"traits": character.traits,
"avatar_url": character.avatar_url,
"created_at": character.created_at,
"updated_at": character.updated_at,
"main_career_id": character.main_career_id,
"main_career_stage": character.main_career_stage,
"sub_careers": json.loads(character.sub_careers) if character.sub_careers else None,
"power_level": None,
"location": None,
"motto": None,
"color": None
}
# 如果是组织,添加组织额外字段
if character.is_organization:
org_result = await db.execute(
select(Organization).where(Organization.character_id == character_id)
)
org = org_result.scalar_one_or_none()
if org:
response_data.update({
"power_level": org.power_level,
"location": org.location,
"motto": org.motto,
"color": org.color
})
return response_data
@router.delete("/{character_id}", summary="删除角色")
@@ -330,7 +515,10 @@ async def create_character(
- 可以创建普通角色(is_organization=False
- 也可以创建组织(is_organization=True
- 如果创建组织且提供了组织额外字段,会自动创建Organization详情记录
- 支持设置主职业和副职业
"""
from app.models.career import CharacterCareer, Career
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(character_data.project_id, user_id, db)
@@ -352,13 +540,78 @@ async def create_character(
organization_purpose=character_data.organization_purpose,
organization_members=character_data.organization_members,
traits=character_data.traits,
avatar_url=character_data.avatar_url
avatar_url=character_data.avatar_url,
main_career_id=character_data.main_career_id,
main_career_stage=character_data.main_career_stage,
sub_careers=character_data.sub_careers
)
db.add(character)
await db.flush() # 获取character.id
logger.info(f"✅ 手动创建角色成功:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
# 处理主职业关联
if character_data.main_career_id and not character.is_organization:
# 验证职业存在
career_result = await db.execute(
select(Career).where(
Career.id == character_data.main_career_id,
Career.project_id == character_data.project_id,
Career.type == 'main'
)
)
career = career_result.scalar_one_or_none()
if career:
# 创建主职业关联
char_career = CharacterCareer(
character_id=character.id,
career_id=character_data.main_career_id,
career_type='main',
current_stage=character_data.main_career_stage or 1,
stage_progress=0
)
db.add(char_career)
logger.info(f"✅ 创建主职业关联:{character.name} -> {career.name}")
else:
logger.warning(f"⚠️ 主职业ID不存在或类型错误: {character_data.main_career_id}")
# 处理副职业关联
if character_data.sub_careers and not character.is_organization:
try:
sub_careers_data = json.loads(character_data.sub_careers) if isinstance(character_data.sub_careers, str) else character_data.sub_careers
for sub_data in sub_careers_data[:2]: # 最多2个副职业
career_id = sub_data.get('career_id')
if not career_id:
continue
# 验证副职业存在
career_result = await db.execute(
select(Career).where(
Career.id == career_id,
Career.project_id == character_data.project_id,
Career.type == 'sub'
)
)
career = career_result.scalar_one_or_none()
if career:
# 创建副职业关联
char_career = CharacterCareer(
character_id=character.id,
career_id=career_id,
career_type='sub',
current_stage=sub_data.get('stage', 1),
stage_progress=0
)
db.add(char_career)
logger.info(f"✅ 创建副职业关联:{character.name} -> {career.name}")
else:
logger.warning(f"⚠️ 副职业ID不存在或类型错误: {career_id}")
except Exception as e:
logger.warning(f"⚠️ 解析副职业数据失败: {e}")
# 如果是组织,且提供了组织额外字段,自动创建Organization详情记录
if character.is_organization and (
character_data.power_level is not None or
@@ -438,6 +691,50 @@ async def generate_character_stream(
if organization_list:
existing_chars_info += "\n\n已有组织:\n" + "\n".join(organization_list)
# 🎯 获取项目职业列表
from app.models.career import Career
careers_result = await db.execute(
select(Career)
.where(Career.project_id == request.project_id)
.order_by(Career.type, Career.name)
)
careers = careers_result.scalars().all()
# 构建职业信息摘要
careers_info = ""
if careers:
main_careers = [c for c in careers if c.type == 'main']
sub_careers = [c for c in careers if c.type == 'sub']
if main_careers:
careers_info += "\n\n可用主职业列表(请在career_info中填写职业名称,系统会自动匹配ID):\n"
for career in main_careers:
# 解析阶段信息
import json as json_lib
try:
stages = json_lib.loads(career.stages) if career.stages else []
stage_names = [s.get('name', f'阶段{s.get("level")}') for s in stages[:3]] # 只显示前3个阶段
stage_info = "".join(stage_names)
if len(stages) > 3:
stage_info += " → ..."
except:
stage_info = f"{career.max_stage}个阶段"
careers_info += f"- 名称: {career.name}"
if career.description:
careers_info += f", 描述: {career.description[:50]}"
careers_info += f", 阶段: {stage_info}\n"
if sub_careers:
careers_info += "\n可用副职业列表(请在career_info中填写职业名称,系统会自动匹配ID):\n"
for career in sub_careers[:5]: # 最多显示5个副职业
careers_info += f"- 名称: {career.name}"
if career.description:
careers_info += f", 描述: {career.description[:50]}"
careers_info += "\n"
else:
careers_info = "\n\n⚠️ 项目中暂无职业设定"
# 构建项目上下文
project_context = f"""
项目信息:
@@ -449,6 +746,7 @@ async def generate_character_stream(
- 氛围基调:{project.world_atmosphere or '未设定'}
- 世界规则:{project.world_rules or '未设定'}
{existing_chars_info}
{careers_info}
"""
user_input = f"""
@@ -544,6 +842,62 @@ async def generate_character_stream(
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)
# 提取职业信息(支持通过名称匹配)
career_info = character_data.get("career_info", {})
raw_main_career_name = career_info.get("main_career_name") if career_info else None
main_career_stage = career_info.get("main_career_stage", 1) if career_info else None
raw_sub_careers_data = career_info.get("sub_careers", []) if career_info else []
# 调试日志:输出职业信息
logger.info(f"🔍 提取职业信息 - career_info: {career_info}")
logger.info(f"🔍 raw_main_career_name: {raw_main_career_name}, main_career_stage: {main_career_stage}")
logger.info(f"🔍 raw_sub_careers_data类型: {type(raw_sub_careers_data)}, 内容: {raw_sub_careers_data}")
# 🔧 通过职业名称匹配数据库中的职业ID
from app.models.career import Career
main_career_id = None
sub_careers_data = []
# 匹配主职业名称
if raw_main_career_name and not is_organization:
career_check = await db.execute(
select(Career).where(
Career.name == raw_main_career_name,
Career.project_id == request.project_id,
Career.type == 'main'
)
)
matched_career = career_check.scalar_one_or_none()
if matched_career:
main_career_id = matched_career.id
logger.info(f"✅ 主职业名称匹配成功: {raw_main_career_name} -> ID: {main_career_id}")
else:
logger.warning(f"⚠️ AI返回的主职业名称未找到: {raw_main_career_name}")
# 匹配副职业名称
if raw_sub_careers_data and not is_organization and isinstance(raw_sub_careers_data, list):
for sub_data in raw_sub_careers_data[:2]:
if isinstance(sub_data, dict):
career_name = sub_data.get('career_name')
if career_name:
career_check = await db.execute(
select(Career).where(
Career.name == career_name,
Career.project_id == request.project_id,
Career.type == 'sub'
)
)
matched_career = career_check.scalar_one_or_none()
if matched_career:
# 转换为包含ID的格式
sub_careers_data.append({
'career_id': matched_career.id,
'stage': sub_data.get('stage', 1)
})
logger.info(f"✅ 副职业名称匹配成功: {career_name} -> ID: {matched_career.id}")
else:
logger.warning(f"⚠️ AI返回的副职业名称未找到: {career_name}")
# 创建角色
character = Character(
project_id=request.project_id,
@@ -559,13 +913,92 @@ async def generate_character_stream(
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
traits=traits_json,
main_career_id=main_career_id,
main_career_stage=main_career_stage if main_career_id else None,
sub_careers=json.dumps(sub_careers_data, ensure_ascii=False) if sub_careers_data else None
)
db.add(character)
await db.flush()
logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id})")
# 处理主职业关联
if main_career_id and not is_organization:
from app.models.career import CharacterCareer, Career
career_result = await db.execute(
select(Career).where(
Career.id == main_career_id,
Career.project_id == request.project_id,
Career.type == 'main'
)
)
career = career_result.scalar_one_or_none()
if career:
char_career = CharacterCareer(
character_id=character.id,
career_id=main_career_id,
career_type='main',
current_stage=main_career_stage,
stage_progress=0
)
db.add(char_career)
logger.info(f"✅ AI生成角色-创建主职业关联:{character.name} -> {career.name}")
else:
logger.warning(f"⚠️ AI返回的主职业ID不存在: {main_career_id}")
# 处理副职业关联
if sub_careers_data and not is_organization:
from app.models.career import CharacterCareer, Career
logger.info(f"🔍 开始处理副职业关联,数据: {sub_careers_data}")
# 确保sub_careers_data是列表
if not isinstance(sub_careers_data, list):
logger.warning(f"⚠️ sub_careers_data不是列表类型: {type(sub_careers_data)}")
sub_careers_data = []
for idx, sub_data in enumerate(sub_careers_data[:2]): # 最多2个副职业
logger.info(f"🔍 处理第{idx+1}个副职业,数据: {sub_data}, 类型: {type(sub_data)}")
# 兼容不同的数据格式
if isinstance(sub_data, dict):
career_id = sub_data.get('career_id')
stage = sub_data.get('stage', 1)
else:
logger.warning(f"⚠️ 副职业数据格式错误,应为dict: {sub_data}")
continue
if not career_id:
logger.warning(f"⚠️ 副职业数据缺少career_id字段")
continue
logger.info(f"🔍 查询副职业: career_id={career_id}, project_id={request.project_id}")
career_result = await db.execute(
select(Career).where(
Career.id == career_id,
Career.project_id == request.project_id,
Career.type == 'sub'
)
)
career = career_result.scalar_one_or_none()
if career:
char_career = CharacterCareer(
character_id=character.id,
career_id=career_id,
career_type='sub',
current_stage=stage,
stage_progress=0
)
db.add(char_career)
logger.info(f"✅ AI生成角色-创建副职业关联:{character.name} -> {career.name} (阶段{stage})")
else:
logger.warning(f"⚠️ AI返回的副职业ID不存在: {career_id} (项目ID: {request.project_id})")
# 如果是组织,创建Organization详情
if is_organization:
yield await SSEResponse.send_progress("创建组织详情...", 85)
+245 -7
View File
@@ -11,6 +11,7 @@ from app.models.project import Project
from app.models.character import Character
from app.models.outline import Outline
from app.models.chapter import Chapter
from app.models.career import Career, CharacterCareer
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
from app.models.writing_style import WritingStyle
from app.models.project_default_style import ProjectDefaultStyle
@@ -239,6 +240,121 @@ async def world_building_generator(
project.wizard_step = 1
await db.commit()
# ===== 自动生成职业体系 =====
yield await SSEResponse.send_progress("🎯 开始生成职业体系框架...", 75)
logger.info(f"🎯 世界观已完成,开始为项目 {project.id} 自动生成职业体系")
try:
# 获取职业生成提示词模板(支持用户自定义)
template = await PromptService.get_template("CAREER_SYSTEM_GENERATION", user_id, db)
career_prompt = PromptService.format_prompt(
template,
title=project.title,
genre=genre or '未设定',
theme=theme or '未设定',
time_period=world_data.get('time_period', '未设定'),
location=world_data.get('location', '未设定'),
atmosphere=world_data.get('atmosphere', '未设定'),
rules=world_data.get('rules', '未设定')
)
yield await SSEResponse.send_progress("正在生成职业体系...", 78)
# 调用AI生成职业
result = await user_ai_service.generate_text(prompt=career_prompt)
career_response = result.get('content', '') if isinstance(result, dict) else result
if not career_response or not career_response.strip():
logger.warning("⚠️ AI返回空职业体系,跳过职业生成")
yield await SSEResponse.send_progress("职业体系生成跳过(AI返回为空)", 85)
else:
yield await SSEResponse.send_progress("解析职业体系数据...", 82)
# 清洗并解析JSON
try:
cleaned_response = user_ai_service._clean_json_response(career_response)
career_data = json.loads(cleaned_response)
logger.info(f"✅ 职业体系JSON解析成功")
# 保存主职业
main_careers_created = []
for idx, career_info in enumerate(career_data.get("main_careers", [])):
try:
stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
attribute_bonuses = career_info.get("attribute_bonuses")
attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
career = Career(
project_id=project.id,
name=career_info.get("name", f"未命名主职业{idx+1}"),
type="main",
description=career_info.get("description"),
category=career_info.get("category"),
stages=stages_json,
max_stage=career_info.get("max_stage", 10),
requirements=career_info.get("requirements"),
special_abilities=career_info.get("special_abilities"),
worldview_rules=career_info.get("worldview_rules"),
attribute_bonuses=attribute_bonuses_json,
source="ai"
)
db.add(career)
await db.flush()
main_careers_created.append(career.name)
logger.info(f" ✅ 创建主职业:{career.name}")
except Exception as e:
logger.error(f" ❌ 创建主职业失败:{str(e)}")
continue
# 保存副职业
sub_careers_created = []
for idx, career_info in enumerate(career_data.get("sub_careers", [])):
try:
stages_json = json.dumps(career_info.get("stages", []), ensure_ascii=False)
attribute_bonuses = career_info.get("attribute_bonuses")
attribute_bonuses_json = json.dumps(attribute_bonuses, ensure_ascii=False) if attribute_bonuses else None
career = Career(
project_id=project.id,
name=career_info.get("name", f"未命名副职业{idx+1}"),
type="sub",
description=career_info.get("description"),
category=career_info.get("category"),
stages=stages_json,
max_stage=career_info.get("max_stage", 5),
requirements=career_info.get("requirements"),
special_abilities=career_info.get("special_abilities"),
worldview_rules=career_info.get("worldview_rules"),
attribute_bonuses=attribute_bonuses_json,
source="ai"
)
db.add(career)
await db.flush()
sub_careers_created.append(career.name)
logger.info(f" ✅ 创建副职业:{career.name}")
except Exception as e:
logger.error(f" ❌ 创建副职业失败:{str(e)}")
continue
await db.commit()
logger.info(f"🎉 职业体系生成完成:主职业{len(main_careers_created)}个,副职业{len(sub_careers_created)}")
yield await SSEResponse.send_progress(
f"✅ 职业体系生成完成(主{len(main_careers_created)}+副{len(sub_careers_created)}",
90
)
except json.JSONDecodeError as e:
logger.error(f"❌ 职业体系JSON解析失败: {e}")
yield await SSEResponse.send_progress("⚠️ 职业体系解析失败,已跳过", 85)
except Exception as e:
logger.error(f"❌ 职业体系保存失败: {e}")
yield await SSEResponse.send_progress("⚠️ 职业体系保存失败,已跳过", 85)
except Exception as e:
logger.error(f"❌ 职业体系生成异常: {e}")
yield await SSEResponse.send_progress("⚠️ 职业体系生成失败,已跳过(不影响项目创建)", 85)
db_committed = True
# 发送最终结果
@@ -381,6 +497,40 @@ async def characters_generator(
logger.warning(f"MCP工具调用失败(降级处理): {e}")
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 12)
# 获取项目的职业列表,用于角色职业分配
yield await SSEResponse.send_progress("加载职业体系...", 13)
career_result = await db.execute(
select(Career).where(Career.project_id == project_id).order_by(Career.type, Career.id)
)
careers = career_result.scalars().all()
main_careers = [c for c in careers if c.type == "main"]
sub_careers = [c for c in careers if c.type == "sub"]
# 构建职业上下文
careers_context = ""
if main_careers or sub_careers:
careers_context = "\n\n【职业体系】\n"
if main_careers:
careers_context += "主职业:\n"
for career in main_careers:
careers_context += f"- {career.name}: {career.description or '暂无描述'}\n"
if sub_careers:
careers_context += "\n副职业:\n"
for career in sub_careers:
careers_context += f"- {career.name}: {career.description or '暂无描述'}\n"
careers_context += "\n请为每个角色分配职业:\n"
careers_context += "- 每个角色必须有1个主职业(从上述主职业中选择)\n"
careers_context += "- 每个角色可以有0-2个副职业(从上述副职业中选择,可选)\n"
careers_context += "- 主职业初始阶段建议为1-3\n"
careers_context += "- 副职业初始阶段建议为1-2\n"
careers_context += "- 请在返回的JSON中包含 career_assignment 字段:\n"
careers_context += ' {"main_career": "职业名称", "main_stage": 2, "sub_careers": [{"career": "副职业名称", "stage": 1}]}\n'
logger.info(f"✅ 加载了{len(main_careers)}个主职业和{len(sub_careers)}个副职业")
else:
logger.warning("⚠️ 项目没有职业体系,跳过职业分配")
# 优化的分批策略:每批生成3个,平衡效率和成功率
BATCH_SIZE = 3 # 每批生成3个角色
MAX_RETRIES = 3 # 每批最多重试3次
@@ -445,7 +595,7 @@ async def characters_generator(
rules=world_context.get("rules", ""),
theme=theme or project.theme or "",
genre=genre or project.genre or "",
requirements=batch_requirements
requirements=batch_requirements + careers_context # 添加职业上下文
)
# 如果有MCP参考资料,增强提示词
@@ -626,14 +776,102 @@ async def characters_generator(
await db.flush() # 获取所有角色的ID
# 第二阶段:为角色分配职业并创建CharacterCareer关联
if main_careers or sub_careers:
yield await SSEResponse.send_progress("分配角色职业...", 86)
careers_assigned = 0
# 构建职业名称到对象的映射
career_name_to_obj = {c.name: c for c in careers}
for character, char_data in created_characters:
# 跳过组织
if character.is_organization:
continue
try:
career_assignment = char_data.get("career_assignment", {})
# 分配主职业
main_career_name = career_assignment.get("main_career")
main_career_stage = career_assignment.get("main_stage", 1)
if main_career_name and main_career_name in career_name_to_obj:
main_career = career_name_to_obj[main_career_name]
# 创建CharacterCareer关联
char_career = CharacterCareer(
character_id=character.id,
career_id=main_career.id,
career_type="main",
current_stage=min(main_career_stage, main_career.max_stage),
stage_progress=0
)
db.add(char_career)
# 更新Character冗余字段
character.main_career_id = main_career.id
character.main_career_stage = char_career.current_stage
careers_assigned += 1
logger.info(f" ✅ 分配主职业:{character.name} -> {main_career.name} (阶段{char_career.current_stage})")
else:
if main_career_name:
logger.warning(f" ⚠️ 主职业不存在:{character.name} -> {main_career_name}")
# 分配副职业
sub_career_assignments = career_assignment.get("sub_careers", [])
sub_career_list = []
for sub_assign in sub_career_assignments[:2]: # 最多2个副职业
sub_career_name = sub_assign.get("career")
sub_career_stage = sub_assign.get("stage", 1)
if sub_career_name and sub_career_name in career_name_to_obj:
sub_career = career_name_to_obj[sub_career_name]
# 创建CharacterCareer关联
char_career = CharacterCareer(
character_id=character.id,
career_id=sub_career.id,
career_type="sub",
current_stage=min(sub_career_stage, sub_career.max_stage),
stage_progress=0
)
db.add(char_career)
# 添加到副职业列表
sub_career_list.append({
"career_id": sub_career.id,
"stage": char_career.current_stage
})
careers_assigned += 1
logger.info(f" ✅ 分配副职业:{character.name} -> {sub_career.name} (阶段{char_career.current_stage})")
else:
if sub_career_name:
logger.warning(f" ⚠️ 副职业不存在:{character.name} -> {sub_career_name}")
# 更新Character冗余字段
if sub_career_list:
character.sub_careers = json.dumps(sub_career_list, ensure_ascii=False)
except Exception as e:
logger.warning(f" ❌ 分配职业失败:{character.name} - {str(e)}")
continue
await db.flush()
logger.info(f"💼 职业分配完成:共分配{careers_assigned}个职业")
yield await SSEResponse.send_progress(f"已分配{careers_assigned}个职业", 87)
# 刷新并建立名称映射
for character, _ in created_characters:
await db.refresh(character)
character_name_to_obj[character.name] = character
logger.info(f"向导创建角色:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})")
# 为is_organization=True的角色创建Organization记录
yield await SSEResponse.send_progress("创建组织记录...", 87)
# 第三阶段:为is_organization=True的角色创建Organization记录
yield await SSEResponse.send_progress("创建组织记录...", 88)
organization_name_to_obj = {} # 组织名称到Organization对象的映射
for character, char_data in created_characters:
@@ -669,8 +907,8 @@ async def characters_generator(
for character, _ in created_characters:
await db.refresh(character)
# 第阶段:创建角色间的关系
yield await SSEResponse.send_progress("创建角色关系...", 90)
# 第阶段:创建角色间的关系
yield await SSEResponse.send_progress("创建角色关系...", 91)
relationships_created = 0
for character, char_data in created_characters:
@@ -737,8 +975,8 @@ async def characters_generator(
logger.warning(f" ❌ 向导创建关系失败:{character.name} - {str(e)}")
continue
# 第阶段:创建组织成员关系
yield await SSEResponse.send_progress("创建组织成员关系...", 93)
# 第阶段:创建组织成员关系
yield await SSEResponse.send_progress("创建组织成员关系...", 94)
members_created = 0
for character, char_data in created_characters: