"""CollaborativePipeline —— 端云协同协作管线编排(v2 核心调度)。 流程(对齐《实现方案_v2》第 2/4.3 节): 用户 query -> [快路径] Worker 直答+自验证通过即返回 -> 否则 Architect.brief 写交流文本 -> 协作循环(Worker 实现/自验证, issue 时 Architect.decide 裁决)-> 全步完成 -> Architect.final_review -> 交付(入人工检验队列) 护栏(D6):rounds_cap / api_token_cap 任一触顶即熔断;熔断按 breach_policy 走 Architect 兜底代做(有 key)或本地降级提示(无 key)。 测试封闭性(D11):architect 用 httpx.MockTransport,worker 用假 generate; 不依赖真实 llama-server 或 API key。 """ from __future__ import annotations import time import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional from .architect import ArchitectCircuitBreaker, ArchitectClient, ArchitectError from .worker import WorkerLoop from .workspace import Workspace @dataclass class PipelineResult: """v2 协作管线一次运行的结果。""" query: str response: str request_id: str status: str # done | fast_path | escalated | failed fast_path: bool rounds_used: int api_input_tokens: int api_output_tokens: int cost_est: float model_used: str latency_ms: float route: List[str] = field(default_factory=list) workspace_path: Optional[str] = None error: Optional[str] = None class CollaborativePipeline: """编排快路径、协作循环、熔断、终审。""" def __init__( self, architect: ArchitectClient, worker: WorkerLoop, fast_path: bool = True, rounds_cap: int = 6, api_token_cap: int = 8000, breach_policy: str = "architect_do", # architect_do | local_only run_dir: str = "runs", ): self.architect = architect self.worker = worker self.fast_path = fast_path self.rounds_cap = rounds_cap self.api_token_cap = api_token_cap self.breach_policy = breach_policy self.run_dir = Path(run_dir) # --------------------------------------------------------------- # 入口 # --------------------------------------------------------------- async def run(self, query: str) -> PipelineResult: request_id = uuid.uuid4().hex[:12] ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap) route: List[str] = ["v2"] t0 = time.perf_counter() * 1000.0 # ---- 快路径:小模型直答 + 自验证(省 API 钱,D3) ---- if self.fast_path: direct = await self.worker.direct_answer(query) dom = self._guess_domain(query) passed, _det = self.worker.verifier.verify(dom, "answer.md", direct, query, kb=self.worker.kb) if passed: route.append(f"fast_path@domain:{dom}") self._save_artifact(request_id, "answer.md", direct) return self._finalize(query, ws, response=direct, status="fast_path", fast_path=True, route=route, model_used=self.worker.model_used, t0=t0, save_ws=False) route.append("fast_path:miss") # ---- brief(Architect 一次) ---- try: brief = await self.architect.brief(query, ws) ws.apply_brief(brief) route.append("brief") except (ArchitectError, ArchitectCircuitBreaker) as e: return await self._handle_breach(query, ws, route, t0, exc=e) # ---- 协作循环 ---- route.append("loop") plan = brief.get("plan") or [] pending = [p.get("id") for p in plan] while pending and not ws.exhausted(): progressed = False for sid in list(pending): step = next((p for p in plan if p.get("id") == sid), {}) if not self._deps_done(ws, step.get("deps") or []): continue existing = self._read_artifact(request_id, self._artifact_name(sid, ws)) outcome = await self.worker.run_step(ws, sid, existing_artifact=existing, hint=self._last_decision_for(ws, sid)) if outcome.status == "done": pending.remove(sid) self._save_artifact(request_id, outcome.artifact_name, outcome.artifact_text) ws.rollup() route.append(f"step:{sid}:done") progressed = True else: # issue -> Architect 裁决 route.append(f"step:{sid}:issue") try: dec = await self.architect.decide(ws) ws.add_decision(dec.get("ref") or outcome.issue_id or "", dec.get("reply", ""), dec.get("patch_plan")) self._apply_patch_plan(ws, dec.get("patch_plan")) route.append(f"decide:{sid}") except (ArchitectError, ArchitectCircuitBreaker) as e: return await self._handle_breach(query, ws, route, t0, exc=e) progressed = True ws.mark_round() if not progressed: # 死锁(依赖/裁决都推不动)-> 熔断兜底 return await self._handle_breach(query, ws, route, t0, exc=RuntimeError("协作循环死锁:无进度")) if ws.exhausted() and pending: return await self._handle_breach(query, ws, route, t0, exc=RuntimeError("预算/回合触顶")) # ---- 终审 ---- ws.transition("reviewing") route.append("reviewing") try: rev = await self.architect.final_review(ws) if rev.get("verdict") == "done": ws.transition("done") route.append("done") status = "done" else: # reviewing --fail--> in_progress(修正回合);MVP 返回 escalated 标记打回 ws.transition("in_progress") route.append("review:fix") status = "escalated" except (ArchitectError, ArchitectCircuitBreaker) as e: return await self._handle_breach(query, ws, route, t0, exc=e) response = self._build_response(ws, request_id) return self._finalize(query, ws, response=response, status=status, fast_path=False, route=route, model_used=self.architect.model, t0=t0) # --------------------------------------------------------------- # 熔断兜底 # --------------------------------------------------------------- async def _handle_breach(self, query: str, ws: Workspace, route: List[str], t0: float, exc: BaseException) -> PipelineResult: route.append("breach") if self.breach_policy == "architect_do" and self.architect.api_key: # 有 key:Architect 兜底代做 try: briefish = ws.render_for_architect() answer = await self.architect._chat_once( ws, [{"role": "user", "content": "以下任务自动升级,请直接给出最终可交付答案(非 JSON):" + briefish}]) route.append("breach:architect_do") return self._finalize(query, ws, response=answer, status="escalated", fast_path=False, route=route, model_used=self.architect.model, t0=t0, error=str(exc)) except Exception as e2: route.append(f"breach:architect_do:fail:{type(e2).__name__}") # 本地降级 route.append("breach:local_deg") msg = ("(本地降级)当前请求超出本地可处理范围,且未配置大模型密钥或预算熔断。" f"原因:{exc}") return self._finalize(query, ws, response=msg, status="failed", fast_path=False, route=route, model_used="none", t0=t0, error=str(exc)) # --------------------------------------------------------------- # 结果组装 # --------------------------------------------------------------- def _finalize(self, query: str, ws: Workspace, response: str, status: str, fast_path: bool, route: List[str], model_used: str, t0: float, save_ws: bool = True, error: Optional[str] = None) -> PipelineResult: if save_ws: path = self.run_dir / ws.request_id / "workspace.json" ws.save(path) ws_path = str(path) else: ws_path = None b = ws.budget() return PipelineResult( query=query, response=response, request_id=ws.request_id, status=status, fast_path=fast_path, rounds_used=ws.meta()["round"], api_input_tokens=b["api_input_tokens"], api_output_tokens=b["api_output_tokens"], cost_est=0.0, model_used=model_used, latency_ms=time.perf_counter() * 1000.0 - t0, route=route, workspace_path=ws_path, error=error, ) # --------------------------------------------------------------- # 辅助 # --------------------------------------------------------------- def _guess_domain(self, query: str) -> str: if self.worker.kb is not None: hits = self.worker.kb.match(query) if hits: return hits[0].domain return "general" def _artifact_name(self, sid: str, ws: Workspace) -> str: from .worker import artifact_name_for domain = self._guess_domain(ws["query"]) # 用 brief.tags 优先 tags = (ws.get("brief") or {}).get("tags") or [] for t in tags: if t != "safety": domain = t break return artifact_name_for(sid, domain) def _deps_done(self, ws: Workspace, deps: List[str]) -> bool: done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"} return all(d in done for d in deps) def _last_decision_for(self, ws: Workspace, sid: str) -> str: """取最近一条针对该 step 的决策 reply,作为 worker hint。""" step_issues = {i.get("id") for i in ws.get("issues", []) if i.get("step") == sid} for dec in reversed(ws.get("decisions", []) or []): if dec.get("ref") in step_issues: return dec.get("reply", "") return "" def _apply_patch_plan(self, ws: Workspace, patch_plan: Optional[List[Dict[str, Any]]]) -> None: if not patch_plan: return updates = {} for item in patch_plan: if isinstance(item, dict) and item.get("id") and item.get("task"): updates[item["id"]] = item["task"] if updates: ws.revise_plan(updates) def _build_response(self, ws: Workspace, request_id: str) -> str: # 汇总 archive 摘要 + 各已完成步骤工件 lines = list(ws.get("archive", []) or []) brief = ws.get("brief") or {} parts: List[str] = [] if brief.get("goal"): parts.append("任务:" + brief["goal"]) if lines: parts.append("完成情况:") parts.extend(f"- {ln}" for ln in lines) # 附上最后一个已完成步骤的工件全文 progress = ws.get("progress", []) or [] done_steps = [p for p in progress if p.get("status") == "done"] if done_steps: last = done_steps[-1] art = self._read_artifact(request_id, self._artifact_name(last["step"], ws)) if art: parts.append("产出:") parts.append(art) return "\n\n".join(parts) if parts else "(协作管线未产出有效内容)" def _save_artifact(self, request_id: str, name: str, text: str) -> None: if not text: return d = self.run_dir / request_id / "artifacts" d.mkdir(parents=True, exist_ok=True) (d / name).write_text(text, encoding="utf-8") def _read_artifact(self, request_id: str, name: str) -> str: p = self.run_dir / request_id / "artifacts" / name if p.exists(): return p.read_text(encoding="utf-8") return "" def build_pipeline(cfg: Dict[str, Any], architect: ArchitectClient, worker: WorkerLoop) -> CollaborativePipeline: """cfg 为 config.pipeline 段。""" p = cfg.get("pipeline", {}) return CollaborativePipeline( architect=architect, worker=worker, fast_path=bool(p.get("fast_path", True)), rounds_cap=int(p.get("rounds_cap", 6)), api_token_cap=int(p.get("api_token_cap", 8000)), breach_policy=p.get("breach_policy", "architect_do"), run_dir=p.get("run_dir", "runs"), )