Files

229 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ArchitectClient —— 大模型(API)客户端,端云协同的"决策/终审"角色。
职责(对齐《实现方案_v2》5.1 / 6.3):
- brief(query):开局任务分析 -> 生成交流文本的 briefgoal/constraints/acceptance/plan/tags
- decide(ws):读 issues 等 -> 输出裁决(reply + patch_plan 修订计划)
- final_review(ws):终审 -> {verdict: done|fix, issues: [...]}
工程约束(D7 / D8 / D9 / D11):
- 走 OpenAI 兼容 /chat/completionsresponse_format={"type":"json_object"}prompt 内嵌 schema 描述。
- Architect 输入永不包含工件全文:只传 Workspace 渲染出的 meta+issues+decisions+锚点片段(render_for_architect)。
- 所有结构化输出解析为 JSON;失败把错误回喂重写一次,仍失败抛 ArchitectError(由编排层降级,禁止带病继续)。
- token 计量回写 ws.meta.budget;调用前先查预算,触顶抛 ArchitectCircuitBreakerD6)。
- httpx 惰性导入(零顶层依赖,对齐仓库既有 APIExpert 模式);client/transport 可注入,测试用 httpx.MockTransportD11)。
"""
from __future__ import annotations
import json
from typing import Any, Callable, Dict, List, Optional
from .workspace import Workspace
# brief 的 JSON schema(描述性提示,约束模型输出结构)
_BRIEF_SCHEMA_HINT = {
"goal": "string(<=500字)",
"constraints": "string[](<=8条)",
"tags": "string[](code/math/legal/medical/finance/life/education/general/safety 之一)",
"acceptance": "list[{id, check(string), machine_checkable(bool)}]",
"plan": "list[{id, task(string<=300字), deps(string[]), done_criteria(string)}](<=5步, 有依赖序)",
}
_DECIDE_SCHEMA_HINT = {
"reply": "string(<=600字)",
"patch_plan": "list[{id, task(string)}]",
}
_REVIEW_SCHEMA_HINT = {
"verdict": "enum(done|fix)",
"notes": "string(<=300字)",
"fix_issues": "list[string]",
}
_SYSTEM_PROMPT = (
"你是任务分析架构师。你的输入是不含工件全文的协作摘要(交流文本),"
"你的输出必须是合法 JSON 对象(不要用 markdown 代码块包裹)。"
)
class ArchitectError(RuntimeError):
"""Architect 调用失败(网络/超时/JSON 解析失败/服务错误)。"""
class ArchitectCircuitBreaker(RuntimeError):
"""预算熔断(D6):api_token_cap / rounds_cap 触顶。"""
class ArchitectClient:
"""DeepSeek(或任意 OpenAI 兼容)大模型客户端。"""
def __init__(
self,
model: str,
base_url: str = "https://api.deepseek.com",
api_key: Optional[str] = None,
temperature: float = 0.2,
timeout_s: float = 60.0,
max_tokens: int = 2048,
transport: Any = None,
_client: Any = None,
):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.temperature = temperature
self.timeout_s = timeout_s
self.max_tokens = max_tokens
self._transport = transport
self._client = _client # 注入的 AsyncClient(测试用 MockTransport
self._owns_client = _client is None
def _get_client(self):
if self._client is None:
import httpx
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
if self._transport is not None:
kwargs["transport"] = self._transport
self._client = httpx.AsyncClient(**kwargs)
return self._client
async def aclose(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
# ---------------------------------------------------------------
# 三个对外能力
# ---------------------------------------------------------------
async def brief(self, query: str, ws: Workspace) -> Dict[str, Any]:
"""生成 brief。返回解析后的 brief dicttoken 计量写入 ws。"""
user = (
"用户原始需求:" + "\n" + query + "\n\n"
"请生成任务 brief,仅输出符合如下结构的 JSON 对象:" + "\n"
+ json.dumps(_BRIEF_SCHEMA_HINT, ensure_ascii=False)
+ "\n注意:plan 中的 id 用 s1..sndeps 引用已完成步骤 id"
"acceptance 尽量 machine_checkable。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def decide(self, ws: Workspace) -> Dict[str, Any]:
"""根据交流文本当前状态做裁决。返回 {reply, patch_plan}。"""
context = ws.render_for_architect()
user = (
"以下是交流文本摘要(不含工件全文):" + "\n\n" + context + "\n\n"
"请针对未解决 issues 做出裁决,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_DECIDE_SCHEMA_HINT, ensure_ascii=False)
+ "\nreply 给 Worker 具体可执行指示;patch_plan 列出需要修订的 step 与任务。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def final_review(self, ws: Workspace) -> Dict[str, Any]:
"""终审。返回 {verdict: done|fix, notes, fix_issues}。"""
context = ws.render_for_architect()
user = (
"以下是待终审的交流文本摘要:" + "\n\n" + context + "\n\n"
"对照 brief 的 acceptance 做终审,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False)
+ "\nverdict=done 表示验收通过;fix 表示打回,fix_issues 列出需修正项。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
# ---------------------------------------------------------------
# 底层
# ---------------------------------------------------------------
async def _chat_with_retry(self, ws: Workspace,
msgs: List[tuple]) -> Dict[str, Any]:
"""调用 + JSON 解析;解析失败回喂一次重写,再失败抛 ArchitectError。"""
if not self.api_key:
raise ArchitectError(
"Architect 未配置 API Keyenv: 见 config.architect.api_key_env)。"
"请设置密钥,或使用本地降级模式(pipeline.breach_policy: local_only)。"
)
messages = [{"role": r, "content": c} for r, c in msgs]
for attempt in (1, 2):
content = await self._chat_once(ws, messages)
try:
return self._parse_json(content)
except ValueError as e:
if attempt == 1:
# 4.6:把原始输出与错误回喂重写一次
messages = messages + [
{"role": "assistant", "content": content},
{"role": "user",
"content": f"你的输出不是合法 JSON{e})。请重新只输出合法 JSON 对象。"},
]
continue
raise ArchitectError(f"Architect 输出非合法 JSON,重试后仍失败: {e}") from e
raise ArchitectError("未预期:_chat_with_retry 未返回") # 不可达
async def _chat_once(self, ws: Workspace, messages: List[Dict[str, Any]]) -> str:
if ws.exhausted():
raise ArchitectCircuitBreaker(
f"预算熔断:api_tokens={ws.budget()['api_input_tokens'] + ws.budget()['api_output_tokens']}"
f"/{ws.budget()['api_token_cap']}, round={ws.meta()['round']}/{ws.budget()['rounds_cap']}"
)
client = self._get_client()
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
body = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"response_format": {"type": "json_object"},
}
try:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=body,
)
resp.raise_for_status()
except Exception as e:
raise ArchitectError(f"Architect API 调用失败: {type(e).__name__}: {e}") from e
data = resp.json()
usage = data.get("usage", {})
ws.add_budget(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError) as e:
raise ArchitectError(f"Architect 响应缺少 choices/content: {e}") from e
@staticmethod
def _parse_json(content: str) -> Dict[str, Any]:
"""从模型输出解析 JSON:剥除 markdown 代码围栏,取首个 JSON 对象。"""
text = content.strip()
_bt = "\x60" # 反引号(避免与构建脚本的字符串定界冲突)
fence = _bt * 3
text = text.replace(fence + "json", fence).replace(fence, "").strip()
try:
obj = json.loads(text)
if isinstance(obj, dict):
return obj
raise ValueError("顶层不是 object")
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
obj = json.loads(text[start:end + 1])
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
raise ValueError("无法解析为 JSON 对象")
def build_architect(cfg: Dict[str, Any],
get_env: Callable[[str], Optional[str]] = None) -> ArchitectClient:
"""cfg 为 config.architect 段。get_env 可注入(默认读 os.environ)。"""
import os
_env = get_env or os.environ.get
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
return ArchitectClient(
model=cfg.get("model", "deepseek-v4-flash"),
base_url=cfg.get("base_url", "https://api.deepseek.com"),
api_key=key,
temperature=float(cfg.get("temperature", 0.2)),
timeout_s=float(cfg.get("timeout_s", 60)),
)