feat: virtual company M2 control + tier-3/4 capabilities + per-dept session fix
- M2-a/b/c: stop/pause/resume/skip dept, plan/rework approval gates, budget & timeout circuit breakers, per-dept cancel and on-demand rework - tier-3: batched event persistence (_EventBuffer), project/dept concurrency semaphores, company_ws event streaming - tier-4: cross-dept deliverable handoff, CEO memory feedback loop, per-dept model override, one-click Markdown report export (RFC5987) - fix: per-department independent SQLAlchemy session in parallel waves (shared Session race across asyncio tasks in company_orchestrator) - fix: rename process_open_fds gauge to app_process_open_fds (collides with prometheus_client ProcessCollector on Linux) - fix: dept role resolution ladder, real token usage & cost tracking, deliverable file capture (mtime scan of dept workspace) - tests: test_company_orchestrator.py 22 cases with mocked LLM - frontend: CompanyControlRoom, companyExecution store, EChart, dept model config dialog, ws proxy, build/typecheck decoupling - docs: virtual company design/usage docs, M2 control plan, deepseek4pro handover docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -254,11 +254,44 @@ class AgentRuntime:
|
||||
# 返回 True 表示预算充足;返回 False 或抛出异常表示超限
|
||||
self.on_llm_invocation: Optional[Callable[[], Any]] = None
|
||||
|
||||
# 真实 token 累计(与 token_budget 开关无关,永远统计本次运行的真实用量)
|
||||
self._real_usage: Dict[str, int] = {
|
||||
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "llm_calls": 0,
|
||||
}
|
||||
|
||||
def _record_real_usage(self, metrics: Dict[str, Any]) -> None:
|
||||
"""无条件累计每次 LLM 调用的真实用量(来自 API usage)。"""
|
||||
p = int(metrics.get("prompt_tokens") or 0)
|
||||
c = int(metrics.get("completion_tokens") or 0)
|
||||
t = int(metrics.get("total_tokens") or 0) or (p + c)
|
||||
self._real_usage["prompt_tokens"] += p
|
||||
self._real_usage["completion_tokens"] += c
|
||||
self._real_usage["total_tokens"] += t
|
||||
self._real_usage["llm_calls"] += 1
|
||||
|
||||
def _real_token_usage_payload(self) -> Dict[str, Any]:
|
||||
"""构造随终态事件下发的真实用量 dict(形状含 total_tokens,供下游/前端与计费消费)。"""
|
||||
return {
|
||||
"prompt_tokens": self._real_usage["prompt_tokens"],
|
||||
"completion_tokens": self._real_usage["completion_tokens"],
|
||||
"total_tokens": self._real_usage["total_tokens"],
|
||||
"llm_calls": self._real_usage["llm_calls"],
|
||||
"model": self.config.llm.model,
|
||||
}
|
||||
|
||||
def _attach_token_usage(self, result: AgentResult) -> AgentResult:
|
||||
"""将 TokenBudget 摘要附加到 AgentResult(若启用)。"""
|
||||
"""将 token 用量摘要附加到 AgentResult。预算启用则用其摘要;否则用真实累计用量。"""
|
||||
from app.agent_runtime.schemas import TokenUsageInfo
|
||||
if self._token_budget:
|
||||
from app.agent_runtime.schemas import TokenUsageInfo
|
||||
result.token_usage = TokenUsageInfo(**self._token_budget.summary())
|
||||
else:
|
||||
ru = self._real_usage
|
||||
result.token_usage = TokenUsageInfo(
|
||||
cumulative_total=ru["total_tokens"],
|
||||
cumulative_prompt=ru["prompt_tokens"],
|
||||
cumulative_completion=ru["completion_tokens"],
|
||||
llm_call_count=ru["llm_calls"],
|
||||
)
|
||||
return result
|
||||
|
||||
def _build_execution_log_kwargs(self, user_input: str, result: AgentResult, latency_ms: int) -> dict:
|
||||
@@ -391,6 +424,7 @@ class AgentRuntime:
|
||||
llm_callback_ctx = {"step_type": "think", "tool_name": None}
|
||||
|
||||
def _llm_callback(metrics: Dict[str, Any]):
|
||||
self._record_real_usage(metrics) # 无条件累计真实 token(不受预算开关影响)
|
||||
# Token 预算追踪 (P2)
|
||||
if self._token_budget:
|
||||
prompt_tok = metrics.get("prompt_tokens", 0)
|
||||
@@ -869,6 +903,7 @@ class AgentRuntime:
|
||||
llm_callback_ctx = {"step_type": "think", "tool_name": None}
|
||||
|
||||
def _llm_callback(metrics: Dict[str, Any]):
|
||||
self._record_real_usage(metrics) # 无条件累计真实 token(不受预算开关影响)
|
||||
# Token 预算追踪 (P2)
|
||||
if self._token_budget:
|
||||
prompt_tok = metrics.get("prompt_tokens", 0)
|
||||
@@ -1016,7 +1051,9 @@ class AgentRuntime:
|
||||
self.context.add_user_message(fix_prompt)
|
||||
continue # 回到 ReAct 循环,让 LLM 修正
|
||||
|
||||
token_usage_final = self._token_budget.summary() if self._token_budget else None
|
||||
token_usage_final = self._real_token_usage_payload()
|
||||
if self._token_budget:
|
||||
token_usage_final = {**self._token_budget.summary(), **token_usage_final}
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": final_text,
|
||||
@@ -1278,7 +1315,9 @@ class AgentRuntime:
|
||||
# 提取知识到全局知识池(即便截断,工具调用序列仍有参考价值)
|
||||
if last_content:
|
||||
await self._extract_global_knowledge(user_input, last_content, steps)
|
||||
token_usage_truncated = self._token_budget.summary() if self._token_budget else None
|
||||
token_usage_truncated = self._real_token_usage_payload()
|
||||
if self._token_budget:
|
||||
token_usage_truncated = {**self._token_budget.summary(), **token_usage_truncated}
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": last_content or "已达最大迭代次数,但模型未返回最终回答。",
|
||||
|
||||
@@ -13,12 +13,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.models.company import Company, CompanyProject
|
||||
from app.models.company_project_event import CompanyProjectEvent
|
||||
from app.models.team import Team, TeamMember
|
||||
from app.models.company_schedule import CompanySchedule
|
||||
from app.models.company_knowledge import CompanyKnowledge
|
||||
@@ -63,6 +65,31 @@ class ExecuteRequest(BaseModel):
|
||||
ceo_plan: Optional[Dict[str, Any]] = Field(
|
||||
None, description="已确认的 CEO 计划;提供时跳过二次规划,直接据此执行"
|
||||
)
|
||||
max_rounds: Optional[int] = Field(3, ge=1, le=10, description="最大迭代轮次")
|
||||
# ─── M2-b 运行限额(默认全关,回归安全)───
|
||||
max_cost_yuan: Optional[float] = Field(None, gt=0, description="成本熔断上限(¥),超阈自动停")
|
||||
max_duration_min: Optional[float] = Field(None, gt=0, description="超时熔断上限(分钟),超阈自动停")
|
||||
approve_plan: Optional[bool] = Field(False, description="开 CEO 计划人在环审批,批准才派部门")
|
||||
approve_rework: Optional[bool] = Field(False, description="开返工人在环审批,批准才进入下一轮")
|
||||
|
||||
|
||||
class SkipRequest(BaseModel):
|
||||
department_name: str = Field(..., min_length=1, description="要跳过的部门名(尚未开始)")
|
||||
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
decision: str = Field(..., description="审批裁决:approved / rejected")
|
||||
gate: Optional[str] = Field(None, description="门 id(plan/rework),可选校验用")
|
||||
feedback: Optional[str] = Field(None, description="人在环注入的反馈(返工门用,注入到部门返工 prompt)")
|
||||
|
||||
|
||||
# ─── Helpers ───
|
||||
|
||||
|
||||
def _assert_company_owner(company: Company, current_user: User) -> None:
|
||||
"""校验公司归属(创建者或平台管理员),否则 403。"""
|
||||
if company.user_id != current_user.id and getattr(current_user, "role", "") != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权操作该公司")
|
||||
|
||||
|
||||
# ─── Endpoints ───
|
||||
@@ -365,6 +392,47 @@ def remove_department(
|
||||
return {"message": "部门已移除"}
|
||||
|
||||
|
||||
class DepartmentModelRequest(BaseModel):
|
||||
"""四档-③ 按部门配模型:provider/model 皆空表示清除覆盖,回归成员 Agent 自身配置。"""
|
||||
provider: Optional[str] = Field(None, max_length=50, description="LLM 提供方,如 deepseek/openai/anthropic")
|
||||
model: Optional[str] = Field(None, max_length=100, description="模型名,如 deepseek-v4-pro")
|
||||
|
||||
|
||||
@router.put("/{company_id}/departments/{department_id}/model")
|
||||
def set_department_model(
|
||||
company_id: str,
|
||||
department_id: str,
|
||||
body: DepartmentModelRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""设置/清除某部门的模型覆盖(存 Team.config["model_override"])。
|
||||
|
||||
该部门执行时所有成员统一走此模型(成本/质量分层调优);provider/model 皆空则清除。
|
||||
"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
team = db.query(Team).filter(Team.id == department_id, Team.parent_company_id == company_id).first()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
|
||||
provider = (body.provider or "").strip()
|
||||
model = (body.model or "").strip()
|
||||
cfg = dict(team.config or {})
|
||||
if not provider and not model:
|
||||
cfg.pop("model_override", None) # 清除
|
||||
override = None
|
||||
else:
|
||||
override = {"provider": provider, "model": model}
|
||||
cfg["model_override"] = override
|
||||
team.config = cfg
|
||||
flag_modified(team, "config") # JSON 原地替换需显式标记脏,确保落库
|
||||
db.commit()
|
||||
return {"department_id": department_id, "model_override": override}
|
||||
|
||||
|
||||
# ─── 项目执行 ───
|
||||
|
||||
|
||||
@@ -379,6 +447,7 @@ async def plan_company(
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
|
||||
try:
|
||||
return await orchestrator.preview_plan(body.project_description)
|
||||
@@ -397,6 +466,7 @@ async def execute_company(
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
|
||||
try:
|
||||
result = await orchestrator.execute(body.project_description, ceo_plan=body.ceo_plan)
|
||||
@@ -416,6 +486,7 @@ async def execute_company_stream(
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
|
||||
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
|
||||
|
||||
@@ -429,6 +500,92 @@ async def execute_company_stream(
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/{company_id}/execute/async")
|
||||
async def execute_company_async(
|
||||
company_id: str,
|
||||
body: ExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""异步执行公司级项目(白盒控制室):预建项目行 + 后台任务,立即返回 project_id。
|
||||
|
||||
执行在 API 进程内的后台 asyncio 任务中进行,事件落库 company_project_events,
|
||||
前端经 WS `/ws/company-projects/{project_id}` 或 HTTP `/projects/{pid}/events` 回放+实时观测。
|
||||
执行不随请求/连接断开而中止(可恢复)。
|
||||
"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
|
||||
# 预建项目行(queued)——供 WS 立即订阅;真正执行由后台任务复用该行
|
||||
project = CompanyProject(
|
||||
company_id=company_id,
|
||||
name=body.project_description[:200],
|
||||
description=body.project_description,
|
||||
status="queued",
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
project_id = project.id
|
||||
|
||||
from app.services import company_runner
|
||||
limits = {
|
||||
"max_cost_yuan": body.max_cost_yuan,
|
||||
"max_duration_min": body.max_duration_min,
|
||||
"approve_plan": bool(body.approve_plan),
|
||||
"approve_rework": bool(body.approve_rework),
|
||||
}
|
||||
company_runner.spawn(
|
||||
project_id, company_id, current_user.id, body.project_description,
|
||||
max_rounds=body.max_rounds or 3, ceo_plan=body.ceo_plan, limits=limits,
|
||||
)
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"status": "queued",
|
||||
"ws_url": f"/api/v1/ws/company-projects/{project_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{company_id}/projects/{project_id}/events")
|
||||
def get_project_events(
|
||||
company_id: str,
|
||||
project_id: str,
|
||||
since_seq: int = Query(0, ge=0),
|
||||
limit: int = Query(1000, ge=1, le=5000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""按 seq 回放项目事件(WS 不可用时的 HTTP 兜底 / 续传)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
project = (
|
||||
db.query(CompanyProject)
|
||||
.filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
rows = (
|
||||
db.query(CompanyProjectEvent)
|
||||
.filter(CompanyProjectEvent.project_id == project_id, CompanyProjectEvent.seq > since_seq)
|
||||
.order_by(CompanyProjectEvent.seq)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
events = [r.to_envelope() for r in rows]
|
||||
last_seq = rows[-1].seq if rows else since_seq
|
||||
return {
|
||||
"events": events,
|
||||
"last_seq": last_seq,
|
||||
"status": project.status,
|
||||
"finished": project.status in ("completed", "failed", "stopped"),
|
||||
}
|
||||
|
||||
|
||||
# ─── 统计 ───
|
||||
|
||||
|
||||
@@ -531,10 +688,229 @@ def export_company(
|
||||
lines.append("")
|
||||
|
||||
markdown = "\n".join(lines)
|
||||
from urllib.parse import quote
|
||||
|
||||
safe = (company.name or "company")[:60].replace('"', "").replace("/", "_").replace("\\", "_")
|
||||
ascii_fallback = safe.encode("ascii", "ignore").decode("ascii").strip() or "company"
|
||||
fname = f"{safe}_export.md"
|
||||
ascii_fname = f"{ascii_fallback}_export.md"
|
||||
disposition = (
|
||||
f'attachment; filename="{ascii_fname}"; '
|
||||
f"filename*=UTF-8''{quote(fname)}"
|
||||
)
|
||||
return Response(
|
||||
content=markdown,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{company.name}_export.md"'},
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
# ─── 四档-④:单项目运行报告导出 ───
|
||||
|
||||
_REPORT_MILESTONES = {
|
||||
"company_start": "项目启动",
|
||||
"ceo_plan_done": "CEO 规划完成",
|
||||
"awaiting_approval": "等待人工审批",
|
||||
"approval_resolved": "审批完成",
|
||||
"paused": "已暂停",
|
||||
"resumed": "已继续",
|
||||
"dept_done": "部门完成",
|
||||
"dept_skipped": "部门跳过",
|
||||
"ceo_score_done": "CEO 评审完成",
|
||||
"rework_start": "返工开始",
|
||||
"rework_done": "返工完成",
|
||||
"budget_tripped": "预算熔断",
|
||||
"timeout_tripped": "超时熔断",
|
||||
"company_terminated": "项目终止",
|
||||
"company_done": "项目完成",
|
||||
}
|
||||
|
||||
|
||||
def _fmt_dur_ms(ms) -> str:
|
||||
try:
|
||||
s = int(ms) // 1000
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
return f"{s // 60}m{s % 60:02d}s"
|
||||
|
||||
|
||||
def _render_project_report(company, project, events) -> str:
|
||||
"""把单个项目渲染成 Markdown 运行报告(纯函数,便于隔离测试)。
|
||||
|
||||
数据源:project.ceo_plan / review_scores + 事件流(终态事件取汇总、里程碑做时间轴)。
|
||||
对缺字段一律 .get 兜底,不抛。
|
||||
"""
|
||||
ceo_plan = project.ceo_plan if isinstance(project.ceo_plan, dict) else {}
|
||||
review_scores = project.review_scores if isinstance(project.review_scores, list) else []
|
||||
|
||||
# 终态事件汇总(company_done / company_terminated 取最后一个)
|
||||
terminal = {}
|
||||
for e in events:
|
||||
if e.type in ("company_done", "company_terminated"):
|
||||
terminal = e.payload or {}
|
||||
total_cost = terminal.get("total_cost_yuan")
|
||||
total_dur_ms = terminal.get("total_duration_ms")
|
||||
overall_score = terminal.get("overall_score")
|
||||
if not overall_score and review_scores:
|
||||
overall_score = review_scores[0].get("overall_score")
|
||||
dept_results = terminal.get("departments") or []
|
||||
|
||||
L = []
|
||||
L.append(f"# 运行报告 · {project.name}")
|
||||
L.append("")
|
||||
L.append(f"- **公司**: {company.name}")
|
||||
L.append(f"- **项目描述**: {project.description or project.name}")
|
||||
L.append(f"- **状态**: {project.status}")
|
||||
L.append(f"- **创建时间**: {project.created_at.isoformat() if project.created_at else '未知'}")
|
||||
if project.completed_at:
|
||||
L.append(f"- **完成时间**: {project.completed_at.isoformat()}")
|
||||
if getattr(project, "round_count", None):
|
||||
L.append(f"- **迭代轮次**: {project.round_count}")
|
||||
L.append("")
|
||||
L.append("## 概要")
|
||||
L.append("")
|
||||
L.append(f"- **综合评分**: {overall_score if overall_score is not None else '—'} / 10")
|
||||
L.append(f"- **总成本**: ¥{total_cost if total_cost is not None else '—'}")
|
||||
L.append(f"- **总时长**: {_fmt_dur_ms(total_dur_ms)}")
|
||||
L.append(f"- **部门数**: {len(dept_results) or len(ceo_plan.get('departments', []))}")
|
||||
L.append("")
|
||||
|
||||
# CEO 规划
|
||||
analysis = ceo_plan.get("analysis", "")
|
||||
dept_plans = ceo_plan.get("departments", []) if isinstance(ceo_plan, dict) else []
|
||||
if analysis or dept_plans:
|
||||
L.append("## CEO 规划")
|
||||
L.append("")
|
||||
if analysis:
|
||||
L.append(analysis)
|
||||
L.append("")
|
||||
for dp in dept_plans:
|
||||
name = dp.get("department_name") or dp.get("department_type") or "未知部门"
|
||||
L.append(f"### {name}")
|
||||
if dp.get("goal"):
|
||||
L.append(f"- **目标**: {dp['goal']}")
|
||||
deps = dp.get("dependencies") or []
|
||||
if deps:
|
||||
L.append(f"- **依赖**: {', '.join(str(d) for d in deps)}")
|
||||
dlv = dp.get("deliverables") or []
|
||||
if dlv:
|
||||
L.append("- **预期交付物**:")
|
||||
for d in dlv:
|
||||
L.append(f" - {d}")
|
||||
L.append("")
|
||||
|
||||
# 部门执行结果
|
||||
if dept_results:
|
||||
L.append("## 部门执行结果")
|
||||
L.append("")
|
||||
for dr in dept_results:
|
||||
name = dr.get("department_name", "未知部门")
|
||||
ok = "✓ 成功" if dr.get("success") else ("✗ 失败" if dr.get("success") is False else "—")
|
||||
L.append(f"### {name} — {ok}")
|
||||
meta = []
|
||||
if dr.get("cost_yuan") is not None:
|
||||
meta.append(f"成本 ¥{dr.get('cost_yuan')}")
|
||||
if dr.get("tokens_used") is not None:
|
||||
meta.append(f"tokens {dr.get('tokens_used')}")
|
||||
if dr.get("duration_ms") is not None:
|
||||
meta.append(f"耗时 {_fmt_dur_ms(dr.get('duration_ms'))}")
|
||||
if meta:
|
||||
L.append("- " + " · ".join(meta))
|
||||
result = dr.get("result") if isinstance(dr.get("result"), dict) else {}
|
||||
deliverable = (result.get("deliverable") or "").strip()
|
||||
if deliverable:
|
||||
L.append("- **交付物摘录**:")
|
||||
L.append("")
|
||||
L.append("> " + deliverable[:1200].replace("\n", "\n> "))
|
||||
files = result.get("files") or []
|
||||
if files:
|
||||
L.append(f"- **产出文件** ({len(files)}): {', '.join(str(f) for f in files[:20])}")
|
||||
if dr.get("error"):
|
||||
L.append(f"- **错误**: {dr.get('error')}")
|
||||
L.append("")
|
||||
|
||||
# CEO 评审打分
|
||||
if review_scores:
|
||||
L.append("## CEO 评审打分")
|
||||
L.append("")
|
||||
for s in review_scores:
|
||||
nm = s.get("name", "")
|
||||
sc = s.get("score", "—")
|
||||
passed = "✓" if s.get("pass") else "✗"
|
||||
L.append(f"### {nm}: {sc}/10 {passed}")
|
||||
if s.get("feedback"):
|
||||
L.append(str(s["feedback"])[:800])
|
||||
areas = s.get("improvement_areas") or []
|
||||
if areas:
|
||||
L.append("**改进方向**:")
|
||||
for a in areas:
|
||||
L.append(f"- {a}")
|
||||
L.append("")
|
||||
|
||||
# 执行时间轴
|
||||
milestones = [e for e in events if e.type in _REPORT_MILESTONES]
|
||||
if milestones:
|
||||
t0 = milestones[0].created_at
|
||||
L.append("## 执行时间轴")
|
||||
L.append("")
|
||||
for e in milestones:
|
||||
delta = "+00:00"
|
||||
if t0 and e.created_at:
|
||||
secs = int((e.created_at - t0).total_seconds())
|
||||
delta = f"+{secs // 60:02d}:{secs % 60:02d}"
|
||||
label = _REPORT_MILESTONES.get(e.type, e.type)
|
||||
payload = e.payload or {}
|
||||
detail = payload.get("department_name") or payload.get("decision") or payload.get("reason") or ""
|
||||
L.append(f"- `[{delta}]` {label}" + (f" — {detail}" if detail else ""))
|
||||
L.append("")
|
||||
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
@router.get("/{company_id}/projects/{project_id}/report")
|
||||
def export_project_report(
|
||||
company_id: str,
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出单个项目的运行报告为 Markdown(含概要/规划/部门结果/评分/时间轴)。"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
project = (
|
||||
db.query(CompanyProject)
|
||||
.filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
events = (
|
||||
db.query(CompanyProjectEvent)
|
||||
.filter(CompanyProjectEvent.project_id == project_id)
|
||||
.order_by(CompanyProjectEvent.seq)
|
||||
.all()
|
||||
)
|
||||
markdown = _render_project_report(company, project, events)
|
||||
from urllib.parse import quote
|
||||
|
||||
safe = (project.name or "project")[:60].replace('"', "").replace("/", "_").replace("\\", "_")
|
||||
ascii_fallback = safe.encode("ascii", "ignore").decode("ascii").strip() or "project"
|
||||
fname = f"report_{safe}.md"
|
||||
ascii_fname = f"report_{ascii_fallback}.md"
|
||||
disposition = (
|
||||
f'attachment; filename="{ascii_fname}"; '
|
||||
f"filename*=UTF-8''{quote(fname)}"
|
||||
)
|
||||
return Response(
|
||||
content=markdown,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": disposition},
|
||||
)
|
||||
|
||||
|
||||
@@ -820,3 +1196,108 @@ def recover_project(company_id: str, project_id: str, db: Session = Depends(get_
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
# ─── M2-a 运行时控制:停止 / 暂停 / 继续 / 跳过部门 ───
|
||||
|
||||
|
||||
def _load_owned_project(company_id: str, project_id: str, db: Session, current_user: User) -> CompanyProject:
|
||||
"""校验公司归属并返回项目,否则 404/403。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
_assert_company_owner(company, current_user)
|
||||
project = db.query(CompanyProject).filter(
|
||||
CompanyProject.id == project_id, CompanyProject.company_id == company_id
|
||||
).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/stop")
|
||||
def stop_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""停止(硬取消)运行中的项目任务,幂等。取消后 runner 落 stopped。"""
|
||||
_load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
stopped = company_runner.stop(project_id)
|
||||
return {"stopped": stopped, "status": "stopped" if stopped else "not_running"}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/pause")
|
||||
def pause_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""暂停:不再开新波次/新轮次(在飞部门跑完才生效)。"""
|
||||
project = _load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if not company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目未在运行")
|
||||
company_runner.get_control(project_id).pause_event.clear()
|
||||
project.status = "paused"
|
||||
db.commit()
|
||||
return {"paused": True}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/resume")
|
||||
def resume_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""继续:解除暂停。"""
|
||||
project = _load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if not company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目未在运行")
|
||||
company_runner.get_control(project_id).pause_event.set()
|
||||
project.status = "in_progress"
|
||||
db.commit()
|
||||
return {"resumed": True}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/skip")
|
||||
def skip_department(company_id: str, project_id: str, body: SkipRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""跳过一个尚未开始的部门(就绪选取时拦截,下游依赖照常推进)。"""
|
||||
_load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if not company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目未在运行")
|
||||
company_runner.get_control(project_id).skip_depts.add(body.department_name)
|
||||
return {"skipped": body.department_name}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/approve")
|
||||
def approve_gate(company_id: str, project_id: str, body: ApproveRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""裁决当前待批的审批门(CEO 计划 / 返工)。放行编排器 await。"""
|
||||
_load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if not company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目未在运行")
|
||||
ctrl = company_runner.get_control(project_id)
|
||||
if not ctrl.pending_gate:
|
||||
raise HTTPException(status_code=409, detail="当前无待审批的门")
|
||||
decision = "approved" if str(body.decision).strip().lower() in ("approved", "approve", "yes", "ok") else "rejected"
|
||||
gate = ctrl.pending_gate
|
||||
ctrl.resolve_gate(decision, (body.feedback or "").strip())
|
||||
return {"resolved": decision, "gate": gate}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/departments/{department_name}/cancel")
|
||||
async def cancel_department(company_id: str, project_id: str, department_name: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""M2-c:硬取消运行中的单个部门任务(不停整个公司)。下游依赖照常推进。"""
|
||||
_load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if not company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目未在运行")
|
||||
cancelled = company_runner.cancel_department(project_id, department_name)
|
||||
return {"cancelled": cancelled, "department_name": department_name}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/departments/{department_name}/rework")
|
||||
async def rework_department(company_id: str, project_id: str, department_name: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""M2-c:项目跑完后只重跑一个部门;新事件续写进同一流,替换其交付物与打分。"""
|
||||
project = _load_owned_project(company_id, project_id, db, current_user)
|
||||
from app.services import company_runner
|
||||
if company_runner.is_running(project_id):
|
||||
raise HTTPException(status_code=409, detail="项目在运行中,不能返工")
|
||||
dept_plans = (project.ceo_plan or {}).get("departments", []) if project.ceo_plan else []
|
||||
names = {dp.get("department_name") for dp in dept_plans} | {dp.get("department_type") for dp in dept_plans}
|
||||
if department_name not in names:
|
||||
raise HTTPException(status_code=400, detail="该部门不在项目计划中")
|
||||
company_runner.spawn_rework(project_id, company_id, current_user.id, department_name)
|
||||
return {"reworking": department_name}
|
||||
|
||||
139
backend/app/api/company_ws.py
Normal file
139
backend/app/api/company_ws.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
公司项目「白盒控制室」WebSocket — 事件回放 + 准实时推送
|
||||
|
||||
数据源为 MySQL 表 company_project_events(后台执行器持续落库)。
|
||||
连接后按 since_seq 回放历史事件,随后轮询该表 seq>last 的新事件推送,
|
||||
直至项目终态(completed/failed/stopped)。轮询式(非广播)→ 顺序正确、无回放/实时竞态、天然可恢复。
|
||||
|
||||
鉴权:JWT via query ?token=,校验用户 → 项目所属公司归属(创建者或平台管理员)。
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User
|
||||
from app.models.company import Company, CompanyProject
|
||||
from app.models.company_project_event import CompanyProjectEvent
|
||||
from typing import Optional
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_TERMINAL = ("completed", "failed", "stopped")
|
||||
_POLL_INTERVAL = 0.8 # 秒
|
||||
|
||||
|
||||
def _validate_company_ws_token(token: Optional[str], project_id: str) -> dict:
|
||||
"""校验 WS token 并确认项目归属。失败返回 {error, code}。"""
|
||||
if not token:
|
||||
return {"error": "缺少认证令牌", "code": 4001}
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
return {"error": "无效的访问令牌", "code": 4001}
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return {"error": "无效的令牌载荷", "code": 4001}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"error": "用户不存在", "code": 4001}
|
||||
if user.status == "deleted":
|
||||
return {"error": "账号已注销", "code": 4003}
|
||||
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
|
||||
if not project:
|
||||
return {"error": "项目不存在", "code": 4004}
|
||||
company = db.query(Company).filter(Company.id == project.company_id).first()
|
||||
if not company:
|
||||
return {"error": "公司不存在", "code": 4004}
|
||||
|
||||
# 平台管理员绕过;否则须为公司创建者
|
||||
if user.role != "admin" and company.user_id != user_id:
|
||||
return {"error": "无权查看此项目", "code": 4003}
|
||||
|
||||
return {"user_id": user_id, "workspace_id": payload.get("ws", "")}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.websocket("/api/v1/ws/company-projects/{project_id}")
|
||||
async def company_project_ws(
|
||||
websocket: WebSocket,
|
||||
project_id: str,
|
||||
token: Optional[str] = Query(None),
|
||||
since_seq: int = Query(0),
|
||||
):
|
||||
"""回放 + 准实时推送公司项目执行事件。"""
|
||||
auth = _validate_company_ws_token(token, project_id)
|
||||
if "error" in auth:
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"type": "error", "message": auth["error"], "code": auth["code"]})
|
||||
await websocket.close(code=auth["code"])
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
db = SessionLocal()
|
||||
last = since_seq
|
||||
empty_after_terminal = 0
|
||||
try:
|
||||
while True:
|
||||
# 1) 推送 seq>last 的新事件(含首次回放)
|
||||
rows = (
|
||||
db.query(CompanyProjectEvent)
|
||||
.filter(CompanyProjectEvent.project_id == project_id, CompanyProjectEvent.seq > last)
|
||||
.order_by(CompanyProjectEvent.seq)
|
||||
.limit(500)
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
await websocket.send_json(r.to_envelope())
|
||||
last = r.seq
|
||||
|
||||
# 2) 检查终态(强制刷新 ORM 缓存以读到最新 status)
|
||||
db.expire_all()
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
|
||||
if not project:
|
||||
await websocket.send_json({"type": "error", "message": "项目不存在"})
|
||||
break
|
||||
status = project.status
|
||||
|
||||
if status in _TERMINAL:
|
||||
# 终态后需再排空尾部事件(company_done 可能晚于 status 落库)
|
||||
if not rows:
|
||||
empty_after_terminal += 1
|
||||
if empty_after_terminal >= 2:
|
||||
await websocket.send_json(
|
||||
{"type": "_stream_end", "status": status, "last_seq": last}
|
||||
)
|
||||
break
|
||||
else:
|
||||
empty_after_terminal = 0
|
||||
|
||||
# 3) 等待客户端 ping 或超时(轮询间隔)
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_text(), timeout=_POLL_INTERVAL)
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
if msg.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
except Exception:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("公司项目 WS 错误 [%s]: %s", project_id, e)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e)})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
@@ -101,7 +101,31 @@ class Settings(BaseSettings):
|
||||
|
||||
# 单执行工具实际执行次数上限(LLM function calling 每执行一个工具计 1)
|
||||
WORKFLOW_MAX_TOOL_CALLS_PER_RUN: int = 500
|
||||
|
||||
|
||||
# ── 虚拟公司:事件批量落库(三档-a)──
|
||||
# 每积攒 N 条事件即刷库(bulk commit);N=1 等价旧的逐条 commit(回退开关)
|
||||
COMPANY_EVENT_FLUSH_MAX_EVENTS: int = 25
|
||||
# 或每 T 秒强制刷库一次(保证 WS 可见延迟 ≤ ~1.5s;WS 轮询 0.8s)
|
||||
COMPANY_EVENT_FLUSH_INTERVAL_SEC: float = 0.7
|
||||
|
||||
# ── 虚拟公司:并发上限(三档-b)──
|
||||
# 同时运行的公司项目数上限;超出的排队(真实队列),空出名额再启动。调高≈无限
|
||||
MAX_CONCURRENT_COMPANY_PROJECTS: int = 3
|
||||
# 排队期间刷新 updated_at 的间隔(防长队被僵尸监管误杀,ZOMBIE_TIMEOUT=900s)
|
||||
COMPANY_QUEUE_HEARTBEAT_SEC: float = 60.0
|
||||
# 单次编排每一波并行运行的部门数上限(防超大 CEO 计划一次拉起过多部门)。调高≈无限
|
||||
MAX_CONCURRENT_DEPARTMENTS: int = 4
|
||||
|
||||
# ── 虚拟公司:CEO 规划回喂历史知识(四档-② 学习闭环)──
|
||||
# 规划前检索本公司历史知识(CompanyKnowledge)按相关性注入 CEO prompt,越跑越懂本公司。关闭即回归旧行为
|
||||
COMPANY_CEO_MEMORY_ENABLED: bool = True
|
||||
# 最多注入的历史知识条数(按相关性+category 权重排序取 TopN)
|
||||
COMPANY_CEO_MEMORY_MAX_ENTRIES: int = 5
|
||||
# 注入历史知识的总字数上限(防 CEO prompt 膨胀/超 context)
|
||||
COMPANY_CEO_MEMORY_MAX_CHARS: int = 4000
|
||||
# 项目终态时自动沉淀知识到 CompanyKnowledge(闭合学习闭环;否则需手动调 generate 接口)
|
||||
COMPANY_AUTO_EXTRACT_KNOWLEDGE: bool = True
|
||||
|
||||
# 外部访问地址(用于飞书通知中的详情链接等)
|
||||
EXTERNAL_URL: str = ""
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ def init_db():
|
||||
import app.models.billing
|
||||
import app.models.company_schedule
|
||||
import app.models.company_knowledge
|
||||
import app.models.company_project_event
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# v1.1.0: 手机验证字段迁移(safe ALTER)
|
||||
|
||||
@@ -120,7 +120,7 @@ process_memory_bytes = Gauge(
|
||||
)
|
||||
|
||||
process_open_fds = Gauge(
|
||||
"process_open_fds", "进程打开的文件描述符数"
|
||||
"app_process_open_fds", "进程打开的文件描述符数"
|
||||
)
|
||||
|
||||
db_connections_active = Gauge(
|
||||
|
||||
@@ -527,7 +527,7 @@ async def startup_event():
|
||||
logger.error(f"公司定时调度器启动失败: {e}")
|
||||
|
||||
# 注册路由
|
||||
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users, api_keys, company_presets, companies
|
||||
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users, api_keys, company_presets, companies, company_ws
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(workspaces.router)
|
||||
@@ -584,6 +584,7 @@ app.include_router(users.router)
|
||||
app.include_router(api_keys.router)
|
||||
app.include_router(company_presets.router)
|
||||
app.include_router(companies.router)
|
||||
app.include_router(company_ws.router)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
|
||||
@@ -34,10 +34,11 @@ from app.models.workspace import Workspace, WorkspaceMembership
|
||||
from app.models.scene_contract import SceneContract
|
||||
from app.models.team import Team, TeamMember
|
||||
from app.models.company import Company, CompanyProject
|
||||
from app.models.company_project_event import CompanyProjectEvent
|
||||
from app.models.company_schedule import CompanySchedule
|
||||
from app.models.company_knowledge import CompanyKnowledge
|
||||
from app.models.chat_message import ChatMessage
|
||||
from app.models.agent_session import AgentSession
|
||||
from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
|
||||
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "Company", "CompanyProject", "CompanySchedule", "CompanyKnowledge", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"]
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "Company", "CompanyProject", "CompanyProjectEvent", "CompanySchedule", "CompanyKnowledge", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"]
|
||||
44
backend/app/models/company_project_event.py
Normal file
44
backend/app/models/company_project_event.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
公司级项目执行事件 — 白盒控制室的持久事件流
|
||||
|
||||
每条编排事件(CEO 规划 / 部门 ReAct / 打分 / 完成…)落一行,
|
||||
供控制室按 seq 回放、断线恢复、审计。表由 create_all 自动创建。
|
||||
"""
|
||||
from sqlalchemy import Column, String, Integer, DateTime, JSON, ForeignKey, Index, func
|
||||
from sqlalchemy.dialects.mysql import CHAR
|
||||
from app.core.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class CompanyProjectEvent(Base):
|
||||
"""公司项目执行事件(一次编排 = 一串按 seq 递增的事件)。"""
|
||||
__tablename__ = "company_project_events"
|
||||
|
||||
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="事件ID")
|
||||
project_id = Column(
|
||||
CHAR(36),
|
||||
ForeignKey("company_projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="所属项目ID",
|
||||
)
|
||||
seq = Column(Integer, nullable=False, comment="项目内自增序号,从 1")
|
||||
type = Column(String(50), nullable=False, comment="事件类型")
|
||||
payload = Column(JSON, nullable=True, comment="事件负载(原编排事件字段)")
|
||||
created_at = Column(DateTime, default=func.now(), comment="落库时间")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_cpe_project_seq", "project_id", "seq"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CompanyProjectEvent(project_id={self.project_id}, seq={self.seq}, type={self.type})>"
|
||||
|
||||
def to_envelope(self) -> dict:
|
||||
"""转为前端消费的事件信封。"""
|
||||
return {
|
||||
"seq": self.seq,
|
||||
"pid": self.project_id,
|
||||
"type": self.type,
|
||||
"payload": self.payload,
|
||||
"ts": self.created_at.timestamp() if self.created_at else None,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
483
backend/app/services/company_runner.py
Normal file
483
backend/app/services/company_runner.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
公司项目后台执行器 — 白盒控制室的执行解耦层
|
||||
|
||||
把公司级流式执行从「请求协程内跑完」解耦为「API 进程内的后台 asyncio 任务」:
|
||||
- 执行不再随 SSE/WS 断开而中止(可恢复)
|
||||
- 每条编排事件落库 company_project_events(供 WS/HTTP 按 seq 回放)
|
||||
- 周期性刷新 project.updated_at(心跳),避免僵尸监管误杀长任务
|
||||
|
||||
前提:API 为单 uvicorn worker,后台任务与 WS 端点同进程共享 DB/表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.company import CompanyProject
|
||||
from app.models.company_project_event import CompanyProjectEvent
|
||||
from app.services.company_orchestrator import CompanyOrchestrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 进程内后台任务句柄:防止 asyncio.create_task 结果被 GC;完成后自动清理
|
||||
_RUNNING: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# 三档-b:全局项目并发信号量(lazy,绑定到运行时事件循环)。单 worker 单 loop 单实例。
|
||||
_PROJECT_SEM: Optional[asyncio.Semaphore] = None
|
||||
|
||||
|
||||
def _get_project_sem() -> asyncio.Semaphore:
|
||||
"""取(缺则建)全局项目并发信号量。lazy 构造避免 import 期绑错事件循环。"""
|
||||
global _PROJECT_SEM
|
||||
if _PROJECT_SEM is None:
|
||||
_PROJECT_SEM = asyncio.Semaphore(max(1, int(settings.MAX_CONCURRENT_COMPANY_PROJECTS)))
|
||||
return _PROJECT_SEM
|
||||
|
||||
|
||||
class ProjectControl:
|
||||
"""单个运行中项目的进程内控制态(M2-a:暂停/继续/跳过)。
|
||||
|
||||
与 `_RUNNING` 同为进程内状态:编排任务与 API handler 同进程,靠同一对象通信。
|
||||
进程重启则控制态随运行任务一起消失(任务本就死),无持久化需求。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pause_event = asyncio.Event()
|
||||
self.pause_event.set() # set = 运行中;clear = 暂停(编排器在检查点 await)
|
||||
self.skip_depts: set[str] = set() # 待跳过的(未开始)部门名
|
||||
# M2-b 人在环审批门:set = 放行/无门;clear = 编排器在门前 await 等人裁决
|
||||
self.approval_event = asyncio.Event()
|
||||
self.approval_event.set()
|
||||
self.approval_decision: str = "" # 最近一次裁决:"approved" / "rejected"
|
||||
self.approval_feedback: str = "" # 人在环注入的反馈文本(返工门用,供下一轮注入部门 prompt)
|
||||
self.pending_gate: str = "" # 非空=有门待批,值即门 id:"plan" / "rework"
|
||||
# M2-c 部门粒度控制:运行中部门 name→task(供单部门硬取消)
|
||||
self.dept_tasks: Dict[str, asyncio.Task] = {}
|
||||
self.cancelled_depts: set[str] = set() # 已请求取消的部门名(记录意图)
|
||||
|
||||
def open_gate(self, gate: str) -> None:
|
||||
"""编排器开门等审批:登记门 id、清裁决、阻塞后续 await。"""
|
||||
self.pending_gate = gate
|
||||
self.approval_decision = ""
|
||||
self.approval_feedback = ""
|
||||
self.approval_event.clear()
|
||||
|
||||
def resolve_gate(self, decision: str, feedback: str = "") -> None:
|
||||
"""API 端裁决:落决定+可选反馈并放行编排器 await。pending_gate 由编排器过门后清。"""
|
||||
self.approval_decision = decision
|
||||
self.approval_feedback = feedback or ""
|
||||
self.approval_event.set()
|
||||
|
||||
|
||||
# 进程内控制注册表,键为 project_id
|
||||
_CONTROL: Dict[str, ProjectControl] = {}
|
||||
|
||||
|
||||
def get_control(project_id: str) -> ProjectControl:
|
||||
"""取(缺则建)项目控制态;API 与编排器共用同一对象,幂等。"""
|
||||
ctrl = _CONTROL.get(project_id)
|
||||
if ctrl is None:
|
||||
ctrl = ProjectControl()
|
||||
_CONTROL[project_id] = ctrl
|
||||
return ctrl
|
||||
|
||||
|
||||
def clear_control(project_id: str) -> None:
|
||||
_CONTROL.pop(project_id, None)
|
||||
|
||||
|
||||
def stop(project_id: str) -> bool:
|
||||
"""请求停止(硬取消)运行中的项目任务。返回是否有任务被取消。
|
||||
|
||||
复用 run_company_project 的 CancelledError→stopped 雏形(落 company_terminated)。
|
||||
"""
|
||||
task = _RUNNING.get(project_id)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cancel_department(project_id: str, name: str) -> bool:
|
||||
"""M2-c:硬取消运行中的单个部门任务(不停整个公司)。返回是否有任务被取消。
|
||||
|
||||
编排器在建部门 task 时把句柄登记进 ctrl.dept_tasks;此处 cancel 触发
|
||||
`_run_department_stream` 的 CancelledError 分支,仍发 dept_done 让波次推进、
|
||||
下游依赖照常解锁。
|
||||
"""
|
||||
ctrl = get_control(project_id)
|
||||
task = ctrl.dept_tasks.get(name)
|
||||
if task is not None and not task.done():
|
||||
ctrl.cancelled_depts.add(name)
|
||||
task.cancel()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _max_seq(db, project_id: str) -> int:
|
||||
"""当前项目已落库事件的最大 seq(无则 0)。返工据此续写,避免前端按 seq 丢弃。"""
|
||||
try:
|
||||
return db.query(func.max(CompanyProjectEvent.seq)).filter(
|
||||
CompanyProjectEvent.project_id == project_id
|
||||
).scalar() or 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
_TERMINAL = {"completed", "failed", "stopped"}
|
||||
_MAX_STR = 8192 # 单个字符串字段落库上限,防止行爆(如超长 tool_call.input)
|
||||
|
||||
|
||||
def _truncate(obj: Any, limit: int = _MAX_STR):
|
||||
"""递归截断过长字符串,控制事件行体积。"""
|
||||
if isinstance(obj, str):
|
||||
return obj if len(obj) <= limit else obj[:limit] + f"…[truncated {len(obj) - limit} chars]"
|
||||
if isinstance(obj, dict):
|
||||
return {k: _truncate(v, limit) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_truncate(v, limit) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
# 三档-a:终态/关键事件类型——立即 force-flush 落地,保证 WS 不截断 finale
|
||||
_TERMINAL_EVENT_TYPES = {
|
||||
"company_done", "company_terminated", "rework_done",
|
||||
"budget_tripped", "timeout_tripped", "error", "review_parse_failed",
|
||||
}
|
||||
|
||||
|
||||
class _EventBuffer:
|
||||
"""按 run 缓冲事件、批量落库(三档-a)。单事件循环单写者,无需锁。
|
||||
|
||||
- add() 只入内存;flush() 一次事务 add_all + 单 commit(省远程 MySQL 逐条 RTT)。
|
||||
- 心跳折叠进 flush(每 hb_interval 秒顺带 bump updated_at)。
|
||||
- 终态/错误事件走 flush_terminal 立即落地,配合 WS 的 2-空轮询宽限防截断 finale。
|
||||
- 用 add_all(非 bulk_insert_mappings):模型 id/created_at 是 Python 侧 default,
|
||||
add_all 构 ORM 实例默认照常触发;SQLAlchemy 1.4+ 仍渲染多行 INSERT。
|
||||
"""
|
||||
|
||||
def __init__(self, project_id: str) -> None:
|
||||
self.project_id = project_id
|
||||
self.rows: list[dict] = []
|
||||
self.last_flush = time.time()
|
||||
self.last_hb = time.time()
|
||||
self.max_events = max(1, int(settings.COMPANY_EVENT_FLUSH_MAX_EVENTS))
|
||||
self.max_interval = float(settings.COMPANY_EVENT_FLUSH_INTERVAL_SEC)
|
||||
self.hb_interval = 5.0
|
||||
|
||||
def add(self, seq: int, evt: Dict[str, Any]) -> None:
|
||||
self.rows.append({
|
||||
"project_id": self.project_id,
|
||||
"seq": seq,
|
||||
"type": str(evt.get("type", "unknown"))[:50],
|
||||
"payload": _truncate(evt),
|
||||
})
|
||||
|
||||
def should_flush(self, now: float) -> bool:
|
||||
return len(self.rows) >= self.max_events or (
|
||||
bool(self.rows) and (now - self.last_flush) >= self.max_interval
|
||||
)
|
||||
|
||||
def flush(self, db, force: bool = False) -> None:
|
||||
"""一次事务落盘缓冲事件 + 折叠心跳。失败不阻断执行(沿用旧契约)。"""
|
||||
now = time.time()
|
||||
hb_due = force or (now - self.last_hb) >= self.hb_interval
|
||||
if not self.rows and not hb_due:
|
||||
return
|
||||
pending = len(self.rows)
|
||||
try:
|
||||
if self.rows:
|
||||
db.add_all([CompanyProjectEvent(**r) for r in self.rows])
|
||||
if hb_due:
|
||||
db.query(CompanyProject).filter(CompanyProject.id == self.project_id).update(
|
||||
{"updated_at": datetime.now()}
|
||||
)
|
||||
db.commit()
|
||||
self.rows.clear()
|
||||
self.last_flush = now
|
||||
if hb_due:
|
||||
self.last_hb = now
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning("事件批量落库失败 [%s, %d 行]: %s", self.project_id, pending, e)
|
||||
# 保留 rows 下次重试,但上限 10*N 防持续故障时无限增长(丢最旧)
|
||||
cap = self.max_events * 10
|
||||
if len(self.rows) > cap:
|
||||
dropped = len(self.rows) - cap
|
||||
self.rows = self.rows[-cap:]
|
||||
logger.warning("事件缓冲超限,丢弃最旧 %d 行 [%s]", dropped, self.project_id)
|
||||
|
||||
def flush_terminal(self, db, seq: int, evt: Dict[str, Any]) -> None:
|
||||
"""终态/错误事件:入队并立即 force-flush(事件 + status-bump 一次 commit 落地)。"""
|
||||
self.add(seq, evt)
|
||||
self.flush(db, force=True)
|
||||
|
||||
|
||||
def _write_queued(project_id: str, seq: int) -> None:
|
||||
"""排队中:临时 session 写一条 queued 事件 + 刷新心跳后关闭(不占用长连接)。"""
|
||||
tmp = SessionLocal()
|
||||
try:
|
||||
_persist_event(tmp, project_id, seq,
|
||||
{"type": "queued", "reason": "waiting_slot", "_ts": time.time()})
|
||||
_heartbeat(tmp, project_id)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
|
||||
def _bump_heartbeat(project_id: str) -> None:
|
||||
"""排队等待中周期性刷新 updated_at,防僵尸监管误杀(临时 session)。"""
|
||||
tmp = SessionLocal()
|
||||
try:
|
||||
_heartbeat(tmp, project_id)
|
||||
finally:
|
||||
tmp.close()
|
||||
|
||||
|
||||
def is_running(project_id: str) -> bool:
|
||||
task = _RUNNING.get(project_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
|
||||
def spawn(project_id: str, company_id: str, user_id: str, description: str,
|
||||
max_rounds: int = 3, ceo_plan: Optional[Dict[str, Any]] = None,
|
||||
limits: Optional[Dict[str, Any]] = None) -> asyncio.Task:
|
||||
"""在当前事件循环内派发后台执行任务,并登记句柄防 GC。"""
|
||||
task = asyncio.create_task(
|
||||
run_company_project(project_id, company_id, user_id, description, max_rounds, ceo_plan, limits)
|
||||
)
|
||||
_RUNNING[project_id] = task
|
||||
|
||||
def _on_done(t: asyncio.Task) -> None:
|
||||
_RUNNING.pop(project_id, None)
|
||||
clear_control(project_id) # 清理控制态,避免注册表泄漏
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
return task
|
||||
|
||||
|
||||
async def run_company_project(
|
||||
project_id: str,
|
||||
company_id: str,
|
||||
user_id: str,
|
||||
description: str,
|
||||
max_rounds: int = 3,
|
||||
ceo_plan: Optional[Dict[str, Any]] = None,
|
||||
limits: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""后台执行一个公司级项目:驱动 execute_stream,批量事件落库 + 心跳;受全局并发上限约束。"""
|
||||
sem = _get_project_sem()
|
||||
seq = 0
|
||||
acquired = False
|
||||
db_orch = None
|
||||
db_evt = None
|
||||
buf: Optional[_EventBuffer] = None
|
||||
try:
|
||||
# 三档-b:并发上限 + 真实排队。无空名额时先写 queued 事件、排队期周期心跳。
|
||||
if sem.locked():
|
||||
seq += 1
|
||||
_write_queued(project_id, seq)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(sem.acquire(), timeout=settings.COMPANY_QUEUE_HEARTBEAT_SEC)
|
||||
acquired = True
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
_bump_heartbeat(project_id) # 排队久刷新 updated_at 防僵尸误杀
|
||||
|
||||
# 名额到手后再开 DB session(排队期零连接,防池死锁)
|
||||
db_orch = SessionLocal() # 编排器专用(会被内部并行部门协程共享,沿用既有行为)
|
||||
db_evt = SessionLocal() # 事件落库 + 心跳专用,隔离编排器 session 状态
|
||||
buf = _EventBuffer(project_id)
|
||||
orch = CompanyOrchestrator(db_orch, company_id, user_id)
|
||||
async for evt in orch.execute_stream(
|
||||
description, max_rounds=max_rounds, ceo_plan=ceo_plan, project_id=project_id,
|
||||
limits=limits,
|
||||
):
|
||||
seq += 1
|
||||
buf.add(seq, evt)
|
||||
if evt.get("type") in _TERMINAL_EVENT_TYPES:
|
||||
buf.flush(db_evt, force=True) # 终态立即落地,防 WS 截断 finale
|
||||
else:
|
||||
now = time.time()
|
||||
if buf.should_flush(now) or (now - buf.last_hb) >= buf.hb_interval:
|
||||
buf.flush(db_evt)
|
||||
buf.flush(db_evt, force=True) # 收尾兜底:保 company_done 等 finale 落地
|
||||
# execute_stream 正常结束时已把 project 状态落 completed/failed(其自身 session)
|
||||
logger.info("公司后台执行完成 [%s]: %d 事件", project_id, seq)
|
||||
except asyncio.CancelledError:
|
||||
if db_evt is None:
|
||||
db_evt = SessionLocal()
|
||||
if buf is None:
|
||||
buf = _EventBuffer(project_id)
|
||||
buf.flush(db_evt, force=True)
|
||||
_set_status(db_evt, project_id, "stopped")
|
||||
buf.flush_terminal(db_evt, seq + 1,
|
||||
{"type": "company_terminated", "reason": "cancelled", "_ts": time.time()})
|
||||
logger.info("公司后台执行被取消 [%s]", project_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("公司后台执行失败 [%s]: %s", project_id, e, exc_info=True)
|
||||
if db_evt is None:
|
||||
db_evt = SessionLocal()
|
||||
if buf is None:
|
||||
buf = _EventBuffer(project_id)
|
||||
buf.flush(db_evt, force=True)
|
||||
_set_status(db_evt, project_id, "failed")
|
||||
buf.flush_terminal(db_evt, seq + 1,
|
||||
{"type": "error", "message": str(e), "phase": "runner", "_ts": time.time()})
|
||||
finally:
|
||||
if acquired:
|
||||
sem.release() # 先放名额,再关 session
|
||||
try:
|
||||
if db_orch is not None:
|
||||
db_orch.close()
|
||||
finally:
|
||||
if db_evt is not None:
|
||||
db_evt.close()
|
||||
|
||||
|
||||
def spawn_rework(project_id: str, company_id: str, user_id: str, dept_name: str) -> asyncio.Task:
|
||||
"""M2-c:派发单部门返工的后台任务(跑完后重跑一个部门)。镜像 spawn 的注册。"""
|
||||
task = asyncio.create_task(
|
||||
run_department_rework(project_id, company_id, user_id, dept_name)
|
||||
)
|
||||
_RUNNING[project_id] = task
|
||||
|
||||
def _on_done(t: asyncio.Task) -> None:
|
||||
_RUNNING.pop(project_id, None)
|
||||
clear_control(project_id)
|
||||
|
||||
task.add_done_callback(_on_done)
|
||||
return task
|
||||
|
||||
|
||||
async def run_department_rework(
|
||||
project_id: str,
|
||||
company_id: str,
|
||||
user_id: str,
|
||||
dept_name: str,
|
||||
) -> None:
|
||||
"""后台重跑单个部门:seq 续写进同一事件流,替换该部门交付物与打分。
|
||||
|
||||
结构镜像 run_company_project(并发上限 + 批量落库 + CancelledError/Exception 收尾),
|
||||
但不走 execute_stream 全流程,只驱动编排器的 rework_single_department。
|
||||
"""
|
||||
sem = _get_project_sem()
|
||||
acquired = False
|
||||
db_orch = None
|
||||
db_evt = None
|
||||
buf: Optional[_EventBuffer] = None
|
||||
seq = 0
|
||||
try:
|
||||
# 三档-b:返工共用同一全局信号量(=一个部门的完整负载,不该绕过上限)
|
||||
if sem.locked():
|
||||
tmp = SessionLocal()
|
||||
try:
|
||||
seq = _max_seq(tmp, project_id) + 1
|
||||
_persist_event(tmp, project_id, seq,
|
||||
{"type": "queued", "reason": "waiting_slot", "_ts": time.time()})
|
||||
_heartbeat(tmp, project_id)
|
||||
finally:
|
||||
tmp.close()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(sem.acquire(), timeout=settings.COMPANY_QUEUE_HEARTBEAT_SEC)
|
||||
acquired = True
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
_bump_heartbeat(project_id)
|
||||
|
||||
db_orch = SessionLocal()
|
||||
db_evt = SessionLocal()
|
||||
buf = _EventBuffer(project_id)
|
||||
seq = _max_seq(db_evt, project_id) # 从当前最大 seq(含刚写的 queued)续写
|
||||
_set_status(db_evt, project_id, "in_progress")
|
||||
seq += 1
|
||||
buf.add(seq, {"type": "rework_start", "department_name": dept_name, "_ts": time.time()})
|
||||
buf.flush(db_evt, force=True) # 状态刚翻 in_progress,前端要重连,rework_start 尽快可见
|
||||
orch = CompanyOrchestrator(db_orch, company_id, user_id)
|
||||
orch.auto_approve_files = True
|
||||
async for evt in orch.rework_single_department(project_id, dept_name):
|
||||
seq += 1
|
||||
buf.add(seq, evt)
|
||||
if evt.get("type") in _TERMINAL_EVENT_TYPES:
|
||||
buf.flush(db_evt, force=True)
|
||||
else:
|
||||
now = time.time()
|
||||
if buf.should_flush(now) or (now - buf.last_hb) >= buf.hb_interval:
|
||||
buf.flush(db_evt)
|
||||
buf.flush(db_evt, force=True)
|
||||
logger.info("单部门返工完成 [%s / %s]", project_id, dept_name)
|
||||
except asyncio.CancelledError:
|
||||
if db_evt is None:
|
||||
db_evt = SessionLocal()
|
||||
if buf is None:
|
||||
buf = _EventBuffer(project_id)
|
||||
buf.flush(db_evt, force=True)
|
||||
_set_status(db_evt, project_id, "stopped")
|
||||
buf.flush_terminal(db_evt, seq + 1,
|
||||
{"type": "company_terminated", "reason": "cancelled", "_ts": time.time()})
|
||||
logger.info("单部门返工被取消 [%s / %s]", project_id, dept_name)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("单部门返工失败 [%s / %s]: %s", project_id, dept_name, e, exc_info=True)
|
||||
if db_evt is None:
|
||||
db_evt = SessionLocal()
|
||||
if buf is None:
|
||||
buf = _EventBuffer(project_id)
|
||||
buf.flush(db_evt, force=True)
|
||||
_set_status(db_evt, project_id, "failed")
|
||||
buf.flush_terminal(db_evt, seq + 1,
|
||||
{"type": "error", "message": str(e), "phase": "rework", "_ts": time.time()})
|
||||
finally:
|
||||
if acquired:
|
||||
sem.release()
|
||||
try:
|
||||
if db_orch is not None:
|
||||
db_orch.close()
|
||||
finally:
|
||||
if db_evt is not None:
|
||||
db_evt.close()
|
||||
|
||||
|
||||
def _persist_event(db, project_id: str, seq: int, evt: Dict[str, Any]) -> None:
|
||||
"""落一行事件;失败不阻断执行。"""
|
||||
try:
|
||||
row = CompanyProjectEvent(
|
||||
project_id=project_id,
|
||||
seq=seq,
|
||||
type=str(evt.get("type", "unknown"))[:50],
|
||||
payload=_truncate(evt),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning("事件落库失败 [%s seq=%s]: %s", project_id, seq, e)
|
||||
|
||||
|
||||
def _heartbeat(db, project_id: str) -> None:
|
||||
try:
|
||||
db.query(CompanyProject).filter(CompanyProject.id == project_id).update(
|
||||
{"updated_at": datetime.now()}
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
def _set_status(db, project_id: str, status: str) -> None:
|
||||
try:
|
||||
db.query(CompanyProject).filter(CompanyProject.id == project_id).update(
|
||||
{"status": status, "updated_at": datetime.now()}
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -12,7 +12,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import difflib
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
@@ -39,8 +41,34 @@ from app.services.project_scanner import (
|
||||
)
|
||||
|
||||
|
||||
def _build_agent_config(agent: Agent, auto_approve_files: bool = True) -> AgentConfig:
|
||||
"""从 Agent 的 workflow_config 构建 AgentConfig,供 AgentRuntime 使用。"""
|
||||
def _extract_model_override(team_config: Any) -> Optional[dict]:
|
||||
"""四档-③:从 Team.config 解析部门级模型覆盖 {provider?, model?}(无则 None)。
|
||||
|
||||
约定存于 `config["model_override"] = {"provider": "...", "model": "..."}`;
|
||||
provider/model 任一为空即视为未设该项,两者皆空 → None(回归各成员 Agent 自身配置)。
|
||||
"""
|
||||
if not isinstance(team_config, dict):
|
||||
return None
|
||||
mo = team_config.get("model_override")
|
||||
if not isinstance(mo, dict):
|
||||
return None
|
||||
model = str(mo.get("model") or "").strip()
|
||||
provider = str(mo.get("provider") or "").strip()
|
||||
if not model and not provider:
|
||||
return None
|
||||
return {"model": model or None, "provider": provider or None}
|
||||
|
||||
|
||||
def _build_agent_config(
|
||||
agent: Agent,
|
||||
auto_approve_files: bool = True,
|
||||
model_override: Optional[dict] = None,
|
||||
) -> AgentConfig:
|
||||
"""从 Agent 的 workflow_config 构建 AgentConfig,供 AgentRuntime 使用。
|
||||
|
||||
model_override(四档-③ 部门级配模型)不为空时覆盖 provider/model,
|
||||
让同一部门内所有成员统一走部门指定的模型(成本/质量分层调优)。
|
||||
"""
|
||||
wf = agent.workflow_config or {}
|
||||
nodes = wf.get("nodes", [])
|
||||
llm_node = None
|
||||
@@ -58,8 +86,16 @@ def _build_agent_config(agent: Agent, auto_approve_files: bool = True) -> AgentC
|
||||
temperature = float(llm_data.get("temperature", 0.4))
|
||||
max_iterations = int(llm_data.get("max_iterations", 15))
|
||||
|
||||
# 四档-③:部门级模型覆盖(优先于成员 Agent 自身配置)
|
||||
if model_override:
|
||||
if model_override.get("model"):
|
||||
model = model_override["model"]
|
||||
if model_override.get("provider"):
|
||||
provider = model_override["provider"]
|
||||
|
||||
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)
|
||||
logger.info("_build_agent_config: agent=%s tools=%d permission_level=%s model=%s%s",
|
||||
agent.name, len(tools), perm, model, " (dept-override)" if model_override else "")
|
||||
return AgentConfig(
|
||||
name=agent.name,
|
||||
system_prompt=system_prompt,
|
||||
@@ -232,6 +268,48 @@ def _parse_depends_on(raw: str) -> List[int]:
|
||||
return []
|
||||
|
||||
|
||||
def _norm_role(s: str) -> str:
|
||||
"""角色名归一化:小写、去空白、下划线/空格统一为连字符。"""
|
||||
return (s or "").strip().lower().replace("_", "-").replace(" ", "-")
|
||||
|
||||
|
||||
# LLM 常见叫法 → 预设 slug(归一化后的键)。其余靠归一化+模糊兜底。
|
||||
_ROLE_ALIASES: Dict[str, str] = {
|
||||
"backend": "dev-backend",
|
||||
"back-end": "dev-backend",
|
||||
"backend-dev": "dev-backend",
|
||||
"backend-developer": "dev-backend",
|
||||
"developer": "dev-backend",
|
||||
"engineer": "dev-backend",
|
||||
"程序员": "dev-backend",
|
||||
"后端": "dev-backend",
|
||||
"后端工程师": "dev-backend",
|
||||
"frontend": "dev-frontend",
|
||||
"front-end": "dev-frontend",
|
||||
"frontend-dev": "dev-frontend",
|
||||
"frontend-developer": "dev-frontend",
|
||||
"前端": "dev-frontend",
|
||||
"前端工程师": "dev-frontend",
|
||||
"产品经理": "pm",
|
||||
"product-manager": "pm",
|
||||
"pm-lead": "pm",
|
||||
"设计师": "designer",
|
||||
"ui": "designer",
|
||||
"ui-designer": "designer",
|
||||
"测试": "qa",
|
||||
"测试工程师": "qa",
|
||||
"qa-engineer": "qa",
|
||||
"运维": "devops",
|
||||
"文档": "tech-writer",
|
||||
"技术文档": "tech-writer",
|
||||
"tech-writer": "tech-writer",
|
||||
"techwriter": "tech-writer",
|
||||
"首席执行官": "ceo",
|
||||
"技术总监": "cto",
|
||||
"首席技术官": "cto",
|
||||
}
|
||||
|
||||
|
||||
def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
|
||||
"""从 Markdown 表格中提取 phase 信息。支持格式:
|
||||
| **phase-1** | `functional_tester` | 核心任务 | 工时 |
|
||||
@@ -240,10 +318,10 @@ def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
phases = []
|
||||
# 匹配表格行: | (可选**的) phase-N (可选**) | role | description | [可选 depends_on] |
|
||||
# role 可能带反引号 `role` 或直接是文本
|
||||
# role 可能带反引号 `role` 或直接是文本;支持连字符 slug(如 dev-backend)
|
||||
table_row = re.compile(
|
||||
r'^\|\s*\*{0,2}(?:phase[-–]?\s*)?(\d+)\*{0,2}\s*\|'
|
||||
r'\s*(?:`)?([a-z_]+)(?:`)?\s*\|'
|
||||
r'\s*(?:`)?([a-z0-9_-]+)(?:`)?\s*\|'
|
||||
r'\s*([^|]+?)\s*\|'
|
||||
r'(?:\s*([^|]*?)\s*\|)?',
|
||||
re.MULTILINE | re.IGNORECASE
|
||||
@@ -253,16 +331,8 @@ def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
|
||||
role = m.group(2).strip().lower()
|
||||
desc = m.group(3).strip()
|
||||
depends_raw = (m.group(4) or '').strip() if m.lastindex and m.lastindex >= 4 else ''
|
||||
# 验证 role 是合法角色名
|
||||
if role not in ("test_planner", "functional_tester", "ux_reviewer", "edge_explorer",
|
||||
"architect",
|
||||
"performance_evaluator", "pm", "designer", "developer", "qa", "devops",
|
||||
"curriculum_designer", "instructor", "teaching_assistant", "academic_admin",
|
||||
"fullstack_dev", "fe_ux_engineer", "platform_devops", "qa_engineer", "product_lead",
|
||||
"doc_architect", "tech_writer", "api_doc_specialist", "translator_reviewer",
|
||||
"release_manager", "health_assessor", "nutritionist", "exercise_rehab",
|
||||
"psychologist", "chronic_manager", "triage_specialist", "record_analyst",
|
||||
"medication_reviewer", "followup_manager", "insurance_coordinator"):
|
||||
# 接受任何非空 role slug;不存在的角色交由 _resolve_phase_agent 归一化/兜底
|
||||
if not role:
|
||||
continue
|
||||
phases.append({
|
||||
"phase": phase_num,
|
||||
@@ -311,6 +381,27 @@ def _scan_directory_files(directory: Path) -> List[str]:
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def _scan_recent_files(root: Path, since_ts: float) -> List[str]:
|
||||
"""递归扫描 root,返回本次运行期间(mtime >= since_ts)修改过的文件绝对路径。
|
||||
|
||||
用于精确捕获部门本轮真实产物:Agent 有时把产物写到 project_path 以外的
|
||||
兄弟目录,或用脚本(code_execute)生成文件——这些都不在 file_write 追踪里,
|
||||
但只要落在部门工作区且是本轮修改的,就应计入交付物。按 mtime 过滤可避免
|
||||
把历史运行的陈旧文件误纳入。
|
||||
"""
|
||||
import os
|
||||
hits: List[str] = []
|
||||
if not root.exists():
|
||||
return hits
|
||||
for p in root.rglob("*"):
|
||||
try:
|
||||
if p.is_file() and p.stat().st_mtime >= since_ts:
|
||||
hits.append(str(p))
|
||||
except OSError:
|
||||
continue
|
||||
return hits
|
||||
|
||||
|
||||
def _extract_written_files(result: AgentResult) -> List[str]:
|
||||
"""从 AgentResult.steps 中提取 file_write 成功产出的绝对路径列表。"""
|
||||
files: List[str] = []
|
||||
@@ -357,6 +448,11 @@ class TeamOrchestrator:
|
||||
self.team_id = team_id
|
||||
self.user_id = user_id
|
||||
self.auto_approve_files = auto_approve_files
|
||||
self._model_override: Optional[dict] = None # 四档-③:部门级模型覆盖,execute[_stream] 载入 team 后设置
|
||||
|
||||
def _acfg(self, agent: Agent) -> AgentConfig:
|
||||
"""构建成员 AgentConfig,自动应用部门级模型覆盖(四档-③)。"""
|
||||
return _build_agent_config(agent, self.auto_approve_files, self._model_override)
|
||||
|
||||
def _load_team(self) -> Team:
|
||||
team = self.db.query(Team).filter(Team.id == self.team_id).first()
|
||||
@@ -373,12 +469,66 @@ class TeamOrchestrator:
|
||||
)
|
||||
|
||||
def _get_agent_by_role(self, members: List[TeamMember], role: str) -> Optional[Agent]:
|
||||
"""按角色查找 Agent。"""
|
||||
"""按角色查找 Agent(严格精确匹配)。规划者角色解析依赖其 None 回退,勿放宽。"""
|
||||
for m in members:
|
||||
if m.role == role:
|
||||
return m.agent
|
||||
return None
|
||||
|
||||
def _resolve_phase_agent(
|
||||
self, members: List[TeamMember], role: str
|
||||
) -> tuple[Optional[Agent], str, bool]:
|
||||
"""健壮地把阶段 role 解析到成员 Agent,避免「部门空转」。
|
||||
|
||||
解析阶梯:精确 → 归一化相等 → 复合拆分(a/b、a,b) → alias 表 → 模糊 → 兜底到 Leader。
|
||||
Returns: (agent, matched_role, is_fallback)。members 为空才返回 (None, role, False)。
|
||||
"""
|
||||
if not members:
|
||||
return None, role, False
|
||||
|
||||
# a. 精确
|
||||
for m in members:
|
||||
if m.role == role:
|
||||
return m.agent, m.role, False
|
||||
|
||||
norm_to_member = {_norm_role(m.role): m for m in members}
|
||||
nrole = _norm_role(role)
|
||||
|
||||
# b. 归一化相等
|
||||
if nrole in norm_to_member:
|
||||
m = norm_to_member[nrole]
|
||||
return m.agent, m.role, False
|
||||
|
||||
# c. 复合拆分("ceo/cto"、"a, b"、"a and b"、"a、b")
|
||||
tokens = [t.strip().strip("-") for t in re.split(r"[/,&|、]|\band\b", nrole)]
|
||||
tokens = [t for t in tokens if t]
|
||||
if len(tokens) > 1:
|
||||
for tok in tokens:
|
||||
if tok in norm_to_member:
|
||||
m = norm_to_member[tok]
|
||||
return m.agent, m.role, False
|
||||
alias = _ROLE_ALIASES.get(tok)
|
||||
if alias and _norm_role(alias) in norm_to_member:
|
||||
m = norm_to_member[_norm_role(alias)]
|
||||
return m.agent, m.role, False
|
||||
|
||||
# d. alias 表
|
||||
alias = _ROLE_ALIASES.get(nrole)
|
||||
if alias and _norm_role(alias) in norm_to_member:
|
||||
m = norm_to_member[_norm_role(alias)]
|
||||
return m.agent, m.role, False
|
||||
|
||||
# e. 模糊
|
||||
close = difflib.get_close_matches(nrole, list(norm_to_member.keys()), n=1, cutoff=0.6)
|
||||
if close:
|
||||
m = norm_to_member[close[0]]
|
||||
return m.agent, m.role, False
|
||||
|
||||
# f. 兜底到 Leader(无则第一个成员)
|
||||
lead = next((m for m in sorted(members, key=lambda x: x.position) if m.is_lead), None)
|
||||
m = lead or sorted(members, key=lambda x: x.position)[0]
|
||||
return m.agent, m.role, True
|
||||
|
||||
def _resolve_planner_role(self, members: List[TeamMember], workflow: str) -> str:
|
||||
"""根据团队 workflow 类型解析规划者角色。
|
||||
|
||||
@@ -491,14 +641,10 @@ class TeamOrchestrator:
|
||||
self_review = phase.get("self_review", False)
|
||||
|
||||
# DB 查询在协程内完成(members 已预加载,此处仅内存迭代)
|
||||
agent = None
|
||||
for m in members:
|
||||
if m.role == role:
|
||||
agent = m.agent
|
||||
break
|
||||
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
|
||||
|
||||
if not agent:
|
||||
logger.warning("团队编排 [%s]: 阶段 %s 缺少角色 '%s',跳过",
|
||||
logger.warning("团队编排 [%s]: 阶段 %s 无可用成员(角色 '%s'),跳过",
|
||||
self.team_id, phase_num, role)
|
||||
return {
|
||||
"phase": phase_num, "name": phase_name, "role": role,
|
||||
@@ -508,11 +654,16 @@ class TeamOrchestrator:
|
||||
"files": [],
|
||||
}
|
||||
|
||||
if role_fallback or matched_role != role:
|
||||
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
|
||||
self.team_id, phase_num, role, matched_role,
|
||||
"(兜底 Leader)" if role_fallback else "")
|
||||
|
||||
logger.info("团队编排 [%s]: 执行阶段 %s — %s (角色: %s, Agent: %s)",
|
||||
self.team_id, phase_num, phase_name, role, agent.name)
|
||||
self.team_id, phase_num, phase_name, matched_role, agent.name)
|
||||
|
||||
try:
|
||||
agent_config = _build_agent_config(agent, self.auto_approve_files)
|
||||
agent_config = self._acfg(agent)
|
||||
runtime = AgentRuntime(agent_config)
|
||||
|
||||
phase_input = self._build_phase_input(
|
||||
@@ -555,6 +706,7 @@ class TeamOrchestrator:
|
||||
|
||||
return {
|
||||
"phase": phase_num, "name": phase_name, "role": role,
|
||||
"actual_role": matched_role, "role_fallback": role_fallback,
|
||||
"agent_name": agent.name,
|
||||
"output": output_text,
|
||||
"success": phase_result.success,
|
||||
@@ -597,15 +749,13 @@ class TeamOrchestrator:
|
||||
role = phase.get("role", "developer")
|
||||
phase_name = phase.get("name", f"阶段 {phase_num}")
|
||||
|
||||
agent = None
|
||||
for m in members:
|
||||
if m.role == role:
|
||||
agent = m.agent
|
||||
break
|
||||
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
|
||||
|
||||
base_event = {
|
||||
"phase": phase_num,
|
||||
"role": role,
|
||||
"actual_role": matched_role,
|
||||
"role_fallback": role_fallback,
|
||||
"name": phase_name,
|
||||
"agent": agent.name if agent else None,
|
||||
}
|
||||
@@ -627,8 +777,13 @@ class TeamOrchestrator:
|
||||
"files": [],
|
||||
}
|
||||
|
||||
if role_fallback or matched_role != role:
|
||||
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
|
||||
self.team_id, phase_num, role, matched_role,
|
||||
"(兜底 Leader)" if role_fallback else "")
|
||||
|
||||
try:
|
||||
agent_config = _build_agent_config(agent, self.auto_approve_files)
|
||||
agent_config = self._acfg(agent)
|
||||
runtime = AgentRuntime(agent_config)
|
||||
|
||||
phase_input = self._build_phase_input(
|
||||
@@ -838,7 +993,7 @@ class TeamOrchestrator:
|
||||
members = self._load_members()
|
||||
if not members:
|
||||
raise ValueError("团队没有成员,请先为角色分配 Agent")
|
||||
|
||||
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
|
||||
# ─── 创建项目目录 ───
|
||||
workspace_root = _resolve_workspace_root()
|
||||
safe_name = _safe_project_name(project_description)
|
||||
@@ -846,6 +1001,7 @@ class TeamOrchestrator:
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("团队编排 [%s]: 项目目录 %s", self.team_id, project_path)
|
||||
|
||||
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
|
||||
prev_files = set(_scan_directory_files(project_path))
|
||||
all_files: List[str] = []
|
||||
|
||||
@@ -877,7 +1033,7 @@ class TeamOrchestrator:
|
||||
logger.info("团队编排 [%s]: Architect 开始扫描 %d 个目标路径",
|
||||
self.team_id, len(target_paths))
|
||||
try:
|
||||
arch_config = _build_agent_config(architect_agent, self.auto_approve_files)
|
||||
arch_config = self._acfg(architect_agent)
|
||||
arch_runtime = AgentRuntime(arch_config)
|
||||
# ─── 程序化预扫描源码 ───
|
||||
pre_scan_result = scan_multiple_dirs(target_paths)
|
||||
@@ -932,27 +1088,29 @@ class TeamOrchestrator:
|
||||
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
|
||||
if enhanced_description != project_description and "architect" in available_roles:
|
||||
available_roles.remove("architect")
|
||||
roles_hint = "/".join(available_roles)
|
||||
roles_hint = ", ".join(available_roles)
|
||||
first_role = available_roles[0] if available_roles else "developer"
|
||||
|
||||
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_config = self._acfg(planner_agent)
|
||||
planner_runtime = AgentRuntime(planner_config)
|
||||
planner_input = (
|
||||
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
|
||||
f"需求描述:{enhanced_description}\n"
|
||||
f"输出文件目录:{project_path}\n\n"
|
||||
f"你的团队有以下角色:{roles_hint}\n"
|
||||
f"每个阶段必须分配上述角色之一。\n\n"
|
||||
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
|
||||
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
|
||||
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
|
||||
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
|
||||
f'{{"project_name":"...","analysis":"...","phases":[\n'
|
||||
f' {{"phase":1,"name":"...","role":"{roles_hint.split("/")[0] if "/" in roles_hint else roles_hint}","description":"...","expected_output":"...","depends_on":[]}},\n'
|
||||
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
|
||||
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
|
||||
f'],"acceptance_criteria":["..."]}}\n\n'
|
||||
f"关键要求:\n"
|
||||
f"1. phases 数组是强制性的——没有它计划无法执行\n"
|
||||
f"2. 每个 phase 的 role 必须是上述角色之一\n"
|
||||
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
|
||||
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
|
||||
f"4. 4-7 个阶段最佳\n"
|
||||
f"5. 用中文输出\n"
|
||||
@@ -1090,7 +1248,7 @@ class TeamOrchestrator:
|
||||
|
||||
async def _run_qa_review(round_label: str = "") -> tuple:
|
||||
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output)"""
|
||||
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
|
||||
qa_config = self._acfg(qa_agent)
|
||||
qa_runtime = AgentRuntime(qa_config)
|
||||
deliverables = "\n\n---\n\n".join(all_outputs)
|
||||
prefix = f"({round_label})" if round_label else ""
|
||||
@@ -1238,6 +1396,9 @@ class TeamOrchestrator:
|
||||
final_scan = set(_scan_directory_files(project_path))
|
||||
for fp in all_files:
|
||||
final_scan.add(fp)
|
||||
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
|
||||
for fp in _scan_recent_files(project_path.parent, run_start_ts):
|
||||
final_scan.add(fp)
|
||||
results["files"] = sorted(final_scan)
|
||||
results["completed_at"] = datetime.now().isoformat()
|
||||
return results
|
||||
@@ -1256,6 +1417,7 @@ class TeamOrchestrator:
|
||||
try:
|
||||
team = self._load_team()
|
||||
members = self._load_members()
|
||||
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
|
||||
except Exception as e:
|
||||
yield {"type": "error", "content": str(e)}
|
||||
return
|
||||
@@ -1269,6 +1431,7 @@ class TeamOrchestrator:
|
||||
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)
|
||||
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
|
||||
prev_files = set(_scan_directory_files(project_path))
|
||||
all_files: List[str] = []
|
||||
|
||||
@@ -1296,7 +1459,7 @@ class TeamOrchestrator:
|
||||
yield {"type": "architect_start", "agent": architect_agent.name,
|
||||
"paths": [str(p) for p in target_paths]}
|
||||
try:
|
||||
arch_config = _build_agent_config(architect_agent, self.auto_approve_files)
|
||||
arch_config = self._acfg(architect_agent)
|
||||
arch_runtime = AgentRuntime(arch_config)
|
||||
# ─── 程序化预扫描源码 ───
|
||||
pre_scan_result = scan_multiple_dirs(target_paths)
|
||||
@@ -1353,24 +1516,26 @@ class TeamOrchestrator:
|
||||
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
|
||||
if enhanced_description != project_description and "architect" in available_roles:
|
||||
available_roles.remove("architect")
|
||||
roles_hint = "/".join(available_roles)
|
||||
roles_hint = ", ".join(available_roles)
|
||||
first_role = available_roles[0] if available_roles else "developer"
|
||||
|
||||
planner_config = _build_agent_config(planner_agent, self.auto_approve_files)
|
||||
planner_config = self._acfg(planner_agent)
|
||||
planner_runtime = AgentRuntime(planner_config)
|
||||
planner_input = (
|
||||
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
|
||||
f"需求描述:{enhanced_description}\n"
|
||||
f"输出文件目录:{project_path}\n\n"
|
||||
f"你的团队有以下角色:{roles_hint}\n"
|
||||
f"每个阶段必须分配上述角色之一。\n\n"
|
||||
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
|
||||
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
|
||||
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
|
||||
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
|
||||
f'{{"project_name":"...","analysis":"...","phases":[\n'
|
||||
f' {{"phase":1,"name":"...","role":"{roles_hint.split("/")[0] if "/" in roles_hint else roles_hint}","description":"...","expected_output":"...","depends_on":[]}},\n'
|
||||
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
|
||||
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
|
||||
f'],"acceptance_criteria":["..."]}}\n\n'
|
||||
f"关键要求:\n"
|
||||
f"1. phases 数组是强制性的——没有它计划无法执行\n"
|
||||
f"2. 每个 phase 的 role 必须是上述角色之一\n"
|
||||
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
|
||||
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
|
||||
f"4. 4-7 个阶段最佳\n"
|
||||
f"5. 用中文输出\n"
|
||||
@@ -1552,7 +1717,7 @@ class TeamOrchestrator:
|
||||
role = phase.get("role", "developer")
|
||||
phase_name = phase.get("name", f"阶段 {phase_num}")
|
||||
|
||||
agent = self._get_agent_by_role(members, role)
|
||||
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
|
||||
if not agent:
|
||||
yield {"type": "phase_done", "phase": phase_num, "name": phase_name,
|
||||
"role": role, "success": False, "error": f"missing_role:{role}"}
|
||||
@@ -1564,6 +1729,8 @@ class TeamOrchestrator:
|
||||
"phase": phase_num,
|
||||
"name": phase_name,
|
||||
"role": role,
|
||||
"actual_role": matched_role,
|
||||
"role_fallback": role_fallback,
|
||||
"agent": agent.name,
|
||||
}
|
||||
|
||||
@@ -1615,7 +1782,7 @@ class TeamOrchestrator:
|
||||
|
||||
async def _run_qa_review_stream(round_label: str = "") -> tuple:
|
||||
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output, success)"""
|
||||
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
|
||||
qa_config = self._acfg(qa_agent)
|
||||
qa_runtime = AgentRuntime(qa_config)
|
||||
deliverables = "\n\n---\n\n".join(all_outputs)
|
||||
prefix = f"({round_label})" if round_label else ""
|
||||
@@ -1792,6 +1959,9 @@ class TeamOrchestrator:
|
||||
final_scan = set(_scan_directory_files(project_path))
|
||||
for fp in all_files:
|
||||
final_scan.add(fp)
|
||||
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
|
||||
for fp in _scan_recent_files(project_path.parent, run_start_ts):
|
||||
final_scan.add(fp)
|
||||
yield {
|
||||
"type": "final",
|
||||
"deliverable": final,
|
||||
|
||||
Reference in New Issue
Block a user