""" 项目管理 API 路由 提供项目的 CRUD 操作和剧集执行功能 """ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from typing import List, Optional from pydantic import BaseModel, Field from app.models.project import ( SeriesProject, SeriesProjectCreate, Episode, EpisodeExecuteRequest, EpisodeExecuteResponse ) from app.models.review import ReviewConfig from app.models.skill_config import ( ProjectSkillConfigUpdate, SkillConfigResponse, EpisodeSkillConfigUpdate ) from app.core.agents.series_creation_agent import get_series_agent from app.core.execution.batch_executor import get_batch_executor from app.core.execution.retry_manager import get_retry_manager, RetryConfig from app.db.repositories import project_repo, episode_repo from app.utils.logger import get_logger logger = get_logger(__name__) router = APIRouter(prefix="/projects", tags=["项目管理"]) # ============================================ # 项目管理 # ============================================ @router.post("/", response_model=SeriesProject, status_code=status.HTTP_201_CREATED) async def create_project(project_data: SeriesProjectCreate): """创建新项目""" try: project = await project_repo.create(project_data) return project except Exception as e: logger.error(f"创建项目失败: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"创建项目失败: {str(e)}" ) @router.get("/", response_model=List[SeriesProject]) async def list_projects(skip: int = 0, limit: int = 100): """列出所有项目""" projects = await project_repo.list(skip=skip, limit=limit) return projects @router.get("/{project_id}", response_model=SeriesProject) async def get_project(project_id: str): """获取项目详情""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) return project @router.put("/{project_id}", response_model=SeriesProject) async def update_project(project_id: str, project_data: dict): """更新项目""" project = await project_repo.update(project_id, project_data) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) return project @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project(project_id: str): """删除项目""" success = await project_repo.delete(project_id) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) return None # ============================================ # 剧集管理 # ============================================ @router.get("/{project_id}/episodes", response_model=List[Episode]) async def list_episodes(project_id: str): """列出项目的所有剧集""" # 先验证项目存在 project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) episodes = await episode_repo.list_by_project(project_id) return episodes @router.get("/{project_id}/episodes/{episode_number}", response_model=Episode) async def get_episode(project_id: str, episode_number: int): """获取指定集数""" episodes = await episode_repo.list_by_project(project_id) for ep in episodes: if ep.number == episode_number: return ep raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"剧集不存在: EP{episode_number}" ) # ============================================ # 剧集执行(核心功能) # ============================================ @router.post("/{project_id}/execute", response_model=EpisodeExecuteResponse) async def execute_episode( project_id: str, request: EpisodeExecuteRequest ): """ 执行单集创作 这是核心功能端点,调用 Agent 执行完整的创作流程 """ # 获取项目 project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) try: # 获取 Agent agent = get_series_agent() # 执行创作 logger.info(f"开始执行创作: 项目 {project_id}, EP{request.episodeNumber}") episode = await agent.execute_episode( project=project, episode_number=request.episodeNumber, title=request.title ) # 保存剧集 episode.projectId = project_id await episode_repo.create(episode) # 更新项目记忆 await project_repo.update(project_id, { "memory": project.memory.dict() }) return EpisodeExecuteResponse( episode=episode, success=True, message=f"EP{request.episodeNumber} 创作完成" ) except Exception as e: logger.error(f"执行创作失败: {str(e)}") return EpisodeExecuteResponse( episode=Episode( projectId=project_id, number=request.episodeNumber, status="needs-review" ), success=False, message=f"创作失败: {str(e)}" ) # ============================================ # 请求/响应模型 # ============================================ class BatchExecuteRequest(BaseModel): """批量执行请求""" start_episode: int = Field(..., ge=1, description="起始集数") end_episode: int = Field(..., ge=1, description="结束集数") enable_review: bool = Field(True, description="是否启用质量检查") enable_retry: bool = Field(True, description="是否启用自动重试") max_retries: int = Field(2, ge=0, le=5, description="最大重试次数") quality_threshold: float = Field(75.0, ge=0, le=100, description="质量阈值") class AutoExecuteRequest(BaseModel): """自动执行请求""" start_episode: int = Field(1, ge=1, description="起始集数") episode_count: Optional[int] = Field(None, ge=1, description="执行集数(不指定则执行到项目总集数)") enable_review: bool = Field(True, description="是否启用质量检查") enable_retry: bool = Field(True, description="是否启用自动重试") max_retries: int = Field(2, ge=0, le=5, description="最大重试次数") quality_threshold: float = Field(75.0, ge=0, le=100, description="质量阈值") stop_on_failure: bool = Field(False, description="失败时是否停止") class StopExecutionRequest(BaseModel): """停止执行请求""" batch_id: str = Field(..., description="批次ID") # ============================================ # 增强的批量执行 # ============================================ @router.post("/{project_id}/execute-batch", response_model=dict) async def execute_batch_enhanced( project_id: str, request: BatchExecuteRequest, background_tasks: BackgroundTasks ): """ 增强的批量执行 新功能: - 集成质量检查 - 自动重试失败剧集 - 实时进度推送(通过 WebSocket) - 生成详细执行摘要 返回批次ID,可通过 WebSocket 或状态端点追踪进度 """ project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) if request.end_episode < request.start_episode: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="结束集数必须大于或等于起始集数" ) # 获取批量执行器 batch_executor = get_batch_executor() # 定义进度回调(发送 WebSocket 消息) async def on_progress(progress_data): from app.api.v1.websocket import broadcast_batch_progress await broadcast_batch_progress( batch_id=progress_data["batch_id"], current_episode=progress_data["current_episode"], total_episodes=progress_data["total_episodes"], completed=progress_data["completed_episodes"], failed=progress_data["failed_episodes"], data=progress_data.get("current_episode_result", {}) ) # 定义剧集完成回调 async def on_episode_complete(episode_result): from app.api.v1.websocket import broadcast_episode_complete await broadcast_episode_complete( project_id=project_id, episode_number=episode_result["episode_number"], success=episode_result["success"], quality_score=episode_result.get("quality_score", 0), data=episode_result ) # 定义错误回调 async def on_error(error_data): from app.api.v1.websocket import broadcast_error await broadcast_error( project_id=project_id, episode_number=error_data.get("episode_number"), error=error_data.get("error", "未知错误"), error_type="batch_execution_error" ) # 构建审核配置 review_config = None if request.enable_review: from app.models.review import ReviewConfig, DimensionConfig, DimensionType review_config = ReviewConfig( enabled_review_skills=["consistency_checker"], overall_strictness=0.7, pass_threshold=request.quality_threshold ) # 添加默认维度 for dim_type in [DimensionType.consistency, DimensionType.quality, DimensionType.dialogue]: review_config.dimension_settings[dim_type] = DimensionConfig( enabled=True, strictness=0.7, weight=1.0 ) # 在后台执行批量创作 async def run_batch(): try: summary = await batch_executor.execute_batch( project=project, start_episode=request.start_episode, end_episode=request.end_episode, review_config=review_config, enable_retry=request.enable_retry, max_retries=request.max_retries, on_progress=on_progress, on_episode_complete=on_episode_complete, on_error=on_error ) # 广播完成消息 from app.api.v1.websocket import broadcast_batch_complete await broadcast_batch_complete(summary["batch_id"], summary) except Exception as e: logger.error(f"批量执行后台任务失败: {str(e)}") from app.api.v1.websocket import broadcast_error await broadcast_error( project_id=project_id, episode_number=None, error=f"批量执行失败: {str(e)}", error_type="batch_error" ) # 添加后台任务 background_tasks.add_task(run_batch) # 立即返回批次ID # 注意:由于是在后台执行,我们需要先创建一个占位的批次ID import uuid batch_id = str(uuid.uuid4()) return { "batch_id": batch_id, "project_id": project_id, "start_episode": request.start_episode, "end_episode": request.end_episode, "status": "started", "message": "批量执行已启动,请通过 WebSocket 或状态端点追踪进度", "websocket_url": f"/ws/batches/{batch_id}/execute" } @router.post("/{project_id}/execute-auto", response_model=dict) async def execute_auto( project_id: str, request: AutoExecuteRequest, background_tasks: BackgroundTasks ): """ 自动执行模式 自动执行指定数量的剧集,具有以下特性: - 智能质量检查和重试 - 失败时可选停止或继续 - 实时进度通知 - 完整的执行摘要 这是推荐的执行方式,适合大部分使用场景 """ project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) # 确定结束集数 end_episode = request.start_episode + (request.episode_count or 10) - 1 if end_episode > project.totalEpisodes: end_episode = project.totalEpisodes # 获取执行器 batch_executor = get_batch_executor() # 定义回调 async def on_progress(progress_data): from app.api.v1.websocket import broadcast_batch_progress await broadcast_batch_progress( batch_id=progress_data["batch_id"], current_episode=progress_data["current_episode"], total_episodes=progress_data["total_episodes"], completed=progress_data["completed_episodes"], failed=progress_data["failed_episodes"], data=progress_data.get("current_episode_result", {}) ) async def on_episode_complete(episode_result): from app.api.v1.websocket import broadcast_episode_complete await broadcast_episode_complete( project_id=project_id, episode_number=episode_result["episode_number"], success=episode_result["success"], quality_score=episode_result.get("quality_score", 0), data=episode_result ) async def on_error(error_data): from app.api.v1.websocket import broadcast_error await broadcast_error( project_id=project_id, episode_number=error_data.get("episode_number"), error=error_data.get("error", "未知错误"), error_type="auto_execution_error" ) # 如果设置为失败时停止,则停止批次执行 if request.stop_on_failure: batch_id = error_data.get("batch_id") if batch_id: await batch_executor.stop_batch(batch_id) # 构建审核配置 review_config = None if request.enable_review: from app.models.review import ReviewConfig, DimensionConfig, DimensionType review_config = ReviewConfig( enabled_review_skills=["consistency_checker"], overall_strictness=0.7, pass_threshold=request.quality_threshold ) for dim_type in [DimensionType.consistency, DimensionType.quality, DimensionType.dialogue]: review_config.dimension_settings[dim_type] = DimensionConfig( enabled=True, strictness=0.7, weight=1.0 ) # 后台执行 async def run_auto(): try: summary = await batch_executor.execute_batch( project=project, start_episode=request.start_episode, end_episode=end_episode, review_config=review_config, enable_retry=request.enable_retry, max_retries=request.max_retries, on_progress=on_progress, on_episode_complete=on_episode_complete, on_error=on_error ) from app.api.v1.websocket import broadcast_batch_complete await broadcast_batch_complete(summary["batch_id"], summary) except Exception as e: logger.error(f"自动执行失败: {str(e)}") from app.api.v1.websocket import broadcast_error await broadcast_error( project_id=project_id, episode_number=None, error=f"自动执行失败: {str(e)}", error_type="auto_error" ) background_tasks.add_task(run_auto) import uuid batch_id = str(uuid.uuid4()) return { "batch_id": batch_id, "project_id": project_id, "start_episode": request.start_episode, "end_episode": end_episode, "total_episodes": end_episode - request.start_episode + 1, "status": "started", "stop_on_failure": request.stop_on_failure, "message": "自动执行已启动", "websocket_url": f"/ws/batches/{batch_id}/execute" } @router.get("/{project_id}/execution-status") async def get_execution_status( project_id: str, batch_id: Optional[str] = None ): """ 获取执行状态 Args: project_id: 项目ID batch_id: 批次ID(可选,不提供则返回所有活跃批次) Returns: 执行状态信息 """ project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) batch_executor = get_batch_executor() if batch_id: # 获取指定批次状态 status = batch_executor.get_batch_status(batch_id) if not status: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"批次不存在: {batch_id}" ) return status else: # 返回所有活跃批次(简化实现) # 在实际应用中,可能需要维护项目到批次的映射 return { "project_id": project_id, "active_batches": [], "message": "请提供具体的批次ID" } @router.post("/{project_id}/stop-execution") async def stop_execution(project_id: str, request: StopExecutionRequest): """ 停止执行 停止正在运行的批量执行任务 Args: project_id: 项目ID request: 停止请求(包含批次ID) Returns: 停止结果 """ project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) batch_executor = get_batch_executor() success = await batch_executor.stop_batch(request.batch_id) if success: return { "batch_id": request.batch_id, "project_id": project_id, "status": "stopping", "message": "已发送停止信号" } else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"批次不存在或已完成: {request.batch_id}" ) # ============================================ # 旧的批量执行端点(保留兼容性) # ============================================ @router.post("/{project_id}/execute-batch-legacy") async def execute_batch_legacy( project_id: str, start_episode: int = 1, end_episode: int = 3 ): """ 旧的批量执行端点(保留用于向后兼容) 创作指定范围的剧集(用于分批次模式) """ project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) agent = get_series_agent() results = [] for ep_num in range(start_episode, end_episode + 1): try: episode = await agent.execute_episode(project, ep_num) episode.projectId = project_id await episode_repo.create(episode) results.append({ "episode": ep_num, "success": True, "qualityScore": episode.qualityScore }) except Exception as e: results.append({ "episode": ep_num, "success": False, "error": str(e) }) return { "projectId": project_id, "results": results, "total": len(results), "success": sum(1 for r in results if r["success"]) } # ============================================ # 记忆系统 # ============================================ @router.get("/{project_id}/memory") async def get_project_memory(project_id: str): """获取项目记忆系统""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) return project.memory # ============================================ # Skills 配置管理 # ============================================ @router.get("/{project_id}/skill-config", response_model=SkillConfigResponse) async def get_project_skill_config(project_id: str): """获取项目的 Skills 配置""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) return { "defaultTaskSkills": project.defaultTaskSkills, "episodeSkillOverrides": project.episodeSkillOverrides } @router.put("/{project_id}/skill-config") async def update_project_skill_config( project_id: str, config: ProjectSkillConfigUpdate ): """更新项目的 Skills 配置""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) try: # 转换配置为字典格式(用于保存) update_data = { "defaultTaskSkills": [t.dict() for t in config.defaultTaskSkills], "episodeSkillOverrides": { k: v.dict() for k, v in config.episodeSkillOverrides.items() } } await project_repo.update(project_id, update_data) return { "success": True, "message": "Skills 配置已更新", "defaultTaskSkills": config.defaultTaskSkills, "episodeSkillOverrides": config.episodeSkillOverrides } except Exception as e: logger.error(f"更新 Skills 配置失败: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"更新失败: {str(e)}" ) @router.put("/{project_id}/episodes/{episode_number}/skill-config") async def update_episode_skill_config( project_id: str, episode_number: int, config: EpisodeSkillConfigUpdate ): """更新单集的 Skills 覆盖配置""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) try: # 获取现有覆盖配置 overrides = dict(project.episodeSkillOverrides) # 更新或添加单集配置 overrides[episode_number] = { "episode_number": config.episode_number, "task_configs": [t.dict() for t in config.task_configs], "use_project_default": config.use_project_default } await project_repo.update(project_id, { "episodeSkillOverrides": overrides }) return { "success": True, "message": f"EP{episode_number} 的 Skills 配置已更新", "config": config } except Exception as e: logger.error(f"更新单集 Skills 配置失败: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"更新失败: {str(e)}" ) @router.delete("/{project_id}/episodes/{episode_number}/skill-config") async def delete_episode_skill_config( project_id: str, episode_number: int ): """删除单集的 Skills 覆盖配置(恢复使用项目默认)""" project = await project_repo.get(project_id) if not project: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"项目不存在: {project_id}" ) try: overrides = dict(project.episodeSkillOverrides) if episode_number not in overrides: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"EP{episode_number} 没有自定义配置" ) # 删除单集配置 del overrides[episode_number] await project_repo.update(project_id, { "episodeSkillOverrides": overrides }) return { "success": True, "message": f"EP{episode_number} 的配置已删除,将使用项目默认配置" } except HTTPException: raise except Exception as e: logger.error(f"删除单集 Skills 配置失败: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"删除失败: {str(e)}" )