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:
+48
@@ -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"
|
||||||
|
}
|
||||||
+48
@@ -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"
|
||||||
|
}
|
||||||
+48
@@ -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"
|
||||||
|
}
|
||||||
+48
@@ -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"
|
||||||
|
}
|
||||||
+48
@@ -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"
|
||||||
|
}
|
||||||
+48
@@ -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
+169
@@ -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)
|
||||||
+212
@@ -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 + prefix:token 数同 A3;prefix_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. A3(rollup)收益为规模相关:小样例下 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())
|
||||||
+171
@@ -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)
|
||||||
+365
@@ -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()})
|
||||||
+471
@@ -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/https(file://、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
|
||||||
+152
@@ -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
|
||||||
+264
@@ -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}/health,2 秒超时;网络异常视为不健康。"""
|
||||||
|
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:
|
||||||
|
"""优雅停止:terminate(CTRL_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"),
|
||||||
|
)
|
||||||
+177
@@ -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.com(env 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))
|
||||||
+673
@@ -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()
|
||||||
+545
@@ -0,0 +1,545 @@
|
|||||||
|
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
|
||||||
|
|
||||||
|
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
|
||||||
|
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
|
||||||
|
|
||||||
|
本模块实现(对齐《实现方案_v2》第 4 节):
|
||||||
|
- WORKSPACE_SCHEMA:draft-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 Schema(draft-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
|
||||||
|
|
||||||
|
# ---------- rollup(4.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
|
||||||
+54
@@ -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/"
|
||||||
|
}
|
||||||
@@ -211,7 +211,7 @@ class LlamaManager:
|
|||||||
args.extend(extra_args)
|
args.extend(extra_args)
|
||||||
|
|
||||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
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:
|
try:
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
|
|||||||
@@ -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 规则执行器(零参数、确定性)
|
||||||
|
# - ModelNodeExecutor:L2 专家池小模型(≤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)")
|
||||||
@@ -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=value(value 支持 {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)
|
||||||
|
# 其他动作类型暂不实现(保留扩展位)
|
||||||
@@ -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()})
|
||||||
@@ -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)
|
||||||
@@ -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+disclaimer,medical 需要 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)})"]
|
||||||
@@ -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()
|
||||||
@@ -492,13 +492,11 @@ class Workspace:
|
|||||||
def save(self, path: Path) -> None:
|
def save(self, path: Path) -> None:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path) -> "Workspace":
|
def load(cls, path: Path) -> "Workspace":
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
data = json.load(f)
|
|
||||||
return cls(data)
|
return cls(data)
|
||||||
|
|
||||||
def prefix_signature(self) -> str:
|
def prefix_signature(self) -> str:
|
||||||
|
|||||||
+24
-6
@@ -14,12 +14,14 @@ LlamaServerManager 负责:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.parse
|
||||||
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
@@ -97,15 +99,31 @@ class LlamaServerManager:
|
|||||||
# 健康检查
|
# 健康检查
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
def _default_health_check(self, endpoint: str) -> bool:
|
def _default_health_check(self, endpoint: str) -> bool:
|
||||||
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。"""
|
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。
|
||||||
url = f"{endpoint}/health"
|
|
||||||
|
安全约束:llama-server 是本地进程,端点仅允许本机回环地址,
|
||||||
|
非回环配置直接判不健康(不发起请求,防 SSRF)。
|
||||||
|
"""
|
||||||
try:
|
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:
|
if resp.status != 200:
|
||||||
return False
|
return False
|
||||||
body = resp.read(200).decode("utf-8", errors="replace")
|
body = resp.read(200).decode("utf-8", errors="replace")
|
||||||
data = json.loads(body) if body else {}
|
finally:
|
||||||
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
|
conn.close()
|
||||||
|
data = json.loads(body) if body else {}
|
||||||
|
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
|
|||||||
|
|
||||||
# CSV
|
# CSV
|
||||||
csv_path = out / "E1_token_economics.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 = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||||
w.writeheader()
|
w.writeheader()
|
||||||
w.writerows(rows)
|
w.writerows(rows)
|
||||||
|
|||||||
@@ -115,9 +115,12 @@ def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
|
|||||||
break
|
break
|
||||||
if target is None:
|
if target is None:
|
||||||
return "zip 中未找到 llama-server.exe"
|
return "zip 中未找到 llama-server.exe"
|
||||||
|
# zip-slip 防护:拒绝绝对路径或含 .. 的成员名
|
||||||
|
if target.startswith(("/", "\\")) or ".." in Path(target).parts:
|
||||||
|
return "zip 内成员路径非法(疑似路径穿越)"
|
||||||
dest = bin_dir / "llama-server.exe"
|
dest = bin_dir / "llama-server.exe"
|
||||||
with zf.open(target) as src, open(dest, "wb") as out:
|
with zf.open(target) as src:
|
||||||
out.write(src.read())
|
dest.write_bytes(src.read())
|
||||||
return None
|
return None
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return f"解压失败: {type(e).__name__}: {e}"
|
return f"解压失败: {type(e).__name__}: {e}"
|
||||||
|
|||||||
Vendored
+10
-7
@@ -1,7 +1,7 @@
|
|||||||
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
|
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
|
||||||
|
|
||||||
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
|
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
|
||||||
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
|
- 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录)
|
||||||
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
|
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
|
||||||
- 进程被终止时正常退出
|
- 进程被终止时正常退出
|
||||||
"""
|
"""
|
||||||
@@ -10,6 +10,8 @@ import http.server
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -22,12 +24,13 @@ def main() -> int:
|
|||||||
parser.add_argument("-ctv", dest="ctv", default="")
|
parser.add_argument("-ctv", dest="ctv", default="")
|
||||||
args, _ = parser.parse_known_args()
|
args, _ = parser.parse_known_args()
|
||||||
|
|
||||||
marker = os.environ.get("FAKE_MARKER")
|
marker_name = os.environ.get("FAKE_MARKER_NAME")
|
||||||
if marker:
|
if marker_name:
|
||||||
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
|
# 环境变量仅传文件名(取 basename 防穿越),路径固定派生自系统临时目录
|
||||||
with open(marker, "w", encoding="utf-8") as f:
|
marker_path = Path(tempfile.gettempdir()) / Path(marker_name).name
|
||||||
json.dump({"pid": os.getpid(), "port": args.port,
|
marker_path.write_text(json.dumps({"pid": os.getpid(), "port": args.port,
|
||||||
"model": args.model, "args": sys.argv[1:]}, f)
|
"model": args.model, "args": sys.argv[1:]}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
|
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -147,7 +148,7 @@ def test_agent_model_from_pool(agent_env, client, monkeypatch):
|
|||||||
client.post("/pool", json={
|
client.post("/pool", json={
|
||||||
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
|
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
|
||||||
"base_url": "https://api.example.com", "model": "big-model-x",
|
"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"})
|
client.put("/pool/roles", json={"agent": "ag-1"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
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)
|
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)
|
transport = httpx.MockTransport(handler)
|
||||||
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
|
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):
|
def _resp_json(content, usage=None):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -26,9 +27,10 @@ def _make_fake_binary(tmp: Path) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _make_manager(tmp, binary, port, model, **kw):
|
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 = dict(os.environ)
|
||||||
env["FAKE_MARKER"] = str(marker)
|
env["FAKE_MARKER_NAME"] = marker.name
|
||||||
return LlamaServerManager(
|
return LlamaServerManager(
|
||||||
binary=str(binary),
|
binary=str(binary),
|
||||||
model=str(model),
|
model=str(model),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
|
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
|
||||||
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("fastapi")
|
pytest.importorskip("fastapi")
|
||||||
@@ -30,7 +32,7 @@ def _entry(**over):
|
|||||||
base = {
|
base = {
|
||||||
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
|
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
|
||||||
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
|
"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,
|
"enabled": True,
|
||||||
}
|
}
|
||||||
base.update(over)
|
base.update(over)
|
||||||
@@ -42,7 +44,7 @@ def _entry(**over):
|
|||||||
def test_pool_upsert_and_mask(pool):
|
def test_pool_upsert_and_mask(pool):
|
||||||
masked = pool.upsert(_entry())
|
masked = pool.upsert(_entry())
|
||||||
assert masked["api_key_set"] is True
|
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()
|
data = pool.list()
|
||||||
assert data["entries"][0]["model"] == "deepseek-v4-pro"
|
assert data["entries"][0]["model"] == "deepseek-v4-pro"
|
||||||
assert data["entries"][0]["api_key_set"] is True
|
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):
|
def test_pool_upsert_keeps_key_when_blank(pool):
|
||||||
pool.upsert(_entry())
|
pool.upsert(_entry())
|
||||||
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
|
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):
|
def test_pool_validation(pool):
|
||||||
@@ -95,7 +97,7 @@ def test_entry_cfg_mapping(pool):
|
|||||||
e = pool.get("prem-1") or _entry()
|
e = pool.get("prem-1") or _entry()
|
||||||
acfg = entry_to_architect_cfg(_entry())
|
acfg = entry_to_architect_cfg(_entry())
|
||||||
assert acfg["model"] == "deepseek-v4-pro"
|
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())
|
wcfg = entry_to_worker_cfg(_entry())
|
||||||
assert wcfg["backend"] == "openai"
|
assert wcfg["backend"] == "openai"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user