diff --git a/backend/app/agent_runtime/core.py b/backend/app/agent_runtime/core.py index 7878d89..5950430 100644 --- a/backend/app/agent_runtime/core.py +++ b/backend/app/agent_runtime/core.py @@ -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 "已达最大迭代次数,但模型未返回最终回答。", diff --git a/backend/app/api/companies.py b/backend/app/api/companies.py index 78ec115..6fedd35 100644 --- a/backend/app/api/companies.py +++ b/backend/app/api/companies.py @@ -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} diff --git a/backend/app/api/company_ws.py b/backend/app/api/company_ws.py new file mode 100644 index 0000000..04c0c54 --- /dev/null +++ b/backend/app/api/company_ws.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 900a431..13b50ec 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 = "" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0c645bb..6064440 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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) diff --git a/backend/app/core/metrics.py b/backend/app/core/metrics.py index dc69775..b4adacb 100644 --- a/backend/app/core/metrics.py +++ b/backend/app/core/metrics.py @@ -120,7 +120,7 @@ process_memory_bytes = Gauge( ) process_open_fds = Gauge( - "process_open_fds", "进程打开的文件描述符数" + "app_process_open_fds", "进程打开的文件描述符数" ) db_connections_active = Gauge( diff --git a/backend/app/main.py b/backend/app/main.py index c646ebf..1b98f32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b1eb348..c4fa346 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"] \ No newline at end of file +__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"] \ No newline at end of file diff --git a/backend/app/models/company_project_event.py b/backend/app/models/company_project_event.py new file mode 100644 index 0000000..842b474 --- /dev/null +++ b/backend/app/models/company_project_event.py @@ -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"" + + 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, + } diff --git a/backend/app/services/company_orchestrator.py b/backend/app/services/company_orchestrator.py index f4be796..33528e2 100644 --- a/backend/app/services/company_orchestrator.py +++ b/backend/app/services/company_orchestrator.py @@ -13,14 +13,17 @@ import asyncio import json import logging import re +import difflib from datetime import datetime from pathlib import Path from typing import Any, AsyncGenerator, Dict, List, Optional from sqlalchemy.orm import Session +from app.core.database import SessionLocal from app.models.agent import Agent from app.models.company import Company, CompanyProject +from app.models.company_knowledge import CompanyKnowledge from app.models.team import Team, TeamMember from app.agent_runtime.core import AgentRuntime from app.agent_runtime.schemas import ( @@ -128,6 +131,85 @@ def _parse_ceo_json(raw_output: str) -> Dict[str, Any]: return {} +# ─── 四档-②:历史知识相关性筛选(学习闭环)─── + +# 领域信号词——用于粗粒度中文主题匹配(与 knowledge_extractor._extract_tags 同源) +_MEMORY_DOMAIN_KEYWORDS = [ + "市场", "产品", "技术", "营销", "销售", "运营", "风险", "增长", "用户", "创新", + "架构", "前端", "后端", "数据", "设计", "测试", "安全", "成本", "内容", "策略", +] + +# category → 中文标签 + 权重(review/insight 是"教训",规划最该参考,权重更高) +_MEMORY_CATEGORY_META = { + "review": ("审查教训", 2.0), + "insight": ("风险与指标", 2.0), + "ceo_plan": ("战略分析", 1.0), + "dept_output": ("部门产出", 0.5), + "general": ("经验", 0.5), +} + + +def _select_relevant_knowledge( + entries: List[Dict[str, Any]], + project_desc: str, + max_entries: int, + max_chars: int, +) -> str: + """从本公司历史知识里挑与新项目相关的,格式化成注入 CEO prompt 的「历史项目经验」块。 + + 纯函数(不碰 DB),便于隔离测试。相关性 = 领域词在知识正文命中 + tag 命中新项目描述; + 再叠加 category 权重(review/insight 优先)。**topical<=0 的条目不注入**(只喂真相关的,避免噪声)。 + 无相关条目 → 返回 ""(回归旧行为,不改 CEO prompt)。 + """ + if not entries or max_entries <= 0 or max_chars <= 0: + return "" + desc = project_desc or "" + proj_terms = [kw for kw in _MEMORY_DOMAIN_KEYWORDS if kw in desc] + + scored = [] + for e in entries: + title = (e.get("title") or "").strip() + content = (e.get("content") or "").strip() + category = e.get("category") or "general" + tags = e.get("tags") or [] + hay = f"{title}\n{content}" + # 主题命中:领域词出现在知识正文 + 该知识 tag 出现在新项目描述 + keyword_hits = sum(1 for t in proj_terms if t in hay) + tag_hits = sum(1 for tg in tags if tg and str(tg) in desc) + topical = keyword_hits * 1.0 + tag_hits * 2.0 + if topical <= 0: + continue + _, cat_w = _MEMORY_CATEGORY_META.get(category, ("经验", 0.5)) + final = topical + cat_w + scored.append((final, e.get("_recency", 0), e)) + + if not scored: + return "" + scored.sort(key=lambda x: (x[0], x[1]), reverse=True) + + sections: List[str] = [] + used = 0 + for _, _rec, e in scored[:max_entries]: + cat_cn, _ = _MEMORY_CATEGORY_META.get(e.get("category") or "general", ("经验", 0.5)) + title = (e.get("title") or "").strip() + content = (e.get("content") or "").strip() + block = f"【{cat_cn}】{title}\n{content}" + remaining = max_chars - used + if remaining <= 0: + break + if len(block) > remaining: + block = block[:remaining] + sections.append(block) + used += len(block) + + if not sections: + return "" + return ( + "## 历史项目经验(本公司过往项目沉淀,供规划参考:复用被验证有效的做法、规避已知风险与低分项)\n" + + "\n\n---\n\n".join(sections) + ) + + class CompanyOrchestrator: """虚拟公司编排器 — 多部门并行协作执行。""" @@ -142,6 +224,18 @@ class CompanyOrchestrator: self.company_id = company_id self.user_id = user_id self.auto_approve_files = auto_approve_files + self._dept_sem: Optional[asyncio.Semaphore] = None # 三档-b:每波部门并发信号量(lazy) + + def _get_dept_sem(self) -> asyncio.Semaphore: + """取(缺则建)本 run 的部门并发信号量:限制每一波并行运行的部门数。 + + 每个编排实例独立,避免不同项目相互耦合;也覆盖 rework_single_department 复用 + _run_department_stream 的路径。lazy 构造绑定到运行时事件循环。 + """ + if self._dept_sem is None: + from app.core.config import settings + self._dept_sem = asyncio.Semaphore(max(1, int(settings.MAX_CONCURRENT_DEPARTMENTS))) + return self._dept_sem def _load_company(self) -> Company: company = self.db.query(Company).filter(Company.id == self.company_id).first() @@ -157,6 +251,70 @@ class CompanyOrchestrator: .all() ) + def _auto_extract_knowledge(self, project) -> None: + """四档-②:项目终态时自动把本次项目沉淀为知识(闭合学习闭环)。 + + 受 settings 门控;按 project_id 去重(避免与手动 generate 或返工重复落库); + 任何异常一律吞掉(学习沉淀失败绝不影响项目收尾)。 + """ + from app.core.config import settings + if not settings.COMPANY_AUTO_EXTRACT_KNOWLEDGE: + return + try: + exists = ( + self.db.query(CompanyKnowledge) + .filter(CompanyKnowledge.project_id == project.id) + .first() + ) + if exists: + return # 已沉淀过,跳过 + from app.services.company_knowledge_extractor import extract_from_project + entries = extract_from_project(project, self.db) + self.db.commit() + logger.info("项目终态自动沉淀知识: project=%s entries=%d", project.id, len(entries)) + except Exception as e: + try: + self.db.rollback() + except Exception: + pass + logger.warning("自动沉淀知识失败(忽略): project=%s err=%s", getattr(project, "id", "?"), e) + + def _load_ceo_memory(self, project_desc: str) -> str: + """四档-②:检索本公司历史知识,返回注入 CEO prompt 的「历史项目经验」块(或 "")。 + + 受 settings 门控与上限约束;DB/检索异常一律降级为空串(学习闭环失败不阻断规划)。 + """ + from app.core.config import settings + if not settings.COMPANY_CEO_MEMORY_ENABLED: + return "" + try: + rows = ( + self.db.query(CompanyKnowledge) + .filter(CompanyKnowledge.company_id == self.company_id) + .order_by(CompanyKnowledge.created_at.desc()) + .limit(200) + .all() + ) + except Exception as e: + logger.warning("加载 CEO 历史知识失败(降级为空): company=%s err=%s", self.company_id, e) + return "" + entries = [ + { + "title": r.title, + "content": r.content, + "category": r.category, + "tags": r.tags or [], + "_recency": r.created_at.timestamp() if r.created_at else 0, + } + for r in rows + ] + return _select_relevant_knowledge( + entries, + project_desc, + int(settings.COMPANY_CEO_MEMORY_MAX_ENTRIES), + int(settings.COMPANY_CEO_MEMORY_MAX_CHARS), + ) + async def _run_ceo_planning(self, company: Company, departments: List[Team], project_desc: str) -> Dict[str, Any]: """Phase 1: CEO 战略规划""" # Build CEO agent config @@ -194,6 +352,12 @@ class CompanyOrchestrator: # Build CEO prompt ceo_prompt = CEO_SYSTEM_PROMPT.replace("{departments_desc}", dept_desc_str) + # 四档-②:学习闭环——把本公司历史项目知识按相关性注入 CEO prompt + memory_block = self._load_ceo_memory(project_desc) + if memory_block: + ceo_prompt = f"{ceo_prompt}\n\n{memory_block}" + logger.info("CEO 规划注入历史知识: company=%s chars=%d", self.company_id, len(memory_block)) + # Create runtime config wf = ceo_agent.workflow_config or {} nodes = wf.get("nodes", []) @@ -227,6 +391,52 @@ class CompanyOrchestrator: logger.info("CEO 规划完成: company=%s departments=%d", self.company_id, len(plan.get("departments", []))) return plan + # 四档-①:部门间交接产物——把已完成上游依赖部门的交付物注入下游部门的 context + _HANDOFF_PER_DEPT_CHARS = 2000 # 每个上游交付物注入上限 + _HANDOFF_TOTAL_CHARS = 6000 # 全部上游交付物合计上限(防 prompt 膨胀) + + def _build_dept_context( + self, + dept_plan: Dict[str, Any], + ceo_analysis: str, + completed_deliverables: Dict[str, str], + ) -> str: + """构造某部门的执行 context = CEO 总体分析 + 其依赖的上游部门交付物摘要。 + + 只按该部门声明的 `dependencies` 精准注入(无依赖 → 仅 CEO 分析,回归旧行为)。 + 逐项与合计各有字数上限,避免把下游 prompt 撑爆。 + """ + base = ceo_analysis or "" + deps = dept_plan.get("dependencies", []) + if isinstance(deps, str): + deps = [deps] if deps else [] + if not deps: + return base + + sections: List[str] = [] + used = 0 + for dep_name in deps: + deliverable = (completed_deliverables.get(dep_name) or "").strip() + if not deliverable: + continue + snippet = deliverable[: self._HANDOFF_PER_DEPT_CHARS] + if used + len(snippet) > self._HANDOFF_TOTAL_CHARS: + snippet = snippet[: max(0, self._HANDOFF_TOTAL_CHARS - used)] + if not snippet: + break + sections.append(f"— 上游部门「{dep_name}」的交付物:\n{snippet}") + used += len(snippet) + if used >= self._HANDOFF_TOTAL_CHARS: + break + + if not sections: + return base + handoff = ( + "【上游部门已完成的交付物——请在此基础上衔接,勿重复其工作】\n" + + "\n\n".join(sections) + ) + return f"{base}\n\n{handoff}" if base else handoff + async def _run_department( self, department: Team, @@ -243,9 +453,12 @@ class CompanyOrchestrator: if context: dept_task = f"Company context: {context}\n\nDepartment task: {dept_task}" + # per-dept 独立 session:execute() 用 asyncio.gather 并行多部门, + # 共享 self.db 会产生 SQLAlchemy 事务态竞态;各部门自建、用完即关。 + dept_db = SessionLocal() try: orchestrator = TeamOrchestrator( - db=self.db, + db=dept_db, team_id=department.id, user_id=self.user_id, auto_approve_files=self.auto_approve_files, @@ -271,6 +484,11 @@ class CompanyOrchestrator: "plan": dept_plan, "result": None, } + finally: + try: + dept_db.close() + except Exception: + pass async def _run_department_stream( self, @@ -282,12 +500,17 @@ class CompanyOrchestrator: ): """Phase 2 (stream): 运行单个部门并实时推送 Agent 事件到队列。""" from app.services.team_orchestrator import TeamOrchestrator + from app.services.cost_estimator import estimate_cost_yuan goal = dept_plan.get("goal", context) deliverables = dept_plan.get("deliverables", []) dept_task = f"{goal}\n\nExpected deliverables:\n" + "\n".join(f"- {d}" for d in deliverables) if context: dept_task = f"Company context: {context}\n\nDepartment task: {dept_task}" + # M2-b 返工注入:本轮为返工时,把 CEO 评审反馈 + 人工意见拼进任务,驱动针对性改进 + rework_fb = dept_plan.get("rework_feedback", "") + if rework_fb: + dept_task += ("\n\n【本轮为返工——请针对以下反馈做针对性改进,不要照抄上一版】\n" + rework_fb) dept_result = { "department_id": department.id, @@ -303,10 +526,19 @@ class CompanyOrchestrator: current_phase = 0 total_phases = 0 current_phase_name = "" + dept_real_tokens = 0 # 真实 token 累加(来自 Agent final.token_usage) + dept_cost_yuan = 0.0 # 真实成本累加(¥),按每个 agent 各自 model 分别计价 + sem = self._get_dept_sem() # 三档-b:每波部门并发上限 + acquired = False + # per-dept 独立 session:同波并行部门若共享 db_orch,SQLAlchemy Session + # 在交错 await 下会交叉污染事务态(竞态)。各部门自建、用完即关。 + dept_db = SessionLocal() try: + await sem.acquire() + acquired = True orchestrator = TeamOrchestrator( - db=self.db, + db=dept_db, team_id=department.id, user_id=self.user_id, auto_approve_files=self.auto_approve_files, @@ -320,6 +552,20 @@ class CompanyOrchestrator: inner_type = inner.get("type", "") if inner_type == "think": last_thought = inner.get("content", "")[:2000] + elif inner_type == "final": + tu = inner.get("token_usage") or {} + try: + dept_real_tokens += int(tu.get("total_tokens") or 0) + except (TypeError, ValueError): + pass + try: + dept_cost_yuan += estimate_cost_yuan( + tu.get("model") or "", + int(tu.get("prompt_tokens") or 0), + int(tu.get("completion_tokens") or 0), + ) + except (TypeError, ValueError): + pass elif t_type == "phase_start": current_phase = team_event.get("phase", 0) total_phases = team_event.get("total_phases", total_phases or 1) @@ -358,6 +604,23 @@ class CompanyOrchestrator: dept_result["success"] = True logger.info("Department (stream) %s completed: success=%s", department.name, dept_result["success"]) + except asyncio.CancelledError: + # M2-c 单部门取消:吞掉 CancelledError(协作式),仍发 dept_done 让波次推进、 + # 下游依赖照常解锁;标 cancelled 供 UI 显示「已取消」而非 error。 + logger.info("Department (stream) %s cancelled", department.name) + dept_result["success"] = False + dept_result["error"] = "cancelled" + dept_result["cancelled"] = True + event_queue.put_nowait({ + "type": "dept_agent_event", + "department_name": department.name, + "sub_type": "error", + "data": {"content": "该部门已被取消"}, + "last_thought": last_thought, + "phase_progress": f"{current_phase}/{total_phases}" if total_phases > 0 else "", + "phase_name": current_phase_name, + "_ts": __import__("time").time(), + }) except Exception as e: logger.error("Department (stream) %s failed: %s", department.name, e) dept_result["success"] = False @@ -372,6 +635,13 @@ class CompanyOrchestrator: "phase_name": current_phase_name, "_ts": __import__("time").time(), }) + finally: + try: + dept_db.close() + except Exception: + pass + if acquired: + sem.release() # 释放部门名额(在 dept_done 之前),供本波下一个部门启动 # Signal completion await event_queue.put({ @@ -380,9 +650,126 @@ class CompanyOrchestrator: "success": dept_result["success"], "result": dept_result.get("result"), "error": dept_result.get("error"), + "cancelled": dept_result.get("cancelled", False), + "real_tokens": dept_real_tokens, + "cost_yuan": round(dept_cost_yuan, 4), "_ts": __import__("time").time(), }) + async def rework_single_department(self, project_id: str, dept_name: str): + """M2-c:跑完后重跑单个部门,事件续写进同一流,替换其交付物与打分。 + + 不走 execute_stream 全流程;只驱动单个 _run_department_stream + 单部门重打分, + 再把该部门的 review_scores 条目替换、状态翻回 completed/failed。 + """ + import time as _time + project = self.db.query(CompanyProject).filter(CompanyProject.id == project_id).first() + if project is None: + yield {"type": "error", "message": "项目不存在", "_ts": _time.time()} + return + ceo_plan = project.ceo_plan or {} + dept_plans = ceo_plan.get("departments", []) + if not dept_plans: + yield {"type": "error", "message": "项目无 CEO 计划,无法返工", "_ts": _time.time()} + return + + company = self._load_company() + departments = self._load_departments() + + # 匹配 (Team, plan):精确名/型 + target_plan = next( + (dp for dp in dept_plans + if dp.get("department_name") == dept_name or dp.get("department_type") == dept_name), + None, + ) + target_dept = next( + (d for d in departments if d.name == dept_name or d.department_type == dept_name), + None, + ) + if target_plan is None or target_dept is None: + yield {"type": "error", "message": f"部门 {dept_name} 不在计划或已删除", "_ts": _time.time()} + return + + from app.services import company_runner # 惰性导入,复用取消能力 + control = company_runner.get_control(project_id) + + context = ceo_plan.get("analysis", "") + project_path = Path(f"./company_projects/{self.company_id}/{_safe_name(project.description or '')}") + project_path.mkdir(parents=True, exist_ok=True) + + # 运行单个部门(复用 _run_department_stream) + q: asyncio.Queue = asyncio.Queue() + t_start = _time.time() + task = asyncio.create_task( + self._run_department_stream(target_dept, target_plan, context, project_path, q) + ) + control.dept_tasks[target_dept.name] = task + dept_result: Optional[Dict[str, Any]] = None + while True: + evt = await q.get() + yield evt + if evt.get("type") == "dept_done": + dept_result = evt + break + control.dept_tasks.pop(target_dept.name, None) + dur = int((_time.time() - t_start) * 1000) + + # 组装该部门结果供重打分 + one_result = { + "department_name": (dept_result or {}).get("department_name", dept_name), + "department_type": target_dept.department_type or "", + "success": bool((dept_result or {}).get("success")), + "plan": target_plan, + "result": (dept_result or {}).get("result"), + "error": (dept_result or {}).get("error"), + "duration_ms": dur, + "tokens_used": int((dept_result or {}).get("real_tokens") or 0), + "cost_yuan": float((dept_result or {}).get("cost_yuan") or 0.0), + } + + # 单部门重打分,替换 review_scores 中该条目(知识/洞察读它) + try: + review = await self._ceo_review_with_scoring(company, ceo_plan, [one_result], project.description or "") + new_scores = review.get("departments", []) + existing = list(project.review_scores or []) + for ns in new_scores: + nm = ns.get("name") + replaced = False + for i, es in enumerate(existing): + if es.get("name") == nm: + existing[i] = ns + replaced = True + break + if not replaced: + existing.append(ns) + project.review_scores = existing + self.db.commit() + yield { + "type": "ceo_score_done", + "scores": new_scores, + "overall_score": review.get("overall_score", 0), + "summary": review.get("summary", ""), + "next_steps": review.get("next_steps", ""), + "round_index": 0, + "_ts": _time.time(), + } + except Exception as e: + yield {"type": "error", "message": f"返工打分失败: {e}", "phase": "rework_score", "_ts": _time.time()} + + # 收尾:状态翻回 completed/failed + success = bool(dept_result.get("success")) if dept_result else False + project.status = "completed" if success else "failed" + project.completed_at = datetime.now() + self.db.commit() + yield { + "type": "rework_done", + "department_name": dept_name, + "success": success, + "duration_ms": dur, + "cost_yuan": float(dept_result.get("cost_yuan") or 0.0) if dept_result else 0.0, + "_ts": _time.time(), + } + async def preview_plan(self, project_description: str) -> Dict[str, Any]: """仅运行 CEO 规划并返回计划,不派发部门、不落库(无状态预览)。 @@ -455,13 +842,32 @@ class CompanyOrchestrator: dept_map[d.name] = d if d.department_type: dept_map[d.department_type] = d + norm_dept_map: Dict[str, Team] = {} + for k, v in dept_map.items(): + nk = (k or "").strip().lower() + if nk: + norm_dept_map.setdefault(nk, v) - # Match plans to departments + # Match plans to departments (精确 → 归一化 → 模糊) matched_tasks = [] for dp in dept_plans: dept_name = dp.get("department_name", "") dept_type = dp.get("department_type", "") target_dept = dept_map.get(dept_name) or dept_map.get(dept_type) + if not target_dept: + for key in (dept_name, dept_type): + nk = (key or "").strip().lower() + if nk and nk in norm_dept_map: + target_dept = norm_dept_map[nk] + break + if not target_dept: + cand = difflib.get_close_matches( + (dept_name or dept_type or "").strip().lower(), + list(norm_dept_map.keys()), n=1, cutoff=0.6, + ) + if cand: + target_dept = norm_dept_map[cand[0]] + logger.warning("Department fuzzy-matched: plan=%s → %s", dept_name or dept_type, target_dept.name) if target_dept: matched_tasks.append((target_dept, dp)) else: @@ -470,6 +876,7 @@ class CompanyOrchestrator: # Sort by dependency: no-deps first, then dependent dept_results: List[Dict] = [] completed_names: set = set() + completed_deliverables: Dict[str, str] = {} # 四档-①:name→交付物,供下游交接 # Execute in waves based on dependencies while matched_tasks: @@ -494,15 +901,25 @@ class CompanyOrchestrator: project_path = Path(f"./company_projects/{self.company_id}/{_safe_name(project_description)}") project_path.mkdir(parents=True, exist_ok=True) - context = ceo_plan.get("analysis", "") + ceo_analysis = ceo_plan.get("analysis", "") tasks = [ - self._run_department(dept, plan, context, project_path) + self._run_department( + dept, plan, + self._build_dept_context(plan, ceo_analysis, completed_deliverables), + project_path, + ) for dept, plan in ready ] batch_results = await asyncio.gather(*tasks) dept_results.extend(batch_results) for r in batch_results: completed_names.add(r["department_name"]) + # 四档-①:记录交付物供下一波下游交接(非流式 result 形状为 TeamOrchestrator.execute 返回) + _res = r.get("result") + if isinstance(_res, dict): + _dlv = _res.get("deliverable") or _res.get("final_output") or "" + if _dlv: + completed_deliverables[r["department_name"]] = _dlv matched_tasks = remaining @@ -530,12 +947,30 @@ class CompanyOrchestrator: "success": all_success, } + async def _halt(self, project, event_type: str, extra: Dict[str, Any]): + """M2-b 优雅自我终止:置项目 stopped + commit,再 yield 终止事件。 + + 必须 yield 事件后由调用方 `return`(勿 raise——raise 会进 runner 的 + except 分支把状态覆盖成 failed)。runner 视编排器正常结束为成功、不改状态, + 故这里先把 status 落成 stopped。 + """ + import time as _time + try: + project.status = "stopped" + project.completed_at = datetime.now() + self.db.commit() + except Exception: + self.db.rollback() + yield {"type": event_type, **extra, "_ts": _time.time()} + async def execute_stream( self, project_description: str, auto_approve_files: bool = True, max_rounds: int = 3, ceo_plan: Optional[Dict[str, Any]] = None, + project_id: Optional[str] = None, + limits: Optional[Dict[str, Any]] = None, ) -> AsyncGenerator[Dict[str, Any], None]: """流式执行公司级项目,SSE 事件实时推送。支持多轮迭代+打分+预算。 @@ -560,6 +995,12 @@ class CompanyOrchestrator: import time as _time t0 = _time.time() self.auto_approve_files = auto_approve_files + # ─── M2-b 运行限额(人在环审批 + 预算/超时熔断);默认全关,回归安全 ─── + limits = limits or {} + max_cost = limits.get("max_cost_yuan") # ¥ 上限,None=不限 + max_dur = limits.get("max_duration_min") # 分钟上限,None=不限 + approve_plan = bool(limits.get("approve_plan")) + approve_rework = bool(limits.get("approve_rework")) yield {"type": "start", "message": "Loading company...", "_ts": _time.time()} try: @@ -591,17 +1032,42 @@ class CompanyOrchestrator: yield {"type": "ceo_plan_done", "plan": ceo_plan, "_ts": _time.time(), "duration_ms": int((_time.time() - t_ceo_start) * 1000)} - # Create project - project = CompanyProject( - company_id=self.company_id, - name=project_description[:200], - description=project_description, - status="in_progress", - ceo_plan=ceo_plan, - ) + # Create project(若调用方已预建项目行则复用它,避免重复创建) + project = None + if project_id: + project = self.db.query(CompanyProject).filter(CompanyProject.id == project_id).first() + if project is None: + project = CompanyProject( + company_id=self.company_id, + name=project_description[:200], + description=project_description, + status="in_progress", + ceo_plan=ceo_plan, + ) + self.db.add(project) + else: + project.ceo_plan = ceo_plan + project.status = "in_progress" self.db.add(project) self.db.commit() + # ─── M2-a 运行时控制(进程内注册表:暂停/继续/跳过)─── + from app.services import company_runner # 惰性导入,避免与 runner 循环依赖 + control = company_runner.get_control(project_id) if project_id else company_runner.ProjectControl() + + # ─── M2-b CEO 计划审批门:开门等人裁决,批准才派部门;拒绝→优雅终止 ─── + if approve_plan: + control.open_gate("plan") + yield {"type": "awaiting_approval", "gate": "plan", "plan": ceo_plan, "_ts": _time.time()} + await control.approval_event.wait() + dec = control.approval_decision or "rejected" + control.pending_gate = "" + yield {"type": "approval_resolved", "gate": "plan", "decision": dec, "_ts": _time.time()} + if dec != "approved": + async for ev in self._halt(project, "company_terminated", {"reason": "plan_rejected"}): + yield ev + return + # ─── Budget setup ─── company_config = company.config or {} budget_config = company_config.get("budget", {}) @@ -617,14 +1083,40 @@ class CompanyOrchestrator: dept_map[d.name] = d if d.department_type: dept_map[d.department_type] = d + # 归一化键(小写去空白)+ 候选列表,供模糊匹配 + norm_dept_map: Dict[str, Team] = {} + for k, v in dept_map.items(): + nk = (k or "").strip().lower() + if nk: + norm_dept_map.setdefault(nk, v) def match_plans(plans): + """返回 (matched, unmatched)。匹配阶梯:精确 → 归一化 → 模糊。""" matched = [] + unmatched = [] for dp in plans: - target = dept_map.get(dp.get("department_name", "")) or dept_map.get(dp.get("department_type", "")) + name = dp.get("department_name", "") + dtype = dp.get("department_type", "") + target = dept_map.get(name) or dept_map.get(dtype) + if not target: + for key in (name, dtype): + nk = (key or "").strip().lower() + if nk and nk in norm_dept_map: + target = norm_dept_map[nk] + break + if not target: + cand = difflib.get_close_matches( + (name or dtype or "").strip().lower(), + list(norm_dept_map.keys()), n=1, cutoff=0.6, + ) + if cand: + target = norm_dept_map[cand[0]] + logger.warning("Department fuzzy-matched: plan=%s → %s", name or dtype, target.name) if target: matched.append((target, dp)) - return matched + else: + unmatched.append(dp) + return matched, unmatched project_path = Path(f"./company_projects/{self.company_id}/{_safe_name(project_description)}") project_path.mkdir(parents=True, exist_ok=True) @@ -635,9 +1127,30 @@ class CompanyOrchestrator: current_plans = dept_plans round_index = 0 all_pass = False + company_cost_yuan = 0.0 # 公司总成本累加(¥),跨轮次 + human_rework_feedback = "" # M2-b 返工门注入的人工反馈,供下一轮返工部门 prompt while round_index < max_rounds and not all_pass: round_index += 1 + + # M2-a 暂停闸(轮次顶):暂停时 hold 住不开新轮次 + if not control.pause_event.is_set(): + yield {"type": "paused", "round_index": round_index, "_ts": _time.time()} + await control.pause_event.wait() + yield {"type": "resumed", "round_index": round_index, "_ts": _time.time()} + + # M2-b 熔断守卫(轮次顶):超时/预算任一超阈 → 优雅终止 + if max_dur and (_time.time() - t0) >= max_dur * 60: + async for ev in self._halt(project, "timeout_tripped", + {"elapsed_min": round((_time.time() - t0) / 60, 2), "limit_min": max_dur}): + yield ev + return + if max_cost and company_cost_yuan >= max_cost: + async for ev in self._halt(project, "budget_tripped", + {"cost_yuan": round(company_cost_yuan, 4), "limit_yuan": max_cost}): + yield ev + return + rework_depts = [] if round_index > 1: # Identify departments that need rework @@ -651,9 +1164,24 @@ class CompanyOrchestrator: "message": f"Round {round_index}: {len(rework_depts)} departments need rework", "_ts": _time.time(), } - # Filter plans to only rework departments - filtered_plans = [p for p in dept_plans if p.get("department_name", "") in failed_names or p.get("department_type", "") in failed_names] + # Filter plans to only rework departments(复制 plan 字典,避免污染已存的 ceo_plan) + filtered_plans = [dict(p) for p in dept_plans if p.get("department_name", "") in failed_names or p.get("department_type", "") in failed_names] if filtered_plans: + # M2-b 返工注入:把 CEO 评审反馈 + improvement_areas + 人工意见拼进各失败部门的 plan + failed_map = {d.get("name"): d for d in previous_scores if not d.get("pass", True)} + for p in filtered_plans: + entry = failed_map.get(p.get("department_name")) or failed_map.get(p.get("department_type")) + parts = [] + if entry: + if entry.get("feedback"): + parts.append("CEO 评审反馈:" + str(entry["feedback"])) + areas = entry.get("improvement_areas") or [] + if areas: + parts.append("改进方向:" + ";".join(str(a) for a in areas)) + if human_rework_feedback: + parts.append("人工意见:" + human_rework_feedback) + if parts: + p["rework_feedback"] = "\n".join(parts) current_plans = filtered_plans else: yield { @@ -666,15 +1194,37 @@ class CompanyOrchestrator: } # Phase 2: Department Execution (wave-based + budget tracking) - matched = match_plans(current_plans) + matched, unmatched = match_plans(current_plans) + for dp in unmatched: + logger.warning("Department not found for plan: name=%s type=%s", + dp.get("department_name"), dp.get("department_type")) + yield { + "type": "dept_unmatched", + "department_name": dp.get("department_name", ""), + "department_type": dp.get("department_type", ""), + "goal": dp.get("goal", ""), + "_ts": _time.time(), + } dept_results: List[Dict] = [] completed_names: set = set() + completed_deliverables: Dict[str, str] = {} # 四档-①:name→交付物,供下游交接 dept_timings: Dict[str, float] = {} while matched: ready = [] remaining = [] for dept, plan in matched: + # M2-a 跳过闸:跳过尚未开始的部门,标记为已完成让下游依赖可推进 + if dept.name in control.skip_depts: + control.skip_depts.discard(dept.name) + completed_names.add(dept.name) + yield { + "type": "dept_skipped", + "department_name": dept.name, + "round_index": round_index, + "_ts": _time.time(), + } + continue deps = plan.get("dependencies", []) if isinstance(deps, str): deps = [deps] if deps else [] @@ -718,14 +1268,24 @@ class CompanyOrchestrator: } # Execute ready departments in parallel with stream forwarding - context = ceo_plan.get("analysis", "") + ceo_analysis = ceo_plan.get("analysis", "") dept_event_queue: asyncio.Queue = asyncio.Queue() + # 计时起点:部门被派发时(而非首个事件到达时),避免 duration≈0 + for dept, plan in ready: + dept_timings.setdefault(dept.name, __import__("time").time()) tasks = { asyncio.create_task( - self._run_department_stream(dept, plan, context, project_path, dept_event_queue) + self._run_department_stream( + dept, plan, + self._build_dept_context(plan, ceo_analysis, completed_deliverables), + project_path, dept_event_queue, + ) ): (dept, plan) for dept, plan in ready } + # M2-c 登记部门 task 句柄,供单部门硬取消(cancel_department) + for task, (dept, _plan) in tasks.items(): + control.dept_tasks[dept.name] = task done_count = 0 while done_count < len(tasks): @@ -747,7 +1307,17 @@ class CompanyOrchestrator: done_count += 1 r = evt completed_names.add(r["department_name"]) + # 四档-①:记录该部门交付物,供同轮下游部门交接 + _dlv = (r.get("result") or {}).get("deliverable", "") if isinstance(r.get("result"), dict) else "" + if _dlv: + completed_deliverables[r["department_name"]] = _dlv + control.dept_tasks.pop(r["department_name"], None) # M2-c 注销句柄 dur = int((__import__("time").time() - dept_timings.get(r["department_name"], __import__("time").time())) * 1000) + approx_used = dept_token_usage.get(r["department_name"], 0) + real_used = int(r.get("real_tokens") or 0) + used_display = real_used if real_used > 0 else approx_used + dept_cost = float(r.get("cost_yuan") or 0.0) + company_cost_yuan += dept_cost dept_results.append({ "department_name": r["department_name"], "department_type": "", @@ -756,11 +1326,13 @@ class CompanyOrchestrator: "result": r.get("result"), "error": r.get("error"), "duration_ms": dur, - "tokens_used": dept_token_usage.get(r["department_name"], 0), + "tokens_used": used_display, + "tokens_used_approx": approx_used, + "cost_yuan": round(dept_cost, 4), }) # Emit budget summary for this department dept_name = r["department_name"] - used = dept_token_usage.get(dept_name, 0) + used = approx_used limit = dept_token_total.get(dept_name, 0) if limit > 0: yield { @@ -777,9 +1349,17 @@ class CompanyOrchestrator: "success": r["success"], "result": r.get("result"), "duration_ms": dur, - "tokens_used": used, + "tokens_used": used_display, + "tokens_used_approx": approx_used, + "cost_yuan": round(dept_cost, 4), "_ts": __import__("time").time(), } + # M2-b 预算熔断(部门结束粒度,最灵敏):累计成本超阈 → 优雅终止 + if max_cost and company_cost_yuan >= max_cost: + async for ev in self._halt(project, "budget_tripped", + {"cost_yuan": round(company_cost_yuan, 4), "limit_yuan": max_cost}): + yield ev + return elif evt.get("type") == "dept_agent_event": dept_name = evt.get("department_name", "") if dept_name and dept_name not in dept_timings: @@ -787,6 +1367,19 @@ class CompanyOrchestrator: matched = remaining + # M2-a 暂停闸(波次边界):在飞部门已跑完,暂停时 hold 住不开下一波 + if matched and not control.pause_event.is_set(): + yield {"type": "paused", "round_index": round_index, "_ts": _time.time()} + await control.pause_event.wait() + yield {"type": "resumed", "round_index": round_index, "_ts": _time.time()} + + # M2-b 超时熔断(波次边界,粒度更细):总耗时超阈 → 优雅终止 + if max_dur and (_time.time() - t0) >= max_dur * 60: + async for ev in self._halt(project, "timeout_tripped", + {"elapsed_min": round((_time.time() - t0) / 60, 2), "limit_min": max_dur}): + yield ev + return + # Phase 3: CEO Review with Scoring yield { "type": "ceo_score_start", @@ -831,6 +1424,30 @@ class CompanyOrchestrator: "_ts": _time.time(), } + # 评审解析失败:不基于不可信打分盲目返工(会空转+重复失败),直接保守收尾 + if review.get("parse_failed"): + yield {"type": "review_parse_failed", "round_index": round_index, "_ts": _time.time()} + break + + # ─── M2-b 返工审批门:有失败部门且还能再迭代时,问是否返工 ─── + if approve_rework and not all_pass and round_index < max_rounds: + control.open_gate("rework") + yield { + "type": "awaiting_approval", "gate": "rework", + "failed_depts": [d["name"] for d in scores if not d.get("pass", True)], + "round_index": round_index, "_ts": _time.time(), + } + await control.approval_event.wait() + dec = control.approval_decision or "rejected" + human_rework_feedback = getattr(control, "approval_feedback", "") or "" + control.pending_gate = "" + yield {"type": "approval_resolved", "gate": "rework", "decision": dec, + "feedback": human_rework_feedback, "_ts": _time.time()} + if dec != "approved": + # 拒绝返工 = 就地收尾(按当前产出定 completed/failed),非失败 + yield {"type": "rework_skipped", "round_index": round_index, "_ts": _time.time()} + break + # ─── Finalize ─── all_success = all(d["success"] for d in all_dept_results) project.review_scores = previous_scores @@ -839,6 +1456,9 @@ class CompanyOrchestrator: project.completed_at = datetime.now() self.db.commit() + # 四档-②:终态自动沉淀知识,供后续项目的 CEO 规划回喂(闭合学习闭环) + self._auto_extract_knowledge(project) + total_duration = int((_time.time() - t0) * 1000) yield { "type": "company_done", @@ -850,6 +1470,7 @@ class CompanyOrchestrator: "round_count": round_index, "overall_score": previous_scores[0].get("overall_score", 0) if previous_scores else 0, "review_scores": previous_scores, + "total_cost_yuan": round(company_cost_yuan, 4), "_ts": _time.time(), } @@ -882,9 +1503,17 @@ class CompanyOrchestrator: result = dr.get("result", {}) deliverable = result.get("deliverable", "")[:800] if result else "" error = dr.get("error", "") + # 把真实产出文件清单喂给评审:仅凭 deliverable 文本会误判「没做出东西」 + files = (result.get("files") or []) if result else [] + file_line = "" + if files: + names = [Path(fp).name for fp in files[:25]] + more = f" 等{len(files)}个" if len(files) > 25 else "" + file_line = f"Produced files ({len(files)}): {', '.join(names)}{more}\n" dept_summaries.append( f"### {dr['department_name']}\n" f"Success: {dr['success']}\n" + f"{file_line}" f"Output: {deliverable or error or 'No output'}" ) @@ -931,45 +1560,62 @@ A department passes if score >= 6. Be honest but fair.""" break llm_data = llm_node.get("data", {}) if llm_node else {} - config = AgentConfig( - name=ceo_agent.name, - system_prompt="You are the CEO. Review and score your company's work. Output ONLY valid JSON.", - user_id=self.user_id, - memory_scope_id=f"company_ceo_review_{self.company_id}", - llm=AgentLLMConfig( - provider=llm_data.get("provider", "deepseek"), - model=llm_data.get("model", "deepseek-v4-pro"), - temperature=0.2, - max_iterations=3, - ), - tools=AgentToolConfig(include_tools=[]), - memory=AgentMemoryConfig(enabled=False, max_history_messages=5), - ) + # 直接单次 LLM 调用(非 ReAct):评审是「产出严格 JSON」的一次性任务。 + # 走 AgentRuntime 的 ReAct 循环会把 max_iterations 耗在 Thought/Action 上、 + # 从不产出最终 JSON(返回「已达最大迭代次数(3),任务被截断」或中途「Let me check...」), + # 解析失败 → 旧逻辑静默 auto-pass(score=5) 污染打分/返工判断。 + from app.agent_runtime.core import _LLMClient + + review_llm = AgentLLMConfig( + provider=llm_data.get("provider", "deepseek"), + model=llm_data.get("model", "deepseek-v4-pro"), + temperature=0.2, + max_tokens=1500, + request_timeout=90.0, + ) + client = _LLMClient(review_llm) + messages = [ + {"role": "system", "content": "You are the CEO. Review and score your company's work. Output ONLY a single valid JSON object — no markdown fences, no explanation, no tool use."}, + {"role": "user", "content": review_prompt}, + ] + + raw = "" + review: Dict[str, Any] = {} + for attempt in range(2): + try: + resp = await client.chat(messages=messages, tools=None, iteration=0) + except Exception as e: + logger.warning("CEO review LLM call failed (attempt %d): %s", attempt + 1, e) + break + raw = getattr(resp, "content", "") or (resp.get("content", "") if isinstance(resp, dict) else "") + review = _parse_ceo_json(raw) + if review: + break + # 一次纠正性重试:明确告知上一轮不是合法 JSON + messages.append({"role": "assistant", "content": (raw or "")[:2000]}) + messages.append({"role": "user", "content": "Your previous response was not a valid JSON object. Output ONLY the JSON object described above, nothing else."}) - runtime = AgentRuntime(config) - result = await runtime.run(review_prompt) - review = _parse_ceo_json(result.content) if not review: - # Fallback: try to extract scoring info from raw output - logger.warning("CEO review JSON parsing failed, raw output (first 500 chars): %.500s", result.content or "") - # Try regex extraction of overall_score - score_match = re.search(r'"overall_score"\s*:\s*(\d+)', result.content or "") - extracted_score = int(score_match.group(1)) if score_match else 5 - # Create default pass entries for each department so they don't fail + # 兜底(正常几乎不触发):解析仍失败 → 保守判「未通过」并显式标注,绝不静默 auto-pass; + # 上层 round 循环遇 parse_failed 会直接收尾,避免基于不可信打分盲目返工空转。 + logger.warning("CEO review JSON parsing failed after retries, raw (first 500 chars): %.500s", raw) + score_match = re.search(r'"overall_score"\s*:\s*(\d+)', raw) + extracted_score = int(score_match.group(1)) if score_match else 0 fallback_depts = [] for dr_item in dept_results: fallback_depts.append({ "name": dr_item.get("department_name", ""), "score": extracted_score, - "feedback": "Review parsing failed — auto-passed. See raw output.", - "pass": True, + "feedback": "CEO 评审输出无法解析为 JSON,已保守判为未通过,请人工复核。", + "pass": False, "improvement_areas": [], }) review = { "overall_score": extracted_score, - "summary": (result.content or "Review failed")[:500], + "summary": (raw or "Review failed")[:500], "departments": fallback_depts, "next_steps": "", + "parse_failed": True, } return review diff --git a/backend/app/services/company_runner.py b/backend/app/services/company_runner.py new file mode 100644 index 0000000..cd0948d --- /dev/null +++ b/backend/app/services/company_runner.py @@ -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() diff --git a/backend/app/services/team_orchestrator.py b/backend/app/services/team_orchestrator.py index 823c506..01a0b8b 100644 --- a/backend/app/services/team_orchestrator.py +++ b/backend/app/services/team_orchestrator.py @@ -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, diff --git a/backend/tests/test_company_orchestrator.py b/backend/tests/test_company_orchestrator.py new file mode 100644 index 0000000..e5bbeca --- /dev/null +++ b/backend/tests/test_company_orchestrator.py @@ -0,0 +1,404 @@ +""" +虚拟公司编排器测试 — 全程 mock LLM,不产生真实 API 调用 + +覆盖: +- _parse_ceo_json:CEO 规划输出的 JSON 提取(直接/代码块/尾逗号/垃圾输入) +- _select_relevant_knowledge:学习闭环的知识相关性筛选(纯函数) +- _build_dept_context:四档-① 部门间交接的上下文构造与字数上限 +- execute_stream:波次 DAG 执行顺序、部门间交接注入、per-dept 独立 session +- _ceo_review_with_scoring:评审 JSON 解析失败的保守兜底 +""" +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +# 注册公司相关模型到 Base.metadata(db_session fixture 建表用) +import app.models.company # noqa: F401 +import app.models.company_knowledge # noqa: F401 +import app.models.team # noqa: F401 +from app.models.agent import Agent +from app.models.company import Company +from app.models.team import Team + +from app.services import company_orchestrator as co_module +from app.services.company_orchestrator import ( + CompanyOrchestrator, + _parse_ceo_json, + _select_relevant_knowledge, +) + + +# ─── _parse_ceo_json ─── + +class TestParseCeoJson: + def test_direct_json(self): + plan = {"analysis": "a", "departments": [{"department_name": "数据组"}]} + assert _parse_ceo_json(json.dumps(plan, ensure_ascii=False)) == plan + + def test_markdown_fenced(self): + plan = {"analysis": "a", "departments": []} + raw = f"```json\n{json.dumps(plan, ensure_ascii=False)}\n```" + assert _parse_ceo_json(raw) == plan + + def test_trailing_comma_fixed(self): + raw = '{"analysis": "a", "departments": [{"department_name": "数据组",}],}' + result = _parse_ceo_json(raw) + assert result["departments"][0]["department_name"] == "数据组" + + def test_garbage_returns_empty(self): + assert _parse_ceo_json("完全不是 JSON 的输出") == {} + + def test_brace_extract_with_surrounding_text(self): + raw = '这是计划:\n{"analysis": "a", "departments": []}\n以上。' + result = _parse_ceo_json(raw) + assert result.get("analysis") == "a" + + +# ─── _select_relevant_knowledge ─── + +class TestSelectRelevantKnowledge: + def test_empty_entries(self): + assert _select_relevant_knowledge([], "市场营销", 5, 1000) == "" + + def test_zero_limits(self): + entries = [{"title": "市场", "content": "市场", "category": "review", "tags": []}] + assert _select_relevant_knowledge(entries, "市场", 0, 1000) == "" + assert _select_relevant_knowledge(entries, "市场", 5, 0) == "" + + def test_irrelevant_excluded(self): + entries = [ + {"title": "市场分析教训", "content": "上次市场项目的增长策略", "category": "review", + "tags": ["市场"], "_recency": 2}, + {"title": "无关条目", "content": "烹饪菜谱大全", "category": "general", + "tags": [], "_recency": 1}, + ] + result = _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) + assert "市场分析教训" in result + assert "无关条目" not in result + + def test_no_relevant_returns_empty(self): + entries = [{"title": "菜谱", "content": "红烧肉做法", "category": "general", "tags": []}] + assert _select_relevant_knowledge(entries, "做一个市场营销产品", 5, 2000) == "" + + def test_max_chars_truncation(self): + entries = [ + {"title": "市场教训", "content": "市场" + "长" * 500, "category": "review", "tags": []}, + ] + result = _select_relevant_knowledge(entries, "市场分析", 5, 50) + assert result != "" + # 正文块被截到 50 字以内(外加固定头部) + assert "长" * 100 not in result + + def test_review_category_weighted_higher(self): + """review/insight 类别权重高,同等主题命中下排前。""" + entries = [ + {"title": "部门产出", "content": "市场 数据", "category": "dept_output", + "tags": [], "_recency": 99}, + {"title": "审查教训", "content": "市场 数据", "category": "review", + "tags": [], "_recency": 1}, + ] + result = _select_relevant_knowledge(entries, "市场数据分析", 1, 2000) + # 只取 1 条时,权重更高的 review 入选 + assert "审查教训" in result + assert "部门产出" not in result + + +# ─── _build_dept_context ─── + +class TestBuildDeptContext: + def _orch(self): + return CompanyOrchestrator(db=None, company_id="c1", user_id="u1") + + def test_no_dependencies_returns_base(self): + orch = self._orch() + ctx = orch._build_dept_context({"dependencies": []}, "CEO 分析", {"A": "交付物"}) + assert ctx == "CEO 分析" + + def test_handoff_injected(self): + orch = self._orch() + ctx = orch._build_dept_context( + {"dependencies": ["数据组"]}, "CEO 分析", {"数据组": "上游数据内容"} + ) + assert "上游部门「数据组」" in ctx + assert "上游数据内容" in ctx + assert "CEO 分析" in ctx + + def test_per_dept_char_limit(self): + orch = self._orch() + ctx = orch._build_dept_context( + {"dependencies": ["A"]}, "", {"A": "x" * 5000} + ) + # 单部门交付物注入上限 2000 字 + assert "x" * 2000 in ctx + assert "x" * 2001 not in ctx + + def test_total_char_limit(self): + orch = self._orch() + deliverables = {n: "x" * 2000 for n in ["A", "B", "C", "D"]} + ctx = orch._build_dept_context( + {"dependencies": ["A", "B", "C", "D"]}, "", deliverables + ) + # 合计上限 6000:前三个各 2000 注满,第四个被截断 + assert "上游部门「A」" in ctx + assert "上游部门「C」" in ctx + assert "上游部门「D」" not in ctx + + def test_missing_deliverable_skipped(self): + orch = self._orch() + ctx = orch._build_dept_context( + {"dependencies": ["不存在"]}, "CEO 分析", {} + ) + assert ctx == "CEO 分析" + + +# ─── execute_stream 全流程(mock TeamOrchestrator + CEO 评审)─── + +class _FakeTeamOrchestrator: + """替代 TeamOrchestrator:记录收到的 db/task,吐固定事件流,不调 LLM。""" + + instances = [] + + def __init__(self, db, team_id, user_id, auto_approve_files=True): + self.db = db + self.team_id = team_id + self.tasks = [] + _FakeTeamOrchestrator.instances.append(self) + + async def execute_stream(self, task, auto_approve_files=True): + self.tasks.append(task) + yield {"type": "phase_start", "phase": 1, "total_phases": 1, "name": "执行"} + yield { + "type": "final", + "deliverable": f"交付物-{self.team_id}", + "files": [f"{self.team_id}.py"], + "phase_count": 1, + } + + +def _make_company(db, dept_names): + company = Company(name="测试公司", user_id="u1") + db.add(company) + db.flush() + teams = [] + for i, name in enumerate(dept_names): + t = Team(name=name, user_id="u1", parent_company_id=company.id, + department_type=f"type{i}") + db.add(t) + teams.append(t) + db.commit() + return company, teams + + +def _patch_session_local(monkeypatch): + """把编排器里的 SessionLocal 换成 MagicMock 工厂,追踪 per-dept session 生命周期。""" + sessions = [] + + def factory(): + s = MagicMock(name=f"dept_session_{len(sessions)}") + sessions.append(s) + return s + + monkeypatch.setattr(co_module, "SessionLocal", factory) + return sessions + + +def _patch_team_orchestrator(monkeypatch): + import app.services.team_orchestrator as to_module + _FakeTeamOrchestrator.instances = [] + monkeypatch.setattr(to_module, "TeamOrchestrator", _FakeTeamOrchestrator) + return _FakeTeamOrchestrator.instances + + +def _patch_review(monkeypatch, all_pass=True): + async def fake(self, company, ceo_plan, dept_results, project_desc): + return { + "summary": "整体良好", + "overall_score": 8, + "next_steps": "", + "departments": [ + {"name": r["department_name"], "score": 8 if all_pass else 4, + "feedback": "fb", "pass": all_pass} + for r in dept_results + ], + } + monkeypatch.setattr(CompanyOrchestrator, "_ceo_review_with_scoring", fake) + + +def _disable_knowledge_extract(monkeypatch): + from app.core.config import settings + monkeypatch.setattr(settings, "COMPANY_AUTO_EXTRACT_KNOWLEDGE", False) + + +async def test_execute_stream_parallel_wave_per_dept_sessions(db_session, monkeypatch, tmp_path): + """同波并行部门:各自拿到独立 session(非编排器共享 session),且用完关闭。""" + monkeypatch.chdir(tmp_path) # project_path mkdir 落到临时目录 + company, teams = _make_company(db_session, ["数据组", "前端组"]) + plan = {"analysis": "分析", "departments": [ + {"department_name": "数据组", "goal": "g1", "deliverables": ["a"], "dependencies": []}, + {"department_name": "前端组", "goal": "g2", "deliverables": ["b"], "dependencies": []}, + ]} + sessions = _patch_session_local(monkeypatch) + instances = _patch_team_orchestrator(monkeypatch) + _patch_review(monkeypatch) + _disable_knowledge_extract(monkeypatch) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] + + types = [e["type"] for e in events] + assert "company_done" in types + assert types.count("dept_done") >= 2 + + # 核心断言:两个部门各自新建 session,且都已关闭 + assert len(sessions) == 2 + assert sessions[0] is not sessions[1] + assert all(s.close.called for s in sessions) + # TeamOrchestrator 收到的不是编排器共享的 self.db + assert len(instances) == 2 + assert all(inst.db is not db_session for inst in instances) + assert all(inst.db in sessions for inst in instances) + + done = [e for e in events if e["type"] == "company_done"][0] + assert done["success"] is True + + +async def test_execute_stream_dependency_order_and_handoff(db_session, monkeypatch, tmp_path): + """依赖 DAG:下游等上游完成后才启动,且上游交付物注入下游任务上下文。""" + monkeypatch.chdir(tmp_path) + company, teams = _make_company(db_session, ["数据组", "前端组"]) + plan = {"analysis": "分析", "departments": [ + {"department_name": "数据组", "goal": "g1", "deliverables": ["a"], "dependencies": []}, + {"department_name": "前端组", "goal": "g2", "deliverables": ["b"], + "dependencies": ["数据组"]}, + ]} + _patch_session_local(monkeypatch) + instances = _patch_team_orchestrator(monkeypatch) + _patch_review(monkeypatch) + _disable_knowledge_extract(monkeypatch) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] + + # 第一波:前端组依赖未满足 → dept_waiting + assert any(e["type"] == "dept_waiting" and e.get("department_name") == "前端组" + for e in events) + + # 数据组先实例化执行,前端组第二波才启动 + assert len(instances) == 2 + assert instances[0].team_id == teams[0].id + assert instances[1].team_id == teams[1].id + + # 四档-① 交接:数据组的交付物注入前端组任务上下文 + downstream_task = instances[1].tasks[0] + assert "上游部门「数据组」" in downstream_task + assert f"交付物-{teams[0].id}" in downstream_task + + +async def test_execute_stream_uses_shared_session_only_sequentially( + db_session, monkeypatch, tmp_path): + """编排主流程(建项目/落状态)仍用 self.db,但绝不把它交给并行部门。""" + monkeypatch.chdir(tmp_path) + company, teams = _make_company(db_session, ["数据组"]) + plan = {"analysis": "分析", "departments": [ + {"department_name": "数据组", "goal": "g", "deliverables": ["a"], "dependencies": []}, + ]} + sessions = _patch_session_local(monkeypatch) + instances = _patch_team_orchestrator(monkeypatch) + _patch_review(monkeypatch) + _disable_knowledge_extract(monkeypatch) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + events = [e async for e in orch.execute_stream("测试项目", ceo_plan=plan)] + + assert any(e["type"] == "company_done" for e in events) + assert len(instances) == 1 + assert instances[0].db is sessions[0] + assert instances[0].db is not db_session + + +# ─── _ceo_review_with_scoring 解析兜底 ─── + +class _FakeLLMClient: + """替代 _LLMClient:返回固定 content,不调真实 API。""" + content = "" + + def __init__(self, cfg): + pass + + async def chat(self, messages, tools, iteration): + return SimpleNamespace(content=_FakeLLMClient.content) + + +def _setup_ceo_agent(db_session): + agent = Agent(name="测试CEO", workflow_config={}, user_id="u1") + db_session.add(agent) + db_session.flush() + company = Company(name="测试公司", user_id="u1", ceo_agent_id=agent.id) + db_session.add(company) + db_session.commit() + return company + + +async def test_ceo_review_valid_json(db_session, monkeypatch): + company = _setup_ceo_agent(db_session) + payload = { + "overall_score": 8, "summary": "好", "next_steps": "继续", + "departments": [{"name": "数据组", "score": 8, "feedback": "好", + "pass": True, "improvement_areas": []}], + } + _FakeLLMClient.content = json.dumps(payload, ensure_ascii=False) + import app.agent_runtime.core as arc + monkeypatch.setattr(arc, "_LLMClient", _FakeLLMClient) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + review = await orch._ceo_review_with_scoring( + company, {"departments": []}, + [{"department_name": "数据组", "success": True, "result": {"deliverable": "x"}}], + "项目", + ) + assert review["overall_score"] == 8 + assert review["departments"][0]["pass"] is True + assert not review.get("parse_failed") + + +async def test_ceo_review_parse_failure_conservative(db_session, monkeypatch): + """评审输出两次都不是合法 JSON → 保守判未通过 + 显式 parse_failed,绝不静默 auto-pass。""" + company = _setup_ceo_agent(db_session) + _FakeLLMClient.content = "这根本不是 JSON,两次重试也都不是" + import app.agent_runtime.core as arc + monkeypatch.setattr(arc, "_LLMClient", _FakeLLMClient) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + review = await orch._ceo_review_with_scoring( + company, {"departments": []}, + [{"department_name": "数据组", "success": True, "result": {"deliverable": "x"}}], + "项目", + ) + assert review.get("parse_failed") is True + assert review["departments"][0]["pass"] is False + + +async def test_ceo_review_llm_exception_conservative(db_session, monkeypatch): + """LLM 调用直接抛异常 → 同样走保守兜底。""" + company = _setup_ceo_agent(db_session) + + class _BoomClient: + def __init__(self, cfg): + pass + + async def chat(self, messages, tools, iteration): + raise RuntimeError("network down") + + import app.agent_runtime.core as arc + monkeypatch.setattr(arc, "_LLMClient", _BoomClient) + + orch = CompanyOrchestrator(db_session, company.id, "u1") + review = await orch._ceo_review_with_scoring( + company, {"departments": []}, + [{"department_name": "数据组", "success": True, "result": {"deliverable": "x"}}], + "项目", + ) + assert review.get("parse_failed") is True + assert review["departments"][0]["pass"] is False diff --git a/docs/M2_control_plan.md b/docs/M2_control_plan.md new file mode 100644 index 0000000..ae8159d --- /dev/null +++ b/docs/M2_control_plan.md @@ -0,0 +1,69 @@ +# 虚拟公司「运行时控制」(M2) 规划文档 + +> 状态:M2-a(能力 1–4)已排期实施;M2-b/c 登记待办。 +> 关联:M1 白盒控制室(可观测)已完成;一档(部门角色匹配 + 可信真实 token/成本¥)已完成并验证。 +> 更新:2026-07-12 + +## 背景与动机 + +白盒控制室(M1)解决了「看得见」,一档解决了「数据可信」(真实 token + 成本¥,实测一次运行约 ¥9.88、耗时 ~60 分钟)。但目前**只能看不能动**:一次运行跑歪了,只能干等到结束或直接杀进程。M2 给控制室补上「运行时控制」,让人能在长运行过程中干预。 + +M2 拆成 **10 项可独立验收的原子能力**,按依赖与价值分三片交付。一次全做改动面广、验证成本高(单次运行 ~1 小时),故分片。 + +## 架构基线(现状,已逐点核实) + +- **同进程模型**:`company_runner.spawn()` 在 API(uvicorn)进程内 `asyncio.create_task` 起编排任务,句柄存 `_RUNNING: Dict[str, asyncio.Task]`。API handler 与编排任务同进程 → 控制用「进程内注册表 + 协作检查点」即可,无需 DB/消息队列。 +- **停止半成品**:已有 `CancelledError → status=stopped` + 落 `company_terminated` 事件的雏形,缺「按 project_id 取消」的 helper 与对外端点。 +- **编排检查点**:`company_orchestrator.execute_stream` 有干净的注入点——轮次顶、就绪部门选取、事件抽取循环、波次边界。 +- **事件单写者**:编排器 `yield` → runner `_persist_event` 落库并分配单调 `seq`。控制确认事件必须走编排器 yield,避免 API 直接写库造成 seq 竞争。 +- **可复用件**:`approval_manager.py`(asyncio.Event 式 create/wait/resolve,供审批);round-to-round rework 过滤(供单部门返工);一档已累计的 `company_cost_yuan`(供预算熔断)。 + +## 10 项能力总览 + +| # | 能力 | 片 | 机制 / 复用点 | 关键取舍 | +|---|------|----|----------------|----------| +| 1 | **停止**(硬取消整公司运行) | M2-a | `company_runner.stop(pid)` = `_RUNNING[pid].cancel()`,复用 `CancelledError→stopped` 雏形 | 打断在飞 LLM,立即生效 | +| 2 | **暂停**(hold,不开新波次) | M2-a | `ProjectControl.pause_event.clear()`;编排器波次/轮次边界 `await wait()` | 在飞部门跑完才 hold(部门粒度 ~15min 延迟) | +| 3 | **继续**(resume) | M2-a | `pause_event.set()` | — | +| 4 | **跳过部门**(skip 未开始部门) | M2-a | `control.skip_depts`;就绪选取处拦截并 `completed_names.add` 让下游推进 | 仅未开始部门;在跑部门不停 | +| 5 | **单部门返工**(对已完成项目只重跑一个部门,不重跑整公司) | M2-c | 复用 rework 过滤(`failed_names→filtered_plans→current_plans`),单部门子集外部驱动;新 `POST …/rework {department_name}` | 需读回历史 ceo_plan/产出做上下文 | +| 6 | **审批·CEO 计划**(花钱前把关) | M2-b | 复用 `approval_manager.py`;CEO 规划后 `yield awaiting_approval → await → 放行/改计划` | 阻塞式,带 timeout 兜底 | +| 7 | **审批·部门评分/返工**(返工前把关) | M2-b | 同上,插在 CEO 打分后、决定返工前 | — | +| 8 | **预算熔断**(成本超 ¥X 自动停) | M2-b | 把 advisory 预算检查改**强制**:读运行中 `company_cost_yuan`,超阈值 → `budget_tripped` + 触发停止 | 阈值入 `company.config["budget"]`;按 ¥ 而非 token | +| 9 | **超时熔断**(总耗时超 N 分钟自动停) | M2-b | 新增 deadline:编排器起始记 `t0`,检查点比对(当前无任何 timeout,仅事后判僵尸) | 与预算熔断共用「自动停」出口 | +| 10 | **单部门取消**(取消在跑的某部门,非停整公司) | M2-c | 为每个 `_run_department_stream` task 建 per-dept 句柄表,`cancel` 单个;team 层需容错半途取消 | 比 skip 复杂,需 team_orchestrator 配合 | + +## 分片与依赖 + +- **M2-a(本片)· 核心四控**:1 停止、2 暂停、3 继续、4 跳过。共用「进程内控制注册表 + 编排检查点」,自成闭环、最易验证。 +- **M2-b · 把关与自动停**:6/7 人在环审批(`approval_manager`)+ 8 预算熔断 + 9 超时熔断(8/9 共用「自动停」出口,与一档成本累计联动)。 +- **M2-c · 部门粒度**:5 单部门返工 + 10 单部门取消(都要 per-dept 粒度的重跑/取消,最深,放最后)。 + +**依赖顺序**:8/9 熔断依赖 1 停止的出口;5 返工依赖一档产出落库;10 取消依赖 per-dept task 句柄。故 1–4 必须先行。 + +## M2-a 设计要点(本片实施) + +### 后端 +- `company_runner.py`:`ProjectControl{pause_event, skip_depts}` + `_CONTROL` 注册表 + `get_control/clear_control` + `stop(pid)`(取消 `_RUNNING[pid]`,复用 stopped 雏形);done-callback 清理控制态。 +- `company_orchestrator.execute_stream`:惰性 `import company_runner`,起始取 `control`;**暂停闸**(轮次顶 + 波次边界,`await pause_event.wait()`,yield `paused`/`resumed`);**跳过闸**(就绪选取处拦 `skip_depts`,标记 `completed_names` 让下游推进,yield `dept_skipped`)。 +- `companies.py`:`POST …/projects/{pid}/{stop|pause|resume|skip}` 四端点,`_assert_company_owner`;pause/resume/skip 需 `is_running`(否则 409),pause/resume 同步 `project.status`。 + +### 前端 +- `api/companies.ts`:`stopProject/pauseProject/resumeProject/skipDepartment`(镜像 `recoverProject` POST)。 +- `stores/companyExecution.ts`:state `paused/controlPending`;action `stop/pause/resume/skipDept`;`applyEvent` 加 `paused/resumed/dept_skipped` 归并。 +- `views/CompanyControlRoom.vue`:控制栏(暂停/继续/停止,停止带 `ElMessageBox.confirm`)+ 每部门 `waiting` 时显「跳过」;状态标签显「已暂停」。 + +### 语义取舍(写进 UI 提示) +- **停止** = 硬取消,立即 → stopped(打断在飞 LLM)。 +- **暂停** = 只拦新波次/新轮次,在飞部门跑完才 hold(部门粒度延迟);`/pause` 即时置 `status=paused` 缓解 UI 反馈。 +- **跳过** = 仅未开始部门;在跑部门跳过留 M2-c(能力 10)。 +- **进程内状态**:进程重启则控制态随运行任务一起消失(任务本就死),无持久化需求;单 worker 假设(与 `_RUNNING` 一致)。 + +## 验证(端到端) +1. 重启后端 + `npm run build`(= vite)。 +2. 起一次 async 运行。 +3. 暂停/继续:`/pause` → status=paused、下一波不启动、`paused` 事件;`/resume` → `resumed` 且继续。 +4. 跳过:对 `waiting` 部门 `/skip` → `dept_skipped`、下游依赖照常推进、该部门不产生 agent 事件。 +5. 停止:`/stop` → 取消、落 `company_terminated`、status=stopped、WS `_stream_end`。 +6. 前端按钮可用(停止有确认)、断线重连控制态从事件流恢复。 +7. 回归:不点任何控制的普通运行仍正常完成(检查点在未暂停/无跳过时是 no-op)。 diff --git a/docs/architecture/(红头)项目核心文档汇总.md b/docs/architecture/(红头)项目核心文档汇总.md index 70b7135..3b615fe 100644 --- a/docs/architecture/(红头)项目核心文档汇总.md +++ b/docs/architecture/(红头)项目核心文档汇总.md @@ -193,7 +193,36 @@ - **执行管理**:支持手动触发、暂停/恢复、执行状态跟踪 - **飞书推送**:定时任务执行结果自动推送到飞书(Webhook + 应用消息) -### 12. 飞书集成和通知系统 +### 12. 虚拟团队(Team Orchestrator) + +- **多 Agent 流水线**:PM 规划 → Design → Dev → QA → DevOps 顺序执行 +- **TeamOrchestrator**:~1975 行,管理团队内 Agent 编排与上下文传递 +- **SSE 流式执行**:实时推送每步执行状态 +- **交付物管理**:自动捕获团队产出文件 + +### 13. 虚拟公司(Company Orchestrator) + +> 最近半年投入最大的模块,在虚拟团队之上加第 3 级组织。 + +- **三级组织**:Company → Department(复用 Team)→ Member(Agent) +- **三阶段执行**:CEO 规划(需求分析→部门拆分→任务分配)→ 部门并行(DAG 拓扑排序)→ CEO 评审(打分+返工) +- **四档迭代**(M1 后全部完成): + - 一档:基础 MVP(CEO 规划→执行→评审) + - 二档:人工审批 / 返工 / 预算 / 6 个行业预设模板 + - 三档-a:事件批量落库(_EventBuffer,N=25/T=0.7s) + - 三档-b:并发上限+排队(_PROJECT_SEM 信号量,MAX_CONCURRENT_COMPANY_PROJECTS=3) + - 四档-①:部门间上下文交接(_build_dept_context) + - 四档-②:公司学习闭环(自动沉淀+注入历史知识) + - 四档-③:按部门配模型(Team.config["model_override"]) + - 四档-④:一键导出运行报告(RFC 5987 Markdown) +- **per-dept 独立 session**(2026-07-26 K3 改造):消除共享 db_orch 竞态 +- **核心文件**: + - `services/company_orchestrator.py`(~1600 行) + - `services/company_runner.py`(~350 行) + - `api/companies.py`(~950 行) + - `api/company_ws.py`(~150 行,WebSocket 事件推送) + +### 14. 飞书集成和通知系统 - **通知系统**:统一的通知管理(notifications 表、通知服务、API) - **飞书定时任务推送**:定时任务执行结果自动推送飞书 - **飞书应用消息**:通过飞书应用 API 发送消息通知 @@ -232,14 +261,19 @@ aiagent/ │ │ │ ├── agent_monitoring.py # Agent 监控 API 5 个端点 │ │ │ ├── agent_schedules.py # Agent 定时任务 CRUD API + 手动触发 │ │ │ ├── feishu_bind.py # 飞书账号绑定/解绑 API -│ │ │ └── notifications.py # 通知系统 API +│ │ │ ├── notifications.py # 通知系统 API +│ │ │ ├── companies.py # 公司 + 项目 CRUD API(~950 行) +│ │ │ └── company_ws.py # 公司 WebSocket 事件推送 │ │ ├── core/ # 核心模块 │ │ ├── models/ # 数据库模型 │ │ │ ├── agent_llm_log.py # Agent LLM 调用日志模型 │ │ │ ├── agent_learning_pattern.py # Agent 学习模式模型(自主学习) │ │ │ ├── agent_schedule.py # Agent 定时任务调度模型 │ │ │ ├── notification.py # 通知模型 -│ │ │ └── agent_vector_memory.py # Agent 向量记忆表模型 +│ │ │ ├── agent_vector_memory.py # Agent 向量记忆表模型 +│ │ │ ├── company.py # 公司/项目/知识模型 +│ │ │ ├── company_project_event.py # 项目事件日志模型 +│ │ │ └── company_knowledge.py # 公司知识沉淀模型 │ │ ├── schemas/ # Pydantic模式 │ │ ├── services/ # 业务逻辑 │ │ │ ├── agent_monitoring_service.py # Agent 监控服务 @@ -254,7 +288,12 @@ aiagent/ │ │ │ ├── knowledge_service.py # 知识库 RAG 服务 │ │ │ ├── embedding_service.py # Embedding 生成服务 │ │ │ ├── document_parser.py # 文档解析器 -│ │ │ └── text_chunker.py # 文本分块器 +│ │ │ ├── text_chunker.py # 文本分块器 +│ │ │ ├── company_orchestrator.py # 虚拟公司编排器(~1600 行) +│ │ │ ├── company_runner.py # 公司项目运行管理器(~350 行) +│ │ │ ├── company_knowledge_extractor.py # 公司知识自动提取 +│ │ │ ├── company_presets.py # 6 个行业预设模板 +│ │ │ └── team_orchestrator.py # 虚拟团队编排器(~1975 行) │ │ └── main.py # 应用入口 │ ├── alembic/ # 数据库迁移 │ ├── tests/ # 测试 @@ -380,6 +419,29 @@ pnpm dev - `GET /api/v1/agent-monitoring/tool-usage?days=7` - 工具调用频次统计 - `GET /api/v1/agent-monitoring/daily-trend?days=7` - 每日 LLM 调用趋势 +### 虚拟公司 API(新增) + +- `GET /api/v1/companies` — 公司列表 +- `POST /api/v1/companies` — 创建公司 +- `GET /api/v1/companies/{id}` — 公司详情 +- `DELETE /api/v1/companies/{id}` — 删除公司 +- `GET /api/v1/companies/{id}/export` — 导出公司报告(RFC 5987 Markdown) +- `POST /api/v1/companies/{id}/projects` — 创建公司项目 +- `POST /api/v1/companies/{id}/projects/{pid}/execute` — 同步执行 +- `POST /api/v1/companies/{id}/projects/{pid}/execute/async` — 异步执行 +- `GET /api/v1/companies/{id}/projects` — 项目列表 +- `GET /api/v1/companies/{id}/projects/{pid}` — 项目详情 +- `POST /api/v1/companies/{id}/projects/{pid}/stop` — 停止项目 +- `GET /api/v1/companies/{id}/projects/{pid}/report` — 导出项目报告 +- `WS /api/v1/ws/company/{pid}` — 实时事件推送(DB 轮询,0.8s 间隔) + +### 虚拟团队 API(新增) + +- `GET /api/v1/teams` — 团队列表 +- `POST /api/v1/teams` — 创建团队 +- `POST /api/v1/teams/{id}/execute` — 执行团队项目 +- `POST /api/v1/teams/{id}/execute/stream` — 执行(SSE 流式) + ### WebSocket API - `ws://localhost:8037/ws/execution/{execution_id}` - 执行状态实时推送 @@ -505,6 +567,14 @@ alembic downgrade -1 - **Agent 自主学习**: 100% ✅(2026-05 新增) - **Agent 定时任务系统**: 100% ✅(2026-05 新增) - **飞书通知与机器人对话**: 100% ✅(2026-05 新增) +- **虚拟团队(TeamOrchestrator)**: 100% ✅(2026-06 新增) +- **虚拟公司 MVP(一档)**: 100% ✅(2026-06 新增) +- **虚拟公司 二档**(审批/返工/预算/模板): 100% ✅(2026-07 新增) +- **虚拟公司 三档-a/b**(事件批量落库+并发上限): 100% ✅(2026-07 新增) +- **虚拟公司 四档**(交接/学习闭环/按部门配模型/导出报告): 100% ✅(2026-07 新增) +- **per-dept 独立 session**(K3 改造): 100% ✅(2026-07-26) +- **编排器测试 22 个**: 100% ✅(2026-07-26) +- **演示项目 4 个**: 100% ✅ - **整体项目**: 约 99.9% ### 已完成核心功能 @@ -534,6 +604,14 @@ alembic downgrade -1 24. **Agent 自主学习** - 从历史执行中自动优化工具选择策略(AgentLearningPattern) 25. **Agent 定时任务系统** - cron 表达式定时触发 Agent 执行,集成 Celery Beat 26. **飞书通知与机器人对话** - 通知系统、飞书定时任务推送、橙子飞书机器人 +27. **虚拟团队编排** - TeamOrchestrator(~1975 行),PM→Design→Dev→QA→DevOps 流水线 +28. **虚拟公司编排** - CompanyOrchestrator(~1600 行),CEO 规划→部门并行→评审返工 +29. **事件批量落库** - _EventBuffer(add_all + commit),N=25/T=0.7s +30. **并发上限+排队** - _PROJECT_SEM 信号量,MAX_CONCURRENT_COMPANY_PROJECTS=3 +31. **学习闭环** - 自动沉淀 CompanyKnowledge + 注入历史知识到 CEO +32. **一键导出报告** - RFC 5987 编码,Markdown 格式项目运行报告 +33. **per-dept 独立 session** - 消除共享 db_orch 竞态(K3 2026-07-26) +34. **编排器测试** - 22 个 pytest(CEO JSON/知识过滤/交接上下文/全流程 mock) ### 近期规划 1. **多租户支持** - 租户模型、数据隔离、资源配额管理 @@ -576,25 +654,35 @@ alembic downgrade -1 - **前端服务(Docker)**:http://localhost:8038 - **问题反馈**:查看项目文档或联系开发团队 -## 关键文档索引(建议) +## 演示项目 -- Windows 启停唯一文档:[`(红头)Windows服务器启动与重启唯一指南.md`](./(红头)Windows服务器启动与重启唯一指南.md) -- 上传图片与OCR实现:[`(红头)上传图片和识别的实现文档.md`](./(红头)上传图片和识别的实现文档.md) -- 教育行业批量Agent脚本:`backend/scripts/create_education_agents_batch.py` -- 政务/媒体批量Agent脚本:`backend/scripts/create_gov_media_agents_batch.py` -- 企业场景批量Agent脚本:`backend/scripts/create_enterprise_scenario_agents.py` -- 自主 AI Agent 改造完成情况:[`自主AI Agent改造完成情况.md`](./自主AI%20Agent改造完成情况.md) -- Agent Runtime 源码入口:`backend/app/agent_runtime/core.py` -- Agent 聊天 API 路由:`backend/app/api/agent_chat.py` -- Agent 聊天前端页面:`frontend/src/views/AgentChat.vue` -- Agent 配置页面:`frontend/src/views/AgentConfig.vue` -- 多 Agent 编排引擎:`backend/app/agent_runtime/orchestrator.py` -- Agent 监控 Dashboard:`frontend/src/views/AgentDashboard.vue` -- Agent LLM 调用日志模型:`backend/app/models/agent_llm_log.py` +| # | 项目 | 生成方式 | 状态 | +|---|------|----------|------| +| 1 | Framework 知识管理软件 | 虚拟团队 | 可运行(Vue3 + FastAPI + SQLite) | +| 2 | 「今天吃啥」渭南家常菜 | 虚拟公司 | 可运行(69 道菜,PWA) | +| 3 | 家庭阅读激励工具 | 虚拟公司 | 设计文档(PRD/原型),无代码 | +| 4 | 家庭泡饮制作助手 | 虚拟公司 + 人工打磨 | 可直接用(59 款饮品,真图,纯前端) | + +> 详细启动方式见 `docs/天工虚拟团队演示项目.md` + +## 关键文档索引 + +- **项目管理**:[`天工平台项目管理.md`](../../../mkdocs/docs/Obsidian笔记体系/Projects/agent/天工平台项目管理.md)(Obsidian) +- **Windows 启停**:[`docs/startup-deploy/(红头)Windows服务器启动与重启唯一指南(1.0版本).md`](../startup-deploy/(红头)Windows服务器启动与重启唯一指南(1.0版本).md) +- **Git 上传**:[`docs/startup-deploy/上传git仓.md`](../startup-deploy/上传git仓.md) +- **平台资料**:[`docs/平台资料.md`](../平台资料.md)(Gitea + 项目结构速查) +- **商业化计划**:[`docs/商业化落地计划.md`](../商业化落地计划.md) +- **演示项目**:[`docs/天工虚拟团队演示项目.md`](../天工虚拟团队演示项目.md) +- **K3 交接资料**:[`docs/deepseek4pro交接/`](../deepseek4pro交接/)(6 份) +- **虚拟公司迭代方案**:`.claude/projects/D--cd-claude-code/memory/project_company_roadmap.md` +- **Agent Runtime 源码入口**:`backend/app/agent_runtime/core.py` +- **公司编排器**:`backend/app/services/company_orchestrator.py` +- **团队编排器**:`backend/app/services/team_orchestrator.py` +- **编排器测试**:`backend/tests/test_company_orchestrator.py`(22 个) --- -**最后更新**: 2026-05-02 -**文档版本**: 1.7 +**最后更新**: 2026-07-26 +**文档版本**: 2.0 *本文档基于项目现有文档整理生成,涵盖项目核心信息。详细技术方案请参考[方案-优化版.md](./方案-优化版.md)。DeepSeek 模型名与 Base URL 以官方文档为准,变更时请同步修订本节。* \ No newline at end of file diff --git a/docs/architecture/解决缺失能力计划.md b/docs/architecture/解决缺失能力计划.md deleted file mode 100644 index 8d78e27..0000000 --- a/docs/architecture/解决缺失能力计划.md +++ /dev/null @@ -1,429 +0,0 @@ -# 解决缺失能力计划 - -## 总体策略 - -按梯队分阶段实施。每个阶段聚焦 2-3 个能力,完成即可独立提升系统质量。 -每阶段包含:涉及文件、核心改动、验证方式。 - ---- - -## 第一阶段:闭环质量(1-2 周) - -优先解决"输出质量验证",这是连接自主进化系统的最后一环。 - -### 1.1 输出质量验证 → 新增 `evaluator` 节点 + `self_review` 工具 - -**目标:** Agent 执行完后自动检查输出质量,不达标则自我修正。 - -**方案:** - -#### A. 新增 `self_review` 工具(第 35 个内置工具) - -``` -self_review(content="...", criteria="回答必须包含SQL优化建议和执行计划") -→ { - "score": 0.75, - "passed": true, - "issues": ["缺少具体执行计划"], - "suggestions": ["补充EXPLAIN输出分析"] - } -``` - -**实现:** 用 LLM 作为评判器(deepseek-v4-flash,轻量调用),根据 criteria 打分 0-1。 - -#### B. 新增 `evaluator` 工作流节点类型 - -在工作流引擎中添加 evaluator 节点: -- 接收上游节点输出 + 评判标准(criteria) -- 调用 LLM 评判输出质量 -- 输出 `{score, passed, issues, suggestions}` -- 配合 condition 节点:`passed=false` → 走修正分支 → 重试 - -#### C. AgentRuntime 内建 self-review - -在 AgentRuntime ReAct 循环末尾添加可选的 self-review 步骤: -- `AgentConfig` 添加 `self_review: bool = False` -- 启用后,Agent 在返回最终答案前自动调用 `self_review` -- 评分 < 0.6 则追加一轮修正迭代 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/services/builtin_tools.py` | 新增 `self_review_tool` + `SELF_REVIEW_SCHEMA` | -| `backend/app/core/tools_bootstrap.py` | 34→35,import + register | -| `backend/app/services/workflow_engine.py` | 新增 `evaluator` 节点类型 | -| `backend/app/agent_runtime/core.py` | 末尾添加 self-review 步骤 | -| `backend/app/agent_runtime/schemas.py` | AgentConfig 添加 `self_review` 字段 | -| `frontend/src/utils/agentSkills.ts` | 新增条目 | - -**验证:** -1. `self_review("SELECT * FROM t", "需要包含索引建议")` → score < 0.5 -2. 工作流:agent → evaluator(criteria="...") → condition(passed?) → 正常结束 / 修正重试 -3. 全能助手对话测试:问一个复杂问题,开启 self_review,确认输出质量提升 - ---- - -### 1.2 节点级自动重试 - -**目标:** error_handler 从空壳变为真正的重试机制。 - -**方案:** - -修改 `error_handler` 节点执行逻辑: -``` -当上游节点 status == "failed" 时: - 1. 检查 retry_count(剩余重试次数) - 2. 等待 retry_delay 秒 - 3. 重新执行上游节点(复用 get_node_input + execute_node) - 4. 成功 → 继续流程 - 5. 失败且 retry_count > 0 → 循环 - 6. 失败且 retry_count == 0 → 触发 on_error 动作 -``` - -`on_error` 动作: -- `stop`:抛出错误停止工作流 -- `notify`:发送告警继续 -- `skip`:跳过该节点继续 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/services/workflow_engine.py` | 重写 error_handler 节点逻辑 | -| `backend/app/core/exceptions.py` | 添加 `NodeRetryExhausted` 异常 | - -**验证:** -1. 模拟 LLM 超时 → error_handler 重试 3 次 → 第 3 次成功 → 继续 -2. 模拟连续失败 → error_handler 耗尽重试 → 触发 on_error=notify → 告警记录 - ---- - -## 第二阶段:编排整合(2-3 周) - -### 2.1 Orchestrator 进入工作流 - -**目标:** 4 种编排模式变为工作流节点类型。 - -**方案:** - -新增 `orchestrator` 工作流节点类型,数据配置: -```json -{ - "mode": "route | sequential | debate | pipeline", - "agents": ["agent_id_1", "agent_id_2", ...], - "routing_prompt": "用于 route 模式的路由指令", - "aggregation_prompt": "用于 debate 模式的汇总指令" -} -``` - -`workflow_integration.py` 添加 `run_orchestrator_node()`: -- 解析 node_data 中的 agent 列表 -- 从 DB 加载 Agent 配置 -- 创建 AgentOrchestrator 实例 -- 执行对应模式,返回结构化结果 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/agent_runtime/workflow_integration.py` | 新增 `run_orchestrator_node()` | -| `backend/app/services/workflow_engine.py` | 新增 orchestrator 节点类型 | -| `frontend/src/utils/agentSkills.ts` | 无需改动(后端节点类型) | - -**验证:** -1. 创建工作流:start → orchestrator(debate, [AgentA, AgentB, AgentC]) → end -2. 执行:确认 3 个 Agent 均被调用,结果被汇总 -3. 创建工作流:start → orchestrator(route, [代码助手, 数据分析师]) → end,确认按路由分发 - ---- - -### 2.2 工具级人工审批 - -**目标:** 危险工具执行前暂停等待用户确认。 - -**方案:** - -#### A. AgentToolConfig 扩展 -```python -class AgentToolConfig(BaseModel): - include_tools: List[str] = [] - exclude_tools: List[str] = [] - require_approval: List[str] = [] # 新增:需要审批的工具名列表 -``` - -#### B. AgentRuntime 审批拦截 -在 ReAct 循环的工具执行环节(`core.py` 约 287 行): -``` -if tool_name in config.tools.require_approval: - yield ApprovalRequest(tool_name, tool_args) # 暂停,等待外部确认 - decision = await wait_for_approval() # 阻塞等待 - if decision != "approved": - continue # 跳过该工具调用 -``` - -#### C. WebSocket 审批通道 -- AgentRuntime 暂停时通过 WebSocket 推送审批请求 -- 用户通过 WebSocket 回复 approve/deny -- 超时默认策略可配置(approve/deny/skip) - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/agent_runtime/schemas.py` | AgentToolConfig 添加 require_approval | -| `backend/app/agent_runtime/core.py` | 工具执行前审批拦截 | -| `backend/app/websocket/manager.py` | 添加审批消息类型 | - -**验证:** -1. 配置 Agent: `require_approval: ["deploy_push"]` -2. Agent 调用 deploy_push → WebSocket 收到审批请求 -3. 用户 approve → Agent 继续执行 -4. 用户 deny → Agent 跳过该工具 - ---- - -## 第三阶段:性能与效率(2-3 周) - -### 3.1 并行执行 - -**目标:** 无依赖节点并行执行,Orchestrator debate 并行。 - -**方案:** - -#### A. 工作流 DAG 并行 -修改 `execute()` 主循环: -``` -当前:每轮取 1 个节点执行 -改为:每轮取所有"前置节点全部完成"的节点,asyncio.gather 并行执行 -``` - -注意事项: -- 并行节点不能有共享状态依赖 -- 需要分别捕获每个并行节点的异常 -- WebSocket 需支持多节点同时推送进度 - -#### B. Orchestrator debate 并行 -```python -# 改前 -for agent in agents: - result = await agent.run(query) - -# 改后 -results = await asyncio.gather(*[agent.run(query) for agent in agents]) -``` - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/services/workflow_engine.py` | execute() 并行执行 | -| `backend/app/agent_runtime/orchestrator.py` | debate/sequential 并行 | -| `backend/app/websocket/manager.py` | 支持多节点并发推送 | - -**验证:** -1. 3 个独立分支的 DAG → 总耗时 ≈ 最长分支耗时(而非三者之和) -2. debate 模式 3 Agent → 总耗时 ≈ 最慢 Agent 耗时 - ---- - -### 3.2 粒度进度上报 - -**目标:** 实时推送"第 3/7 步完成"到前端。 - -**方案:** - -#### A. WorkflowEngine 进度回调 -在 `execute()` 循环中添加钩子: -```python -async def on_progress(self, current_step, total_steps, node_name, status): - # 推送到 WebSocket - await ws_manager.broadcast(execution_id, { - "type": "progress", - "current": current_step, - "total": total_steps, - "node": node_name, - "status": status, - "percent": int(current_step / total_steps * 100), - }) -``` - -#### B. 统计 DAG 总步数 -执行前遍历拓扑排序结果,计算可达节点总数作为 total_steps。 - -#### C. WebSocket 改为推送模式 -当前是轮询 DB,改为 WorkflowEngine 主动调用 WebSocket manager 推送。 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/services/workflow_engine.py` | 添加 on_progress 回调 + 步数统计 | -| `backend/app/websocket/manager.py` | 添加 execution 进度推送方法 | -| `backend/app/api/websocket.py` | 适配新的推送模式 | - -**验证:** -1. 执行 7 节点的 DAG → WebSocket 收到 7 条进度消息 -2. 前端进度条从 0% 逐步递增到 100% - ---- - -### 3.3 结果缓存 - -**目标:** 相同输入不重复计算。 - -**方案:** - -添加缓存层,使用 Redis: -```python -# 工具结果缓存 -cache_key = f"tool:{tool_name}:{hash(json.dumps(args))}" -cached = await redis.get(cache_key) -if cached: - return cached.decode() - -# LLM 响应缓存(相同 prompt + 相同 messages) -cache_key = f"llm:{hash(messages_json)}" -``` - -配置: -- TTL 默认 1 小时 -- 工具维度可选开启/关闭 -- 确定性工具(file_read、math_calculate)默认开启 -- 非确定性工具(web_search、http_request)默认关闭 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/agent_runtime/tool_manager.py` | 工具执行前后加缓存层 | -| `backend/app/agent_runtime/core.py` | LLM 调用前后加缓存层 | -| `backend/app/core/config.py` | 添加缓存配置项 | - -**验证:** -1. 连续两次相同 file_read → 第二次命中缓存(耗时 < 1ms) -2. 连续两次相同 math_calculate → 第二次命中缓存 - ---- - -## 第四阶段:容错与共享(2-3 周) - -### 4.1 降级/回退链 - -**目标:** 模型/Agent 失败自动切换备用方案。 - -**方案:** - -AgentLLMConfig 扩展: -```python -class AgentLLMConfig(BaseModel): - provider: str = "openai" - model: str = "gpt-4o-mini" - fallback_llm: Optional['AgentLLMConfig'] = None # 降级模型 -``` - -Agent 节点 data 扩展: -```json -{ - "fallback_agent": "备选Agent的ID" // 可选 -} -``` - -执行逻辑: -1. 主模型调用失败 → 切换 fallback_llm 重试 -2. Agent 执行失败 → 查找 fallback_agent 重试 -3. 所有备用方案都失败 → 抛出错误 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/agent_runtime/schemas.py` | AgentLLMConfig 添加 fallback_llm | -| `backend/app/agent_runtime/core.py` | LLM 调用失败后切换 fallback | -| `backend/app/services/workflow_engine.py` | Agent 节点失败后切换 fallback_agent | - ---- - -### 4.2 Agent 间知识共享 - -**目标:** 打破记忆隔离,Agent 间共享知识。 - -**方案:** - -添加全局知识索引层: -``` -每个 Agent 执行完成后 → 提取关键知识 → 写入 global_knowledge 表 -Agent 初始化时 → 从 global_knowledge 加载相关条目 -``` - -实现: -- 新增 `GlobalKnowledge` 模型(content, embedding, source_agent_id, tags, created_at) -- `AgentMemory.initialize()` 添加全局知识检索步骤 -- 自主进化创建的 Agent 自动继承创建者的知识 - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/models/agent.py` | 新增 GlobalKnowledge 模型 | -| `backend/app/agent_runtime/memory.py` | initialize() 添加全局知识加载 | -| `backend/app/agent_runtime/core.py` | 执行完毕提取知识写入全局 | - ---- - -### 4.3 Agent 异步执行实现 - -**目标:** 填空 `execute_agent_task`,支持真正异步 Agent 执行。 - -**方案:** - -`execute_agent_task` 当前是空壳,需要实现: -```python -@celery_app.task -def execute_agent_task(agent_id, query, user_id): - db = SessionLocal() - agent = db.query(Agent).filter(Agent.id == agent_id).first() - config = build_agent_config_from_db(agent) - runtime = AgentRuntime(config) - result = asyncio.run(runtime.run(query)) - # 更新 execution 记录 - return {"status": "completed", "output": result.content} -``` - -**涉及文件:** - -| 文件 | 改动 | -|------|------| -| `backend/app/tasks/agent_tasks.py` | 实现 execute_agent_task | -| `backend/app/tasks/scheduler_tasks.py` | 调度触发真正的异步执行 | - ---- - -## 实施路线图总览 - -``` -第一阶段(1-2周) - ├── 1.1 输出质量验证:self_review工具 + evaluator节点 + AgentRuntime自检 - └── 1.2 节点级自动重试:error_handler从空壳到真正重试 - -第二阶段(2-3周) - ├── 2.1 Orchestrator进入工作流:新增orchestrator节点类型 - └── 2.2 工具级人工审批:AgentToolConfig审批标记 + WebSocket审批通道 - -第三阶段(2-3周) - ├── 3.1 并行执行:DAG并行 + Orchestrator debate并行 - ├── 3.2 粒度进度上报:on_progress回调 + WebSocket推送 - └── 3.3 结果缓存:Redis缓存层 - -第四阶段(2-3周) - ├── 4.1 降级/回退链:fallback_llm + fallback_agent - ├── 4.2 Agent知识共享:GlobalKnowledge + 跨Agent检索 - └── 4.3 Agent异步执行:填空execute_agent_task -``` - -## 优先级逻辑 - -- **第一阶段优先**:质量验证是自主进化的最后一环,没有它进化无方向;重试是稳定性基础 -- **第二阶段其次**:编排整合让复杂任务可视化、安全化 -- **第三阶段提速**:并行 + 缓存大幅提升性能(生产部署前必备) -- **第四阶段收尾**:容错和共享让系统长期运行可持续 diff --git a/docs/deepseek4pro交接/00-项目概览.md b/docs/deepseek4pro交接/00-项目概览.md new file mode 100644 index 0000000..1f4f4a2 --- /dev/null +++ b/docs/deepseek4pro交接/00-项目概览.md @@ -0,0 +1,80 @@ +# 天工智能体平台 — 项目概览 + +> 给 Kimi K3 的项目全景速览。建议按编号顺序阅读。 + +## 这是什么 + +**天工智能体平台**(Tiangong AI Agent Platform)是一个企业级 AI 智能体(Agent)构建与运营平台。用户可以: +- 创建/配置 LLM Agent(温度、系统提示、工具、知识库、记忆) +- 将 Agent 组成**虚拟团队**(Team)→ 多 Agent 按角色顺序协作执行项目 +- 将虚拟团队进一步组织为**虚拟公司**(Company),CEO 规划→部门并行→汇总评分 +- 用编排画布(工作流)拖拽构建自动化流水线 +- 对话交互(Web Chat + Android App + 飞书集成) + +定位:面向非开发者的 AI 自动化平台,核心差异化在「虚拟团队/公司」的多 Agent 编排。 + +## 技术栈速览 + +| 层 | 技术 | +|---|------| +| 前端 | Vue 3 (Composition API) + Vite + Pinia + Element Plus + TypeScript | +| 后端 | FastAPI (Python 3.12) + SQLAlchemy 2.0 + Pydantic 2 | +| 数据库 | MySQL 8.0(腾讯云远程) + Redis 7(本地) | +| 异步 | asyncio + Celery(Redis broker) | +| 认证 | JWT (OAuth2PasswordBearer) | +| LLM | DeepSeek v4-pro(主力),SiliconFlow(Embedding),OpenAI/Anthropic(备用) | +| 部署 | Docker Compose,云端服务器 101.43.95.130 | + +## 关键数字 + +- **245+** API 端点(45 个路由模块) +- **43** 数据模型 +- **80+** Service 文件 +- **41** 个前端页面/视图 +- **59** 款饮品产出(demo 项目,见 05 号文档) +- 代码仓库只 1 人维护(renjianbo) + +## 目录结构(简版) + +``` +aiagent/ +├── frontend/ # Vue 3 前端(:3001 dev) +│ └── src/ +│ ├── views/ # 41 个页面 +│ ├── api/ # Axios 请求封装 +│ ├── stores/ # Pinia 状态 +│ └── router/ # 路由 + Auth guard +├── backend/ # FastAPI 后端(:8037) +│ └── app/ +│ ├── api/ # 45 个路由模块 +│ ├── services/ # 80+ 业务逻辑文件 +│ ├── models/ # 43 个 SQLAlchemy 模型 +│ ├── core/ # 配置/数据库/安全/中间件 +│ └── main.py # FastAPI 入口 + 路由注册 + 中间件链 +├── team_projects/ # 虚拟团队/公司的文件产出目录 +├── company_projects/ # 虚拟公司工作区 +├── deliverables/ # 精选成品项目(人工打磨后的可直接用产物) +├── docs/ # 文档 +├── scripts/ # 启动/运维脚本 +└── docker-compose.dev.yml # Docker 开发环境 +``` + +## 核心概念关系 + +``` +Agent(智能体:系统提示+工具+模型+知识库+记忆) + └─ 可加入多个 Team + └─ Team(团队:多 Agent 按角色编排,顺序流水线) + └─ 可选归属给 Company(公司,做为企业部门) + └─ Company(公司:3 级架构) + └─ Company > Project(项目) + ├─ Phase1: CEO 规划(分析→部门拆分→任务分配) + ├─ Phase2: 部门并行执行(多波,依赖驱动的拓扑排序) + └─ Phase3: CEO 评审 + 打分 + 返工 +``` + +## 谁在用 + +- **管理员**:Web 管理后台(:3001),Agent/团队/公司/工作流全管理 +- **终端用户**:Android App(对话交互)+ 飞书 App(多应用机器人) +- **开发者**:本地 localhost 开发,所有后端 API 有 `/docs`(Swagger) diff --git a/docs/deepseek4pro交接/01-架构与技术栈.md b/docs/deepseek4pro交接/01-架构与技术栈.md new file mode 100644 index 0000000..2b2e6f4 --- /dev/null +++ b/docs/deepseek4pro交接/01-架构与技术栈.md @@ -0,0 +1,78 @@ +# 架构与技术栈详解 + +## 完整技术栈 + +| 层级 | 技术 | 版本 | 备注 | +|------|------|------|------| +| 前端框架 | Vue 3 (Composition API) | 3.4+ | SFC + ` + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5c5ef80..f96664a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -220,6 +220,12 @@ const router = createRouter({ component: () => import('@/views/CompanyBuilder.vue'), meta: { requiresAuth: true } }, + { + path: '/companies/:companyId/projects/:projectId/monitor', + name: 'company-control-room', + component: () => import('@/views/CompanyControlRoom.vue'), + meta: { requiresAuth: true } + }, { path: '/app-versions', name: 'app-versions', diff --git a/frontend/src/stores/companyExecution.ts b/frontend/src/stores/companyExecution.ts new file mode 100644 index 0000000..d353444 --- /dev/null +++ b/frontend/src/stores/companyExecution.ts @@ -0,0 +1,489 @@ +/** + * 虚拟公司「白盒控制室」执行状态 store + * + * 通过 WebSocket 订阅 /ws/company-projects/{pid}(回放 + 准实时), + * 断线按 lastSeq 指数退避重连;WS 建连失败降级为 HTTP 轮询 /events。 + * applyEvent 把编排事件归并为结构化面板,**保留工具入参/出参与真实 token** + * (CompanyBuilder.routeStreamEvent 会丢弃这些)。 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { ElMessage } from 'element-plus' +import { + companyProjectWsUrl, getProjectEvents, + stopProject, pauseProject, resumeProject, skipDepartment, approveGate, + cancelDepartment, reworkDepartment, +} from '@/api/companies' + +export interface ToolCall { + name: string + input: any + result: any + durationMs?: number +} +export interface AgentIteration { + think: string + reasoning: string + toolCalls: ToolCall[] + final?: string + tokens?: number +} +export interface DeptPanel { + name: string + type: string + status: 'waiting' | 'running' | 'done' | 'error' + phaseName: string + phaseProgress: string + waitingFor: string[] + iterations: AgentIteration[] + activity: Array<{ icon: string; text: string }> + files: string[] + tokens: number + approxTokens: number + durationMs: number + deliverable: string + costYuan: number +} +export interface TimelineItem { + type: string + label: string + durationMs?: number + success?: boolean +} +export interface Envelope { + seq: number + pid: string + type: string + payload: any + ts: number | null +} + +function newPanel(name: string, type = ''): DeptPanel { + return { + name, type, status: 'running', phaseName: '', phaseProgress: '', waitingFor: [], + iterations: [], activity: [], files: [], tokens: 0, approxTokens: 0, durationMs: 0, deliverable: '', costYuan: 0, + } +} + +export const useCompanyExecutionStore = defineStore('companyExecution', () => { + const projectId = ref('') + const companyId = ref('') + const status = ref('queued') + const connected = ref(false) + const finished = ref(false) + const errorMsg = ref('') + const paused = ref(false) + const controlPending = ref('') // 进行中的控制动作: '' | 'pause' | 'resume' | 'stop' | 'skip' + const awaitingApproval = ref(null) // M2-b: 非空=有审批门待批 { gate, plan?, failed_depts? } + const terminatedReason = ref('') // M2-b: 熔断/拒绝终止原因文案 + + const companyName = ref('') + const departmentNames = ref([]) + const ceoPlan = ref(null) + const ceoScores = ref([]) + const ceoSummary = ref('') + const rounds = ref>([]) + const currentRound = ref(0) + const maxRounds = ref(1) + + const depts = ref>({}) + const timeline = ref([]) + const rawEvents = ref([]) + const lastSeq = ref(0) + + let ws: WebSocket | null = null + let poller: number | null = null + let reconnectTimer: number | null = null + let reconnectDelay = 1000 + let manualClose = false + + const deptList = computed(() => Object.values(depts.value)) + const tokensTotal = computed(() => + Object.values(depts.value).reduce((sum, d) => sum + (d.tokens || 0), 0), + ) + const costTotal = computed(() => + Object.values(depts.value).reduce((sum, d) => sum + (d.costYuan || 0), 0), + ) + const runningDepts = computed(() => deptList.value.filter(d => d.status === 'running').map(d => d.name)) + const dagOption = computed(() => buildDag(ceoPlan.value, depts.value)) + + function reset() { + status.value = 'queued'; connected.value = false; finished.value = false; errorMsg.value = '' + paused.value = false; controlPending.value = '' + awaitingApproval.value = null; terminatedReason.value = '' + companyName.value = ''; departmentNames.value = []; ceoPlan.value = null; ceoScores.value = [] + ceoSummary.value = ''; rounds.value = []; currentRound.value = 0; maxRounds.value = 1 + depts.value = {}; timeline.value = []; rawEvents.value = []; lastSeq.value = 0 + } + + function _panel(name: string, type = ''): DeptPanel { + if (!depts.value[name]) depts.value[name] = newPanel(name, type) + return depts.value[name] + } + + function applyEvent(env: Envelope) { + // 去重 / 顺序保护 + if (typeof env.seq === 'number') { + if (env.seq <= lastSeq.value) return + lastSeq.value = env.seq + } + rawEvents.value.push(env) + if (rawEvents.value.length > 5000) rawEvents.value.shift() + + const t = env.type + const p = env.payload || {} + + if (t === 'company_start') { + companyName.value = p.company_name || '' + departmentNames.value = p.departments || [] + } else if (t === 'ceo_plan_start') { + timeline.value.push({ type: 'ceo', label: p.approved ? '采用已确认计划' : 'CEO 战略规划中...' }) + } else if (t === 'ceo_plan_done') { + ceoPlan.value = p.plan || null + timeline.value.push({ type: 'ceo', label: `CEO 规划完成 (${p.plan?.departments?.length || 0} 部门任务)`, durationMs: p.duration_ms }) + } else if (t === 'round_start') { + currentRound.value = p.round_index || 1 + maxRounds.value = p.max_rounds || maxRounds.value + rounds.value.push({ index: currentRound.value, reworkDepts: p.rework_depts || [], overallScore: 0, done: false }) + timeline.value.push({ type: 'round', label: `第 ${currentRound.value} 轮开始` + (p.rework_depts?.length ? ` (返工 ${p.rework_depts.join(',')})` : '') }) + } else if (t === 'dept_waiting') { + if (p.department_name && p.department_name !== 'all_remaining') { + const d = _panel(p.department_name) + d.status = 'waiting'; d.waitingFor = p.waiting_for || [] + } + } else if (t === 'dept_agent_event') { + applyDeptAgentEvent(env) + } else if (t === 'dept_done') { + const d = _panel(p.department_name) + if (p.cancelled) { + // M2-c 取消:走完成态(下游视觉解锁),不标 error + d.status = 'done' + d.activity.push({ icon: '🛑', text: '已取消' }) + timeline.value.push({ type: 'control', label: `取消 ${p.department_name}` }) + } else { + d.status = p.success ? 'done' : 'error' + timeline.value.push({ type: 'dept', label: `${p.department_name} ${p.success ? '完成' : '失败'}`, durationMs: p.duration_ms, success: p.success }) + } + if (typeof p.tokens_used === 'number' && p.tokens_used > 0) d.tokens = p.tokens_used + if (typeof p.tokens_used_approx === 'number') d.approxTokens = p.tokens_used_approx + if (typeof p.cost_yuan === 'number') d.costYuan = p.cost_yuan + if (typeof p.duration_ms === 'number') d.durationMs = p.duration_ms + if (p.result?.files) d.files = p.result.files + if (p.result?.deliverable) d.deliverable = p.result.deliverable + } else if (t === 'ceo_score_done') { + ceoScores.value = p.scores || [] + ceoSummary.value = p.summary || '' + const pass = ceoScores.value.filter((s: any) => s.pass).length + timeline.value.push({ type: 'score', label: `CEO 打分: ${pass}/${ceoScores.value.length} 通过`, success: pass === ceoScores.value.length, durationMs: p.duration_ms }) + } else if (t === 'round_done') { + const r = rounds.value[rounds.value.length - 1] + if (r) { r.done = true; r.overallScore = p.overall_score || 0 } + timeline.value.push({ type: 'round', label: `第 ${p.round_index || currentRound.value} 轮完成` }) + } else if (t === 'paused') { + paused.value = true + timeline.value.push({ type: 'control', label: '已暂停' }) + } else if (t === 'resumed') { + paused.value = false + timeline.value.push({ type: 'control', label: '已继续' }) + } else if (t === 'dept_skipped') { + if (p.department_name) { + const d = _panel(p.department_name) + d.status = 'done' + d.activity.push({ icon: '⏭️', text: '已跳过' }) + } + timeline.value.push({ type: 'control', label: `跳过 ${p.department_name || ''}` }) + } else if (t === 'rework_start') { + if (p.department_name) { + const d = _panel(p.department_name) + d.status = 'running' + d.activity.push({ icon: '🔁', text: '开始重跑' }) + } + finished.value = false // 返工把已完成项目重新拉活 + timeline.value.push({ type: 'control', label: `重跑 ${p.department_name || ''}` }) + } else if (t === 'rework_done') { + timeline.value.push({ type: 'control', label: `${p.department_name || ''} 重跑${p.success ? '完成' : '失败'}`, success: p.success }) + } else if (t === 'awaiting_approval') { + awaitingApproval.value = { gate: p.gate, plan: p.plan, failed_depts: p.failed_depts, round_index: p.round_index } + timeline.value.push({ type: 'control', label: p.gate === 'rework' ? '等待返工审批' : '等待计划审批' }) + } else if (t === 'approval_resolved') { + awaitingApproval.value = null + timeline.value.push({ type: 'control', label: `审批${p.decision === 'approved' ? '通过' : '拒绝'} (${p.gate})` }) + if (p.feedback) timeline.value.push({ type: 'control', label: `注入反馈:${String(p.feedback).slice(0, 60)}` }) + } else if (t === 'rework_skipped') { + timeline.value.push({ type: 'control', label: '拒绝返工,就地收尾' }) + } else if (t === 'budget_tripped') { + terminatedReason.value = `预算熔断:成本 ¥${p.cost_yuan} 已达上限 ¥${p.limit_yuan}` + finished.value = true + timeline.value.push({ type: 'error', label: terminatedReason.value }) + } else if (t === 'timeout_tripped') { + terminatedReason.value = `超时熔断:已运行 ${p.elapsed_min} 分钟,达上限 ${p.limit_min} 分钟` + finished.value = true + timeline.value.push({ type: 'error', label: terminatedReason.value }) + } else if (t === 'company_done') { + timeline.value.push({ type: 'done', label: `全部完成`, success: p.success, durationMs: p.total_duration_ms }) + } else if (t === 'error' || t === 'company_terminated') { + if (t === 'company_terminated' && p.reason === 'plan_rejected') terminatedReason.value = 'CEO 计划被拒绝,已终止' + errorMsg.value = p.message || p.reason || '执行出错' + timeline.value.push({ type: 'error', label: terminatedReason.value || errorMsg.value }) + } else if (t === '_stream_end') { + status.value = env.payload?.status || status.value + finished.value = true + } + } + + function applyDeptAgentEvent(env: Envelope) { + const p = env.payload || {} + const name = p.department_name + if (!name) return + const d = _panel(name, p.department_type || '') + d.status = 'running' + if (p.phase_name) d.phaseName = p.phase_name + if (p.phase_progress) d.phaseProgress = p.phase_progress + + const sub = p.sub_type + const data = p.data || {} + + if (sub === 'phase_start') { + d.phaseName = data.name || d.phaseName + d.activity.push({ icon: '🔄', text: `阶段${data.phase ?? ''}: ${data.name || ''} — ${data.agent || ''}` }) + } else if (sub === 'phase_done') { + d.activity.push({ icon: data.success ? '✅' : '❌', text: `阶段${data.phase ?? ''} ${data.success ? '完成' : '失败'}` }) + } else if (sub === 'agent_event') { + const inner = data.data || data + const it = inner.type || '' + if (it === 'think') { + d.iterations.push({ think: inner.content || '', reasoning: inner.reasoning || '', toolCalls: [] }) + d.activity.push({ icon: '💭', text: truncate(inner.content, 120) }) + } else if (it === 'tool_call') { + const cur = lastIter(d) + cur.toolCalls.push({ name: inner.name || inner.tool_name || inner.tool || '', input: inner.input ?? inner.args ?? null, result: null }) + d.activity.push({ icon: '🔧', text: inner.name || inner.tool_name || inner.tool || '' }) + } else if (it === 'tool_result') { + const cur = lastIter(d) + // 补到最近一个未有结果的同名 toolCall + const tc = [...cur.toolCalls].reverse().find(c => c.result === null) + if (tc) { tc.result = inner.result ?? null; tc.durationMs = inner.duration_ms } + d.activity.push({ icon: '📥', text: `${inner.name || inner.tool_name || ''} ${inner.duration_ms ? inner.duration_ms + 'ms' : ''}` }) + } else if (it === 'final') { + const cur = lastIter(d) + cur.final = inner.content || '' + const tu = inner.token_usage || {} + if (typeof tu.total_tokens === 'number') { cur.tokens = tu.total_tokens; d.tokens += tu.total_tokens } + d.activity.push({ icon: '📤', text: truncate(inner.content, 120) }) + } else if (it === 'error') { + d.activity.push({ icon: '❌', text: inner.content || inner.message || '错误' }) + } + } else if (sub === 'final') { + d.status = 'done' + d.activity.push({ icon: '🎉', text: `交付完成 (${data.phase_count || 0} 阶段)` }) + } else if (sub === 'error') { + d.status = 'error' + d.activity.push({ icon: '❌', text: data.content || data.message || '错误' }) + } + } + + function lastIter(d: DeptPanel): AgentIteration { + if (!d.iterations.length) d.iterations.push({ think: '', reasoning: '', toolCalls: [] }) + return d.iterations[d.iterations.length - 1] + } + + // ─── 连接 ─── + + function connect(cid: string, pid: string) { + disconnect() + manualClose = false + reset() + companyId.value = cid + projectId.value = pid + _openWs() + } + + function _openWs() { + try { + ws = new WebSocket(companyProjectWsUrl(projectId.value, lastSeq.value)) + } catch { + _startPolling() + return + } + ws.onopen = () => { connected.value = true; reconnectDelay = 1000 } + ws.onmessage = (e) => { + try { + const env = JSON.parse(e.data) + if (env.type === 'error' && env.code) { errorMsg.value = env.message || '连接错误'; return } + applyEvent(env) + if (env.type === '_stream_end') { manualClose = true; ws?.close() } + } catch { /* ignore */ } + } + ws.onclose = () => { + connected.value = false + if (manualClose || finished.value) return + _scheduleReconnect() + } + ws.onerror = () => { /* onclose 会接手重连;持续失败则降级轮询 */ } + } + + function _scheduleReconnect() { + if (reconnectTimer) return + if (reconnectDelay > 8000) { _startPolling(); return } // WS 反复失败 → 降级 HTTP 轮询 + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + reconnectDelay = Math.min(reconnectDelay * 2, 10000) + _openWs() + }, reconnectDelay) + } + + function _startPolling() { + if (poller) return + const tick = async () => { + try { + const resp = await getProjectEvents(companyId.value, projectId.value, lastSeq.value) + for (const env of resp.events) applyEvent(env as Envelope) + status.value = resp.status + if (resp.finished) { finished.value = true; _stopPolling(); return } + } catch { /* keep polling */ } + } + poller = window.setInterval(tick, 1500) + tick() + } + + function _stopPolling() { if (poller) { clearInterval(poller); poller = null } } + + function disconnect() { + manualClose = true + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } + _stopPolling() + if (ws) { try { ws.close() } catch { /* */ } ws = null } + connected.value = false + } + + // ─── M2-a 运行时控制 ─── + + async function pause() { + controlPending.value = 'pause' + try { + await pauseProject(companyId.value, projectId.value) + paused.value = true + ElMessage.info('暂停请求已发送,将在当前波次结束后生效') + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '暂停失败') + } finally { controlPending.value = '' } + } + + async function resume() { + controlPending.value = 'resume' + try { + await resumeProject(companyId.value, projectId.value) + paused.value = false + ElMessage.success('已继续') + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '继续失败') + } finally { controlPending.value = '' } + } + + async function stop() { + controlPending.value = 'stop' + try { + const r = await stopProject(companyId.value, projectId.value) + ElMessage.info(r?.stopped ? '已发送停止' : '项目未在运行') + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '停止失败') + } finally { controlPending.value = '' } + } + + async function skipDept(name: string) { + controlPending.value = 'skip' + try { + await skipDepartment(companyId.value, projectId.value, name) + ElMessage.success(`已请求跳过 ${name}`) + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '跳过失败') + } finally { controlPending.value = '' } + } + + async function resolveApproval(decision: 'approved' | 'rejected', feedback?: string) { + const gate = awaitingApproval.value?.gate + controlPending.value = 'approve' + try { + await approveGate(companyId.value, projectId.value, { decision, gate, feedback: feedback?.trim() || undefined }) + awaitingApproval.value = null + ElMessage.success(decision === 'approved' ? '已批准' : '已拒绝') + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '审批提交失败') + } finally { controlPending.value = '' } + } + + async function cancelDept(name: string) { + controlPending.value = 'cancel' + try { + await cancelDepartment(companyId.value, projectId.value, name) + ElMessage.success(`已请求取消 ${name}`) + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '取消失败') + } finally { controlPending.value = '' } + } + + async function reworkDept(name: string) { + controlPending.value = 'rework' + try { + await reworkDepartment(companyId.value, projectId.value, name) + ElMessage.success(`已请求重跑 ${name}`) + // 项目此前已完成、WS 已关;重连以拉取续写的返工事件(reset 后全量回放) + connect(companyId.value, projectId.value) + } catch (e: any) { + ElMessage.error(e?.response?.data?.detail || '重跑失败') + } finally { controlPending.value = '' } + } + + return { + // state + projectId, companyId, status, connected, finished, errorMsg, paused, controlPending, + awaitingApproval, terminatedReason, + companyName, departmentNames, ceoPlan, ceoScores, ceoSummary, rounds, currentRound, maxRounds, + depts, timeline, rawEvents, lastSeq, + // getters + deptList, tokensTotal, costTotal, runningDepts, dagOption, + // actions + connect, disconnect, applyEvent, pause, resume, stop, skipDept, resolveApproval, cancelDept, reworkDept, + } +}) + +function truncate(s: any, n: number): string { + const str = typeof s === 'string' ? s : (s == null ? '' : String(s)) + return str.length > n ? str.slice(0, n) + '…' : str +} + +const STATUS_COLOR: Record = { + waiting: '#e6a23c', running: '#409eff', done: '#67c23a', error: '#f56c6c', +} + +function buildDag(ceoPlan: any, depts: Record) { + const deptsPlan = ceoPlan?.departments || [] + const nodes: any[] = [{ name: 'CEO', symbolSize: 46, itemStyle: { color: '#909399' }, category: 0 }] + const links: any[] = [] + for (const dp of deptsPlan) { + const nm = dp.department_name || dp.department_type || '?' + const st = depts[nm]?.status || 'waiting' + nodes.push({ name: nm, symbolSize: 34, itemStyle: { color: STATUS_COLOR[st] || '#c0c4cc' } }) + const deps = Array.isArray(dp.dependencies) ? dp.dependencies : (dp.dependencies ? [dp.dependencies] : []) + if (!deps.length) { + links.push({ source: 'CEO', target: nm }) + } else { + for (const dep of deps) links.push({ source: dep, target: nm }) + } + } + return { + tooltip: {}, + series: [{ + type: 'graph', + layout: 'force', + roam: true, + label: { show: true, position: 'right', fontSize: 11 }, + force: { repulsion: 180, edgeLength: 90 }, + edgeSymbol: ['none', 'arrow'], + edgeSymbolSize: 8, + lineStyle: { color: '#bbb', curveness: 0.1 }, + data: nodes, + links, + }], + } +} diff --git a/frontend/src/utils/echarts.ts b/frontend/src/utils/echarts.ts new file mode 100644 index 0000000..6e18f32 --- /dev/null +++ b/frontend/src/utils/echarts.ts @@ -0,0 +1,24 @@ +// Tree-shaken ECharts setup — only register the pieces the app actually uses. +// Import `echarts` from this module instead of the full 'echarts' package. +import * as echarts from 'echarts/core' +import { BarChart, GraphChart } from 'echarts/charts' +import { + GridComponent, + TooltipComponent, + LegendComponent, + TitleComponent, +} from 'echarts/components' +import { CanvasRenderer } from 'echarts/renderers' + +echarts.use([ + BarChart, + GraphChart, + GridComponent, + TooltipComponent, + LegendComponent, + TitleComponent, + CanvasRenderer, +]) + +export { echarts } +export type { EChartsCoreOption } from 'echarts/core' diff --git a/frontend/src/views/CompanyBuilder.vue b/frontend/src/views/CompanyBuilder.vue index 5b49f82..628b44d 100644 --- a/frontend/src/views/CompanyBuilder.vue +++ b/frontend/src/views/CompanyBuilder.vue @@ -121,6 +121,16 @@ {{ dept.name }} {{ dept.department_type || '通用' }} {{ (dept.members || []).length }}人 + + {{ deptModelLabel(dept) }} +
后台执行 + + 进入白盒控制室 +
@@ -536,8 +554,9 @@ {{ row.status }} - + + + +
+ 为该部门指定统一模型(覆盖成员各自配置,用于成本/质量分层)。留空则清除、回归各成员 Agent 自身模型。 +
+ + + + + + + + + + + + + + +
+ @@ -941,6 +985,22 @@ + +
运行选项(M2-b:审批 / 熔断,默认全关)
+ + + + + + + + + + + + + +