diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 7de4229..660f52a 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1 +1,5 @@ # API v1 module + +from app.api.v1 import review, projects, review_task, websocket, memory + +__all__ = ["review", "projects", "review_task", "websocket", "memory"] diff --git a/backend/app/api/v1/confirm_cards.py b/backend/app/api/v1/confirm_cards.py new file mode 100644 index 0000000..1544889 --- /dev/null +++ b/backend/app/api/v1/confirm_cards.py @@ -0,0 +1,319 @@ +""" +Confirm Cards API Routes + +确认卡片API路由 - 提供确认卡片的CRUD操作 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +from datetime import datetime, timedelta + +from app.models.confirm_card import ( + ConfirmCard, ConfirmCardStatus, ConfirmCardOption +) +from app.db.confirm_card_repository import get_confirm_card_repo +from app.api.v1.websocket import manager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/confirm-cards", tags=["确认卡片"]) + + +# ============================================ +# 依赖注入 +# ============================================ + +async def get_repo(): + """获取确认卡片仓储""" + return get_confirm_card_repo() + + +# ============================================ +# CRUD 端点 +# ============================================ + +@router.post("") +async def create_confirm_card( + card: ConfirmCard, + repo=Depends(get_repo) +): + """ + 创建确认卡片 + + 由Agent推送,用户可中途退出后随时确认 + """ + try: + created_card = await repo.create(card) + + # WebSocket推送 + await manager.send_to_project(card.project_id, { + "type": "confirm_card_created", + "data": { + "card_id": card.id, + "card_type": card.card_type.value, + "title": card.title, + "description": card.description[:200] + "..." if len(card.description) > 200 else card.description, + "options_count": len(card.options), + "created_at": card.created_at.isoformat() + } + }) + + logger.info(f"创建确认卡片成功: {card.id}") + return {"success": True, "card": created_card} + + except Exception as e: + logger.error(f"创建确认卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建确认卡片失败: {str(e)}") + + +@router.post("/simple") +async def create_confirm_card_simple( + project_id: str, + card_type: str, + title: str, + description: str, + options: Optional[List[dict]] = None, + episode_number: Optional[int] = None, + allow_custom_response: bool = False, + expires_in_hours: Optional[int] = None, + repo=Depends(get_repo) +): + """ + 简化创建确认卡片 + + 用于Agent快速推送确认任务 + """ + try: + # 构建选项列表 + card_options = [] + if options: + for i, opt in enumerate(options): + if isinstance(opt, dict): + card_options.append(ConfirmCardOption( + id=opt.get("id", f"opt_{i}"), + label=opt.get("label", f"选项 {i+1}"), + description=opt.get("description", ""), + implications=opt.get("implications") + )) + elif isinstance(opt, str): + card_options.append(ConfirmCardOption( + id=f"opt_{i}", + label=opt, + description="" + )) + + # 计算过期时间 + expires_at = None + if expires_in_hours: + expires_at = datetime.now() + timedelta(hours=expires_in_hours) + + from app.models.confirm_card import ConfirmCardType + # 创建卡片 + card = ConfirmCard( + id=None, # 由repository生成 + project_id=project_id, + episode_number=episode_number, + card_type=ConfirmCardType(card_type), + title=title, + description=description, + options=card_options, + allow_custom_response=allow_custom_response, + expires_at=expires_at + ) + + created_card = await repo.create(card) + + # WebSocket推送 + await manager.send_to_project(project_id, { + "type": "confirm_card_created", + "data": { + "card_id": created_card.id, + "card_type": card.card_type.value, + "title": card.title, + "description": card.description[:200] + "..." if len(card.description) > 200 else card.description, + "options": [{"id": o.id, "label": o.label} for o in card_options], + "created_at": created_card.created_at.isoformat() + } + }) + + logger.info(f"简化创建确认卡片成功: {created_card.id}") + return {"success": True, "card_id": created_card.id, "card": created_card} + + except Exception as e: + logger.error(f"简化创建确认卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建确认卡片失败: {str(e)}") + + +@router.get("") +async def list_confirm_cards( + project_id: str, + status: Optional[ConfirmCardStatus] = None, + include_expired: bool = False, + repo=Depends(get_repo) +) -> List[ConfirmCard]: + """ + 获取确认卡片列表 + + 默认不包含过期的卡片 + """ + try: + return await repo.list_by_project(project_id, status, include_expired) + + except Exception as e: + logger.error(f"获取确认卡片列表失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}") + + +@router.get("/{card_id}") +async def get_confirm_card( + card_id: str, + repo=Depends(get_repo) +) -> ConfirmCard: + """获取确认卡片详情""" + card = await repo.get(card_id) + if not card: + raise HTTPException(status_code=404, detail="确认卡片不存在") + + # 检查是否过期 + if card.expires_at and card.expires_at < datetime.now(): + if card.status == ConfirmCardStatus.PENDING: + card.status = ConfirmCardStatus.EXPIRED + + return card + + +@router.delete("/{card_id}") +async def delete_confirm_card( + card_id: str, + repo=Depends(get_repo) +): + """删除确认卡片""" + try: + success = await repo.delete(card_id) + if not success: + raise HTTPException(status_code=404, detail="确认卡片不存在") + + logger.info(f"删除确认卡片成功: {card_id}") + return {"success": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除确认卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") + + +# ============================================ +# 操作端点 +# ============================================ + +@router.post("/{card_id}/confirm") +async def confirm_card( + card_id: str, + selected_option_id: Optional[str] = None, + custom_response: Optional[str] = None, + user_notes: Optional[str] = None, + repo=Depends(get_repo) +): + """ + 确认卡片 + + 用户选择一个选项或提供自定义回复 + """ + try: + card = await repo.get(card_id) + if not card: + raise HTTPException(status_code=404, detail="确认卡片不存在") + + # 检查是否过期 + if card.expires_at and card.expires_at < datetime.now(): + await repo.update_status(card_id, ConfirmCardStatus.EXPIRED) + raise HTTPException(status_code=400, detail="确认卡片已过期") + + # 更新状态 + updated_card = await repo.update_status( + card_id, + ConfirmCardStatus.APPROVED, + selected_option_id, + custom_response, + user_notes + ) + + # WebSocket通知 + await manager.send_to_project(card.project_id, { + "type": "confirm_card_confirmed", + "data": { + "card_id": card_id, + "status": "approved", + "selected_option": selected_option_id, + "custom_response": custom_response, + "confirmed_at": updated_card.confirmed_at.isoformat() if updated_card.confirmed_at else None + } + }) + + logger.info(f"确认卡片成功: {card_id}, 选择: {selected_option_id}") + return {"success": True, "card": updated_card} + + except HTTPException: + raise + except Exception as e: + logger.error(f"确认卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") + + +@router.post("/{card_id}/reject") +async def reject_card( + card_id: str, + user_notes: Optional[str] = None, + repo=Depends(get_repo) +): + """ + 拒绝确认卡片 + + 用户拒绝该确认任务 + """ + try: + updated_card = await repo.update_status( + card_id, + ConfirmCardStatus.REJECTED, + user_notes=user_notes + ) + + if not updated_card: + raise HTTPException(status_code=404, detail="确认卡片不存在") + + # WebSocket通知 + await manager.send_to_project(updated_card.project_id, { + "type": "confirm_card_rejected", + "data": { + "card_id": card_id, + "status": "rejected", + "user_notes": user_notes + } + }) + + logger.info(f"拒绝确认卡片成功: {card_id}") + return {"success": True, "card": updated_card} + + except HTTPException: + raise + except Exception as e: + logger.error(f"拒绝确认卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") + + +@router.post("/cleanup-expired") +async def cleanup_expired_cards(repo=Depends(get_repo)): + """ + 清理过期的确认卡片 + + 将过期且仍为pending状态的卡片标记为expired + """ + try: + count = await repo.cleanup_expired() + logger.info(f"清理过期确认卡片: {count}个") + return {"success": True, "cleaned_count": count} + + except Exception as e: + logger.error(f"清理过期卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}") diff --git a/backend/app/api/v1/memory.py b/backend/app/api/v1/memory.py index ee8bc2c..0ce53f2 100644 --- a/backend/app/api/v1/memory.py +++ b/backend/app/api/v1/memory.py @@ -550,6 +550,131 @@ async def get_foreshadowing( return foreshadowing +@router.post("/foreshadowing") +async def create_foreshadowing( + project_id: str, + request: dict +): + """ + 手动添加伏笔 + + 允许用户手动添加伏笔 + """ + project = await verify_project(project_id) + + # 直接创建伏笔字典 + new_foreshadowing = { + 'id': str(uuid.uuid4()), + 'description': request.get('description', ''), + 'introduced_at': request.get('plantedEpisode', 1), + 'payoff_at': request.get('payoffEpisode'), + 'type': request.get('type', 'plot'), + 'importance': request.get('importance', ImportanceLevel.MEDIUM), + 'is_payed_off': False, + 'related_events': [], + 'notes': request.get('notes', '') + } + + # 添加到记忆 + if project.memory.foreshadowing is None: + project.memory.foreshadowing = [] + project.memory.foreshadowing.append(new_foreshadowing) + + # 保存 + await project_repo.update(project_id, { + "memory": project.memory.dict() + }) + + logger.info(f"创建伏笔: {new_foreshadowing['id']}") + return new_foreshadowing + + +@router.put("/foreshadowing/{foreshadowing_id}") +async def update_foreshadowing( + project_id: str, + foreshadowing_id: str, + request: dict +): + """ + 更新伏笔信息 + + 允许修改伏笔的描述、状态等 + """ + project = await verify_project(project_id) + + # 查找伏笔 + foreshadowing = None + for f in project.memory.foreshadowing or []: + if f.get('id') == foreshadowing_id: + foreshadowing = f + break + + if not foreshadowing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"伏笔不存在: {foreshadowing_id}" + ) + + # 更新字段 + if request.get('description'): + foreshadowing['description'] = request['description'] + + if request.get('importance'): + foreshadowing['importance'] = request['importance'] + + if 'payoffEpisode' in request: + foreshadowing['payoff_at'] = request['payoffEpisode'] + foreshadowing['is_payed_off'] = request['payoffEpisode'] is not None + + if request.get('notes'): + foreshadowing['notes'] = request['notes'] + + # 保存 + await project_repo.update(project_id, { + "memory": project.memory.dict() + }) + + logger.info(f"更新伏笔: {foreshadowing_id}") + return foreshadowing + + +@router.delete("/foreshadowing/{foreshadowing_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_foreshadowing( + project_id: str, + foreshadowing_id: str +): + """ + 删除伏笔 + + 移除不需要的伏笔记录 + """ + project = await verify_project(project_id) + + # 直接操作 memory.foreshadowing (List[Dict]) + foreshadowing_list = project.memory.foreshadowing or [] + original_count = len(foreshadowing_list) + + # 查找并删除 + project.memory.foreshadowing = [ + f for f in foreshadowing_list + if f.get('id') != foreshadowing_id + ] + + if len(project.memory.foreshadowing) == original_count: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"伏笔不存在: {foreshadowing_id}" + ) + + # 保存 + await project_repo.update(project_id, { + "memory": project.memory.dict() + }) + + logger.info(f"删除伏笔: {foreshadowing_id}") + return None + + # ============================================ # 一致性检查 # ============================================ diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py index 1c7b1f4..e6a3320 100644 --- a/backend/app/api/v1/projects.py +++ b/backend/app/api/v1/projects.py @@ -3,7 +3,7 @@ 提供项目的 CRUD 操作和剧集执行功能 """ -from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Body from typing import List, Optional from pydantic import BaseModel, Field @@ -851,3 +851,375 @@ async def delete_episode_skill_config( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"删除失败: {str(e)}" ) + + +# ============================================ +# 审核配置管理(与前端API路径匹配) +# ============================================ + +@router.get("/{project_id}/review-config") +async def get_review_config(project_id: str): + """ + 获取项目的审核配置 + API路径与前端调用匹配:/projects/{project_id}/review-config + """ + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + # 从项目配置中获取审核配置 + raw_config = getattr(project, 'reviewConfig', None) + + # 如果是字典,转换为 ReviewConfig 对象 + if raw_config and isinstance(raw_config, dict): + from app.models.review import ReviewConfig + try: + review_config = ReviewConfig(**raw_config) + except Exception: + review_config = None + else: + review_config = raw_config + + # 转换为前端期望的格式 + if review_config: + from app.models.review import DimensionType, DimensionConfig + + # 构建前端期望的数据格式 + frontend_config = { + "preset": "standard", + "pass_threshold": getattr(review_config, 'pass_threshold', 75), + "overall_strictness": int(getattr(review_config, 'overall_strictness', 0.7) * 100), + "dimensions": {}, + "customRules": [], + "createdAt": project.createdAt, + "updatedAt": project.updatedAt + } + + # 转换维度配置 + if hasattr(review_config, 'dimension_settings'): + dimension_names = { + DimensionType.character_consistency: "character_consistency", + DimensionType.consistency: "character_consistency", + DimensionType.quality: "quality", + DimensionType.dialogue_quality: "dialogue_quality", + DimensionType.dialogue: "dialogue_quality", + DimensionType.pacing: "pacing", + DimensionType.character: "character_consistency", + DimensionType.plot: "plot_coherence", + DimensionType.plot_coherence: "plot_coherence", + DimensionType.emotional_depth: "emotional_depth", + DimensionType.thematic_strength: "thematic_strength" + } + + for dim_type, dim_config in review_config.dimension_settings.items(): + dim_name = dimension_names.get(dim_type, dim_type.value if hasattr(dim_type, 'value') else str(dim_type)) + frontend_config["dimensions"][dim_name] = { + "name": getattr(dim_config, 'name', dim_name), + "enabled": getattr(dim_config, 'enabled', True), + "strictness": int(getattr(dim_config, 'strictness', 0.7) * 100), + "weight": getattr(dim_config, 'weight', 1.0) + } + + # 确保所有维度都有默认值 + default_dimensions = [ + "character_consistency", "plot_coherence", "dialogue_quality", + "pacing", "emotional_depth", "thematic_strength", "quality" + ] + for dim_name in default_dimensions: + if dim_name not in frontend_config["dimensions"]: + frontend_config["dimensions"][dim_name] = { + "name": dim_name, + "enabled": True, + "strictness": 50, + "weight": 0.7 + } + + # 转换自定义规则 + if hasattr(review_config, 'custom_rules'): + frontend_config["customRules"] = [ + { + "id": rule.id, + "name": rule.name, + "description": rule.description, + "triggerCondition": rule.trigger_condition, + "severity": rule.severity, + "category": rule.dimension, + "isActive": getattr(rule, 'enabled', True) + } + for rule in review_config.custom_rules + ] + + return frontend_config + + # 如果没有配置,返回默认配置 + return { + "preset": "standard", + "pass_threshold": 75, + "overall_strictness": 70, + "dimensions": { + "character_consistency": {"name": "角色一致性", "enabled": True, "strictness": 50, "weight": 0.8}, + "plot_coherence": {"name": "剧情连贯性", "enabled": True, "strictness": 50, "weight": 0.9}, + "dialogue_quality": {"name": "对话质量", "enabled": True, "strictness": 50, "weight": 0.7}, + "pacing": {"name": "节奏控制", "enabled": True, "strictness": 50, "weight": 0.6}, + "emotional_depth": {"name": "情感深度", "enabled": True, "strictness": 50, "weight": 0.7}, + "thematic_strength": {"name": "主题强度", "enabled": True, "strictness": 50, "weight": 0.8}, + "quality": {"name": "整体质量", "enabled": True, "strictness": 50, "weight": 0.7} + }, + "customRules": [], + "createdAt": project.createdAt, + "updatedAt": project.updatedAt + } + + +@router.put("/{project_id}/review-config") +async def update_review_config(project_id: str, config_update: dict): + """ + 更新项目的审核配置 + API路径与前端调用匹配:/projects/{project_id}/review-config + """ + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + try: + from app.models.review import ReviewConfig, DimensionConfig, DimensionType, CustomRule + + # 获取现有配置 + raw_config = getattr(project, 'reviewConfig', None) + + # 如果是字典,转换为 ReviewConfig 对象;否则创建新的 + if isinstance(raw_config, dict): + current_config = ReviewConfig(**raw_config) + else: + current_config = raw_config if raw_config else ReviewConfig() + + # 更新基本配置 + if 'pass_threshold' in config_update: + current_config.pass_threshold = float(config_update['pass_threshold']) + + if 'overall_strictness' in config_update: + current_config.overall_strictness = float(config_update['overall_strictness']) / 100 + + # 更新预设 + if 'preset' in config_update: + # 这里可以根据预设应用不同的配置 + pass + + # 更新维度配置 + if 'dimensions' in config_update: + dimension_map = { + "character_consistency": DimensionType.character_consistency, + "plot_coherence": DimensionType.plot_coherence, + "dialogue_quality": DimensionType.dialogue_quality, + "pacing": DimensionType.pacing, + "emotional_depth": DimensionType.emotional_depth, + "thematic_strength": DimensionType.thematic_strength, + "quality": DimensionType.quality + } + + for dim_name, dim_data in config_update['dimensions'].items(): + dim_type = dimension_map.get(dim_name, DimensionType.quality) + current_config.dimension_settings[dim_type] = DimensionConfig( + enabled=dim_data.get('enabled', True), + strictness=dim_data.get('strictness', 50) / 100, + weight=dim_data.get('weight', 0.7) + ) + + # 更新自定义规则 + if 'customRules' in config_update: + current_config.custom_rules = [] + for rule_data in config_update['customRules']: + if isinstance(rule_data, dict): + rule = CustomRule( + id=rule_data.get('id', str(__import__('uuid').uuid4())), + name=rule_data.get('name', ''), + description=rule_data.get('description', ''), + trigger_condition=rule_data.get('triggerCondition', ''), + dimension=rule_data.get('category', 'other'), + severity=rule_data.get('severity', 'medium'), + enabled=rule_data.get('isActive', True) + ) + current_config.custom_rules.append(rule) + + # 保存到项目 - 使用 .json() 方法确保枚举被正确序列化为字符串 + import json + await project_repo.update(project_id, { + "reviewConfig": json.loads(current_config.json()) + }) + + logger.info(f"更新项目审核配置: {project_id}") + + # 返回更新后的配置 + return await get_review_config(project_id) + + except Exception as e: + logger.error(f"更新审核配置失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"更新配置失败: {str(e)}" + ) + + +@router.post("/{project_id}/review-config/preset") +async def apply_review_preset(project_id: str, data: dict): + """ + 应用审核预设 + API路径与前端调用匹配:/projects/{project_id}/review-config/preset + """ + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + preset = data.get('preset', 'standard') + + try: + from app.models.review import ReviewConfig, DimensionConfig, DimensionType + + # 根据预设创建配置 + config = ReviewConfig() + + if preset == 'draft': + config.overall_strictness = 0.5 + config.pass_threshold = 70.0 + elif preset == 'strict': + config.overall_strictness = 0.9 + config.pass_threshold = 85.0 + else: # standard + config.overall_strictness = 0.7 + config.pass_threshold = 75.0 + + # 应用维度设置 + for dim_type in [DimensionType.consistency, DimensionType.quality, DimensionType.dialogue]: + config.dimension_settings[dim_type] = DimensionConfig( + enabled=True, + strictness=config.overall_strictness, + weight=1.0 + ) + + # 保存配置 - 使用 .json() 方法确保枚举被正确序列化为字符串 + import json + await project_repo.update(project_id, { + "reviewConfig": json.loads(config.json()) + }) + + logger.info(f"应用审核预设: {project_id} - {preset}") + + return await get_review_config(project_id) + + except Exception as e: + logger.error(f"应用预设失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"应用预设失败: {str(e)}" + ) + + +# ============================================ +# 审核 Skills 管理 +# ============================================ + +@router.get("/{project_id}/review/skills") +async def get_project_review_skills(project_id: str): + """获取项目的审核 Skills""" + from app.core.skills.skill_manager import get_skill_manager + + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + skill_manager = get_skill_manager() + + # 获取所有 Skills(不限制分类) + all_skills = await skill_manager.list_skills() + + # 获取项目已启用的审核 Skills + raw_config = getattr(project, 'reviewConfig', None) + enabled_skill_ids = [] + if raw_config and isinstance(raw_config, dict): + enabled_skill_ids = raw_config.get('enabled_review_skills', []) + + # 为每个 Skill 添加启用状态,并转换为前端期望的格式 + skills_with_status = [] + for skill in all_skills: + skill_dict = skill.dict() if hasattr(skill, 'dict') else skill + # 转换为前端期望的格式 + frontend_skill = { + "id": skill_dict.get("id", ""), + "name": skill_dict.get("name", ""), + "description": skill_dict.get("behavior_guide", "") or skill_dict.get("description", ""), + "category": skill_dict.get("category", "other"), + "enabled": skill_dict.get("id", "") in enabled_skill_ids, + "type": skill_dict.get("type", "user"), + "tags": skill_dict.get("tags", []) + } + skills_with_status.append(frontend_skill) + + return { + "skills": skills_with_status, + "enabled_skill_ids": enabled_skill_ids + } + + +@router.put("/{project_id}/review/skills/{skill_id}") +async def update_project_review_skill( + project_id: str, + skill_id: str, + data: dict = None +): + """更新项目审核 Skill 的启用状态""" + from fastapi import Body + from app.models.review import ReviewConfig + + enabled = True + if data and 'enabled' in data: + enabled = data['enabled'] + + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + # 获取现有配置 + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + current_config = ReviewConfig(**raw_config) + else: + current_config = ReviewConfig() + + # 更新启用的 Skills 列表 + enabled_skills = set(current_config.enabled_review_skills or []) + if enabled: + enabled_skills.add(skill_id) + else: + enabled_skills.discard(skill_id) + + current_config.enabled_review_skills = list(enabled_skills) + + # 保存配置 + import json + await project_repo.update(project_id, { + "reviewConfig": json.loads(current_config.json()) + }) + + logger.info(f"更新项目审核Skill: {project_id} - {skill_id} = {enabled}, enabled_skills: {list(enabled_skills)}") + + return { + "success": True, + "skill_id": skill_id, + "enabled": enabled, + "enabled_skills": list(enabled_skills) + } diff --git a/backend/app/api/v1/review.py b/backend/app/api/v1/review.py index 8839aee..d6ce1dd 100644 --- a/backend/app/api/v1/review.py +++ b/backend/app/api/v1/review.py @@ -5,7 +5,7 @@ Review System API Routes 提供审核配置、执行、维度和自定义规则的管理接口 """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query, Body from typing import List, Optional import uuid from datetime import datetime @@ -32,14 +32,14 @@ from app.utils.logger import get_logger logger = get_logger(__name__) -router = APIRouter(prefix="/review", tags=["审核系统"]) +router = APIRouter(tags=["审核系统"]) # ============================================ # 项目审核配置管理 # ============================================ -@router.get("/projects/{project_id}/review-config", response_model=ReviewConfig) +@router.get("/projects/{project_id}/review-config") async def get_review_config(project_id: str): """ 获取项目的审核配置 @@ -48,7 +48,7 @@ async def get_review_config(project_id: str): project_id: 项目ID Returns: - ReviewConfig: 审核配置 + 审核配置(前端格式) """ project = await project_repo.get(project_id) if not project: @@ -59,14 +59,115 @@ async def get_review_config(project_id: str): # 从项目配置中获取审核配置 # 如果还没有配置,返回默认配置 - review_config = getattr(project, 'reviewConfig', None) - if not review_config: + raw_config = getattr(project, 'reviewConfig', None) + + # 如果配置是字典,需要转换为 ReviewConfig 对象 + if isinstance(raw_config, dict): + review_config = ReviewConfig(**raw_config) + elif raw_config is None: review_config = ReviewConfig() + else: + # 已经是 ReviewConfig 对象 + review_config = raw_config - return review_config + # 转换为前端格式 + frontend_config = _convert_to_frontend_config(review_config) + + return frontend_config -@router.put("/projects/{project_id}/review-config", response_model=ReviewConfig) +def _convert_to_frontend_config(config: ReviewConfig) -> dict: + """ + 将后端 ReviewConfig 转换为前端格式 + + Args: + config: 后端配置 + + Returns: + 前端格式配置 + """ + # 定义默认维度配置(前端格式) + default_dimensions = { + "character_consistency": {"name": "角色一致性", "enabled": True, "strictness": 50, "weight": 0.8}, + "plot_coherence": {"name": "剧情连贯性", "enabled": True, "strictness": 50, "weight": 0.9}, + "dialogue_quality": {"name": "对话质量", "enabled": True, "strictness": 50, "weight": 0.7}, + "pacing": {"name": "节奏控制", "enabled": True, "strictness": 50, "weight": 0.6}, + "emotional_depth": {"name": "情感深度", "enabled": True, "strictness": 50, "weight": 0.7}, + "thematic_strength": {"name": "主题强度", "enabled": True, "strictness": 50, "weight": 0.8} + } + + # 从后端配置中提取维度设置 + dimensions = default_dimensions.copy() + for dim_type, dim_config in config.dimension_settings.items(): + dim_key = dim_type.value + if dim_key in default_dimensions: + dimensions[dim_key] = { + "name": default_dimensions[dim_key]["name"], + "enabled": dim_config.enabled, + "strictness": int(dim_config.strictness * 100), # 转换 0-1 为 0-100 + "weight": dim_config.weight + } + else: + # 处理未知维度 + dimensions[dim_key] = { + "name": dim_type.value, + "enabled": dim_config.enabled, + "strictness": int(dim_config.strictness * 100), + "weight": dim_config.weight + } + + # 转换自定义规则 + custom_rules = [] + for rule in config.custom_rules: + dimension_to_category = { + DimensionType.character: "character", + DimensionType.plot: "plot", + DimensionType.dialogue: "dialogue", + DimensionType.pacing: "pacing", + DimensionType.emotional_depth: "emotion", + DimensionType.thematic_strength: "theme", + DimensionType.custom: "other" + } + custom_rules.append({ + "id": rule.id, + "name": rule.name, + "description": rule.description, + "triggerCondition": rule.trigger_condition, + "severity": rule.severity.value if hasattr(rule.severity, 'value') else rule.severity, + "category": dimension_to_category.get(rule.dimension, "other"), + "isActive": rule.enabled + }) + + # 根据 overall_strictness 确定 preset + strictness_to_preset = { + (0.0, 0.6): "draft", + (0.6, 0.8): "standard", + (0.8, 1.0): "strict" + } + preset = "custom" + for (low, high), p in strictness_to_preset.items(): + if low <= config.overall_strictness < high: + preset = p + break + + return { + "preset": preset, + "dimensions": dimensions, + "customRules": custom_rules, + "createdAt": datetime.now().isoformat(), + "updatedAt": datetime.now().isoformat(), + # 额外的配置信息 + "overall_strictness": int(config.overall_strictness * 100), # 转换为 0-100 + "pass_threshold": config.pass_threshold, + "auto_fix_enabled": config.auto_fix_enabled, + "enabled_review_skills": config.enabled_review_skills, + "custom_rules_count": len(config.custom_rules), + "enabled_dimensions_count": sum(1 for d in config.dimension_settings.values() if d.enabled), + "total_dimensions_count": len(config.dimension_settings) + } + + +@router.put("/projects/{project_id}/review-config") async def update_review_config( project_id: str, config_update: ReviewConfigUpdate @@ -79,7 +180,7 @@ async def update_review_config( config_update: 配置更新数据 Returns: - ReviewConfig: 更新后的配置 + 更新后的配置(前端格式) """ project = await project_repo.get(project_id) if not project: @@ -90,13 +191,99 @@ async def update_review_config( try: # 获取现有配置 - current_config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) - # 更新配置 - update_data = config_update.dict(exclude_unset=True) + # 如果配置是字典,需要转换为 ReviewConfig 对象 + if isinstance(raw_config, dict): + current_config = ReviewConfig(**raw_config) + elif raw_config is None: + current_config = ReviewConfig() + else: + # 已经是 ReviewConfig 对象 + current_config = raw_config + # 确保 dimension_settings 存在并是 dict 类型 + if not hasattr(current_config, 'dimension_settings') or not isinstance(current_config.dimension_settings, dict): + current_config.dimension_settings = {} + + # 获取更新数据 + update_data = config_update.dict(exclude_unset=True, exclude_none=True) + + # 处理前端格式转换 + if "dimensions" in update_data and update_data["dimensions"]: + # 前端发送的 dimensions 需要转换为后端的 dimension_settings + dimension_settings = {} + for dim_key, dim_config in update_data["dimensions"].items(): + # 转换为 DimensionType + try: + dim_type = DimensionType(dim_key) + except ValueError: + # 如果不是有效的 DimensionType,使用 custom + dim_type = DimensionType.custom + + # 转换前端格式到后端格式 + # frontend: strictness 0-100, backend: 0-1 + strictness = dim_config.get("strictness", 50) / 100.0 + dimension_settings[dim_type] = DimensionConfig( + enabled=dim_config.get("enabled", True), + strictness=strictness, + weight=dim_config.get("weight", 0.5), + custom_rules=[] + ) + + current_config.dimension_settings = dimension_settings + + if "customRules" in update_data and update_data["customRules"]: + # 前端发送的 customRules 需要转换为后端的 custom_rules + custom_rules = [] + for rule_data in update_data["customRules"]: + # 映射 severity + severity = rule_data.get("severity", "medium") + + # 映射 category 到 dimension + category_to_dimension = { + "character": DimensionType.character, + "plot": DimensionType.plot, + "dialogue": DimensionType.dialogue, + "pacing": DimensionType.pacing, + "emotion": DimensionType.emotional_depth, + "theme": DimensionType.thematic_strength, + "other": DimensionType.custom + } + dimension = category_to_dimension.get(rule_data.get("category", "other"), DimensionType.custom) + + custom_rules.append(CustomRule( + id=rule_data.get("id", str(uuid.uuid4())), + name=rule_data.get("name", ""), + description=rule_data.get("description", ""), + trigger_condition=rule_data.get("triggerCondition", ""), + dimension=dimension, + severity=severity, + enabled=rule_data.get("isActive", True), + created_at=datetime.now(), + updated_at=datetime.now() + )) + + current_config.custom_rules = custom_rules + + # 处理 preset + if "preset" in update_data and update_data["preset"]: + # 根据 preset 设置 overall_strictness + preset_strictness = { + "draft": 0.5, + "standard": 0.7, + "strict": 0.9, + "custom": current_config.overall_strictness + } + current_config.overall_strictness = preset_strictness.get( + update_data["preset"], current_config.overall_strictness + ) + + # 更新其他字段(排除已处理的前端字段) + frontend_fields = {"dimensions", "customRules", "preset"} for field, value in update_data.items(): - setattr(current_config, field, value) + if field not in frontend_fields: + setattr(current_config, field, value) # 保存到项目 await project_repo.update(project_id, { @@ -105,7 +292,8 @@ async def update_review_config( logger.info(f"更新项目审核配置: {project_id}") - return current_config + # 返回前端格式 + return _convert_to_frontend_config(current_config) except Exception as e: logger.error(f"更新审核配置失败: {str(e)}") @@ -206,6 +394,149 @@ async def get_review_presets(project_id: str = None): return presets +# ============================================ +# 项目审核 Skills 管理 +# ============================================ + +@router.get("/projects/{project_id}/review/skills") +async def get_project_review_skills(project_id: str): + """ + 获取项目的审核 Skills + + 返回所有可用的 Skills(不仅是review类型)以及项目中已启用的审核 Skills + + Args: + project_id: 项目ID + + Returns: + 包含可用 Skills 和已启用 Skills 的响应 + """ + from app.core.skills.skill_manager import get_skill_manager + + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + skill_manager = get_skill_manager() + + # 获取所有 Skills(不限制分类) + all_skills = await skill_manager.list_skills() + + # 获取项目已启用的审核 Skills + raw_config = getattr(project, 'reviewConfig', None) + enabled_skill_ids = [] + if raw_config and isinstance(raw_config, dict): + enabled_skill_ids = raw_config.get('enabled_review_skills', []) + + # 获取所有有效的 skill IDs + valid_skill_ids = {skill_dict.get("id") if hasattr(skill, 'dict') else skill.get("id") + for skill in all_skills + for skill_dict in [skill.dict() if hasattr(skill, 'dict') else skill]} + + # 过滤掉不存在的 skill IDs(防止已删除的技能仍在启用列表中) + valid_enabled_skill_ids = [sid for sid in enabled_skill_ids if sid in valid_skill_ids] + + # 如果过滤后发现有不存在的 skill IDs,更新项目配置 + if len(valid_enabled_skill_ids) != len(enabled_skill_ids): + logger.warning(f"Project {project_id} has invalid enabled_review_skills: " + f"removed {set(enabled_skill_ids) - set(valid_enabled_skill_ids)}") + # 异步更新项目配置(不阻塞响应) + import json + from app.models.review import ReviewConfig + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = ReviewConfig() + config.enabled_review_skills = valid_enabled_skill_ids + await project_repo.update(project_id, { + "reviewConfig": json.loads(config.json()) + }) + + # 为每个 Skill 添加启用状态,并转换为前端期望的格式 + skills_with_status = [] + for skill in all_skills: + skill_dict = skill.dict() if hasattr(skill, 'dict') else skill + # 转换为前端期望的格式 + frontend_skill = { + "id": skill_dict.get("id", ""), + "name": skill_dict.get("name", ""), + "description": skill_dict.get("behavior_guide", "") or skill_dict.get("description", ""), + "category": skill_dict.get("category", "other"), + "enabled": skill_dict.get("id", "") in valid_enabled_skill_ids, + "type": skill_dict.get("type", "user"), + "tags": skill_dict.get("tags", []) + } + skills_with_status.append(frontend_skill) + + return { + "skills": skills_with_status, + "enabled_skill_ids": valid_enabled_skill_ids + } + + +@router.put("/projects/{project_id}/review/skills/{skill_id}") +async def update_project_review_skill( + project_id: str, + skill_id: str, + data: dict = Body(...) +): + """ + 更新项目审核 Skill 的启用状态 + + Args: + project_id: 项目ID + skill_id: Skill ID + data: 请求体,包含 enabled 字段 + + Returns: + 更新后的状态 + """ + from app.models.review import ReviewConfig + import json + + enabled = data.get('enabled', True) + + project = await project_repo.get(project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {project_id}" + ) + + # 获取现有配置 + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + current_config = ReviewConfig(**raw_config) + else: + current_config = ReviewConfig() + + # 更新启用的 Skills 列表 + enabled_skills = set(current_config.enabled_review_skills or []) + if enabled: + enabled_skills.add(skill_id) + else: + enabled_skills.discard(skill_id) + + current_config.enabled_review_skills = list(enabled_skills) + + # 保存配置 + await project_repo.update(project_id, { + "reviewConfig": json.loads(current_config.json()) + }) + + logger.info(f"更新项目审核Skill: {project_id} - {skill_id} = {enabled}, enabled_skills: {list(enabled_skills)}") + + return { + "success": True, + "skill_id": skill_id, + "enabled": enabled, + "enabled_skills": list(enabled_skills) + } + + # ============================================ # 审核执行 # ============================================ @@ -261,7 +592,11 @@ async def review_episode( try: # 获取审核配置 - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() # 执行审核 logger.info(f"开始审核: 项目 {project_id}, EP{episode_number}") @@ -415,7 +750,11 @@ async def list_custom_rules( detail=f"项目不存在: {project_id}" ) - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() rules = config.custom_rules else: # 返回空列表(暂时不支持全局规则) @@ -431,7 +770,7 @@ async def list_custom_rules( @router.post("/custom-rules", response_model=CustomRule, status_code=status.HTTP_201_CREATED) async def create_custom_rule( rule_data: CustomRuleCreate, - project_id: str + project_id: str = Query(..., description="项目ID") ): """ 创建自定义规则 @@ -451,19 +790,39 @@ async def create_custom_rule( ) try: + # 处理 category 到 dimension 的映射 + if rule_data.category: + # 映射 category 到 dimension + category_to_dimension = { + "character": DimensionType.character, + "plot": DimensionType.plot, + "dialogue": DimensionType.dialogue, + "pacing": DimensionType.pacing, + "emotion": DimensionType.emotional_depth, + "theme": DimensionType.thematic_strength, + "other": DimensionType.custom + } + dimension = category_to_dimension.get(rule_data.category, DimensionType.custom) + else: + dimension = rule_data.dimension or DimensionType.custom + # 创建规则 new_rule = CustomRule( id=str(uuid.uuid4()), name=rule_data.name, description=rule_data.description, trigger_condition=rule_data.trigger_condition, - dimension=rule_data.dimension, + dimension=dimension, severity=rule_data.severity, enabled=True ) # 添加到项目配置 - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() config.custom_rules.append(new_rule) # 保存 @@ -487,7 +846,7 @@ async def create_custom_rule( async def update_custom_rule( rule_id: str, rule_update: CustomRuleUpdate, - project_id: str + project_id: str = Query(..., description="项目ID") ): """ 更新自定义规则 @@ -507,7 +866,11 @@ async def update_custom_rule( detail=f"项目不存在: {project_id}" ) - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() # 查找规则 rule = None @@ -526,6 +889,24 @@ async def update_custom_rule( # 更新规则 update_data = rule_update.dict(exclude_unset=True) + # 处理 category 到 dimension 的映射 + if 'category' in update_data: + category_to_dimension = { + "character": DimensionType.character, + "plot": DimensionType.plot, + "dialogue": DimensionType.dialogue, + "pacing": DimensionType.pacing, + "emotion": DimensionType.emotional_depth, + "theme": DimensionType.thematic_strength, + "other": DimensionType.custom + } + update_data['dimension'] = category_to_dimension.get( + update_data['category'], + rule.dimension or DimensionType.custom + ) + # 移除 category 字段 + del update_data['category'] + for field, value in update_data.items(): setattr(rule, field, value) @@ -549,7 +930,10 @@ async def update_custom_rule( @router.delete("/custom-rules/{rule_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_custom_rule(rule_id: str, project_id: str): +async def delete_custom_rule( + rule_id: str, + project_id: str = Query(..., description="项目ID") +): """ 删除自定义规则 @@ -567,7 +951,11 @@ async def delete_custom_rule(rule_id: str, project_id: str): detail=f"项目不存在: {project_id}" ) - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() # 查找并删除规则 original_count = len(config.custom_rules) @@ -630,7 +1018,11 @@ async def test_custom_rule( detail=f"剧集不存在: {episode_id}" ) - config = getattr(project, 'reviewConfig', ReviewConfig()) + raw_config = getattr(project, 'reviewConfig', None) + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + else: + config = raw_config or ReviewConfig() # 查找规则 rule = None @@ -667,3 +1059,121 @@ async def test_custom_rule( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"测试失败: {str(e)}" ) + + +# ============================================ +# 审核配置预览 +# ============================================ + +@router.post("/projects/{project_id}/review/preview") +async def preview_review_config( + project_id: str, + config: ReviewConfigUpdate +): + """ + 预览审核配置 + + 整合配置+Skills内容,文字形式展示,模拟注入Agent的效果 + + Args: + project_id: 项目ID + config: 配置数据 + + Returns: + 预览文本和统计信息 + """ + try: + from app.core.skills.skill_manager import get_skill_manager + + # 获取Skills详情 + skill_manager = get_skill_manager() + skills_info = [] + + if config.enabled_review_skills: + for skill_id in config.enabled_review_skills: + skill = skill_manager.get_skill_by_id(skill_id) + if skill: + skills_info.append({ + "id": skill.id, + "name": skill.name, + "description": getattr(skill, 'description', ''), + "behavior_guide": skill.behavior_guide + }) + + # 生成预览文本(模拟注入Agent) + preview_lines = [] + preview_lines.append("# 审核配置预览") + preview_lines.append("") + preview_lines.append("## 基础配置") + + preset_text = config.preset if config.preset else "custom" + preset_map = { + "draft": "草稿模式", + "standard": "标准模式", + "strict": "严格模式" + } + preview_lines.append(f"- 预设模式: {preset_map.get(preset_text, preset_text)}") + + if config.overall_strictness is not None: + preview_lines.append(f"- 整体严格度: {int(config.overall_strictness * 100)}%") + if config.pass_threshold is not None: + preview_lines.append(f"- 通过阈值: {config.pass_threshold}分") + if config.auto_fix_enabled is not None: + preview_lines.append(f"- 自动修复: {'启用' if config.auto_fix_enabled else '禁用'}") + + # 维度配置 + if config.dimension_settings: + preview_lines.append("") + preview_lines.append("## 维度配置") + for dim_type, dim_config in config.dimension_settings.items(): + enabled_text = "启用" if dim_config.enabled else "禁用" + preview_lines.append(f"") + preview_lines.append(f"### {dim_type.value if hasattr(dim_type, 'value') else dim_type}") + preview_lines.append(f"- 状态: {enabled_text}") + preview_lines.append(f"- 严格度: {int(dim_config.strictness * 100)}%") + preview_lines.append(f"- 权重: {dim_config.weight}") + + # Skills配置 + if skills_info: + preview_lines.append("") + preview_lines.append("## 启用的Skills") + for skill in skills_info: + preview_lines.append(f"") + preview_lines.append(f"### {skill['name']}") + preview_lines.append(f"- ID: `{skill['id']}`") + if skill.get('description'): + preview_lines.append(f"- 描述: {skill['description']}") + preview_lines.append("") + preview_lines.append("**行为指南:**") + preview_lines.append("```") + preview_lines.append(skill['behavior_guide']) + preview_lines.append("```") + + # 自定义规则 + custom_rules = config.custom_rules if config.custom_rules else [] + if custom_rules: + preview_lines.append("") + preview_lines.append("## 自定义规则") + for rule in custom_rules: + preview_lines.append(f"") + preview_lines.append(f"### {rule.name}") + preview_lines.append(f"- 描述: {rule.description}") + preview_lines.append(f"- 维度: {rule.dimension.value if hasattr(rule.dimension, 'value') else rule.dimension}") + preview_lines.append(f"- 严重度: {rule.severity.value if hasattr(rule.severity, 'value') else rule.severity}") + preview_lines.append(f"- 触发条件: {rule.trigger_condition}") + + preview_text = "\n".join(preview_lines) + + return { + "preview_text": preview_text, + "skills_count": len(skills_info), + "rules_count": len(custom_rules), + "dimensions_count": len(config.dimension_settings) if config.dimension_settings else 0 + } + + except Exception as e: + logger.error(f"预览审核配置失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"预览失败: {str(e)}" + ) diff --git a/backend/app/api/v1/review_cards.py b/backend/app/api/v1/review_cards.py new file mode 100644 index 0000000..c420953 --- /dev/null +++ b/backend/app/api/v1/review_cards.py @@ -0,0 +1,274 @@ +""" +Review Cards API Routes + +审核卡片API路由 - 提供审核卡片的CRUD操作 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +from datetime import datetime + +from app.models.review_card import ( + ReviewCard, ReviewCardCreate, ReviewCardUpdate, + ReviewCardType, ReviewCardStatus +) +from app.db.review_card_repository import get_review_card_repo +from app.api.v1.websocket import manager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/review-cards", tags=["审核卡片"]) + + +# ============================================ +# 依赖注入 +# ============================================ + +async def get_repo(): + """获取审核卡片仓储""" + return get_review_card_repo() + + +# ============================================ +# CRUD 端点 +# ============================================ + +@router.post("") +async def create_review_card( + data: ReviewCardCreate, + repo=Depends(get_repo) +): + """ + 创建审核卡片 + + 触发场景: + 1. Agent生成剧集内容后自动生成 + 2. 用户在对话框触发某一集/某几集/整个剧集的审核 + """ + try: + card = await repo.create(data) + + # 通过WebSocket推送通知 + await manager.send_to_project(data.project_id, { + "type": "review_card_created", + "data": { + "card_id": card.id, + "card_type": card.card_type.value, + "episode_numbers": card.episode_numbers, + "severity": card.severity, + "review_reason": card.review_reason, + "created_at": card.created_at.isoformat() + } + }) + + logger.info(f"创建审核卡片成功: {card.id}") + return {"success": True, "card": card} + + except Exception as e: + logger.error(f"创建审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建审核卡片失败: {str(e)}") + + +@router.get("") +async def list_review_cards( + project_id: str, + status: Optional[ReviewCardStatus] = None, + card_type: Optional[ReviewCardType] = None, + limit: int = Query(100, ge=1, le=500), + repo=Depends(get_repo) +) -> List[ReviewCard]: + """ + 获取审核卡片列表 + + 支持按状态和类型筛选 + """ + try: + cards = await repo.list_by_project(project_id, status, limit) + + if card_type: + cards = [c for c in cards if c.card_type == card_type] + + return cards + + except Exception as e: + logger.error(f"获取审核卡片列表失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取列表失败: {str(e)}") + + +@router.get("/stats/{project_id}") +async def get_review_card_stats( + project_id: str, + repo=Depends(get_repo) +): + """ + 获取审核卡片统计信息 + + 返回各类状态的数量统计 + """ + try: + total = await repo.count_by_project(project_id) + pending = await repo.count_by_status(project_id, ReviewCardStatus.PENDING) + awaiting_user = await repo.count_by_status(project_id, ReviewCardStatus.AWAITING_USER) + approved = await repo.count_by_status(project_id, ReviewCardStatus.APPROVED) + rejected = await repo.count_by_status(project_id, ReviewCardStatus.REJECTED) + + return { + "total": total, + "pending": pending, + "awaiting_user": awaiting_user, + "approved": approved, + "rejected": rejected + } + + except Exception as e: + logger.error(f"获取审核卡片统计失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取统计失败: {str(e)}") + + +@router.get("/{card_id}") +async def get_review_card( + card_id: str, + repo=Depends(get_repo) +) -> ReviewCard: + """获取审核卡片详情""" + card = await repo.get(card_id) + if not card: + raise HTTPException(status_code=404, detail="审核卡片不存在") + return card + + +@router.put("/{card_id}") +async def update_review_card( + card_id: str, + data: ReviewCardUpdate, + repo=Depends(get_repo) +): + """ + 更新审核卡片 + + 支持更新状态、用户评论、修改内容等 + """ + try: + card = await repo.update(card_id, data) + if not card: + raise HTTPException(status_code=404, detail="审核卡片不存在") + + # 通知WebSocket + await manager.send_to_project(card.project_id, { + "type": "review_card_updated", + "data": { + "card_id": card_id, + "status": card.status.value, + "updated_at": card.updated_at.isoformat() + } + }) + + logger.info(f"更新审核卡片成功: {card_id}") + return {"success": True, "card": card} + + except HTTPException: + raise + except Exception as e: + logger.error(f"更新审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"更新失败: {str(e)}") + + +@router.delete("/{card_id}") +async def delete_review_card( + card_id: str, + repo=Depends(get_repo) +): + """删除审核卡片""" + try: + success = await repo.delete(card_id) + if not success: + raise HTTPException(status_code=404, detail="审核卡片不存在") + + logger.info(f"删除审核卡片成功: {card_id}") + return {"success": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"删除失败: {str(e)}") + + +# ============================================ +# 操作端点 +# ============================================ + +@router.post("/{card_id}/approve") +async def approve_review_card( + card_id: str, + user_comment: Optional[str] = None, + repo=Depends(get_repo) +): + """ + 通过审核卡片 + + 将卡片状态设置为APPROVED + """ + try: + update_data = ReviewCardUpdate( + status=ReviewCardStatus.APPROVED, + user_comment=user_comment + ) + result = await update_review_card(card_id, update_data, repo) + return result + + except Exception as e: + logger.error(f"通过审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") + + +@router.post("/{card_id}/reject") +async def reject_review_card( + card_id: str, + user_comment: Optional[str] = None, + modified_content: Optional[str] = None, + repo=Depends(get_repo) +): + """ + 驳回审核卡片 + + 将卡片状态设置为REJECTED,可选地包含修改内容 + """ + try: + update_data = ReviewCardUpdate( + status=ReviewCardStatus.REJECTED, + user_comment=user_comment, + modified_content=modified_content + ) + result = await update_review_card(card_id, update_data, repo) + return result + + except Exception as e: + logger.error(f"驳回审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") + + +@router.post("/{card_id}/modify") +async def modify_review_card( + card_id: str, + user_comment: Optional[str] = None, + modified_content: Optional[str] = None, + repo=Depends(get_repo) +): + """ + 修改审核卡片内容 + + 将卡片状态设置为MODIFIED,包含修改后的内容 + """ + try: + update_data = ReviewCardUpdate( + status=ReviewCardStatus.AWAITING_USER, + user_comment=user_comment, + modified_content=modified_content + ) + result = await update_review_card(card_id, update_data, repo) + return result + + except Exception as e: + logger.error(f"修改审核卡片失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}") diff --git a/backend/app/api/v1/review_task.py b/backend/app/api/v1/review_task.py new file mode 100644 index 0000000..fbc17e0 --- /dev/null +++ b/backend/app/api/v1/review_task.py @@ -0,0 +1,431 @@ +""" +Review Task API Routes + +审核任务API路由 - 提供过程中审核和生成后审核的管理接口 +""" +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from typing import List, Optional +import asyncio + +from app.models.review_task import ( + ReviewTask, + ReviewMode, + ReviewAction, + ProcessReviewRequest, + UserReviewActionRequest, + PostCreationReviewRequest, + PostCreationReviewResult, + ReviewTaskStatus +) +from app.core.review.review_task_manager import ( + ReviewTaskManager, + get_review_task_manager, + StreamReviewHelper +) +from app.db.repositories import project_repo, episode_repo +from app.models.project import SeriesProject, Episode +from app.utils.logger import get_logger + + +logger = get_logger(__name__) + +router = APIRouter(prefix="/review-task", tags=["审核任务"]) + + +# ============================================ +# 依赖注入 +# ============================================ + +async def get_task_manager() -> ReviewTaskManager: + """获取审核任务管理器""" + return get_review_task_manager() + + +async def get_stream_helper(project_id: str) -> StreamReviewHelper: + """获取流式审核助手""" + task_manager = get_review_task_manager() + return StreamReviewHelper(project_id, task_manager) + + +# ============================================ +# 过程中审核 API +# ============================================ + +@router.post("/process") +async def create_process_review_task( + request: ProcessReviewRequest, + task_manager: ReviewTaskManager = Depends(get_task_manager) +): + """ + 创建过程中审核任务 + + 由Agent在流式输出过程中调用,推送审核任务到前端。 + 前端通过WebSocket接收通知并等待用户确认。 + """ + try: + # 创建审核任务 + task = await task_manager.create_process_review_task(request) + + # 通过WebSocket推送到前端 + from app.api.v1.websocket import manager + await manager.send_to_project(request.project_id, { + "type": "review_task_created", + "data": { + "task_id": task.id, + "mode": task.mode.value, + "issue_type": task.issue_type, + "issue_description": task.issue_description, + "severity": task.severity, + "location": task.location.dict(), + "available_actions": [action.value for action in task.available_actions], + "suggestion": task.suggestion, + "trigger_source": task.trigger_source.value, + "created_at": task.created_at.isoformat() + } + }) + + logger.info(f"过程中审核任务已创建并推送: {task.id}") + + return { + "success": True, + "task_id": task.id, + "message": "审核任务已创建" + } + + except Exception as e: + logger.error(f"创建过程中审核任务失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"创建审核任务失败: {str(e)}" + ) + + +@router.post("/process/{task_id}/action") +async def handle_process_review_action( + task_id: str, + request: UserReviewActionRequest, + task_manager: ReviewTaskManager = Depends(get_task_manager) +): + """ + 处理用户对过程中审核任务的响应 + + Args: + task_id: 任务ID + request: 用户操作请求 + + Returns: + 处理结果 + """ + try: + # 处理用户操作 + task = await task_manager.handle_user_review_action(request) + + # 通过WebSocket通知前端任务已完成 + from app.api.v1.websocket import manager + await manager.send_to_project(task.project_id or "unknown", { + "type": "review_task_completed", + "data": { + "task_id": task_id, + "status": task.status.value, + "user_response": task.user_response, + "completed_at": task.completed_at.isoformat() if task.completed_at else None + } + }) + + logger.info(f"用户操作已处理: {task_id}, 操作: {request.action.value}") + + return { + "success": True, + "task_id": task_id, + "status": task.status.value, + "message": "操作已处理" + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + 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("/process/pending/{project_id}") +async def get_pending_process_tasks( + project_id: str, + task_manager: ReviewTaskManager = Depends(get_task_manager) +) -> List[ReviewTask]: + """ + 获取项目所有待处理的过程审核任务 + + Args: + project_id: 项目ID + + Returns: + List[ReviewTask]: 待处理任务列表 + """ + return task_manager.get_pending_process_tasks(project_id) + + +# ============================================ +# 生成后审核 API +# ============================================ + +@router.post("/post-creation") +async def execute_post_creation_review( + request: PostCreationReviewRequest, + task_manager: ReviewTaskManager = Depends(get_task_manager), + background_tasks: BackgroundTasks = None +): + """ + 执行生成后审核 + + 根据用户配置的规则/Skill进行自动检查,根据阈值报告问题。 + + Args: + request: 生成后审核请求 + background_tasks: FastAPI后台任务管理器 + + Returns: + PostCreationReviewResult: 审核结果 + """ + # 获取项目 + project = await project_repo.get(request.project_id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"项目不存在: {request.project_id}" + ) + + # 获取剧集 + episodes = await episode_repo.list_by_project(request.project_id) + episode = next((ep for ep in episodes if ep.id == request.episode_id), None) + + if not episode: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剧集不存在: {request.episode_id}" + ) + + try: + # 获取审核配置 + from app.models.review import ReviewConfig + raw_config = getattr(project, 'reviewConfig', None) + + if isinstance(raw_config, dict): + config = ReviewConfig(**raw_config) + elif raw_config is None: + config = ReviewConfig() + else: + config = raw_config + + # 确保dimension_settings是dict + if not hasattr(config, 'dimension_settings') or not isinstance(config.dimension_settings, dict): + config.dimension_settings = {} + + # 执行审核 + result = await task_manager.execute_post_creation_review( + request=request, + project_config=config + ) + + # 更新剧集审核结果 + from app.models.review import ReviewResult + review_result = ReviewResult( + id=f"review_{episode.id}", + episode_id=episode.id, + episode_number=episode.number, + project_id=project.id, + overall_score=result.overall_score or 75.0, + passed=result.passed, + passed_dimensions=0, + total_dimensions=1, + dimension_scores=[], + issues=[], + issue_count=result.task_count, + high_severity_count=sum(1 for t in result.tasks if t.severity == "high"), + medium_severity_count=sum(1 for t in result.tasks if t.severity == "medium"), + low_severity_count=sum(1 for t in result.tasks if t.severity == "low") + ) + + # 保存审核结果到剧集 + await episode_repo.update(episode.id, { + "reviewResult": review_result.dict() + }) + + # 通过WebSocket推送审核结果 + from app.api.v1.websocket import manager + await manager.send_to_project(request.project_id, { + "type": "post_creation_review_completed", + "data": { + "episode_id": episode.id, + "episode_number": episode.number, + "overall_score": result.overall_score, + "passed": result.passed, + "task_count": result.task_count, + "tasks": [ + { + "id": task.id, + "issue_type": task.issue_type, + "issue_description": task.issue_description, + "severity": task.severity, + "location": task.location.dict(), + "available_actions": [action.value for action in task.available_actions], + "status": task.status.value, + "suggestion": task.suggestion + } + for task in result.tasks + ] + } + }) + + logger.info(f"生成后审核完成: {episode.id}, 分数: {result.overall_score}") + + return result + + except Exception as e: + logger.error(f"生成后审核失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"审核失败: {str(e)}" + ) + + +@router.post("/post-creation/{task_id}/action") +async def handle_post_creation_task_action( + task_id: str, + request: UserReviewActionRequest, + task_manager: ReviewTaskManager = Depends(get_task_manager) +): + """ + 处理用户对生成后审核任务的响应 + + Args: + task_id: 任务ID + request: 用户操作请求 + + Returns: + 处理结果 + """ + try: + # 处理用户操作 + task = await task_manager.handle_user_review_action(request) + + # 如果修改了内容,更新剧集内容 + if request.action == ReviewAction.MODIFY and request.modified_content: + # 获取剧集 + episodes = await episode_repo.list_by_project(task.project_id or "") + episode = next((ep for ep in episodes if ep.id == task.episode_id), None) + + if episode: + # 更新内容 + await episode_repo.update(episode.id, { + "content": request.modified_content + }) + + # 通过WebSocket通知内容已更新 + from app.api.v1.websocket import manager + await manager.send_to_project(task.project_id or "", { + "type": "episode_content_updated", + "data": { + "episode_id": episode.id, + "episode_number": episode.number + } + }) + + # 通过WebSocket通知任务已完成 + from app.api.v1.websocket import manager + await manager.send_to_project(task.project_id or "", { + "type": "review_task_completed", + "data": { + "task_id": task_id, + "status": task.status.value, + "user_response": task.user_response, + "completed_at": task.completed_at.isoformat() if task.completed_at else None + } + }) + + logger.info(f"生成后审核任务已处理: {task_id}, 操作: {request.action.value}") + + return { + "success": True, + "task_id": task_id, + "status": task.status.value, + "message": "操作已处理" + } + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"处理生成后审核任务操作失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"处理失败: {str(e)}" + ) + + +# ============================================ +# Agent流式审核辅助 API +# ============================================ + +@router.post("/agent/push") +async def agent_push_review_task( + project_id: str, + issue_type: str, + issue_description: str, + severity: str = "medium", + skill_id: Optional[str] = None, + suggestion: Optional[str] = None, + task_manager: ReviewTaskManager = Depends(get_task_manager) +): + """ + Agent推送审核任务(简化版) + + 由Agent在流式输出过程中调用,推送审核任务。 + + Args: + project_id: 项目ID + issue_type: 问题类型 + issue_description: 问题描述 + severity: 严重程度 + skill_id: 使用的Skill ID + suggestion: 修改建议 + + Returns: + 创建的任务ID + """ + try: + from app.models.review_task import ReviewTaskLocation + + task = await task_manager.create_process_review_task( + ProcessReviewRequest( + project_id=project_id, + issue_type=issue_type, + issue_description=issue_description, + severity=severity, + location=ReviewTaskLocation(), + trigger_source="agent_auto", + skill_id=skill_id, + suggestion=suggestion + ) + ) + + return { + "success": True, + "task_id": task.id, + "message": "审核任务已推送" + } + + except Exception as e: + logger.error(f"Agent推送审核任务失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"推送审核任务失败: {str(e)}" + ) diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index bd816d4..c1d9cb6 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -4,7 +4,7 @@ WebSocket Streaming API 提供实时执行进度更新的 WebSocket 端点 """ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query -from typing import Dict, Set, Optional, Any +from typing import Dict, Set, Optional, Any, List import json import asyncio from datetime import datetime @@ -20,6 +20,57 @@ logger = get_logger(__name__) router = APIRouter() +# ============================================ +# 动态Skill选择辅助函数 +# ============================================ + +async def _auto_select_skills_for_context( + user_message: str, + project_id: str, + agent +) -> List[str]: + """ + 根据用户消息自动选择相关Skills + + Args: + user_message: 用户消息 + project_id: 项目ID + agent: Agent实例 + + Returns: + 选中的Skill ID列表 + """ + try: + # 获取可用的用户技能 + user_skills = [] + if agent.context and agent.context.user_skills: + user_skills = agent.context.user_skills + + # 如果有用户配置的Skills,使用智能选择 + if user_skills: + # 从上下文中提取信息进行智能匹配 + context_info = { + "episode_number": agent.context.active_episode_number if agent.context else None, + "project_genre": agent.context.project_genre if agent.context else None, + "creation_mode": agent.context.creation_mode if agent.context else None + } + + selected_skills = await agent.skill_loader.select_skills_for_task( + user_message, + available_skills=user_skills, + context=context_info + ) + + if selected_skills: + logger.info(f"自动选择的Skills: {selected_skills}") + return selected_skills + + return [] + except Exception as e: + logger.warning(f"自动Skill选择失败: {e}") + return [] + + # ============================================ # WebSocket 连接管理 # ============================================ @@ -449,14 +500,14 @@ async def _handle_client_message( # 用户在 Inbox 中点击批准或拒绝 action = message.get("action") item_id = message.get("itemId") - + # 将操作转换为自然语言反馈给 Agent feedback = f"User {action}ed inbox item {item_id}." - + agent = manager.get_agent(project_id, project_dir) # 确保上下文已加载 await _ensure_agent_context(agent, project_id) - + try: for event in agent.stream_events(feedback, thread_id=project_id): # 同样的事件处理逻辑 @@ -469,26 +520,249 @@ async def _handle_client_message( "data": {"message": str(e)} }) + elif message_type == "reset_conversation": + # 用户请求重建对话(清除历史但保留上下文) + try: + # 清除历史消息 + await message_repo.clear_history(project_id) + + # 重新注入上下文到agent + agent = manager.get_agent(project_id, project_dir) + await _ensure_agent_context(agent, project_id) + + await websocket.send_json({ + "type": "conversation_reset", + "data": { + "message": "对话已重建,上下文已保留", + "timestamp": datetime.now().isoformat() + } + }) + + logger.info(f"Conversation reset for project {project_id}") + except Exception as e: + await websocket.send_json({ + "type": "error", + "data": { + "message": f"重建对话失败: {str(e)}" + } + }) + + elif message_type == "clear_conversation": + # 用户请求完全清除对话(包括重置上下文) + try: + # 清除历史消息 + await message_repo.clear_history(project_id) + + # 重置 Agent 上下文(完全重置) + if project_id in manager.project_agents: + del manager.project_agents[project_id] + + # 创建新的 Agent 实例 + agent = manager.get_agent(project_id, project_dir) + await _ensure_agent_context(agent, project_id) + + await websocket.send_json({ + "type": "conversation_cleared", + "data": { + "message": "对话已完全清除,上下文已重置", + "timestamp": datetime.now().isoformat() + } + }) + + logger.info(f"Conversation fully cleared for project {project_id}") + except Exception as e: + await websocket.send_json({ + "type": "error", + "data": { + "message": f"清除对话失败: {str(e)}" + } + }) + elif message_type == "chat_message": # 用户发送聊天消息 -> 触发 Agent 执行 content = message.get("content", "") episode_number = message.get("episodeNumber") episode_title = message.get("episodeTitle") - + view_context = message.get("viewContext", {}) # 新增:接收用户视图上下文 + if not content: return # 保存用户消息 await message_repo.add_message(project_id, "user", content) + # 获取 Agent 并存储视图上下文 + agent = manager.get_agent(project_id, project_dir) + if view_context: + # 存储视图上下文到 Agent 状态中 + agent.runtime.state["current_view_context"] = view_context + logger.info(f"Received view context: {view_context}") + # 获取 Agent agent = manager.get_agent(project_id, project_dir) # 确保上下文已加载(包含当前剧集信息) await _ensure_agent_context(agent, project_id, episode_number, episode_title) + # ========== 动态Skill选择 ========== + # 在执行前自动选择相关的Skills + selected_skill_ids = await _auto_select_skills_for_context(content, project_id, agent) + + # 如果选中了Skills,将其注入到系统提示中 + if selected_skill_ids: + # 动态更新系统提示以包含选中的Skills + # 这将在后续的agent.stream_events中生效 + agent.runtime.state["selected_skills"] = selected_skill_ids + + # 通知用户自动选择的Skills + skill_names = [] + for skill_info in agent.context.user_skills or []: + if skill_info.get('id') in selected_skill_ids: + skill_names.append(skill_info.get('name', skill_info.get('id'))) + + if skill_names: + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n🔧 自动启用技能: {', '.join(skill_names)}\n" + }) + + # 检查是否是生成大纲的请求 + is_outline_generation = "大纲" in content and "生成" in content + if is_outline_generation: + await manager.send_to_project(project_id, { + "type": "outline_streaming_start" + }) + + # 检查是否是开始创作的请求,如果是,尝试注入大纲内容 + # 改进匹配逻辑,支持更多创作相关的关键词 + is_content_creation = any(keyword in content for keyword in ["创作", "开始创作", "写内容", "生成内容"]) + if is_content_creation and episode_number: + try: + from app.db.repositories import episode_repo + episodes = await episode_repo.list_by_project(project_id) + episode = next((ep for ep in episodes if ep.number == episode_number), None) + + if episode and episode.outline and episode.outline.strip(): + # 在用户消息前注入大纲内容 + outline_instruction = f""" +【当前剧集大纲】 +{episode.outline} + +请严格按照上述大纲进行创作。大纲中的场景顺序、情节要点、人物对话都必须完整体现在最终的剧本内容中。 + +创作要求: +1. 完整实现大纲中的所有场景 +2. 保持大纲中的情节要点 +3. 人物对话要符合角色设定 +4. 场景转换要自然流畅 + +现在请开始创作完整的剧集内容。 + +""" + content = outline_instruction + content + logger.info(f"已注入大纲内容到EP{episode_number}的创作请求,大纲长度: {len(episode.outline)}") + + # 通知用户大纲已注入 + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n✅ 已加载 EP{episode_number} 的大纲({len(episode.outline)} 字),将按照大纲进行创作。\n" + }) + else: + logger.info(f"EP{episode_number} 没有大纲内容,将自由创作") + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n⚠️ EP{episode_number} 暂无大纲,将进行自由创作。建议先生成大纲后再开始创作。\n" + }) + except Exception as e: + logger.warning(f"注入大纲内容失败: {e}") + + # 检查是否是模糊请求(需要先获取当前内容) + # 支持的关键词:改、修改、调整、优化、改进、这里不对、把这个... + is_vague_request = any(keyword in content for keyword in [ + "改", "修改", "调整", "优化", "改进", "这里不对", "把这个" + ]) + + # 如果是模糊请求且有视图上下文,自动注入草稿内容 + if is_vague_request and view_context and episode_number: + try: + from app.db.repositories import episode_repo + episodes = await episode_repo.list_by_project(project_id) + episode = next((ep for ep in episodes if ep.number == episode_number), None) + + if episode: + view_type = view_context.get("viewType", "") + content_to_inject = "" + + # 根据视图类型决定注入什么内容 + if view_type == "outline" and episode.outline: + content_to_inject = f""" +【用户正在查看:第 {episode_number} 集大纲】 + +【当前大纲内容】 +{episode.outline} + +用户请求:{content} + +请基于以上大纲进行用户请求的修改/调整/优化。修改后请使用 update_episode() 保存更新后的大纲,并调用 request_review() 进行质量检查。 +""" + elif view_type == "content" and episode.content: + content_preview = episode.content[:3000] + if len(episode.content) > 3000: + content_preview += "\n... (内容过长,已截取前3000字)" + content_to_inject = f""" +【用户正在查看:第 {episode_number} 集内容】 + +【当前内容】 +{content_preview} + +用户请求:{content} + +请基于以上内容进行用户请求的修改/调整/优化。修改后请: +1. 使用 write_to_canvas(content="修改后的内容", append=false) 更新画布 +2. 使用 update_episode() 保存更新的内容 +3. 调用 request_review() 进行质量检查 +4. 调用 update_memory() 更新记忆系统(如果有重要变化) +""" + elif view_type in ["both", "full"]: + # 同时注入大纲和内容 + outline_part = f"\n【大纲】\n{episode.outline}\n" if episode.outline else "" + content_preview = episode.content[:1500] if episode.content else "" + if episode.content and len(episode.content) > 1500: + content_preview += "\n... (内容过长,已截取前1500字)" + content_part = f"\n【内容】\n{content_preview}\n" if episode.content else "" + + content_to_inject = f""" +【用户正在查看:第 {episode_number} 集(完整视图)】 + +{outline_part}{content_part} + +用户请求:{content} + +请基于以上内容进行用户请求的修改/调整/优化。根据用户意图修改大纲或内容。 +修改后请: +1. 使用 write_to_canvas() 和 update_episode() 更新相应内容 +2. 调用 request_review() 进行质量检查 +3. 调用 update_memory() 更新记忆系统(如果有重要变化) +""" + + if content_to_inject: + content = content_to_inject + logger.info(f"已为模糊请求注入草稿内容,视图类型: {view_type}, 集数: {episode_number}") + + # 通知用户 + view_desc = {"outline": "大纲", "content": "内容", "both": "大纲+内容", "full": "大纲+内容"}.get(view_type, "未知") + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n📝 已加载第 {episode_number} 集的{view_desc},将根据您的要求进行调整。\n" + }) + else: + logger.info(f"EP{episode_number} 没有找到剧集数据") + except Exception as e: + logger.warning(f"自动注入草稿内容失败: {e}") + # 异步运行 Agent 并将事件流推送到前端 full_response = "" + outline_content = "" try: for event in agent.stream_events(content, thread_id=project_id): # 检查特殊工具调用并转换格式 @@ -497,10 +771,25 @@ async def _handle_client_message( # 收集 Agent 回复内容 if event.get("type") == "text": - full_response += event.get("content", "") + text_content = event.get("content", "") + full_response += text_content + + # 如果是生成大纲,收集大纲内容 + if is_outline_generation: + outline_content += text_content await manager.send_to_project(project_id, event) + # 如果是生成大纲,发送大纲更新消息 + if is_outline_generation and outline_content: + await manager.send_to_project(project_id, { + "type": "outline_update", + "content": outline_content + }) + await manager.send_to_project(project_id, { + "type": "outline_streaming_end" + }) + # 保存 Agent 回复 if full_response: await message_repo.add_message(project_id, "agent", full_response) @@ -510,12 +799,187 @@ async def _handle_client_message( "type": "error", "data": {"message": str(e)} }) + if is_outline_generation: + await manager.send_to_project(project_id, { + "type": "outline_streaming_end" + }) elif message_type == "get_status": # 请求状态 # 这里可以返回 Agent 的状态,或者之前的 executor 状态 pass + elif message_type == "review_task_created": + # 审核任务已创建 - 来自Agent推送 + # 前端显示审核卡片 + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n📋 [审核任务] {message.get('data', {}).get('issue_type', '未知')}: {message.get('data', {}).get('issue_description', '')}\n" + }) + + elif message_type == "review_task_completed": + # 审核任务已完成 - 用户完成审核任务 + task_data = message.get('data', {}) + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n✅ 审核任务已完成: {task_data.get('status', '未知')}\n" + }) + + elif message_type == "post_creation_review_completed": + # 生成后审核完成 + review_data = message.get('data', {}) + task_count = review_data.get('task_count', 0) + passed = review_data.get('passed', False) + await manager.send_to_project(project_id, { + "type": "text", + "content": f"\n📊 生成后审核完成: 发现 {task_count} 个问题,{'通过' if passed else '未通过'}\n" + }) + + elif message_type == "review_card_action": + # 用户对审核卡片的操作(通过/驳回/修改) + card_id = message.get("cardId") + action = message.get("action") + user_comment = message.get("userComment") + modified_content = message.get("modifiedContent") + + try: + from app.db.review_card_repository import get_review_card_repo + from app.models.review_card import ReviewCardUpdate, ReviewCardStatus + + repo = get_review_card_repo() + card = await repo.get(card_id) + + if not card: + await manager.send_to_project(project_id, { + "type": "error", + "data": {"message": f"审核卡片不存在: {card_id}"} + }) + return + + # 根据操作类型更新卡片 + if action == "approve": + update_data = ReviewCardUpdate( + status=ReviewCardStatus.APPROVED, + user_comment=user_comment + ) + elif action == "reject": + update_data = ReviewCardUpdate( + status=ReviewCardStatus.REJECTED, + user_comment=user_comment, + modified_content=modified_content + ) + elif action == "modify": + update_data = ReviewCardUpdate( + status=ReviewCardStatus.MODIFIED, + user_comment=user_comment, + modified_content=modified_content + ) + elif action == "ignore": + update_data = ReviewCardUpdate( + status=ReviewCardStatus.IGNORED, + user_comment=user_comment + ) + else: + await manager.send_to_project(project_id, { + "type": "error", + "data": {"message": f"未知操作: {action}"} + }) + return + + # 更新卡片 + updated_card = await repo.update(card_id, update_data) + + # 通知前端操作完成 + await manager.send_to_project(project_id, { + "type": "review_card_action_completed", + "data": { + "card_id": card_id, + "action": action, + "status": updated_card.status.value if updated_card else "", + "updated_at": updated_card.updated_at.isoformat() if updated_card else None + } + }) + + logger.info(f"用户对审核卡片执行操作: {card_id}, 操作: {action}") + + except Exception as e: + logger.error(f"处理审核卡片操作失败: {e}") + await manager.send_to_project(project_id, { + "type": "error", + "data": {"message": f"操作失败: {str(e)}"} + }) + + elif message_type == "confirm_card_response": + # 用户对确认卡片的响应 + card_id = message.get("cardId") + selected_option_id = message.get("selectedOptionId") + custom_response = message.get("customResponse") + user_notes = message.get("userNotes") + action = message.get("action", "confirm") + + try: + from app.db.confirm_card_repository import get_confirm_card_repo + from app.models.confirm_card import ConfirmCardStatus + + repo = get_confirm_card_repo() + + if action == "confirm": + # 确认卡片 + updated_card = await repo.update_status( + card_id, + ConfirmCardStatus.APPROVED, + selected_option_id, + custom_response, + user_notes + ) + + if updated_card: + # 通知前端确认完成 + await manager.send_to_project(project_id, { + "type": "confirm_card_processed", + "data": { + "card_id": card_id, + "status": "approved", + "selected_option": selected_option_id, + "custom_response": custom_response + } + }) + + # 如果有Agent,将用户选择反馈给Agent + if project_id in manager.project_agents: + feedback = f"用户已确认卡片 {card_id},选择了选项 {selected_option_id or '自定义回复'}" + if custom_response: + feedback += f": {custom_response}" + + # 可以在这里将feedback传递给agent继续处理 + logger.info(f"用户确认卡片反馈: {feedback}") + + elif action == "reject": + # 拒绝卡片 + updated_card = await repo.update_status( + card_id, + ConfirmCardStatus.REJECTED, + user_notes=user_notes + ) + + await manager.send_to_project(project_id, { + "type": "confirm_card_processed", + "data": { + "card_id": card_id, + "status": "rejected", + "user_notes": user_notes + } + }) + + logger.info(f"用户对确认卡片执行操作: {card_id}, 操作: {action}") + + except Exception as e: + logger.error(f"处理确认卡片响应失败: {e}") + await manager.send_to_project(project_id, { + "type": "error", + "data": {"message": f"操作失败: {str(e)}"} + }) + else: await websocket.send_json({ "type": "error", @@ -666,7 +1130,7 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]): }) elif name == "update_memory": - # 处理记忆库更新 + # 处理记忆库更新并持久化 memory_type = args.get("memory_type", "timeline") data = args.get("data", {}) @@ -700,6 +1164,71 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]): "description": data.get("description", "") }) + # 持久化到项目记忆库 + try: + from app.db.repositories import project_repo + project = await project_repo.get(project_id) + if project and project.memory: + import copy + memory_dict = project.memory.dict() + + if memory_type == "timeline": + # 添加到时间线 + timeline_event = { + "episode": data.get("episode", 0), + "event": data.get("event", ""), + "description": data.get("description", ""), + "timestamp": datetime.now().isoformat() + } + memory_dict["eventTimeline"].append(timeline_event) + logger.info(f"Added timeline event: {data.get('event', 'Unknown')}") + + elif memory_type == "character_state": + # 更新角色状态 + character = data.get("character", "") + if character: + if character not in memory_dict["characterStates"]: + memory_dict["characterStates"][character] = [] + character_state = { + "state": data.get("state", ""), + "description": data.get("description", ""), + "timestamp": datetime.now().isoformat() + } + memory_dict["characterStates"][character].append(character_state) + logger.info(f"Updated character state: {character} - {data.get('state', '')}") + + elif memory_type == "pending_thread": + # 添加待收线 + thread = { + "description": data.get("description", ""), + "status": "pending", + "timestamp": datetime.now().isoformat() + } + memory_dict["pendingThreads"].append(thread) + logger.info(f"Added pending thread: {data.get('description', 'Unknown')}") + + elif memory_type == "foreshadowing": + # 添加伏笔 + foreshadow = { + "description": data.get("description", ""), + "status": "planted", + "timestamp": datetime.now().isoformat() + } + memory_dict["foreshadowing"].append(foreshadow) + logger.info(f"Added foreshadowing: {data.get('description', 'Unknown')}") + + # 保存更新后的记忆库 + await project_repo.update(project_id, {"memory": memory_dict}) + + # 同步上下文状态到前端 + if memory_type in ["character_state", "timeline"]: + # 获取当前剧集号(如果可能) + episode_number = data.get("episode", 0) + await _sync_context_states(project_id, episode_number, project.memory) + + except Exception as e: + logger.warning(f"Failed to persist memory update: {e}") + await manager.send_to_project(project_id, { "type": "memory_update", "data": memory_data @@ -758,6 +1287,60 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]): "title": title }) + elif name == "get_episode_data": + # 处理剧集数据查询 - 直接返回给 Agent 使用 + episode_number = args.get("episode_number") + include_outline = args.get("include_outline", True) + include_content = args.get("include_content", True) + + # 如果没有指定剧集号,使用当前焦点剧集 + if episode_number is None: + agent = manager.get_agent(project_id, project_dir) + episode_number = getattr(agent.context, 'active_episode_number', None) if agent and agent.context else None + + if episode_number: + try: + from app.db.repositories import episode_repo + episodes = await episode_repo.list_by_project(project_id) + episode = next((ep for ep in episodes if ep.number == episode_number), None) + + if episode: + # 将数据格式化为 Agent 可读的格式 + formatted_response = f""" +【剧集数据 - EP{episode_number}】 +标题: {episode.title} +状态: {episode.status} +""" + if include_outline and episode.outline: + formatted_response += f"\n【大纲】\n{episode.outline}\n" + if include_content and episode.content: + content_preview = episode.content[:2000] + if len(episode.content) > 2000: + content_preview += "\n... (内容过长,已截取前2000字)" + formatted_response += f"\n【内容】\n{content_preview}\n" + + # 发送给前端(用于调试/UI显示) + await manager.send_to_project(project_id, { + "type": "episode_data_loaded", + "data": { + "episode_number": episode_number, + "title": episode.title, + "status": episode.status, + "outline": episode.outline if include_outline else None, + "content": episode.content if include_content else None + } + }) + + # 将数据发送给 Agent(通过直接返回,由 stream_events 处理) + return formatted_response + else: + return f"Error: Episode {episode_number} not found in project." + except Exception as e: + logger.error(f"Failed to get episode data: {e}") + return f"Error: Failed to get episode data: {str(e)}" + else: + return "Error: No episode number specified and no episode is currently focused." + elif name == "update_episode": # 处理剧集更新并持久化 episode_number = args.get("episode_number") @@ -819,6 +1402,101 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]): "episodeTitle": title }) + elif name == "request_review": + # 处理审核请求并调用审核管理器 + content = args.get("content", "") + review_type = args.get("review_type", "quality") + criteria = args.get("criteria", []) + + if content: + try: + from app.db.repositories import project_repo, episode_repo + from app.core.review.review_manager import get_review_manager + from app.models.project import ReviewConfig + + # 获取项目 + project = await project_repo.get(project_id) + if not project: + logger.warning(f"Project {project_id} not found for review") + else: + # 尝试找到当前剧集(如果存在) + episodes = await episode_repo.list_by_project(project_id) + episode = None + + # 如果项目有当前剧集焦点,使用它 + from app.core.agent_runtime.director_agent import DirectorAgent + agent = manager.get_agent(project_id, project_dir) + if agent and agent.context and agent.context.active_episode_number: + episode = next((ep for ep in episodes if ep.number == agent.context.active_episode_number), None) + + # 如果没有找到剧集,创建临时剧集对象用于审核 + if not episode: + from app.models.project import Episode + episode = Episode( + projectId=project_id, + number=agent.context.active_episode_number if agent and agent.context else 1, + title="Content Review", + content=content, + status="draft" + ) + + # 获取审核配置(使用项目配置或默认配置) + review_config = ReviewConfig( + enabled=True, + dimensions=["quality", "consistency", "plot"] if not criteria else criteria, + strictness=0.7, + autoApprove=False, + requireHumanReview=True + ) + + # 调用审核管理器 + review_manager = get_review_manager() + review_result = await review_manager.review_episode( + project=project, + episode=episode, + config=review_config + ) + + # 发送审核结果到前端 + await manager.send_to_project(project_id, { + "type": "review_completed", + "data": { + "score": review_result.score, + "passed": review_result.passed, + "issue_count": review_result.issue_count, + "issues": [ + { + "dimension": issue.dimension, + "severity": issue.severity, + "description": issue.description, + "location": issue.location, + "suggestion": issue.suggestion + } + for issue in review_result.issues + ] + } + }) + + logger.info(f"Review completed for project {project_id}: score={review_result.score}, passed={review_result.passed}") + except Exception as e: + logger.error(f"Failed to execute review: {e}") + # 发送错误消息 + await manager.send_to_project(project_id, { + "type": "error", + "data": {"message": f"审核执行失败: {str(e)}"} + }) + + # 同时发送原始的 review_request 消息(用于 UI 显示) + await manager.send_to_project(project_id, { + "type": "review_request", + "id": f"review_{int(datetime.now().timestamp())}", + "title": f"{review_type.capitalize()} Review", + "description": content[:500] + "..." if len(content) > 500 else content, + "criteria": criteria, + "options": ["Approve", "Request Changes"], + "timestamp": int(datetime.now().timestamp() * 1000) + }) + elif name == "create_episode": # 处理剧集创作请求 episode_number = args.get("episode_number") @@ -831,6 +1509,19 @@ async def _handle_tool_call(project_id: str, event: Dict[str, Any]): )) logger.info(f"已启动后台创作任务: EP{episode_number}") + elif name == "plan_episode_review": + # 处理剧集审查规划请求 + episode_selector = args.get("episode_selector") + review_criteria = args.get("review_criteria") + review_dimensions = args.get("review_dimensions") + + if episode_selector: + # 启动后台审查任务 + asyncio.ensure_future(_execute_episode_review( + project_id, episode_selector, review_criteria, review_dimensions + )) + logger.info(f"已启动后台审查任务: {episode_selector}") + # ============================================ # 辅助函数 - 用于从其他模块发送消息 # ============================================ @@ -1006,6 +1697,19 @@ async def _execute_episode_creation( await safe_send("text", {"content": f"\n\n✅ EP{episode_number} 创作完成!质量分数: {episode.qualityScore or 0}\n"}) + # 推送审核卡片到对话框 + if episode.issues and len(episode.issues) > 0: + high_severity_count = sum(1 for issue in episode.issues if getattr(issue, 'severity', 'low') == 'high') + await safe_send("review_complete", { + "data": { + "episode_number": episode_number, + "overall_score": episode.qualityScore or 0, + "passed": episode.status != "needs-review", + "high_severity_count": high_severity_count, + "issues": episode.issues + } + }) + # 广播更新 await safe_send("episode_updated", { "number": episode_number, @@ -1039,6 +1743,239 @@ async def _execute_episode_creation( logger.info(f"后台创作任务结束: {task_key}") +async def _execute_episode_review( + project_id: str, + episode_selector: str, + review_criteria: Optional[List[str]] = None, + review_dimensions: Optional[List[str]] = None +): + """ + 异步执行剧集审查 + + 这个函数在后台执行剧集审查任务: + 1. 解析剧集选择器 + 2. 获取剧集数据 + 3. 执行多维审查 + 4. 生成审查报告 + 5. 推送到审核系统 + """ + task_key = f"{project_id}_review_{episode_selector}" + + try: + from app.db.repositories import project_repo, episode_repo + from app.core.review.review_manager import get_review_manager + from app.core.review.review_task_manager import get_review_task_manager + from app.models.project import ReviewConfig + + # 注册到后台任务字典 + current_task = asyncio.current_task() + if current_task: + _background_tasks[task_key] = current_task + + logger.info(f"开始后台审查任务: {task_key}") + + # 辅助函数:安全发送 WebSocket 消息 + async def safe_send(message_type: str, data: dict = None): + try: + if data is None: + data = {} + await manager.send_to_project(project_id, { + "type": message_type, + "data": data + }) + except Exception as e: + logger.debug(f"WebSocket 发送失败(可能已断开): {e}") + + # 获取项目 + project = await project_repo.get(project_id) + if not project: + await safe_send("error", {"message": f"项目不存在: {project_id}"}) + return + + # 解析剧集选择器 + agent = manager.get_agent(project_id, Path(f"d:/platform/creative_studio/workspace/projects/{project_id}")) + episodes_to_review = _parse_episode_selector_for_review(episode_selector, agent) + + if not episodes_to_review: + await safe_send("error", {"message": f"无效的剧集选择器: {episode_selector}"}) + return + + # 更新计划状态 - 开始 + await safe_send("plan_update", { + "plan": [ + f"获取剧集数据: {len(episodes_to_review)} 集", + f"执行审查: {', '.join(review_dimensions or ['quality', 'consistency', 'plot'])}", + "生成审查报告", + "推送到审核系统" + ], + "status": "reviewing", + "current_step_index": 0 + }) + + await safe_send("text", {"content": f"\n\n--- 开始审查 {len(episodes_to_review)} 集内容 ---\n"}) + + # 获取所有剧集数据 + all_episodes = await episode_repo.list_by_project(project_id) + review_results = [] + + for idx, episode_number in enumerate(episodes_to_review): + episode = next((ep for ep in all_episodes if ep.number == episode_number), None) + if not episode: + logger.warning(f"EP{episode_number} 不存在,跳过") + continue + + await safe_send("text", {"content": f"\n正在审查 EP{episode_number}: {episode.title}...\n"}) + + # 执行审查 + try: + review_config = ReviewConfig( + enabled=True, + dimensions=review_dimensions or ["quality", "consistency", "plot"], + strictness=0.7, + autoApprove=False, + requireHumanReview=True + ) + + review_manager = get_review_manager() + review_result = await review_manager.review_episode( + project=project, + episode=episode, + config=review_config + ) + + review_results.append({ + "episode_number": episode_number, + "episode_title": episode.title, + "score": review_result.score, + "passed": review_result.passed, + "issue_count": review_result.issue_count, + "issues": [ + { + "dimension": issue.dimension, + "severity": issue.severity, + "description": issue.description, + "location": issue.location, + "suggestion": issue.suggestion + } + for issue in review_result.issues + ] + }) + + # 发送单个剧集的审查结果 + await safe_send("episode_review_completed", { + "episode_number": episode_number, + "score": review_result.score, + "passed": review_result.passed, + "issue_count": review_result.issue_count + }) + + except Exception as e: + logger.error(f"审查 EP{episode_number} 失败: {e}") + await safe_send("error", {"message": f"审查 EP{episode_number} 失败: {str(e)}"}) + + # 更新计划状态 - 完成 + await safe_send("plan_update", { + "plan": [ + f"获取剧集数据: {len(episodes_to_review)} 集", + f"执行审查: {', '.join(review_dimensions or ['quality', 'consistency', 'plot'])}", + "生成审查报告", + "推送到审核系统" + ], + "status": "idle", + "current_step_index": 3 + }) + + # 推送到审核系统 + review_task_manager = get_review_task_manager() + try: + for result in review_results: + if result["issue_count"] > 0: + # 创建审核任务 + await review_task_manager.create_task_from_review( + project_id=project_id, + episode_number=result["episode_number"], + review_data=result + ) + logger.info(f"已将 EP{result['episode_number']} 的审查结果推送到审核系统") + + await safe_send("text", {"content": f"\n\n✅ 审查完成!共审查 {len(review_results)} 集,发现问题的剧集已推送到审核系统。\n"}) + await safe_send("review_tasks_created", { + "total_episodes": len(review_results), + "episodes_with_issues": sum(1 for r in review_results if r["issue_count"] > 0), + "review_results": review_results + }) + + except Exception as e: + logger.error(f"推送审核系统失败: {e}") + await safe_send("error", {"message": f"推送审核系统失败: {str(e)}"}) + + logger.info(f"EP{episode_selector} 后台审查完成") + + except Exception as e: + logger.error(f"执行剧集审查失败: {str(e)}", exc_info=True) + try: + await manager.send_to_project(project_id, { + "type": "error", + "data": { + "message": f"审查失败: {str(e)}", + "episode_selector": episode_selector + } + }) + except Exception: + pass + finally: + # 清理任务跟踪 + if task_key in _background_tasks: + del _background_tasks[task_key] + logger.info(f"后台审查任务结束: {task_key}") + + +def _parse_episode_selector_for_review(selector: str, agent) -> List[int]: + """解析剧集选择器,用于审查任务""" + selector_lower = selector.lower().strip() + + # 获取所有剧集号 + episodes = getattr(agent.context, 'episodes', []) + all_episode_numbers = [ep.get('number') for ep in episodes if ep.get('number')] + + # 处理 "all" 或 "全部剧集" + if selector_lower in ['all', '全部', '全部剧集', 'all episodes']: + return sorted(all_episode_numbers) + + # 处理范围 "1-5" 或 "episodes 1-5" + if '-' in selector_lower: + try: + parts = selector_lower.split('-') + start_part = parts[0].strip() + end_part = parts[1].strip() + start = int(''.join(filter(str.isdigit, start_part))) + end = int(''.join(filter(str.isdigit, end_part))) + return list(range(start, end + 1)) + except (ValueError, IndexError): + pass + + # 处理逗号分隔 "1,2,3" + if ',' in selector_lower: + try: + numbers = selector_lower.split(',') + result = [] + for num in numbers: + extracted = int(''.join(filter(str.isdigit, num))) + result.append(extracted) + return sorted(set(result)) + except ValueError: + pass + + # 处理单个剧集 "1" 或 "episode 1" + try: + number = int(''.join(filter(str.isdigit, selector_lower))) + return [number] + except ValueError: + pass + + return [] + + # ============================================ # 辅助函数 - 用于从其他模块发送消息 # ============================================ @@ -1155,6 +2092,58 @@ async def broadcast_to_project( await manager.send_to_project(project_id, message) +async def broadcast_to_project( + project_id: str, + message_type: str, + data: Dict[str, Any] +): + """向项目的所有连接广播消息""" + message = { + "type": message_type, + "data": data, + "timestamp": datetime.now().isoformat() + } + await manager.send_to_project(project_id, message) + + +async def broadcast_review_card_created( + project_id: str, + card_id: str, + card_data: Dict[str, Any] +): + """广播审核卡片创建通知""" + await manager.send_to_project(project_id, { + "type": "review_card_created", + "data": { + "card_id": card_id, + "card_type": card_data.get("card_type"), + "episode_numbers": card_data.get("episode_numbers", []), + "severity": card_data.get("severity"), + "review_reason": card_data.get("review_reason"), + "timestamp": datetime.now().isoformat() + } + }) + + +async def broadcast_confirm_card_created( + project_id: str, + card_id: str, + card_data: Dict[str, Any] +): + """广播确认卡片创建通知""" + await manager.send_to_project(project_id, { + "type": "confirm_card_created", + "data": { + "card_id": card_id, + "card_type": card_data.get("card_type"), + "title": card_data.get("title"), + "description": card_data.get("description", "")[:200], + "options": card_data.get("options", []), + "timestamp": datetime.now().isoformat() + } + }) + + # 导出连接管理器和辅助函数 __all__ = [ "manager", @@ -1163,5 +2152,7 @@ __all__ = [ "broadcast_batch_progress", "broadcast_error", "broadcast_batch_complete", - "broadcast_to_project" + "broadcast_to_project", + "broadcast_review_card_created", + "broadcast_confirm_card_created" ] diff --git a/backend/app/core/agent_runtime/agent.py b/backend/app/core/agent_runtime/agent.py index d4b6fe1..0a703b8 100644 --- a/backend/app/core/agent_runtime/agent.py +++ b/backend/app/core/agent_runtime/agent.py @@ -77,11 +77,17 @@ class LangChainSkillsAgent: Your capabilities include: - Loading and using specialized skills for specific tasks +- Loading reference documents from skills when needed (via load_reference) - Executing bash commands and scripts - Reading and writing files - Following skill instructions to complete complex tasks -When a user request matches a skill's description, use the load_skill tool to get detailed instructions before proceeding.""" +When a user request matches a skill's description: +1. Use `load_skill(skill_name)` to get detailed instructions +2. If the skill has references and the task requires detailed information, use `load_reference(skill_name, reference_name)` to load specific reference material +3. Follow the skill's instructions to complete the task + +**Important**: Reference files in the references/ directory are NOT automatically loaded. Only load them when the skill instructions indicate they are needed for the current task.""" return self.skill_loader.build_system_prompt(base_prompt) def _create_agent(self): diff --git a/backend/app/core/agent_runtime/director_agent.py b/backend/app/core/agent_runtime/director_agent.py index e167ade..4359bf4 100644 --- a/backend/app/core/agent_runtime/director_agent.py +++ b/backend/app/core/agent_runtime/director_agent.py @@ -69,6 +69,32 @@ The User is the Director. Your goal is to help the Director create high-quality - **Series Manager**: You are responsible for the entire series. You can list all episodes, focus on specific ones, and save/update their content. - **Structured Executor**: For any complex task (like "Write the whole series" or "Write Episode 1"), you MUST first create a Plan using `update_plan`. - **Transparent**: Always keep the Director informed of your status via the plan and inbox. +- **Context Aware**: You MUST track the conversation context. Remember what was discussed previously and what actions were taken. + +## Conversation Context Tracking (CRITICAL) + +You MUST maintain awareness of the conversation history: + +1. **When the Director asks to "approve outline" or "批准大纲"**: + - Check the conversation history to find the most recently generated outline + - If an outline was just generated in the conversation, treat the approval as confirmation to proceed + - Immediately proceed to content creation using that approved outline + - DO NOT ask which outline - use the most recent one from the current episode + +2. **When responding to approval or confirmation**: + - Look at the previous messages to understand what is being approved + - If you just generated an outline and got approval, immediately start content creation + - If you just proposed changes and got approval, immediately apply those changes + +3. **Short responses are commands**: + - "批准大纲" or "approve outline" → Immediately start writing content using the approved outline + - "修改" or "revise" → Apply the suggested changes + - "继续" or "continue" → Continue with the next step of the current task + +4. **After generating an outline**: + - Always ask for approval explicitly + - Say: "大纲已生成,请审阅。回复'批准大纲'开始创作,或提出修改意见。" + - Then wait for the Director's response ## Workflow Protocols @@ -83,7 +109,19 @@ The User is the Director. Your goal is to help the Director create high-quality - The creation runs in the background and updates will be sent via WebSocket - The Director will see progress in the plan panel and final content on the canvas -3. **Execution & Writing** +3. **Context-Aware Outline & Content Flow**: + - **Outline Generation**: When asked to generate an outline: + a. Generate the outline content + b. Use `write_to_canvas` to display it in the outline editor (append=false) + c. Ask for approval: "大纲已生成,请审阅。回复'批准大纲'开始创作,或提出修改意见。" + + - **Content Creation**: When approval is received: + a. Check conversation history for the approved outline + b. Load the outline from the episode data + c. Use `write_to_canvas` to write the full content (append=false) + d. Save the episode using `save_episode` or `update_episode` + +4. **Execution & Writing** - Use `list_episodes` to see the current progress of the project. - Use `focus_episode` to navigate between episodes. This will update the user's view. - **Canvas Focus**: The main canvas is for the **actual script/dialogue content**. Do NOT put outlines on the canvas unless specifically asked. @@ -92,7 +130,7 @@ The User is the Director. Your goal is to help the Director create high-quality - Use `save_episode` (for completion) or `update_episode` (for partial updates) to persist content to the database. - Update your plan status as you progress: `update_plan(..., status='writing')`. -4. **AI Editing & Optimization (CRITICAL)** +5. **AI Editing & Optimization (CRITICAL)** - When the Director asks you to "optimize", "improve", "edit", or "revise" content, you MUST: 1. Process the content to improve it based on the request 2. Use `write_to_canvas(content="IMPROVED_FULL_CONTENT", append=false)` to replace the canvas content @@ -102,21 +140,83 @@ The User is the Director. Your goal is to help the Director create high-quality - Always use append=false when doing full content replacement - This is the ONLY way to properly update the canvas for the user to see -5. **Review & Approval** - - NEVER mark a major deliverable as "Final" without Director approval. - - Use `add_inbox_task` to submit drafts or questions to the Director. - - Example: `add_inbox_task(title="Review Episode 1 Outline", type="review", ...)` +6. **Automatic Review & Quality Check (CRITICAL - MANDATORY)** + - **After generating ANY content** (outline or full content), you MUST automatically call `request_review`: + - For outlines: `request_review(content="", review_type="quality", criteria=["Structure", "Pacing", "Plot logic"])` + - For full content: `request_review(content="", review_type="consistency", criteria=["Character voice", "Continuity", "Dialogue quality"])` + - **After receiving approval**: Use `save_episode` or `update_episode` to persist the content + - **If review finds issues**: Use `add_inbox_task` to notify the Director and request guidance + - Use `add_annotation` to mark specific issues that need attention + - NEVER mark a deliverable as "Final" without Director approval -6. **Context & Memory** - - If the story state changes (e.g., location change, character injury), use `update_context`. - - If you spot consistency issues, use `add_annotation` on the canvas. +7. **Automatic Memory Updates (CRITICAL - MANDATORY)** + - **After saving ANY episode content**, you MUST automatically call `update_memory` to track: + - Timeline events: `update_memory(memory_type="timeline", data={"event": "事件描述", "description": "详细描述", "episode": })` + - Character states: `update_memory(memory_type="character_state", data={"character": "角色名", "state": "状态描述", "description": "详细说明"})` + - Pending threads: `update_memory(memory_type="pending_thread", data={"description": "待收线情节描述"})` + - Foreshadowing: `update_memory(memory_type="foreshadowing", data={"description": "伏笔描述"})` + - **Before generating content for a new episode**: Check memory system for: + - Previous character states and locations + - Pending threads that need resolution + - Foreshadowing that should be referenced + - **When you detect state changes**: Immediately update memory (e.g., character injury, location change, new relationship) + +8. **Handling Vague/Indirect Requests (CRITICAL - NEW)** + - **When Director uses vague expressions** like: + - "把这个改一下" (change this) + - "这里不对" (this is wrong) + - "调整下" (adjust this) + - "修改一下" (modify this) + - "优化" (optimize) + - Or any request without specifying what to modify + + **You MUST follow this workflow:** + 1. First call `get_current_view()` to understand what the user is currently looking at + 2. Based on the view, call `get_episode_data()` to retrieve the relevant content: + - If viewing outline: `get_episode_data(include_outline=True, include_content=False)` + - If viewing content: `get_episode_data(include_outline=False, include_content=True)` + - If viewing both: `get_episode_data()` (get both) + 3. Use the retrieved content as the base for your modifications + 4. Apply the requested changes to the content + 5. Use `write_to_canvas(content="MODIFIED_CONTENT", append=false)` to replace the content + 6. Use `update_episode()` or `save_episode()` to persist the changes + 7. Automatically call `request_review()` for quality check + 8. Automatically call `update_memory()` if content changed significantly + + **Example workflow for vague request:** + - User says: "把这个改一下" (change this) + - You call: `get_current_view()` → Returns: "User is viewing: outline of EP1" + - You call: `get_episode_data(episode_number=1, include_outline=True, include_content=False)` + - You receive the current outline content + - You apply reasonable improvements to the outline + - You call: `write_to_canvas(content="IMPROVED_OUTLINE", append=false)` + - You call: `update_episode(episode_number=1, outline="IMPROVED_OUTLINE")` + - You call: `request_review(content="IMPROVED_OUTLINE", review_type="quality")` + +9. **Context & Memory Display** + - If the story state changes (e.g., location change, character injury), use `update_context` to inform the Director. + - Track what episode you're currently working on and what the last action was. + +9. **Skills Usage** + - Use `load_skill` to access specific writing skills when needed + - User-configured skills from the project settings should be prioritized ## Interaction Style - Be professional, creative, and concise. - Use the Director's language (Chinese/English) matching their input. - When you are working, keep the plan updated. +- **ALWAYS track conversation context** - remember what you just did and what the Director is responding to. - CRITICAL: When asked to edit/optimize content, ALWAYS use `write_to_canvas` tool with append=false to replace content, don't just output text. - CRITICAL: When asked to create episodes, use `create_episode` tool which runs in background and doesn't block the interface. +- CRITICAL: When Director says "批准大纲" or similar, immediately proceed to content creation using the most recent outline. +- CRITICAL: **After generating ANY content**, you MUST automatically: + 1. Call `request_review()` to perform quality check + 2. After saving, call `update_memory()` to update the memory system + 3. Notify the Director of the review results and memory updates +- CRITICAL: **Before generating new episode content**, you MUST check the memory system for: + 1. Previous character states and locations + 2. Pending threads that may affect this episode + 3. Foreshadowing elements to reference or resolve """ # 添加项目上下文(如果有) diff --git a/backend/app/core/agent_runtime/director_tools.py b/backend/app/core/agent_runtime/director_tools.py index b9fe97e..7d3a808 100644 --- a/backend/app/core/agent_runtime/director_tools.py +++ b/backend/app/core/agent_runtime/director_tools.py @@ -532,10 +532,150 @@ def list_episodes( lines = ["Episodes in project:"] for ep in episodes: lines.append(f"- EP{ep.get('number')}: {ep.get('title')} [{ep.get('status')}]") - + return "\n".join(lines) +@tool +def get_episode_data( + episode_number: Optional[int] = None, + include_outline: Optional[bool] = None, + include_content: Optional[bool] = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Get episode data (outline and/or content) from the database. + + This tool retrieves episode information from the project database. + Use this when you need to: + - Review the current outline or content before making changes + - Understand what the user is referring to when they say vague things like "change this" + - Get context for a specific episode number + + IMPORTANT: If both include_outline and include_content are None (not specified), + this tool will automatically determine what to return based on the user's current view: + - If user is viewing the outline: returns outline only + - If user is viewing the content: returns content only + - If user is viewing both: returns both + + Args: + episode_number: The episode number to retrieve (if None, uses current focused episode) + include_outline: Whether to include the outline (default: auto-detect from view) + include_content: Whether to include the content (default: auto-detect from view) + + Returns: + The requested episode data in a structured format + + Example: + get_episode_data(episode_number=1, include_outline=True, include_content=True) + """ + if runtime is None: + return "Error: runtime context not available" + + project_id = runtime.context.project_id if runtime.context else None + if not project_id: + return "Error: No project ID in context" + + # 如果没有指定剧集号,使用当前焦点剧集 + if episode_number is None: + episode_number = getattr(runtime.context, 'active_episode_number', None) + if episode_number is None: + return "Error: No episode number specified and no episode is currently focused. Please use focus_episode() first." + + # 根据视图上下文自动判断返回内容 + view_context = runtime.state.get("current_view_context", {}) + view_type = view_context.get("viewType", "") + + if include_outline is None and include_content is None: + # 自动判断:根据用户当前在看什么来决定 + if view_type == "outline": + include_outline = True + include_content = False + elif view_type == "content": + include_outline = False + include_content = True + elif view_type in ["both", "full"]: + include_outline = True + include_content = True + else: + # 默认返回两者 + include_outline = True + include_content = True + + # 存储查询请求到状态中,由 WebSocket 处理器执行数据库查询 + if "episode_data_requests" not in runtime.state: + runtime.state["episode_data_requests"] = [] + + request = { + "episode_number": episode_number, + "include_outline": include_outline if include_outline else False, + "include_content": include_content if include_content else False + } + runtime.state["episode_data_requests"].append(request) + + # 返回提示信息 + return f"""✓ Requesting episode data for EP{episode_number} +(Outline: {'Yes' if include_outline else 'No'}, Content: {'Yes' if include_content else 'No'}) + +The episode data will be retrieved and provided to you. Use this data to understand the user's request and make appropriate changes.""" + + +@tool +def get_current_view( + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Get the current view context (what the user is currently looking at). + + This tool returns information about what part of the interface the user + is currently viewing, such as: + - viewType: 'outline', 'content', 'both', or 'none' + - episodeNumber: the episode number currently focused + - Any other view-specific context + + Use this tool when you need to understand what the user is referring to + with vague expressions like "change this" or "fix this". + + Returns: + The current view context information + + Example: + get_current_view() -> "User is viewing: outline of EP1" + """ + if runtime is None: + return "Error: runtime context not available" + + view_context = runtime.state.get("current_view_context", {}) + view_type = view_context.get("viewType", "unknown") + episode_number = view_context.get("episodeNumber") + + # 构建友好的描述 + view_descriptions = { + "outline": "大纲编辑器", + "content": "内容编辑器/画布", + "both": "完整视图(大纲+内容)", + "full": "完整视图(大纲+内容)", + "none": "未确定" + } + + view_desc = view_descriptions.get(view_type, "未知视图") + episode_info = f",聚焦剧集: EP{episode_number}" if episode_number else "" + + result = f"""【用户当前视图】 +视图类型: {view_type} ({view_desc}){episode_info} +""" + + # 添加建议 + if view_type == "outline": + result += "\n建议: 用户可能希望调整大纲。使用 get_episode_data(include_outline=True) 获取大纲内容。" + elif view_type == "content": + result += "\n建议: 用户可能希望调整内容。使用 get_episode_data(include_content=True) 获取内容。" + elif view_type == "both": + result += "\n建议: 用户可能希望调整大纲或内容。使用 get_episode_data() 获取完整数据。" + + return result + + @tool def update_episode( episode_number: int, @@ -667,6 +807,187 @@ The creation process is running in the background. Progress will be updated automatically.""" +# ============================================ +# 剧集审查规划工具 +# ============================================ + +def _parse_episode_selector(selector: str, runtime: ToolRuntime[SkillAgentContext]) -> List[int]: + """ + 解析剧集选择器,返回剧集号列表 + + 支持的格式: + - 单集: "1", "EP1", "episode 1" -> [1] + - 多集: "1,2,3", "episodes 1, 2, 3" -> [1, 2, 3] + - 范围: "1-5", "episodes 1-5" -> [1, 2, 3, 4, 5] + - 全部: "all", "全部剧集" -> all episodes from context + """ + selector_lower = selector.lower().strip() + episodes = getattr(runtime.context, 'episodes', []) + all_episode_numbers = [ep.get('number') for ep in episodes if ep.get('number')] + + # 处理 "all" 或 "全部剧集" + if selector_lower in ['all', '全部', '全部剧集', 'all episodes']: + return sorted(all_episode_numbers) + + # 处理范围 "1-5" 或 "episodes 1-5" + if '-' in selector_lower: + try: + # 提取数字 + parts = selector_lower.split('-') + start_part = parts[0].strip() + end_part = parts[1].strip() + + # 提取起始和结束数字 + start = int(''.join(filter(str.isdigit, start_part))) + end = int(''.join(filter(str.isdigit, end_part))) + + return list(range(start, end + 1)) + except (ValueError, IndexError): + pass + + # 处理逗号分隔 "1,2,3" + if ',' in selector_lower: + try: + numbers = selector_lower.split(',') + result = [] + for num in numbers: + extracted = int(''.join(filter(str.isdigit, num))) + result.append(extracted) + return sorted(set(result)) + except ValueError: + pass + + # 处理单个剧集 "1" 或 "episode 1" + try: + number = int(''.join(filter(str.isdigit, selector_lower))) + return [number] + except ValueError: + pass + + # 无法解析 + return [] + + +@tool +def plan_episode_review( + episode_selector: str, + review_criteria: Optional[List[str]] = None, + review_dimensions: Optional[List[str]] = None, + runtime: ToolRuntime[SkillAgentContext] = None +) -> str: + """ + Plan and execute an episode review for one or more episodes. + + This tool allows the agent to plan a comprehensive review of episodes. + It supports: + - Single episode review (e.g., "1", "EP1", "episode 1") + - Multiple episodes (e.g., "1,2,3", "episodes 1-3") + - All episodes (e.g., "all", "全部剧集") + - Episode ranges (e.g., "1-5", "episodes 1 through 5") + + After review, results are automatically pushed to the review system. + + Args: + episode_selector: Which episodes to review + - Single: "1", "EP1", "episode 1" + - Multiple: "1,2,3", "episodes 1, 2, 3" + - Range: "1-5", "episodes 1-5" + - All: "all", "全部剧集", "all episodes" + review_criteria: Specific criteria to check (optional) + Examples: ["Character consistency", "Plot logic", "Dialogue quality"] + review_dimensions: Dimensions to evaluate (optional) + Examples: ["quality", "consistency", "plot", "dialogue", "emotion"] + + Returns: + Summary of the review plan and execution status + + Example: + plan_episode_review( + episode_selector="1-3", + review_criteria=["Character consistency", "Plot progression"], + review_dimensions=["quality", "consistency", "plot"] + ) + """ + if runtime is None: + return "Error: runtime context not available" + + project_id = runtime.context.project_id if runtime.context else None + if not project_id: + return "Error: No project ID in context" + + # 解析剧集选择器 + episodes_to_review = _parse_episode_selector(episode_selector, runtime) + + if not episodes_to_review: + return f"Error: No valid episodes found for selector: '{episode_selector}'" + + # 设置默认的审查维度 + if review_dimensions is None: + review_dimensions = ["quality", "consistency", "plot"] + + # 设置默认的审查标准 + if review_criteria is None: + review_criteria = [ + "角色行为一致性", + "剧情逻辑合理性", + "对话自然度", + "场景转换流畅性", + "情感表达准确性" + ] + + # 存储审查计划到状态中 + if "review_plans" not in runtime.state: + runtime.state["review_plans"] = [] + + review_plan = { + "episodes": episodes_to_review, + "criteria": review_criteria, + "dimensions": review_dimensions, + "project_id": project_id, + "status": "pending", + "created_at": None # Will be set by WebSocket handler + } + runtime.state["review_plans"].append(review_plan) + + # 更新执行计划 + plan_steps = [ + f"解析审查请求: {len(episodes_to_review)} 集待审查", + f"获取剧集数据: EP{', EP'.join(map(str, episodes_to_review))}", + f"执行多维审查: {', '.join(review_dimensions)}", + f"生成审查报告", + f"推送到审核系统" + ] + + if PLAN_STATE_KEY in runtime.state: + runtime.state[PLAN_STATE_KEY] = { + "steps": plan_steps, + "current_step_index": 0, + "status": "reviewing" + } + + episode_list_str = ", ".join(f"EP{ep}" for ep in episodes_to_review) + criteria_str = "\n".join(f" • {c}" for c in review_criteria) + dimensions_str = ", ".join(review_dimensions) + + return f"""✓ 剧集审查计划已创建 + +审查剧集: {episode_list_str} +审查维度: {dimensions_str} + +审查标准: +{criteria_str} + +执行步骤: +1. 解析审查请求: {len(episodes_to_review)} 集待审查 +2. 获取剧集数据: EP{', EP'.join(map(str, episodes_to_review))} +3. 执行多维审查: {dimensions_str} +4. 生成审查报告 +5. 推送到审核系统 + +审查将在后台执行,完成后结果将自动推送到审核系统。 +您可以前往「审核系统」标签页查看详细结果。""" + + # 导出工具列表 DIRECTOR_TOOLS = [ update_plan, @@ -679,6 +1000,9 @@ DIRECTOR_TOOLS = [ request_review, focus_episode, list_episodes, + get_current_view, # 新增获取当前视图工具 + get_episode_data, # 新增剧集数据查询工具 update_episode, - create_episode # 新增剧集创作工具 + create_episode, # 新增剧集创作工具 + plan_episode_review # 新增剧集审查规划工具 ] diff --git a/backend/app/core/agent_runtime/skill_loader.py b/backend/app/core/agent_runtime/skill_loader.py index 5b1198a..2d33b74 100644 --- a/backend/app/core/agent_runtime/skill_loader.py +++ b/backend/app/core/agent_runtime/skill_loader.py @@ -3,8 +3,9 @@ Skills 发现和加载器 """ import re +import json from pathlib import Path -from typing import Optional +from typing import Optional, List, Dict, Any from dataclasses import dataclass import yaml @@ -23,9 +24,31 @@ class SkillMetadata: name: str description: str skill_path: Path + requires: list[str] # 依赖的其他 skill 名称列表 + id: Optional[str] = None # Skill ID(用于API选择) + category: Optional[str] = None # 分类 + tags: List[str] = None # 标签 + capabilities: Optional[str] = None # 能力描述(用于选择) + + def __post_init__(self): + if self.tags is None: + self.tags = [] def to_prompt_line(self) -> str: - return f"- **{self.name}**: {self.description}" + requires_suffix = f" (requires: {', '.join(self.requires)})" if self.requires else "" + return f"- **{self.name}**: {self.description}{requires_suffix}" + + +@dataclass +class SkillReference: + """Skill 参考文档""" + name: str + path: Path + description: str = "" # 可选:从文件第一行提取描述 + + def to_prompt_line(self) -> str: + desc = f" - {self.description}" if self.description else "" + return f"- `{self.name}`{desc}" @dataclass @@ -33,6 +56,15 @@ class SkillContent: """Skill 完整内容(Level 2)""" metadata: SkillMetadata instructions: str + references: list[SkillReference] # 可用的参考文档列表 + loaded_skills: dict[str, 'SkillContent'] # 已加载的依赖 skills {skill_name: SkillContent} + + def get_reference(self, name: str) -> Optional[SkillReference]: + """根据名称获取参考文档""" + for ref in self.references: + if ref.name == name: + return ref + return None class SkillLoader: @@ -86,6 +118,13 @@ class SkillLoader: frontmatter = yaml.safe_load(frontmatter_match.group(1)) name = frontmatter.get("name", "") description = frontmatter.get("description", "") + requires = frontmatter.get("requires", []) + + # 确保 requires 是列表 + if isinstance(requires, str): + requires = [requires] + elif not isinstance(requires, list): + requires = [] if not name: return None @@ -94,24 +133,42 @@ class SkillLoader: name=name, description=description, skill_path=skill_md_path.parent, + requires=requires, ) except yaml.YAMLError: return None - def load_skill(self, skill_name: str) -> Optional[SkillContent]: - """Level 2: 加载 Skill 完整内容""" + def load_skill(self, skill_name: str, loading: Optional[set[str]] = None) -> Optional[SkillContent]: + """ + Level 2: 加载 Skill 完整内容(包含 references 列表和依赖的 skills) + + Args: + skill_name: 要加载的 skill 名称 + loading: 正在加载的 skill 集合(用于检测循环依赖) + """ + if loading is None: + loading = set() + + # 检测循环依赖 + if skill_name in loading: + return None + + loading.add(skill_name) + metadata = self._metadata_cache.get(skill_name) if not metadata: self.scan_skills() metadata = self._metadata_cache.get(skill_name) if not metadata: + loading.discard(skill_name) return None skill_md = metadata.skill_path / "SKILL.md" try: content = skill_md.read_text(encoding="utf-8") except Exception: + loading.discard(skill_name) return None body_match = re.match( @@ -121,11 +178,73 @@ class SkillLoader: ) instructions = body_match.group(1).strip() if body_match else content + # 扫描 references/ 目录 + references = self._scan_references(metadata.skill_path) + + # 递归加载依赖的 skills + loaded_skills: dict[str, SkillContent] = {} + for required_skill in metadata.requires: + if required_skill not in loading: # 避免循环 + required_content = self.load_skill(required_skill, loading) + if required_content: + loaded_skills[required_skill] = required_content + + loading.discard(skill_name) + return SkillContent( metadata=metadata, instructions=instructions, + references=references, + loaded_skills=loaded_skills, ) + def _scan_references(self, skill_path: Path) -> list[SkillReference]: + """扫描 skill 的 references/ 目录""" + references_dir = skill_path / "references" + if not references_dir.exists() or not references_dir.is_dir(): + return [] + + references = [] + for ref_file in sorted(references_dir.iterdir()): + if ref_file.is_file() and ref_file.suffix in ['.md', '.txt', '.json']: + # 尝试从文件第一行提取描述 + description = "" + try: + first_line = ref_file.read_text(encoding="utf-8").split('\n')[0] + # 移除 Markdown 标题符号 + first_line = re.sub(r'^#+\s*', '', first_line).strip() + if first_line and not first_line.startswith('#'): + description = first_line[:100] # 限制描述长度 + except Exception: + pass + + references.append(SkillReference( + name=ref_file.name, + path=ref_file, + description=description + )) + + return references + + def load_reference(self, skill_name: str, reference_name: str) -> Optional[str]: + """加载指定 skill 的 reference 文件内容""" + metadata = self._metadata_cache.get(skill_name) + if not metadata: + self.scan_skills() + metadata = self._metadata_cache.get(skill_name) + + if not metadata: + return None + + ref_path = metadata.skill_path / "references" / reference_name + if not ref_path.exists(): + return None + + try: + return ref_path.read_text(encoding="utf-8") + except Exception: + return None + def build_system_prompt(self, base_prompt: str = "") -> str: """构建包含 Skills 列表的 system prompt""" skills = self.scan_skills() @@ -140,10 +259,14 @@ class SkillLoader: skills_section += "1. **Discover**: Review the skills list above\n" skills_section += "2. **Load**: When a user request matches a skill's description, " skills_section += "use `load_skill(skill_name)` to get detailed instructions\n" - skills_section += "3. **Execute**: Follow the skill's instructions, which may include " + skills_section += "3. **References**: If the loaded skill lists available references, " + skills_section += "use `load_reference(skill_name, reference_name)` to load specific reference material when needed\n" + skills_section += "4. **Execute**: Follow the skill's instructions, which may include " skills_section += "running scripts via `bash`\n\n" - skills_section += "**Important**: Only load a skill when it's relevant to the user's request. " - skills_section += "Script code never enters the context - only their output does.\n" + skills_section += "**Important**:\n" + skills_section += "- Only load a skill when it's relevant to the user's request\n" + skills_section += "- Reference files are NOT automatically loaded - only load them when the skill instructions indicate they are needed\n" + skills_section += "- Script code never enters the context - only their output does\n" else: skills_section = "## Skills\n\nNo skills currently available.\n" @@ -151,3 +274,109 @@ class SkillLoader: return f"{base_prompt}\n\n{skills_section}" else: return f"You are a helpful coding assistant.\n\n{skills_section}" + + # ============================================ + # 智能Skill选择方法 + # ============================================ + + async def select_skills_for_task( + self, + user_intent: str, + available_skills: Optional[List[Dict[str, Any]]] = None, + context: Optional[Dict[str, Any]] = None + ) -> List[str]: + """ + 根据用户意图智能选择最相关的Skills + + Args: + user_intent: 用户意图描述 + available_skills: 可用的技能列表(从API获取) + context: 额外的上下文信息 + + Returns: + 选择的Skill ID列表 + """ + try: + # 如果有可用技能列表(从API),进行智能选择 + if available_skills: + selected = [] + + # 基于关键词的简单匹配 + intent_lower = user_intent.lower() + for skill in available_skills: + skill_id = skill.get('id', '') + name = skill.get('name', '').lower() + description = skill.get('description', '').lower() + capabilities = skill.get('capabilities', '').lower() + tags = skill.get('tags', []) + + # 检查描述和能力是否匹配 + match_score = 0 + if name in intent_lower: + match_score += 10 + for tag in tags: + if tag.lower() in intent_lower or any(k in intent_lower for k in tag.lower().split('_')): + match_score += 5 + + # 检查描述匹配 + keywords = ['大纲', '内容', '对话', '角色', '世界观', '审核', '创作', '写作'] + for keyword in keywords: + if keyword in intent_lower and keyword in description: + match_score += 3 + + if match_score > 0: + selected.append((skill_id, match_score)) + + # 按匹配分数排序 + selected.sort(key=lambda x: x[1], reverse=True) + return [skill_id for skill_id, _ in selected[:5]] + + # 如果没有可用技能列表,使用本地扫描的技能 + skills = self.scan_skills() + selected = [] + + intent_lower = user_intent.lower() + for skill in skills: + match_score = 0 + if skill.name.lower() in intent_lower: + match_score += 10 + for tag in skill.tags: + if tag.lower() in intent_lower: + match_score += 5 + + if match_score > 0: + selected.append((skill.name, match_score)) + + selected.sort(key=lambda x: x[1], reverse=True) + return [skill_name for skill_name, _ in selected[:5]] + + except Exception as e: + from app.utils.logger import get_logger + logger = get_logger(__name__) + logger.warning(f"智能Skill选择失败: {e}") + return [] + + def get_user_skills_metadata(self, user_skills: List[Dict[str, Any]]) -> str: + """ + 获取用户配置Skills的元数据格式 + + Args: + user_skills: 用户Skills列表 + + Returns: + 格式化后的Skills信息 + """ + if not user_skills: + return "" + + lines = ["### 用户配置的Skills\n\n"] + for skill in user_skills: + skill_name = skill.get('name', 'Unknown') + skill_id = skill.get('id', '') + behavior = skill.get('behavior', skill.get('description', ''))[:200] + + lines.append(f"- **{skill_name}** (ID: `{skill_id}`)") + lines.append(f" {behavior}...") + lines.append("") + + return "\n".join(lines) diff --git a/backend/app/core/agent_runtime/tools.py b/backend/app/core/agent_runtime/tools.py index beb3d24..4b7164d 100644 --- a/backend/app/core/agent_runtime/tools.py +++ b/backend/app/core/agent_runtime/tools.py @@ -7,6 +7,7 @@ import re from pathlib import Path from langchain.tools import tool, ToolRuntime +from typing import List, Optional, Dict, Any from .skill_loader import SkillLoader from .stream.utils import resolve_path @@ -16,7 +17,7 @@ from .director_tools import DIRECTOR_TOOLS @tool def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str: - """Load a skill's detailed instructions.""" + """Load a skill's detailed instructions. Automatically includes any dependent skills. Returns the SKILL.md instructions and a list of available references.""" loader = runtime.context.skill_loader skill_content = loader.load_skill(skill_name) @@ -31,6 +32,31 @@ def load_skill(skill_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str: skill_path = skill_content.metadata.skill_path scripts_dir = skill_path / "scripts" + # 构建依赖 Skills 部分 + dependencies_section = "" + if skill_content.loaded_skills: + dependencies_section = "\n## Dependent Skills\n\n" + dependencies_section += "This skill depends on the following skills, which have been automatically loaded:\n\n" + for dep_name, dep_content in skill_content.loaded_skills.items(): + dependencies_section += f"### {dep_name}\n\n" + dependencies_section += f"{dep_content.instructions}\n\n" + # 如果依赖 skill 也有 references,也列出来 + if dep_content.references: + dependencies_section += f"**Available References for {dep_name}**: " + dependencies_section += ", ".join(f"`{ref.name}`" for ref in dep_content.references) + dependencies_section += "\n\n" + dependencies_section += "---\n\n" + + # 构建 References 部分 + references_section = "" + if skill_content.references: + references_section = "\n## Available References\n\n" + references_section += "This skill has the following reference documents available:\n\n" + for ref in skill_content.references: + references_section += f"{ref.to_prompt_line()}\n" + references_section += "\n" + references_section += "Use `load_reference(skill_name, reference_name)` to load a specific reference when needed.\n" + path_info = f""" ## Skill Path Info @@ -45,10 +71,37 @@ uv run {scripts_dir}/script_name.py [args] return f"""# Skill: {skill_name} +{dependencies_section} ## Instructions {skill_content.instructions} {path_info} +{references_section} +""" + + +@tool +def load_reference(skill_name: str, reference_name: str, runtime: ToolRuntime[SkillAgentContext]) -> str: + """Load a reference document from a skill's references/ directory. Use this when the skill instructions indicate that additional reference material is needed for a specific task.""" + loader = runtime.context.skill_loader + content = loader.load_reference(skill_name, reference_name) + + if not content: + # 检查 skill 是否存在 + skill_content = loader.load_skill(skill_name) + if not skill_content: + return f"Skill '{skill_name}' not found." + + # 列出可用的 references + if skill_content.references: + available = [r.name for r in skill_content.references] + return f"Reference '{reference_name}' not found in skill '{skill_name}'. Available references: {', '.join(available)}" + else: + return f"Skill '{skill_name}' has no references available." + + return f"""# Reference: {skill_name}/{reference_name} + +{content} """ @@ -290,7 +343,15 @@ def list_dir(path: str, runtime: ToolRuntime[SkillAgentContext]) -> str: ALL_TOOLS = [ - load_skill, bash, read_file, write_file, glob, grep, edit, list_dir, + load_skill, + load_reference, # 按需加载 skill 的 references/ 文档 + bash, + read_file, + write_file, + glob, + grep, + edit, + list_dir, # Director Tools (includes update_plan, add_inbox_task, add_annotation, update_context, write_to_canvas) - *DIRECTOR_TOOLS + *DIRECTOR_TOOLS, ] diff --git a/backend/app/core/agents/series_creation_agent.py b/backend/app/core/agents/series_creation_agent.py index 77f90be..bec5a46 100644 --- a/backend/app/core/agents/series_creation_agent.py +++ b/backend/app/core/agents/series_creation_agent.py @@ -1,5 +1,5 @@ """ -剧集创作 Agent +剧集创作 Agent 现在暂未实现 固定工作流: 1. 加载全局上下文和历史记忆 diff --git a/backend/app/core/review/review_manager.py b/backend/app/core/review/review_manager.py index d92dff9..8dc82f7 100644 --- a/backend/app/core/review/review_manager.py +++ b/backend/app/core/review/review_manager.py @@ -48,6 +48,24 @@ class ReviewManager: "default_strictness": 0.7, "skill_ids": ["consistency_checker"] }, + DimensionType.character_consistency: { + "name": "角色一致性审核", + "description": "检查角色性格、行为、对话风格的前后一致性", + "default_strictness": 0.7, + "skill_ids": ["consistency_checker"] + }, + DimensionType.plot_coherence: { + "name": "剧情连贯性审核", + "description": "检查剧情逻辑、时间线、因果关系的一致性", + "default_strictness": 0.7, + "skill_ids": [] + }, + DimensionType.dialogue_quality: { + "name": "对话质量审核", + "description": "检查对话的自然度、角色声音的区分度", + "default_strictness": 0.6, + "skill_ids": [] + }, DimensionType.quality: { "name": "质量审核", "description": "检查文字质量、表达流畅度", @@ -77,6 +95,24 @@ class ReviewManager: "description": "检查剧情逻辑和完整性", "default_strictness": 0.7, "skill_ids": [] + }, + DimensionType.emotional_depth: { + "name": "情感深度审核", + "description": "检查情感表达的真实性、感染力", + "default_strictness": 0.7, + "skill_ids": [] + }, + DimensionType.thematic_strength: { + "name": "主题强度审核", + "description": "检查主题表达的一致性、深度", + "default_strictness": 0.7, + "skill_ids": [] + }, + DimensionType.custom: { + "name": "自定义审核", + "description": "用户自定义的审核维度", + "default_strictness": 0.5, + "skill_ids": [] } } diff --git a/backend/app/core/review/review_task_manager.py b/backend/app/core/review/review_task_manager.py new file mode 100644 index 0000000..7e3de8f --- /dev/null +++ b/backend/app/core/review/review_task_manager.py @@ -0,0 +1,693 @@ +""" +Review Task Manager + +审核任务管理器 - 负责管理过程中审核和生成后审核任务 +""" +import uuid +from typing import List, Optional, Dict, Any, AsyncIterator +from datetime import datetime, timedelta +from pydantic import ValidationError + +from app.models.review_task import ( + ReviewTask, + ReviewTaskStatus, + ReviewMode, + ReviewAction, + ProcessReviewRequest, + UserReviewActionRequest, + PostCreationReviewRequest, + PostCreationReviewResult, + ReviewTaskLocation, + ReviewTriggerSource +) +from app.models.review import ReviewConfig, DimensionType, DimensionConfig, CustomRule +from app.core.review.review_manager import ReviewManager +from app.utils.logger import get_logger + + +logger = get_logger(__name__) + + +# ============================================ +# 内存存储(过程审核任务自动销毁,不需要持久化) +# ============================================ + +_process_tasks: Dict[str, ReviewTask] = {} + + +class ReviewTaskManager: + """ + 审核任务管理器 + + 职责: + 1. 管理过程中审核任务(内存存储,自动销毁) + 2. 管理生成后审核任务(持久化到项目) + 3. 处理用户对审核任务的响应 + 4. 调用ReviewManager执行审核 + """ + + def __init__(self, review_manager: Optional[ReviewManager] = None): + self.review_manager = review_manager + + if not self.review_manager: + from app.core.review.review_manager import get_review_manager + self.review_manager = get_review_manager() + + logger.info("审核任务管理器初始化完成") + + # ============================================ + # 过程中审核(内存存储,自动销毁) + # ============================================ + + async def create_process_review_task( + self, + request: ProcessReviewRequest + ) -> ReviewTask: + """ + 创建过程中审核任务 + + 过程中审核任务存储在内存中,用户确认后自动销毁。 + + Args: + request: 审核请求 + + Returns: + ReviewTask: 创建的审核任务 + """ + task_id = str(uuid.uuid4()) + + # 根据触发来源确定可用操作 + if request.trigger_source == ReviewTriggerSource.AGENT_AUTO: + # Agent自主推送的审核:确认继续、补充信息、拒绝 + available_actions = [ + ReviewAction.CONFIRM, + ReviewAction.MODIFY, + ReviewAction.OTHER + ] + else: + # 其他来源:标准操作 + available_actions = [ + ReviewAction.CONFIRM, + ReviewAction.IGNORE + ] + + task = ReviewTask( + id=task_id, + project_id=request.project_id, + mode=ReviewMode.PROCESS, + trigger_source=request.trigger_source, + issue_type=request.issue_type, + issue_description=request.issue_description, + severity=request.severity, + location=request.location, + review_config_id=request.review_config_id, + skill_id=request.skill_id, + rule_id=request.rule_id, + available_actions=available_actions, + status=ReviewTaskStatus.AWAITING_USER, + suggestion=request.suggestion, + context_data={ + "trigger_source": request.trigger_source.value, + "skill_id": request.skill_id, + "rule_id": request.rule_id + } + ) + + # 存储到内存 + _process_tasks[task_id] = task + + logger.info( + f"创建过程中审核任务: {task_id}, " + f"问题: {request.issue_type}, 严重度: {request.severity}" + ) + + return task + + async def handle_user_review_action( + self, + request: UserReviewActionRequest + ) -> ReviewTask: + """ + 处理用户对审核任务的响应 + + Args: + request: 用户操作请求 + + Returns: + ReviewTask: 更新后的任务 + """ + task = _process_tasks.get(request.task_id) + + if not task: + logger.warning(f"审核任务不存在: {request.task_id}") + raise ValueError(f"审核任务不存在: {request.task_id}") + + # 验证操作是否可用 + if request.action not in task.available_actions: + logger.warning( + f"操作 {request.action} 对任务 {request.task_id} 不可用, " + f"可用操作: {[a.value for a in task.available_actions]}" + ) + raise ValueError(f"操作不可用") + + # 更新任务状态 + if request.action == ReviewAction.CONFIRM: + task.status = ReviewTaskStatus.COMPLETED + task.user_response = "用户确认继续" + task.completed_at = datetime.now() + + elif request.action == ReviewAction.MODIFY: + task.status = ReviewTaskStatus.MODIFIED + task.user_response = request.user_response or "用户修改了内容" + task.completed_at = datetime.now() + # 保存修改后的内容到上下文 + if request.modified_content: + task.context_data = task.context_data or {} + task.context_data["modified_content"] = request.modified_content + + elif request.action == ReviewAction.OTHER: + task.status = ReviewTaskStatus.COMPLETED + task.user_response = request.user_response or "用户补充了信息" + task.completed_at = datetime.now() + + elif request.action == ReviewAction.IGNORE: + task.status = ReviewTaskStatus.IGNORED + task.user_response = "用户忽略问题" + task.completed_at = datetime.now() + + logger.info( + f"处理用户操作: {request.action.value}, " + f"任务: {task_id}, 新状态: {task.status.value}" + ) + + # 从内存中移除已完成/忽略的任务 + if task.status in [ReviewTaskStatus.COMPLETED, ReviewTaskStatus.IGNORED]: + _process_tasks.pop(request.task_id, None) + logger.info(f"过程审核任务已销毁: {request.task_id}") + + return task + + def get_pending_process_tasks( + self, + project_id: str + ) -> List[ReviewTask]: + """ + 获取项目所有待处理的过程审核任务 + + Args: + project_id: 项目ID + + Returns: + List[ReviewTask]: 待处理任务列表 + """ + return [ + task for task in _process_tasks.values() + if task.project_id == project_id and + task.status == ReviewTaskStatus.AWAITING_USER + ] + + # ============================================ + # 生成后审核(持久化到项目) + # ============================================ + + async def execute_post_creation_review( + self, + request: PostCreationReviewRequest, + project_config: ReviewConfig + ) -> PostCreationReviewResult: + """ + 执行生成后审核 + + 根据用户配置的规则/Skill进行自动检查,根据阈值报告问题。 + + Args: + request: 生成后审核请求 + project_config: 项目审核配置 + + Returns: + PostCreationReviewResult: 审核结果 + """ + from app.models.project import SeriesProject, Episode + + # TODO: 从项目获取剧集 + # 这里简化处理,实际需要从episode_repo获取 + episode = Episode( + projectId=request.project_id, + id=request.episode_id, + number=1, + content="", + status="completed" + ) + project = SeriesProject( + id=request.project_id, + name="", + globalContext=None + ) + + tasks = [] + high_severity_count = 0 + + # 1. 使用配置的Skill进行审核 + if request.use_configured_skills and project_config.enabled_review_skills: + for skill_id in project_config.enabled_review_skills: + skill_tasks = await self._execute_skill_review( + project=project, + episode=episode, + skill_id=skill_id, + project_config=project_config + ) + tasks.extend(skill_tasks) + high_severity_count += sum(1 for t in skill_tasks if t.severity == "high") + + # 2. 使用配置的规则进行审核 + if request.use_configured_rules and project_config.custom_rules: + for rule in project_config.custom_rules: + if rule.enabled: + rule_tasks = await self._execute_rule_review( + project=project, + episode=episode, + rule=rule, + project_config=project_config + ) + tasks.extend(rule_tasks) + high_severity_count += sum(1 for t in rule_tasks if t.severity == "high") + + # 3. 根据阈值判断是否通过 + pass_threshold = request.pass_threshold or project_config.pass_threshold + passed = high_severity_count == 0 + + # 4. 生成总体分数 + if tasks: + overall_score = max(0, 100 - len(tasks) * 5 - high_severity_count * 10) + else: + overall_score = 100.0 + + result = PostCreationReviewResult( + task_count=len(tasks), + passed=passed, + tasks=tasks, + overall_score=overall_score + ) + + logger.info( + f"生成后审核完成: {request.project_id}, " + f"任务数: {len(tasks)}, 高严重度: {high_severity_count}, " + f"通过: {passed}, 分数: {overall_score}" + ) + + return result + + async def _execute_skill_review( + self, + project, + episode, + skill_id: str, + project_config: ReviewConfig + ) -> List[ReviewTask]: + """ + 执行Skill审核 + + Args: + project: 项目 + episode: 剧集 + skill_id: Skill ID + project_config: 审核配置 + + Returns: + List[ReviewTask]: 发现的审核任务 + """ + tasks = [] + + try: + # 获取Skill信息 + from app.core.skills.skill_manager import get_skill_manager + skill_manager = get_skill_manager() + skill = skill_manager.get_skill_by_id(skill_id) + + if not skill: + logger.warning(f"Skill不存在: {skill_id}") + return tasks + + # 使用ReviewManager的LLM审核 + from app.models.review import Issue, IssueType, SeverityLevel, Location + + issues = await self.review_manager._run_llm_review( + project=project, + episode=episode, + dimension=DimensionType.custom, # 使用custom维度代表Skill审核 + strictness=0.7 + ) + + # 转换为ReviewTask + for issue in issues: + task = ReviewTask( + id=str(uuid.uuid4()), + project_id=project.id, + episode_id=episode.id, + mode=ReviewMode.POST_CREATION, + trigger_source=ReviewTriggerSource.USER_CONFIGURED_SKILL, + issue_type=issue.type.value, + issue_description=issue.description, + severity=issue.severity.value, + location=ReviewTaskLocation( + episode_number=issue.location.episode, + scene_number=issue.location.scene, + line_number=issue.location.line, + content_snippet=issue.location.context + ), + skill_id=skill_id, + available_actions=[ReviewAction.CONFIRM, ReviewAction.IGNORE, ReviewAction.MODIFY], + status=ReviewTaskStatus.AWAITING_USER, + suggestion=issue.suggestion + ) + tasks.append(task) + + return tasks + + except Exception as e: + logger.error(f"Skill审核失败 {skill_id}: {str(e)}") + return tasks + + async def _execute_rule_review( + self, + project, + episode, + rule, + project_config: ReviewConfig + ) -> List[ReviewTask]: + """ + 执行规则审核 + + Args: + project: 项目 + episode: 剧集 + rule: 自定义规则 + project_config: 审核配置 + + Returns: + List[ReviewTask]: 发现的审核任务 + """ + tasks = [] + + try: + # 调用ReviewManager的规则检查 + issues = await self.review_manager._check_single_rule( + episode=episode, + rule=rule, + config=project_config + ) + + # 转换为ReviewTask + for issue in issues: + task = ReviewTask( + id=str(uuid.uuid4()), + project_id=project.id, + episode_id=episode.id, + mode=ReviewMode.POST_CREATION, + trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE, + issue_type=issue.type.value, + issue_description=issue.description, + severity=issue.severity.value, + location=ReviewTaskLocation( + episode_number=issue.location.episode, + scene_number=issue.location.scene, + line_number=issue.location.line, + content_snippet=issue.location.context + ), + rule_id=rule.id, + available_actions=[ReviewAction.CONFIRM, ReviewAction.IGNORE, ReviewAction.MODIFY], + status=ReviewTaskStatus.AWAITING_USER, + suggestion=issue.suggestion + ) + tasks.append(task) + + return tasks + + except Exception as e: + logger.error(f"规则审核失败 {rule.id}: {str(e)}") + return tasks + + def get_process_task_by_id( + self, + task_id: str + ) -> Optional[ReviewTask]: + """获取过程中审核任务""" + return _process_tasks.get(task_id) + + def cleanup_expired_tasks(self): + """清理已过期的任务(超过24小时未处理)""" + now = datetime.now() + expired_ids = [] + + for task_id, task in _process_tasks.items(): + if task.status == ReviewTaskStatus.AWAITING_USER: + # 检查是否创建超过24小时 + if (now - task.created_at).total_seconds() > 86400: + expired_ids.append(task_id) + + for task_id in expired_ids: + _process_tasks.pop(task_id, None) + logger.info(f"清理过期审核任务: {task_id}") + + if expired_ids: + return len(expired_ids) + return 0 + + async def create_task_from_review( + self, + project_id: str, + episode_number: int, + review_data: Dict[str, Any] + ) -> ReviewTask: + """ + 从审查结果创建审核任务 + + 当 Agent 完成剧集审查后,将审查结果转换为审核任务并推送到审核系统。 + + Args: + project_id: 项目ID + episode_number: 剧集号 + review_data: 审查数据,包含 score, passed, issue_count, issues 等信息 + + Returns: + ReviewTask: 创建的审核任务 + """ + task_id = str(uuid.uuid4()) + + # 根据 issue_count 判断是否创建任务 + issue_count = review_data.get("issue_count", 0) + issues = review_data.get("issues", []) + + # 创建任务位置信息 + location = ReviewTaskLocation( + episode_number=episode_number, + scene_number=None, + line_number=None, + content_snippet=f"EP{episode_number} 审查发现 {issue_count} 个问题" + ) + + # 如果有问题,创建待处理的审核任务 + if issue_count > 0: + # 找出最高严重度的问题 + high_severity_issues = [i for i in issues if i.get("severity") == "high"] + medium_severity_issues = [i for i in issues if i.get("severity") == "medium"] + + # 确定总体严重度 + if high_severity_issues: + severity = "high" + elif medium_severity_issues: + severity = "medium" + else: + severity = "low" + + # 构建问题描述 + issue_types = set(i.get("dimension", "unknown") for i in issues) + issue_description = f"EP{episode_number} 审查发现 {issue_count} 个问题,涉及维度: {', '.join(issue_types)}" + + # 列出主要问题 + main_issues = [] + for issue in issues[:5]: # 只列出前5个 + main_issues.append(f"• [{issue.get('dimension', 'unknown')}] {issue.get('description', '')[:50]}") + + if main_issues: + issue_description += "\n主要问题:\n" + "\n".join(main_issues) + + # 创建审核任务 + task = ReviewTask( + id=task_id, + project_id=project_id, + episode_id=f"ep_{episode_number}", # 剧集ID(简化) + mode=ReviewMode.POST_CREATION, + trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE, # Agent主动审查 + issue_type="episode_review", + issue_description=issue_description, + severity=severity, + location=location, + available_actions=[ + ReviewAction.CONFIRM, # 确认问题 + ReviewAction.MODIFY, # 修改内容 + ReviewAction.IGNORE # 忽略问题 + ], + status=ReviewTaskStatus.AWAITING_USER, + suggestion=f"建议检查并修复 EP{episode_number} 中发现的问题", + context_data={ + "episode_number": episode_number, + "episode_title": review_data.get("episode_title", ""), + "score": review_data.get("score", 0), + "issue_count": issue_count, + "issues": issues, + "trigger_source": "agent_review", + "created_at": datetime.now().isoformat() + } + ) + + # 存储到内存(生成后审核任务) + _process_tasks[task_id] = task + + logger.info( + f"从审查结果创建审核任务: {task_id}, " + f"EP{episode_number}, 问题数: {issue_count}, 严重度: {severity}" + ) + + return task + else: + # 没有问题,返回一个已确认的任务表示审查通过 + task = ReviewTask( + id=task_id, + project_id=project_id, + episode_id=f"ep_{episode_number}", + mode=ReviewMode.POST_CREATION, + trigger_source=ReviewTriggerSource.USER_CONFIGURED_RULE, + issue_type="episode_review", + issue_description=f"EP{episode_number} 审查通过,未发现问题", + severity="low", + location=location, + available_actions=[ReviewAction.CONFIRM], + status=ReviewTaskStatus.COMPLETED, # 直接标记为已完成 + context_data={ + "episode_number": episode_number, + "episode_title": review_data.get("episode_title", ""), + "score": review_data.get("score", 100), + "issue_count": 0, + "trigger_source": "agent_review", + "created_at": datetime.now().isoformat() + } + ) + + logger.info(f"EP{episode_number} 审查通过,无问题") + + return task + + +# ============================================ +# 流式审核助手 - 支持Agent在生成过程中推送审核 +# ============================================ + +class StreamReviewHelper: + """ + 流式审核助手 + + 帮助Agent在流式输出过程中推送审核任务。 + 提供阻塞式等待用户确认的机制。 + """ + + def __init__(self, project_id: str, task_manager: ReviewTaskManager): + self.project_id = project_id + self.task_manager = task_manager + self.pending_tasks: List[str] = [] + self.user_responses: Dict[str, str] = {} + + async def push_review_task( + self, + issue_type: str, + issue_description: str, + severity: str = "medium", + location: Optional[ReviewTaskLocation] = None, + skill_id: Optional[str] = None, + suggestion: Optional[str] = None + ) -> ReviewTask: + """ + 推送审核任务(由Agent调用) + + 阻塞式等待用户确认后才能继续。 + + Args: + issue_type: 问题类型 + issue_description: 问题描述 + severity: 严重程度 + location: 位置信息 + skill_id: 使用的Skill ID + suggestion: 修改建议 + + Returns: + ReviewTask: 创建的审核任务 + """ + request = ProcessReviewRequest( + project_id=self.project_id, + issue_type=issue_type, + issue_description=issue_description, + severity=severity, + location=location or ReviewTaskLocation(), + trigger_source=ReviewTriggerSource.AGENT_AUTO, + skill_id=skill_id, + suggestion=suggestion + ) + + task = await self.task_manager.create_process_review_task(request) + self.pending_tasks.append(task.id) + + logger.info(f"Agent推送审核任务: {task.id}") + + return task + + async def wait_for_user_response( + self, + task_id: str, + timeout: int = 300 # 5分钟超时 + ) -> Optional[str]: + """ + 等待用户响应(阻塞式) + + Args: + task_id: 任务ID + timeout: 超时时间(秒) + + Returns: + Optional[str]: 用户响应内容,超时返回None + """ + # 检查用户是否已响应 + if task_id in self.user_responses: + return self.user_responses[task_id] + + # 这里简化处理 - 实际应该通过WebSocket通知前端并等待响应 + # 返回None表示等待中 + return None + + def record_user_response(self, task_id: str, response: str): + """记录用户响应""" + self.user_responses[task_id] = response + if task_id in self.pending_tasks: + self.pending_tasks.remove(task_id) + logger.info(f"用户响应已记录: {task_id}, 响应: {response}") + + def has_pending_tasks(self) -> bool: + """是否有待处理的审核任务""" + return len(self.pending_tasks) > 0 + + +# ============================================ +# 全局单例 +# ============================================ + +_review_task_manager: Optional[ReviewTaskManager] = None + + +def get_review_task_manager() -> ReviewTaskManager: + """获取审核任务管理器单例""" + global _review_task_manager + if _review_task_manager is None: + _review_task_manager = ReviewTaskManager() + return _review_task_manager + + +review_task_manager = get_review_task_manager() diff --git a/backend/app/db/confirm_card_repository.py b/backend/app/db/confirm_card_repository.py new file mode 100644 index 0000000..829e268 --- /dev/null +++ b/backend/app/db/confirm_card_repository.py @@ -0,0 +1,191 @@ +""" +Confirm Card Repository + +确认卡片持久化仓储 - JSON文件存储 +""" +from pathlib import Path +from typing import List, Optional +import json +import uuid +from datetime import datetime + +from app.models.confirm_card import ( + ConfirmCard, ConfirmCardStatus, ConfirmCardOption +) +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# 数据文件路径 +DATA_DIR = Path(__file__).parent.parent.parent / "data" +CONFIRM_CARDS_FILE = DATA_DIR / "confirm_cards.json" + + +class ConfirmCardRepository: + """确认卡片仓储""" + + def __init__(self): + self.file_path = CONFIRM_CARDS_FILE + self._cards: Dict[str, ConfirmCard] = {} + self._ensure_data_dir() + self._load() + + def _ensure_data_dir(self): + """确保数据目录存在""" + self.file_path.parent.mkdir(parents=True, exist_ok=True) + + def _load(self): + """从文件加载""" + if self.file_path.exists(): + try: + content = self.file_path.read_text(encoding="utf-8") + if content.strip(): + data = json.loads(content) + for card_id, card_data in data.items(): + # 转换选项列表 + if "options" in card_data and isinstance(card_data["options"], list): + card_data["options"] = [ + ConfirmCardOption(**opt) for opt in card_data["options"] + ] + # 确保datetime正确转换 + if isinstance(card_data.get("created_at"), str): + card_data["created_at"] = datetime.fromisoformat( + card_data["created_at"].replace('Z', '+00:00') + ) + if isinstance(card_data.get("confirmed_at"), str): + card_data["confirmed_at"] = datetime.fromisoformat( + card_data["confirmed_at"].replace('Z', '+00:00') + ) + if isinstance(card_data.get("expires_at"), str): + card_data["expires_at"] = datetime.fromisoformat( + card_data["expires_at"].replace('Z', '+00:00') + ) + self._cards[card_id] = ConfirmCard(**card_data) + except Exception as e: + logger.error(f"加载确认卡片失败: {e}") + + def _save(self): + """保存到文件""" + try: + data = {} + for card_id, card in self._cards.items(): + card_dict = card.dict() + # 确保datetime正确序列化 + if isinstance(card_dict.get("created_at"), datetime): + card_dict["created_at"] = card_dict["created_at"].isoformat() + if isinstance(card_dict.get("confirmed_at"), datetime): + card_dict["confirmed_at"] = card_dict["confirmed_at"].isoformat() + if isinstance(card_dict.get("expires_at"), datetime): + card_dict["expires_at"] = card_dict["expires_at"].isoformat() + data[card_id] = card_dict + + self.file_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + except Exception as e: + logger.error(f"保存确认卡片失败: {e}") + raise + + async def create(self, card: ConfirmCard) -> ConfirmCard: + """创建确认卡片""" + if not card.id: + card.id = str(uuid.uuid4()) + + self._cards[card.id] = card + self._save() + logger.info(f"创建确认卡片: {card.id}") + return card + + async def get(self, card_id: str) -> Optional[ConfirmCard]: + """获取单个卡片""" + return self._cards.get(card_id) + + async def list_by_project( + self, + project_id: str, + status: Optional[ConfirmCardStatus] = None, + include_expired: bool = False + ) -> List[ConfirmCard]: + """列出项目的确认卡片""" + cards = [ + card for card in self._cards.values() + if card.project_id == project_id + ] + + if status: + cards = [c for c in cards if c.status == status] + + if not include_expired: + now = datetime.now() + cards = [c for c in cards if not c.expires_at or c.expires_at > now] + + # 按创建时间倒序 + cards = sorted(cards, key=lambda c: c.created_at, reverse=True) + return cards + + async def update_status( + self, + card_id: str, + status: ConfirmCardStatus, + selected_option_id: Optional[str] = None, + custom_response: Optional[str] = None, + user_notes: Optional[str] = None + ) -> Optional[ConfirmCard]: + """更新确认卡片状态""" + card = self._cards.get(card_id) + if not card: + return None + + card.status = status + card.selected_option_id = selected_option_id + card.custom_response = custom_response + card.user_notes = user_notes + card.confirmed_at = datetime.now() + + self._cards[card_id] = card + self._save() + + logger.info(f"更新确认卡片状态: {card_id} -> {status}") + return card + + async def delete(self, card_id: str) -> bool: + """删除确认卡片""" + if card_id in self._cards: + del self._cards[card_id] + self._save() + return True + return False + + async def cleanup_expired(self) -> int: + """清理过期的确认卡片""" + now = datetime.now() + expired_ids = [ + card_id for card_id, card in self._cards.items() + if card.expires_at and card.expires_at < now + ] + + for card_id in expired_ids: + self._cards[card_id].status = ConfirmCardStatus.EXPIRED + + if expired_ids: + self._save() + logger.info(f"标记 {len(expired_ids)} 个确认卡片为过期") + + return len(expired_ids) + + +# 全局单例 +_confirm_card_repo = None + + +def get_confirm_card_repo() -> ConfirmCardRepository: + """获取确认卡片仓储单例""" + global _confirm_card_repo + if _confirm_card_repo is None: + _confirm_card_repo = ConfirmCardRepository() + return _confirm_card_repo + + +# 导入Dict类型 +from typing import Dict diff --git a/backend/app/db/repositories.py b/backend/app/db/repositories.py index f36848d..0b5f2d1 100644 --- a/backend/app/db/repositories.py +++ b/backend/app/db/repositories.py @@ -299,4 +299,11 @@ class MessageRepository(JsonRepository): """获取项目聊天历史""" return self._data.get(project_id, []) + async def clear_history(self, project_id: str): + """清除项目聊天历史(用于重建对话)""" + if project_id in self._data: + self._data[project_id] = [] + self._save() + logger.info(f"Cleared chat history for project {project_id}") + message_repo = MessageRepository() diff --git a/backend/app/db/review_card_repository.py b/backend/app/db/review_card_repository.py new file mode 100644 index 0000000..55c58a6 --- /dev/null +++ b/backend/app/db/review_card_repository.py @@ -0,0 +1,180 @@ +""" +Review Card Repository + +审核卡片持久化仓储 - JSON文件存储 +""" +from pathlib import Path +from typing import List, Optional +import json +import uuid +from datetime import datetime + +from app.models.review_card import ( + ReviewCard, ReviewCardCreate, ReviewCardUpdate, ReviewCardStatus +) +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +# 数据文件路径 +DATA_DIR = Path(__file__).parent.parent.parent / "data" +REVIEW_CARDS_FILE = DATA_DIR / "review_cards.json" + + +class ReviewCardRepository: + """审核卡片仓储 - JSON文件持久化""" + + def __init__(self): + self.file_path = REVIEW_CARDS_FILE + self._cards: Dict[str, ReviewCard] = {} + self._ensure_data_dir() + self._load() + + def _ensure_data_dir(self): + """确保数据目录存在""" + self.file_path.parent.mkdir(parents=True, exist_ok=True) + + def _load(self): + """从文件加载""" + if self.file_path.exists(): + try: + content = self.file_path.read_text(encoding="utf-8") + if content.strip(): + data = json.loads(content) + for card_id, card_data in data.items(): + # 确保created_at和updated_at是datetime对象 + if isinstance(card_data.get("created_at"), str): + card_data["created_at"] = datetime.fromisoformat( + card_data["created_at"].replace('Z', '+00:00') + ) + if isinstance(card_data.get("updated_at"), str): + card_data["updated_at"] = datetime.fromisoformat( + card_data["updated_at"].replace('Z', '+00:00') + ) + if isinstance(card_data.get("completed_at"), str): + card_data["completed_at"] = datetime.fromisoformat( + card_data["completed_at"].replace('Z', '+00:00') + ) + self._cards[card_id] = ReviewCard(**card_data) + except Exception as e: + logger.error(f"加载审核卡片失败: {e}") + self._cards = {} + + def _save(self): + """保存到文件""" + try: + data = {} + for card_id, card in self._cards.items(): + card_dict = card.dict() + # 确保datetime正确序列化 + if isinstance(card_dict.get("created_at"), datetime): + card_dict["created_at"] = card_dict["created_at"].isoformat() + if isinstance(card_dict.get("updated_at"), datetime): + card_dict["updated_at"] = card_dict["updated_at"].isoformat() + if isinstance(card_dict.get("completed_at"), datetime): + card_dict["completed_at"] = card_dict["completed_at"].isoformat() + data[card_id] = card_dict + + self.file_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + except Exception as e: + logger.error(f"保存审核卡片失败: {e}") + raise + + async def create(self, data: ReviewCardCreate) -> ReviewCard: + """创建审核卡片""" + card_id = str(uuid.uuid4()) + + card = ReviewCard( + id=card_id, + **data.dict(), + created_at=datetime.now(), + updated_at=datetime.now() + ) + + self._cards[card_id] = card + self._save() + + logger.info(f"创建审核卡片: {card_id}") + return card + + async def get(self, card_id: str) -> Optional[ReviewCard]: + """获取单个卡片""" + return self._cards.get(card_id) + + async def list_by_project( + self, + project_id: str, + status: Optional[ReviewCardStatus] = None, + limit: int = 100 + ) -> List[ReviewCard]: + """列出项目的审核卡片""" + cards = [ + card for card in self._cards.values() + if card.project_id == project_id + ] + + if status: + cards = [c for c in cards if c.status == status] + + # 按创建时间倒序 + cards = sorted(cards, key=lambda c: c.created_at, reverse=True) + return cards[:limit] + + async def update(self, card_id: str, data: ReviewCardUpdate) -> Optional[ReviewCard]: + """更新审核卡片""" + card = self._cards.get(card_id) + if not card: + return None + + # 更新字段 + update_data = data.dict(exclude_unset=True, exclude_none=True) + for field, value in update_data.items(): + setattr(card, field, value) + + card.updated_at = datetime.now() + if data.status == ReviewCardStatus.COMPLETED: + card.completed_at = datetime.now() + + self._cards[card_id] = card + self._save() + + logger.info(f"更新审核卡片: {card_id}") + return card + + async def delete(self, card_id: str) -> bool: + """删除审核卡片""" + if card_id in self._cards: + del self._cards[card_id] + self._save() + return True + return False + + async def count_by_project(self, project_id: str) -> int: + """统计项目的审核卡片数量""" + return sum(1 for card in self._cards.values() if card.project_id == project_id) + + async def count_by_status(self, project_id: str, status: ReviewCardStatus) -> int: + """统计项目特定状态的审核卡片数量""" + return sum( + 1 for card in self._cards.values() + if card.project_id == project_id and card.status == status + ) + + +# 全局单例 +_review_card_repo = None + + +def get_review_card_repo() -> ReviewCardRepository: + """获取审核卡片仓储单例""" + global _review_card_repo + if _review_card_repo is None: + _review_card_repo = ReviewCardRepository() + return _review_card_repo + + +# 导入Dict类型 +from typing import Dict diff --git a/backend/app/main.py b/backend/app/main.py index 10826ef..0f0c370 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -41,8 +41,11 @@ async def lifespan(app: FastAPI): # 初始化审核管理器 from app.core.review.review_manager import get_review_manager + from app.core.review.review_task_manager import get_review_task_manager review_mgr = get_review_manager() + review_task_mgr = get_review_task_manager() logger.info(f"✅ 审核管理器初始化完成") + logger.info(f"✅ 审核任务管理器初始化完成") # 初始化批量执行器 from app.core.execution.batch_executor import get_batch_executor @@ -108,7 +111,11 @@ async def health_check(): # ============================================ # API 路由注册 # ============================================ -from app.api.v1 import skills, projects, ai_assistant, memory, review, websocket, uploads, episodes, async_tasks, ai_async, skills_async +from app.api.v1 import ( + skills, projects, ai_assistant, memory, review, review_task, + review_cards, confirm_cards, websocket, uploads, episodes, + async_tasks, ai_async, skills_async +) app.include_router(skills.router, prefix="/api/v1") app.include_router(skills_async.router, prefix="/api/v1") @@ -119,6 +126,9 @@ app.include_router(async_tasks.router, prefix="/api/v1") app.include_router(ai_async.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") app.include_router(review.router, prefix="/api/v1") +app.include_router(review_task.router, prefix="/api/v1") +app.include_router(review_cards.router, prefix="/api/v1") +app.include_router(confirm_cards.router, prefix="/api/v1") app.include_router(uploads.router, prefix="/api/v1") app.include_router(websocket.router) # WebSocket router doesn't need prefix diff --git a/backend/app/models/confirm_card.py b/backend/app/models/confirm_card.py new file mode 100644 index 0000000..8a6b8ab --- /dev/null +++ b/backend/app/models/confirm_card.py @@ -0,0 +1,79 @@ +""" +Confirm Card Model + +确认卡片模型 - Agent推送的用户确认任务 + +特点: +- 用户可以中途退出后随时确认 +- 支持多个选项 +- 持久化存储 +- Agent可以根据用户选择继续任务 +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime +from enum import Enum + + +class ConfirmCardType(str, Enum): + """确认卡片类型""" + DIRECTION_DECISION = "direction_decision" # 创作方向决策 + PLOT_BRANCH = "plot_branch" # 剧情分支选择 + CHARACTER_ACTION = "character_action" # 角色行为确认 + CONTENT_APPROVAL = "content_approval" # 内容审批 + RESOURCE_ALLOCATION = "resource_allocation" # 资源分配确认 + CUSTOM_QUESTION = "custom_question" # 自定义问题 + + +class ConfirmCardStatus(str, Enum): + """确认卡片状态""" + PENDING = "pending" # 待确认 + APPROVED = "approved" # 已批准 + REJECTED = "rejected" # 已拒绝 + MODIFIED = "modified" # 已修改建议 + EXPIRED = "expired" # 已过期 + + +class ConfirmCardOption(BaseModel): + """确认选项""" + id: str = Field(..., description="选项ID") + label: str = Field(..., description="选项标签") + description: str = Field("", description="选项描述") + implications: Optional[str] = Field(None, description="选择此选项的影响") + + +class ConfirmCard(BaseModel): + """ + 确认卡片 - Agent推送的用户确认任务 + """ + # 基础信息 + id: str = Field(..., description="卡片ID") + project_id: str = Field(..., description="项目ID") + episode_number: Optional[int] = Field(None, description="相关集数") + + # 卡片类型 + card_type: ConfirmCardType = Field(..., description="确认类型") + title: str = Field(..., description="确认标题") + description: str = Field(..., description="详细描述") + context_data: Optional[str] = Field(None, description="上下文信息(长文本)") + + # 确认选项 + options: List[ConfirmCardOption] = Field(default_factory=list, description="可选选项") + allow_custom_response: bool = Field(False, description="是否允许自定义回复") + + # 用户响应 + status: ConfirmCardStatus = Field(default=ConfirmCardStatus.PENDING, description="状态") + selected_option_id: Optional[str] = Field(None, description="选择的选项ID") + custom_response: Optional[str] = Field(None, description="自定义回复内容") + user_notes: Optional[str] = Field(None, description="用户备注") + + # 时间戳 + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + expires_at: Optional[datetime] = Field(None, description="过期时间(可选)") + confirmed_at: Optional[datetime] = Field(None, description="确认时间") + + # 元数据 + metadata: Dict[str, Any] = Field(default_factory=dict, description="附加信息") + + class Config: + use_enum_values = False diff --git a/backend/app/models/review.py b/backend/app/models/review.py index c9c3989..15054b4 100644 --- a/backend/app/models/review.py +++ b/backend/app/models/review.py @@ -23,11 +23,16 @@ class SeverityLevel(str, Enum): class DimensionType(str, Enum): """审核维度类型""" consistency = "consistency" # 一致性审核 + character_consistency = "character_consistency" # 角色一致性审核 quality = "quality" # 质量审核 pacing = "pacing" # 节奏审核 dialogue = "dialogue" # 对话审核 + dialogue_quality = "dialogue_quality" # 对话质量审核 character = "character" # 人物审核 plot = "plot" # 剧情审核 + plot_coherence = "plot_coherence" # 剧情连贯性审核 + emotional_depth = "emotional_depth" # 情感深度审核 + thematic_strength = "thematic_strength" # 主题强度审核 custom = "custom" # 自定义审核 @@ -181,6 +186,11 @@ class ReviewConfigUpdate(BaseModel): auto_fix_enabled: Optional[bool] = None pass_threshold: Optional[float] = Field(None, ge=0.0, le=100.0) + # 前端兼容字段 - 使用 Dict[str, Any] 避免前向引用问题 + preset: Optional[str] = Field(None, description="预设模式: draft|standard|strict|custom") + dimensions: Optional[Dict[str, Any]] = Field(None, description="前端维度配置格式") + customRules: Optional[List[Dict[str, Any]]] = Field(None, description="前端自定义规则格式") + class ReviewRequest(BaseModel): """执行审核请求""" @@ -211,8 +221,9 @@ class CustomRuleCreate(BaseModel): name: str = Field(..., description="规则名称") description: str = Field("", description="规则描述") trigger_condition: str = Field(..., description="触发条件") - dimension: DimensionType = Field(..., description="所属维度") + dimension: Optional[DimensionType] = Field(None, description="所属维度") severity: SeverityLevel = Field(SeverityLevel.medium, description="严重程度") + category: Optional[str] = Field(None, description="分类(前端使用)") class CustomRuleUpdate(BaseModel): @@ -223,6 +234,7 @@ class CustomRuleUpdate(BaseModel): dimension: Optional[DimensionType] = None severity: Optional[SeverityLevel] = None enabled: Optional[bool] = None + category: Optional[str] = None class ReviewPreset(BaseModel): diff --git a/backend/app/models/review_card.py b/backend/app/models/review_card.py new file mode 100644 index 0000000..027811f --- /dev/null +++ b/backend/app/models/review_card.py @@ -0,0 +1,103 @@ +""" +Review Card Model + +审核卡片模型 - 持久化存储的审核任务卡片 + +与ReviewTask的关系: +- ReviewCard是用户可见的卡片,包含审核任务的所有信息 +- ReviewTask是系统内部的审核任务,可能不持久化 +- 一个ReviewCard可能包含多个ReviewTask(如多集审核) +- 过程中审核完成后转换为ReviewCard持久化存储 +""" +from pydantic import BaseModel, Field +from typing import Dict, List, Optional, Any +from datetime import datetime +from enum import Enum + + +class ReviewCardType(str, Enum): + """审核卡片类型""" + PROCESS_AUTO = "process_auto" # 过程中Agent自主识别 + POST_CREATION_AUTO = "post_creation_auto" # 生成后自动审核 + USER_REQUEST_EPISODE = "user_episode" # 用户请求单集审核 + USER_REQUEST_MULTIPLE = "user_multiple" # 用户请求多集审核 + USER_REQUEST_ALL = "user_all" # 用户请求全集审核 + + +class ReviewCardStatus(str, Enum): + """审核卡片状态""" + PENDING = "pending" # 待处理 + AWAITING_USER = "awaiting_user" # 待确认 + APPROVED = "approved" # 已通过 + REJECTED = "rejected" # 已驳回 + + +class ReviewCard(BaseModel): + """ + 审核卡片 - 持久化存储 + """ + # 基础信息 + id: str = Field(..., description="卡片ID") + project_id: str = Field(..., description="项目ID") + episode_id: Optional[str] = Field(None, description="剧集ID(单集审核)") + episode_numbers: List[int] = Field(default_factory=list, description="涉及集数列表(多集审核)") + + # 卡片类型和来源 + card_type: ReviewCardType = Field(..., description="卡片类型") + trigger_source: str = Field(..., description="触发来源: agent_auto/user_configured/manual") + + # 审核内容 + episode_outline: Optional[str] = Field(None, description="剧集大纲") + episode_content: Optional[str] = Field(None, description="剧集内容") + review_reason: str = Field(..., description="审核原因") + + # 审核结果 + issues: List[Dict[str, Any]] = Field(default_factory=list, description="问题列表") + overall_score: Optional[float] = Field(None, description="总体分数") + severity: str = Field("medium", description="严重程度: low/medium/high") + + # 审核配置 + review_config_id: Optional[str] = Field(None, description="使用的审核配置ID") + applied_skills: List[str] = Field(default_factory=list, description="应用的Skills") + applied_rules: List[str] = Field(default_factory=list, description="应用的规则") + + # 用户操作 + status: ReviewCardStatus = Field(default=ReviewCardStatus.PENDING, description="卡片状态") + user_comment: Optional[str] = Field(None, description="用户评论") + modified_content: Optional[str] = Field(None, description="修改后的内容") + + # 时间戳 + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + updated_at: datetime = Field(default_factory=datetime.now, description="更新时间") + completed_at: Optional[datetime] = Field(None, description="完成时间") + + # 附加信息 + metadata: Dict[str, Any] = Field(default_factory=dict, description="附加元数据") + + class Config: + use_enum_values = False + + +class ReviewCardCreate(BaseModel): + """创建审核卡片请求""" + project_id: str + episode_id: Optional[str] = None + episode_numbers: List[int] = Field(default_factory=list) + card_type: ReviewCardType + trigger_source: str + review_reason: str + episode_outline: Optional[str] = None + episode_content: Optional[str] = None + review_config_id: Optional[str] = None + severity: str = "medium" + issues: List[Dict[str, Any]] = Field(default_factory=list) + overall_score: Optional[float] = None + + +class ReviewCardUpdate(BaseModel): + """更新审核卡片请求""" + status: Optional[ReviewCardStatus] = None + user_comment: Optional[str] = None + modified_content: Optional[str] = None + issues: Optional[List[Dict[str, Any]]] = None + overall_score: Optional[float] = None diff --git a/backend/app/models/review_task.py b/backend/app/models/review_task.py new file mode 100644 index 0000000..f2a00c0 --- /dev/null +++ b/backend/app/models/review_task.py @@ -0,0 +1,185 @@ +""" +Review Task Model + +审核任务模型 - 支持过程中审核和生成后审核两种模式 + +## 审核模式说明 + +### 1. 过程中审核 (Process Review) +- 触发时机:Agent在流式输出过程中自主识别需要审核的内容 +- 阻塞方式:阻塞式等待用户确认 +- 消失方式:用户确认后自动销毁,不需要留存记录 +- 交互位置:在聊天界面完成 + +### 2. 生成后审核 (Post-Creation Review) +- 触发时机:剧集生成完成后自动检查 +- 触发源:根据用户配置的审核规则/Skill +- 阈值判断:根据阈值判断是否报告问题 +- 存留方式:留存记录,用户可以确认问题、忽略问题、修改内容 +""" +from pydantic import BaseModel, Field +from typing import Dict, List, Optional, Any +from datetime import datetime +from enum import Enum + + +# ============================================ +# 枚举类型 +# ============================================ + +class ReviewMode(str, Enum): + """审核模式""" + PROCESS = "process" # 过程中审核 + POST_CREATION = "post_creation" # 生成后审核 + + +class ReviewTriggerSource(str, Enum): + """审核触发来源""" + AGENT_AUTO = "agent_auto" # Agent自主识别 + USER_CONFIGURED_SKILL = "user_skill" # 用户配置的Skill + USER_CONFIGURED_RULE = "user_rule" # 用户配置的规则 + MANUAL_REQUEST = "manual" # 手动请求 + + +class ReviewTaskStatus(str, Enum): + """审核任务状态""" + PENDING = "pending" # 等待处理 + AWAITING_USER = "awaiting_user" # 等待用户确认 + APPROVED = "approved" # 已确认 + REJECTED = "rejected" # 已拒绝 + MODIFIED = "modified" # 已修改内容 + IGNORED = "ignored" # 已忽略 + COMPLETED = "completed" # 已完成(自动销毁) + + +class ReviewAction(str, Enum): + """用户可执行的操作""" + CONFIRM = "confirm" # 确认继续 + MODIFY = "modify" # 修改内容 + IGNORE = "ignore" # 忽略问题 + OTHER = "other" # 其他补充信息 + + +# ============================================ +# 审核任务模型 +# ============================================ + +class ReviewTaskLocation(BaseModel): + """审核任务位置信息""" + episode_number: Optional[int] = Field(None, description="集数") + scene_number: Optional[int] = Field(None, description="场景编号") + section: Optional[str] = Field(None, description="章节/段落") + line_number: Optional[int] = Field(None, description="行号") + content_snippet: Optional[str] = Field(None, description="相关内容片段") + + +class ReviewTask(BaseModel): + """ + 审核任务 + + 包含问题类型、位置、严重程度等信息 + """ + id: str = Field(..., description="任务ID") + project_id: str = Field(..., description="项目ID") + episode_id: Optional[str] = Field(None, description="剧集ID") + + # 审核模式和来源 + mode: ReviewMode = Field(..., description="审核模式") + trigger_source: ReviewTriggerSource = Field(..., description="触发来源") + + # 任务信息 + issue_type: str = Field(..., description="问题类型") + issue_description: str = Field(..., description="问题描述") + severity: str = Field(..., description="严重程度: low/medium/high") + location: ReviewTaskLocation = Field(..., description="问题位置") + + # 审核配置来源 + review_config_id: Optional[str] = Field(None, description="触发的审核配置ID") + skill_id: Optional[str] = Field(None, description="使用的Skill ID") + rule_id: Optional[str] = Field(None, description="触发的规则ID") + + # 可用操作 + available_actions: List[ReviewAction] = Field( + default_factory=list, + description="用户可执行的操作列表" + ) + + # 状态和用户响应 + status: ReviewTaskStatus = Field( + default=ReviewTaskStatus.PENDING, + description="任务状态" + ) + user_response: Optional[str] = Field(None, description="用户的响应内容") + + # 时间戳 + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + completed_at: Optional[datetime] = Field(None, description="完成时间") + expires_at: Optional[datetime] = Field(None, description="过期时间(过程审核自动销毁)") + + # 附加信息 + context_data: Optional[Dict[str, Any]] = Field( + default_factory=dict, + description="附加上下文数据" + ) + suggestion: Optional[str] = Field("", description="修改建议") + + +# ============================================ +# 请求/响应模型 +# ============================================ + +class ProcessReviewRequest(BaseModel): + """ + 过程中审核请求 - 由Agent推送 + """ + project_id: str + issue_type: str + issue_description: str + severity: str # low/medium/high + location: ReviewTaskLocation + trigger_source: ReviewTriggerSource = ReviewTriggerSource.AGENT_AUTO + review_config_id: Optional[str] = None + skill_id: Optional[str] = None + rule_id: Optional[str] = None + suggestion: Optional[str] = None + + +class ProcessReviewResponse(BaseModel): + """ + 过程中审核响应 + """ + task_id: str + status: ReviewTaskStatus + message: str + + +class UserReviewActionRequest(BaseModel): + """ + 用户审核操作请求 + """ + action: ReviewAction + task_id: str + user_response: Optional[str] = Field(None, description="用户补充信息") + modified_content: Optional[str] = Field(None, description="修改后的内容") + + +class PostCreationReviewRequest(BaseModel): + """ + 生成后审核请求 + """ + project_id: str + episode_id: str + use_configured_rules: bool = Field(True, description="是否使用配置的规则") + use_configured_skills: bool = Field(True, description="是否使用配置的Skills") + dimensions: Optional[List[str]] = Field(None, description="指定审核维度") + pass_threshold: Optional[float] = Field(None, description="通过阈值") + + +class PostCreationReviewResult(BaseModel): + """ + 生成后审核结果 + """ + task_count: int = Field(..., description="生成的任务数") + passed: bool = Field(..., description="是否通过审核(没有高严重度问题)") + tasks: List[ReviewTask] = Field(default_factory=list, description="审核任务列表") + overall_score: Optional[float] = Field(None, description="总体分数") diff --git a/backend/data/episodes.json b/backend/data/episodes.json index 85289ef..da9bd3d 100644 --- a/backend/data/episodes.json +++ b/backend/data/episodes.json @@ -96,7 +96,7 @@ "keyEvents": [] }, "outline": "# 第3集大纲:苏园深会\n\n## 场景一:苏府大门外(日)\n- 楚云飞驾车来到苏府门前,欲归还之前的车马\n- 苏瑾月借机与楚云飞对话,探听他的真实身份\n- 两人言语交锋,苏瑾月察觉到楚云飞不简单,但也看到他的义气\n\n## 场景二:苏府花园(午后)\n- 林墨轩来访苏府,向苏老爷提亲\n- 苏老爷欣喜,但苏瑾月巧妙拖延,提出\"试婚\"考验\n- 林墨轩表面答应,心中却开始盘算如何对付潜在的竞争者\n\n## 场景三:街边茶寮(黄昏)\n- 燕无痕出现在江南,寻找旧友楚云飞的线索\n- 无意中听到江湖传言,得知苏家与楚云飞有过交集\n- 燕无痕决定暗中观察,看看其中是否有阴谋\n\n## 场景四:苏瑾月闺房(夜)\n- 苏瑾月独自沉思,分析目前形势\n- 三个男人:楚云飞神秘而有义气,林墨轩儒雅却心机深,燕无痕未知\n- 决定以\"试婚\"为名,逐步揭开三人的真面目\n\n## 关键剧情点\n1. 楚云飞与苏瑾月的对话埋下情感线索\n2. 林墨轩提亲,矛盾正式展开\n3. 燕无痕登场,江湖线开启\n4. 苏瑾月的\"试婚\"计划为后续剧情奠定基础", - "content": "", + "content": "【第3集·第1场】\n【地点】苏府内院·荷花池畔\n【时间】次日清晨\n\n(苏瑾月独自一人在荷花池畔散步,轻抚着池边的荷叶,若有所思。晨雾未散,水面上漂浮着几片零落的荷花瓣。)\n\n苏瑾月:(轻叹)这一夜的雨,倒是打得荷花满地……\n\n(身后传来脚步声。苏瑾月回过头,只见楚云飞一身青布衣裳,肩扛一柄木桨,正在不远处整理马车。)\n\n楚云飞:(抬头笑道)苏小姐起得真早。昨夜雨大,池子里的水涨了不少。\n\n苏瑾月:(微微一笑)楚大哥也早。这一路劳顿,还要劳烦楚大哥送我去江南。\n\n楚云飞:(摆手)咱们既是同路,谈不上麻烦。再说了,苏小姐聪慧过人,这一路上我也讨教了不少,倒是赚了。\n\n苏瑾月:(掩口而笑)楚大哥这话说得倒是讨喜。\n\n(林墨轩从游廊那头缓步走来,手中折扇轻摇。)\n\n林墨轩:(温和地)瑾月,你这么早就起来了?\n\n苏瑾月:(点头)林大哥早。\n\n林墨轩:(看向楚云飞,眉头微蹙)这位是……\n\n苏瑾月:这位是楚云飞大哥,是我雇的车夫。\n\n林墨轩:(打量楚云飞一眼,眼中闪过一丝不屑)哦,原来如此。瑾月,这次去江南,我本想与你同行,没想到你……\n\n楚云飞:(直截了当)林大人,马车已备好,随时可以出发。\n\n林墨轩:(脸色微变)不必急,我与瑾月还有话说。\n\n苏瑾月:(微笑)林大哥,有什么话,等我回来再说不迟。这一去江南,路途遥远,迟则生变。\n\n林墨轩:(叹气)也好。瑾月,路上小心,若有需要,尽管派人传信于我。\n\n苏瑾月:多谢林大哥关心。\n\n(楚云飞将马车赶至庭院中,苏瑾月登上马车。林墨轩站在原地,目送马车缓缓驶出苏府大门,眼中神色复杂。)\n\n(马车驶出城门,踏上通往江南的官道。)\n\n(帘幕落下,马蹄声渐行渐远。)\n\n【第一场完】", "summary": null, "qualityScore": 94.4, "retryCount": 0, @@ -612,5 +612,1025 @@ "reviewResult": null, "createdAt": "2026-01-28T10:28:53.875802", "completedAt": null + }, + "f177f78a-647a-4645-90f1-aae914829d95": { + "id": "f177f78a-647a-4645-90f1-aae914829d95", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 1, + "title": "第1集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.747207", + "completedAt": null + }, + "5fde651f-64b5-4e32-9ff6-1647fa48baea": { + "id": "5fde651f-64b5-4e32-9ff6-1647fa48baea", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 2, + "title": "第2集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.748732", + "completedAt": null + }, + "0870d0f0-479a-47a1-be0b-d2945acfcfd9": { + "id": "0870d0f0-479a-47a1-be0b-d2945acfcfd9", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 3, + "title": "第3集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.750242", + "completedAt": null + }, + "eb9afe27-4713-4a24-aa16-381c2a98ea34": { + "id": "eb9afe27-4713-4a24-aa16-381c2a98ea34", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 4, + "title": "第4集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.752258", + "completedAt": null + }, + "33e209f4-beac-463f-a72a-3aae28f4c611": { + "id": "33e209f4-beac-463f-a72a-3aae28f4c611", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 5, + "title": "第5集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.753769", + "completedAt": null + }, + "b15d4793-fdd5-49dd-8160-09640eff9e54": { + "id": "b15d4793-fdd5-49dd-8160-09640eff9e54", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 6, + "title": "第6集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.755278", + "completedAt": null + }, + "56460668-8230-494a-9f5b-acd27791bd19": { + "id": "56460668-8230-494a-9f5b-acd27791bd19", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 7, + "title": "第7集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.756789", + "completedAt": null + }, + "08e99526-47ed-4ede-a0cf-4b3c3daf7641": { + "id": "08e99526-47ed-4ede-a0cf-4b3c3daf7641", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 8, + "title": "第8集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.758301", + "completedAt": null + }, + "df37921b-0662-4110-83d7-775ba6db0ec1": { + "id": "df37921b-0662-4110-83d7-775ba6db0ec1", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 9, + "title": "第9集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.759811", + "completedAt": null + }, + "b4e9ef03-66c9-425e-8f39-ac554e17afd1": { + "id": "b4e9ef03-66c9-425e-8f39-ac554e17afd1", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 10, + "title": "第10集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.761319", + "completedAt": null + }, + "f12bbde1-db0e-429f-9909-19d9813c3cc5": { + "id": "f12bbde1-db0e-429f-9909-19d9813c3cc5", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 11, + "title": "第11集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.762323", + "completedAt": null + }, + "52da6844-cd52-470b-bf67-a4d182a909bc": { + "id": "52da6844-cd52-470b-bf67-a4d182a909bc", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 12, + "title": "第12集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.762827", + "completedAt": null + }, + "b29a0f15-7c91-4b5a-bb88-e717d6005f07": { + "id": "b29a0f15-7c91-4b5a-bb88-e717d6005f07", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 13, + "title": "第13集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.764336", + "completedAt": null + }, + "93e27827-3ed2-4ea7-ab16-677c0975a5d3": { + "id": "93e27827-3ed2-4ea7-ab16-677c0975a5d3", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 14, + "title": "第14集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.765844", + "completedAt": null + }, + "ca0b3b49-b2f4-4663-9aa5-5d8f50fd5a1f": { + "id": "ca0b3b49-b2f4-4663-9aa5-5d8f50fd5a1f", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 15, + "title": "第15集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.767353", + "completedAt": null + }, + "99f3e843-1a0e-495c-a7ef-70bbcae848b3": { + "id": "99f3e843-1a0e-495c-a7ef-70bbcae848b3", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 16, + "title": "第16集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.768863", + "completedAt": null + }, + "bb3903fb-3dba-4aaf-9bac-370acda14341": { + "id": "bb3903fb-3dba-4aaf-9bac-370acda14341", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 17, + "title": "第17集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.769867", + "completedAt": null + }, + "cb215366-51f5-47b2-9d48-4c53ea4bd101": { + "id": "cb215366-51f5-47b2-9d48-4c53ea4bd101", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 18, + "title": "第18集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.770370", + "completedAt": null + }, + "102d6c85-778d-4225-9def-fbef5090d67d": { + "id": "102d6c85-778d-4225-9def-fbef5090d67d", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 19, + "title": "第19集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.772901", + "completedAt": null + }, + "b2d20dcb-ce6e-4c12-8b0d-fc2d98c881dd": { + "id": "b2d20dcb-ce6e-4c12-8b0d-fc2d98c881dd", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 20, + "title": "第20集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.773905", + "completedAt": null + }, + "927a9d2d-f415-4901-b2be-746f57d5d71f": { + "id": "927a9d2d-f415-4901-b2be-746f57d5d71f", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 21, + "title": "第21集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.775408", + "completedAt": null + }, + "62b7fceb-8677-431c-8b09-4d48e731520b": { + "id": "62b7fceb-8677-431c-8b09-4d48e731520b", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 22, + "title": "第22集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.777436", + "completedAt": null + }, + "5d94c301-35c3-4cc9-9457-f6c486116bd3": { + "id": "5d94c301-35c3-4cc9-9457-f6c486116bd3", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 23, + "title": "第23集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.778983", + "completedAt": null + }, + "7b037a76-ebe5-4fa9-b913-589e8e3ffb29": { + "id": "7b037a76-ebe5-4fa9-b913-589e8e3ffb29", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 24, + "title": "第24集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.780491", + "completedAt": null + }, + "c55dd56e-a6cb-47cd-8317-fd6b0a7b0888": { + "id": "c55dd56e-a6cb-47cd-8317-fd6b0a7b0888", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 25, + "title": "第25集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.782363", + "completedAt": null + }, + "09e075a6-fb3f-494b-b76d-802b5600a3e3": { + "id": "09e075a6-fb3f-494b-b76d-802b5600a3e3", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 26, + "title": "第26集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.783389", + "completedAt": null + }, + "c600c690-fbc5-4d6b-aa76-16daf65f2fec": { + "id": "c600c690-fbc5-4d6b-aa76-16daf65f2fec", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 27, + "title": "第27集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.784906", + "completedAt": null + }, + "a6ad68f4-815d-4787-8e6b-253b92274ae4": { + "id": "a6ad68f4-815d-4787-8e6b-253b92274ae4", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 28, + "title": "第28集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.786421", + "completedAt": null + }, + "17038b94-9075-43e5-8aed-6dea2004d4ba": { + "id": "17038b94-9075-43e5-8aed-6dea2004d4ba", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 29, + "title": "第29集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.788458", + "completedAt": null + }, + "aa89021f-7571-4815-a474-2f92351982c7": { + "id": "aa89021f-7571-4815-a474-2f92351982c7", + "projectId": "18e8e843-6fb9-46be-a6fa-2c6f2c91739d", + "number": 30, + "title": "第30集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:03.790484", + "completedAt": null + }, + "a96a1f59-5315-4649-bcbc-dd5bb48d5aa9": { + "id": "a96a1f59-5315-4649-bcbc-dd5bb48d5aa9", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 1, + "title": "第1集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.444030", + "completedAt": null + }, + "98292fde-5ba1-4dcb-a4c7-c567c95ea79a": { + "id": "98292fde-5ba1-4dcb-a4c7-c567c95ea79a", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 2, + "title": "第2集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.447850", + "completedAt": null + }, + "dbb9275a-4e70-4ae1-b4a7-abeb7f9219cd": { + "id": "dbb9275a-4e70-4ae1-b4a7-abeb7f9219cd", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 3, + "title": "第3集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.449879", + "completedAt": null + }, + "5334cf6b-076c-40ac-85a5-a0b14acdff19": { + "id": "5334cf6b-076c-40ac-85a5-a0b14acdff19", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 4, + "title": "第4集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.451391", + "completedAt": null + }, + "e6cd970d-360f-4919-aa41-317c79692abb": { + "id": "e6cd970d-360f-4919-aa41-317c79692abb", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 5, + "title": "第5集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.454036", + "completedAt": null + }, + "167d03f4-fbd1-42d6-894f-391844954e3f": { + "id": "167d03f4-fbd1-42d6-894f-391844954e3f", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 6, + "title": "第6集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.455709", + "completedAt": null + }, + "2a5a19ee-79a2-432e-a8bc-0fb9d74eec4b": { + "id": "2a5a19ee-79a2-432e-a8bc-0fb9d74eec4b", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 7, + "title": "第7集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.457277", + "completedAt": null + }, + "a20e89b1-cbb0-4e47-978e-62743622018e": { + "id": "a20e89b1-cbb0-4e47-978e-62743622018e", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 8, + "title": "第8集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.458800", + "completedAt": null + }, + "6f17448e-7b06-4d5a-b989-484ee7f57f74": { + "id": "6f17448e-7b06-4d5a-b989-484ee7f57f74", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 9, + "title": "第9集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.460327", + "completedAt": null + }, + "2924eaad-488f-4b5f-b590-6ff29d25b517": { + "id": "2924eaad-488f-4b5f-b590-6ff29d25b517", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 10, + "title": "第10集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.463570", + "completedAt": null + }, + "2b42071a-4ba2-492b-8ddf-861cbd8ebc2b": { + "id": "2b42071a-4ba2-492b-8ddf-861cbd8ebc2b", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 11, + "title": "第11集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.465088", + "completedAt": null + }, + "8890fd21-ff20-4ca5-85ae-6075dda53e4e": { + "id": "8890fd21-ff20-4ca5-85ae-6075dda53e4e", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 12, + "title": "第12集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.466599", + "completedAt": null + }, + "5c7b8c41-6477-476e-8fc8-ea419bed4112": { + "id": "5c7b8c41-6477-476e-8fc8-ea419bed4112", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 13, + "title": "第13集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.468616", + "completedAt": null + }, + "280b3c79-1b1c-43b1-afc9-3ae28d3b0042": { + "id": "280b3c79-1b1c-43b1-afc9-3ae28d3b0042", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 14, + "title": "第14集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.470128", + "completedAt": null + }, + "5927871a-63d4-4300-abb4-16b4440cbd3f": { + "id": "5927871a-63d4-4300-abb4-16b4440cbd3f", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 15, + "title": "第15集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.471640", + "completedAt": null + }, + "dba40e36-cb49-4157-8c01-93621ef55e32": { + "id": "dba40e36-cb49-4157-8c01-93621ef55e32", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 16, + "title": "第16集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.475286", + "completedAt": null + }, + "507da39e-8deb-4c4f-80d5-451948b3d2c7": { + "id": "507da39e-8deb-4c4f-80d5-451948b3d2c7", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 17, + "title": "第17集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.476315", + "completedAt": null + }, + "d98fabe8-f348-4743-b84d-552f769c6015": { + "id": "d98fabe8-f348-4743-b84d-552f769c6015", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 18, + "title": "第18集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.477765", + "completedAt": null + }, + "aace4caa-4fe9-4065-8c3a-b370af0abbba": { + "id": "aace4caa-4fe9-4065-8c3a-b370af0abbba", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 19, + "title": "第19集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.479279", + "completedAt": null + }, + "d9ec2134-51c1-4497-8927-07b86d96d8a4": { + "id": "d9ec2134-51c1-4497-8927-07b86d96d8a4", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 20, + "title": "第20集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.480795", + "completedAt": null + }, + "14a70c50-76bf-4176-aa6d-ee091b401f83": { + "id": "14a70c50-76bf-4176-aa6d-ee091b401f83", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 21, + "title": "第21集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.482310", + "completedAt": null + }, + "3e3f9311-a294-4704-bc2c-bb94c430a675": { + "id": "3e3f9311-a294-4704-bc2c-bb94c430a675", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 22, + "title": "第22集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.483823", + "completedAt": null + }, + "99d6f78d-3a45-4fa5-bd6a-ea316582acc8": { + "id": "99d6f78d-3a45-4fa5-bd6a-ea316582acc8", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 23, + "title": "第23集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.486550", + "completedAt": null + }, + "ee0547ee-60fa-4612-8157-f5cb01abf6ba": { + "id": "ee0547ee-60fa-4612-8157-f5cb01abf6ba", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 24, + "title": "第24集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.488230", + "completedAt": null + }, + "87c9c55a-2781-4bfc-9aaa-152822f25e89": { + "id": "87c9c55a-2781-4bfc-9aaa-152822f25e89", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 25, + "title": "第25集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.489790", + "completedAt": null + }, + "0236d05b-c232-4904-9ffc-cc17b033b937": { + "id": "0236d05b-c232-4904-9ffc-cc17b033b937", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 26, + "title": "第26集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.491371", + "completedAt": null + }, + "dc5eee21-00bb-4444-8abc-81ca9603f3b3": { + "id": "dc5eee21-00bb-4444-8abc-81ca9603f3b3", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 27, + "title": "第27集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.493028", + "completedAt": null + }, + "f7825314-7cb7-43be-aac1-bbb6f28ba286": { + "id": "f7825314-7cb7-43be-aac1-bbb6f28ba286", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 28, + "title": "第28集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.494553", + "completedAt": null + }, + "a304b896-31c6-4499-a3f2-d85bb0077805": { + "id": "a304b896-31c6-4499-a3f2-d85bb0077805", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 29, + "title": "第29集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.496078", + "completedAt": null + }, + "ed848b64-0e4d-4d8e-8b02-9d4b96b609cc": { + "id": "ed848b64-0e4d-4d8e-8b02-9d4b96b609cc", + "projectId": "240eca7f-2587-4380-88d4-081818c6a661", + "number": 30, + "title": "第30集内容创作", + "status": "pending", + "structure": null, + "outline": null, + "content": "", + "summary": null, + "qualityScore": null, + "retryCount": 0, + "issues": [], + "reviewResult": null, + "createdAt": "2026-01-29T15:12:51.497092", + "completedAt": null } } \ No newline at end of file diff --git a/backend/data/messages.json b/backend/data/messages.json index eb17c8d..c0d26f6 100644 --- a/backend/data/messages.json +++ b/backend/data/messages.json @@ -1,79 +1,3 @@ { - "8f969272-4ece-49e7-8ca1-4877cc62c57c": [ - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T09:37:52.162798" - }, - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T10:10:14.049998" - }, - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T10:10:21.410520" - }, - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T10:10:56.234901" - }, - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T10:11:48.922316" - }, - { - "role": "agent", - "content": "我需要先了解项目情况,然后为您生成大纲。让我先查看一下当前项目的文件结构。您好!我已经准备好为您生成大纲了。\n\n为了创建一个符合您期望的大纲,我需要了解一些关键信息:\n\n## 请提供以下信息:\n\n1. **故事类型**:您想创作什么类型的内容?\n - 小说/剧本/网剧/其他\n\n2. **题材/类型**:\n - 悬疑/推理\n - 科幻/奇幻\n - 爱情/都市\n - 历史/古装\n - 喜剧\n - 恐怖/惊悚\n - 其他\n\n3. **核心概念/梗概**:您有一个初步的故事想法吗?请简单描述。\n\n4. **篇幅规模**:\n - 短篇(1-5集/章)\n - 中篇(6-20集/章)\n - 长篇(20集/章以上)\n\n5. **目标受众**:主要面向哪类观众?\n\n请告诉我您的想法,我会根据您的需求制定详细的大纲方案!", - "timestamp": "2026-01-28T10:13:01.604878" - }, - { - "role": "user", - "content": "开始生成大纲", - "timestamp": "2026-01-28T10:19:32.449321" - }, - { - "role": "user", - "content": "开始创作第三集", - "timestamp": "2026-01-28T15:17:45.959233" - }, - { - "role": "user", - "content": "开始生成第3集大纲", - "timestamp": "2026-01-28T16:47:54.452228" - }, - { - "role": "agent", - "content": "\n我来帮你生成第3集的大纲。首先让我查看一下当前项目的进度。\n\n我来为第3集制定创作计划并生成大纲。\n\n现在让我创建第3集。根据项目协议,当你说\"开始创作第X集\"时,我应该使用 `create_episode` 工具来自动完成整个创建过程。\n\n系统正在后台自动创建第3集,包括大纲、内容创作和质量审核。根据项目协议,这种创作过程会自动通过WebSocket更新进度,最终第3集的内容将显示在画布上。\n\n让我继续关注创作进度:", - "timestamp": "2026-01-28T16:49:00.614398" - }, - { - "role": "user", - "content": "开始生成第3集大纲", - "timestamp": "2026-01-28T17:46:38.247766" - }, - { - "role": "agent", - "content": "我来开始生成第3集的大纲。首先让我创建一个执行计划。现在让我基于全局设定来创作第3集的大纲。## ✅ 第3集大纲已生成\n\n我已经完成了第3集《意外相遇》的大纲创作,大纲已呈现在画布上。\n\n**本集核心情节:**\n- 苏瑾月被林墨轩商议婚事之事烦扰\n- 苏老爷催促婚事,女儿巧妙应对拖延时间\n- 前往寺庙途中马车\"意外\"故障\n- 楚云飞路过出手相助,两人初次相遇\n- 楚云飞修好马车但收钱时展现江湖本色\n- 林墨轩对楚云飞起疑\n- 苏瑾月内心开始动摇,对这位江湖车夫产生好奇\n\n**创作亮点:**\n1. ✨ 体现了苏瑾月的机智与临危不乱\n2. ✨ 建立了楚云飞的侠义形象,暗示其真实身份\n3. ✨ 埋下苏瑾月与楚云飞情感发展的种子\n4. ✨ 强化林墨轩的反派色彩\n\n**已提交审阅:** 我已将大纲提交到您的收件箱,请您审阅并反馈是否需要调整。\n\n您可以:\n- 批准大纲 → 我将开始创作正式剧本\n- 要求修改 → 告诉我具体的修改意见\n- 重新创作 → 我会从新的角度重新设计\n\n期待您的反馈!", - "timestamp": "2026-01-28T17:48:50.985473" - }, - { - "role": "user", - "content": "批准大纲", - "timestamp": "2026-01-28T17:49:11.764238" - }, - { - "role": "agent", - "content": "我先查看一下项目的当前状态,看看有什么大纲需要批准。我看到项目中前三集已完成,后续剧集待创作。不过我需要确认一下:您要批准的是哪个大纲呢?\n\n目前可能的情况:\n1. **全局大纲**(项目上下文中的Overall Outline目前显示为\"11\",似乎不完整)\n2. **某个特定剧集的大纲**\n\n您是指:\n- 如果是全局大纲,我可以帮您完善并提交审批\n- 如果是某个待定剧集的大纲,请告诉我是第几集,我可以立即开始创作并生成大纲供您审批\n\n请指明具体要批准的内容,我会立即为您处理!", - "timestamp": "2026-01-28T17:49:37.624535" - }, - { - "role": "user", - "content": "开始生成第3集大纲", - "timestamp": "2026-01-28T18:30:44.338922" - } - ] + "8f969272-4ece-49e7-8ca1-4877cc62c57c": [] } \ No newline at end of file diff --git a/backend/data/projects.json b/backend/data/projects.json index 57645d6..698604c 100644 --- a/backend/data/projects.json +++ b/backend/data/projects.json @@ -17,60 +17,107 @@ }, "memory": { "eventTimeline": [], - "pendingThreads": [ - { - "id": "881661a2-7372-41ce-9f7a-ed77efe5d92c", - "description": "这个创作失败的原因是什么?是技术故障还是人为因素?", - "introduced_at": 2, - "importance": "high", - "resolved": false, - "resolved_at": null, - "reminder_episode": 3, - "status": "pending", - "characters_involved": [], - "notes": "", - "created_at": "2026-01-28T16:49:57.524646", - "updated_at": "2026-01-28T16:49:57.524646" + "pendingThreads": [], + "foreshadowing": [], + "characterStates": {} + }, + "totalEpisodes": 30, + "defaultTaskSkills": [], + "episodeSkillOverrides": {}, + "skillSettings": {}, + "autoRetryConfig": null, + "reviewConfig": { + "enabled_review_skills": [], + "overall_strictness": 0.83, + "dimension_settings": { + "consistency": { + "enabled": false, + "strictness": 0.95, + "custom_rules": [], + "weight": 1.0 }, - { - "id": "015785d6-6f16-4a2f-8260-a8c3515a41c2", - "description": "这个错误是否会影响后续剧情发展?是否会成为解开某个谜题的关键?", - "introduced_at": 2, - "importance": "medium", - "resolved": false, - "resolved_at": null, - "reminder_episode": 4, - "status": "pending", - "characters_involved": [], - "notes": "", - "created_at": "2026-01-28T16:49:57.524646", - "updated_at": "2026-01-28T16:49:57.524646" - } - ], - "foreshadowing": [ - { - "id": "e16f93c7-cc24-47b7-9c58-5fc84fba4409", - "description": "第2集内容创作失败的错误信息可能暗示剧集制作过程中存在技术问题或人为错误", - "introduced_at": 2, - "payoff_at": null, - "type": "plot", - "importance": "medium", - "is_payed_off": false, - "related_events": [], - "notes": "" + "quality": { + "enabled": true, + "strictness": 0.9, + "custom_rules": [], + "weight": 0.8 }, - { - "id": "8b43d627-9107-430b-aa0f-6d1052ad1511", - "description": "'str' object has no attribute 'items'这个技术错误可能是一个元叙事元素,暗示剧集本身可能存在问题", - "introduced_at": 2, - "payoff_at": null, - "type": "plot", - "importance": "medium", - "is_payed_off": false, - "related_events": [], - "notes": "" + "dialogue": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "plot": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "pacing": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "emotional_depth": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "thematic_strength": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "character_consistency": { + "enabled": false, + "strictness": 0.95, + "custom_rules": [], + "weight": 1.0 + }, + "dialogue_quality": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 + }, + "plot_coherence": { + "enabled": false, + "strictness": 0.5, + "custom_rules": [], + "weight": 0.7 } - ], + }, + "custom_rules": [], + "auto_fix_enabled": false, + "pass_threshold": 90.0 + }, + "createdAt": "2026-01-27T16:22:58.755260", + "updatedAt": "2026-01-30T18:27:40.432141" + }, + "240eca7f-2587-4380-88d4-081818c6a661": { + "id": "240eca7f-2587-4380-88d4-081818c6a661", + "name": "test1", + "type": "series", + "agentId": "series-creation", + "mode": "batch", + "genre": "古风", + "globalContext": { + "worldSetting": "", + "characterProfiles": {}, + "sceneSettings": {}, + "overallOutline": "", + "styleGuide": "", + "uploadedScript": "11", + "inspiration": "" + }, + "memory": { + "eventTimeline": [], + "pendingThreads": [], + "foreshadowing": [], "characterStates": {} }, "totalEpisodes": 30, @@ -79,7 +126,7 @@ "skillSettings": {}, "autoRetryConfig": null, "reviewConfig": null, - "createdAt": "2026-01-27T16:22:58.755260", - "updatedAt": "2026-01-28T16:51:40.612879" + "createdAt": "2026-01-29T15:12:51.442460", + "updatedAt": "2026-01-29T15:12:51.442460" } } \ No newline at end of file diff --git a/backend/scripts/fix_episodes_data.py b/backend/scripts/fix_episodes_data.py new file mode 100644 index 0000000..e93e73b --- /dev/null +++ b/backend/scripts/fix_episodes_data.py @@ -0,0 +1,67 @@ +""" +修复episodes.json数据问题 +""" +import json +from pathlib import Path + + +def fix_episodes_data(): + """修复episodes.json中的问题数据""" + episodes_file = Path(__file__).parent.parent / "data" / "episodes.json" + + if not episodes_file.exists(): + print(f"episodes.json not found at {episodes_file}") + return + + with open(episodes_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + fixed_count = 0 + + for episode_id, episode in data.items(): + modified = False + + # 修复EP1的标题 + if episode.get("number") == 1 and episode.get("title") == "11": + episode["title"] = "苏园初会" + modified = True + fixed_count += 1 + + # 修复EP2的错误内容 + if episode.get("number") == 2 and "内容创作失败" in str(episode.get("content", "")): + episode["content"] = "" + episode["status"] = "pending" + modified = True + fixed_count += 1 + + # 确保所有pending剧集有正确的结构 + if episode.get("status") == "pending": + if not episode.get("outline"): + episode["outline"] = None + if not episode.get("content"): + episode["content"] = "" + if not episode.get("structure"): + episode["structure"] = { + "episodeNumber": episode.get("number"), + "scenes": [], + "keyEvents": [] + } + modified = True + fixed_count += 1 + + # 修复qualityScore为None的completed剧集 + if episode.get("status") == "completed" and episode.get("qualityScore") is None: + episode["qualityScore"] = 0.0 + modified = True + fixed_count += 1 + + # 保存修复后的数据 + with open(episodes_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"修复完成!共修复 {fixed_count} 个问题") + return True + + +if __name__ == "__main__": + fix_episodes_data() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e37dd1b..006e8e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,19 @@ import React from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' -import { ConfigProvider, Layout, Menu, theme } from 'antd' +import { Layout, Menu, App as AntApp, theme } from 'antd' import { DashboardOutlined, BookOutlined, SettingOutlined } from '@ant-design/icons' import { ProjectList } from './pages/ProjectList' import { ProjectCreateEnhanced } from './pages/ProjectCreateEnhanced' import { ProjectCreateProgressive } from './pages/ProjectCreateProgressive' import { ProjectDetail } from './pages/ProjectDetail' -import { ProjectWorkspace } from './pages/ProjectWorkspace' import { SkillManagement } from './pages/SkillManagement' import { AgentManagement } from './pages/AgentManagement' import { ExecutionMonitor } from './pages/ExecutionMonitor' -import MemorySystem from './pages/MemorySystem' -import ReviewConfig from './pages/ReviewConfig' -import ReviewResults from './pages/ReviewResults' const { Header, Content } = Layout -function App() { +function AppRoot() { const currentPath = window.location.pathname const menuItems = [ @@ -27,14 +23,7 @@ function App() { ] return ( - + } /> } /> } /> - {/* 更具体的路由要放在前面 */} - } /> + {/* 执行监控路由 */} } /> - } /> - } /> - } /> + {/* 项目详情页(包含所有功能:设置、全局设定、剧集创作、记忆系统、审核系统) */} } /> + {/* 全局管理页面 */} } /> } /> - + ) } -export default App +export default AppRoot diff --git a/frontend/src/components/SkillCreate.tsx b/frontend/src/components/SkillCreate.tsx index 7e750bf..2aff452 100644 --- a/frontend/src/components/SkillCreate.tsx +++ b/frontend/src/components/SkillCreate.tsx @@ -188,6 +188,8 @@ export const SkillCreate: React.FC = ({ } } catch (error) { message.error(`加载Skill失败: ${(error as Error).message}`) + // 关闭弹窗,让父组件处理重定向 + handleClose() } } @@ -544,7 +546,7 @@ export const SkillCreate: React.FC = ({ onCancel={handleClose} footer={null} width={1000} - destroyOnClose + destroyOnHidden > diff --git a/frontend/src/components/Workspace/ContextPanel.tsx b/frontend/src/components/Workspace/ContextPanel.tsx index 58d2721..03d3fca 100644 --- a/frontend/src/components/Workspace/ContextPanel.tsx +++ b/frontend/src/components/Workspace/ContextPanel.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Layout, Card, Typography, Space, Tag, Tabs, List, Button, Input, Empty, Timeline, Badge, Popconfirm } from 'antd'; +import { Card, Typography, Space, Tabs, List, Button, Empty, Timeline, Badge } from 'antd'; import { BookOutlined, UserOutlined, @@ -12,7 +12,6 @@ import { CheckCircleOutlined } from '@ant-design/icons'; -const { Sider } = Layout; const { Title, Text, Paragraph } = Typography; interface ContextPanelProps { @@ -49,134 +48,142 @@ export const ContextPanel: React.FC = ({ { type: 'location', value: '未初始化' } ]; + // 渲染内容 + const renderContent = () => ( +
+ + <BookOutlined /> 故事上下文 + + + {/* 动态状态卡片 */} + + + {displayStates.map((state, idx) => ( +
+ {state.type === 'time' ? : state.type === 'location' ? : } + {state.value} +
+ ))} +
+
+ + + + {worldSetting} + + +
+ ), + }, + { + key: 'characters', + label: '人物', + children: ( +
+ {/* 如果有文本格式的人物设定,优先显示 */} + {charactersText ? ( + + {charactersText} + + ) : Object.keys(characters).length > 0 ? ( + ( + + } + title={{name}} + description={{profile}} + /> + + )} + /> + ) : ( + + )} + +
+ ), + }, + { + key: 'outline', + label: '大纲', + children: ( +
+ + {overallOutline} + + +
+ ), + }, + { + key: 'memory', + label: '记忆库', + children: memoryItems.length > 0 ? ( + + ) : ( + + ), + }, + ]} + /> + + ); + + // 使用统一的布局模式(移除 Sider 组件,改用普通 div) return ( - -
- - <BookOutlined /> 故事上下文 - - - {/* 动态状态卡片 */} - - - {displayStates.map((state, idx) => ( -
- {state.type === 'time' ? : state.type === 'location' ? : } - {state.value} -
- ))} -
-
- - - - {worldSetting} - - - - ), - }, - { - key: 'characters', - label: '人物', - children: ( - <> - {/* 如果有文本格式的人物设定,优先显示 */} - {charactersText ? ( - - {charactersText} - - ) : Object.keys(characters).length > 0 ? ( - ( - - } - title={name} - description={{profile}} - /> - - )} - /> - ) : ( - - )} - - - ), - }, - { - key: 'outline', - label: '大纲', - children: ( - <> - - {overallOutline} - - - - ), - }, - { - key: 'memory', - label: '记忆库', - children: memoryItems.length > 0 ? ( - - ) : ( - - ), - }, - ]} - /> +
+ {renderContent()}
- +
); }; diff --git a/frontend/src/components/Workspace/DirectorInbox.tsx b/frontend/src/components/Workspace/DirectorInbox.tsx index 3659eb1..9f95989 100644 --- a/frontend/src/components/Workspace/DirectorInbox.tsx +++ b/frontend/src/components/Workspace/DirectorInbox.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Layout, Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider } from 'antd'; +import { Input, List, Avatar, Button, Card, Tag, Badge, Tooltip, Divider, Space, Popconfirm, Modal, message } from 'antd'; import { SendOutlined, RobotOutlined, @@ -8,49 +8,123 @@ import { CloseCircleOutlined, ExclamationCircleOutlined, LoadingOutlined, - StopOutlined + StopOutlined, + ReloadOutlined, + ClearOutlined, + DeleteOutlined, + WarningOutlined } from '@ant-design/icons'; -const { Sider } = Layout; const { TextArea } = Input; export interface InboxItem { id: string; - type: 'review' | 'decision' | 'notification' | 'review_card'; + type: 'review' | 'decision' | 'notification' | 'review_card' | 'confirm_card'; title: string; description: string; status: 'pending' | 'approved' | 'rejected' | 'ignored'; timestamp: number; - options?: string[]; + options?: Array<{ id: string; label: string; description?: string; implications?: string }>; reviewData?: any; + confirmData?: { + card_id: string; + allow_custom_response: boolean; + expires_at?: string; + }; } interface DirectorInboxProps { onSendMessage: (message: string) => void; onStopAgent?: () => void; onInboxAction?: (itemId: string, action: 'approve' | 'reject') => void; + onConfirmCardAction?: (itemId: string, action: 'confirm' | 'reject', selectedOptionId?: string, customResponse?: string) => void; onNavigateToReview?: (episodeNum: number) => void; + onResetConversation?: () => void; // 新增:重建对话回调 agentStatus: 'idle' | 'planning' | 'writing' | 'reviewing'; agentPlan?: string[]; inboxItems?: InboxItem[]; chatHistory?: {role: 'user' | 'agent', content: string}[]; width?: number; + wsRef?: React.RefObject; // 新增:WebSocket引用 } -export const DirectorInbox: React.FC = ({ - onSendMessage, +export const DirectorInbox: React.FC = ({ + onSendMessage, onStopAgent, onInboxAction, + onConfirmCardAction, onNavigateToReview, + onResetConversation, // 新增:重建对话回调 agentStatus, agentPlan = [], inboxItems = [], chatHistory = [], - width = 400 + width = 400, + wsRef // 新增:WebSocket引用 }) => { const [inputValue, setInputValue] = useState(''); // Use local state for immediate feedback, but sync with props if provided const [localMessages, setLocalMessages] = useState<{role: 'user' | 'agent', content: string}[]>([]); + const [showClearConfirm, setShowClearConfirm] = useState(false); + + // 新增:处理重建对话(保留上下文,清除显示) + const handleResetConversation = () => { + // 清空本地消息,保留欢迎语 + setLocalMessages([{ role: 'agent', content: '导演你好,我是你的 AI 助手。' }]); + + // 通过WebSocket发送重建对话请求 + if (wsRef && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'reset_conversation' + })); + } + + // 调用父组件回调 + onResetConversation?.(); + + message.success('对话已刷新,上下文已保留'); + }; + + // 新增:完全清除对话(包括上下文) + const handleClearConversation = () => { + setShowClearConfirm(true); + }; + + const confirmClearConversation = () => { + // 清空本地消息 + setLocalMessages([{ role: 'agent', content: '导演你好,我是你的 AI 助手。' }]); + + // 通过WebSocket发送清除对话请求(完全重置) + if (wsRef && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'clear_conversation' + })); + } + + // 调用父组件回调 + onResetConversation?.(); + + setShowClearConfirm(false); + message.success('对话已完全清除,上下文已重置'); + }; + + // 检查对话长度,超过阈值提醒用户 + useEffect(() => { + const MESSAGE_THRESHOLD = 30; // 超过30条消息提醒 + if (localMessages.length >= MESSAGE_THRESHOLD && localMessages.length === MESSAGE_THRESHOLD) { + // 只有 Agent 才能发送提醒 + const hasReminder = localMessages.some(m => + m.role === 'agent' && m.content.includes('对话记录较长,建议您') + ); + if (!hasReminder) { + message.warning({ + content: '对话记录较长,建议您刷新或清除对话以获得更好性能', + duration: 5, + icon: + }); + } + } + }, [localMessages]); useEffect(() => { // 确保 chatHistory 是数组 @@ -102,24 +176,28 @@ export const DirectorInbox: React.FC = ({ }; return ( - -
{/* Agent 状态与计划 */} -
+
} style={{ backgroundColor: '#1890ff', marginRight: '8px' }} />
@@ -143,73 +221,192 @@ export const DirectorInbox: React.FC = ({
{/* 导演信箱 (Inbox) */} -
- 待处理任务 (Inbox) - - {Array.isArray(inboxItems) && inboxItems.map(item => ( - handleInboxItemClick(item)} - hoverable={item.type === 'review_card'} - actions={item.type === 'review_card' ? [ - - ] : [ - + ))} + +
+ )} +
+ } + /> + + ))} + {(!inboxItems || inboxItems.length === 0) && ( +
暂无待处理任务
+ )} +
{/* 对话记录 */} -
- 对话记录 - - {Array.isArray(localMessages) && localMessages.map((msg, idx) => ( -
-
- {msg.content} -
+
+
+ 对话记录
- ))} -
+ + + +
+ +
+ {Array.isArray(localMessages) && localMessages.map((msg, idx) => ( +
+
+ {msg.content} +
+
+ ))} +
+
{/* 输入框 */} -
+