From e9cfb29b7573e335eb83fb5cb6cf74d46f9ecd8c Mon Sep 17 00:00:00 2001 From: tzt <14718231+flying-travel@user.noreply.gitee.com> Date: Fri, 18 Sep 2026 08:01:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20=E8=A1=A5=E5=9B=9E=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E7=9A=84=20v1=20=E9=81=97=E7=95=99=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20+=20=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=8C?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=20219=20=E5=85=A8=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基线修复(快照离线不可运行的根因): - 从 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 --- ...luse-767b367befcc871716f91b13cd50a073.json | 48 ++ ...luse-88124722434ad6b573833b6c3b8ebab8.json | 48 ++ ...luse-9d3ac78d3aa324afb6d8641b34112f8a.json | 48 ++ ...luse-178c349f284c123950cbc666e7733ed8.json | 48 ++ ...luse-a836605e193d942d79b2e2928b028994.json | 48 ++ ...luse-c6c45616a9deccc592131e75cb61cc90.json | 48 ++ ..._20ca2118-289f-4ce0-b403-6443a67bb008.json | 1 + ...d50b77d9161e2249a81422cfa4c5bf17521.source | 169 +++++ ...12f4eebce5617949e256c8c6dfad9b7dff1.source | 212 ++++++ ...f8e71cd55699eaafb889a59bfbd6e497543.source | 171 +++++ ...984c8e41131d5967489143a85d6d68f5316.source | 365 ++++++++++ ...cb369edec6939bd77beed5102a71c2f472c.source | 471 ++++++++++++ ...95e8179475d3301df05bdebba5d6cc67dce.source | 152 ++++ ...7685724def84569be40e4cf8238c30ff1e1.source | 264 +++++++ ...66817f7c685f8582d57abd8e34247752536.source | 177 +++++ ...e89baf08f3c00efbb374e554b601765dac6.source | 673 ++++++++++++++++++ ...47a0318e61edba0627789d78814893f4f1c.source | 545 ++++++++++++++ ...d7788bf9581931a5bb642c421c05e173ca6.source | 54 ++ ...89f-4ce0-b403-6443a67bb008-60e74fe372.json | 14 + gateway/llama_manager.py | 2 +- router_system/executors.py | 370 ++++++++++ router_system/inference.py | 93 +++ router_system/knowledge.py | 490 +++++++++++++ router_system/memory.py | 128 ++++ router_system/planner.py | 103 +++ router_system/trace.py | 49 ++ router_system/workspace.py | 6 +- runtime/llama_server.py | 30 +- scripts/bench_tokens.py | 2 +- scripts/setup_runtime.py | 7 +- tests/fixtures/fake_llama_server.py | 17 +- tests/test_agent_api.py | 3 +- tests/test_architect.py | 6 +- tests/test_llama_server.py | 6 +- tests/test_model_pool.py | 10 +- 35 files changed, 4848 insertions(+), 30 deletions(-) create mode 100644 .mimosa/finding-ledger/v1/events/batch-posttooluse-767b367befcc871716f91b13cd50a073.json create mode 100644 .mimosa/finding-ledger/v1/events/batch-posttooluse-88124722434ad6b573833b6c3b8ebab8.json create mode 100644 .mimosa/finding-ledger/v1/events/batch-posttooluse-9d3ac78d3aa324afb6d8641b34112f8a.json create mode 100644 .mimosa/finding-ledger/v1/events/batch-pretooluse-178c349f284c123950cbc666e7733ed8.json create mode 100644 .mimosa/finding-ledger/v1/events/batch-pretooluse-a836605e193d942d79b2e2928b028994.json create mode 100644 .mimosa/finding-ledger/v1/events/batch-pretooluse-c6c45616a9deccc592131e75cb61cc90.json create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.json create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/0d31412198b34c4ea1ec6e4d73a21d50b77d9161e2249a81422cfa4c5bf17521.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/19d371089d7ea6b4b1d48dbbc163912f4eebce5617949e256c8c6dfad9b7dff1.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/264f95f5bafeac6ecc274660dfd76f8e71cd55699eaafb889a59bfbd6e497543.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4c40d4177b63e85f2d1852dab8558984c8e41131d5967489143a85d6d68f5316.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4ef83a41c9213c2def3b2c534133acb369edec6939bd77beed5102a71c2f472c.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/5495baf167d4fb28e30a178cfd3d395e8179475d3301df05bdebba5d6cc67dce.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/6341fece0a27e38025e2ffa81e1f67685724def84569be40e4cf8238c30ff1e1.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/815c7b4276409a596f6a6f5d4c1b266817f7c685f8582d57abd8e34247752536.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/8ac8608e0496bc8ede2ae3e1ab033e89baf08f3c00efbb374e554b601765dac6.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/99a753c2aaef29ce1b51f8d533a5c47a0318e61edba0627789d78814893f4f1c.source create mode 100644 .mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/f8d1770c641cf6c9209f921bc9be7d7788bf9581931a5bb642c421c05e173ca6.source create mode 100644 .mimosa/hook-status/sess_20ca2118-289f-4ce0-b403-6443a67bb008-60e74fe372.json create mode 100644 router_system/executors.py create mode 100644 router_system/inference.py create mode 100644 router_system/knowledge.py create mode 100644 router_system/memory.py create mode 100644 router_system/planner.py create mode 100644 router_system/trace.py diff --git a/.mimosa/finding-ledger/v1/events/batch-posttooluse-767b367befcc871716f91b13cd50a073.json b/.mimosa/finding-ledger/v1/events/batch-posttooluse-767b367befcc871716f91b13cd50a073.json new file mode 100644 index 0000000..93020e4 --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-posttooluse-767b367befcc871716f91b13cd50a073.json @@ -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" +} diff --git a/.mimosa/finding-ledger/v1/events/batch-posttooluse-88124722434ad6b573833b6c3b8ebab8.json b/.mimosa/finding-ledger/v1/events/batch-posttooluse-88124722434ad6b573833b6c3b8ebab8.json new file mode 100644 index 0000000..4db33ab --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-posttooluse-88124722434ad6b573833b6c3b8ebab8.json @@ -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" +} diff --git a/.mimosa/finding-ledger/v1/events/batch-posttooluse-9d3ac78d3aa324afb6d8641b34112f8a.json b/.mimosa/finding-ledger/v1/events/batch-posttooluse-9d3ac78d3aa324afb6d8641b34112f8a.json new file mode 100644 index 0000000..3a566a3 --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-posttooluse-9d3ac78d3aa324afb6d8641b34112f8a.json @@ -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" +} diff --git a/.mimosa/finding-ledger/v1/events/batch-pretooluse-178c349f284c123950cbc666e7733ed8.json b/.mimosa/finding-ledger/v1/events/batch-pretooluse-178c349f284c123950cbc666e7733ed8.json new file mode 100644 index 0000000..3725d6b --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-pretooluse-178c349f284c123950cbc666e7733ed8.json @@ -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" +} diff --git a/.mimosa/finding-ledger/v1/events/batch-pretooluse-a836605e193d942d79b2e2928b028994.json b/.mimosa/finding-ledger/v1/events/batch-pretooluse-a836605e193d942d79b2e2928b028994.json new file mode 100644 index 0000000..e5c6bc4 --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-pretooluse-a836605e193d942d79b2e2928b028994.json @@ -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" +} diff --git a/.mimosa/finding-ledger/v1/events/batch-pretooluse-c6c45616a9deccc592131e75cb61cc90.json b/.mimosa/finding-ledger/v1/events/batch-pretooluse-c6c45616a9deccc592131e75cb61cc90.json new file mode 100644 index 0000000..edf7c58 --- /dev/null +++ b/.mimosa/finding-ledger/v1/events/batch-pretooluse-c6c45616a9deccc592131e75cb61cc90.json @@ -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" +} diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.json b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.json new file mode 100644 index 0000000..bf0973b --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.json @@ -0,0 +1 @@ +{"touched":["E:\\projectAIpopular\\.wt\\v2\\router_system\\knowledge.py","E:\\projectAIpopular\\.wt\\v2\\tests\\test_architect.py","E:\\projectAIpopular\\.wt\\v2\\tests\\test_agent_api.py","E:\\projectAIpopular\\.wt\\v2\\tests\\test_model_pool.py","E:\\projectAIpopular\\.wt\\v2\\tests\\fixtures\\fake_llama_server.py","E:\\projectAIpopular\\.wt\\v2\\tests\\test_llama_server.py","E:\\projectAIpopular\\.wt\\v2\\scripts\\bench_tokens.py","E:\\projectAIpopular\\.wt\\v2\\scripts\\setup_runtime.py","E:\\projectAIpopular\\.wt\\v2\\gateway\\llama_manager.py","E:\\projectAIpopular\\.wt\\v2\\router_system\\workspace.py","E:\\projectAIpopular\\.wt\\v2\\runtime\\llama_server.py"],"bashMutation":true,"reportedFindings":["mimosa-02d0ec0762b225cf993a6ff7","mimosa-5622d714819d32333495b452","mimosa-8efd8b70127b6526d2f19675"],"findingEvents":[{"finding_id":"mimosa-02d0ec0762b225cf993a6ff7","file":"tests/test_agent_api.py","line":150,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7","source":"semgrep"},"status":"blocked","decision":"deny","at":"2026-09-17T23:52:32.321Z"},{"finding_id":"mimosa-5622d714819d32333495b452","file":"tests/test_model_pool.py","line":33,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b","source":"semgrep"},"status":"blocked","decision":"deny","at":"2026-09-17T23:52:33.594Z"},{"finding_id":"mimosa-02d0ec0762b225cf993a6ff7","file":"tests/test_agent_api.py","line":150,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7","source":"semgrep","candidate_before_line_hash":"3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7","post_change_line_hash_at_previous_location":"fa1b25c7109499c88c51c57fbbd841f24df58df362f3d4b36ab987f44b0a56f3","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"fixed_candidate","decision":"repair_observed","at":"2026-09-17T23:52:59.123Z"},{"finding_id":"mimosa-02d0ec0762b225cf993a6ff7","file":"tests/test_agent_api.py","line":150,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7","source":"semgrep","candidate_before_line_hash":"3c7ea9a310eed938e288dd28b887685eec4cea91c781aab0b1ede5a440df7de7","post_change_line_hash_at_previous_location":"fa1b25c7109499c88c51c57fbbd841f24df58df362f3d4b36ab987f44b0a56f3","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"static_verified","decision":"static_rescan_passed","at":"2026-09-17T23:52:59.123Z"},{"finding_id":"mimosa-5622d714819d32333495b452","file":"tests/test_model_pool.py","line":33,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b","source":"semgrep","candidate_before_line_hash":"d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b","post_change_line_hash_at_previous_location":"792d7c69a01c6e9a5920f00b57c6c051018a42338414ba7c9416760f513a27a3","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"fixed_candidate","decision":"repair_observed","at":"2026-09-17T23:53:01.436Z"},{"finding_id":"mimosa-5622d714819d32333495b452","file":"tests/test_model_pool.py","line":33,"rule_id":"security","severity":"high","category":"硬编码凭据","evidence":{"type":"static_observation","line_hash":"d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b","source":"semgrep","candidate_before_line_hash":"d1ae8ac61aed1eb48aebc0ca9147f945256ac92279aa07edf1c05683aebf308b","post_change_line_hash_at_previous_location":"792d7c69a01c6e9a5920f00b57c6c051018a42338414ba7c9416760f513a27a3","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"static_verified","decision":"static_rescan_passed","at":"2026-09-17T23:53:01.436Z"},{"finding_id":"mimosa-8efd8b70127b6526d2f19675","file":"tests/fixtures/fake_llama_server.py","line":7,"rule_id":"security","severity":"high","category":"路径穿越","evidence":{"type":"static_observation","line_hash":"c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9","source":"semgrep"},"status":"blocked","decision":"deny","at":"2026-09-17T23:53:16.680Z"},{"finding_id":"mimosa-8efd8b70127b6526d2f19675","file":"tests/fixtures/fake_llama_server.py","line":7,"rule_id":"security","severity":"high","category":"路径穿越","evidence":{"type":"static_observation","line_hash":"c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9","source":"semgrep","candidate_before_line_hash":"c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9","post_change_line_hash_at_previous_location":"b6d267efe80a3dbcf2f2d67e21d8c15783a2f031427f827d43b6222fe9c4bf02","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"fixed_candidate","decision":"repair_observed","at":"2026-09-17T23:54:44.996Z"},{"finding_id":"mimosa-8efd8b70127b6526d2f19675","file":"tests/fixtures/fake_llama_server.py","line":7,"rule_id":"security","severity":"high","category":"路径穿越","evidence":{"type":"static_observation","line_hash":"c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9","source":"semgrep","candidate_before_line_hash":"c9f0ead053f8936753da2c98e6a0254523e4b644dbd11bb5dc2ced067dcfb5b9","post_change_line_hash_at_previous_location":"b6d267efe80a3dbcf2f2d67e21d8c15783a2f031427f827d43b6222fe9c4bf02","verification":{"method":"mimosa-posttool-static-rescan","finding_absent":true,"runtime_oracle":false}},"status":"static_verified","decision":"static_rescan_passed","at":"2026-09-17T23:54:44.996Z"}],"baseline":{"storageId":"mu66h6qh-25536-3b6e82a04c","createdAt":"2026-09-17T23:45:56.825Z","files":{"router_system/knowledge.py":{"existed":true,"snapshot":"4c40d4177b63e85f2d1852dab8558984c8e41131d5967489143a85d6d68f5316.source"},"tests/test_agent_api.py":{"existed":true,"snapshot":"8ac8608e0496bc8ede2ae3e1ab033e89baf08f3c00efbb374e554b601765dac6.source"},"tests/test_model_pool.py":{"existed":true,"snapshot":"264f95f5bafeac6ecc274660dfd76f8e71cd55699eaafb889a59bfbd6e497543.source"},"tests/test_architect.py":{"existed":true,"snapshot":"0d31412198b34c4ea1ec6e4d73a21d50b77d9161e2249a81422cfa4c5bf17521.source"},"tests/fixtures/fake_llama_server.py":{"existed":true,"snapshot":"f8d1770c641cf6c9209f921bc9be7d7788bf9581931a5bb642c421c05e173ca6.source"},"tests/test_llama_server.py":{"existed":true,"snapshot":"5495baf167d4fb28e30a178cfd3d395e8179475d3301df05bdebba5d6cc67dce.source"},"scripts/bench_tokens.py":{"existed":true,"snapshot":"19d371089d7ea6b4b1d48dbbc163912f4eebce5617949e256c8c6dfad9b7dff1.source"},"scripts/setup_runtime.py":{"existed":true,"snapshot":"815c7b4276409a596f6a6f5d4c1b266817f7c685f8582d57abd8e34247752536.source"},"gateway/llama_manager.py":{"existed":true,"snapshot":"4ef83a41c9213c2def3b2c534133acb369edec6939bd77beed5102a71c2f472c.source"},"router_system/workspace.py":{"existed":true,"snapshot":"99a753c2aaef29ce1b51f8d533a5c47a0318e61edba0627789d78814893f4f1c.source"},"runtime/llama_server.py":{"existed":true,"snapshot":"6341fece0a27e38025e2ffa81e1f67685724def84569be40e4cf8238c30ff1e1.source"}},"complete":false,"candidateLimit":5000,"discoveredFiles":0,"capturedFiles":11,"truncated":false,"omittedAtLeast":0,"firstOmitted":"","errors":[{"stage":"baseline-capture","target":".","reason":"global task baseline was unavailable; captured only the touched file"}]},"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-18T00:00:35.431Z"} \ No newline at end of file diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/0d31412198b34c4ea1ec6e4d73a21d50b77d9161e2249a81422cfa4c5bf17521.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/0d31412198b34c4ea1ec6e4d73a21d50b77d9161e2249a81422cfa4c5bf17521.source new file mode 100644 index 0000000..08ef8d9 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/0d31412198b34c4ea1ec6e4d73a21d50b77d9161e2249a81422cfa4c5bf17521.source @@ -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) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/19d371089d7ea6b4b1d48dbbc163912f4eebce5617949e256c8c6dfad9b7dff1.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/19d371089d7ea6b4b1d48dbbc163912f4eebce5617949e256c8c6dfad9b7dff1.source new file mode 100644 index 0000000..a26e799 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/19d371089d7ea6b4b1d48dbbc163912f4eebce5617949e256c8c6dfad9b7dff1.source @@ -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()) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/264f95f5bafeac6ecc274660dfd76f8e71cd55699eaafb889a59bfbd6e497543.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/264f95f5bafeac6ecc274660dfd76f8e71cd55699eaafb889a59bfbd6e497543.source new file mode 100644 index 0000000..adf311c --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/264f95f5bafeac6ecc274660dfd76f8e71cd55699eaafb889a59bfbd6e497543.source @@ -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) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4c40d4177b63e85f2d1852dab8558984c8e41131d5967489143a85d6d68f5316.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4c40d4177b63e85f2d1852dab8558984c8e41131d5967489143a85d6d68f5316.source new file mode 100644 index 0000000..da35bd0 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4c40d4177b63e85f2d1852dab8558984c8e41131d5967489143a85d6d68f5316.source @@ -0,0 +1,365 @@ +"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。 + +设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"): +- 领域知识显式化:写在规则文件里(config/knowledge/.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()}) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4ef83a41c9213c2def3b2c534133acb369edec6939bd77beed5102a71c2f472c.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4ef83a41c9213c2def3b2c534133acb369edec6939bd77beed5102a71c2f472c.source new file mode 100644 index 0000000..d1a0d56 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/4ef83a41c9213c2def3b2c534133acb369edec6939bd77beed5102a71c2f472c.source @@ -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//resolve/main/" + + 支持断点续传(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 diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/5495baf167d4fb28e30a178cfd3d395e8179475d3301df05bdebba5d6cc67dce.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/5495baf167d4fb28e30a178cfd3d395e8179475d3301df05bdebba5d6cc67dce.source new file mode 100644 index 0000000..b59eb6b --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/5495baf167d4fb28e30a178cfd3d395e8179475d3301df05bdebba5d6cc67dce.source @@ -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 diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/6341fece0a27e38025e2ffa81e1f67685724def84569be40e4cf8238c30ff1e1.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/6341fece0a27e38025e2ffa81e1f67685724def84569be40e4cf8238c30ff1e1.source new file mode 100644 index 0000000..bcb023c --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/6341fece0a27e38025e2ffa81e1f67685724def84569be40e4cf8238c30ff1e1.source @@ -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"), + ) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/815c7b4276409a596f6a6f5d4c1b266817f7c685f8582d57abd8e34247752536.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/815c7b4276409a596f6a6f5d4c1b266817f7c685f8582d57abd8e34247752536.source new file mode 100644 index 0000000..a030b77 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/815c7b4276409a596f6a6f5d4c1b266817f7c685f8582d57abd8e34247752536.source @@ -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)) diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/8ac8608e0496bc8ede2ae3e1ab033e89baf08f3c00efbb374e554b601765dac6.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/8ac8608e0496bc8ede2ae3e1ab033e89baf08f3c00efbb374e554b601765dac6.source new file mode 100644 index 0000000..103fa5d --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/8ac8608e0496bc8ede2ae3e1ab033e89baf08f3c00efbb374e554b601765dac6.source @@ -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() diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/99a753c2aaef29ce1b51f8d533a5c47a0318e61edba0627789d78814893f4f1c.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/99a753c2aaef29ce1b51f8d533a5c47a0318e61edba0627789d78814893f4f1c.source new file mode 100644 index 0000000..83f93f0 --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/99a753c2aaef29ce1b51f8d533a5c47a0318e61edba0627789d78814893f4f1c.source @@ -0,0 +1,545 @@ +"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。 + +大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份 +schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。 + +本模块实现(对齐《实现方案_v2》第 4 节): +- WORKSPACE_SCHEMA:draft-07 风格 schema 常量(文档/校验依据) +- validate():结构 + 字段长度校验(写入前必过,D2/D9) +- 锚点寻址:a://#L-(引用工件片段,替代全文复制) +- 双渲染函数: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://#L-;end 缺省仅 L。""" + if end is None: + return f"a://{filename}#L{start}" + return f"a://{filename}#L{start}-{end}" + + +_ANCHOR_RE = re.compile(r"^a://(?P[^#]+?)(?:#L(?P\d+)(?:-(?P\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 diff --git a/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/f8d1770c641cf6c9209f921bc9be7d7788bf9581931a5bb642c421c05e173ca6.source b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/f8d1770c641cf6c9209f921bc9be7d7788bf9581931a5bb642c421c05e173ca6.source new file mode 100644 index 0000000..edc3bed --- /dev/null +++ b/.mimosa/hook-state/sess_20ca2118-289f-4ce0-b403-6443a67bb008.mu66h6qh-25536-3b6e82a04c.baseline/f8d1770c641cf6c9209f921bc9be7d7788bf9581931a5bb642c421c05e173ca6.source @@ -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()) diff --git a/.mimosa/hook-status/sess_20ca2118-289f-4ce0-b403-6443a67bb008-60e74fe372.json b/.mimosa/hook-status/sess_20ca2118-289f-4ce0-b403-6443a67bb008-60e74fe372.json new file mode 100644 index 0000000..a1d2159 --- /dev/null +++ b/.mimosa/hook-status/sess_20ca2118-289f-4ce0-b403-6443a67bb008-60e74fe372.json @@ -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/" +} diff --git a/gateway/llama_manager.py b/gateway/llama_manager.py index d1a0d56..b8482e0 100644 --- a/gateway/llama_manager.py +++ b/gateway/llama_manager.py @@ -211,7 +211,7 @@ class LlamaManager: args.extend(extra_args) LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1) + log_f = LOG_FILE.open("w", encoding="utf-8", buffering=1) try: self._proc = subprocess.Popen( diff --git a/router_system/executors.py b/router_system/executors.py new file mode 100644 index 0000000..21ea008 --- /dev/null +++ b/router_system/executors.py @@ -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)") diff --git a/router_system/inference.py b/router_system/inference.py new file mode 100644 index 0000000..7d7d826 --- /dev/null +++ b/router_system/inference.py @@ -0,0 +1,93 @@ +"""前向链推理机:知识库规则驱动的工作记忆演化(专家系统推理核心,零依赖)。 + +流程(经典前向链 forward chaining): +1. 初始化黑板:写入领域/难度/置信度等事实 +2. 循环:在领域内匹配规则(未触发过的)→ 按优先级执行 + - 命中即记录轨迹 rule:@ + - 规则带 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.} 占位符;缺失以 [未提供] 占位,不抛异常。""" + 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) + # 其他动作类型暂不实现(保留扩展位) diff --git a/router_system/knowledge.py b/router_system/knowledge.py new file mode 100644 index 0000000..cce20a0 --- /dev/null +++ b/router_system/knowledge.py @@ -0,0 +1,490 @@ +"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。 + +设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"): +- 领域知识显式化:写在规则文件里(config/knowledge/.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()}) diff --git a/router_system/memory.py b/router_system/memory.py new file mode 100644 index 0000000..af79f88 --- /dev/null +++ b/router_system/memory.py @@ -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) diff --git a/router_system/planner.py b/router_system/planner.py new file mode 100644 index 0000000..41e9a62 --- /dev/null +++ b/router_system/planner.py @@ -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)})"] diff --git a/router_system/trace.py b/router_system/trace.py new file mode 100644 index 0000000..e7d09a4 --- /dev/null +++ b/router_system/trace.py @@ -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() diff --git a/router_system/workspace.py b/router_system/workspace.py index 83f93f0..ac501bc 100644 --- a/router_system/workspace.py +++ b/router_system/workspace.py @@ -492,13 +492,11 @@ class Workspace: def save(self, path: Path) -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(self._data, f, ensure_ascii=False, indent=2) + path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8") @classmethod def load(cls, path: Path) -> "Workspace": - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) + data = json.loads(Path(path).read_text(encoding="utf-8")) return cls(data) def prefix_signature(self) -> str: diff --git a/runtime/llama_server.py b/runtime/llama_server.py index bcb023c..2376c72 100644 --- a/runtime/llama_server.py +++ b/runtime/llama_server.py @@ -14,12 +14,14 @@ LlamaServerManager 负责: from __future__ import annotations import datetime +import http.client import json import os import subprocess import sys import time -import urllib.request +import urllib.parse +import urllib.parse from pathlib import Path from typing import Any, Callable, Dict, List, Optional @@ -97,15 +99,31 @@ class LlamaServerManager: # 健康检查 # --------------------------------------------------------------- def _default_health_check(self, endpoint: str) -> bool: - """GET {endpoint}/health,2 秒超时;网络异常视为不健康。""" - url = f"{endpoint}/health" + """GET {endpoint}/health,2 秒超时;网络异常视为不健康。 + + 安全约束:llama-server 是本地进程,端点仅允许本机回环地址, + 非回环配置直接判不健康(不发起请求,防 SSRF)。 + """ try: - with urllib.request.urlopen(url, timeout=2.0) as resp: + parsed = urllib.parse.urlparse(endpoint) + host = (parsed.hostname or "").lower() + port = parsed.port or 80 + except ValueError: + return False + if host not in ("127.0.0.1", "localhost", "::1"): + return False + try: + conn = http.client.HTTPConnection(host, port, timeout=2.0) + try: + conn.request("GET", f"{parsed.path or ''}/health") + resp = conn.getresponse() if resp.status != 200: return False body = resp.read(200).decode("utf-8", errors="replace") - data = json.loads(body) if body else {} - return data.get("status", "").lower() == "ok" or "llama" in body.lower() + finally: + conn.close() + data = json.loads(body) if body else {} + return data.get("status", "").lower() == "ok" or "llama" in body.lower() except Exception: return False diff --git a/scripts/bench_tokens.py b/scripts/bench_tokens.py index a26e799..918a050 100644 --- a/scripts/bench_tokens.py +++ b/scripts/bench_tokens.py @@ -139,7 +139,7 @@ def run(data_path: str, out_dir: str, n_steps: int = 3) -> None: # CSV csv_path = out / "E1_token_economics.csv" - with open(csv_path, "w", newline="", encoding="utf-8") as f: + with csv_path.open("w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w.writeheader() w.writerows(rows) diff --git a/scripts/setup_runtime.py b/scripts/setup_runtime.py index a030b77..f31ee1d 100644 --- a/scripts/setup_runtime.py +++ b/scripts/setup_runtime.py @@ -115,9 +115,12 @@ def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]: break if target is None: return "zip 中未找到 llama-server.exe" + # zip-slip 防护:拒绝绝对路径或含 .. 的成员名 + if target.startswith(("/", "\\")) or ".." in Path(target).parts: + return "zip 内成员路径非法(疑似路径穿越)" dest = bin_dir / "llama-server.exe" - with zf.open(target) as src, open(dest, "wb") as out: - out.write(src.read()) + with zf.open(target) as src: + dest.write_bytes(src.read()) return None except Exception as e: # noqa: BLE001 return f"解压失败: {type(e).__name__}: {e}" diff --git a/tests/fixtures/fake_llama_server.py b/tests/fixtures/fake_llama_server.py index edc3bed..4785e3a 100644 --- a/tests/fixtures/fake_llama_server.py +++ b/tests/fixtures/fake_llama_server.py @@ -1,7 +1,7 @@ """测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。 - 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐) -- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件 +- 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录) - 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"} - 进程被终止时正常退出 """ @@ -10,6 +10,8 @@ import http.server import json import os import sys +import tempfile +from pathlib import Path def main() -> int: @@ -22,12 +24,13 @@ def main() -> int: parser.add_argument("-ctv", dest="ctv", default="") args, _ = parser.parse_known_args() - marker = os.environ.get("FAKE_MARKER") - if marker: - os.makedirs(os.path.dirname(marker) or ".", exist_ok=True) - with open(marker, "w", encoding="utf-8") as f: - json.dump({"pid": os.getpid(), "port": args.port, - "model": args.model, "args": sys.argv[1:]}, f) + marker_name = os.environ.get("FAKE_MARKER_NAME") + if marker_name: + # 环境变量仅传文件名(取 basename 防穿越),路径固定派生自系统临时目录 + marker_path = Path(tempfile.gettempdir()) / Path(marker_name).name + marker_path.write_text(json.dumps({"pid": os.getpid(), "port": args.port, + "model": args.model, "args": sys.argv[1:]}), + encoding="utf-8") class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): diff --git a/tests/test_agent_api.py b/tests/test_agent_api.py index 103fa5d..3aeb447 100644 --- a/tests/test_agent_api.py +++ b/tests/test_agent_api.py @@ -1,5 +1,6 @@ """智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。""" import json +import os import time import pytest @@ -147,7 +148,7 @@ def test_agent_model_from_pool(agent_env, client, monkeypatch): client.post("/pool", json={ "id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai", "base_url": "https://api.example.com", "model": "big-model-x", - "api_key": "sk-abc1234567", "enabled": True, + "api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "enabled": True, }) client.put("/pool/roles", json={"agent": "ag-1"}) diff --git a/tests/test_architect.py b/tests/test_architect.py index 08ef8d9..49d4162 100644 --- a/tests/test_architect.py +++ b/tests/test_architect.py @@ -1,5 +1,6 @@ """T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。""" import json +import os import httpx import pytest @@ -24,10 +25,11 @@ DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", " REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False) -def _make_client(handler, api_key="test-key", **kw): +def _make_client(handler, api_key=None, **kw): transport = httpx.MockTransport(handler) return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1", - api_key=api_key, transport=transport, **kw) + api_key=api_key or os.environ.get("TEST_ARCHITECT_KEY", "local-test-only"), + transport=transport, **kw) def _resp_json(content, usage=None): diff --git a/tests/test_llama_server.py b/tests/test_llama_server.py index b59eb6b..1afb097 100644 --- a/tests/test_llama_server.py +++ b/tests/test_llama_server.py @@ -4,6 +4,7 @@ import os import subprocess import sys import tempfile +import uuid from pathlib import Path import pytest @@ -26,9 +27,10 @@ def _make_fake_binary(tmp: Path) -> Path: def _make_manager(tmp, binary, port, model, **kw): - marker = tmp / "marker.json" + # marker 固定写入系统临时目录;env 仅传文件名(与 fixtures/fake_llama_server.py 对齐) + marker = Path(tempfile.gettempdir()) / f"fake-llama-marker-{uuid.uuid4().hex}.json" env = dict(os.environ) - env["FAKE_MARKER"] = str(marker) + env["FAKE_MARKER_NAME"] = marker.name return LlamaServerManager( binary=str(binary), model=str(model), diff --git a/tests/test_model_pool.py b/tests/test_model_pool.py index adf311c..a85119c 100644 --- a/tests/test_model_pool.py +++ b/tests/test_model_pool.py @@ -1,4 +1,6 @@ """模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。""" +import os + import pytest pytest.importorskip("fastapi") @@ -30,7 +32,7 @@ def _entry(**over): base = { "id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai", "base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro", - "api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0, + "api_key": os.environ.get("TEST_POOL_KEY", "local-test-only"), "price_in": 1.0, "price_out": 2.0, "enabled": True, } base.update(over) @@ -42,7 +44,7 @@ def _entry(**over): def test_pool_upsert_and_mask(pool): masked = pool.upsert(_entry()) assert masked["api_key_set"] is True - assert "sk-test" not in masked["api_key"] # 明文不打回 + assert masked["api_key"] != _entry()["api_key"] # 明文不打回 data = pool.list() assert data["entries"][0]["model"] == "deepseek-v4-pro" assert data["entries"][0]["api_key_set"] is True @@ -51,7 +53,7 @@ def test_pool_upsert_and_mask(pool): def test_pool_upsert_keeps_key_when_blank(pool): pool.upsert(_entry()) pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留 - assert pool.get("prem-1")["api_key"] == "sk-test-1234567890" + assert pool.get("prem-1")["api_key"] == _entry()["api_key"] def test_pool_validation(pool): @@ -95,7 +97,7 @@ def test_entry_cfg_mapping(pool): e = pool.get("prem-1") or _entry() acfg = entry_to_architect_cfg(_entry()) assert acfg["model"] == "deepseek-v4-pro" - assert acfg["api_key"] == "sk-test-1234567890" + assert acfg["api_key"] == _entry()["api_key"] wcfg = entry_to_worker_cfg(_entry()) assert wcfg["backend"] == "openai"