Files
aiagent/backend/app/services/team_orchestrator.py
renjianbo f612248f9e feat: add education-training and platform-engineering team templates
- Add 2 new team templates: 教育培训团队 (4 roles) and 天工平台工程团队 (5 roles)
- Fix orchestrator to support multi-template workflow types (remove hardcoded PM planner)
- Add _resolve_planner_role() to auto-detect planner based on team.config.workflow
- Add frontend buttons and API clients for new templates
- Merge 14 preset roles from 3 template families in get_preset_roles()
- Add creation guide and platform engineering usage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-17 00:09:54 +08:00

639 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
团队编排引擎 — PM 规划 → 顺序阶段执行 → QA 审查 → 交付物
用法:
orchestrator = TeamOrchestrator(db, team_id, user_id)
result = await orchestrator.execute("做一个个人博客")
async for event in orchestrator.execute_stream("做一个个人博客"):
...
"""
from __future__ import annotations
import json
import re
import logging
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.team import Team, TeamMember
from app.models.agent import Agent
from app.agent_runtime.core import AgentRuntime
from app.agent_runtime.schemas import (
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
AgentMemoryConfig,
AgentResult,
)
logger = logging.getLogger(__name__)
def _build_agent_config(agent: Agent, auto_approve_files: bool = True) -> AgentConfig:
"""从 Agent 的 workflow_config 构建 AgentConfig供 AgentRuntime 使用。"""
wf = agent.workflow_config or {}
nodes = wf.get("nodes", [])
llm_node = None
for n in nodes:
if n.get("type") == "llm":
llm_node = n
break
llm_data = llm_node.get("data", {}) if llm_node else {}
tools = llm_data.get("selected_tools") or llm_data.get("tools") or []
system_prompt = llm_data.get("prompt") or ""
model = llm_data.get("model", "deepseek-v4-pro")
provider = llm_data.get("provider", "deepseek")
temperature = float(llm_data.get("temperature", 0.4))
max_iterations = int(llm_data.get("max_iterations", 10))
perm = "acceptEdits" if auto_approve_files else "default"
logger.info("_build_agent_config: agent=%s tools=%d permission_level=%s", agent.name, len(tools), perm)
return AgentConfig(
name=agent.name,
system_prompt=system_prompt,
user_id=agent.user_id,
memory_scope_id=f"team_{agent.id}",
llm=AgentLLMConfig(
provider=provider,
model=model,
temperature=temperature,
max_iterations=max_iterations,
),
tools=AgentToolConfig(include_tools=tools, permission_level=perm),
memory=AgentMemoryConfig(
enabled=True,
max_history_messages=30,
persist_to_db=True,
learning_enabled=True,
),
)
def _parse_plan_json(raw_output: str) -> Dict[str, Any]:
"""从 LLM 原始输出中提取 JSON 计划。"""
text = raw_output.strip()
# 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 从 ```json ... ``` 中提取
import re
m = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
# 从 { ... } 中提取
m = re.search(r'\{[\s\S]*"phases"[\s\S]*\}', text)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
logger.warning("无法从输出中解析 JSON 计划,原始输出: %.500s", text)
return {}
def _resolve_workspace_root() -> Path:
"""获取文件工具的工作区根目录(与 builtin_tools.py 逻辑一致)。"""
from app.core.config import settings
raw = (getattr(settings, "LOCAL_FILE_TOOLS_ROOT", None) or "").strip()
if raw:
return Path(raw).expanduser().resolve()
# fallback: 4 级父目录 (builtin_tools.py → app/services → app → backend → repo root)
return Path(__file__).resolve().parent.parent.parent.parent
def _safe_project_name(description: str) -> str:
"""从项目描述中提取安全的目录名(英文/拼音/数字/连字符)。"""
# 取前 40 个字符,只保留英文、数字、中文转拼音首字母的近似
cleaned = description.strip().replace(" ", "-").replace("/", "-").replace("\\", "-")
# 去掉非 ASCII 和非基本标点
safe = re.sub(r'[^\w\-]', '', cleaned, flags=re.ASCII)
if not safe:
# 如果全是中文,用时间戳
from datetime import datetime
safe = f"project-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
return safe[:40].strip("-")
def _scan_directory_files(directory: Path) -> List[str]:
"""扫描目录下所有文件的绝对路径列表(递归)。"""
files: List[str] = []
if not directory.exists():
return files
for p in directory.rglob("*"):
if p.is_file():
files.append(str(p))
return sorted(files)
def _extract_written_files(result: AgentResult) -> List[str]:
"""从 AgentResult.steps 中提取 file_write 成功产出的绝对路径列表。"""
files: List[str] = []
seen = set()
for step in result.steps:
if step.tool_name == "file_write":
# 优先从 tool_result JSON 中取绝对路径(仅当写入成功时)
if step.tool_result:
try:
data = json.loads(step.tool_result)
# 跳过失败的调用(权限不足 / 需确认)
if data.get("error") or data.get("requires_confirmation"):
logger.warning("file_write 未成功(%s),跳过文件追踪", data.get("error", "requires_confirmation"))
continue
fp = data.get("file_path")
if fp and fp not in seen:
files.append(fp)
seen.add(fp)
continue
except (json.JSONDecodeError, TypeError):
pass
# 回退:从 tool_input 取相对路径,再拼接工作区根(仅当 tool_result 为空时)
if step.tool_input:
fp = step.tool_input.get("file_path")
if fp:
abs_path = str(_resolve_workspace_root() / fp)
if abs_path not in seen:
files.append(abs_path)
seen.add(abs_path)
return files
class TeamOrchestrator:
"""虚拟团队编排器 — 按角色分工顺序执行软件项目。"""
def __init__(
self,
db: Session,
team_id: str,
user_id: str,
auto_approve_files: bool = True,
):
self.db = db
self.team_id = team_id
self.user_id = user_id
self.auto_approve_files = auto_approve_files
def _load_team(self) -> Team:
team = self.db.query(Team).filter(Team.id == self.team_id).first()
if not team:
raise ValueError(f"团队不存在: {self.team_id}")
return team
def _load_members(self) -> List[TeamMember]:
return (
self.db.query(TeamMember)
.filter(TeamMember.team_id == self.team_id)
.order_by(TeamMember.position)
.all()
)
def _get_agent_by_role(self, members: List[TeamMember], role: str) -> Optional[Agent]:
"""按角色查找 Agent。"""
for m in members:
if m.role == role:
return m.agent
return None
def _resolve_planner_role(self, members: List[TeamMember], workflow: str) -> str:
"""根据团队 workflow 类型解析规划者角色。
- software_company → pm
- education_training → curriculum_designer
- platform_engineering → product_lead (产品负责人做规划)
- 回退: 第一个 is_lead=True 的成员
- 最终回退: 第一个成员
"""
workflow_planner_map = {
"software_company": "pm",
"education_training": "curriculum_designer",
"platform_engineering": "product_lead",
}
role = workflow_planner_map.get(workflow)
if role and self._get_agent_by_role(members, role):
return role
# 回退:找第一个 Leader
for m in sorted(members, key=lambda x: x.position):
if m.is_lead:
return m.role
# 最终回退:第一个成员
if members:
return members[0].role
return "pm"
async def execute(self, project_description: str, auto_approve_files: bool = True) -> Dict[str, Any]:
"""执行团队项目(非流式)。
Returns:
{
"project_name": str,
"plan": {...},
"phases": [{phase, role, agent_name, output, steps, success, error}],
"qa_review": {...} | None,
"final_deliverable": str,
"success": bool,
}
"""
self.auto_approve_files = auto_approve_files
team = self._load_team()
members = self._load_members()
if not members:
raise ValueError("团队没有成员,请先为角色分配 Agent")
# ─── 创建项目目录 ───
workspace_root = _resolve_workspace_root()
safe_name = _safe_project_name(project_description)
project_path = workspace_root / "team_projects" / self.team_id / safe_name
project_path.mkdir(parents=True, exist_ok=True)
logger.info("团队编排 [%s]: 项目目录 %s", self.team_id, project_path)
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
results: Dict[str, Any] = {
"team_id": self.team_id,
"team_name": team.name,
"project_description": project_description,
"project_path": str(project_path),
"files": [],
"started_at": datetime.now().isoformat(),
"plan": None,
"phases": [],
"qa_review": None,
"final_deliverable": "",
"success": True,
"error": None,
}
# ─── Phase 0: 规划阶段(由 Leader Agent 担任) ───
# 选择规划者:优先用 team.config 中指定的 workflow否则回退兼容
workflow = (team.config or {}).get("workflow", "software_company")
planner_role = self._resolve_planner_role(members, workflow)
planner_agent = self._get_agent_by_role(members, planner_role)
if not planner_agent:
raise ValueError(
f"团队缺少 Leader 角色(需要 role='{planner_role}'),无法分解项目。"
f"可用角色: {[m.role for m in members]}"
)
# 动态构建可用的角色列表(来自团队成员实际角色)
available_roles = [m.role for m in members]
roles_hint = "/".join(available_roles)
logger.info("团队编排 [%s]: 规划开始 (workflow=%s, planner=%s, role=%s)",
self.team_id, workflow, planner_agent.name, planner_role)
planner_config = _build_agent_config(planner_agent, self.auto_approve_files)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"请分析以下需求,按 JSON 格式输出执行计划:\n\n"
f"需求描述:{project_description}\n"
f"输出文件目录:{project_path}\n\n"
f"你的团队有以下角色(每个 phase 必须使用这些角色之一):{roles_hint}\n"
f"请输出包含 phases 数组的完整 JSON 计划,每个 phase 需指定 role 为上述角色之一。"
)
planner_result = await planner_runtime.run(planner_input)
plan = _parse_plan_json(planner_result.content) if planner_result.success else {}
results["plan"] = plan
if not plan or not plan.get("phases"):
results["success"] = False
results["error"] = f"{planner_role} 未能生成有效计划"
results["final_deliverable"] = planner_result.content
return results
logger.info("团队编排 [%s]: PM 生成 %d 个阶段", self.team_id, len(plan["phases"]))
# ─── Phase 1..N: 顺序执行 ───
all_outputs: List[str] = []
context_doc = f"# 项目: {plan.get('project_name', '未命名')}\n\n## 需求分析\n{plan.get('analysis', '')}\n\n"
for i, phase in enumerate(plan["phases"]):
phase_num = phase.get("phase", i + 1)
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
phase_desc = phase.get("description", "")
expected = phase.get("expected_output", "")
agent = self._get_agent_by_role(members, role)
if not agent:
logger.warning("团队编排 [%s]: 阶段 %s 缺少角色 '%s',跳过", self.team_id, phase_num, role)
all_outputs.append(f"[跳过] 阶段 {phase_num} ({phase_name}): 无 {role} 角色")
results["phases"].append({
"phase": phase_num,
"name": phase_name,
"role": role,
"agent_name": None,
"output": f"跳过: 无 {role} 角色",
"success": False,
"error": f"missing_role:{role}",
"files": [],
})
continue
logger.info("团队编排 [%s]: 执行阶段 %s%s (角色: %s, Agent: %s)",
self.team_id, phase_num, phase_name, role, agent.name)
try:
agent_config = _build_agent_config(agent, self.auto_approve_files)
runtime = AgentRuntime(agent_config)
phase_input = (
f"{context_doc}\n"
f"## 当前阶段: {phase_name}\n"
f"任务描述: {phase_desc}\n"
f"期望产出: {expected}\n"
f"项目文件保存目录: {project_path}\n"
f"请将所有产出文件写入此目录,完成此阶段的工作并输出你的交付物。"
)
phase_result = await runtime.run(phase_input)
output_text = phase_result.content if phase_result.success else (
phase_result.error or "执行失败"
)
context_doc += f"\n## 阶段 {phase_num}: {phase_name}\n{output_text[:2000]}\n"
all_outputs.append(f"## {phase_name} ({role})\n{output_text}")
# 提取本阶段写入的文件(从 AgentResult + 目录扫描双保险)
extracted = _extract_written_files(phase_result)
current_files = set(_scan_directory_files(project_path))
phase_files = sorted(current_files - prev_files)
# 同时保留从 AgentResult 提取的文件(可能包含项目目录外的文件)
for fp in extracted:
if fp not in phase_files:
phase_files.append(fp)
prev_files = current_files
all_files.extend(phase_files)
results["phases"].append({
"phase": phase_num,
"name": phase_name,
"role": role,
"agent_name": agent.name,
"output": output_text,
"success": phase_result.success,
"iterations": phase_result.iterations_used,
"tool_calls": phase_result.tool_calls_made,
"error": phase_result.error if not phase_result.success else None,
"files": phase_files,
})
except Exception as e:
logger.error("团队编排 [%s]: 阶段 %s 异常: %s", self.team_id, phase_num, e)
results["phases"].append({
"phase": phase_num,
"name": phase_name,
"role": role,
"agent_name": agent.name if agent else None,
"output": f"执行异常: {e}",
"success": False,
"error": str(e),
"files": [],
})
results["success"] = False
# ─── QA 审查 ───
qa_agent = self._get_agent_by_role(members, "qa")
if qa_agent and all_outputs:
logger.info("团队编排 [%s]: QA 审查开始", self.team_id)
try:
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
qa_input = (
f"请审查以下项目交付物:\n\n"
f"项目: {project_description}\n\n"
f"{deliverables[:8000]}\n\n"
f"请按 JSON 格式输出审查结果,包含 pass/score/issues/overall_assessment。"
)
qa_result = await qa_runtime.run(qa_input)
qa_review = _parse_plan_json(qa_result.content) if qa_result.success else {}
results["qa_review"] = {
"output": qa_result.content,
"review": qa_review,
"success": qa_result.success,
}
except Exception as e:
logger.error("团队编排 [%s]: QA 审查异常: %s", self.team_id, e)
results["qa_review"] = {"error": str(e)}
# ─── 汇总最终交付物 ───
results["final_deliverable"] = "\n\n---\n\n".join(all_outputs)
# 最终文件列表以目录扫描为准,合并从 AgentResult 提取的
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
results["files"] = sorted(final_scan)
results["completed_at"] = datetime.now().isoformat()
return results
async def execute_stream(self, project_description: str, auto_approve_files: bool = True) -> AsyncGenerator[Dict[str, Any], None]:
"""流式执行团队项目yield SSE 事件。
事件类型:
- plan_start / plan / plan_done — PM 规划阶段
- phase_start / phase_output / phase_done — 各执行阶段
- qa_start / qa_review / qa_done — QA 审查
- final — 全部完成
- error — 错误
"""
self.auto_approve_files = auto_approve_files
try:
team = self._load_team()
members = self._load_members()
except Exception as e:
yield {"type": "error", "content": str(e)}
return
if not members:
yield {"type": "error", "content": "团队没有成员"}
return
# ─── 创建项目目录 ───
workspace_root = _resolve_workspace_root()
safe_name = _safe_project_name(project_description)
project_path = workspace_root / "team_projects" / self.team_id / safe_name
project_path.mkdir(parents=True, exist_ok=True)
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
yield {
"type": "plan_start",
"team_name": team.name,
"member_count": len(members),
"project_path": str(project_path),
}
all_outputs: List[str] = []
# ─── 规划阶段(由 Leader Agent 担任) ───
workflow = (team.config or {}).get("workflow", "software_company")
planner_role = self._resolve_planner_role(members, workflow)
planner_agent = self._get_agent_by_role(members, planner_role)
if not planner_agent:
yield {"type": "error", "content": f"团队缺少 Leader 角色(需要 role='{planner_role}'),可用角色: {[m.role for m in members]}"}
return
try:
available_roles = [m.role for m in members]
roles_hint = "/".join(available_roles)
planner_config = _build_agent_config(planner_agent, self.auto_approve_files)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"请分析以下需求,按 JSON 格式输出执行计划:\n\n"
f"需求描述:{project_description}\n"
f"输出文件目录:{project_path}\n"
f"你的团队有以下角色(每个 phase 必须使用这些角色之一):{roles_hint}\n"
f"请输出包含 phases 数组的完整 JSON 计划,每个 phase 需指定 role 为上述角色之一。"
)
yield {"type": "plan", "status": "running", "agent": planner_agent.name}
planner_result = await planner_runtime.run(planner_input)
plan = _parse_plan_json(planner_result.content) if planner_result.success else {}
yield {
"type": "plan_done",
"plan": plan,
"raw_output": planner_result.content[:2000],
"success": planner_result.success,
}
except Exception as e:
yield {"type": "error", "content": f"{planner_role} 规划失败: {e}"}
return
if not plan or not plan.get("phases"):
yield {"type": "error", "content": f"{planner_role} 未能生成有效计划"}
return
phases = plan["phases"]
context_doc = f"# 项目: {plan.get('project_name', '未命名')}\n\n## 需求分析\n{plan.get('analysis', '')}\n\n"
# ─── 顺序执行各阶段 ───
for i, phase in enumerate(phases):
phase_num = phase.get("phase", i + 1)
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
phase_desc = phase.get("description", "")
expected = phase.get("expected_output", "")
agent = self._get_agent_by_role(members, role)
if not agent:
msg = f"阶段 {phase_num} ({phase_name}) 跳过: 无 {role} 角色"
yield {"type": "phase_done", "phase": phase_num, "name": phase_name,
"role": role, "success": False, "error": f"missing_role:{role}"}
all_outputs.append(f"[跳过] {msg}")
continue
yield {
"type": "phase_start",
"phase": phase_num,
"name": phase_name,
"role": role,
"agent": agent.name,
}
try:
agent_config = _build_agent_config(agent, self.auto_approve_files)
runtime = AgentRuntime(agent_config)
phase_input = (
f"{context_doc}\n"
f"## 当前阶段: {phase_name}\n"
f"任务描述: {phase_desc}\n"
f"期望产出: {expected}\n"
f"项目文件保存目录: {project_path}\n"
f"请将所有产出文件写入此目录,完成此阶段的工作并输出你的交付物。"
)
phase_result = await runtime.run(phase_input)
output_text = phase_result.content if phase_result.success else (
phase_result.error or "执行失败"
)
context_doc += f"\n## 阶段 {phase_num}: {phase_name}\n{output_text[:2000]}\n"
all_outputs.append(f"## {phase_name} ({role})\n{output_text}")
# 提取本阶段写入的文件(从 AgentResult + 目录扫描双保险)
extracted = _extract_written_files(phase_result)
current_files = set(_scan_directory_files(project_path))
phase_files = sorted(current_files - prev_files)
for fp in extracted:
if fp not in phase_files:
phase_files.append(fp)
prev_files = current_files
all_files.extend(phase_files)
yield {
"type": "phase_done",
"phase": phase_num,
"name": phase_name,
"role": role,
"agent": agent.name,
"output": output_text,
"success": phase_result.success,
"iterations": phase_result.iterations_used,
"tool_calls": phase_result.tool_calls_made,
"error": phase_result.error if not phase_result.success else None,
"files": phase_files,
}
except Exception as e:
logger.error("团队编排: 阶段 %s 异常: %s", phase_num, e)
yield {
"type": "phase_done",
"phase": phase_num,
"name": phase_name,
"role": role,
"agent": agent.name,
"success": False,
"error": str(e),
}
# ─── QA 审查 ───
qa_agent = self._get_agent_by_role(members, "qa")
if qa_agent and all_outputs:
yield {"type": "qa_start", "agent": qa_agent.name}
try:
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
qa_input = (
f"请审查以下项目交付物:\n\n"
f"项目: {project_description}\n\n"
f"{deliverables[:8000]}\n\n"
f"请按 JSON 格式输出审查结果,包含 pass/score/issues/overall_assessment。"
)
qa_result = await qa_runtime.run(qa_input)
qa_review_data = _parse_plan_json(qa_result.content) if qa_result.success else {}
yield {
"type": "qa_done",
"output": qa_result.content,
"review": qa_review_data,
"success": qa_result.success,
}
except Exception as e:
logger.error("团队编排: QA 审查异常: %s", e)
yield {"type": "qa_done", "error": str(e)}
# ─── 最终交付物 ───
final = "\n\n---\n\n".join(all_outputs)
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
yield {
"type": "final",
"deliverable": final,
"phase_count": len(phases),
"team_name": team.name,
"project_path": str(project_path),
"files": sorted(final_scan),
}