feat(v2): T3 ArchitectClient(DeepSeek API,JSON 约束输出)
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
"""ArchitectClient —— 大模型(API)客户端,端云协同的"决策/终审"角色。
|
||||||
|
|
||||||
|
职责(对齐《实现方案_v2》5.1 / 6.3):
|
||||||
|
- brief(query):开局任务分析 -> 生成交流文本的 brief(goal/constraints/acceptance/plan/tags)
|
||||||
|
- decide(ws):读 issues 等 -> 输出裁决(reply + patch_plan 修订计划)
|
||||||
|
- final_review(ws):终审 -> {verdict: done|fix, issues: [...]}
|
||||||
|
|
||||||
|
工程约束(D7 / D8 / D9 / D11):
|
||||||
|
- 走 OpenAI 兼容 /chat/completions;response_format={"type":"json_object"},prompt 内嵌 schema 描述。
|
||||||
|
- Architect 输入永不包含工件全文:只传 Workspace 渲染出的 meta+issues+decisions+锚点片段(render_for_architect)。
|
||||||
|
- 所有结构化输出解析为 JSON;失败把错误回喂重写一次,仍失败抛 ArchitectError(由编排层降级,禁止带病继续)。
|
||||||
|
- token 计量回写 ws.meta.budget;调用前先查预算,触顶抛 ArchitectCircuitBreaker(D6)。
|
||||||
|
- httpx 惰性导入(零顶层依赖,对齐仓库既有 APIExpert 模式);client/transport 可注入,测试用 httpx.MockTransport(D11)。
|
||||||
|
"""
|
||||||
|
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/v1",
|
||||||
|
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 dict;token 计量写入 ws。"""
|
||||||
|
user = (
|
||||||
|
"用户原始需求:" + "\n" + query + "\n\n"
|
||||||
|
"请生成任务 brief,仅输出符合如下结构的 JSON 对象:" + "\n"
|
||||||
|
+ json.dumps(_BRIEF_SCHEMA_HINT, ensure_ascii=False)
|
||||||
|
+ "\n注意:plan 中的 id 用 s1..sn,deps 引用已完成步骤 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 Key(env: 见 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-chat"),
|
||||||
|
base_url=cfg.get("base_url", "https://api.deepseek.com/v1"),
|
||||||
|
api_key=key,
|
||||||
|
temperature=float(cfg.get("temperature", 0.2)),
|
||||||
|
timeout_s=float(cfg.get("timeout_s", 60)),
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from router_system.architect import (
|
||||||
|
ArchitectCircuitBreaker,
|
||||||
|
ArchitectClient,
|
||||||
|
ArchitectError,
|
||||||
|
build_architect,
|
||||||
|
)
|
||||||
|
from router_system.workspace import Workspace
|
||||||
|
|
||||||
|
BRIEF_JSON = json.dumps({
|
||||||
|
"goal": "实现快排",
|
||||||
|
"constraints": ["标准库"],
|
||||||
|
"tags": ["code"],
|
||||||
|
"acceptance": [{"id": "a1", "check": "排序正确", "machine_checkable": True}],
|
||||||
|
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "task": "修"}]}, ensure_ascii=False)
|
||||||
|
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(handler, api_key="test-key", **kw):
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
|
||||||
|
api_key=api_key, transport=transport, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _resp_json(content, usage=None):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": content}}],
|
||||||
|
"usage": usage or {"prompt_tokens": 100, "completion_tokens": 20},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ws(**kw):
|
||||||
|
return Workspace.new(request_id="a1b2c3d4e5f6", query=kw.get("query", "写个快排"),
|
||||||
|
api_token_cap=kw.get("cap", 8000), rounds_cap=kw.get("rounds", 6))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- brief 成功 ----------
|
||||||
|
def test_brief_success_records_budget():
|
||||||
|
calls = []
|
||||||
|
def handler(request):
|
||||||
|
calls.append(request.url.path)
|
||||||
|
return _resp_json(BRIEF_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
brief = asyncio_run(client.brief("写个快排", ws))
|
||||||
|
assert brief["goal"] == "实现快排"
|
||||||
|
assert brief["plan"][0]["id"] == "s1"
|
||||||
|
assert calls == ["/v1/chat/completions"]
|
||||||
|
# token 计量回写
|
||||||
|
assert ws.budget()["api_input_tokens"] == 100
|
||||||
|
assert ws.budget()["api_output_tokens"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 缺 key ----------
|
||||||
|
def test_no_key_raises():
|
||||||
|
client = ArchitectClient(model="deepseek-chat", api_key=None)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 坏 JSON 重试一次成功 ----------
|
||||||
|
def test_bad_json_retry_once_success():
|
||||||
|
seq = [{"body": "不是json{{", "ok": False}, {"body": BRIEF_JSON, "ok": True}]
|
||||||
|
calls = []
|
||||||
|
def handler(request):
|
||||||
|
calls.append(1)
|
||||||
|
item = seq[len(calls) - 1]
|
||||||
|
return _resp_json(item["body"])
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
brief = asyncio_run(client.brief("hi", ws))
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert brief["goal"] == "实现快排"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 坏 JSON 两次失败 ----------
|
||||||
|
def test_bad_json_twice_raises():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json("垃圾输出{")
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API 错误(非 2xx) ----------
|
||||||
|
def test_http_error_raises():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(500, text="server error")
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 缺 choices ----------
|
||||||
|
def test_missing_choices_raises():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={"usage": {}})
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 熔断:预算触顶,不再调用 transport ----------
|
||||||
|
def test_circuit_breaker_before_transport():
|
||||||
|
called = []
|
||||||
|
def handler(request):
|
||||||
|
called.append(1)
|
||||||
|
return _resp_json(BRIEF_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws(cap=1)
|
||||||
|
ws.add_budget(input_tokens=1, output_tokens=0) # used=1 >= cap=1
|
||||||
|
with pytest.raises(ArchitectCircuitBreaker):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
assert called == [] # 未触达 API
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- decide / final_review ----------
|
||||||
|
def test_decide():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json(DECIDE_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||||
|
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||||
|
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
|
||||||
|
out = asyncio_run(client.decide(ws))
|
||||||
|
assert out["reply"] == "改用断言"
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_review_done():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json(REVIEW_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||||
|
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||||
|
out = asyncio_run(client.final_review(ws))
|
||||||
|
assert out["verdict"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- build_architect 工厂 ----------
|
||||||
|
def test_build_architect_reads_env(monkeypatch):
|
||||||
|
cfg = {"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY"}
|
||||||
|
client = build_architect(cfg, get_env=lambda name: "sk-fake")
|
||||||
|
assert client.api_key == "sk-fake"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_architect_no_key():
|
||||||
|
cfg = {"api_key_env": "DEEPSEEK_API_KEY"}
|
||||||
|
client = build_architect(cfg, get_env=lambda name: None)
|
||||||
|
assert client.api_key is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 小工具 ----------
|
||||||
|
def asyncio_run(coro):
|
||||||
|
import asyncio
|
||||||
|
return asyncio.run(coro)
|
||||||
+1
-1
@@ -69,7 +69,7 @@ P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯
|
|||||||
|---|------|------|--------|
|
|---|------|------|--------|
|
||||||
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit) |
|
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit) |
|
||||||
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
|
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
|
||||||
| T3 | ArchitectClient(DeepSeek API,JSON 约束) | ⬜ | |
|
| T3 | ArchitectClient(DeepSeek API,JSON 约束) | ✅ 完成 | T3 |
|
||||||
| T4 | Workspace(交流文本 schema/校验/渲染/rollup) | ✅ 完成 | T4 |
|
| T4 | Workspace(交流文本 schema/校验/渲染/rollup) | ✅ 完成 | T4 |
|
||||||
| T5 | WorkerLoop + 接地验证 | ⬜ | |
|
| T5 | WorkerLoop + 接地验证 | ⬜ | |
|
||||||
| T6 | CollaborativePipeline 编排 | ⬜ | |
|
| T6 | CollaborativePipeline 编排 | ⬜ | |
|
||||||
|
|||||||
Reference in New Issue
Block a user