Author SHA1 Message Date
tzt 9fdf91c2fb feat(v1): 架构与算法优化——语义缓存 2.37x、Router 去重、分类器确定性决胜
架构:
- Router:低置信度直连/专家异常两条兜底路径的收尾逻辑(回退→finalize→record)提取为
  _fallback_result 公共方法,消除三处重复收尾块

算法:
- RouterCache:语义条目写入时预计算向量范数(原每次两两比较重算)、
  语义查找单遍完成(原命中后二次 O(N) 查找)、相似度=1.0 提前终止扫描;
  微基准(3000 条目×200 查询):3986ms -> 1685ms,2.37x
- RuleClassifier:同分决胜改为按领域名字典序(与规则表排列顺序无关的确定性)、
  次高分由全排序改 O(n) 扫描

测试:新增 5 项(缓存范数一致性/提升后无残留/淘汰同步清理、决胜确定性、区分度惩罚)
pytest 25 passed(原 20 全绿 + 新增 5)
基线检查点:66b6fd8(操作前已提交,20 passed)
2026-09-18 08:10:02 +08:00
tzt 66b6fd8c3e docs(branch): 第一代快照说明——本地多智能体协作模型路由 2026-09-15 10:02:06 +08:00
118 changed files with 986 additions and 19915 deletions
+1 -11
View File
@@ -12,29 +12,19 @@ htmlcov/
.env .env
*.env *.env
api_keys*.json api_keys*.json
config/settings.json
# Models / data / runtime # Models / data
models/ models/
data/ data/
bin/
runs/
*.bin *.bin
*.gguf
*.safetensors *.safetensors
cached_results/ cached_results/
# OS / editor # OS / editor
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# 模型池与智能体运行时
config/model_pool.json
agent_runs/
agent_workspace/
# 安全扫描器工作目录(不入库) # 安全扫描器工作目录(不入库)
.mimosa/ .mimosa/
-47
View File
@@ -1,47 +0,0 @@
# AGENTS.md — Agent 仓库导读
> 面向在此仓库工作的 AI Agent。人类开发者也可参考。
## 当前权威规划(先读这个)
**《实现方案_v2_端云协同LLM协作系统.md》**(仓库根目录)是当前唯一权威实施方案:
大模型(API)任务分析/决策/终审 + 小模型(本地 llama.cpp)实现/自验证 +「交流文本」结构化共享工作区 + 人工检验队列。
- 实现前必读其第 3 节(不得推翻的设计决策 D1–D11)与第 8 节(工程规约)。
- 任务按其第 7 节 T1–T14 顺序执行;每完成一个任务,在《任务拆解与执行计划.md》登记一行。
- v1 的 L0 专家系统内核**不删除**,保留为 legacy 路由(`POST /chat/legacy`)与离线降级模式;
v1 测试套件(126 项)必须保持全绿。
## 环境事实
- Windows 11shell 为 Git BashPython venv 在 `.venv`Python 3.14)。
- 测试:`.venv/Scripts/python.exe -m pytest tests -q`(基线 126 passed2026-08-30)。
- 运行 demo / 评测 / 网关的命令见 README「快速开始」。
## 硬性约束(违反即返工)
1. `router_system/` 核心包零第三方依赖(纯标准库);`runtime/``gateway/` 可用
httpx / fastapi / pydantic / uvicorn;新增任何依赖需说明理由并登记 requirements*.txt。
2. 所有 LLM 结构化输出必须过 JSON Schema 校验;解析失败重试一次后降级,禁止带病继续。
3. Architect(大模型 API)输入永不包含工件全文,只用锚点+片段(方案 5.1 / D7)。
4. 一切 LLM 调用在测试中用 `httpx.MockTransport` 注入,测试不依赖真实模型或 API key。
5. 不修改 llama.cpp 源码;只捆绑上游 release 二进制(`bin/`gitignore)。
6. Windows 兼容:`pathlib` 路径、CLI 出口 UTF-8 reconfigure(先例 `scripts/eval.py`)、
子进程 terminate→kill 兜底。
7. 金额敏感:API 调用必须计量并受 `api_token_cap` 熔断约束。
## 代码风格
- 中文 docstring;配置构造用 `build_xxx(cfg)` 工厂;与现有文件排版/命名一致。
- 每任务一个 commit,格式 `feat(v2): Tn 描述`
## 文档地图
| 文档 | 用途 |
|---|---|
| 实现方案_v2_端云协同LLM协作系统.md | **当前权威规划**(架构/协议/任务分解/实验设计) |
| 任务拆解与执行计划.md | 任务状态登记表(v1 历史任务 + v2 新任务追加处) |
| 实现方案_多专业小模型+路由模型.md | v1 方案(已被 v2 取代方向,作历史参考) |
| 可行性调研与落地实现路线报告.md | v1 期可行性论证(历史参考) |
| research/2026_papers_survey.md | 文献调研(LLM 路由/级联/验证器) |
| research/routerarena/01_results_and_gap_analysis.md | v1 实测数据(74.4% 准确率、68.9% 升级率——v2 转向依据) |
+21 -24
View File
@@ -1,32 +1,29 @@
# 分支:v2-coding-agent — 端云协同编程智能体(第代) # 分支:v1-model-routing — 本地多智能体协作模型路由(第代)
> **快照点**`747d85c`v2 核心 + v3 Web 应用化 + v4 模型池与工具智能体全部完成、测试全绿时点)。 > **快照点**`1e51167`v1 MVP 完成时点,仓库首个提交)。
> 历史路标,冻结不再演进;集成主线见 `master`。 > 此分支为**历史路标**,冻结不再演进;集成主线见 `master`。
## 这一代是什么 ## 这一代是什么
**命题**:《基于端云协同的编程智能体系统设计与实现》——大模型(API)任务分析/决策/终审 + - **核心命题**:用"规则知识库 + 任务拆解 Planner + 专业执行器池 + 质量控制器(Judge) + 最后处理者"
小模型(本地 llama.cpp)实现/自验证 +「交流文本」结构化共享工作区 + 人工检验队列。 在限定条件下替代单一通用大模型——**本地多智能体协作路由**
- 架构:8 领域规则知识库 → 规则分类器(置信度 1−e^−s)→ Planner 任务拆解(DAG
→ 黑板/前向链 → 规则执行器(L0 零参数、确定性模板)→ RuleJudge 五维评分 → mock 兜底
- 两级路由:`domain_groups`tech/professional/lifestyle/general)→ 组内 RuleClassifier;三级子领域识别
- 接口:FastAPI `/chat` `/health` `/metrics`;核心包 `router_system/` 零第三方依赖
- **v2 核心**`Workspace` 交流文本协议(schema/锚点/rollup/双渲染)→ `ArchitectClient` ## 如何运行
brief/decide/final_reviewJSON 约束)→ `WorkerLoop`(工具化实现+接地验证+自修≤2
`CollaborativePipeline`(快路径/协作循环/双护栏熔断/终审)+ 运维层(llama-server 进程管理、
三档硬件模板)+ `ReviewQueue` 人工检验 + 打包分发
- **v3 Web 应用化**:异步任务 + SSE 实时协作可视化 + Vue3 SPA(对话/协作过程/检验/指标)+ 网关安全加固
- **v4 增补**:多价位模型池(local/budget/premium 角色指派)+ 工具智能体(harness 级工具/工作区选择/
两级智能体/审批流/token 级流式)
- v1 保留为 legacy`POST /chat/legacy`),离线降级可用
## 基线 ```powershell
.venv\Scripts\python.exe scripts/demo.py --trace # L0 演示(离线可跑)
.venv\Scripts\python.exe scripts/eval.py # 迷你评估
.venv\Scripts\python.exe -m pytest tests -q # 测试(初版 20 项)
.venv\Scripts\python.exe scripts/serve.py --port 8000 # 网关
```
测试 281 项全绿(T31 时点);E1 token 经济学实测:交流文本较全量上下文降 ~61%(含缓存计费)。 ## 定位与结论
## 文档 - **方向结论**:规则路由在 9 域平衡集实测 74.4% 准确率、68.9% 升级率(`research/routerarena/`)——
假阳性过高,**被 v2 端云协作风取代**;本代代码在主线保留为 legacy(`POST /chat/legacy`
`实现方案_v2_端云协同编程智能体系统.md``实现方案_v3_Web应用化.md` - 设计文档:`实现方案_多专业小模型+路由模型.md``可行性调研与落地实现路线报告.md`
`实现方案_v4_模型池与工具智能体.md``毕业设计_进度记录.md` `research/2026_papers_survey.md``research/routerarena/01_results_and_gap_analysis.md`
## 与其他分支的关系
- 第一代(规则路由)以 legacy 形式包含在本快照内
- 第三代(校园缓存代理层)在本快照之后的 master 上演进 → 见 `campus-cache-proxy` 分支
+96 -118
View File
@@ -1,147 +1,125 @@
# 端云协同 LLM 协作系统(v2 # 多专业小模型 + 路由模型系统(MVP
大模型(APIArchitect)做任务分析/决策/终审 本地小模型(llama.cpp,Worker)做实现/自验证 用「轻量分类路由器 + 专业小模型池 + 质量控制器(Judge) + 大模型回退」在限定条件下替代单一通用大模型
两者通过**「交流文本」**(一份 schema 约束的结构化 JSON 共享工作区)交接,互不共享内部状态, 实现 **成本降低 80%+、延迟可控** 的目标。本仓库是《实现方案_多专业小模型+路由模型.md》的第一阶段落地。
另有人工检验队列作为第三协作者。目标是:在端到端质量不降的前提下,把大模型 API token 消耗相对
「全量上下文」方案下降 **≥80%**(北极星指标)。
> v1 的 L0 专家系统内核保留为 **legacy 路由**`POST /chat/legacy`)与离线降级模式,不删除;126 项 v1 测试保持全绿。 ## ✨ 当前能力(2026-08-12 已跑通)
--- - ✅ 零依赖 mock 全链路可运行:缓存 → 分类 → 专家 → Judge → 回退
- ✅ 5 领域意图分类(code / math / legal / medical / general),规则分类器准确率 **100%**15 条评测样例)
## ✨ 当前能力(2026-08-30 已落地) - ✅ 两阶段缓存(L1 精确 + L2 语义 n-gram,零依赖),评测缓存命中率 **40%**
- ✅ 质量控制器(Judge)自动评估输出并触发升级,升级率 **20%**(命中第二阶段验收线)
-**交流文本协议**`router_system/workspace.py`):schema 校验、锚点寻址(`a://file#L12-18`)、rollup 压缩、双渲染函数(Architect ≤1200 token / Worker ≤8K token)、状态机、前缀稳定性(T10) -FastAPI 网关:`/chat` `/health` `/metrics`20 项单元测试全部通过
-**ArchitectClient**`architect.py`):DeepSeek JSON 约束输出,失败回喂重写一次,token 计量,预算熔断 -可选接入真实模型:HuggingFace 小模型(`type: hf`)或 OpenAI 兼容 API`type: api`
-**WorkerLoop + 接地验证**`worker.py` / `verifier.py`):实现→自验证(代码沙箱 > facts 对照 > 结构检查)→自修≤2→issue
-**CollaborativePipeline 编排**`pipeline.py`):快路径 → brief → 协作循环 → 终审 → 交付,双护栏熔断
-**运维层**`runtime/`):三档硬件检测(gpu12/gpu8/cpu+ llama-server 进程管理(启停/健康/重启)
-**网关 v2**`gateway/api.py`):`/chat`v2)、`/chat/legacy`v1)、`/runs/{id}/workspace``/runs/{id}/artifacts/{name}``/review/queue``POST /review/{id}``/metrics`(含 v2 统计)
-**人工检验队列**`review.py`):sqlite 队列、抽样 + safety 强制入队、verdict/correction 回写
-**打包分发**`scripts/setup_runtime.py`):llama-server + GGUF 下载(断点续传/大小校验)
-**E1 token 经济学实验**`scripts/bench_tokens.py``research/v2_experiments/`
-**Web 界面**gateway/static/index.htmlFastAPI 托管,无构建):对话 / 协作过程(交流文本可视化)/ 人工检验 / 指标 四视图
-**测试****229 项全绿**(含 v1 legacy 126 项 + v2 新模块)
---
## 🏗️ 架构
```
用户 query
[快路径] Worker 直答 + 自验证通过?──是──▶ 直接返回(省 API 钱)
│ 否
[Architect·API·一次] briefgoal/constraints/acceptance/plan/tags)写入 交流文本
┌─────────── 协作循环(护栏:rounds_cap / api_token_cap 熔断)───────────┐
│ [Worker·本地] 读 brief+当前步 → 实现 → 接地验证 → 通过→progress;失败自修≤2→issue │
│ [Architect·API·按需] 读 issues → decide → 修订 plan / 兜底代做 │
└──────────────────────────────────────────────────────────────────────┘
│ 全步 done
[Architect·API·一次] final_review → done / 打回
交付 + 入人工检验队列(抽样 / safety 强制)
```
**核心经济学**:贵的一方(API)少读少写(每次输入 ≤1200 token 压缩摘要),便宜的一方(本地)多读多干。
---
## 🚀 快速开始 ## 🚀 快速开始
```powershell ```powershell
# 1. 虚拟环境 + 依赖 # 1. 创建虚拟环境并安装依赖(核心 router_system 零依赖,网关/测试需要轻量依赖)
C:\Python314\python.exe -m venv .venv C:\Python314\python.exe -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt .venv\Scripts\python.exe -m pip install -r requirements.txt
# 2. 跑测试(v1 legacy 126 + v2 新模块 = 228 # 2. 运行演示(mock 模式,离线可跑
.venv\Scripts\python.exe -m pytest tests -q .venv\Scripts\python.exe scripts/demo.py
# 3. (可选)准备本地运行时:下载 llama-server 二进制 + GGUF 模型 # 3. 迷你评估(分类准确率 / 升级率 / 缓存命中率)
.venv\Scripts\python.exe scripts/setup_runtime.py .venv\Scripts\python.exe scripts/eval.py --repeat 2
# 4. 启动网关(v2 /chat 默认走 mock worker,无需 API key/模型即可演示) # 4. 运行单元测试
.venv\Scripts\python.exe -m pytest tests -v
# 5. 启动 API 网关
.venv\Scripts\python.exe scripts/serve.py --port 8000 .venv\Scripts\python.exe scripts/serve.py --port 8000
# 浏览器打开 http://127.0.0.1:8000/ 使用 Web 界面(对话 / 协作过程 / 人工检验 / 指标) # 停止:.venv\Scripts\python.exe scripts/serve.py --stop
# ⚠️ 安全默认值(T30):网关默认只绑定 127.0.0.1 且只信任本机 Host # 6. 调用接口
# (网关能读写工作区文件/执行命令,不宜默认暴露局域网)。
# 如需局域网访问:--host 0.0.0.0 并设置环境变量 GATEWAY_TRUSTED_HOSTS
# 放行对应主机名("*" = 放行全部,仅限可信网络)。
# 5. 调用
curl http://127.0.0.1:8000/health curl http://127.0.0.1:8000/health
curl -X POST http://127.0.0.1:8000/chat -H "Content-Type: application/json" -d '{"query":"用 Python 写一个快速排序"}' curl -X POST http://127.0.0.1:8000/chat -H "Content-Type: application/json" -d '{"query":"用 Python 写一个快速排序函数"}'
curl -X POST http://127.0.0.1:8000/chat/legacy -H "Content-Type: application/json" -d '{"query":"基金定投的收益率怎么计算"}'
curl http://127.0.0.1:8000/metrics curl http://127.0.0.1:8000/metrics
# 6. E1 token 经济学实验(本地确定性测量)
.venv\Scripts\python.exe scripts/bench_tokens.py
``` ```
> 无 API key / 无本地模型时,`/chat` 走快路径(mock Worker)或本地降级,不崩溃。 ## 🏗️ 架构
---
## ⚙️ 配置(config/config.yaml v2 段)
| 段 | 关键项 | 说明 |
|----|--------|------|
| `runtime` | binary / model / port / hw_profile / tiers | llama-server 二进制、GGUF、三档硬件模板 |
| `architect` | model / base_url / api_key_env | 大模型(默认 DeepSeek),`DEEPSEEK_API_KEY` |
| `worker` | backend(mock|llama_server) / max_fix_attempts | 小模型后端与自修次数 |
| `pipeline` | fast_path / rounds_cap / api_token_cap / breach_policy | 快路径、双护栏熔断、兜底策略 |
| `review` | queue_db / sample_rate / force_tags | 人工检验抽样 |
---
## 📂 目录结构(v2 新增)
``` ```
├── router_system/ 用户查询
│ ├── workspace.py ★ 交流文本协议(核心)
│ ├── architect.py 大模型客户端(brief/decide/final_review
│ ├── worker.py 小模型实现/自验证循环 ┌───────────────────┐ ┌──────────────────┐
├── verifier.py 接地验证(代码沙箱/facts/结构) RouterCache 缓存 │───▶│ 命中 → 直接返回 │
├── pipeline.py 协作管线编排 (L1精确 / L2语义) │ └──────────────────┘
│ ├── review.py 人工检验队列 └─────────┬─────────┘
│ └── v2stats.py token 计量与聚合 ▼ 未命中
├── runtime/ 运维层(hw_profile / llama_server 进程管理) ┌───────────────────┐ 低置信度(<0.60) ┌──────────────────┐
├── gateway/api.py v2 + v1 legacy 端点 │ 分类路由器 │ ───────────────▶ │ 大模型回退 │
├── scripts/ │ RuleClassifier / │ │ Mock / DeepSeek │
├── setup_runtime.py 下载运行时/模型 HuggingFace │ └──────────────────┘
│ └── bench_tokens.py E1 实验 └─────────┬─────────┘
├── eval/v2_sample.json 实验数据集 ▼ 高置信度
└── research/v2_experiments/ E1 结果与实验规划 ┌───────────────────┐
│ 专家模型池 │ code/math/legal/medical/general
│ Mock / HF / API │
└─────────┬─────────┘
┌───────────────────┐ 质量分<0.70 ┌──────────────────┐
│ Judge 质量控制器 │ ────────────▶ │ 升级大模型回退 │
│ Rule / LLM-as-Judge│ └──────────────────┘
└───────────────────┘
``` ```
--- 一次请求的完整路由轨迹示例:
## 📊 指标(v2 ```
cache:miss -> classify:code@0.95/hard -> expert:expert-code -> judge:0.96
cache:miss -> classify:general@0.50/easy -> direct_fallback
```
| 指标 | 值 | 说明 | ## ⚙️ 配置(config/config.yaml
|------|----|------|
| 测试 | **228 passed** | v1 legacy 126 + v2 新模块 |
| E1 token 下降(A2 交流文本 vs A1 全量) | **~61%**(本地确定性测量) | 真实缓存计费下目标 ≥80%(`--live` 待确认) |
| 稳定前缀可命中 | ~99% 的 A2 输入 | 配合 `--cache-reuse` 进一步降成本 |
| 快路径 / 熔断 / 回合 | /metrics v2 统计 | V2Stats 实时聚合 |
--- 默认全 mock(零依赖离线)。接入真实模型只需改 `type`
## 🔜 下一步 | 组件 | 当前 | 可切换 | 说明 |
|------|------|--------|------|
| classifier | `rule` | `hf` | 正式环境建议训练 BERT 级分类器(94-97% |
| experts.* | `mock` | `hf` / `api` | HF 小模型或 OpenAI 兼容 API |
| judge | `rule` | `llm` | LLM-as-Judge |
| fallback | `mock` | `api` | 设置 `DEEPSEEK_API_KEY` 环境变量 |
1. **`--live` 接入真实链路**:安装 runtime(`setup_runtime.py`+ 设置 `DEEPSEEK_API_KEY`,跑 E1 主实验确认 ≥80% 北极星(`scripts/bench_tokens.py --live`)。 关键阈值:
2. **E2E5 跑数**:端到端质量 / 协作健康度 / KV 量化曲线 / 验证器 P/R(见 `research/v2_experiments/README.md`)。 - `low_confidence_threshold: 0.60` —— 分类置信度低于此值直接走大模型
3. 用 RouterArena 协议化评测 v2 端到端质量。 - `judge_fallback_threshold: 0.70` —— Judge 质量分低于此值升级大模型
4. 写 v2 论文(`research/paper/`)。
## 📌 环境备忘(T1 ## 📂 目录结构
- Windows 11 + Git Bashvenv 在 `.venv`Python 3.14)。 ```
- 测试命令:`.venv/Scripts/python.exe -m pytest tests -q` ├── router_system/ # 核心(零依赖纯标准库)
- `bin/``models/``data/``runs/` 已 gitignore(运行时产物不入库)。 │ ├── classifier.py # 意图分类器(规则 / HF)
│ ├── difficulty.py # 难度估计
│ ├── experts.py # 专家池(Mock / HF / API
│ ├── judge.py # 质量控制器
│ ├── fallback.py # 大模型回退
│ ├── cache.py # 两阶段缓存
│ ├── router.py # 主路由
│ └── stats.py # 指标
├── gateway/api.py # FastAPI 网关
├── scripts/ # demo / eval / serve / train_classifier
├── tests/ # 20 项单元测试
├── config/config.yaml # 配置
└── research/ # 论文调研
```
## 📊 验收指标对照(实现方案 5.1/5.2)
| 指标 | 目标 | 当前(mock 评测) |
|------|------|------------------|
| 分类准确率 | ≥95%(正式) | 100%15 条样例) |
| 升级率(fallback rate | ≤20% | 20% |
| 缓存命中率 | ≥30% | 40% |
| 端到端延迟 | < 大模型 1.5× | mock 下 ~10-16ms |
## 🔜 下一步(对照实现方案)
1. 接入真实小模型:`pip install -r requirements-ml.txt``experts.*.type``hf`
2. 训练 BERT 级分类器替代规则分类器(`scripts/train_classifier.py` 流水线骨架)
3. 用 RouterArena[GitHub](https://github.com/RouteWorks/RouterArena))标准化评测路由质量
4. 接入 DeepSeek 等大模型 API 作为真实回退层
5. 语义缓存升级为 embedding 检索(当前为 n-gram 轻量方案)
+3 -67
View File
@@ -9,18 +9,10 @@ system:
version: 0.1.0 version: 0.1.0
router: router:
low_confidence_threshold: 0.60 # 分类置信度低于此值 -> 直接走最后处理者 low_confidence_threshold: 0.60 # 分类置信度低于此值 -> 直接走大模型
judge_fallback_threshold: 0.70 # Judge 质量分低于此值 -> 升级最后处理者 judge_fallback_threshold: 0.70 # Judge 质量分低于此值 -> 升级大模型
default_temperature: 0.2 default_temperature: 0.2
# 专家系统内核执行配置(L0 默认零参数)
execution:
mode: rule # rule(默认,L0 零参数)| hybrid
planner: rule # rule(规则拆解)| hf(可选小模型拆解)
expert_backend: rule # rule(规则执行器)| hf | api(本地小模型按需加载)
model_level: L0 # L0 纯规则 | L1 分类/Planner增强 | L2 领域生成
max_plan_depth: 3 # 任务拆解深度上限
classifier: classifier:
type: rule # rule(零依赖)| hftransformers type: rule # rule(零依赖)| hftransformers
model: Qwen/Qwen3-0.6B model: Qwen/Qwen3-0.6B
@@ -31,31 +23,17 @@ domains:
- math - math
- legal - legal
- medical - medical
- finance
- life
- education
- general - general
# 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
domain_groups:
tech: [code, math]
professional: [legal, medical, finance]
lifestyle: [life, education]
general: [general]
experts: experts:
code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct } code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct }
math: { type: mock, model: Qwen/Qwen3-4B-Instruct } math: { type: mock, model: Qwen/Qwen3-4B-Instruct }
legal: { type: mock, model: Qwen/Qwen3-4B-Instruct } legal: { type: mock, model: Qwen/Qwen3-4B-Instruct }
medical: { type: mock, model: Qwen/Qwen3-4B-Instruct } medical: { type: mock, model: Qwen/Qwen3-4B-Instruct }
finance: { type: mock, model: Qwen/Qwen3-4B-Instruct }
life: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
education: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
general: { type: mock, model: Qwen/Qwen3-1.7B-Instruct } general: { type: mock, model: Qwen/Qwen3-1.7B-Instruct }
fallback: fallback:
type: mock # none(降级模板)| mock | local(本地≤8B 按需加载)| api type: mock # mock | apiOpenAI 兼容,如 DeepSeek
model: deepseek-chat model: deepseek-chat
base_url: https://api.deepseek.com/v1 base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY api_key_env: DEEPSEEK_API_KEY
@@ -69,45 +47,3 @@ cache:
semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖) semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖)
similarity_threshold: 0.88 similarity_threshold: 0.88
promote_frequency: 5 # 命中 N 次后提升为精确缓存␍ promote_frequency: 5 # 命中 N 次后提升为精确缓存␍
# ============================================================
# v2:端云协同 LLM 协作系统(《实现方案_v2》)配置段
# v1 段(上方)保留,供 legacy 路由(POST /chat/legacy)使用。
# ============================================================
runtime:
llama_server:
binary: bin/llama-server.exe # 捆绑上游 release,不改源码(D1)
model: models/qwen3.5-4b-q4_k_m.gguf
port: 8901
hw_profile: auto # auto | gpu12 | gpu8 | cpu
extra_args: ["-fa", "-ctk", "q8_0", "-ctv", "q8_0", "--cache-reuse", "256"]
tiers: # 三档硬件模板(保守默认,可手动覆盖)
gpu12: {ngl: 99, ctx: 32768}
gpu8: {ngl: 14, ctx: 16384}
cpu: {ngl: 0, ctx: 8192}
architect: # 大模型(API
model: deepseek-v4-flash
base_url: https://api.deepseek.com
api_key_env: DEEPSEEK_API_KEY
temperature: 0.2
timeout_s: 60
worker: # 小模型(本地)
backend: llama_server
temperature: 0.3
max_fix_attempts: 2
per_step_timeout_s: 300
pipeline:
fast_path: true
rounds_cap: 6
api_token_cap: 8000
breach_policy: architect_do # architect_do | local_only
review:
queue_db: data/review.sqlite3
sample_rate: 0.10 # 随机抽样送审
force_tags: [safety] # brief.tags 命中即强制送审
-14
View File
@@ -1,14 +0,0 @@
[
{"id": "code_01", "query": "用 Python 实现快速排序,并分析时间与空间复杂度", "domain": "code"},
{"id": "code_02", "query": "写一个二分查找函数并补充单元测试", "domain": "code"},
{"id": "code_03", "query": "用 Python 解析 JSON 文件并输出其中某个字段", "domain": "code"},
{"id": "math_01", "query": "求解方程 x^2 - 5x + 6 = 0", "domain": "math"},
{"id": "math_02", "query": "求定积分 ∫0^1 x^2 dx", "domain": "math"},
{"id": "legal_01", "query": "劳动合同约定离职后两年内不得从事同行业是否有效", "domain": "legal"},
{"id": "medical_01", "query": "高血压患者日常饮食需要注意什么", "domain": "medical"},
{"id": "finance_01", "query": "基金定投的收益率怎么计算", "domain": "finance"},
{"id": "life_01", "query": "冬季如何预防感冒", "domain": "life"},
{"id": "education_01", "query": "如何高效记忆英语单词", "domain": "education"},
{"id": "general_01", "query": "解释一下深度学习中的注意力机制", "domain": "general"},
{"id": "general_02", "query": "为什么天空是蓝色的", "domain": "general"}
]
-911
View File
@@ -1,911 +0,0 @@
"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。
职责:
- OpenAICompatChatOpenAI 兼容 /chat/completions 的工具调用客户端(ToolLoop 的 chat_fn),
支持 httpx transport/client 注入(测试用 MockTransport,对齐 D11 封闭性)。
- AgentService:运行一次智能体任务——事件逐条落盘 agent_runs/{id}/events.jsonl
终态写 status.json;SSE 端点轮询事件文件增量推送(与 v3 workspace 监视同思路,
不侵入 router_system)。
- 模型来源:模型池 agent 角色(或显式 pool_id),否则回退经典 Architect 设置。
安全与护栏:
- 文件操作被 WorkspaceTools 关押在工作区根目录内
- 轮数上限(agent.max_rounds)与 token 熔断(agent.token_cap)双护栏
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
from router_system.tools import ToolLoop, WorkspaceTools
# 运行目录(与 runs/ 平级)
AGENT_RUNS_DIR = Path("agent_runs")
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
AGENT_SYSTEM_PROMPT = (
"你是端云协同 LLM 系统中的智能体(Agent),正在操作用户选择的**真实项目工作目录**。"
"你拥有的工具:list_dir(列目录)、read_file(读文件)、write_file(写文件/新建)、"
"edit_file(精确替换编辑:old_string 须唯一匹配)、search_files(跨文件搜索内容)、"
"run_command(执行 shell 命令,仅当系统开启 allow_shell 时可用,否则不要尝试)、"
"web_fetch(抓取公网 http/https 文档页面,私网地址会被拒绝)。"
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
"(或 write_file 新建),需要查外部资料时用 web_fetch,需要时运行命令验证。"
"任务完成或给出结论后,直接输出给用户的最终答复(中文,不要再调用工具)。"
"注意:不要反复以完全相同的参数调用同一工具——那不会带来新信息。"
)
# ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ──
PLANNER_SYSTEM_PROMPT = (
"你是两级智能体中的**规划者**(大模型)。执行者是一个能力有限的本地小模型,"
"只能机械地使用工具。你的职责:把用户任务拆成执行者可照做的**具体指令**,"
"并在执行后审查其汇报。输出必须是合法 JSON 对象(不要 markdown 围栏)。"
)
EXECUTOR_SYSTEM_PROMPT = (
"你是两级智能体中的**执行者**(本地小模型)。规划者已给你具体指令,"
"你只负责用工具完成指令并在最后**汇报**:做了什么、结果如何、有什么问题。"
"严格遵守指令范围,不要自行扩大任务。汇报用中文,是给规划者看的,"
"要列出:修改的文件、关键命令输出、未完成项。"
)
# 规划者首轮:产出指令(JSON
_PLAN_SCHEMA_HINT = {
"instructions": "string(给执行者的具体步骤指令,<=600字)",
"acceptance": "string(验收标准,<=200字)",
}
# 规划者审查轮:裁决(JSON
_REVIEW_SCHEMA_HINT = {
"verdict": "enum(done|redo)",
"reply_to_executor": "string(verdict=redo 时给执行者的补充指令;done 时可空)",
"final_answer": "string(verdict=done 时给用户的最终答复)",
}
DEFAULT_MAX_HANDOFFS = 2 # 规划者<->执行者交接轮数上限
def _parse_json_loose(content: str) -> Dict[str, Any]:
"""宽松解析规划者的 JSON 输出(剥围栏/取首个对象);失败返回 {}"""
try:
from router_system.architect import ArchitectClient
return ArchitectClient._parse_json(content)
except Exception:
return {}
# ─────────────────────────────────────────────────────────────────────────────
# OpenAI 兼容工具调用客户端
# ─────────────────────────────────────────────────────────────────────────────
class OpenAICompatChat:
"""ToolLoop.chat_fn 的 OpenAI 兼容实现(支持 tools 参数)。"""
def __init__(
self,
base_url: str,
api_key: Optional[str],
model: str,
temperature: float = 0.3,
max_tokens: int = 4096,
timeout_s: float = 120.0,
stream: bool = True,
max_retries: int = 2,
retry_delay_s: float = 1.0,
transport: Any = None,
_client: Any = None,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.temperature = temperature
self.max_tokens = max_tokens
self.timeout_s = timeout_s
self.stream = stream # D10:默认流式;解析失败自动回退非流式
self.max_retries = max(0, int(max_retries)) # 可重试错误的重试次数(dsh llm-retry 同款)
self.retry_delay_s = max(0.0, float(retry_delay_s)) # 指数退避基数
self._transport = transport
self._client = _client
self._owns = _client is None
def _get_client(self):
if self._client is None:
import httpx
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
if self._transport is not None:
kwargs["transport"] = self._transport
self._client = httpx.AsyncClient(**kwargs)
return self._client
async def aclose(self) -> None:
if self._owns and self._client is not None:
await self._client.aclose()
self._client = None
async def __call__(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]],
on_delta: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
"""ToolLoop.chat_fn:默认流式(D10);流式不可用时回退非流式(带重试退避)。"""
if self.stream:
try:
return await self._stream_call(messages, tools_spec, on_delta)
except Exception:
# 已有部分增量输出则如实抛出;否则回退非流式
if getattr(self, "_stream_partial", False):
raise
return await self._post_with_retry(messages, tools_spec)
@staticmethod
def _is_retryable(exc: Exception) -> bool:
"""可重试错误:网络传输类 / 408 / 429 / 5xxdsh retryableCodes 同思路)。"""
import httpx
if isinstance(exc, httpx.TransportError):
return True
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
return code in (408, 429) or code >= 500
return False
async def _post_with_retry(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]:
"""非流式调用 + 指数退避重试(仅针对可重试错误)。"""
for attempt in range(self.max_retries + 1):
try:
return await self._post_once(messages, tools_spec)
except Exception as exc:
if attempt >= self.max_retries or not self._is_retryable(exc):
raise
await asyncio.sleep(self.retry_delay_s * (2 ** attempt))
async def _stream_call(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]],
on_delta: Optional[Callable[[str], None]]) -> Dict[str, Any]:
"""流式调用:逐段转发 content 增量;tool_calls 碎片按 index 组装(不在正文展示)。"""
import json as _json
self._stream_partial = False # 每次调用前复位(防上次的标志影响本次回退判定)
body: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
if tools_spec:
body["tools"] = tools_spec
body["tool_choice"] = "auto"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
client = self._get_client()
content_parts: List[str] = []
tc_slots: Dict[int, Dict[str, str]] = {}
usage: Dict[str, Any] = {}
async with client.stream("POST", f"{self.base_url}/chat/completions",
headers=headers, json=body) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
try:
obj = _json.loads(payload)
except _json.JSONDecodeError:
continue
choices = obj.get("choices") or [{}]
delta = (choices[0].get("delta") or {}) if choices else {}
piece = delta.get("content")
if piece:
self._stream_partial = True
content_parts.append(piece)
if on_delta is not None:
try:
on_delta(piece)
except Exception:
pass
for tc in delta.get("tool_calls") or []:
idx = int(tc.get("index", 0))
slot = tc_slots.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc.get("id"):
slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
slot["name"] = fn["name"]
if fn.get("arguments"):
slot["args"] += fn["arguments"]
if obj.get("usage"):
usage = obj["usage"]
content = "".join(content_parts) or None
from router_system.tools import _loads_json_object
tool_calls = []
for idx in sorted(tc_slots):
slot = tc_slots[idx]
tool_calls.append({
"id": slot["id"] or f"call_{idx}",
"name": slot["name"],
"arguments": _loads_json_object(slot["args"]),
})
return {"content": content, "tool_calls": tool_calls, "usage": usage}
async def _post_once(self, messages: List[Dict[str, Any]],
tools_spec: List[Dict[str, Any]]) -> Dict[str, Any]:
"""非流式调用(回退路径)。"""
body: Dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
}
if tools_spec:
body["tools"] = tools_spec
body["tool_choice"] = "auto"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
client = self._get_client()
resp = await client.post(f"{self.base_url}/chat/completions",
headers=headers, json=body)
resp.raise_for_status()
data = resp.json()
msg = (data.get("choices") or [{}])[0].get("message") or {}
from router_system.tools import parse_tool_calls
return {
"content": msg.get("content"),
"tool_calls": parse_tool_calls(msg),
"usage": data.get("usage") or {},
}
# ─────────────────────────────────────────────────────────────────────────────
# 智能体服务
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class AgentRunInfo:
"""一次智能体运行的状态快照(内存 + status.json 双写)。"""
request_id: str
task: str = ""
model: str = ""
state: str = STATE_RUNNING
started_at: float = 0.0
finished_at: float = 0.0
error: Optional[str] = None
response: str = ""
rounds: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
pool_id: str = ""
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
mode: str = "single" # single | dual
tool_calls: int = 0 # 本次运行的工具调用步数
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"task": self.task,
"model": self.model,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"rounds": self.rounds,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"pool_id": self.pool_id,
"workspace": self.workspace,
"executor_model": self.executor_model,
"mode": self.mode,
"tool_calls": self.tool_calls,
}
class AgentService:
"""智能体运行服务:事件落盘 + 状态管理。"""
def __init__(self, run_dir: str | Path = AGENT_RUNS_DIR):
self.run_dir = Path(run_dir)
self._runs: Dict[str, AgentRunInfo] = {}
self.max_running = 5
# ---------- 路径 ----------
def _dir(self, request_id: str) -> Path:
return self.run_dir / request_id
def events_path(self, request_id: str) -> Path:
return self._dir(request_id) / "events.jsonl"
def status_path(self, request_id: str) -> Path:
return self._dir(request_id) / "status.json"
# ---------- 注册与查询 ----------
def register(self, request_id: str, task: str, model: str, pool_id: str,
workspace: str = "", executor_model: str = "",
mode: str = "single") -> Optional[AgentRunInfo]:
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
if len(running) >= self.max_running:
return None
info = AgentRunInfo(request_id=request_id, task=task, model=model,
pool_id=pool_id, workspace=workspace,
executor_model=executor_model, mode=mode,
started_at=time.time())
self._runs[request_id] = info
self._dir(request_id).mkdir(parents=True, exist_ok=True)
self._write_status(info)
return info
def get(self, request_id: str) -> Optional[AgentRunInfo]:
return self._runs.get(request_id)
# ---------- 执行 ----------
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
max_rounds: int = 8, token_cap: int = 0,
allow_shell: bool = False, shell_timeout_s: int = 20,
allow_net: bool = True,
executor_chat: Any = None,
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
session: Optional["AgentSession"] = None,
approval_policy: str = "dangerous",
approval_timeout_s: int = 120) -> None:
"""执行智能体任务(由调用方包成后台协程)。
executor_chat 为空 = 单模型模式(chat 全程包办);
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。
"""
history = self._history_from_session(session)
approval_mgr = ApprovalManager()
info._approval_manager = approval_mgr # 供 /approve 端点裁决(瞬态属性)
throttle = DeltaThrottle(lambda ev: self._append_event(info, ev))
async def approval_hook(name: str, args: Dict[str, Any]) -> bool:
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
if not needs_approval(approval_policy, name):
return True
aid = "ap" + uuid.uuid4().hex[:8]
ev = approval_mgr.open(aid)
self._append_event(info, {"type": "approval_request", "id": aid,
"name": name, "arguments": args,
"policy": approval_policy})
# 轮询等待(0.1s 步进):不用 wait_for——portal 循环下其定时器不可靠
allowed = False
note = ""
deadline = time.time() + max(1, approval_timeout_s)
while time.time() < deadline:
if ev.is_set():
allowed = approval_mgr._pending.get(aid, {}).get("allowed", False)
break
await asyncio.sleep(0.1)
else:
note = f"超时({approval_timeout_s}s)未响应,自动拒绝"
if ev.is_set() and not allowed:
note = note or "用户拒绝"
approval_mgr.close(aid)
self._append_event(info, {"type": "approval_decided", "id": aid,
"name": name, "allowed": allowed,
**({"note": note} if note else {})})
return allowed
try:
if executor_chat is not None:
result = await self.run_dual(
info, chat, executor_chat, workspace_dir,
max_rounds=max_rounds, token_cap=token_cap,
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
allow_net=allow_net,
max_handoffs=max_handoffs,
approval_hook=approval_hook)
else:
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s,
allow_net=allow_net)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info),
approval_hook=approval_hook,
on_delta=throttle.make_cb("executor"))
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
history=history)
throttle.flush("executor")
self._apply_result(info, result)
except Exception as exc: # pragma: no cover
info.state = STATE_FAILED
info.error = f"{type(exc).__name__}: {exc}"
self._append_event(info, {"type": "final", "round": info.rounds,
"reason": "error", "error": info.error})
finally:
info.finished_at = time.time()
self._write_status(info)
if session is not None:
session.data["turns"].append({
"request_id": info.request_id,
"task": info.task,
"response": info.response,
"state": info.state,
"tool_calls": info.tool_calls,
"tokens": info.prompt_tokens + info.completion_tokens,
"error": info.error,
"ts": info.finished_at,
})
get_session_store().save(session)
@staticmethod
def _history_from_session(session: Optional["AgentSession"],
max_turns: int = 6,
max_chars: int = 1500) -> List[Dict[str, Any]]:
"""把会话既往轮次折叠成对话上下文(不含工具细节)。"""
if session is None:
return []
turns = [t for t in session.data.get("turns", [])
if t.get("state") == STATE_DONE and t.get("response")]
out: List[Dict[str, Any]] = []
for t in turns[-max_turns:]:
out.append({"role": "user", "content": str(t["task"])[:max_chars]})
out.append({"role": "assistant", "content": str(t["response"])[:max_chars]})
return out
def _apply_result(self, info: AgentRunInfo, result: Dict[str, Any]) -> None:
"""把循环结果落到运行状态(单/两级模式共用)。"""
info.response = result.get("response", "")
info.rounds = int(result.get("rounds", 0))
info.prompt_tokens = int(result.get("prompt_tokens", 0))
info.completion_tokens = int(result.get("completion_tokens", 0))
if result.get("reason") == "error":
info.state = STATE_FAILED
info.error = result.get("error")
elif result.get("reason") in ("token_cap", "max_rounds", "max_handoffs"):
# 触顶属于护栏行为:结果仍交付,但标记部分完成信息
info.state = STATE_DONE
info.error = result.get("error")
else:
info.state = STATE_DONE
# ---------- 两级模式(D7):规划者 + 执行者 ----------
async def run_dual(self, info: AgentRunInfo, planner_chat: Any, executor_chat: Any,
workspace_dir: str | Path, max_rounds: int = 8,
token_cap: int = 0, allow_shell: bool = False,
shell_timeout_s: int = 20, allow_net: bool = True,
max_handoffs: int = DEFAULT_MAX_HANDOFFS,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
) -> Dict[str, Any]:
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
info.mode = "dual"
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s, allow_net=allow_net)
throttle = DeltaThrottle(lambda ev: self._append_event(info, ev))
handoff: Dict[str, Any] = {
"task": info.task, "planner_model": info.model,
"executor_model": info.executor_model, "workspace": info.workspace,
"instructions": "", "acceptance": "", "exchanges": [],
}
spent = {"in": 0, "out": 0}
total_rounds = 0
def _account(usage: Dict[str, Any] | None) -> None:
spent["in"] += int((usage or {}).get("prompt_tokens", 0))
spent["out"] += int((usage or {}).get("completion_tokens", 0))
def _save_handoff() -> None:
try:
(self._dir(info.request_id) / "handoff.json").write_text(
json.dumps(handoff, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError:
pass
def _remaining_cap() -> int:
return (token_cap - spent["in"] - spent["out"]) if token_cap else 1
async def _planner_json(user_msg: str) -> Dict[str, Any]:
"""调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
import inspect
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg}]
content = ""
for attempt in (1, 2):
# 规划者同样流式(前端弱化展示其 JSON 草稿)
try:
accepts = len(inspect.signature(planner_chat).parameters) >= 3
except (TypeError, ValueError):
accepts = False
if accepts:
resp = await planner_chat(messages, [], throttle.make_cb("planner"))
else:
resp = await planner_chat(messages, [])
_account(resp.get("usage"))
content = resp.get("content") or ""
throttle.flush("planner")
obj = _parse_json_loose(content)
if obj:
break
if attempt == 1:
messages += [{"role": "assistant", "content": content},
{"role": "user",
"content": "你的输出不是合法 JSON。请重新只输出合法 JSON 对象。"}]
self._append_event(info, {"type": "message", "role": "planner",
"content": content[:2000]})
return obj
try:
# ---- 阶段 1:规划(大模型拆解为执行者指令) ----
self._append_event(info, {"type": "phase", "phase": "plan", "model": info.model})
plan = await _planner_json(
f"用户任务:{info.task}\n\n"
"请产出给执行者的指令,仅输出符合如下结构的 JSON:\n"
+ json.dumps(_PLAN_SCHEMA_HINT, ensure_ascii=False))
instructions = (plan.get("instructions") or info.task).strip()
handoff["instructions"] = instructions
handoff["acceptance"] = str(plan.get("acceptance", ""))
_save_handoff()
final_text = ""
reason = "answer"
error = None
exec_rounds_total = 0
# ---- 阶段 2/3:执行 <-> 审查(有界交接) ----
for h in range(1, max_handoffs + 1):
# 执行(本地小模型跑工具轮)
self._append_event(info, {"type": "phase", "phase": "execute",
"handoff": h, "model": info.executor_model})
loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds,
token_cap=max(1, _remaining_cap()),
on_event=self._make_event_writer(info),
emit_final=False,
approval_hook=approval_hook,
on_delta=throttle.make_cb("executor"))
exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT)
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
"completion_tokens": exec_result.get("completion_tokens", 0)})
exec_rounds_total += int(exec_result.get("rounds", 0))
report = exec_result.get("response", "")
# 执行者汇报作为消息事件透出(前端可读)
self._append_event(info, {"type": "message", "role": "executor",
"handoff": h, "content": (report or "")[:4000]})
if exec_result.get("reason") == "error":
reason, error = "error", exec_result.get("error")
final_text = report
break
# 审查(大模型裁决)
self._append_event(info, {"type": "phase", "phase": "review",
"handoff": h, "model": info.model})
review = await _planner_json(
f"用户任务:{info.task}\n你之前给出的指令:{instructions}\n"
f"验收标准:{handoff['acceptance'] or '(未明确)'}\n\n"
f"执行者第 {h} 轮汇报:\n{report[:4000]}\n\n"
"请审查是否已按验收标准完成,仅输出符合如下结构的 JSON:\n"
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False))
verdict = str(review.get("verdict", "done")).lower()
handoff["exchanges"].append({
"handoff": h, "executor_report": report,
"verdict": verdict,
"reply_to_executor": str(review.get("reply_to_executor", "")),
})
_save_handoff()
if verdict == "done":
final_text = str(review.get("final_answer") or report)
break
# redo:裁决意见作为下一轮执行者指令(带上一轮上下文)
instructions = str(review.get("reply_to_executor") or instructions)
if h == max_handoffs:
reason = "max_handoffs"
error = f"交接轮数达上限({max_handoffs}),以执行者汇报收尾"
final_text = report
else:
final_text = final_text or ""
self._append_event(info, {"type": "final", "round": total_rounds + exec_rounds_total,
"reason": reason, "error": error})
return {"response": final_text, "rounds": total_rounds + exec_rounds_total,
"reason": reason, "error": error,
"prompt_tokens": spent["in"], "completion_tokens": spent["out"]}
except Exception as exc:
reason = "error"
error = f"{type(exc).__name__}: {exc}"
self._append_event(info, {"type": "final", "round": total_rounds,
"reason": reason, "error": error})
return {"response": "", "rounds": total_rounds, "reason": reason,
"error": error,
"prompt_tokens": spent["in"], "completion_tokens": spent["out"]}
# ---------- 事件 ----------
def _make_event_writer(self, info: AgentRunInfo):
def _on_event(ev: Dict[str, Any]) -> None:
if ev.get("type") == "tool_call":
info.tool_calls += 1 # 工具步数统计(单/两级模式统一在此)
self._append_event(info, ev)
return _on_event
def _append_event(self, info: AgentRunInfo, ev: Dict[str, Any]) -> None:
ev = {"ts": time.time(), **ev}
try:
with self.events_path(info.request_id).open("a", encoding="utf-8") as f:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
except OSError:
pass
def read_events(self, request_id: str) -> List[Dict[str, Any]]:
p = self.events_path(request_id)
if not p.exists():
return []
out = []
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass # 半行(正在写入)忽略
return out
# ---------- 状态 ----------
def _write_status(self, info: AgentRunInfo) -> None:
try:
self.status_path(info.request_id).write_text(
json.dumps(info.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8")
except OSError:
pass
async def watch_events(self, request_id: str, cancel_event: asyncio.Event,
poll_interval: float = 0.3, max_seconds: float = 900.0):
"""SSE 生成器:增量推送 events.jsonl 新行,直到终态/取消/超时。
从文件头开始回放(晚加入的订阅者也能看到完整过程)。
"""
p = self.events_path(request_id)
offset = 0
deadline = time.time() + max_seconds
while not cancel_event.is_set() and time.time() < deadline:
if p.exists():
try:
size = p.stat().st_size
if size > offset:
with p.open("r", encoding="utf-8") as f:
f.seek(offset)
new_text = f.read()
offset = f.tell()
for line in new_text.splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
yield ev
if ev.get("type") == "final":
return
except OSError:
pass
info = self.get(request_id)
if info and info.state in (STATE_DONE, STATE_FAILED):
# 终态兜底:状态已结束但可能没有 final 事件(如注册即失败)
yield {"type": "final", "round": info.rounds,
"reason": "answer" if info.state == STATE_DONE else "error",
"error": info.error}
return
await asyncio.sleep(poll_interval)
yield {"type": "final", "round": 0, "reason": "error", "error": "订阅超时"}
# ---------- 全局单例 ----------
_service: Optional[AgentService] = None
def get_agent_service() -> AgentService:
global _service
if _service is None:
_service = AgentService()
return _service
def reset_agent_service() -> None:
"""测试用:重置全局智能体服务单例。"""
global _service
_service = None
def new_request_id() -> str:
return "ag" + uuid.uuid4().hex[:10]
# ─────────────────────────────────────────────────────────────────────────────
# 审批流(D9):dsh 式 allow-once / denyfail-closed
# ─────────────────────────────────────────────────────────────────────────────
READ_ONLY_TOOLS = {"list_dir", "read_file", "search_files", "web_fetch"}
def needs_approval(policy: str, tool_name: str) -> bool:
"""审批策略判定:off=全放行;all=全询问;dangerous=写/编辑/命令询问,只读放行。"""
if policy == "all":
return True
if policy == "dangerous":
return tool_name not in READ_ONLY_TOOLS
return False
class DeltaThrottle:
"""流式增量节流(D10):积攒超过阈值才落一条 delta 事件,防事件爆炸。"""
THRESHOLD = 48
def __init__(self, append_event):
self._append = append_event # (ev: dict) -> None
self._buf: Dict[str, str] = {}
def make_cb(self, role: str):
def cb(text: str) -> None:
self.add(role, text)
return cb
def add(self, role: str, text: str) -> None:
buf = self._buf.get(role, "") + (text or "")
if len(buf) >= self.THRESHOLD:
self._flush(role, buf)
buf = ""
self._buf[role] = buf
def flush(self, role: Optional[str] = None) -> None:
roles = [role] if role else list(self._buf.keys())
for r in roles:
buf = self._buf.get(r, "")
if buf:
self._flush(r, buf)
self._buf[r] = ""
def _flush(self, role: str, text: str) -> None:
self._append({"type": "delta", "role": role, "text": text})
class ApprovalManager:
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
def __init__(self):
self._pending: Dict[str, Dict[str, Any]] = {}
def open(self, approval_id: str) -> asyncio.Event:
ev = asyncio.Event()
self._pending[approval_id] = {"event": ev, "allowed": False}
return ev
def decide(self, approval_id: str, allowed: bool) -> bool:
p = self._pending.get(approval_id)
if p is None:
return False
p["allowed"] = allowed
p["event"].set()
return True
def close(self, approval_id: str) -> None:
self._pending.pop(approval_id, None)
# ─────────────────────────────────────────────────────────────────────────────
# 会话(dsh 式:工作区内多轮对话,持久化到磁盘)
# ─────────────────────────────────────────────────────────────────────────────
SESSIONS_DIR = Path("agent_runs") / "sessions"
class AgentSession:
"""一个智能体会话:多轮任务 + 配置快照(磁盘持久化)。"""
def __init__(self, data: Dict[str, Any]):
self.data = data
@classmethod
def new(cls, sid: str, title: str, workspace: str,
pool_id: str = "", executor_pool_id: str = "") -> "AgentSession":
now = time.time()
return cls({
"id": sid, "title": title[:24] or "新会话", "workspace": workspace,
"pool_id": pool_id, "executor_pool_id": executor_pool_id,
"created_at": now, "updated_at": now, "busy": False,
"turns": [], # [{request_id, task, response, state, tool_calls, tokens}]
})
def to_dict(self) -> Dict[str, Any]:
return dict(self.data)
def view(self, include_turns: bool = True) -> Dict[str, Any]:
out = self.to_dict()
if not include_turns:
out["turns"] = len(self.data.get("turns", []))
return out
class SessionStore:
"""会话注册表(内存索引 + sessions/{sid}.json 持久化)。"""
def __init__(self, root: Path = SESSIONS_DIR):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self._cache: Dict[str, AgentSession] = {}
def _path(self, sid: str) -> Path:
return self.root / f"{sid}.json"
def create(self, title: str, workspace: str,
pool_id: str = "", executor_pool_id: str = "") -> AgentSession:
sid = "as" + uuid.uuid4().hex[:10]
sess = AgentSession.new(sid, title or "新会话", workspace, pool_id, executor_pool_id)
self._cache[sid] = sess
self._save(sess)
return sess
def get(self, sid: str) -> Optional[AgentSession]:
if sid in self._cache:
return self._cache[sid]
p = self._path(sid)
if not p.exists():
return None
try:
sess = AgentSession(json.loads(p.read_text(encoding="utf-8")))
self._cache[sid] = sess
return sess
except (json.JSONDecodeError, OSError):
return None
def list(self) -> List[Dict[str, Any]]:
out = []
for p in sorted(self.root.glob("*.json"),
key=lambda x: x.stat().st_mtime, reverse=True):
try:
out.append(json.loads(p.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError):
continue
return out
def delete(self, sid: str) -> bool:
self._cache.pop(sid, None)
p = self._path(sid)
if p.exists():
p.unlink()
return True
return False
def rename(self, sid: str, title: str) -> Optional[AgentSession]:
"""重命名会话标题(dsh session.rename 对齐)。"""
sess = self.get(sid)
if sess is None:
return None
title = (title or "").strip()
if not title:
return sess
sess.data["title"] = title[:24]
self.save(sess)
return sess
def save(self, sess: AgentSession) -> None:
self._cache[sess.data["id"]] = sess
self._save(sess)
def _save(self, sess: AgentSession) -> None:
sess.data["updated_at"] = time.time()
try:
self._path(sess.data["id"]).write_text(
json.dumps(sess.data, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError:
pass
_session_store: Optional[SessionStore] = None
def get_session_store() -> SessionStore:
global _session_store
if _session_store is None:
_session_store = SessionStore()
return _session_store
def reset_session_store() -> None:
"""测试用。"""
global _session_store
_session_store = None
+37 -1135
View File
File diff suppressed because it is too large Load Diff
-328
View File
@@ -1,328 +0,0 @@
"""异步后台任务注册表(T1:后端异步化核心)。
设计原则(对齐 v3 方案 D1-D6):
- asyncio 原生,无 Celery/Redis/外部队列
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
- 任务写 runs/{id}/workspace.jsonSSE 生成器只读该文件(D3:不改 pipeline)
- 定期清理已完成任务(防内存泄漏)
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, Optional
# runs/ 目录(与 pipeline.py 默认一致)
RUNS_DIR = Path("runs")
# 任务状态
STATE_PENDING = "pending"
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
@dataclass
class TaskInfo:
"""一个后台任务的状态快照。"""
request_id: str
state: str = STATE_PENDING # pending | running | done | failed
started_at: float = 0.0 # time.time()
finished_at: float = 0.0 # time.time()done/failed 时)
error: Optional[str] = None # failed 时错误信息
# PipelineResult 字段(done 时填充)
response: Optional[str] = None
status: Optional[str] = None # done | fast_path | escalated | failed
fast_path: bool = False
rounds_used: int = 0
api_input_tokens: int = 0
api_output_tokens: int = 0
cost_est: float = 0.0
model_used: Optional[str] = None
latency_ms: float = 0.0
workspace_path: Optional[str] = None
error_detail: Optional[str] = None
route: list = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"workspace_path": self.workspace_path,
"route": self.route,
"error_detail": self.error_detail,
}
def to_result_event(self) -> Dict[str, Any]:
"""终态 SSE result 事件 payload。"""
return {
"type": "result",
"request_id": self.request_id,
"response": self.response or "",
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"route": self.route,
"error": self.error,
}
class JobStore:
"""asyncio 后台任务注册表。
线程安全(asyncio 事件循环单线程,不需要额外锁)。
任务由 register() 注册、由 _task_done() 填充结果。
SSE 生成器调用 watch_file() 轮询 workspace.json 变化。
"""
def __init__(self, max_age_seconds: float = 3600.0, max_running: int = 50):
"""
Args:
max_age_seconds: 已完成任务在内存中保留时间(秒),超时后自动清理
max_running: 最大同时运行任务数,超出后拒绝新任务
"""
self._tasks: Dict[str, TaskInfo] = {}
self._asyncio_tasks: Dict[str, "asyncio.Task[None]"] = {} # request_id -> asyncio.Task
self._cancel_events: Dict[str, asyncio.Event] = {} # request_id -> cancel Event
self.max_age_seconds = max_age_seconds
self.max_running = max_running
self._poll_interval = 0.3 # workspace.json 轮询间隔(秒)
# ─────────────────────────────────────────────────────────────────
# 公共 API
# ─────────────────────────────────────────────────────────────────
def register(self, request_id: str) -> tuple[bool, str]:
"""注册一个 pending 任务。返回 (True, "") 成功,(False, reason) 容量满。"""
running = [t for t in self._tasks.values() if t.state == STATE_RUNNING]
if len(running) >= self.max_running:
return False, f"同时运行任务已达上限 {self.max_running},请稍后重试"
if request_id in self._tasks:
return False, f"任务 {request_id} 已存在"
self._tasks[request_id] = TaskInfo(
request_id=request_id,
state=STATE_PENDING,
started_at=time.time(),
)
return True, ""
def get(self, request_id: str) -> Optional[TaskInfo]:
"""查询任务状态。"""
return self._tasks.get(request_id)
def list_all(self) -> Dict[str, TaskInfo]:
"""列出所有任务(含已完成)。"""
return dict(self._tasks)
def list_running(self) -> Dict[str, TaskInfo]:
"""只列出运行中任务。"""
return {k: v for k, v in self._tasks.items() if v.state == STATE_RUNNING}
def submit(
self,
request_id: str,
coro, # type: asyncio.coroutine
) -> "asyncio.Task[None]":
"""提交协程到后台运行;内部注册 asyncio.Task。"""
task = asyncio.create_task(self._run_wrapper(request_id, coro))
self._asyncio_tasks[request_id] = task
return task
def new_cancel(self, request_id: str) -> asyncio.Event:
"""为 SSE 连接创建一个取消事件。"""
evt = asyncio.Event()
self._cancel_events[request_id] = evt
return evt
def get_cancel(self, request_id: str) -> Optional[asyncio.Event]:
"""获取已有取消事件。"""
return self._cancel_events.get(request_id)
# ─────────────────────────────────────────────────────────────────
# 内部:任务运行包装
# ─────────────────────────────────────────────────────────────────
async def _run_wrapper(self, request_id: str, coro):
"""把用户协程包装成可追踪的任务:更新状态、捕获异常、清理。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_RUNNING
try:
await coro
except Exception as exc: # pragma: no cover
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = f"unhandled:{type(exc).__name__}:{exc}"
finally:
# 清理 asyncio task 引用
self._asyncio_tasks.pop(request_id, None)
# 定期 GC 已完成任务
self._cleanup_aged()
def _task_done(
self,
request_id: str,
result: "PipelineResult", # from pipeline.PipelineResult
) -> None:
"""任务正常完成时由调用方调用,写入结果。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_DONE
info.finished_at = time.time()
info.response = result.response
info.status = result.status
info.fast_path = result.fast_path
info.rounds_used = result.rounds_used
info.api_input_tokens = result.api_input_tokens
info.api_output_tokens = result.api_output_tokens
info.cost_est = result.cost_est
info.model_used = result.model_used
info.latency_ms = result.latency_ms
info.workspace_path = result.workspace_path
info.route = list(result.route) if result.route else []
if result.error:
info.error_detail = result.error
def _task_failed(
self,
request_id: str,
error: str,
detail: Optional[str] = None,
) -> None:
"""任务异常结束时调用。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = error
info.error_detail = detail
# ─────────────────────────────────────────────────────────────────
# SSE 专用:workspace.json 文件轮询
# ─────────────────────────────────────────────────────────────────
async def watch_workspace(
self,
request_id: str,
cancel_event: asyncio.Event,
) -> AsyncGenerator[Dict[str, Any], None]:
"""监视 runs/{id}/workspace.jsonyield 单条事件。
事件类型:
- {"type": "status", "value": <state>, "request_id": <id>}
- {"type": "workspace", "version": <n>, "workspace": <dict>}
- {"type": "error", "detail": <str>}
当任务进入 done/failed 状态或 cancel_event 被 set 时停止。
"""
ws_path = RUNS_DIR / request_id / "workspace.json"
seen_mtime: float = 0.0
seen_size: int = 0
while not cancel_event.is_set():
info = self.get(request_id)
# 检查终态
if info and info.state in (STATE_DONE, STATE_FAILED):
if info.state == STATE_FAILED:
yield {"type": "error", "detail": info.error or "任务失败"}
# result 由 /runs/{id}/status 提供,此处只推送终态 status
yield {"type": "status", "value": info.state, "request_id": request_id}
break
# 读文件变化(mtime + size 双检)
if ws_path.exists():
try:
stat = ws_path.stat()
if stat.st_mtime != seen_mtime or stat.st_size != seen_size:
raw = ws_path.read_text(encoding="utf-8")
data = json.loads(raw)
version = int(data.get("meta", {}).get("round", 0))
seen_mtime = stat.st_mtime
seen_size = stat.st_size
yield {
"type": "workspace",
"version": version,
"state": info.state if info else STATE_RUNNING,
"request_id": request_id,
"workspace": data,
}
except (json.JSONDecodeError, OSError):
pass # 文件正在写入,忽略
await asyncio.sleep(self._poll_interval)
# 连接关闭前最后推一次终态
info = self.get(request_id)
if info:
yield {"type": "status", "value": info.state, "request_id": request_id}
# ─────────────────────────────────────────────────────────────────
# 内存清理
# ─────────────────────────────────────────────────────────────────
def _cleanup_aged(self) -> None:
"""删除超过 max_age_seconds 的已完成任务引用。"""
now = time.time()
to_remove = [
rid for rid, info in self._tasks.items()
if info.state in (STATE_DONE, STATE_FAILED)
and (now - info.finished_at) > self.max_age_seconds
]
for rid in to_remove:
self._tasks.pop(rid, None)
def cancel(self, request_id: str) -> bool:
"""取消运行中的任务。返回 True 找到并取消,False 未找到。"""
task = self._asyncio_tasks.get(request_id)
if task is None:
return False
task.cancel()
info = self.get(request_id)
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = "cancelled_by_user"
return True
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例(gateway 进程内共享)
# ─────────────────────────────────────────────────────────────────────────────
_store: Optional[JobStore] = None
def get_job_store() -> JobStore:
global _store
if _store is None:
_store = JobStore(max_age_seconds=3600.0, max_running=50)
return _store
def reset_job_store() -> None:
"""测试用:重置全局注册表。"""
global _store
_store = None
-471
View File
@@ -1,471 +0,0 @@
"""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 = LOG_FILE.open("w", encoding="utf-8", buffering=1)
try:
self._proc = subprocess.Popen(
args,
stdout=log_f,
stderr=subprocess.STDOUT,
cwd=str(_ROOT),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
)
except OSError as e:
log_f.close()
return LlamaServerStatus(running=False, error=f"启动失败:{e}")
self._pid = self._proc.pid
self._model = str(model_path)
self._port = port
self._started_at = time.time()
self._write_pid(self._pid)
# 等待服务就绪
ok = await self._wait_until_ready(port, timeout=30)
if not ok:
await self.stop()
return LlamaServerStatus(
running=False,
error=f"llama-server 启动后 {port} 端口在 30 秒内未响应",
)
return self.status()
async def stop(self) -> None:
"""优雅停止 llama-server。"""
if self._pid is None:
self._proc = None
return
try:
if sys.platform == "win32":
# Windows: CTRL_BREAK_EVENT 或 taskkill
subprocess.run(
["taskkill", "/PID", str(self._pid), "/T", "/F"],
capture_output=True,
timeout=10,
)
else:
os.kill(self._pid, 15) # SIGTERM
time.sleep(1)
try:
os.kill(self._pid, 0)
os.kill(self._pid, 9)
except OSError:
pass
except Exception:
pass
finally:
self._proc = None
self._pid = None
self._model = None
self._port = None
self._started_at = None
self._clear_pid()
async def _wait_until_ready(self, port: int, timeout: float = 30) -> bool:
"""轮询检查端口是否开始监听。"""
import httpx
url = f"http://127.0.0.1:{port}/v1/models"
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=5.0) as client:
while time.time() < deadline:
try:
r = await client.get(url)
if r.status_code < 500:
return True
except (httpx.ConnectError, httpx.ReadTimeout, OSError):
pass
await asyncio.sleep(0.5)
return False
# ── 模型列表 ─────────────────────────────────────────────────────────
def list_local_models(self) -> list[dict[str, str]]:
"""列出 models/ 目录下所有 .gguf 文件。"""
models = []
for p in MODELS_DIR.glob("*.gguf"):
size_mb = p.stat().st_size // (1024 * 1024)
models.append({
"id": p.name,
"name": p.name,
"size_mb": size_mb,
"path": str(p),
})
return sorted(models, key=lambda m: m["name"])
# ── 模型下载 ─────────────────────────────────────────────────────────
async def download_model(
self,
url: str,
dest: Optional[str] = None,
) -> DownloadProgress:
"""从 HuggingFace 或直链下载 .gguf 模型文件。
HuggingFace 路径别名:用户输入 "Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"
自动转换为 "https://huggingface.co/<repo>/resolve/main/<file>"
支持断点续传(Content-Range)。
返回 DownloadProgress 对象(含当前进度),进度通过 get_download_progress() 查询。
"""
import httpx
# URL 协议白名单:只允许 http/httpsfile://、ftp:// 等一律拒绝)。
# 必须先于 HF 别名转换判定,否则 ftp:// 会被误拼成 HF 地址。
if "://" in url:
scheme = url.split("://", 1)[0].lower()
if scheme not in ("http", "https"):
prog = DownloadProgress(url=url, dest=str(dest or ""),
error=f"仅允许 http/https 下载地址(收到 {scheme}")
return prog
# 路径别名转换
if not url.startswith("http"):
url = f"https://huggingface.co/{url}/resolve/main"
# 解析文件名
filename = url.rstrip("/").split("/")[-1]
if not filename.endswith(".gguf"):
filename += ".gguf"
if dest:
dest_path = Path(dest)
# 目标关押:自定义 dest 必须仍位于 models/ 目录内(防 ../ 越界写盘)
models_root = MODELS_DIR.resolve()
resolved = (models_root / dest_path).resolve() if not dest_path.is_absolute() \
else dest_path.resolve()
if resolved != models_root and models_root not in resolved.parents:
prog = DownloadProgress(url=url, dest=str(dest_path),
error=f"下载目标必须在 models/ 目录内: {dest}")
return prog
dest_path = resolved
else:
dest_path = MODELS_DIR / filename
# 构造 HTTP 头
headers = {}
resume_bytes = 0
if dest_path.exists():
resume_bytes = dest_path.stat().st_size
headers["Range"] = f"bytes={resume_bytes}-"
# 获取文件大小
total_bytes: Optional[int] = None
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0, read=60.0, write=30.0, pool=10.0), follow_redirects=True) as client:
head = await client.head(url, headers={"Range": "bytes=0-0"})
total_raw = head.headers.get("Content-Length")
if total_raw:
total_bytes = int(total_raw)
# Content-Range 响应时 total_bytes 在 Content-Range 头里
cr = head.headers.get("Content-Range", "")
m = re.search(r"/(\d+)", cr)
if m:
total_bytes = int(m.group(1))
except Exception:
pass
prog = DownloadProgress(
url=url,
dest=str(dest_path),
total_bytes=total_bytes,
downloaded_bytes=resume_bytes,
)
with self._dl_lock:
self._downloading[url] = prog
try:
mode = "ab" if resume_bytes > 0 else "wb"
async with httpx.AsyncClient(
timeout=httpx.Timeout(300.0, connect=10.0, read=300.0, write=30.0, pool=10.0),
follow_redirects=True,
) as client:
t0 = time.time()
last_bytes = resume_bytes
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code not in (200, 206):
raise RuntimeError(f"HTTP {resp.status_code}")
with open(dest_path, mode) as f:
async for chunk in resp.aiter_bytes(chunk_size=8192):
f.write(chunk)
prog.downloaded_bytes += len(chunk)
# 速度 & ETA
elapsed = time.time() - t0
if elapsed > 0.5:
speed_bps = (prog.downloaded_bytes - last_bytes) / elapsed
speed_str = _format_speed(speed_bps)
if prog.total_bytes and speed_bps > 0:
remain = prog.total_bytes - prog.downloaded_bytes
eta_s = remain / speed_bps
prog.eta = _format_eta(eta_s)
else:
prog.eta = ""
prog.speed = speed_str
last_bytes = prog.downloaded_bytes
t0 = time.time()
if prog.total_bytes:
prog.progress_pct = min(prog.downloaded_bytes / prog.total_bytes * 100, 100)
except Exception as e:
prog.error = str(e)
finally:
prog.done = True
with self._dl_lock:
self._downloading[url] = prog
return prog
def get_download_progress(self, url: str) -> Optional[DownloadProgress]:
"""查询下载进度。"""
with self._dl_lock:
return self._downloading.get(url)
def list_downloads(self) -> list[DownloadProgress]:
"""列出所有活跃下载。"""
with self._dl_lock:
return list(self._downloading.values())
def _format_speed(bps: float) -> str:
if bps >= 1e9:
return f"{bps/1e9:.1f} GB/s"
if bps >= 1e6:
return f"{bps/1e6:.1f} MB/s"
if bps >= 1e3:
return f"{bps/1e3:.1f} KB/s"
return f"{bps:.0f} B/s"
def _format_eta(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.0f}s"
if seconds < 3600:
return f"{seconds/60:.0f}m"
return f"{seconds/3600:.1f}h"
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例
# ─────────────────────────────────────────────────────────────────────────────
_lm: Optional[LlamaManager] = None
def get_llama_manager() -> LlamaManager:
global _lm
if _lm is None:
_lm = LlamaManager()
return _lm
-270
View File
@@ -1,270 +0,0 @@
"""模型池(PoolStore)—— 多价位异构模型注册表。
设计(《实现方案_v4_模型池与工具智能体.md》D1):
- 叙事从"端云分工"泛化为"按价位分工":local(零边际成本,内置 llama.cpp)、
budget(低价 API)、premium(高价 API)。位置只是价位的属性之一。
- 池条目存"端点 + 凭据 + 模型名 + 价位 + 单价($/1M tokens",不存模型权重。
- roles 把池条目指派给三个角色:architect(决策/终审)、worker(实现/自验证)、
agent(智能体工具循环)。角色留空 = 沿用经典单模型设置(向后兼容)。
- 持久化到 config/model_pool.jsongitignore,与 settings.json 同级)。
"""
import json
import re
import threading
from pathlib import Path
from typing import Any, Dict, Optional
_POOL_PATH = Path(__file__).resolve().parent.parent / "config" / "model_pool.json"
# 合法取值
TIERS = ("local", "budget", "premium")
BACKENDS = ("mock", "llama_server", "openai")
ROLES = ("architect", "worker", "agent")
# 池条目允许的字段(其余字段拒绝写入)
ENTRY_FIELDS = {
"id", "name", "tier", "backend", "base_url", "model", "api_key",
"price_in", "price_out", "temperature", "max_tokens", "enabled",
}
# 单价默认值($/1M tokens);local 档为 0
PRICE_DEFAULTS = {"local": 0.0, "budget": 0.1, "premium": 1.0}
def _empty_pool() -> Dict[str, Any]:
return {
"roles": {"architect": "", "worker": "", "agent": ""},
"entries": [],
}
class PoolError(ValueError):
"""池条目/角色配置非法。"""
class PoolStore:
"""模型池注册表(内存 + model_pool.json 持久化,线程安全)。"""
def __init__(self, path: Optional[Path] = None):
self._path = Path(path) if path else _POOL_PATH
self._lock = threading.Lock()
self._data = _empty_pool()
self.load()
# ---------- 持久化 ----------
def load(self) -> None:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
self._data = {
"roles": {**_empty_pool()["roles"],
**(raw.get("roles") or {})},
"entries": list(raw.get("entries") or []),
}
except Exception:
self._data = _empty_pool()
else:
self._data = _empty_pool()
def save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 条目 CRUD ----------
def list(self) -> Dict[str, Any]:
"""返回完整池(api_key 打码)。"""
with self._lock:
return {
"roles": dict(self._data["roles"]),
"entries": [self._masked(e) for e in self._data["entries"]],
}
def get(self, entry_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
for e in self._data["entries"]:
if e.get("id") == entry_id:
return dict(e)
return None
def upsert(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""新增或更新条目(按 id)。返回打码后的条目。"""
clean = self._validate(entry)
with self._lock:
entries = self._data["entries"]
for i, e in enumerate(entries):
if e.get("id") == clean["id"]:
# 空 api_key 表示保留原值(前端不回传明文)
if not clean.get("api_key"):
clean["api_key"] = e.get("api_key", "")
entries[i] = clean
self.save()
return self._masked(clean)
entries.append(clean)
self.save()
return self._masked(clean)
def delete(self, entry_id: str) -> bool:
with self._lock:
before = len(self._data["entries"])
self._data["entries"] = [
e for e in self._data["entries"] if e.get("id") != entry_id]
changed = len(self._data["entries"]) != before
if changed:
# 清空指向被删条目的角色指派
for role, rid in self._data["roles"].items():
if rid == entry_id:
self._data["roles"][role] = ""
self.save()
return changed
# ---------- 角色指派 ----------
def set_roles(self, roles: Dict[str, str]) -> Dict[str, str]:
"""指派角色 -> 池条目 id(空串 = 沿用经典设置)。"""
with self._lock:
ids = {e.get("id") for e in self._data["entries"]}
for role, rid in roles.items():
if role not in ROLES:
raise PoolError(f"未知角色: {role}")
if rid and rid not in ids:
raise PoolError(f"角色 {role} 指向不存在的模型条目: {rid}")
self._data["roles"][role] = rid or ""
self.save()
return dict(self._data["roles"])
def resolve(self, role: str) -> Optional[Dict[str, Any]]:
"""解析角色当前生效的池条目(未指派/条目禁用时返回 None = 用经典设置)。"""
if role not in ROLES:
return None
with self._lock:
rid = self._data["roles"].get(role, "")
for e in self._data["entries"]:
if e.get("id") == rid:
return dict(e) if e.get("enabled", True) else None
return None
def find_by_model(self, model: str) -> Optional[Dict[str, Any]]:
"""按模型名找条目(用于按模型计价分账)。"""
with self._lock:
for e in self._data["entries"]:
if e.get("model") == model:
return dict(e)
return None
# ---------- 校验与工具 ----------
def _validate(self, entry: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(entry, dict):
raise PoolError("条目必须是对象")
unknown = set(entry) - ENTRY_FIELDS
if unknown:
raise PoolError(f"非法字段: {sorted(unknown)}")
eid = str(entry.get("id") or "").strip()
if not eid:
# 未提供 id 时按名称生成 slug
base = re.sub(r"[^a-zA-Z0-9_-]+", "-",
str(entry.get("name") or entry.get("model") or "model")).strip("-").lower()
eid = base or "model"
with self._lock:
exist = {e.get("id") for e in self._data["entries"]}
if eid in exist:
n = 2
while f"{eid}-{n}" in exist:
n += 1
eid = f"{eid}-{n}"
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", eid):
raise PoolError("id 只允许字母/数字/-/_,长度 1-64")
backend = entry.get("backend", "openai")
if backend not in BACKENDS:
raise PoolError(f"backend 必须是 {BACKENDS} 之一")
tier = entry.get("tier", "budget")
if tier not in TIERS:
raise PoolError(f"tier 必须是 {TIERS} 之一")
if backend != "mock" and not str(entry.get("base_url") or "").strip():
raise PoolError("非 mock 后端必须填写 base_url")
if backend != "mock" and not str(entry.get("model") or "").strip():
raise PoolError("非 mock 后端必须填写 model")
try:
price_in = float(entry.get("price_in", PRICE_DEFAULTS[tier]))
price_out = float(entry.get("price_out", PRICE_DEFAULTS[tier]))
except (TypeError, ValueError):
raise PoolError("price_in/price_out 必须是数字")
if price_in < 0 or price_out < 0:
raise PoolError("单价不能为负")
try:
temperature = float(entry.get("temperature", 0.3))
except (TypeError, ValueError):
temperature = 0.3
try:
max_tokens = int(entry.get("max_tokens", 4096))
except (TypeError, ValueError):
max_tokens = 4096
return {
"id": eid,
"name": str(entry.get("name") or entry.get("model") or eid),
"tier": tier,
"backend": backend,
"base_url": str(entry.get("base_url") or "").strip(),
"model": str(entry.get("model") or "").strip(),
"api_key": str(entry.get("api_key") or ""),
"price_in": price_in,
"price_out": price_out,
"temperature": temperature,
"max_tokens": max_tokens,
"enabled": bool(entry.get("enabled", True)),
}
@staticmethod
def _masked(entry: Dict[str, Any]) -> Dict[str, Any]:
out = dict(entry)
key = out.get("api_key") or ""
out["api_key_set"] = bool(key)
out["api_key"] = (key[:6] + "") if key else ""
return out
def entry_to_architect_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_architect 配置段。"""
cfg: Dict[str, Any] = {
"model": entry.get("model") or "local",
"base_url": entry.get("base_url") or "http://127.0.0.1:8901/v1",
"temperature": float(entry.get("temperature", 0.2)),
}
if entry.get("api_key"):
cfg["api_key"] = entry["api_key"]
if entry.get("max_tokens"):
cfg["max_tokens"] = int(entry["max_tokens"])
return cfg
def entry_to_worker_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_worker 配置段。"""
return {
"backend": entry.get("backend") or "openai",
"base_url": entry.get("base_url") or "",
"model": entry.get("model") or "",
"temperature": float(entry.get("temperature", 0.3)),
}
def compute_cost(entry: Dict[str, Any], input_tokens: int, output_tokens: int) -> float:
"""按条目单价估算成本(USD)。price 单位:$/1M tokens。"""
return (input_tokens / 1e6) * float(entry.get("price_in", 0.0)) + \
(output_tokens / 1e6) * float(entry.get("price_out", 0.0))
# ---------- 全局单例 ----------
_store: Optional[PoolStore] = None
def get_pool() -> PoolStore:
global _store
if _store is None:
_store = PoolStore()
return _store
def reset_pool() -> None:
"""测试用:重置全局池单例。"""
global _store
_store = None
-127
View File
@@ -1,127 +0,0 @@
"""可调整的运行设置(SettingsStore)—— 让用户自定义内置小模型 / 大模型 / 管线。
用户可在 Web 界面"模型设置"里调整并持久化到 config/settings.jsongitignore),
重启后保留。调整会触发 v2 管线重建(gateway.api.build_v2_pipeline 重新读取)。
默认值与 config/config.yaml 的 v2 段一致;settings.json 只存用户改动覆盖项。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Optional
_SETTINGS_PATH = Path(__file__).resolve().parent.parent / "config" / "settings.json"
# 可调整项(含默认值);用户改动存这里
DEFAULTS: Dict[str, Any] = {
"worker": {
"backend": "llama_server", # mock | openai/api | llama_server
"model": "models/qwen3.5-4b-q4_k_m.gguf",
"base_url": "", # openai 后端填 http://127.0.0.1:11434/v1 等
"port": 8901, # llama_server 端口
"temperature": 0.3,
"max_fix_attempts": 2,
"per_step_timeout_s": 15,
"code_timeout_s": 10,
},
"architect": {
"model": "deepseek-v4-flash",
"base_url": "https://api.deepseek.com",
"api_key": "",
},
"pipeline": {
"fast_path": True,
"rounds_cap": 6,
"api_token_cap": 8000,
"breach_policy": "architect_do",
},
"agent": {
"workspace_dir": "agent_workspace", # 智能体工作区根目录(越界即拒)
"recent_workspaces": [], # 最近打开的工作区(供快速切换)
"max_rounds": 8, # 工具循环轮数上限
"token_cap": 20000, # 单次智能体任务 token 熔断
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
"shell_timeout_s": 20, # shell 命令超时
"allow_net": True, # 允许 web_fetch 抓取公网页面(SSRF 防护内置)
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
"approval_policy": "dangerous", # 审批策略:off | dangerous(写/编辑/命令询问)| all
"approval_timeout_s": 120, # 审批等待超时(超时自动拒绝)
},
}
class SettingsStore:
"""用户可调整设置(内存 + settings.json 持久化)。"""
def __init__(self, path: Optional[Path] = None):
self._path = Path(path) if path else _SETTINGS_PATH
self._data: Dict[str, Any] = {}
self.load()
# ---------- 持久化 ----------
def load(self) -> None:
if self._path.exists():
try:
self._data = json.loads(self._path.read_text(encoding="utf-8"))
except Exception:
self._data = {}
else:
self._data = {}
def save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 访问 ----------
def to_dict(self) -> Dict[str, Any]:
"""返回 默认值 + 用户覆盖 合并后的完整设置。"""
merged: Dict[str, Any] = {}
for section, defaults in DEFAULTS.items():
ov = self._data.get(section, {})
merged[section] = {**defaults, **(ov if isinstance(ov, dict) else {})}
return merged
def get(self, section: str, key: str, default: Any = None) -> Any:
merged = self.to_dict()
return merged.get(section, {}).get(key, default)
def update(self, patch: Dict[str, Any]) -> Dict[str, Any]:
"""应用部分更新(可只传改动的 section/key)。返回合并后的完整设置。"""
for section, values in patch.items():
if section not in DEFAULTS or not isinstance(values, dict):
continue
cur = self._data.setdefault(section, {})
for k, v in values.items():
if k in DEFAULTS[section]:
cur[k] = _coerce(v, DEFAULTS[section][k])
self.save()
return self.to_dict()
def reset(self) -> Dict[str, Any]:
"""恢复默认。"""
self._data = {}
self.save()
return self.to_dict()
def _coerce(value: Any, template: Any) -> Any:
"""按默认值的类型把输入转成一致类型(数值容错)。"""
if isinstance(template, bool):
return bool(value)
if isinstance(template, int):
try:
return int(float(value))
except (TypeError, ValueError):
return template
if isinstance(template, float):
try:
return float(value)
except (TypeError, ValueError):
return template
return value
def load_settings(path: Optional[Path] = None) -> SettingsStore:
return SettingsStore(path)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-kbuKhaUa.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,``),d(`th`,null,``),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
@@ -1 +0,0 @@
.metrics-view[data-v-8b237097]{height:100%;padding:20px 24px;overflow-y:auto}.by-model[data-v-8b237097]{border-collapse:collapse;width:100%;font-size:12px}.by-model th[data-v-8b237097],.by-model td[data-v-8b237097]{text-align:left;border-bottom:1px solid #f3f4f6;padding:4px 8px}.by-model th[data-v-8b237097]{color:#6b7280;font-weight:600}.by-model td.mono[data-v-8b237097]{font-family:ui-monospace,Consolas,monospace}.hint[data-v-8b237097]{color:#9ca3af;margin-top:8px;font-size:11px}.metrics-header[data-v-8b237097]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-8b237097]{margin:0;font-size:20px}.refresh[data-v-8b237097]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-8b237097],.error[data-v-8b237097]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-8b237097]{color:#dc2626}.card-grid[data-v-8b237097]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-8b237097]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-8b237097]{border-color:var(--c-primary);background:var(--c-primary-soft)}.metric-card h3[data-v-8b237097]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-8b237097]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-8b237097]{color:#6b7280}.kv-list b[data-v-8b237097]{color:#111;text-align:right}.review-card[data-v-8b237097]{grid-column:span 2}.review-stats[data-v-8b237097]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-8b237097]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-8b237097]{color:var(--c-primary);font-size:28px;font-weight:700}.stat-label[data-v-8b237097]{color:#6b7280;font-size:12px}.progress-wrap[data-v-8b237097]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-8b237097]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-8b237097]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-8b237097]{background:var(--c-bg);border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-8b237097]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-8b237097]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
@@ -1 +0,0 @@
.review-view[data-v-d5b38f1c]{height:100%;padding:20px 24px;overflow-y:auto}.review-header[data-v-d5b38f1c]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.review-header h2[data-v-d5b38f1c]{margin:0;font-size:20px}.controls[data-v-d5b38f1c]{gap:8px;display:flex}button[data-v-d5b38f1c]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px;font-size:13px}button.active[data-v-d5b38f1c]{background:var(--c-primary);color:#fff;border-color:var(--c-primary)}.refresh-btn[data-v-d5b38f1c]{margin-left:auto}.loading[data-v-d5b38f1c],.error[data-v-d5b38f1c],.empty[data-v-d5b38f1c]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-d5b38f1c]{color:#dc2626}.queue-list[data-v-d5b38f1c]{flex-direction:column;gap:16px;display:flex}.review-card[data-v-d5b38f1c]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.card-header[data-v-d5b38f1c]{flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px;display:flex}.card-id[data-v-d5b38f1c]{color:#6b7280;font-family:monospace;font-size:12px}.verdict-badge[data-v-d5b38f1c]{border-radius:99px;padding:2px 8px;font-size:12px;font-weight:600}.verdict-badge.pending[data-v-d5b38f1c]{color:#92400e;background:#fef3c7}.verdict-badge.approved[data-v-d5b38f1c]{color:#16a34a;background:#dcfce7}.verdict-badge.rejected[data-v-d5b38f1c]{color:#dc2626;background:#fee2e2}.tags[data-v-d5b38f1c]{gap:4px;display:flex}.tag[data-v-d5b38f1c]{color:#3730a3;background:#e0e7ff;border-radius:4px;padding:1px 6px;font-size:11px}.date[data-v-d5b38f1c]{color:#9ca3af;margin-left:auto;font-size:11px}.query-block[data-v-d5b38f1c],.response-block[data-v-d5b38f1c]{margin-bottom:8px;font-size:13px;line-height:1.6}.query-block pre[data-v-d5b38f1c],.response-block pre[data-v-d5b38f1c]{background:var(--c-bg);white-space:pre-wrap;border:1px solid #e5e7eb;border-radius:4px;margin:4px 0 0;padding:6px 10px;font-size:13px}.actions[data-v-d5b38f1c]{flex-direction:column;gap:8px;margin-top:10px;display:flex}textarea[data-v-d5b38f1c]{resize:vertical;box-sizing:border-box;border:1px solid #d1d5db;border-radius:6px;width:100%;padding:8px 10px;font-family:inherit;font-size:13px}.btn-row[data-v-d5b38f1c]{gap:8px;display:flex}.approve[data-v-d5b38f1c]{color:#16a34a;background:#dcfce7;border-color:#86efac}.reject[data-v-d5b38f1c]{color:#dc2626;background:#fee2e2;border-color:#fca5a5}.correction[data-v-d5b38f1c]{background:#fffbeb;border:1px solid #fcd34d;border-radius:4px;margin-top:8px;padding:6px 10px;font-size:13px}
@@ -1 +0,0 @@
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-kbuKhaUa.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>端云协同 LLM 协作系统</title>
<script type="module" crossorigin src="/static/assets/index-kbuKhaUa.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-BYO22xUl.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -1,13 +0,0 @@
id,domain,a1_full,a2_ws,a3_rollup,a4_prefix,prefix_hit,reduction_a2,reduction_a4
code_01,code,408,169,209,209,163,0.5858,0.4877
code_02,code,402,162,202,202,156,0.597,0.4975
code_03,code,406,166,206,206,161,0.5911,0.4926
math_01,math,395,155,195,195,150,0.6076,0.5063
math_02,math,392,153,193,193,147,0.6097,0.5077
legal_01,legal,408,169,209,209,166,0.5858,0.4877
medical_01,medical,403,157,197,197,158,0.6104,0.5112
finance_01,finance,399,153,193,193,154,0.6165,0.5163
life_01,life,392,152,192,192,146,0.6122,0.5102
education_01,education,396,148,189,189,154,0.6263,0.5227
general_01,general,403,157,197,197,158,0.6104,0.5112
general_02,general,396,149,190,190,150,0.6237,0.5202
1 id domain a1_full a2_ws a3_rollup a4_prefix prefix_hit reduction_a2 reduction_a4
2 code_01 code 408 169 209 209 163 0.5858 0.4877
3 code_02 code 402 162 202 202 156 0.597 0.4975
4 code_03 code 406 166 206 206 161 0.5911 0.4926
5 math_01 math 395 155 195 195 150 0.6076 0.5063
6 math_02 math 392 153 193 193 147 0.6097 0.5077
7 legal_01 legal 408 169 209 209 166 0.5858 0.4877
8 medical_01 medical 403 157 197 197 158 0.6104 0.5112
9 finance_01 finance 399 153 193 193 154 0.6165 0.5163
10 life_01 life 392 152 192 192 146 0.6122 0.5102
11 education_01 education 396 148 189 189 154 0.6263 0.5227
12 general_01 general 403 157 197 197 158 0.6104 0.5112
13 general_02 general 396 149 190 190 150 0.6237 0.5202
@@ -1,38 +0,0 @@
# E1 token 经济学(本地确定性测量)
> 模式:本地 estimate_tokens 测量(不调用真实 API)。真实数据需 --live + API key + 本地模型。
- 样例数:12
- A1 全量上下文均值:**400.0 token**
- A2 交流文本均值:**157.5 token**
- A3 A2+rollup 均值:**197.67 token**
- A4 A3+prefix 均值:**197.67 token**prefix 可命中 155.25 token
## 北极星指标(token 下降 ≥80%)
- A2 相对 A1**60.6%**
- A4 相对 A1**50.6%**
### 说明(诚实解读)
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 |
|----|--------|----|----|----|----|----|
| code_01 | code | 408 | 169 | 209 | 209 | 163 |
| code_02 | code | 402 | 162 | 202 | 202 | 156 |
| code_03 | code | 406 | 166 | 206 | 206 | 161 |
| math_01 | math | 395 | 155 | 195 | 195 | 150 |
| math_02 | math | 392 | 153 | 193 | 193 | 147 |
| legal_01 | legal | 408 | 169 | 209 | 209 | 166 |
| medical_01 | medical | 403 | 157 | 197 | 197 | 158 |
| finance_01 | finance | 399 | 153 | 193 | 193 | 154 |
| life_01 | life | 392 | 152 | 192 | 192 | 146 |
| education_01 | education | 396 | 148 | 189 | 189 | 154 |
| general_01 | general | 403 | 157 | 197 | 197 | 158 |
| general_02 | general | 396 | 149 | 190 | 190 | 150 |
-32
View File
@@ -1,32 +0,0 @@
# v2 实验目录(research/v2_experiments/
端云协同 LLM 协作系统(《实现方案_v2》第 9 节)的论文数据来源。
## E1 token 经济学(主实验)— 已有本地确定性结果 ✅
- 脚本:`scripts/bench_tokens.py`
- 数据集:`eval/v2_sample.json`12 条,code/math/legal/medical/finance/life/education/general
- 输出:`E1_token_economics.csv``E1_token_economics.md`
- 当前(本地 estimate_tokens 测量):**A2 交流文本相对 A1 全量逐字上下文降 ~61%**,
稳定前缀可命中 ~99% 的 A2 输入。
- 待办:`--live` 模式(API key + 本地 llama-server)确认 ≥80% 北极星。
## E2 端到端质量 — 待接入
- 三臂:快路径 only / 完整协作管线 / 纯 Architect。
- 判分:machine_checkable 用断言;其余 LLM rubric + 10% 人工抽检。
## E3 协作健康度
- 升级率 / 回合数分布 / issue 率 / 自修成功率 / 熔断次数(可复用 /metrics 的 V2Stats)。
## E4 KV 量化内存-精度曲线
- fp16 / q8_0 / q4_0 × 上下文 4K/16K/32K:进程内存 × Worker 验证准确率。需真实 llama-server。
## E5 验证器 P/R
- 100 产物注入 50 处缺陷,测接地验证拦截率/误杀率;对照组=纯模型自由判断。
- 验证器已实现(router_system/verifier.py),跑数待接入。
> 运行命令:`.venv/Scripts/python.exe scripts/bench_tokens.py`
-228
View File
@@ -1,228 +0,0 @@
"""ArchitectClient —— 大模型(API)客户端,端云协同的"决策/终审"角色。
职责(对齐《实现方案_v2》5.1 / 6.3):
- brief(query):开局任务分析 -> 生成交流文本的 briefgoal/constraints/acceptance/plan/tags
- decide(ws):读 issues 等 -> 输出裁决(reply + patch_plan 修订计划)
- final_review(ws):终审 -> {verdict: done|fix, issues: [...]}
工程约束(D7 / D8 / D9 / D11):
- 走 OpenAI 兼容 /chat/completionsresponse_format={"type":"json_object"}prompt 内嵌 schema 描述。
- Architect 输入永不包含工件全文:只传 Workspace 渲染出的 meta+issues+decisions+锚点片段(render_for_architect)。
- 所有结构化输出解析为 JSON;失败把错误回喂重写一次,仍失败抛 ArchitectError(由编排层降级,禁止带病继续)。
- token 计量回写 ws.meta.budget;调用前先查预算,触顶抛 ArchitectCircuitBreakerD6)。
- httpx 惰性导入(零顶层依赖,对齐仓库既有 APIExpert 模式);client/transport 可注入,测试用 httpx.MockTransportD11)。
"""
from __future__ import annotations
import json
from typing import Any, Callable, Dict, List, Optional
from .workspace import Workspace
# brief 的 JSON schema(描述性提示,约束模型输出结构)
_BRIEF_SCHEMA_HINT = {
"goal": "string(<=500字)",
"constraints": "string[](<=8条)",
"tags": "string[](code/math/legal/medical/finance/life/education/general/safety 之一)",
"acceptance": "list[{id, check(string), machine_checkable(bool)}]",
"plan": "list[{id, task(string<=300字), deps(string[]), done_criteria(string)}](<=5步, 有依赖序)",
}
_DECIDE_SCHEMA_HINT = {
"reply": "string(<=600字)",
"patch_plan": "list[{id, task(string)}]",
}
_REVIEW_SCHEMA_HINT = {
"verdict": "enum(done|fix)",
"notes": "string(<=300字)",
"fix_issues": "list[string]",
}
_SYSTEM_PROMPT = (
"你是任务分析架构师。你的输入是不含工件全文的协作摘要(交流文本),"
"你的输出必须是合法 JSON 对象(不要用 markdown 代码块包裹)。"
)
class ArchitectError(RuntimeError):
"""Architect 调用失败(网络/超时/JSON 解析失败/服务错误)。"""
class ArchitectCircuitBreaker(RuntimeError):
"""预算熔断(D6):api_token_cap / rounds_cap 触顶。"""
class ArchitectClient:
"""DeepSeek(或任意 OpenAI 兼容)大模型客户端。"""
def __init__(
self,
model: str,
base_url: str = "https://api.deepseek.com",
api_key: Optional[str] = None,
temperature: float = 0.2,
timeout_s: float = 60.0,
max_tokens: int = 2048,
transport: Any = None,
_client: Any = None,
):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.temperature = temperature
self.timeout_s = timeout_s
self.max_tokens = max_tokens
self._transport = transport
self._client = _client # 注入的 AsyncClient(测试用 MockTransport
self._owns_client = _client is None
def _get_client(self):
if self._client is None:
import httpx
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
if self._transport is not None:
kwargs["transport"] = self._transport
self._client = httpx.AsyncClient(**kwargs)
return self._client
async def aclose(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
# ---------------------------------------------------------------
# 三个对外能力
# ---------------------------------------------------------------
async def brief(self, query: str, ws: Workspace) -> Dict[str, Any]:
"""生成 brief。返回解析后的 brief dicttoken 计量写入 ws。"""
user = (
"用户原始需求:" + "\n" + query + "\n\n"
"请生成任务 brief,仅输出符合如下结构的 JSON 对象:" + "\n"
+ json.dumps(_BRIEF_SCHEMA_HINT, ensure_ascii=False)
+ "\n注意:plan 中的 id 用 s1..sndeps 引用已完成步骤 id"
"acceptance 尽量 machine_checkable。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def decide(self, ws: Workspace) -> Dict[str, Any]:
"""根据交流文本当前状态做裁决。返回 {reply, patch_plan}。"""
context = ws.render_for_architect()
user = (
"以下是交流文本摘要(不含工件全文):" + "\n\n" + context + "\n\n"
"请针对未解决 issues 做出裁决,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_DECIDE_SCHEMA_HINT, ensure_ascii=False)
+ "\nreply 给 Worker 具体可执行指示;patch_plan 列出需要修订的 step 与任务。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def final_review(self, ws: Workspace) -> Dict[str, Any]:
"""终审。返回 {verdict: done|fix, notes, fix_issues}。"""
context = ws.render_for_architect()
user = (
"以下是待终审的交流文本摘要:" + "\n\n" + context + "\n\n"
"对照 brief 的 acceptance 做终审,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False)
+ "\nverdict=done 表示验收通过;fix 表示打回,fix_issues 列出需修正项。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
# ---------------------------------------------------------------
# 底层
# ---------------------------------------------------------------
async def _chat_with_retry(self, ws: Workspace,
msgs: List[tuple]) -> Dict[str, Any]:
"""调用 + JSON 解析;解析失败回喂一次重写,再失败抛 ArchitectError。"""
if not self.api_key:
raise ArchitectError(
"Architect 未配置 API Keyenv: 见 config.architect.api_key_env)。"
"请设置密钥,或使用本地降级模式(pipeline.breach_policy: local_only)。"
)
messages = [{"role": r, "content": c} for r, c in msgs]
for attempt in (1, 2):
content = await self._chat_once(ws, messages)
try:
return self._parse_json(content)
except ValueError as e:
if attempt == 1:
# 4.6:把原始输出与错误回喂重写一次
messages = messages + [
{"role": "assistant", "content": content},
{"role": "user",
"content": f"你的输出不是合法 JSON{e})。请重新只输出合法 JSON 对象。"},
]
continue
raise ArchitectError(f"Architect 输出非合法 JSON,重试后仍失败: {e}") from e
raise ArchitectError("未预期:_chat_with_retry 未返回") # 不可达
async def _chat_once(self, ws: Workspace, messages: List[Dict[str, Any]]) -> str:
if ws.exhausted():
raise ArchitectCircuitBreaker(
f"预算熔断:api_tokens={ws.budget()['api_input_tokens'] + ws.budget()['api_output_tokens']}"
f"/{ws.budget()['api_token_cap']}, round={ws.meta()['round']}/{ws.budget()['rounds_cap']}"
)
client = self._get_client()
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
body = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"response_format": {"type": "json_object"},
}
try:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=body,
)
resp.raise_for_status()
except Exception as e:
raise ArchitectError(f"Architect API 调用失败: {type(e).__name__}: {e}") from e
data = resp.json()
usage = data.get("usage", {})
ws.add_budget(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError) as e:
raise ArchitectError(f"Architect 响应缺少 choices/content: {e}") from e
@staticmethod
def _parse_json(content: str) -> Dict[str, Any]:
"""从模型输出解析 JSON:剥除 markdown 代码围栏,取首个 JSON 对象。"""
text = content.strip()
_bt = "\x60" # 反引号(避免与构建脚本的字符串定界冲突)
fence = _bt * 3
text = text.replace(fence + "json", fence).replace(fence, "").strip()
try:
obj = json.loads(text)
if isinstance(obj, dict):
return obj
raise ValueError("顶层不是 object")
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
obj = json.loads(text[start:end + 1])
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
raise ValueError("无法解析为 JSON 对象")
def build_architect(cfg: Dict[str, Any],
get_env: Callable[[str], Optional[str]] = None) -> ArchitectClient:
"""cfg 为 config.architect 段。get_env 可注入(默认读 os.environ)。"""
import os
_env = get_env or os.environ.get
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
return ArchitectClient(
model=cfg.get("model", "deepseek-v4-flash"),
base_url=cfg.get("base_url", "https://api.deepseek.com"),
api_key=key,
temperature=float(cfg.get("temperature", 0.2)),
timeout_s=float(cfg.get("timeout_s", 60)),
)
+6 -65
View File
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63s=2 -> 0.86s=3 -> 0.95。
from __future__ import annotations from __future__ import annotations
import math import math
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Tuple
from .difficulty import estimate_difficulty from .difficulty import estimate_difficulty
from .models import Classification from .models import Classification
@@ -51,14 +51,6 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("知识产权", 1.1), ("版权", 0.9), ("专利", 0.9), ("违约", 0.9), ("赔偿", 0.8), ("知识产权", 1.1), ("版权", 0.9), ("专利", 0.9), ("违约", 0.9), ("赔偿", 0.8),
("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0), ("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0),
("法规", 0.8), ("条款", 0.7), ("律师", 0.8), ("起诉", 0.9), ("判决", 0.9), ("法规", 0.8), ("条款", 0.7), ("律师", 0.8), ("起诉", 0.9), ("判决", 0.9),
# 劳动法
("加班", 0.9), ("加班费", 1.0), ("工资", 0.8), ("辞退", 0.9), ("裁员", 0.9),
("试用期", 0.9), ("社保", 0.8), ("公积金", 0.8), ("年假", 0.9), ("离职", 0.8),
("解除劳动合同", 1.1), ("经济补偿", 1.0), ("竞业", 1.0),
# 房产/婚姻/消费者
("租房", 0.9), ("买房", 0.9), ("购房", 0.9), ("押金", 0.8), ("房贷", 0.9),
("离婚", 1.0), ("继承", 0.9), ("遗产", 0.9), ("抚养权", 0.9), ("遗嘱", 0.9),
("退款", 0.9), ("退货", 0.8), ("消费者", 0.8), ("七天无理由", 1.0), ("维权", 0.8),
("law", 1.0), ("legal", 1.1), ("contract", 1.0), ("compliance", 1.0), ("law", 1.0), ("legal", 1.1), ("contract", 1.0), ("compliance", 1.0),
("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 0.9), ("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 0.9),
("liability", 0.9), ("regulatory", 0.8), ("jurisdiction", 0.9), ("liability", 0.9), ("regulatory", 0.8), ("jurisdiction", 0.9),
@@ -69,17 +61,11 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("医生", 0.9), ("血压", 0.9), ("高血压", 1.0), ("糖尿病", 1.0), ("感冒", 0.9), ("医生", 0.9), ("血压", 0.9), ("高血压", 1.0), ("糖尿病", 1.0), ("感冒", 0.9),
("剂量", 0.9), ("副作用", 0.9), ("手术", 0.9), ("患者", 0.9), ("剂量", 0.9), ("副作用", 0.9), ("手术", 0.9), ("患者", 0.9),
("吃药", 0.9), ("发烧", 1.0), ("疫苗", 0.9), ("感染", 0.9), ("体检", 0.7), ("吃药", 0.9), ("发烧", 1.0), ("疫苗", 0.9), ("感染", 0.9), ("体检", 0.7),
# 急救/消化/心理/营养/儿科
("烫伤", 1.0), ("烧伤", 1.0), ("止血", 0.9), ("扭伤", 0.9), ("中暑", 1.0),
("急救", 0.9), ("腹泻", 0.9), ("拉肚子", 0.9), ("便秘", 0.9), ("", 0.7),
("失眠", 0.9), ("焦虑", 0.9), ("抑郁", 0.9), ("压力", 0.6), ("睡眠", 0.7),
("减肥", 0.8), ("营养", 0.7), ("卡路里", 0.9), ("儿童", 0.8), ("婴儿", 0.9),
("宝宝", 0.8), ("抗生素", 0.9), ("止咳", 0.9),
("medical", 1.0), ("patient", 0.9), ("symptom", 1.0), ("disease", 0.9), ("medical", 1.0), ("patient", 0.9), ("symptom", 1.0), ("disease", 0.9),
("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0), ("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0),
("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0), ("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0),
("diabetes", 1.0), ("surgery", 0.8), ("clinic", 0.7), ("vaccine", 0.9), ("diabetes", 1.0), ("surgery", 0.8), ("clinic", 0.7), ("vaccine", 0.9),
("infection", 0.9), ("first aid", 0.9), ("insomnia", 0.9), ("infection", 0.9),
], ],
"general": [ "general": [
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3), ("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
@@ -88,40 +74,6 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3), ("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3),
("推荐", 0.3), ("评价", 0.3), ("推荐", 0.3), ("评价", 0.3),
], ],
"finance": [
("理财", 1.0), ("投资", 1.0), ("基金", 1.0), ("股票", 1.0), ("债券", 0.9),
("存款", 0.9), ("储蓄", 0.8), ("利率", 0.8), ("利息", 0.8), ("贷款", 1.0),
("房贷", 1.0), ("月供", 0.9), ("保险", 0.9), ("理赔", 0.9), ("保费", 0.8),
("信用卡", 1.0), ("征信", 0.9), ("逾期", 0.9), ("分期", 0.8), ("记账", 0.7),
("预算", 0.7), ("理财规划", 1.0), ("收益率", 0.9), ("定投", 0.9),
("invest", 0.8), ("fund", 0.8), ("stock", 0.9), ("loan", 0.9),
("mortgage", 0.9), ("insurance", 0.9), ("credit card", 0.9),
("finance", 0.8), ("money", 0.6), ("lpr", 0.9), ("投资理财", 1.1),
],
"life": [
("菜谱", 0.9), ("做饭", 0.8), ("烹饪", 0.9), ("美食", 0.8), ("做法", 0.7),
("旅行", 0.9), ("旅游", 0.9), ("攻略", 0.8), ("机票", 0.8), ("酒店", 0.7),
("签证", 0.9), ("景点", 0.8), ("自驾", 0.8),
("装修", 0.9), ("收纳", 0.8), ("家居", 0.7), ("清洁", 0.7), ("打扫", 0.7),
("宠物", 0.9), ("", 0.7), ("", 0.7), ("猫粮", 0.9), ("驱虫", 0.9),
("健身", 0.9), ("锻炼", 0.8), ("跑步", 0.8), ("增肌", 0.9), ("减脂", 0.9),
("瑜伽", 0.8), ("天气", 0.7), ("气温", 0.7),
("recipe", 0.8), ("travel", 0.9), ("trip", 0.8), ("pet", 0.8),
("workout", 0.9), ("gym", 0.8), ("weather", 0.7), ("cook", 0.8),
],
"education": [
("学习方法", 1.0), ("怎么学", 0.7), ("高效学习", 1.0), ("记忆", 0.6), ("复习", 0.7),
("预习", 0.7), ("笔记", 0.6), ("专注", 0.6), ("拖延", 0.7), ("学习效率", 0.9),
("考试", 0.9), ("备考", 1.0), ("刷题", 0.9), ("模拟考", 0.9), ("中考", 0.9),
("高考", 0.9), ("考研", 0.9), ("考前", 0.7),
("英语", 0.8), ("单词", 0.7), ("口语", 0.8), ("听力", 0.7), ("雅思", 1.0),
("托福", 1.0), ("四级", 0.9), ("六级", 0.9), ("背单词", 0.9),
("选课", 0.9), ("课程", 0.6), ("专业选择", 0.9), ("报班", 0.8), ("网课", 0.7),
("自学", 0.7), ("职业规划", 1.0), ("求职", 0.9), ("面试", 0.8), ("简历", 0.8),
("实习", 0.7), ("跳槽", 0.8), ("转行", 0.9),
("study", 0.8), ("exam", 0.9), ("language", 0.7), ("career", 0.8),
("interview", 0.8), ("education", 0.7), ("learn", 0.6),
],
} }
_STOPWORDS = { _STOPWORDS = {
@@ -140,21 +92,11 @@ class BaseClassifier:
class RuleClassifier(BaseClassifier): class RuleClassifier(BaseClassifier):
"""基于关键词规则的分类器(零依赖)。 """基于关键词规则的分类器(零依赖)。"""
domains 参数(可选):限定只对部分领域打分 —— 两级路由中, def __init__(self, confidence_floor: float = 0.55):
每个大领域的组内路由模型用 RuleClassifier(domains=组内领域)
只认识本组领域,体积与匹配开销约为统一分类器的 1/4。
"""
def __init__(self, confidence_floor: float = 0.55,
domains: Optional[List[str]] = None):
self.confidence_floor = confidence_floor self.confidence_floor = confidence_floor
if domains is None:
self.rules = DOMAIN_RULES self.rules = DOMAIN_RULES
else:
self.rules = {d: DOMAIN_RULES[d] for d in domains if d in DOMAIN_RULES}
self.domains = list(self.rules.keys())
def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]: def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
q = query.lower() q = query.lower()
@@ -218,7 +160,7 @@ class HuggingFaceClassifier(BaseClassifier):
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。 仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
""" """
def __init__(self, model_name: str, num_labels: int = 8, confidence_floor: float = 0.55): def __init__(self, model_name: str, num_labels: int = 5, confidence_floor: float = 0.55):
try: try:
from transformers import AutoModelForSequenceClassification, AutoTokenizer from transformers import AutoModelForSequenceClassification, AutoTokenizer
except ImportError as e: except ImportError as e:
@@ -229,8 +171,7 @@ class HuggingFaceClassifier(BaseClassifier):
self.model = AutoModelForSequenceClassification.from_pretrained( self.model = AutoModelForSequenceClassification.from_pretrained(
model_name, num_labels=num_labels model_name, num_labels=num_labels
) )
self.labels = ["code", "math", "legal", "medical", "general", self.labels = ["code", "math", "legal", "medical", "general"]
"finance", "life", "education"]
self.confidence_floor = confidence_floor self.confidence_floor = confidence_floor
def classify(self, query: str) -> Classification: def classify(self, query: str) -> Classification:
+5 -23
View File
@@ -15,41 +15,23 @@ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "confi
_DEFAULTS: Dict[str, Any] = { _DEFAULTS: Dict[str, Any] = {
"system": {"name": "multi-expert-router", "version": "0.1.0"}, "system": {"name": "multi-expert-router", "version": "0.1.0"},
"router": { "router": {
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走最后处理者 "low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走大模型
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级最后处理者 "judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级大模型
"default_temperature": 0.2, "default_temperature": 0.2,
}, },
"execution": {
"mode": "rule", # ruleL0 零参数)| hybrid
"planner": "rule", # rule | hf
"expert_backend": "rule", # rule(规则执行器)| hf | api
"model_level": "L0", # L0 | L1 | L2
"max_plan_depth": 3,
},
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55}, "classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
"domains": ["code", "math", "legal", "medical", "finance", "life", "education", "general"], "domains": ["code", "math", "legal", "medical", "general"],
# 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
"domain_groups": {
"tech": ["code", "math"],
"professional": ["legal", "medical", "finance"],
"lifestyle": ["life", "education"],
"general": ["general"],
},
"experts": { "experts": {
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"}, "code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"finance": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"life": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
"education": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"}, "general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
}, },
"fallback": { "fallback": {
"type": "mock", "type": "mock",
"model": "deepseek-v4-flash", "model": "deepseek-chat",
"base_url": "https://api.deepseek.com", "base_url": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
}, },
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"}, "judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
-370
View File
@@ -1,370 +0,0 @@
"""执行器体系(L0 默认专家 + NodeExecutor 后端抽象)。
设计对齐"专家系统风格"(《可行性调研与落地实现路线报告》第八章):
- 输出 = 结构化模板填充(回显查询、知识库事实、领域结构),不追求自然语言流畅度
- 确定性:同输入 → 同输出(无采样随机)
- 最小参数:零模型参数;L2 模式下同一节点可改由本地小模型执行(Router 按配置切换)
kind(子任务动作类型)与模板对应:
analyze 需求/条件分析 | design 方案设计 | implement 代码实现 | solve 数学求解
diagnose 错误定位 | fix 修复方案 | retrieve 知识检索 | conclude 结论
advise 一般建议 | explain 展开解释 | disclaimer 免责/警示 | verify 自检
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from .experts import Expert, extract_content_terms
from .knowledge import KnowledgeBase
from .memory import TaskNode, WorkingMemory
from .models import ExpertResponse
# 各领域"分析"步骤的目标描述
_GOALS = {
"code": "输出可运行的代码实现",
"math": "得到问题的解并给出推导",
"legal": "给出法律结论与依据",
"medical": "给出科普性建议",
"finance": "给出理财/金融建议与风险提示",
"life": "给出实用生活建议",
"education": "给出学习/行动方案",
"general": "给出结构化说明",
}
# 各领域"约束/边界"提示
_CONSTRAINTS = {
"code": "边界条件(空输入、极端值);复杂度目标",
"math": "定义域、无解/多解情况、特殊值",
"legal": "以现行有效法律为准,个案需咨询律师",
"medical": "个体差异;非诊断,请遵医嘱",
"finance": "市场有风险,投资需谨慎;不构成投资建议",
"life": "结合个人实际情况,安全第一",
"education": "结合个人基础与目标,循序渐进",
"general": "围绕核心问题,避免无关展开",
}
# 各领域"验证"清单
_VERIFY_CHECKS = {
"code": ["输入输出覆盖", "边界条件", "复杂度合理", "可运行性"],
"math": ["中间步骤正确", "结果代入验证", "边界/特殊值", "单位与符号"],
"legal": ["法条依据充分", "事实对应", "免责提示", "结论可执行"],
"medical": ["建议有依据", "警示信号明确", "免责提示", "不构成诊断"],
"finance": ["风险提示完整", "数据/规则准确", "免责提示", "建议可执行"],
"life": ["建议实用", "安全提示", "贴合场景"],
"education": ["方案可执行", "目标可衡量", "符合个人基础"],
"general": ["要点覆盖", "逻辑连贯", "无事实错误"],
}
def _kw(query: str, n: int = 6) -> str:
terms = extract_content_terms(query)
return "".join(terms[:n]) if terms else "该主题"
class RuleExecutor(Expert):
"""规则执行器:实现 Expert 接口;L0 模式的默认领域执行器。"""
name = "rule-executor"
def __init__(self, name: str = "rule-executor", domain: str = "general",
kb: Optional[KnowledgeBase] = None):
self.name = name
self.domain = domain
self.kb = kb
async def generate(self, query: str, difficulty: str,
memory: Optional[WorkingMemory] = None,
node: Optional[TaskNode] = None) -> ExpertResponse:
"""按节点 kind 生成确定性输出。兼容 Expert 基类签名(后两参可选)。"""
kind = node.kind if node is not None else "explain"
domain = node.domain if node is not None else self.domain
text = self._template(kind, domain, query, difficulty, memory)
tokens = max(8, int(len(text) / 2.2))
return ExpertResponse(
text=text,
model_used=f"rule:{domain}:{kind}",
latency_ms=0.0,
tokens=tokens,
cost_est=0.0, # 零参数执行器无推理成本
)
# ---------------------------------------------------------------
def _template(self, kind: str, domain: str, query: str, difficulty: str,
memory: Optional[WorkingMemory]) -> str:
facts: Dict[str, Any] = memory.facts if memory else {}
goal = _GOALS.get(domain, _GOALS["general"])
constraints = _CONSTRAINTS.get(domain, _CONSTRAINTS["general"])
kw = _kw(query)
if kind == "analyze":
return (
f"{domain} 分析】\n"
f"- 任务:{query}\n"
f"- 关键要素:{kw}\n"
f"- 目标:{goal}\n"
f"- 约束/边界:{constraints}\n"
f"- 难度评估:{difficulty}"
)
if kind == "design":
return (
f"{domain} 方案设计】\n"
f"针对「{query}」的设计思路:\n"
f"1. 明确核心目标与验收标准\n"
f"2. 选择合适的方法/数据结构(依据:{kw}\n"
f"3. 拆解实现步骤并标注复杂度\n"
f"4. 预留边界处理与异常路径\n"
f"5. 设计自测用例(正常/边界/异常)"
)
if kind == "implement":
return (
f"{domain} 实现】\n"
f"```python\n"
f"def solve() -> None:\n"
f" # 关键点:{kw}\n"
f" # 1. 校验输入与边界条件\n"
f" # 2. 核心逻辑(依据 design 步骤)\n"
f" # 3. 输出结果\n"
f" pass\n"
f"```\n"
f"要点:{kw};复杂度与边界说明见 design/verify 步骤。"
)
if kind == "solve":
return (
f"{domain} 求解】\n"
f"题目:{query}\n"
f"步骤:\n"
f"1. 提取已知条件({kw}\n"
f"2. 选择方法:代数变形/公式代入/逐步推导\n"
f"3. 求解并化简中间结果\n"
f"4. 检查特殊值与边界\n"
f"结论:在标准假设下可得到闭合形式解;完整推导见正式解答。"
)
if kind == "diagnose":
return (
f"{domain} 诊断】\n"
f"错误现象:{query}\n"
f"排查步骤:\n"
f"1. 复现并定位出错行\n"
f"2. 检查变量类型与取值(重点:{kw}\n"
f"3. 核对函数签名、作用域与返回值\n"
f"4. 打印中间变量验证假设\n"
f"5. 用最小样例隔离问题"
)
if kind == "fix":
return (
f"{domain} 修复方案】\n"
f"针对「{query}」:\n"
f"1. 根因:见 diagnose 步骤\n"
f"2. 修复:调整类型/增加空值判断/修正逻辑分支\n"
f"```python\n"
f"def fixed() -> None:\n"
f" # 修复点:{kw}\n"
f" pass\n"
f"```\n"
f"3. 回归:补充对应单测后重跑"
)
if kind == "retrieve":
return self._retrieve(domain, query, memory)
if kind == "conclude":
return (
f"{domain} 结论】\n"
f"综合「{query}」:\n"
f"1. 事实梳理:{kw}\n"
f"2. 适用规则/依据(见 retrieve 步骤)\n"
f"3. 结论:在所述前提下,按上述规则处理\n"
f"4. 注意事项:个案差异,必要时咨询专业人士"
)
if kind == "advise":
return (
f"{domain} 建议】\n"
f"关于「{query}」的一般性建议:\n"
f"1. 基础注意事项({kw}\n"
f"2. 可操作建议:分步执行并观察效果\n"
f"3. 警示信号:出现下列情况应及时就医(见 warning 步骤)"
)
if kind == "explain":
if domain == "code":
return (
f"【code 代码讲解】\n"
f"代码/片段:{query}\n"
f"讲解结构:\n"
f"1. 整体目的:这段代码要解决什么问题({kw}\n"
f"2. 执行流程:按行/按函数梳理数据流与调用链\n"
f"3. 关键点:数据结构、边界处理、异常路径\n"
f"4. 可改进点:命名/复杂度/可读性建议"
)
return (
f"{domain} 说明】\n"
f"主题:{query}\n"
f"1. 背景与定义\n"
f"2. 核心要点:{kw}\n"
f"3. 分类/维度/机制\n"
f"4. 实际应用与注意事项\n"
f"如需更深入分析,可补充上下文。"
)
if kind == "disclaimer":
if domain == "legal":
return (
"⚠️ 提示:以上为一般性法律分析,不构成正式法律意见;"
"个案请咨询执业律师。"
)
if domain == "medical":
return (
"⚠️ 提示:以上内容仅供健康科普,不能替代医生诊断;"
"如有不适请及时就医。"
)
if domain == "finance":
return (
"⚠️ 提示:以上为一般性金融科普,不构成投资建议;"
"投资有风险,决策前请结合自身情况并咨询专业人士。"
)
return ""
if kind == "verify":
checks = _VERIFY_CHECKS.get(domain, _VERIFY_CHECKS["general"])
items = "\n".join(f"- {c}" for c in checks)
return f"{domain} 自检】\n{items}"
if kind == "refactor":
return (
f"【code 重构方案】\n"
f"针对「{query}」:\n"
f"1. 现状问题:重复代码/长函数/命名不清/耦合({kw}\n"
f"2. 重构手法:提取函数、消除魔法数字、引入类或模块、统一命名\n"
f"3. 目标结构:单一职责、清晰分层、可测试性\n"
f"4. 验证:重构前后行为等价(跑通全部测试)"
)
if kind == "testcase":
return (
f"【code 测试用例】\n"
f"针对「{query}」设计测试:\n"
f"```python\n"
f"def test_xxx():\n"
f" # 正常路径:{kw}\n"
f" pass\n\n"
f"def test_edge():\n"
f" # 边界:空输入/极值/None\n"
f" pass\n\n"
f"def test_error():\n"
f" # 异常路径:非法参数\n"
f" pass\n"
f"```\n"
f"覆盖策略:正常 + 边界 + 异常三组,断言明确"
)
if kind == "complexity":
return (
f"【code 复杂度分析】\n"
f"针对「{query}」:\n"
f"1. 时间复杂度:核心循环/递归层数 → 平均与最坏情况({kw}\n"
f"2. 空间复杂度:辅助数据结构占用\n"
f"3. 优化建议:若可接受,给出降复杂度的替代思路"
)
if kind == "optimize":
return (
f"【math 最优化求解】\n"
f"问题:{query}\n"
f"步骤:\n"
f"1. 建立目标函数与约束({kw}\n"
f"2. 求导/配方/不等式法找候选极值点\n"
f"3. 比较候选值并与边界比较\n"
f"4. 结论:给出最大值/最小值及取到条件"
)
if kind == "draft":
return (
f"【写作初稿】\n"
f"主题:{query}\n"
f"结构:\n"
f"1. 开头:点明主题与背景({kw}\n"
f"2. 主体:分点展开,每点配一个例子或依据\n"
f"3. 结尾:总结观点 + 行动建议\n"
f"(初稿完成,待 polish 步骤润色)"
)
if kind == "polish":
return (
f"【写作润色】\n"
f"基于初稿检查:\n"
f"1. 语法与错别字\n"
f"2. 逻辑衔接与段落过渡\n"
f"3. 语气统一(正式/亲切)与受众匹配\n"
f"4. 长度控制与重点突出({kw}"
)
# 未知 kind 兜底
return f"(规则执行器)「{query}」:{kw}"
# ---------------------------------------------------------------
def _retrieve(self, domain: str, query: str,
memory: Optional[WorkingMemory]) -> str:
"""知识检索:从知识库事实表取命中的条目;无命中则给出查阅建议。"""
if self.kb is None:
return (
f"{domain} 知识检索】\n"
f"未配置知识库,建议查阅权威资料({_kw(query)})。"
)
facts = self.kb.facts(domain)
hits = [f for f in facts if any(k in query for k in f.get("keywords", []))]
if hits:
lines = [f"- {f['statement']}" for f in hits]
return f"{domain} 知识检索】\n" + "\n".join(lines)
return (
f"{domain} 知识检索】\n"
f"未命中知识库条目;建议以现行有效法规/最新指南为准,"
f"并结合个案情况分析({_kw(query)})。"
)
# ===============================================================
# NodeExecutor:子任务执行后端抽象(T1:整体项目部分拆解·先行实现)
#
# Router._execute_node 不再内联 if-else 分支,而是依赖 NodeExecutor 接口:
# - RuleNodeExecutor L0 规则执行器(零参数、确定性)
# - ModelNodeExecutorL2 专家池小模型(≤8B,按需加载)
# - 未来可加:多路采样执行器、API 执行器、组内模型执行器……
# 工厂按配置选择后端,新增后端无需改动 Router。
# ===============================================================
class NodeExecutor:
"""子任务执行后端抽象接口。"""
name: str = "node-executor"
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
raise NotImplementedError
class RuleNodeExecutor(NodeExecutor):
"""L0:规则执行器后端(零参数、确定性、零成本)。"""
name = "rule"
def __init__(self, kb: Optional[KnowledgeBase] = None):
self._rule = RuleExecutor("rule-executor", "general", kb=kb)
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
return await self._rule.generate(node.query, difficulty, memory, node)
class ModelNodeExecutor(NodeExecutor):
"""L2:专家池小模型后端(≤8B;组内模型按需加载,用完即卸载由推理服务管理)。"""
name = "model"
def __init__(self, experts: Dict[str, Expert]):
self._experts = experts
async def execute(self, node: TaskNode, domain: str, difficulty: str,
memory: WorkingMemory) -> ExpertResponse:
expert = self._experts.get(node.domain) or self._experts.get("general")
return await expert.generate(node.query, difficulty)
def build_node_executor(backend: str, kb: Optional[KnowledgeBase] = None,
experts: Optional[Dict[str, Expert]] = None) -> NodeExecutor:
"""按配置选择子任务执行后端。"""
if backend == "rule":
return RuleNodeExecutor(kb=kb)
if backend in ("hf", "api", "model"):
if not experts:
raise ValueError("ModelNodeExecutor 需要专家池(experts")
return ModelNodeExecutor(experts)
raise ValueError(f"未知执行后端: {backend}(支持 rule | hf | api | model")
+4 -90
View File
@@ -1,10 +1,4 @@
"""大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API """大模型回退层Mock 与 OpenAI 兼容 API 两种后端。"""
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
- MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
- LocalFallback :本地小模型(≤8BQ4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
"""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -20,30 +14,6 @@ class FallbackProvider:
raise NotImplementedError raise NotImplementedError
class NoneFallback(FallbackProvider):
"""降级模板:零参数兜底,明确告知查询超出知识库范围。"""
def __init__(self, model: str = "none"):
self.model = model
self.name = "fallback-none"
async def generate(self, query: str) -> ExpertResponse:
body = (
f"(降级响应)「{query}\n\n"
"当前查询超出本地知识库可处理范围(低置信度或质量校验未通过)。\n"
"可选处理:\n"
"1. 换个更明确的问法重试\n"
"2. 启用 L2 本地小模型或配置最后处理者(fallback.type: local\n"
)
return ExpertResponse(
text=body,
model_used=self.model,
latency_ms=0.0,
tokens=80,
cost_est=0.0,
)
class MockFallback(FallbackProvider): class MockFallback(FallbackProvider):
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。""" """确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
@@ -70,54 +40,6 @@ class MockFallback(FallbackProvider):
) )
class LocalFallback(FallbackProvider):
"""本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1vLLM 默认 8001/v1),
模型按需加载、用完即卸载(由本地推理服务管理),不常驻显存。
"""
def __init__(self, model: str, base_url: str = "http://127.0.0.1:11434/v1",
api_key: str = ""):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.name = f"fallback-local-{model}"
self._client = None
def _get_client(self):
if self._client is None:
import httpx
self._client = httpx.AsyncClient(timeout=120.0)
return self._client
async def generate(self, query: str) -> ExpertResponse:
client = self._get_client()
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json={
"model": self.model,
"messages": [{"role": "user", "content": query}],
"temperature": 0.3,
"max_tokens": 2048,
},
)
resp.raise_for_status()
data = resp.json()
body = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
return ExpertResponse(
text=body,
model_used=self.model,
latency_ms=0.0,
tokens=tokens,
cost_est=0.0, # 本地推理成本按电费计,模型层成本记为 0(相对 API)
)
class APIFallback(FallbackProvider): class APIFallback(FallbackProvider):
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。""" """OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
@@ -163,17 +85,9 @@ class APIFallback(FallbackProvider):
def build_fallback(cfg: Dict) -> FallbackProvider: def build_fallback(cfg: Dict) -> FallbackProvider:
"""cfg 为 fallback 段配置。""" """cfg 为 fallback 段配置。"""
ftype = cfg.get("type", "mock") ftype = cfg.get("type", "mock")
model = cfg.get("model", "deepseek-v4-flash") model = cfg.get("model", "deepseek-chat")
if ftype == "none":
return NoneFallback(model=model)
if ftype == "mock": if ftype == "mock":
return MockFallback(model=model) return MockFallback(model=model)
if ftype == "local":
return LocalFallback(
model,
cfg.get("base_url", "http://127.0.0.1:11434/v1"),
cfg.get("api_key", ""),
)
if ftype == "api": if ftype == "api":
import os import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY")) api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
@@ -181,5 +95,5 @@ def build_fallback(cfg: Dict) -> FallbackProvider:
raise RuntimeError( raise RuntimeError(
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key" f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
) )
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key) return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com/v1"), api_key)
raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api") raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api")
-93
View File
@@ -1,93 +0,0 @@
"""前向链推理机:知识库规则驱动的工作记忆演化(专家系统推理核心,零依赖)。
流程(经典前向链 forward chaining):
1. 初始化黑板:写入领域/难度/置信度等事实
2. 循环:在领域内匹配规则(未触发过的)→ 按优先级执行
- 命中即记录轨迹 rule:<id>@<priority>
- 规则带 output 模板 → 渲染后写入黑板章节(部分解)
- 规则带 actions → 执行动作(写事实/写章节)
3. 终止:无新规则可触发 / 达到步数上限(防死循环)
确定性保证:规则匹配基于子串包含,无随机性;同输入 → 同轨迹。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from .knowledge import KnowledgeBase, Rule
from .memory import WorkingMemory
def render_template(template: str, query: str, facts: Dict[str, Any]) -> str:
"""渲染输出模板:替换 {query}{facts.<key>} 占位符;缺失以 [未提供] 占位,不抛异常。"""
out = template.replace("{query}", query)
for key, value in facts.items():
out = out.replace(f"{{facts.{key}}}", str(value))
# 剩余占位符兜底
while "{" in out and "}" in out:
start = out.find("{")
end = out.find("}", start)
if end == -1:
break
out = out[:start] + "[未提供]" + out[end + 1:]
return out
class InferenceEngine:
"""前向链推理机。"""
def __init__(self, kb: KnowledgeBase, max_steps: int = 20):
self.kb = kb
self.max_steps = max_steps
# ---------------------------------------------------------------
def initialize(self, query: str, domain: str, difficulty: str,
confidence: float, memory: WorkingMemory) -> None:
"""把分类结果写入黑板(事实初始化)。"""
memory.write_fact("query", query)
memory.write_fact("domain", domain)
memory.write_fact("difficulty", difficulty)
memory.write_fact("confidence", round(confidence, 4))
memory.add_trace(f"init:domain={domain},difficulty={difficulty},conf={confidence:.2f}")
# ---------------------------------------------------------------
def run(self, query: str, domain: str, memory: WorkingMemory,
max_steps: Optional[int] = None) -> List[str]:
"""前向链主循环。返回触发规则 id 列表(按触发顺序)。"""
steps = max_steps or self.max_steps
fired: List[str] = []
for _ in range(steps):
rules = self.kb.match(query, domain=domain)
# 选第一个"未触发过"的规则
target: Optional[Rule] = None
for r in rules:
if r.id not in fired:
target = r
break
if target is None:
break # 无新规则可触发 → 终止
fired.append(target.id)
self._fire(target, query, memory)
return fired
# ---------------------------------------------------------------
def _fire(self, rule: Rule, query: str, memory: WorkingMemory) -> None:
"""执行一条规则:记录轨迹 + 写事实 + 产出章节。"""
memory.add_trace(f"rule:{rule.id}@{rule.priority}")
# 规则动作
for action in rule.actions:
self._apply_action(action, rule, query, memory)
# 规则输出模板 → 章节
if rule.output:
text = render_template(rule.output, query, memory.facts)
memory.write_section(rule.id, text)
def _apply_action(self, action: str, rule: Rule, query: str,
memory: WorkingMemory) -> None:
"""动作格式:write_fact:key=valuevalue 支持 {query} 占位)。"""
if action.startswith("write_fact:"):
kv = action[len("write_fact:"):]
key, _, value = kv.partition("=")
value = value.replace("{query}", query)
memory.write_fact(key.strip(), value.strip(), rule_id=rule.id)
# 其他动作类型暂不实现(保留扩展位)
+6 -25
View File
@@ -56,9 +56,8 @@ class BaseJudge:
class RuleJudge(BaseJudge): class RuleJudge(BaseJudge):
"""启发式质量评估(零依赖)。""" """启发式质量评估(零依赖)。"""
def __init__(self, fallback_threshold: float = 0.70, kb=None): def __init__(self, fallback_threshold: float = 0.70):
self.fallback_threshold = fallback_threshold self.fallback_threshold = fallback_threshold
self.kb = kb # 可选知识库:facts 维度校验用
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation: async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
scores: Dict[str, float] = {} scores: Dict[str, float] = {}
@@ -104,25 +103,7 @@ class RuleJudge(BaseJudge):
else: else:
scores["safety"] = 1.0 scores["safety"] = 1.0
# 5) 领域知识引用(facts 维度):法律/医疗响应应覆盖查询命中的知识条目 weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2}
if domain in ("legal", "medical") and self.kb is not None:
facts = self.kb.facts(domain)
hit_facts = [f for f in facts if any(k in query for k in f.get("keywords", []))]
if hit_facts:
ok = sum(
1 for f in hit_facts
if any(k in response for k in f.get("keywords", []))
)
scores["facts"] = ok / len(hit_facts)
if scores["facts"] < 0.6:
reasons.append(f"领域知识引用不足 ({scores['facts']:.0%})")
else:
scores["facts"] = 1.0
else:
scores["facts"] = 1.0
weights = {"coverage": 0.35, "length": 0.15, "format": 0.15,
"safety": 0.15, "facts": 0.2}
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items()) overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
needs = overall < self.fallback_threshold needs = overall < self.fallback_threshold
if needs: if needs:
@@ -176,17 +157,17 @@ class LLMJudge(BaseJudge):
) )
def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> BaseJudge: def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge:
"""cfg 为 judge 段配置。""" """cfg 为 judge 段配置。"""
jtype = cfg.get("type", "rule") jtype = cfg.get("type", "rule")
if jtype == "rule": if jtype == "rule":
return RuleJudge(fallback_threshold=fallback_threshold, kb=kb) return RuleJudge(fallback_threshold=fallback_threshold)
if jtype == "llm": if jtype == "llm":
import os import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY")) api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
return LLMJudge( return LLMJudge(
cfg.get("model", "deepseek-v4-flash"), cfg.get("model", "deepseek-chat"),
cfg.get("base_url", "https://api.deepseek.com"), cfg.get("base_url", "https://api.deepseek.com/v1"),
api_key or "", api_key or "",
fallback_threshold=fallback_threshold, fallback_threshold=fallback_threshold,
) )
-490
View File
@@ -1,490 +0,0 @@
"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。
设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"):
- 领域知识显式化:写在规则文件里(config/knowledge/<domain>.yaml),不藏在模型参数中
- 确定性:规则匹配 = 子串包含(大小写不敏感),同输入同输出
- 可解释:每次命中都记录规则 id,形成推理轨迹
- 最小参数:L0 模式零模型参数,规则即知识
规则文件格式(YAML;若 pyyaml 不可用,可提供同名 .json):
domain: code
rules:
- id: code-sort
priority: 90 # 越大越先触发
patterns: ["排序", "sort"] # 任一子串命中即触发
template: code-implement # 可选:Planner 任务模板 id
output: | # 可选:输出模板({query} 等占位符)
(规则输出)...
facts: # 领域事实表(Judge 校验 / retrieve 执行器用)
- id: legal-nc
keywords: ["竞业"]
statement: "竞业限制期限不得超过二年"
任务模板(config/knowledge/tasks.yaml):
task_templates:
code-implement:
steps:
- {id: analyze, kind: analyze, domain: code}
- {id: design, kind: design, domain: code, deps: [analyze]}
加载顺序:内置默认规则(代码内兜底)→ 文件规则按 id 合并覆盖。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_RULES_DIR = Path(__file__).resolve().parent.parent / "config" / "knowledge"
@dataclass
class Rule:
"""一条领域规则。"""
id: str
domain: str
priority: int = 50
patterns: List[str] = field(default_factory=list)
template: Optional[str] = None # 引用的任务模板 id
output: Optional[str] = None # 输出模板
actions: List[str] = field(default_factory=list) # 保留字段:动作扩展
subdomain: Optional[str] = None # 二级子领域(如 investing/labor/calculus
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime/sorting
def matches(self, text: str) -> bool:
"""任一 pattern 是 text 的子串即命中(大小写不敏感)。"""
if not self.patterns:
return False
q = text.lower()
return any(p.lower() in q for p in self.patterns)
# ---------------------------------------------------------------
# 三级子领域映射(rule_id -> subdomain2
# 集中维护:新增规则时在此加一行即可完成三级细化标注
# ---------------------------------------------------------------
SUBDOMAIN2_MAP: Dict[str, str] = {
# ---- code ----
"code-sort": "sorting",
"code-debug": "error-analysis",
"code-algorithm": "algorithm-general",
"code-refactor": "code-quality",
"code-database": "sql",
"code-explain": "code-reading",
"code-test": "unit-test",
"code-web": "web-dev",
"code-implement-general": "implementation",
"code-git-knowledge": "git",
"code-docker-knowledge": "container",
"code-python-knowledge": "python-env",
# ---- math ----
"math-equation": "equation",
"math-calculus": "calculus",
"math-algebra": "algebra",
"math-geometry": "geometry",
"math-proof": "proof",
"math-probability": "probability",
"math-number-theory": "number-theory",
"math-trigonometry": "trigonometry",
"math-optimization": "optimization",
"math-general": "math-general",
# ---- legal ----
"legal-contract": "contract",
"legal-labor": "labor",
"legal-ip": "intellectual-property",
"legal-housing": "housing",
"legal-marriage": "family-law",
"legal-tax": "tax",
"legal-consumer": "consumer-rights",
"legal-litigation": "litigation",
"legal-compliance": "compliance",
"legal-general": "legal-general",
# ---- medical ----
"medical-hypertension": "hypertension",
"medical-drug": "medication",
"medical-common": "common-illness",
"medical-chronic": "chronic-disease",
"medical-digestive": "digestive",
"medical-nutrition": "nutrition",
"medical-mental": "mental-health",
"medical-firstaid": "first-aid",
"medical-pediatrics": "pediatrics",
"medical-general": "medical-general",
# ---- finance ----
"finance-investing": "investing",
"finance-saving": "saving",
"finance-loan": "loan",
"finance-insurance": "insurance",
"finance-credit-card": "credit",
"finance-personal-budget": "budgeting",
"finance-general": "finance-general",
# ---- life ----
"life-food": "cooking",
"life-travel": "travel",
"life-home": "home",
"life-pet": "pet",
"life-fitness": "fitness",
"life-weather": "weather",
"life-general": "life-general",
# ---- education ----
"edu-study-method": "study-method",
"edu-exam": "exam",
"edu-language": "language",
"edu-course": "course",
"edu-career": "career",
"edu-general": "education-general",
# ---- general ----
"general-explain": "explain",
"general-writing": "writing",
"general-compare": "compare",
"general-translate": "translate",
"general-knowledge": "explain",
}
# ---------------------------------------------------------------
# 二级子领域映射(rule_id -> subdomain
# 三级 subdomain2 的父级类别;与 SUBDOMAIN2_MAP 按 rule_id 对齐维护。
# ---------------------------------------------------------------
SUBDOMAIN_MAP: Dict[str, str] = {
# ---- code ----
"code-sort": "algorithm",
"code-debug": "debugging",
"code-algorithm": "algorithm",
"code-refactor": "quality",
"code-database": "data",
"code-explain": "reading",
"code-test": "quality",
"code-web": "web",
"code-implement-general": "implementation",
"code-git-knowledge": "tooling",
"code-docker-knowledge": "tooling",
"code-python-knowledge": "tooling",
# ---- math ----
"math-equation": "algebra",
"math-calculus": "analysis",
"math-algebra": "algebra",
"math-geometry": "geometry",
"math-proof": "proof",
"math-probability": "probability",
"math-number-theory": "number-theory",
"math-trigonometry": "trigonometry",
"math-optimization": "optimization",
"math-general": "general",
# ---- legal ----
"legal-contract": "contract",
"legal-labor": "labor",
"legal-ip": "ip",
"legal-housing": "civil",
"legal-marriage": "civil",
"legal-tax": "tax",
"legal-consumer": "consumer",
"legal-litigation": "procedure",
"legal-compliance": "compliance",
"legal-general": "general",
# ---- medical ----
"medical-hypertension": "chronic",
"medical-drug": "medication",
"medical-common": "common",
"medical-chronic": "chronic",
"medical-digestive": "common",
"medical-nutrition": "nutrition",
"medical-mental": "mental",
"medical-firstaid": "emergency",
"medical-pediatrics": "pediatrics",
"medical-general": "general",
# ---- finance ----
"finance-investing": "investing",
"finance-saving": "personal-finance",
"finance-loan": "credit",
"finance-insurance": "insurance",
"finance-credit-card": "credit",
"finance-personal-budget": "personal-finance",
"finance-general": "general",
# ---- life ----
"life-food": "daily",
"life-travel": "daily",
"life-home": "daily",
"life-pet": "daily",
"life-fitness": "health",
"life-weather": "daily",
"life-general": "general",
# ---- education ----
"edu-study-method": "learning",
"edu-exam": "learning",
"edu-language": "language",
"edu-course": "learning",
"edu-career": "development",
"edu-general": "general",
# ---- general ----
"general-explain": "explanation",
"general-writing": "writing",
"general-compare": "analysis",
"general-translate": "language",
"general-knowledge": "explanation",
}
# ---------------------------------------------------------------
# 内置默认规则(兜底:即使规则文件缺失/损坏,系统仍可运行)
# ---------------------------------------------------------------
BUILTIN_RULES: List[Dict[str, Any]] = [
# ---- code ----
{"id": "code-sort", "domain": "code", "priority": 90,
"patterns": ["排序", "快速排序", "排序算法", "sort", "quicksort"],
"template": "code-implement"},
{"id": "code-debug", "domain": "code", "priority": 85,
"patterns": ["报错", "错误", "调试", "bug", "debug", "typeerror", "异常", "报 TypeError"],
"template": "code-debug"},
{"id": "code-implement-general", "domain": "code", "priority": 50,
"patterns": ["实现", "编写", "写一个", "函数", "代码", "编程", "用 python", "用 java",
"用 javascript", "sql", "接口", "算法"],
"template": "code-implement"},
# ---- math ----
{"id": "math-equation", "domain": "math", "priority": 90,
"patterns": ["方程", "求解", "求根", "solve", "equation", "解方程"],
"template": "math-solve"},
{"id": "math-calculus", "domain": "math", "priority": 85,
"patterns": ["积分", "导数", "微积分", "求导", "integral", "derivative", ""],
"template": "math-solve"},
{"id": "math-general", "domain": "math", "priority": 50,
"patterns": ["数学", "证明", "定理", "概率", "统计", "计算", "等于", "math", "不等式"],
"template": "math-solve"},
# ---- legal ----
{"id": "legal-contract", "domain": "legal", "priority": 90,
"patterns": ["合同", "条款", "违约", "离职", "竞业", "劳动", "contract", "clause", "赔偿"],
"template": "legal-advice"},
{"id": "legal-ip", "domain": "legal", "priority": 85,
"patterns": ["专利", "版权", "商标", "知识产权", "patent", "copyright", "trademark"],
"template": "legal-advice"},
{"id": "legal-general", "domain": "legal", "priority": 50,
"patterns": ["法律", "合规", "诉讼", "仲裁", "法条", "law", "legal", "法规"],
"template": "legal-advice"},
# ---- medical ----
{"id": "medical-hypertension", "domain": "medical", "priority": 90,
"patterns": ["高血压", "hypertension", "血压"],
"template": "medical-advice"},
{"id": "medical-drug", "domain": "medical", "priority": 85,
"patterns": ["药物", "吃药", "剂量", "副作用", "退烧药", "降压药", "dosage", "prescription"],
"template": "medical-advice"},
{"id": "medical-general", "domain": "medical", "priority": 50,
"patterns": ["医疗", "症状", "诊断", "治疗", "感冒", "发烧", "糖尿病", "医生", "患者",
"体检", "疫苗", "medical", "symptom", "disease"],
"template": "medical-advice"},
# ---- finance ----
{"id": "finance-investing", "domain": "finance", "priority": 90,
"patterns": ["基金", "定投", "收益率", "股票", "投资", "炒股", "证券", "invest", "stock"]},
{"id": "finance-saving", "domain": "finance", "priority": 85,
"patterns": ["存款", "储蓄", "利息", "零钱通", "余额宝", "saving"]},
{"id": "finance-loan", "domain": "finance", "priority": 80,
"patterns": ["贷款", "房贷", "借款", "按揭", "loan"]},
{"id": "finance-insurance", "domain": "finance", "priority": 75,
"patterns": ["保险", "理赔", "保单", "投保", "insurance"]},
{"id": "finance-credit-card", "domain": "finance", "priority": 70,
"patterns": ["信用卡", "花呗", "白条", "credit card"]},
{"id": "finance-personal-budget", "domain": "finance", "priority": 60,
"patterns": ["预算", "记账", "开销", "省钱", "budget"]},
{"id": "finance-general", "domain": "finance", "priority": 50,
"patterns": ["金融", "财务", "外汇", "汇率", "finance"]},
# ---- life ----
{"id": "life-food", "domain": "life", "priority": 90,
"patterns": ["做饭", "做菜", "菜谱", "食谱", "烹饪", "cooking"]},
{"id": "life-travel", "domain": "life", "priority": 85,
"patterns": ["旅游", "旅行", "攻略", "景点", "签证", "travel"]},
{"id": "life-home", "domain": "life", "priority": 80,
"patterns": ["装修", "租房", "家电", "清洁", "搬家", "home"]},
{"id": "life-pet", "domain": "life", "priority": 75,
"patterns": ["宠物", "养猫", "养狗", "撸猫", "pet"]},
{"id": "life-fitness", "domain": "life", "priority": 70,
"patterns": ["健身", "减肥", "跑步", "锻炼", "fitness"]},
{"id": "life-weather", "domain": "life", "priority": 65,
"patterns": ["天气", "下雨", "台风", "降温", "weather"]},
{"id": "life-general", "domain": "life", "priority": 50,
"patterns": ["生活", "日常", "家居", "life"]},
# ---- education ----
{"id": "edu-study-method", "domain": "education", "priority": 90,
"patterns": ["学习方法", "记忆", "做笔记", "笔记法", "专注力"]},
{"id": "edu-exam", "domain": "education", "priority": 85,
"patterns": ["考试", "考研", "复习", "真题", "四六级", "exam"]},
{"id": "edu-language", "domain": "education", "priority": 80,
"patterns": ["英语", "单词", "口语", "语法", "english"]},
{"id": "edu-course", "domain": "education", "priority": 75,
"patterns": ["课程", "网课", "慕课", "选修", "course"]},
{"id": "edu-career", "domain": "education", "priority": 70,
"patterns": ["职业规划", "求职", "面试", "简历", "校招", "career"]},
{"id": "edu-general", "domain": "education", "priority": 50,
"patterns": ["教育", "大学", "专业选择", "education"]},
# ---- general ----
{"id": "general-explain", "domain": "general", "priority": 30,
"patterns": ["总结", "介绍", "解释", "为什么", "优缺点", "是什么", "翻译", "邮件",
"summarize", "explain", "what is", "写一封"],
"template": "general-explain"},
]
# 内置默认任务模板(兜底)
BUILTIN_TASKS: Dict[str, Dict[str, Any]] = {
"code-implement": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "需求与约束分析"},
{"id": "design", "kind": "design", "domain": "code", "deps": ["analyze"], "desc": "算法与数据结构设计"},
{"id": "implement", "kind": "implement", "domain": "code", "deps": ["design"], "desc": "实现代码"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["implement"], "desc": "自测校验"},
]},
"code-debug": {"steps": [
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "错误现象与复现分析"},
{"id": "diagnose", "kind": "diagnose", "domain": "code", "deps": ["analyze"], "desc": "定位错误根因"},
{"id": "fix", "kind": "fix", "domain": "code", "deps": ["diagnose"], "desc": "给出修复方案"},
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["fix"], "desc": "修复后验证"},
]},
"math-solve": {"steps": [
{"id": "conditions", "kind": "analyze", "domain": "math", "desc": "明确已知条件与目标"},
{"id": "solve", "kind": "solve", "domain": "math", "deps": ["conditions"], "desc": "选择方法并求解"},
{"id": "verify", "kind": "verify", "domain": "math", "deps": ["solve"], "desc": "检查边界与验证"},
]},
"legal-advice": {"steps": [
{"id": "facts", "kind": "analyze", "domain": "legal", "desc": "梳理事实与法律问题"},
{"id": "retrieve", "kind": "retrieve", "domain": "legal", "deps": ["facts"], "desc": "检索适用法规"},
{"id": "conclude", "kind": "conclude", "domain": "legal", "deps": ["retrieve"], "desc": "给出法律意见"},
{"id": "disclaimer", "kind": "disclaimer", "domain": "legal", "deps": ["conclude"], "desc": "免责提示"},
]},
"medical-advice": {"steps": [
{"id": "symptoms", "kind": "analyze", "domain": "medical", "desc": "梳理症状与背景"},
{"id": "advise", "kind": "advise", "domain": "medical", "deps": ["symptoms"], "desc": "给出一般建议"},
{"id": "warning", "kind": "disclaimer", "domain": "medical", "deps": ["advise"], "desc": "就医警示"},
]},
"general-explain": {"steps": [
{"id": "outline", "kind": "analyze", "domain": "general", "desc": "梳理主题要点"},
{"id": "explain", "kind": "explain", "domain": "general", "deps": ["outline"], "desc": "展开解释"},
{"id": "conclude", "kind": "conclude", "domain": "general", "deps": ["explain"], "desc": "总结"},
]},
}
# 内置默认事实表(兜底)
BUILTIN_FACTS: Dict[str, List[Dict[str, Any]]] = {
"legal": [
{"id": "legal-noncompete", "keywords": ["竞业", "离职", "同业"],
"statement": "竞业限制期限不得超过二年,且用人单位应在限制期内按月给予经济补偿"},
{"id": "legal-renew-compensation", "keywords": ["不续签", "经济补偿", "劳动合同"],
"statement": "劳动合同期满用人单位不续签的,通常应支付经济补偿(每满一年一个月工资)"},
],
"medical": [
{"id": "medical-hypertension-diet", "keywords": ["高血压", "饮食"],
"statement": "高血压患者应低盐低脂饮食、控制体重、规律运动、戒烟限酒,并在医生指导下用药"},
{"id": "medical-fever-drug", "keywords": ["发烧", "退烧"],
"statement": "体温超过 38.5℃ 可在药师指导下使用退烧药;持续发热或出现严重症状应及时就医"},
],
}
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
try:
import yaml # type: ignore
except ImportError:
return None
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
json_path = path.with_suffix(".json")
if not json_path.exists():
return None
try:
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
class KnowledgeBase:
"""知识库:加载规则文件,提供规则匹配、任务模板、事实表查询。"""
def __init__(self, rules_dir: Optional[str | Path] = None):
self.rules_dir = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
self._rules: Dict[str, Rule] = {}
self._tasks: Dict[str, Dict[str, Any]] = {}
self._facts: Dict[str, List[Dict[str, Any]]] = {}
self.load()
# ---- 加载 ----
def load(self) -> None:
"""内置默认 + 规则文件合并(文件规则按 id 覆盖内置)。"""
self._rules = {}
self._tasks = dict(BUILTIN_TASKS)
for item in BUILTIN_RULES:
self._register_rule(item)
self._facts = {d: [dict(f) for f in facts] for d, facts in BUILTIN_FACTS.items()}
if self.rules_dir.is_dir():
for f in sorted(self.rules_dir.glob("*.yaml")):
data = _try_load_yaml(f)
if data is not None:
self._load_file_data(f, data)
for f in sorted(self.rules_dir.glob("*.json")):
if f.name not in {p.name for p in self.rules_dir.glob("*.yaml")}:
data = _try_load_json(f)
if data is not None:
self._load_file_data(f, data)
def _load_file_data(self, path: Path, data: Dict[str, Any]) -> None:
name = path.stem
if name == "tasks":
for tid, tpl in (data.get("task_templates") or {}).items():
if isinstance(tpl, dict) and isinstance(tpl.get("steps"), list):
self._tasks[tid] = tpl
return
domain = data.get("domain", name)
for item in data.get("rules") or []:
if isinstance(item, dict) and item.get("id"):
self._register_rule({**item, "domain": domain})
for fact in data.get("facts") or []:
if isinstance(fact, dict) and fact.get("id"):
self._facts.setdefault(domain, []).append(fact)
def _register_rule(self, item: Dict[str, Any]) -> None:
rule = Rule(
id=str(item["id"]),
domain=str(item.get("domain", "general")),
priority=int(item.get("priority", 50)),
patterns=[str(p) for p in item.get("patterns", [])],
template=item.get("template"),
output=item.get("output"),
actions=[str(a) for a in item.get("actions", [])],
subdomain=item.get("subdomain") or SUBDOMAIN_MAP.get(str(item["id"])),
subdomain2=item.get("subdomain2") or SUBDOMAIN2_MAP.get(str(item["id"])),
)
self._rules[rule.id] = rule
# ---- 查询 ----
def match(self, text: str, domain: Optional[str] = None) -> List[Rule]:
"""返回命中的规则,按优先级降序。domain 为空则全领域匹配。"""
hits = []
for rule in self._rules.values():
if domain is not None and rule.domain != domain:
continue
if rule.matches(text):
hits.append(rule)
hits.sort(key=lambda r: r.priority, reverse=True)
return hits
def rule(self, rule_id: str) -> Optional[Rule]:
return self._rules.get(rule_id)
def rules_count(self) -> int:
return len(self._rules)
def task_template(self, tid: str) -> Optional[Dict[str, Any]]:
return self._tasks.get(tid)
def task_ids(self) -> List[str]:
return sorted(self._tasks.keys())
def facts(self, domain: str) -> List[Dict[str, Any]]:
return self._facts.get(domain, [])
def domains(self) -> List[str]:
return sorted({r.domain for r in self._rules.values()})
-131
View File
@@ -1,131 +0,0 @@
"""黑板(Blackboard)/ 工作记忆:专家系统风格的共享工作区(零依赖)。
- TaskNode:子任务节点(DAG 顶点),由 Planner 创建、Router 按拓扑序执行
- TaskGraph:子任务 DAG,提供拓扑排序与状态查询
- WorkingMemory:黑板,各知识源(执行器/规则)写入部分解,最后合并为最终答案
对齐《可行性调研与落地实现路线报告》第八章:
"黑板协作:多知识源(领域专家/执行器)通过共享黑板协作,而不是一个模型全包"
"""
from __future__ import annotations
from collections import deque
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 拓扑排序:依赖在前;初始就绪层按插入序稳定输出。
O(V+E) 实现(邻接表 + deque);循环依赖时按插入序兜底(不崩溃)。
"""
insert_pos = {nid: i for i, nid in enumerate(self._nodes)}
indeg: Dict[str, int] = {nid: 0 for nid in self._nodes}
dependents: Dict[str, List[str]] = {nid: [] for nid in self._nodes}
for n in self._nodes.values():
for d in n.deps:
if d in indeg: # 未知依赖 id 忽略(与入度统计口径一致)
indeg[n.id] += 1
dependents[d].append(n.id)
ready = deque(sorted((nid for nid, deg in indeg.items() if deg == 0),
key=insert_pos.__getitem__))
order_ids: List[str] = []
while ready:
nid = ready.popleft()
order_ids.append(nid)
for m in dependents[nid]:
indeg[m] -= 1
if indeg[m] == 0:
ready.append(m)
if len(order_ids) < len(self._nodes):
# 循环依赖兜底:剩余节点按插入序追加
placed = set(order_ids)
order_ids.extend(nid for nid in self._nodes if nid not in placed)
return [self._nodes[nid] for nid in order_ids]
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)
-8
View File
@@ -44,10 +44,6 @@ class RouterResult:
cache_level: Optional[str] = None # exact | semantic cache_level: Optional[str] = None # exact | semantic
cost_est: float = 0.0 cost_est: float = 0.0
error: Optional[str] = None error: Optional[str] = None
subdomain: Optional[str] = None # 二级子领域(如 investing/labor
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime
domain_group: Optional[str] = None # 大领域组(两级路由第一级:tech/professional/...
request_id: Optional[str] = None # 请求 ID(配合 /traces/{id} 查询完整推理链)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@@ -65,10 +61,6 @@ class RouterResult:
"cache_level": self.cache_level, "cache_level": self.cache_level,
"cost_est": round(self.cost_est, 6), "cost_est": round(self.cost_est, 6),
"error": self.error, "error": self.error,
"subdomain": self.subdomain,
"subdomain2": self.subdomain2,
"domain_group": self.domain_group,
"request_id": self.request_id,
} }
-324
View File
@@ -1,324 +0,0 @@
"""CollaborativePipeline —— 端云协同协作管线编排(v2 核心调度)。
流程(对齐《实现方案_v2》第 2/4.3 节):
用户 query -> [快路径] Worker 直答+自验证通过即返回
-> 否则 Architect.brief 写交流文本 -> 协作循环(Worker 实现/自验证,
issue 时 Architect.decide 裁决)-> 全步完成 -> Architect.final_review
-> 交付(入人工检验队列)
护栏(D6):rounds_cap / api_token_cap 任一触顶即熔断;熔断按 breach_policy
走 Architect 兜底代做(有 key)或本地降级提示(无 key)。
测试封闭性(D11):architect 用 httpx.MockTransportworker 用假 generate
不依赖真实 llama-server 或 API key。
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from .architect import ArchitectCircuitBreaker, ArchitectClient, ArchitectError
from .worker import WorkerLoop
from .workspace import Workspace
def _safe_artifact_name(name: str) -> str:
"""工件名单消毒:剥路径成分,只留文件名(工件名来自模型输出,防 ../ 越界写盘)。"""
part = str(name or "").replace("\\", "/").split("/")[-1].strip()
if not part or part in (".", ".."):
return "artifact.bin"
return part[:120]
@dataclass
class PipelineResult:
"""v2 协作管线一次运行的结果。"""
query: str
response: str
request_id: str
status: str # done | fast_path | escalated | failed
fast_path: bool
rounds_used: int
api_input_tokens: int
api_output_tokens: int
cost_est: float
model_used: str
latency_ms: float
route: List[str] = field(default_factory=list)
workspace_path: Optional[str] = None
error: Optional[str] = None
class CollaborativePipeline:
"""编排快路径、协作循环、熔断、终审。"""
def __init__(
self,
architect: ArchitectClient,
worker: WorkerLoop,
fast_path: bool = True,
rounds_cap: int = 6,
api_token_cap: int = 8000,
breach_policy: str = "architect_do", # architect_do | local_only
run_dir: str = "runs",
):
self.architect = architect
self.worker = worker
self.fast_path = fast_path
self.rounds_cap = rounds_cap
self.api_token_cap = api_token_cap
self.breach_policy = breach_policy
self.run_dir = Path(run_dir)
# ---------------------------------------------------------------
# 入口
# ---------------------------------------------------------------
async def run(self, query: str, request_id: Optional[str] = None) -> PipelineResult:
if request_id is None:
request_id = uuid.uuid4().hex[:12]
ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap)
route: List[str] = ["v2"]
t0 = time.perf_counter() * 1000.0
# ---- 快路径:小模型直答 + 自验证(省 API 钱,D3) ----
if self.fast_path:
direct = await self.worker.direct_answer(query)
dom = self._guess_domain(query)
passed, _det = self.worker.verifier.verify(dom, "answer.md", direct, query,
kb=self.worker.kb)
if passed:
route.append(f"fast_path@domain:{dom}")
self._save_artifact(request_id, "answer.md", direct)
return self._finalize(query, ws, response=direct, status="fast_path",
fast_path=True, route=route, model_used=self.worker.model_used,
t0=t0, save_ws=False)
route.append("fast_path:miss")
# ---- briefArchitect 一次) ----
try:
brief = await self.architect.brief(query, ws)
ws.apply_brief(brief)
route.append("brief")
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
# ---- 协作循环 ----
route.append("loop")
plan = brief.get("plan") or []
pending = [p.get("id") for p in plan]
plan_ids = {p.get("id") for p in plan} # 用于 _deps_done 过滤
while pending and not ws.exhausted():
progressed = False
for sid in list(pending):
step = next((p for p in plan if p.get("id") == sid), {})
deps = step.get("deps") or []
# 只检查在 plan 中的依赖;不在 plan 的 ID 视为"不存在"→自动满足
relevant = [d for d in deps if d in plan_ids]
if not self._deps_done(ws, relevant):
continue
existing = self._read_artifact(request_id, self._artifact_name(sid, ws))
outcome = await self.worker.run_step(ws, sid, existing_artifact=existing,
hint=self._last_decision_for(ws, sid))
if outcome.status == "done":
pending.remove(sid)
self._save_artifact(request_id, outcome.artifact_name, outcome.artifact_text)
ws.rollup()
route.append(f"step:{sid}:done")
progressed = True
if not pending: # 所有步骤完成,退出循环
break
else: # issue -> Architect 裁决
route.append(f"step:{sid}:issue")
try:
dec = await self.architect.decide(ws)
ws.add_decision(dec.get("ref") or outcome.issue_id or "",
dec.get("reply", ""), dec.get("patch_plan"))
self._apply_patch_plan(ws, dec.get("patch_plan"))
route.append(f"decide:{sid}")
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
progressed = True
ws.mark_round()
if not progressed:
# 死锁(依赖/裁决都推不动)-> 熔断兜底
return await self._handle_breach(query, ws, route, t0,
exc=RuntimeError("协作循环死锁:无进度"))
if ws.exhausted() and pending:
return await self._handle_breach(query, ws, route, t0,
exc=RuntimeError("预算/回合触顶"))
# ---- 终审 ----
ws.transition("reviewing")
route.append("reviewing")
try:
rev = await self.architect.final_review(ws)
if rev.get("verdict") == "done":
ws.transition("done")
route.append("done")
status = "done"
else:
# reviewing --fail--> in_progress(修正回合);MVP 返回 escalated 标记打回
ws.transition("in_progress")
route.append("review:fix")
status = "escalated"
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
response = self._build_response(ws, request_id)
return self._finalize(query, ws, response=response, status=status, fast_path=False,
route=route, model_used=self.architect.model, t0=t0)
# ---------------------------------------------------------------
# 熔断兜底
# ---------------------------------------------------------------
async def _handle_breach(self, query: str, ws: Workspace, route: List[str],
t0: float, exc: BaseException) -> PipelineResult:
route.append("breach")
if self.breach_policy == "architect_do" and self.architect.api_key:
# 有 keyArchitect 兜底代做
try:
briefish = ws.render_for_architect()
answer = await self.architect._chat_once(
ws, [{"role": "user",
"content": "以下任务自动升级,请直接给出最终可交付答案(非 JSON):" + briefish}])
route.append("breach:architect_do")
return self._finalize(query, ws, response=answer, status="escalated",
fast_path=False, route=route,
model_used=self.architect.model, t0=t0, error=str(exc))
except Exception as e2:
route.append(f"breach:architect_do:fail:{type(e2).__name__}")
# 本地降级
route.append("breach:local_deg")
msg = ("(本地降级)当前请求超出本地可处理范围,且未配置大模型密钥或预算熔断。"
f"原因:{exc}")
return self._finalize(query, ws, response=msg, status="failed", fast_path=False,
route=route, model_used="none", t0=t0, error=str(exc))
# ---------------------------------------------------------------
# 结果组装
# ---------------------------------------------------------------
def _finalize(self, query: str, ws: Workspace, response: str, status: str,
fast_path: bool, route: List[str], model_used: str, t0: float,
save_ws: bool = True, error: Optional[str] = None) -> PipelineResult:
if save_ws:
path = self.run_dir / ws.request_id / "workspace.json"
ws.save(path)
ws_path = str(path)
else:
ws_path = None
b = ws.budget()
return PipelineResult(
query=query, response=response, request_id=ws.request_id, status=status,
fast_path=fast_path, rounds_used=ws.meta()["round"],
api_input_tokens=b["api_input_tokens"], api_output_tokens=b["api_output_tokens"],
cost_est=0.0, model_used=model_used,
latency_ms=time.perf_counter() * 1000.0 - t0,
route=route, workspace_path=ws_path, error=error,
)
# ---------------------------------------------------------------
# 辅助
# ---------------------------------------------------------------
def _guess_domain(self, query: str) -> str:
if self.worker.kb is not None:
hits = self.worker.kb.match(query)
if hits:
return hits[0].domain
return "general"
def _artifact_name(self, sid: str, ws: Workspace) -> str:
from .worker import artifact_name_for
domain = self._guess_domain(ws["query"])
# 用 brief.tags 优先
tags = (ws.get("brief") or {}).get("tags") or []
for t in tags:
if t != "safety":
domain = t
break
return artifact_name_for(sid, domain)
def _deps_done(self, ws: Workspace, deps: List[str]) -> bool:
# progress 中 done 的条目;done 条目 rollup 后移到 archive(字符串格式如 "s1: ..."
done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"}
for entry in ws.get("archive", []):
if isinstance(entry, dict):
sid = entry.get("step", "")
elif isinstance(entry, str):
sid = entry.split(":")[0].strip() if ":" in entry else ""
else:
sid = ""
if sid in deps:
done.add(sid)
return all(d in done for d in deps)
def _last_decision_for(self, ws: Workspace, sid: str) -> str:
"""取最近一条针对该 step 的决策 reply,作为 worker hint。"""
step_issues = {i.get("id") for i in ws.get("issues", []) if i.get("step") == sid}
for dec in reversed(ws.get("decisions", []) or []):
if dec.get("ref") in step_issues:
return dec.get("reply", "")
return ""
def _apply_patch_plan(self, ws: Workspace, patch_plan: Optional[List[Dict[str, Any]]]) -> None:
if not patch_plan:
return
updates = {}
for item in patch_plan:
if isinstance(item, dict) and item.get("id") and item.get("task"):
updates[item["id"]] = item["task"]
if updates:
ws.revise_plan(updates)
def _build_response(self, ws: Workspace, request_id: str) -> str:
# 汇总 archive 摘要 + 各已完成步骤工件
lines = list(ws.get("archive", []) or [])
brief = ws.get("brief") or {}
parts: List[str] = []
if brief.get("goal"):
parts.append("任务:" + brief["goal"])
if lines:
parts.append("完成情况:")
parts.extend(f"- {ln}" for ln in lines)
# 附上最后一个已完成步骤的工件全文
progress = ws.get("progress", []) or []
done_steps = [p for p in progress if p.get("status") == "done"]
if done_steps:
last = done_steps[-1]
art = self._read_artifact(request_id, self._artifact_name(last["step"], ws))
if art:
parts.append("产出:")
parts.append(art)
return "\n\n".join(parts) if parts else "(协作管线未产出有效内容)"
def _save_artifact(self, request_id: str, name: str, text: str) -> None:
if not text:
return
name = _safe_artifact_name(name)
d = self.run_dir / request_id / "artifacts"
d.mkdir(parents=True, exist_ok=True)
(d / name).write_text(text, encoding="utf-8")
def _read_artifact(self, request_id: str, name: str) -> str:
p = self.run_dir / request_id / "artifacts" / _safe_artifact_name(name)
if p.exists():
return p.read_text(encoding="utf-8")
return ""
def build_pipeline(cfg: Dict[str, Any], architect: ArchitectClient,
worker: WorkerLoop) -> CollaborativePipeline:
"""cfg 为 config.pipeline 段。"""
p = cfg.get("pipeline", {})
return CollaborativePipeline(
architect=architect,
worker=worker,
fast_path=bool(p.get("fast_path", True)),
rounds_cap=int(p.get("rounds_cap", 6)),
api_token_cap=int(p.get("api_token_cap", 8000)),
breach_policy=p.get("breach_policy", "architect_do"),
run_dir=p.get("run_dir", "runs"),
)
-103
View File
@@ -1,103 +0,0 @@
"""规则 Planner:把查询拆解为子任务 DAG(任务分解,专家系统风格,零参数)。
拆解逻辑(确定性规则):
1. 在分类领域内匹配知识规则
2. 取最高优先级且带 template 的命中规则 → 对应任务模板
3. 非 easy 难度且有模板 → 生成多节点 DAG(模板 steps 转 TaskNode,含依赖)
4. easy 难度或无模板命中 → 单节点直接求解(不拆,最小开销)
5. 拆解深度防护:节点不再递归拆解(当前为单层拆解,模板本身即最终粒度)
对齐架构目标:"路由模型把任务拆解后分步骤交给各个小模型"
L0 模式下各子任务由规则执行器完成(零参数),L2 模式可交给本地小模型。
"""
from __future__ import annotations
from typing import List, Optional
from .knowledge import KnowledgeBase
from .memory import TaskGraph, TaskNode
from .models import Classification
# 单节点求解时按领域选择默认动作 kind
_SINGLE_KIND = {
"code": "implement",
"math": "solve",
"legal": "conclude",
"medical": "advise",
"general": "explain",
"finance": "conclude",
"life": "advise",
"education": "design",
}
# 强制拆解领域:即使 easy 也走完整任务模板
# legal 需要 retrieve+disclaimermedical 需要 advise+warning
# finance 需要 retrieve+风险免责——均为领域硬要求)
FORCE_SPLIT_DOMAINS = {"legal", "medical", "finance"}
# 强制拆解模板:命中即拆(debug 流程必须 analyze→diagnose→fix→verify
FORCE_SPLIT_TEMPLATES = {"code-debug"}
class Planner:
"""规则 Planner:查询 → 子任务 DAG。"""
def __init__(self, kb: KnowledgeBase, max_depth: int = 3):
self.kb = kb
self.max_depth = max_depth
# ---------------------------------------------------------------
def plan(self, query: str, classification: Classification) -> TaskGraph:
domain = classification.domain
difficulty = classification.difficulty
# 1. 领域内匹配规则,取最高优先级带模板的规则
template_id: Optional[str] = None
hits = self.kb.match(query, domain=domain)
for h in hits:
if h.template:
template_id = h.template
break
graph = TaskGraph()
# 2. 非 easy / 强制拆解领域 / 强制拆解模板 → 多节点 DAG
if template_id and (difficulty != "easy"
or domain in FORCE_SPLIT_DOMAINS
or template_id in FORCE_SPLIT_TEMPLATES):
tpl = self.kb.task_template(template_id)
if tpl and tpl.get("steps"):
for step in tpl["steps"]:
node = TaskNode(
id=str(step["id"]),
kind=str(step.get("kind", "solve")),
domain=str(step.get("domain", domain)),
query=query,
deps=[str(d) for d in step.get("deps", [])],
desc=str(step.get("desc", "")),
)
graph.add_node(node)
return graph
# 3. easy / 无模板 → 单节点
kind = _SINGLE_KIND.get(domain, "explain")
graph.add_node(TaskNode(
id="solve",
kind=kind,
domain=domain,
query=query,
desc=f"单节点求解({domain}/{difficulty}",
))
return graph
# ---------------------------------------------------------------
def explain_plan(self, graph: TaskGraph) -> List[str]:
"""把 DAG 渲染为可读的拆解轨迹(用于 route 与 --trace)。"""
if len(graph) == 1:
n = graph.nodes()[0]
return [f"plan:single[{n.kind}]"]
parts = []
for n in graph.topo_order():
dep = f"<{','.join(n.deps)}" if n.deps else ""
parts.append(f"{n.id}:{n.kind}{dep}")
return [f"plan:multi[{len(graph)}]({' -> '.join(parts)})"]
-167
View File
@@ -1,167 +0,0 @@
"""人工检验队列(ReviewQueue)—— 第三个协作者(纯标准库 sqlite3)。
复用同一"交流文本"协议:异步队列,不阻塞响应路径。系统交付后按抽样率或
safety 标签强制规则入队,由人工审核给出 verdictapprove/edit/reject)并可
回写修正数据(correction),用于后续质量分析(论文 E 实验的人工地基)。
对齐《实现方案_v2》5.1 T8
- SQLite 存储(data/review.sqlite3),零第三方依赖(sqlite3 为标准库)。
- enqueue / get / list / submit(verdict, correction)。
- 抽样规则:brief.tags 命中 force_tags(如 safety)强制入队,否则按 sample_rate 随机抽样。
"""
from __future__ import annotations
import random
import sqlite3
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# 抽样用系统级随机源(CSPRNG
_SYSTEM_RANDOM = random.SystemRandom()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
query TEXT NOT NULL,
response TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending', -- pending | reviewed
verdict TEXT, -- approve | edit | reject
correction TEXT,
reviewer TEXT,
reason TEXT,
workspace_path TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
"""
class ReviewQueue:
"""人工检验队列(sqlite 后端)。方法为同步;调用方按需自行放入线程池。"""
def __init__(self, db_path: str = "data/review.sqlite3"):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(_SCHEMA)
# ---------------------------------------------------------------
# 抽样策略
# ---------------------------------------------------------------
@staticmethod
def should_enqueue(tags: List[str], sample_rate: float = 0.10,
force_tags: Optional[List[str]] = None,
rng: Optional[random.Random] = None) -> bool:
"""是否应入队:tags 命中 force_tags 强制;否则按 sample_rate 抽样。
缺省用系统级 CSPRNG(不可预测,不可被时间种子影响抽样公平性)。
"""
force = force_tags or []
if any(t in force for t in tags):
return True
rng = rng or _SYSTEM_RANDOM
return rng.random() < sample_rate
# ---------------------------------------------------------------
# 入队
# ---------------------------------------------------------------
def enqueue(self, request_id: str, query: str, response: str,
tags: Optional[List[str]] = None, reason: str = "sample",
workspace_path: Optional[str] = None) -> int:
"""入队一条待审记录,返回 review id。"""
tags_json = _json_dumps(tags or [])
with self._lock, self._connect() as conn:
cur = conn.execute(
"INSERT INTO reviews (request_id, query, response, tags, reason, workspace_path, created_at)"
" VALUES (?,?,?,?,?,?,?)",
(request_id, query, response, tags_json, reason, workspace_path, _now_iso()),
)
return int(cur.lastrowid)
# ---------------------------------------------------------------
# 查询
# ---------------------------------------------------------------
def get(self, review_id: int) -> Optional[Dict[str, Any]]:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM reviews WHERE id=?", (review_id,)).fetchone()
return _row_to_dict(row) if row else None
def list(self, status: Optional[str] = None, limit: int = 50) -> List[Dict[str, Any]]:
with self._lock, self._connect() as conn:
if status:
rows = conn.execute(
"SELECT * FROM reviews WHERE status=? ORDER BY id DESC LIMIT ?",
(status, limit)).fetchall()
else:
rows = conn.execute(
"SELECT * FROM reviews ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [_row_to_dict(r) for r in rows]
def count(self, status: Optional[str] = None) -> int:
with self._lock, self._connect() as conn:
if status:
row = conn.execute("SELECT COUNT(*) AS c FROM reviews WHERE status=?",
(status,)).fetchone()
else:
row = conn.execute("SELECT COUNT(*) AS c FROM reviews").fetchone()
return int(row["c"]) if row else 0
# ---------------------------------------------------------------
# 审核提交
# ---------------------------------------------------------------
def submit(self, review_id: int, verdict: str,
correction: Optional[str] = None,
reviewer: Optional[str] = None) -> bool:
"""提交审核结论。verdict: approve | edit | reject。返回是否更新成功。"""
if verdict not in ("approve", "edit", "reject"):
raise ValueError(f"非法 verdict: {verdict}(支持 approve|edit|reject")
with self._lock, self._connect() as conn:
cur = conn.execute(
"UPDATE reviews SET status='reviewed', verdict=?, correction=?, reviewed_at=?, reviewer=?"
" WHERE id=? AND status='pending'",
(verdict, correction, _now_iso(), reviewer, review_id),
)
return cur.rowcount > 0
def _row_to_dict(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
if row is None:
return None
d = dict(row)
try:
import json
d["tags"] = json.loads(d.get("tags") or "[]")
except Exception:
d["tags"] = []
return d
def _json_dumps(obj) -> str:
import json
return json.dumps(obj, ensure_ascii=False)
def build_review_queue(cfg: Dict[str, Any]) -> ReviewQueue:
"""cfg 为 config.review 段。"""
return ReviewQueue(db_path=cfg.get("queue_db", "data/review.sqlite3"))
+60 -254
View File
@@ -1,39 +1,26 @@
"""主路由器:两级路由(大领域组 → 组内路由模型 → 专业执行器)专家系统编排 """主路由器:协调 缓存 -> 分类 -> 专家 -> Judge -> 大模型回退 的完整链路
两级体系(对齐用户架构决策): 流程(对齐实现方案):
第一级:用户通过接口指定大领域组(domain_group: tech/professional/lifestyle/general), 1. 检查缓存(L1 精确 / L2 语义)
或系统自动检测(8 领域分类 → 映射到组 2. 低置信度查询直接走大模型(should_fallback
第二级:组内路由模型(RuleClassifier(domains=组内领域) + 组内知识/模板)识别具体 3. 分类器输出领域 + 难度
领域、子领域、拆解子任务 → 组内专业小模型/规则执行器 4. 选择专家模型生成
组内路由模型只认识本组领域:体积与匹配开销约为统一路由模型的 1/4, 5. Judge 评估质量
且未来 L2 模型层可每组一个更小的路由模型,按需加载不常驻。 6. 质量不达标 -> 升级大模型
7. 记录指标、写缓存、返回结果
链路:缓存 → 组路由(分类/子领域/拆解) → 黑板+前向链 → DAG 执行 → 合并
→ Judge 校验 → (不达标)最后处理者升级 → 缓存/指标
L0 模式(默认):规则分类 + 规则拆解 + 规则执行器 + 规则 Judge —— 零模型参数、零 API。
L2 模式(可选):execution.expert_backend = hf/api 时,子任务改由专家池小模型执行
(≤8B,按需加载),其余流程不变。
""" """
from __future__ import annotations from __future__ import annotations
import uuid from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from .cache import RouterCache from .cache import RouterCache
from .classifier import BaseClassifier, RuleClassifier, build_classifier from .classifier import BaseClassifier, build_classifier
from .config import load_config from .config import load_config
from .executors import NodeExecutor, build_node_executor
from .experts import Expert, build_expert_pool from .experts import Expert, build_expert_pool
from .fallback import FallbackProvider, build_fallback from .fallback import FallbackProvider, build_fallback
from .inference import InferenceEngine
from .judge import BaseJudge, build_judge from .judge import BaseJudge, build_judge
from .knowledge import KnowledgeBase
from .memory import TaskGraph, TaskNode, WorkingMemory
from .models import Classification, ExpertResponse, RouterResult, now_ms from .models import Classification, ExpertResponse, RouterResult, now_ms
from .planner import Planner
from .stats import Stats from .stats import Stats
from .trace import TraceStore
class Router: class Router:
@@ -46,8 +33,6 @@ class Router:
cache: Optional[RouterCache] = None, cache: Optional[RouterCache] = None,
stats: Optional[Stats] = None, stats: Optional[Stats] = None,
config: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None,
kb: Optional[KnowledgeBase] = None,
planner: Optional[Planner] = None,
): ):
self.classifier = classifier self.classifier = classifier
self.experts = experts self.experts = experts
@@ -60,43 +45,11 @@ class Router:
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60) self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70) self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
self.cache_enabled = cfg.get("cache", {}).get("enabled", True) self.cache_enabled = cfg.get("cache", {}).get("enabled", True)
# ---- 专家系统内核 ----
self.kb = kb or KnowledgeBase()
self.planner = planner or Planner(self.kb)
self.inference = InferenceEngine(self.kb)
ecfg = cfg.get("execution", {})
self.expert_backend = ecfg.get("expert_backend", "rule") # rule | hf | api
# 子任务执行后端(T1 抽象:NodeExecutor 工厂,新增后端无需改 Router)
self.node_executor: NodeExecutor = build_node_executor(
self.expert_backend, kb=self.kb, experts=experts)
# 推理链轨迹存储(T3:可解释性产品化)
self.trace_store = TraceStore()
# ---- 两级路由:大领域分组 + 组内路由模型(更小更专) ----
self.domain_groups: Dict[str, List[str]] = cfg.get("domain_groups", {}) or {}
if not self.domain_groups:
# 兜底:未配置时按单组(全部领域)处理,行为退化为一级路由
self.domain_groups = {"all": list(self.experts.keys())}
self._group_of_domain: Dict[str, str] = {}
for g, domains in self.domain_groups.items():
for d in domains:
self._group_of_domain[d] = g
# 组内路由模型:每组一个轻量分类器(只认识组内领域)
self._group_classifiers: Dict[str, RuleClassifier] = {
g: RuleClassifier(domains=domains)
for g, domains in self.domain_groups.items()
}
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def route(self, query: str, domain_group: Optional[str] = None) -> RouterResult: async def route(self, query: str) -> RouterResult:
"""两级路由入口。
domain_group 指定时:跳过 8 领域统一分类器,直接用组内路由模型
RuleClassifier(domains=组内领域))识别组内领域 —— 更小更专。
未指定时:统一分类器识别领域 → 自动映射到大领域组(向后兼容)。
"""
start = now_ms() start = now_ms()
route: list = [] route: list = []
request_id = uuid.uuid4().hex[:12]
# ---- Step 1: 缓存 ---- # ---- Step 1: 缓存 ----
if self.cache_enabled: if self.cache_enabled:
@@ -118,132 +71,59 @@ class Router:
cache_hit=True, cache_hit=True,
cache_level=level, cache_level=level,
cost_est=0.0, cost_est=0.0,
subdomain=cached.get("subdomain"),
subdomain2=cached.get("subdomain2"),
domain_group=cached.get("domain_group"),
request_id=request_id,
)
self._store_trace(
request_id=request_id, query=query, group=cached.get("domain_group"),
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=result.subdomain,
subdomain2=result.subdomain2, route=route, quality=result.quality_score,
upgraded=False, model=result.model_used, latency=latency,
cache_hit=True, cache_level=level,
) )
self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used) self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used)
return result return result
route.append("cache:miss") route.append("cache:miss")
# ---- Step 2: 组路由(两级第一级)→ 组内分类(两级第二级) ---- # ---- Step 2: 分类 ----
classifier = self.classifier classification = self.classifier.classify(query)
group = domain_group
if group is not None:
# 用户指定大领域:校验 + 使用组内路由模型
if group not in self.domain_groups:
raise ValueError(
f"未知大领域组: {group}(可用: {sorted(self.domain_groups)}"
)
classifier = self._group_classifiers[group]
route.append(f"group:{group}@explicit")
classification = classifier.classify(query)
if group is None:
# 自动检测:8 领域分类 → 映射大领域组
group = self._group_of_domain.get(classification.domain, "general")
route.append(f"group:{group}@auto")
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}") route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
subdomain, subdomain2 = self._detect_subdomain(query, classification.domain)
if subdomain:
route.append(f"subdomain:{subdomain}")
if subdomain2:
route.append(f"subdomain2:{subdomain2}")
# ---- Step 3: 低置信度 -> 直接走最后处理者 ---- # 低置信度 -> 直接走大模型
if classifier.should_fallback(classification, self.low_confidence_threshold): if self.classifier.should_fallback(classification, self.low_confidence_threshold):
route.append("direct_fallback") route.append("direct_fallback")
fb = await self._call_fallback(query) return await self._fallback_result(query, classification, route, start)
latency = now_ms() - start
result = self._finalize(query, classification, fb, quality_score=0.0,
upgraded=True, route=route, latency_ms=latency,
model_used=fb.model_used, cost_est=fb.cost_est,
subdomain=subdomain, subdomain2=subdomain2,
domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=0.0,
upgraded=True, model=fb.model_used, latency=latency,
)
self._record(result, latency)
return result
# ---- Step 4: Planner 任务拆解(DAG ---- # ---- Step 3: 选择专家 ----
graph = self.planner.plan(query, classification) domain = classification.domain
route.extend(self.planner.explain_plan(graph)) expert = self.experts.get(domain)
if expert is None:
expert = self.experts.get("general")
route.append("expert:fallback-to-general")
else:
route.append(f"expert:{expert.name}")
# ---- Step 5: 黑板初始化 + 前向链(规则轨迹) ---- # ---- Step 4: 生成 ----
memory = WorkingMemory()
self.inference.initialize(
query, classification.domain, classification.difficulty,
classification.confidence, memory,
)
fired = self.inference.run(query, classification.domain, memory)
if fired:
route.append(f"rules:{','.join(fired[:5])}")
# ---- Step 6: DAG 顺序执行(拓扑序) ----
order = graph.topo_order()
last_model = f"rule:{classification.domain}"
for node in order:
last_model = await self._execute_node(graph, node, classification, memory, route) or last_model
# ---- Step 7: 黑板合并(节点输出 + 推理机规则产出) ----
response = memory.merge([n.id for n in order])
# 追加推理机规则产出的部分解(带 output 的知识规则,如 git/docker/常识条目)
node_ids = {n.id for n in order}
extra_sections = [sid for sid in memory.sections if sid not in node_ids]
extras = [memory.section(s) for s in extra_sections if memory.section(s)]
if extras:
extra_text = "\n\n".join(extras)
response = (response + "\n\n" + extra_text) if response.strip() else extra_text
if not response.strip():
response = "(规则执行器)未能生成有效回答:任务均未产出内容。"
route.append("merge:empty")
# ---- Step 8: Judge 校验 ----
try: try:
evaluation = await self.judge.evaluate(query, response, classification.domain) expert_resp = await expert.generate(query, classification.difficulty)
except Exception as e:
self.stats.record_error()
route.append(f"expert_error:{type(e).__name__}")
return await self._fallback_result(query, classification, route, start,
error=str(e))
# ---- Step 5: Judge 评估 ----
try:
evaluation = await self.judge.evaluate(query, expert_resp.text, domain)
except Exception: except Exception:
evaluation = None evaluation = None
route.append("judge_error") route.append("judge_error")
quality_score = evaluation.overall_score if evaluation else 0.0 quality_score = evaluation.overall_score if evaluation else 0.0
route.append(f"judge:{quality_score:.2f}") route.append(f"judge:{quality_score:.2f}")
upgraded = False upgraded = False
final_resp = expert_resp
if evaluation is not None and evaluation.needs_fallback: if evaluation is not None and evaluation.needs_fallback:
route.append("upgrade") route.append("upgrade")
fb = await self._call_fallback(query) final_resp = await self._call_fallback(query)
response = fb.text
last_model = fb.model_used
upgraded = True upgraded = True
latency = now_ms() - start latency = now_ms() - start
result = self._finalize(query, classification, ExpertResponse( result = self._finalize(query, classification, final_resp, quality_score=quality_score,
text=response, model_used=last_model, latency_ms=latency, upgraded=upgraded, route=route, latency_ms=latency,
tokens=max(8, int(len(response) / 2.2)), cost_est=0.0, model_used=final_resp.model_used, cost_est=final_resp.cost_est)
), quality_score=quality_score, upgraded=upgraded, route=route,
latency_ms=latency, model_used=last_model, cost_est=0.0,
subdomain=subdomain, subdomain2=subdomain2, domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=quality_score,
upgraded=upgraded, model=last_model, latency=latency,
)
self._record(result, latency) self._record(result, latency)
# 未升级的结果写缓存 # 未升级的结果写缓存
@@ -253,87 +133,29 @@ class Router:
return result return result
# --------------------------------------------------------------- # ---------------------------------------------------------------
def _store_trace(self, request_id: str, query: str, group: Optional[str], async def _fallback_result(self, query: str, classification: Classification,
domain: str, difficulty: str, confidence: float, route: list, start: float,
subdomain: Optional[str], subdomain2: Optional[str], error: Optional[str] = None) -> RouterResult:
route: list, quality: float, upgraded: bool, """兜底路径的公共收尾:调用大模型回退 -> finalize -> 记录指标。
model: str, latency: float,
cache_hit: bool = False, cache_level: Optional[str] = None) -> None:
"""记录完整推理链到轨迹存储(T3:可解释性产品化)。"""
self.trace_store.put(request_id, {
"request_id": request_id,
"query": query,
"domain_group": group,
"domain": domain,
"difficulty": difficulty,
"confidence": round(confidence, 4),
"subdomain": subdomain,
"subdomain2": subdomain2,
"route": list(route),
"quality_score": round(quality, 4),
"upgraded": upgraded,
"model_used": model,
"latency_ms": round(latency, 2),
"cache_hit": cache_hit,
"cache_level": cache_level,
})
# --------------------------------------------------------------- 低置信度直连、专家异常两条路径共用,避免收尾逻辑三处重复。
def _detect_subdomain(self, query: str, domain: str) -> tuple:
"""子领域识别:返回 (二级 subdomain, 三级 subdomain2)。
二级取领域内最高优先级带 subdomain 的命中规则;
三级取最高优先级带 subdomain2 的命中规则(可与二级来自不同规则)。
""" """
hits = self.kb.match(query, domain=domain) fb = await self._call_fallback(query)
sub = None latency = now_ms() - start
sub2 = None result = self._finalize(query, classification, fb, quality_score=0.0,
for h in hits: upgraded=True, route=route, latency_ms=latency,
if sub is None and h.subdomain: model_used=fb.model_used, cost_est=fb.cost_est,
sub = h.subdomain error=error)
if sub2 is None and h.subdomain2: self._record(result, latency)
sub2 = h.subdomain2 return result
if sub is not None and sub2 is not None:
break
return sub, sub2
# ---------------------------------------------------------------
async def _execute_node(self, graph: TaskGraph, node: TaskNode,
classification: Classification, memory: WorkingMemory,
route: list) -> Optional[str]:
"""执行一个子任务节点;返回使用的 model_used(失败返回 None)。"""
# 依赖检查:依赖失败/跳过 → 本节点跳过
for dep_id in node.deps:
dep = graph.get(dep_id)
if dep is not None and dep.status in ("failed", "skipped"):
node.status = "skipped"
route.append(f"{node.id}:{node.kind}:skip")
return None
node.status = "running"
try:
# NodeExecutor 后端执行(rule 零参数 / model 专家池 ≤8B)
resp = await self.node_executor.execute(
node, classification.domain, classification.difficulty, memory)
node.output = resp.text
node.status = "done"
memory.write_section(node.id, resp.text)
route.append(f"{node.id}:{node.kind}")
return resp.model_used
except Exception as e:
node.status = "failed"
node.error = str(e)
self.stats.record_error()
route.append(f"{node.id}:{node.kind}:error:{type(e).__name__}")
return None
# ---------------------------------------------------------------
async def _call_fallback(self, query: str) -> ExpertResponse: async def _call_fallback(self, query: str) -> ExpertResponse:
try: try:
return await self.fallback.generate(query) return await self.fallback.generate(query)
except Exception as e: except Exception as e:
# 回退也失败:返回错误占位响应 # 回退也失败:返回错误占位响应
return ExpertResponse( return ExpertResponse(
text=f"[系统错误] 专家与最后处理者均失败:{type(e).__name__}: {e}", text=f"[系统错误] 专家与大模型回退均失败:{type(e).__name__}: {e}",
model_used=f"error:{self.fallback.name}", model_used=f"error:{self.fallback.name}",
cost_est=0.0, cost_est=0.0,
) )
@@ -342,10 +164,7 @@ class Router:
def _finalize(query: str, classification: Classification, resp: ExpertResponse, def _finalize(query: str, classification: Classification, resp: ExpertResponse,
quality_score: float, upgraded: bool, route: list, quality_score: float, upgraded: bool, route: list,
latency_ms: float, model_used: str, cost_est: float, latency_ms: float, model_used: str, cost_est: float,
error: Optional[str] = None, error: Optional[str] = None) -> RouterResult:
subdomain: Optional[str] = None,
subdomain2: Optional[str] = None,
domain_group: Optional[str] = None) -> RouterResult:
return RouterResult( return RouterResult(
query=query, query=query,
response=resp.text, response=resp.text,
@@ -360,9 +179,6 @@ class Router:
cache_hit=False, cache_hit=False,
cost_est=cost_est, cost_est=cost_est,
error=error, error=error,
subdomain=subdomain,
subdomain2=subdomain2,
domain_group=domain_group,
) )
def _record(self, result: RouterResult, latency_ms: float): def _record(self, result: RouterResult, latency_ms: float):
@@ -382,25 +198,18 @@ class Router:
return { return {
"status": "ok", "status": "ok",
"domains": list(self.experts.keys()), "domains": list(self.experts.keys()),
"domain_groups": self.domain_groups,
"classifier": type(self.classifier).__name__, "classifier": type(self.classifier).__name__,
"judge": type(self.judge).__name__, "judge": type(self.judge).__name__,
"fallback": type(self.fallback).__name__, "fallback": type(self.fallback).__name__,
"planner": type(self.planner).__name__,
"execution_mode": self.expert_backend,
"rules": self.kb.rules_count(),
} }
def build_router(config_path: Optional[str] = None) -> Router: def build_router(config_path: Optional[str] = None) -> Router:
"""从配置构建完整 Router(默认 L0 专家系统模式:零参数可跑)。""" """从配置构建完整 Router(默认 mock 全链路,零依赖可跑)。"""
config = load_config(config_path) config = load_config(config_path)
kb = KnowledgeBase()
classifier = build_classifier(config.get("classifier", {})) classifier = build_classifier(config.get("classifier", {}))
experts = build_expert_pool(config.get("experts", {}), config.get("domains", [])) experts = build_expert_pool(config.get("experts", {}), config.get("domains", []))
judge = build_judge(config.get("judge", {}), judge = build_judge(config.get("judge", {}), config.get("router", {}).get("judge_fallback_threshold", 0.70))
config.get("router", {}).get("judge_fallback_threshold", 0.70),
kb=kb)
fallback = build_fallback(config.get("fallback", {})) fallback = build_fallback(config.get("fallback", {}))
cache_cfg = config.get("cache", {}) cache_cfg = config.get("cache", {})
cache = RouterCache( cache = RouterCache(
@@ -408,8 +217,5 @@ def build_router(config_path: Optional[str] = None) -> Router:
similarity_threshold=cache_cfg.get("similarity_threshold", 0.88), similarity_threshold=cache_cfg.get("similarity_threshold", 0.88),
promote_frequency=cache_cfg.get("promote_frequency", 5), promote_frequency=cache_cfg.get("promote_frequency", 5),
) )
ecfg = config.get("execution", {})
planner = Planner(kb, max_depth=ecfg.get("max_plan_depth", 3))
stats = Stats() stats = Stats()
return Router(classifier, experts, judge, fallback, cache, stats, config, return Router(classifier, experts, judge, fallback, cache, stats, config)
kb=kb, planner=planner)
-839
View File
@@ -1,839 +0,0 @@
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
- TOOLS_SPEClist_dir / read_file / write_file / edit_file / search_files /
run_command / web_fetch 七个工具的 OpenAI tools 声明
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib);
web_fetch 带公网 SSRF 防护(dsh web_fetch 同款),run_command 带危险命令拦截
- parse_tool_calls:解析 OpenAI 响应里的 tool_callsarguments 容错为 {}
- ToolLoop:通用智能体循环。chat_fn 注入(网关传 OpenAI 兼容客户端,测试传假实现),
本模块只负责循环编排:调用 -> 执行工具 -> 回喂结果 -> 直到模型给出最终答复。
工程约束:
- 纯标准库(router_system 零第三方依赖不变)
- 工具结果回喂前截断(防止上下文爆炸),轮数与 token 双上限(金额护栏)
- 事件回调 on_event 逐条产出过程事件(供 SSE 透出"智能体在做什么"
- 工具在线程池执行(asyncio.to_thread),长命令/大搜索不阻塞事件循环
- 重复同参调用达到阈值回喂警语(防模型原地打转,dsh repeat-reminder 同款)
"""
from __future__ import annotations
import asyncio
import ipaddress
import json
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
# 回喂给模型的工具结果/读取内容上限(字符)
MAX_READ_CHARS = 8000
MAX_LIST_ENTRIES = 200
MAX_RESULT_CHARS = 8000
MAX_WRITE_CHARS = 200_000
# search_files 上限
SEARCH_MAX_MATCHES = 30
SEARCH_MAX_FILES = 400
SEARCH_MAX_FILE_BYTES = 512 * 1024
SEARCH_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
# run_command 上限
SHELL_OUTPUT_CHARS = 4000
# web_fetch 上限(SSRF 防护:仅公网 http/https,拒绝私网/环回/链路本地地址)
FETCH_MAX_CHARS = 12000
FETCH_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 512 * 1024
FETCH_ALLOWED_SCHEMES = ("http", "https")
# NAT64 Well-Known Prefixdsh 同款拒绝项)
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
# 重复调用提醒阈值(同一工具 + 同一参数第 N 次起提示模型换策略)
REPEAT_CALL_WARN_AT = 3
# 默认循环上限
DEFAULT_MAX_ROUNDS = 8
# 可能长时间运行的工具(命令执行 / 网络抓取 / 大范围搜索)放独立线程执行,
# 不阻塞事件循环;完成等待用 asyncio.sleep 轮询(补丁运行时的 TestClient
# 每请求独立事件循环,不推进 run_in_executor 桥接;轮询在生产/测试两端都可靠)。
SLOW_TOOLS = {"run_command", "web_fetch", "search_files"}
def _spawn_tool_thread(fn, *args):
"""起守护线程执行 fn(*args),返回 (结果盒子, 线程);轮询线程存活后取盒内值。"""
import threading
box: Dict[str, Any] = {}
def _runner():
try:
box["result"] = fn(*args)
except BaseException as exc: # 线程内异常回传给调用方
box["error"] = exc
t = threading.Thread(target=_runner, daemon=True, name="agenttool")
t.start()
return box, t
def _dispatch_tool(tools, name, arguments):
"""统一工具分发(内部辅助)。"""
return tools.execute(name, arguments)
async def run_tool_async(tools: "WorkspaceTools", name: str,
arguments: Dict[str, Any]) -> Dict[str, Any]:
"""执行一个工具:慢工具放线程并轮询等完成,快工具直接内联执行。"""
if name not in SLOW_TOOLS:
return _dispatch_tool(tools, name, arguments)
box, t = _spawn_tool_thread(_dispatch_tool, tools, name, arguments)
while t.is_alive():
await asyncio.sleep(0.02)
if "error" in box:
raise box["error"]
return box["result"]
# OpenAI tools 声明(chat/completions 请求的 tools 参数)
TOOLS_SPEC: List[Dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "list_dir",
"description": "列出工作区内目录的内容(文件与子目录,含大小)。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "工作区内的相对路径,默认根目录"}
},
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取工作区内一个文本文件的内容(过长自动截断)。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "工作区内的相对路径"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "把文本内容写入(或创建/覆盖)工作区内的一个文件。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "工作区内的相对路径"},
"content": {"type": "string", "description": "要写入的全文"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "对工作区内已有文件做精确替换编辑:old_string 必须在文件中恰好出现一次,"
"被替换为 new_string。适合小改动;大改用 write_file 整体重写。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "工作区内的相对路径"},
"old_string": {"type": "string", "description": "要替换的原文(须唯一匹配)"},
"new_string": {"type": "string", "description": "替换后的新文"},
},
"required": ["path", "old_string", "new_string"],
},
},
},
{
"type": "function",
"function": {
"name": "search_files",
"description": "在工作区(或其子目录)内按关键词跨文件搜索文本内容,"
"返回匹配的文件/行号/行内容(自动跳过 .git、node_modules 等目录与二进制大文件)。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索的关键词(大小写敏感)"},
"path": {"type": "string", "description": "限定的子目录,默认整个工作区"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "在工作区根目录执行一条 shell 命令并返回退出码与输出(如运行测试、查看版本)。"
"仅当系统开启 allow_shell 时可用。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令行"},
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "抓取一个公网 http/https URL 的文本内容(如查文档/接口说明),"
"返回截断后的正文。私网/环回地址会被拒绝;仅当系统开启 allow_net 时可用。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "要抓取的完整 URLhttp/https"},
},
"required": ["url"],
},
},
},
]
TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SPEC}
def browse_directories(path: str = "") -> Dict[str, Any]:
"""目录选择器的本地文件系统浏览(只列目录,不读文件内容)。
path 为空时列出 Windows 盘符(POSIX 列根目录)。返回
{"ok", "path", "parent", "dirs": [名称]};用于智能体"选择工作区"
"""
import os
if not path or not path.strip():
if os.name == "nt":
drives = [f"{c}:\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{c}:\\")]
return {"ok": True, "path": "", "parent": "", "dirs": drives}
return {"ok": True, "path": "/", "parent": "",
"dirs": sorted(os.listdir("/"))}
p = Path(path).resolve()
if not p.exists():
return {"ok": False, "error": f"路径不存在: {path}"}
if not p.is_dir():
return {"ok": False, "error": f"不是目录: {path}"}
dirs = []
for child in sorted(p.iterdir(), key=lambda c: c.name.lower()):
try:
if child.is_dir():
dirs.append(child.name)
except OSError:
continue # 无权限/符号链接坏点,跳过
parent = str(p.parent) if p.parent != p else ""
return {"ok": True, "path": str(p), "parent": parent, "dirs": dirs}
class ToolError(Exception):
"""工具执行失败(路径越界/不存在/参数非法)。"""
def _atomic_write_text(p: Path, text: str) -> None:
"""原子写文本:同目录临时文件 + os.replace(防半截文件;dsh atomic-write 同款)。
Windows 上目标被占用时 os.replace 可能 EPERM:小退避重试一次,
仍失败则退回直接写(保可用性,牺牲原子性)。
"""
import os
import tempfile
import time
tmp = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=str(p.parent),
prefix=p.name + ".", suffix=".tmp", delete=False) as f:
tmp = Path(f.name)
f.write(text)
for attempt in (0, 1):
try:
os.replace(tmp, p)
return
except PermissionError:
if attempt == 0:
time.sleep(0.05)
raise
except PermissionError:
if tmp is not None:
tmp.unlink(missing_ok=True)
p.write_text(text, encoding="utf-8") # 退路:非原子但保可用
except BaseException:
if tmp is not None:
tmp.unlink(missing_ok=True)
raise
# 危险命令模式(大小写不敏感):宁可误拦不可漏拦(用户可换写法绕开误拦项)
_DANGEROUS_PATTERNS = [
(r"\bformat\b\s+[a-z]:", "格式化磁盘"),
(r"\brd\s+/s", "递归删除目录"),
(r"\brmdir\s+/s", "递归删除目录"),
(r"\bdel\s+/[fsmq]", "强制/递归删除"),
(r"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+[/~]", "递归强制删除根/家目录"),
(r"\bshutdown\b", "关机/重启"),
(r"\bdiskpart\b", "磁盘分区操作"),
(r"\bbcdedit\b", "启动配置修改"),
(r"\breg\s+delete\b", "注册表删除"),
(r"\bvssadmin\s+delete\b", "卷影副本删除"),
(r"\bmkfs\b", "格式化文件系统"),
(r"\bdd\s+if=", "裸磁盘写入"),
(r":\(\)\s*\{.*\};\s*:", "fork 炸弹"),
(r"\bcurl\b[^|]*\|\s*(ba)?sh\b", "下载并执行脚本"),
(r"\bwget\b[^|]*\|\s*(ba)?sh\b", "下载并执行脚本"),
(r"\biwr\b[^|]*\|\s*iex\b", "下载并执行脚本"),
]
def _dangerous_command_reason(command: str) -> str:
"""命中危险命令模式时返回原因,否则返回空串(审批之外的独立防线)。"""
import re
lowered = command.lower()
for pattern, reason in _DANGEROUS_PATTERNS:
if re.search(pattern, lowered):
return reason
return ""
class WorkspaceTools:
"""被限制在根目录内的文件工具(智能体的"")。
安全:所有路径先 join 再 resolve,解析结果必须仍位于根目录内
(根目录自身允许),否则抛 ToolError——防 ../ 越界与绝对路径逃逸。
"""
def __init__(self, root: str | Path,
allow_shell: bool = False, shell_timeout_s: int = 20,
allow_net: bool = True):
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True)
self.allow_shell = bool(allow_shell)
self.shell_timeout_s = max(1, int(shell_timeout_s))
self.allow_net = bool(allow_net)
# ---------- 路径关押 ----------
def resolve(self, rel_path: str) -> Path:
rel = (rel_path or "").strip().replace("\\", "/").lstrip("/")
p = (self.root / rel).resolve()
if p != self.root and self.root not in p.parents:
raise ToolError(f"路径越界(不允许访问工作区之外): {rel_path}")
return p
# ---------- 三个工具 ----------
def list_dir(self, rel_path: str = "") -> Dict[str, Any]:
d = self.resolve(rel_path)
if not d.exists():
return {"ok": False, "error": f"目录不存在: {rel_path}"}
if not d.is_dir():
return {"ok": False, "error": f"不是目录: {rel_path}"}
entries = []
for child in sorted(d.iterdir(), key=lambda c: (c.is_file(), c.name.lower())):
if child.is_dir():
entries.append({"name": child.name + "/", "type": "dir"})
else:
entries.append({
"name": child.name, "type": "file",
"size": child.stat().st_size,
})
if len(entries) >= MAX_LIST_ENTRIES:
entries.append({"name": f"…(超过 {MAX_LIST_ENTRIES} 项已截断)", "type": "notice"})
break
return {"ok": True, "path": rel_path or ".", "entries": entries}
def read_file(self, rel_path: str) -> Dict[str, Any]:
p = self.resolve(rel_path)
if not p.exists():
return {"ok": False, "error": f"文件不存在: {rel_path}"}
if not p.is_file():
return {"ok": False, "error": f"不是文件: {rel_path}"}
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
return {"ok": False, "error": f"非文本文件(UTF-8 解码失败): {rel_path}"}
truncated = len(text) > MAX_READ_CHARS
return {
"ok": True,
"path": rel_path,
"content": text[:MAX_READ_CHARS],
"truncated": truncated,
"total_chars": len(text),
}
def write_file(self, rel_path: str, content: str) -> Dict[str, Any]:
if len(content) > MAX_WRITE_CHARS:
return {"ok": False, "error": f"内容过长(>{MAX_WRITE_CHARS} 字符),拒绝写入"}
p = self.resolve(rel_path)
p.parent.mkdir(parents=True, exist_ok=True)
_atomic_write_text(p, content)
return {"ok": True, "path": rel_path, "bytes_written": len(content.encode("utf-8"))}
def edit_file(self, rel_path: str, old_string: str, new_string: str) -> Dict[str, Any]:
"""精确替换编辑:old_string 必须在文件中恰好出现一次(harness 式安全编辑)。"""
if not old_string:
return {"ok": False, "error": "old_string 不能为空"}
if len(old_string) > MAX_READ_CHARS:
return {"ok": False, "error": "old_string 过长(先 read_file 分段定位)"}
p = self.resolve(rel_path)
if not p.exists() or not p.is_file():
return {"ok": False, "error": f"文件不存在: {rel_path}"}
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
return {"ok": False, "error": f"非文本文件: {rel_path}"}
count = text.count(old_string)
if count == 0:
return {"ok": False, "error": "old_string 未在文件中找到(先 read_file 核对原文)"}
if count > 1:
return {"ok": False,
"error": f"old_string 出现 {count} 次(要求唯一);请扩大上下文使其唯一"}
new_text = text.replace(old_string, new_string, 1)
_atomic_write_text(p, new_text)
return {
"ok": True, "path": rel_path,
"replaced": 1,
"changed_chars": len(new_text) - len(text),
}
def search_files(self, query: str, rel_path: str = "") -> Dict[str, Any]:
"""跨文件文本搜索(os.walk 修剪依赖/构建目录,限量返回,不跟随符号链接)。"""
import os
if not query:
return {"ok": False, "error": "query 不能为空"}
base = self.resolve(rel_path or "")
if not base.exists() or not base.is_dir():
return {"ok": False, "error": f"目录不存在: {rel_path}"}
matches: List[Dict[str, Any]] = []
scanned = 0
truncated = False
def _match_file(p: Path) -> bool:
"""在单文件内找匹配(找到即 True)。"""
nonlocal matches, truncated
for lineno, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
if query in line:
rel = p.relative_to(self.root).as_posix()
matches.append({
"file": rel, "line": lineno,
"text": line.strip()[:300],
})
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
return True
return False
for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
# 修剪依赖/构建目录:不进入(rglob 全量物化在大工作区上不可接受)
dirnames[:] = sorted((d for d in dirnames if d not in SEARCH_SKIP_DIRS),
key=str.lower)
if len(matches) >= SEARCH_MAX_MATCHES or scanned > SEARCH_MAX_FILES:
truncated = True
break
for fname in sorted(filenames, key=str.lower):
fpath = Path(dirpath) / fname
try:
if not fpath.is_file():
continue
if fpath.stat().st_size > SEARCH_MAX_FILE_BYTES:
continue
scanned += 1
if scanned > SEARCH_MAX_FILES:
truncated = True
break
if _match_file(fpath):
break
except (OSError, UnicodeDecodeError):
continue # 二进制/不可读/并发删除,跳过
if len(matches) >= SEARCH_MAX_MATCHES:
truncated = True
break
return {"ok": True, "query": query, "matches": matches,
"scanned_files": scanned, "truncated": truncated}
def run_command(self, command: str) -> Dict[str, Any]:
"""在工作区根目录执行一条 shell 命令行(默认关闭,allow_shell 开启后可用)。
安全设计(dsh bash 工具同款语义):
- 危险命令模式先拦截(即使审批通过也拒绝)
- 用显式 shell 解释器的参数列表执行(cmd /c 或 /bin/sh -c),
命令字符串对解释器可见属于功能本体,防护依赖 allow_shell 开关
+ 审批门卫 + 超时熔断 + cwd 关押在本工作区
"""
import os
import subprocess
if not self.allow_shell:
return {"ok": False,
"error": "run_command 未启用(系统设置 allow_shell 为关)。"
"请让用户在智能体页打开「允许执行命令」后重试。"}
command = (command or "").strip()
if not command:
return {"ok": False, "error": "command 不能为空"}
blocked = _dangerous_command_reason(command)
if blocked:
return {"ok": False, "error": f"命令被安全策略拒绝({blocked})。请换一种不具破坏性的做法。"}
if os.name == "nt":
# Windows:显式走 cmd /c(与 shell=True 内部同构——整条命令包一层引号,
# 避免参数列表的 CRT 转义与 cmd 引号语义冲突);COMSPEC 取系统 shell 路径
comspec = os.environ.get("COMSPEC", "cmd.exe")
run_args: Any = f'"{comspec}" /c "{command}"'
creationflags = 0x08000000 # CREATE_NO_WINDOW
else:
run_args = ["/bin/sh", "-c", command]
creationflags = 0
try:
proc = subprocess.run(
run_args, cwd=str(self.root), capture_output=True,
timeout=self.shell_timeout_s, creationflags=creationflags,
)
out = (proc.stdout or b"").decode("utf-8", errors="replace")
err = (proc.stderr or b"").decode("utf-8", errors="replace")
combined = (out + ("\n[stderr]\n" + err if err.strip() else "")).strip()
if len(combined) > SHELL_OUTPUT_CHARS:
combined = combined[:SHELL_OUTPUT_CHARS] + "…(输出截断)"
return {"ok": True, "exit_code": proc.returncode,
"output": combined or "(无输出)", "command": command}
except subprocess.TimeoutExpired:
return {"ok": False, "error": f"命令超时(>{self.shell_timeout_s}s),已终止: {command}"}
except OSError as e:
return {"ok": False, "error": f"命令执行失败: {type(e).__name__}: {e}"}
def web_fetch(self, url: str) -> Dict[str, Any]:
"""抓取公网 URL 文本(SSRF 防护:拒绝非 http(s)、私网/环回/链路本地/NAT64 目标)。
校验流程对齐 dsh web_fetch:解析 DNS -> 全部地址必须公网 -> 才发起请求;
响应限量(字节/字符)、超时熔断、二进制嗅探拒绝。
"""
import socket
import urllib.error
import urllib.parse
import urllib.request
if not self.allow_net:
return {"ok": False, "error": "web_fetch 未启用(系统设置 allow_net 为关)。"}
raw = (url or "").strip()
try:
parsed = urllib.parse.urlsplit(raw)
except ValueError:
return {"ok": False, "error": f"URL 无法解析: {raw[:120]}"}
if parsed.scheme.lower() not in FETCH_ALLOWED_SCHEMES:
return {"ok": False, "error": f"仅允许 http/https URL(收到 {parsed.scheme or ''}"}
host = parsed.hostname or ""
if not host:
return {"ok": False, "error": "URL 缺少主机名"}
try:
port = parsed.port
except ValueError:
return {"ok": False, "error": "URL 端口非法"}
# DNS 解析后逐一校验:任何私网/环回/链路本地/保留/NAT64 地址都拒绝
try:
infos = socket.getaddrinfo(host, port or (443 if parsed.scheme == "https" else 80),
proto=socket.IPPROTO_TCP)
except socket.gaierror as e:
return {"ok": False, "error": f"域名解析失败: {host} ({e})"}
for info in infos:
ip = info[4][0]
try:
addr = ipaddress.ip_address(ip.split("%")[0]) # 剥 zone id
except ValueError:
return {"ok": False, "error": f"解析出非法地址: {ip}"}
if (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_reserved or addr.is_multicast or addr.is_unspecified
or (addr.version == 6 and addr in _NAT64_PREFIX)):
return {"ok": False,
"error": f"目标地址 {addr} 属于内网/保留段,已被 SSRF 防护拒绝"}
try:
req = urllib.request.Request(raw, headers={"User-Agent": "router-agent/1.0"})
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_S) as resp:
body = resp.read(FETCH_MAX_BYTES + 1)
charset = resp.headers.get_content_charset() or "utf-8"
except urllib.error.HTTPError as e:
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}"}
except (urllib.error.URLError, OSError, ValueError) as e:
return {"ok": False, "error": f"抓取失败: {type(e).__name__}: {e}"}
if len(body) > FETCH_MAX_BYTES:
return {"ok": False, "error": f"响应超过 {FETCH_MAX_BYTES // 1024}KB 上限,拒绝处理"}
if b"\x00" in body[:512]:
return {"ok": False, "error": "非文本内容(检测到二进制),拒绝处理"}
try:
text = body.decode(charset, errors="replace")
except LookupError:
text = body.decode("utf-8", errors="replace")
truncated = len(text) > FETCH_MAX_CHARS
return {"ok": True, "url": raw,
"content": text[:FETCH_MAX_CHARS], "truncated": truncated,
"total_chars": len(text)}
# ---------- 统一执行入口 ----------
def execute(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""按名字执行工具;任何异常折叠为 {"ok": False, "error": ...}。"""
try:
if name == "list_dir":
return self.list_dir(str(arguments.get("path", "")))
if name == "read_file":
return self.read_file(str(arguments.get("path", "")))
if name == "write_file":
return self.write_file(
str(arguments.get("path", "")), str(arguments.get("content", "")))
if name == "edit_file":
return self.edit_file(
str(arguments.get("path", "")),
str(arguments.get("old_string", "")),
str(arguments.get("new_string", "")))
if name == "search_files":
return self.search_files(
str(arguments.get("query", "")), str(arguments.get("path", "")))
if name == "run_command":
return self.run_command(str(arguments.get("command", "")))
if name == "web_fetch":
return self.web_fetch(str(arguments.get("url", "")))
return {"ok": False, "error": f"未知工具: {name}"}
except ToolError as e:
return {"ok": False, "error": str(e)}
except OSError as e:
return {"ok": False, "error": f"文件系统错误: {type(e).__name__}: {e}"}
def _loads_json_object(text: str) -> Dict[str, Any]:
"""宽松解析 JSON 对象:剥除 markdown 围栏、取首个 {...};失败返回 {}"""
t = (text or "").strip()
fence = "`" * 3
t = t.replace(fence + "json", fence).replace(fence, "").strip()
try:
obj = json.loads(t)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
start, end = t.find("{"), t.rfind("}")
if start != -1 and end > start:
try:
obj = json.loads(t[start:end + 1])
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
return {}
def parse_tool_calls(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""从 OpenAI 响应的 message 解析 tool_calls。
返回 [{"id","name","arguments"(dict)}]arguments 非法 JSON 时容错为 {}
"""
out: List[Dict[str, Any]] = []
for tc in message.get("tool_calls") or []:
fn = tc.get("function") or {}
raw = fn.get("arguments")
if isinstance(raw, dict):
args = raw
elif isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
args = parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
args = {}
else:
args = {}
out.append({
"id": tc.get("id") or f"call_{len(out)}",
"name": fn.get("name") or "",
"arguments": args,
})
return out
class ToolLoop:
"""通用智能体工具循环(zcode 式:思考 -> 调工具 -> 看结果 -> 再思考)。
chat_fn(messages, tools_spec) -> {"content": str|None,
"tool_calls": [ {id,name,arguments}, ... ],
"usage": {"prompt_tokens", "completion_tokens"}}
由网关注入真实 OpenAI 兼容客户端;测试注入脚本化假实现。
on_event(ev) 为可选同步回调,逐条收到过程事件:
{"type":"round","round":n}
{"type":"tool_call","round":n,"name":...,"arguments":...}
{"type":"tool_result","round":n,"name":...,"ok":...,"preview":...}
{"type":"usage","prompt_tokens":...,"completion_tokens":...}
{"type":"final","round":n,"reason":"answer"|"max_rounds"|"token_cap"|"error"}
"""
def __init__(
self,
tools: WorkspaceTools,
chat_fn: Callable[[List[Dict[str, Any]], List[Dict[str, Any]]], Awaitable[Dict[str, Any]]],
max_rounds: int = DEFAULT_MAX_ROUNDS,
token_cap: int = 0,
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
result_preview_chars: int = MAX_RESULT_CHARS,
emit_final: bool = True,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
on_delta: Optional[Callable[[str], None]] = None,
):
self.tools = tools
self.chat_fn = chat_fn
self.max_rounds = max(1, int(max_rounds))
self.token_cap = int(token_cap) # 0 = 不限
self.on_event = on_event
self.result_preview_chars = result_preview_chars
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
self.approval_hook = approval_hook
# 流式增量回调(D10,可选):chat_fn 支持 on_delta 参数时逐段转发模型文本
self.on_delta = on_delta
self._accepts_delta: Optional[bool] = None
# 重复调用计数(dsh repeat-tool-reminder 同款提醒,防模型原地打转)
self._call_counts: Dict[tuple, int] = {}
def _emit(self, ev: Dict[str, Any]) -> None:
if ev.get("type") == "final" and not self.emit_final:
return # 内层循环不发终态事件(外层编排负责)
if self.on_event is not None:
try:
self.on_event(ev)
except Exception:
pass # 事件回调不允许打断主循环
def _total_tokens(self, usage: Dict[str, int]) -> int:
return int(usage.get("prompt_tokens", 0)) + int(usage.get("completion_tokens", 0))
def _invoke_chat(self, messages: List[Dict[str, Any]]) -> Awaitable[Dict[str, Any]]:
"""调用 chat_fn;其签名支持 on_delta 时才传入(对旧假实现向后兼容)。"""
if self._accepts_delta is None:
try:
import inspect
self._accepts_delta = len(inspect.signature(self.chat_fn).parameters) >= 3
except (TypeError, ValueError):
self._accepts_delta = False
if self.on_delta is not None and self._accepts_delta:
return self.chat_fn(messages, TOOLS_SPEC, self.on_delta)
return self.chat_fn(messages, TOOLS_SPEC)
async def run(self, task: str, system: str = "",
history: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""执行任务直到模型给出最终答复或触顶。
history 为既往对话消息(user/assistant,不含工具细节),用于会话式多轮上下文。
"""
messages: List[Dict[str, Any]] = []
if system:
messages.append({"role": "system", "content": system})
if history:
messages.extend(history)
messages.append({"role": "user", "content": task})
total_in = 0
total_out = 0
last_content = ""
for round_no in range(1, self.max_rounds + 1):
self._emit({"type": "round", "round": round_no})
try:
resp = await self._invoke_chat(messages)
except Exception as e:
self._emit({"type": "final", "round": round_no, "reason": "error",
"error": f"{type(e).__name__}: {e}"})
return {"response": "", "rounds": round_no, "reason": "error",
"error": f"{type(e).__name__}: {e}",
"prompt_tokens": total_in, "completion_tokens": total_out}
usage = resp.get("usage") or {}
total_in += int(usage.get("prompt_tokens", 0))
total_out += int(usage.get("completion_tokens", 0))
self._emit({"type": "usage", "prompt_tokens": total_in,
"completion_tokens": total_out})
# 金额护栏(D6 同源):token 触顶立即停
if self.token_cap and total_in + total_out > self.token_cap:
self._emit({"type": "final", "round": round_no, "reason": "token_cap"})
return {"response": last_content, "rounds": round_no, "reason": "token_cap",
"error": f"token 熔断({total_in + total_out}/{self.token_cap}",
"prompt_tokens": total_in, "completion_tokens": total_out}
calls = resp.get("tool_calls") or []
if not calls:
self._emit({"type": "final", "round": round_no, "reason": "answer"})
return {"response": resp.get("content") or "", "rounds": round_no,
"reason": "answer", "error": None,
"prompt_tokens": total_in, "completion_tokens": total_out}
# 有工具调用:回填 assistant 消息(带原始 tool_calls 结构)+ 逐个执行
messages.append({
"role": "assistant",
"content": resp.get("content") or None,
"tool_calls": [
{
"id": c["id"],
"type": "function",
"function": {"name": c["name"],
"arguments": json.dumps(c["arguments"], ensure_ascii=False)},
}
for c in calls
],
})
for c in calls:
self._emit({"type": "tool_call", "round": round_no,
"name": c["name"], "arguments": c["arguments"]})
# 审批门卫(D9):按策略挂起等待用户裁决;拒绝/超时折叠为失败结果回喂
if self.approval_hook is not None:
allowed = False
try:
allowed = await self.approval_hook(c["name"], c["arguments"])
except Exception as e:
self._emit({"type": "approval_decided", "round": round_no,
"id": "", "allowed": False,
"note": f"审批流程异常: {type(e).__name__}"})
if not allowed:
denied = {"ok": False,
"error": "用户拒绝执行该操作(如需执行请调整审批策略或换一种做法)"}
preview = json.dumps(denied, ensure_ascii=False)
self._emit({"type": "tool_result", "round": round_no,
"name": c["name"], "ok": False, "preview": preview})
messages.append({"role": "tool", "tool_call_id": c["id"],
"content": preview})
continue
result = await run_tool_async(self.tools, c["name"], c["arguments"])
preview = json.dumps(result, ensure_ascii=False)
if len(preview) > self.result_preview_chars:
preview = preview[:self.result_preview_chars] + "…(截断)"
# 重复调用提醒:同一工具同一参数第 N 次起,回喂时附警语促使换策略
key = (c["name"], json.dumps(c["arguments"], sort_keys=True, ensure_ascii=False))
seen = self._call_counts.get(key, 0) + 1
self._call_counts[key] = seen
if seen >= REPEAT_CALL_WARN_AT:
preview += (f"\n[系统提示] 该工具已第 {seen} 次以完全相同的参数调用。"
"重复同样的调用不会带来新信息;请改变做法或直接给出最终答复。")
self._emit({"type": "repeat_warning", "round": round_no,
"name": c["name"], "count": seen})
self._emit({"type": "tool_result", "round": round_no,
"name": c["name"], "ok": bool(result.get("ok")),
"preview": preview})
messages.append({
"role": "tool",
"tool_call_id": c["id"],
"content": preview,
})
last_content = resp.get("content") or last_content
# 轮次耗尽:不再给工具,让模型立即总结(无 tools 的最后一次调用)
self._emit({"type": "final", "round": self.max_rounds, "reason": "max_rounds"})
try:
messages.append({"role": "user",
"content": "工具轮次已达上限。请基于以上信息立即给出最终答复,不要再调用工具。"})
resp = await self.chat_fn(messages, [])
usage = resp.get("usage") or {}
total_in += int(usage.get("prompt_tokens", 0))
total_out += int(usage.get("completion_tokens", 0))
final_text = resp.get("content") or last_content
except Exception:
final_text = last_content
return {"response": final_text, "rounds": self.max_rounds, "reason": "max_rounds",
"error": "工具轮次达上限,已强制总结",
"prompt_tokens": total_in, "completion_tokens": total_out}
-49
View File
@@ -1,49 +0,0 @@
"""推理链轨迹存储(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()
-96
View File
@@ -1,96 +0,0 @@
"""v2 统计聚合器(V2Stats)—— token 计量与账单(纯标准库)。
T9:每请求 API token 记账;聚合快路径命中率、回合数分布、熔断次数、累计 token/成本。
配合 /metrics 对外透出(论文 E1 token 经济学数据来源之一)。
"""
from __future__ import annotations
import threading
from typing import Any, Dict, List, Optional
class V2Stats:
"""线程安全的 v2 运行统计。"""
def __init__(self):
self._lock = threading.Lock()
self._total = 0
self._fast_path = 0
self._breach = 0
self._by_status: Dict[str, int] = {}
self._rounds: List[int] = []
self._api_input_tokens = 0
self._api_output_tokens = 0
self._api_cost_usd = 0.0
self._by_model: Dict[str, Dict[str, Any]] = {}
self._recent: List[Dict[str, Any]] = []
def record(self, result) -> None:
"""记录一次 PipelineResult。"""
with self._lock:
self._total += 1
if getattr(result, "fast_path", False):
self._fast_path += 1
status = getattr(result, "status", "?")
self._by_status[status] = self._by_status.get(status, 0) + 1
self._rounds.append(getattr(result, "rounds_used", 0))
if "breach" in " ".join(getattr(result, "route", [])):
self._breach += 1
self._api_input_tokens += getattr(result, "api_input_tokens", 0)
self._api_output_tokens += getattr(result, "api_output_tokens", 0)
self._api_cost_usd += getattr(result, "cost_est", 0.0)
# 按模型分账(token / 成本 / 次数)
model = getattr(result, "model_used", None) or "unknown"
in_tok = getattr(result, "api_input_tokens", 0)
out_tok = getattr(result, "api_output_tokens", 0)
bucket = self._by_model.setdefault(model, {
"requests": 0, "input_tokens": 0, "output_tokens": 0, "cost_est_usd": 0.0,
})
bucket["requests"] += 1
bucket["input_tokens"] += in_tok
bucket["output_tokens"] += out_tok
bucket["cost_est_usd"] = round(bucket["cost_est_usd"] + getattr(result, "cost_est", 0.0), 6)
self._recent.append({
"request_id": getattr(result, "request_id", ""),
"status": status,
"fast_path": getattr(result, "fast_path", False),
"api_input_tokens": getattr(result, "api_input_tokens", 0),
"api_output_tokens": getattr(result, "api_output_tokens", 0),
"rounds_used": getattr(result, "rounds_used", 0),
})
if len(self._recent) > 200:
self._recent = self._recent[-200:]
def summary(self) -> Dict[str, Any]:
with self._lock:
total = self._total
rounds = self._rounds
return {
"total_requests": total,
"fast_path_rate": round(self._fast_path / total, 4) if total else 0.0,
"status_distribution": dict(self._by_status),
"breach_count": self._breach,
"rounds_used": {
"avg": round(sum(rounds) / len(rounds), 2) if rounds else 0.0,
"max": max(rounds) if rounds else 0,
"distribution": _histogram(rounds),
},
"api_tokens": {
"input": self._api_input_tokens,
"output": self._api_output_tokens,
"total": self._api_input_tokens + self._api_output_tokens,
"cost_est_usd": round(self._api_cost_usd, 6),
},
"by_model": {
m: {**b, "cost_est_usd": round(b["cost_est_usd"], 6)}
for m, b in self._by_model.items()
},
}
def _histogram(values: List[int], max_bucket: int = 10) -> Dict[str, int]:
out: Dict[str, int] = {}
for v in values:
key = str(v) if v <= max_bucket else f">{max_bucket}"
out[key] = out.get(key, 0) + 1
return out
-135
View File
@@ -1,135 +0,0 @@
"""接地验证器(D4)—— Worker 自验证的"接地"来源。
验证分层优先级(D4):
可执行验证(跑代码/跑测试) > facts 对照 > 结构检查 > 模型自由判断(最后手段)
- 可执行验证:code 域 .py 工件在临时目录子进程沙箱运行(timeout、-I 隔离、捕获输出)。
- facts 对照:把回答/工件与知识库 factsstatement/keywords)比对覆盖度。
- 结构检查:工件非空、长度达标、不含纯占位。
说明:沙箱目前做"临时目录 + 超时 + 解释器隔离",Windows 下真正禁网需系统级工具,
此处以超时与隔离为主要护栏(文档如实记录)。验证器为纯逻辑,可注入 runner 便于单测。
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
# 判定为"含代码"的启发标记
_CODE_HINTS = ("def ", "class ", "import ", "return ", "if __name__", "print(")
def detect_artifact_language(name: str) -> str:
"""按文件名推断工件语言:python / json / text。"""
suffix = Path(name).suffix.lower()
if suffix in (".py", ".pyw"):
return "python"
if suffix in (".json",):
return "json"
return "text"
def extract_code_block(text: str) -> str:
"""从模型输出提取 python 代码块(剥除 markdown 围栏),无则返回原文本。"""
t = text.strip()
fence = chr(96) * 3 # 三个反引号
marker = fence + "python"
start = t.find(marker)
if start == -1:
return t
body_start = start + len(marker)
end = t.find(fence, body_start)
if end == -1:
return t[body_start:].strip()
return t[body_start:end].strip()
def run_code_sandbox(code: str, timeout_s: float = 10.0,
runner: Optional[Callable[[List[str], Dict[str, str], float], Tuple[int, str, str]]] = None
) -> Tuple[int, str, str]:
"""在临时目录子进程运行 python 代码。返回 (returncode, stdout, stderr)。
隔离措施:临时工作目录、-I 隔离模式、timeout 超时强杀、捕获输出。
runner 可注入(测试用假执行器,避免真跑任意代码)。
"""
if runner is not None:
return runner([sys.executable, "-I"], {}, timeout_s)
with tempfile.TemporaryDirectory(prefix="v2_sandbox_") as tmp:
script = Path(tmp) / "main.py"
script.write_text(code, encoding="utf-8")
try:
proc = subprocess.run(
[sys.executable, "-I", str(script)],
capture_output=True, text=True, timeout=timeout_s,
cwd=tmp,
creationflags=subprocess.CREATE_NO_WINDOW,
)
return proc.returncode, proc.stdout or "", proc.stderr or ""
except subprocess.TimeoutExpired:
return -1, "", "timeout exceeded"
class Verifier:
"""按 D4 分层执行接地验证。"""
def __init__(self, code_timeout_s: float = 10.0,
sandbox_runner: Optional[Callable[..., Tuple[int, str, str]]] = None):
self.code_timeout_s = code_timeout_s
self._sandbox_runner = sandbox_runner
def verify(self, domain: str, artifact_name: str, artifact_text: str,
query: str, kb: Any = None) -> Tuple[bool, List[str]]:
"""返回 (passed, details)。kb 为 KnowledgeBase(可 None,跳过 facts 层)。"""
lang = detect_artifact_language(artifact_name)
details: List[str] = []
# 1) 可执行验证(D4 最高优先级):code + python 工件且含代码
if lang == "python" and any(h in artifact_text for h in _CODE_HINTS):
rc, out, err = run_code_sandbox(artifact_text, self.code_timeout_s,
runner=self._sandbox_runner)
if rc == 0:
details.append("代码沙箱运行通过 (rc=0)")
return True, details
# 可执行验证失败 -> 直接拒绝(不落回结构检查,避免"假通过")
details.append(f"代码沙箱运行失败 rc={rc}: {(err or out)[:120]}")
return False, details
elif lang == "python":
details.append("工件不含可执行代码(跳过沙箱,进入 facts/结构检查)")
# 2) facts 对照
if kb is not None:
fact_hits = self._check_facts(domain, artifact_text, kb)
if fact_hits:
details.append(f"facts 对照命中 {fact_hits}")
return True, details
# 3) 结构检查
text = artifact_text.strip()
if len(text) < 20:
details.append(f"工件过短({len(text)} 字符)")
return False, details
if text.lower() in ("pass", "none", "todo", "待实现", ""):
details.append("工件为占位内容")
return False, details
details.append("结构检查通过(非空、长度达标)")
return True, details
def _check_facts(self, domain: str, text: str, kb: Any) -> int:
"""统计工件文本命中知识库事实的数量。"""
facts = kb.facts(domain) or []
if not facts:
return 0
hits = 0
for f in facts:
kw = f.get("keywords") or []
if any(str(k) in text for k in kw):
hits += 1
return hits
def build_verifier(cfg: Dict[str, Any]) -> Verifier:
"""cfg 为 config.worker 段。"""
return Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
-215
View File
@@ -1,215 +0,0 @@
"""WorkerLoop —— 小模型(本地 llama.cpp)的"实现/自验证"循环(端云协同的执行者)。
流程(对齐《实现方案_v2》5.1 T5 / 4.4):
读 brief+当前步 -> 模型生成工件 -> 接地验证(D4 分层)
-> 通过:写 progress(done)
-> 失败:自修 <= max_fix_attempts 次(把验证错误回喂重新生成)
-> 仍失败:写 issue(增量、带锚点)
- generate 为可注入的文本生成器(真实为 llama-server 端点;测试用假实现)。
- 工件落盘:runs/<request_id>/artifacts/<step>.py(由 pipeline 负责写盘,本模块只产出文本)。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
from .verifier import Verifier, detect_artifact_language, extract_code_block
from .workspace import Workspace, build_anchor
# 按领域推断默认工件扩展名
_DOMAIN_EXT = {
"code": ".py",
"math": ".md",
"legal": ".md",
"medical": ".md",
"finance": ".md",
"life": ".md",
"education": ".md",
"general": ".md",
}
def artifact_name_for(step_id: str, domain: str) -> str:
"""为 step 生成工件文件名。"""
ext = _DOMAIN_EXT.get(domain, ".md")
return f"{step_id}{ext}"
@dataclass
class StepOutcome:
"""单步执行结果。"""
step_id: str
status: str # done | issue
summary: str = ""
model_used: str = "local"
attempts: int = 0
issue_id: Optional[str] = None
artifact_name: Optional[str] = None
artifact_text: str = ""
details: List[str] = field(default_factory=list)
class WorkerLoop:
"""小模型 Worker:实现 -> 验证 -> 自修 -> issue。"""
def __init__(
self,
generate: Callable[[str], Awaitable[str]],
verifier: Optional[Verifier] = None,
kb: Any = None,
max_fix_attempts: int = 2,
model_used: str = "local-llama",
):
self.generate = generate
self.verifier = verifier or Verifier()
self.kb = kb
self.max_fix_attempts = max_fix_attempts
self.model_used = model_used
async def direct_answer(self, query: str) -> str:
"""快路径直答:让 Worker 直接生成用户回答(非 JSON、无围栏)。"""
prompt = ("请直接回答下面这个问题,输出对用户有用的正文"
"(不要输出 JSON,不要加代码块围栏)。问题:" + query)
return await self.generate(prompt)
def _domain_from(self, ws: Workspace) -> str:
tags = (ws.get("brief") or {}).get("tags") or []
for t in tags:
if t != "safety":
return t
return "general"
async def run_step(self, ws: Workspace, step_id: str,
existing_artifact: str = "", hint: str = "") -> StepOutcome:
"""执行单个 step。existing_artifact 为该步当前已有工件全文;hint 为 Architect 裁决提示。"""
domain = self._domain_from(ws)
brief = ws.get("brief") or {}
plan = brief.get("plan") or []
step_def = next((p for p in plan if p.get("id") == step_id), {})
done_criteria = step_def.get("done_criteria", "")
artifact_name = artifact_name_for(step_id, domain)
current = existing_artifact
details: List[str] = []
for attempt in range(1, self.max_fix_attempts + 1):
prompt = self._build_prompt(ws, step_id, current, attempt, done_criteria, hint)
out = await self.generate(prompt)
if domain == "code":
candidate = extract_code_block(out)
else:
candidate = out.strip()
details.append(f"attempt{attempt}: 生成 {len(candidate)} 字符")
passed, v_details = self.verifier.verify(
domain, artifact_name, candidate, ws["query"], kb=self.kb)
details.extend(f" - {d}" for d in v_details)
if passed:
# 写回交流文本:progress(done) + 摘要
ws.add_progress(step_id, "done", f"步骤完成({attempt} 次尝试)",
artifact=build_anchor(artifact_name, 1))
return StepOutcome(
step_id=step_id, status="done",
summary=f"步骤完成({attempt} 次尝试)",
model_used=self.model_used, attempts=attempt,
artifact_name=artifact_name, artifact_text=candidate,
details=details,
)
# 未通过:带错误反馈重新生成(自修)
current = candidate
feedback = "".join(v_details)
details.append(f"attempt{attempt} 未通过,进入自修")
# 全部尝试失败 -> 写 issue
anchor = build_anchor(artifact_name, 1, 30)
iid = ws.add_issue(
step=step_id,
anchor=anchor,
observed=f"验证未通过:{''.join(d for d in details if d.startswith(' - ')) or '未知'}",
expected=done_criteria or "满足该步 done_criteria",
tried=f"已自修 {self.max_fix_attempts}",
ask="请裁决该步的实现方向或提供兜底实现",
)
return StepOutcome(
step_id=step_id, status="issue", summary="未能通过验证,已上报 issue",
model_used=self.model_used, attempts=self.max_fix_attempts,
issue_id=iid, artifact_name=artifact_name, artifact_text=current,
details=details,
)
def _build_prompt(self, ws: Workspace, step_id: str, current: str,
attempt: int, done_criteria: str, hint: str = "") -> str:
base = ws.render_for_worker(step_id, artifact_text=current or None)
if attempt > 1:
base += (
"\n\n[注意] 上次生成的工件未通过接地验证。请修正以下问题后重新输出"
f"完整工件。本次为第 {attempt} 次尝试。"
)
if hint:
base += "\n\n[架构师裁决] " + hint
return base
def build_worker(cfg: Dict[str, Any], kb: Any = None,
generate: Optional[Callable[[str], Awaitable[str]]] = None) -> WorkerLoop:
"""cfg 为 config.worker 段。generate 缺省时按 backend 选择:
mock(零运行时演示)| openai/api(任意 OpenAI 兼容端点,如 Ollama/vLLM|
llama_server(内置本地 llama-server)。"""
backend = cfg.get("backend", "llama_server")
if generate is None:
if backend == "mock":
generate = _mock_generate()
elif backend in ("openai", "api"):
generate = _make_llama_generate(
cfg, default_base_url=cfg.get("base_url") or "http://127.0.0.1:11434/v1")
else:
generate = _make_llama_generate(cfg)
verifier = Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
return WorkerLoop(
generate=generate,
verifier=verifier,
kb=kb,
max_fix_attempts=int(cfg.get("max_fix_attempts", 2)),
model_used=cfg.get("backend", "llama_server"),
)
def _mock_generate() -> Callable[[str], Awaitable[str]]:
"""零运行时 mock 生成器:返回一段确定性文本(演示/测试,不连真实模型)。"""
async def _gen(prompt: str) -> str:
return ("mock worker)以下是对当前步骤的实现说明:"
"步骤已完成,内容足够长且非占位,可供接地验证通过。")
return _gen
def _make_llama_generate(cfg: Dict[str, Any],
default_base_url: Optional[str] = None) -> Callable[[str], Awaitable[str]]:
"""返回调用本地 OpenAI 兼容端点(llama-server / Ollama / vLLM)的生成器。"""
if default_base_url is None:
default_base_url = f"http://127.0.0.1:{cfg.get('port', 8901)}/v1"
base_url = cfg.get("base_url") or default_base_url
model = cfg.get("model") or "local"
temperature = float(cfg.get("temperature", 0.3))
timeout_s = float(cfg.get("per_step_timeout_s", 300))
async def _gen(prompt: str) -> str:
try:
import httpx
async with httpx.AsyncClient(timeout=timeout_s) as client:
resp = await client.post(
f"{base_url}/chat/completions",
json={"model": model, "messages": [{"role": "user", "content": prompt}],
"temperature": temperature, "max_tokens": 4096},
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
except Exception as e: # noqa: BLE001
# 连不上本地模型 -> 优雅降级(不抛 500),提示用户检查模型端点
return ("(本地降级)无法连接本地模型端点,未能生成该步骤内容。"
f"请检查模型后端配置或启动服务。错误:{type(e).__name__}")
return _gen
-543
View File
@@ -1,543 +0,0 @@
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
本模块实现(对齐《实现方案_v2》第 4 节):
- WORKSPACE_SCHEMAdraft-07 风格 schema 常量(文档/校验依据)
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
VERSION = "1.0"
# ---------------------------------------------------------------------------
# 字段长度上限(同时是 rollup 依据,4.2)
# ---------------------------------------------------------------------------
LIMITS = {
"goal": 500, # brief.goal 字数
"task": 300, # brief.plan[].task 字数
"summary": 200, # progress[].summary 字数
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
"reply": 600, # decisions[].reply 字数
"archive": 160, # archive[] 每条字数
"constraints": 8, # brief.constraints 上限条数
"plan_steps": 5, # brief.plan 上限步数
"acceptance": 20, # brief.acceptance 上限条数
"query_truncate": 200, # render_for_architect 中 query 截断
}
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
"life", "education", "general", "safety", "science"}
STATUS_FLOW = {
"draft": {"in_progress"},
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
"reviewing": {"done", "in_progress", "failed"},
"escalated": {"reviewing", "done", "failed"},
"done": set(),
"failed": set(),
}
# ---------------------------------------------------------------------------
# JSON Schemadraft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
# ---------------------------------------------------------------------------
WORKSPACE_SCHEMA: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Communication Workspace",
"type": "object",
"required": ["version", "request_id", "query", "meta"],
"additionalProperties": False,
"properties": {
"version": {"const": VERSION},
"request_id": {"type": "string", "minLength": 1},
"query": {"type": "string", "minLength": 1},
"meta": {
"type": "object",
"required": ["status", "round", "budget"],
"properties": {
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
"round": {"type": "integer", "minimum": 0},
"budget": {
"type": "object",
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
"properties": {
"api_input_tokens": {"type": "integer", "minimum": 0},
"api_output_tokens": {"type": "integer", "minimum": 0},
"api_token_cap": {"type": "integer", "minimum": 1},
"rounds_cap": {"type": "integer", "minimum": 1},
},
},
},
},
"brief": {
"type": "object",
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
"properties": {
"goal": {"type": "string"},
"constraints": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}},
"acceptance": {"type": "array", "items": {"type": "object"}},
"plan": {"type": "array", "items": {"type": "object"}},
},
},
"progress": {"type": "array", "items": {"type": "object"}},
"issues": {"type": "array", "items": {"type": "object"}},
"decisions": {"type": "array", "items": {"type": "object"}},
"archive": {"type": "array", "items": {"type": "string"}},
},
}
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
def estimate_tokens(text: str) -> int:
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
if not text:
return 0
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
en = len(text) - zh
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
"""构造锚点:a://<file>#L<start>-<end>end 缺省仅 L<start>。"""
if end is None:
return f"a://{filename}#L{start}"
return f"a://{filename}#L{start}-{end}"
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
"""解析锚点为 {file, start, end};非法返回 None。"""
m = _ANCHOR_RE.match(anchor)
if not m:
return None
start = int(m.group("start")) if m.group("start") else 1
end = int(m.group("end")) if m.group("end") else start
return {"file": m.group("file"), "start": start, "end": end}
def _clip(text: str, limit: int) -> str:
"""按字数截断(中文按字符)。"""
if len(text) <= limit:
return text
return text[:limit] + ""
# ---------------------------------------------------------------------------
# 校验
# ---------------------------------------------------------------------------
def validate(ws: Dict[str, Any]) -> List[str]:
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
errors: List[str] = []
if not isinstance(ws, dict):
return ["workspace 必须是 object"]
if ws.get("version") != VERSION:
errors.append(f"version 必须是 {VERSION}")
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
errors.append("request_id 必须是非空字符串")
if not isinstance(ws.get("query"), str) or not ws["query"]:
errors.append("query 必须是非空字符串")
meta = ws.get("meta")
if not isinstance(meta, dict):
errors.append("meta 必须是 object")
else:
if meta.get("status") not in STATUS_FLOW:
errors.append(f"meta.status 非法: {meta.get('status')}")
budget = meta.get("budget")
if not isinstance(budget, dict):
errors.append("meta.budget 必须是 object")
else:
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
errors.append(f"meta.budget.{k} 必须是非负整数")
brief = ws.get("brief")
if brief is not None:
if not isinstance(brief, dict):
errors.append("brief 必须是 object")
else:
if not isinstance(brief.get("goal"), str):
errors.append("brief.goal 必须是字符串")
elif len(brief["goal"]) > LIMITS["goal"]:
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
if not isinstance(brief.get("constraints"), list):
errors.append("brief.constraints 必须是数组")
elif len(brief["constraints"]) > LIMITS["constraints"]:
errors.append(f"brief.constraints 超过 {LIMITS['constraints']}")
if not isinstance(brief.get("tags"), list):
errors.append("brief.tags 必须是数组")
for t in brief.get("tags", []) or []:
if t not in ALLOWED_TAGS:
errors.append(f"brief.tags 含非法标签: {t}")
acc = brief.get("acceptance")
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
plan = brief.get("plan")
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
else:
ids = [p.get("id") for p in plan if isinstance(p, dict)]
if len(set(ids)) != len(ids):
errors.append("brief.plan 存在重复 step id")
for p in plan:
if not isinstance(p, dict):
errors.append("brief.plan 元素必须是 object")
continue
if not isinstance(p.get("task"), str):
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
elif len(p["task"]) > LIMITS["task"]:
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
for i, entry in enumerate(ws.get("progress", []) or []):
if not isinstance(entry, dict):
errors.append(f"progress[{i}] 必须是 object"); continue
if entry.get("status") not in ("done", "failed", "blocked"):
errors.append(f"progress[{i}].status 非法")
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
errors.append(f"progress[{i}].summary 非法或超长")
for i, entry in enumerate(ws.get("issues", []) or []):
if not isinstance(entry, dict):
errors.append(f"issues[{i}] 必须是 object"); continue
for k in ("observed", "expected", "tried", "ask"):
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
for i, entry in enumerate(ws.get("decisions", []) or []):
if not isinstance(entry, dict):
errors.append(f"decisions[{i}] 必须是 object"); continue
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
for i, line in enumerate(ws.get("archive", []) or []):
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
errors.append(f"archive[{i}] 非法或超长")
return errors
# ---------------------------------------------------------------------------
# Workspace
# ---------------------------------------------------------------------------
class Workspace:
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
def __init__(self, data: Dict[str, Any]):
errors = validate(data)
if errors:
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
self._data = data
self._brief_locked = False
# ---------- 构造 ----------
@classmethod
def new(cls, request_id: str, query: str,
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
data = {
"version": VERSION,
"request_id": request_id,
"query": query,
"meta": {
"status": "draft",
"round": 0,
"budget": {
"api_input_tokens": 0,
"api_output_tokens": 0,
"api_token_cap": api_token_cap,
"rounds_cap": rounds_cap,
},
},
"brief": None,
"progress": [],
"issues": [],
"decisions": [],
"archive": [],
}
return cls(data)
# ---------- 访问 ----------
@property
def request_id(self) -> str:
return self._data["request_id"]
@property
def status(self) -> str:
return self._data["meta"]["status"]
@property
def data(self) -> Dict[str, Any]:
return copy.deepcopy(self._data)
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._data[key]
def meta(self) -> Dict[str, Any]:
return self._data["meta"]
def budget(self) -> Dict[str, int]:
return self._data["meta"]["budget"]
# ---------- 写入(均先校验) ----------
def _commit(self, data: Dict[str, Any]) -> None:
errors = validate(data)
if errors:
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
self._data = data
def transition(self, new_status: str) -> None:
cur = self.status
if new_status == cur:
return
if new_status not in STATUS_FLOW.get(cur, set()):
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
self._data["meta"]["status"] = new_status
def apply_brief(self, brief: Dict[str, Any]) -> None:
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
if self._data.get("brief") is not None or self._brief_locked:
raise ValueError("brief 已写入,不可重复")
new = copy.deepcopy(self._data)
new["brief"] = brief
new["meta"]["status"] = "in_progress"
self._commit(new)
self._brief_locked = True
def add_progress(self, step: str, status: str, summary: str,
artifact: Optional[str] = None) -> None:
new = copy.deepcopy(self._data)
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
if artifact:
entry["artifact"] = artifact
new["progress"].append(entry)
self._commit(new)
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
tried: str, ask: str) -> str:
new = copy.deepcopy(self._data)
iid = f"i{len(new['issues']) + 1}"
entry = {
"id": iid, "step": step, "anchor": anchor,
"observed": observed, "expected": expected,
"tried": tried, "ask": ask,
}
new["issues"].append(entry)
self._commit(new)
return iid
def add_decision(self, ref: str, reply: str,
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
new = copy.deepcopy(self._data)
new["decisions"].append({
"ref": ref, "reply": reply,
"patch_plan": patch_plan or [],
})
self._commit(new)
def revise_plan(self, updates: Dict[str, str]) -> None:
"""按 decision.patch_plan 修订既有 step 的 task(不改结构/顺序)。"""
if self._data.get("brief") is None:
raise ValueError("brief 尚未写入,无法修订 plan")
new = copy.deepcopy(self._data)
for pid, task in updates.items():
for p in new["brief"]["plan"]:
if p["id"] == pid:
p["task"] = task
break
self._commit(new)
def mark_round(self) -> None:
self._data["meta"]["round"] += 1
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
b = self._data["meta"]["budget"]
b["api_input_tokens"] += int(input_tokens)
b["api_output_tokens"] += int(output_tokens)
self._commit(self._data)
def exhausted(self) -> bool:
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
b = self._data["meta"]["budget"]
used = b["api_input_tokens"] + b["api_output_tokens"]
if b["api_token_cap"] and used >= b["api_token_cap"]:
return True
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
return True
return False
# ---------- rollup4.5 ----------
def rollup(self) -> int:
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
返回本次折叠的条目数。
"""
folded = 0
new_progress: List[Dict[str, Any]] = []
for entry in self._data.get("progress", []):
if entry.get("status") == "done" and entry.get("step"):
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
folded += 1
else:
new_progress.append(entry)
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
kept_issues: List[Dict[str, Any]] = []
for iss in self._data.get("issues", []):
if iss.get("id") in resolved:
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
else:
kept_issues.append(iss)
self._data["issues"] = kept_issues
self._data["progress"] = new_progress
return folded
# ---------- 渲染 ----------
def render_for_architect(self) -> str:
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
+最近回合 progress 摘要。目标 ≤1200 token。"""
d = self._data
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']} "
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
if d.get("brief"):
b = d["brief"]
parts.append("== brief(锁定) ==")
parts.append(f"goal: {_clip(b['goal'], 120)}")
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
f"ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== 最近 3 条 decisions ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress 摘要 ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-8:]:
parts.append(line)
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions
out = "\n".join(parts)
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
d = copy.deepcopy(d)
d["archive"] = d["archive"][4:]
out = "\n".join(_rerender(self, d))
return out
def render_for_worker(self, step_id: str,
artifact_text: Optional[str] = None) -> str:
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
d = self._data
b = d.get("brief")
parts: List[str] = []
if b:
parts.append("== 任务目标 (goal) ==")
parts.append(b["goal"])
parts.append("== 约束 (constraints) ==")
parts.extend(f"- {c}" for c in b.get("constraints", []))
parts.append("== 全部步骤 (plan) ==")
for p in b.get("plan", []):
mark = " <-- 当前步" if p.get("id") == step_id else ""
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
parts.append("== 依赖步摘要 (archive) ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
if artifact_text:
parts.append(f"== 当前步已有工件({step_id} ==")
parts.append(artifact_text)
parts.append("== 验收标准 ==")
if b:
for a in b.get("acceptance", []):
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
f"(machine_checkable={a.get('machine_checkable', False)})")
parts.append("== 要求 ==")
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
return "\n".join(parts)
# ---------- 持久化 ----------
def save(self, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
@classmethod
def load(cls, path: Path) -> "Workspace":
data = json.loads(Path(path).read_text(encoding="utf-8"))
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
-6
View File
@@ -1,6 +0,0 @@
"""运维层(runtime):本地 llama.cpp 运行时与硬件档位管理。
与 router_system 核心解耦:本包允许使用 httpx/fastapi 等第三方依赖,
用于真实本地模型(llama-server)的进程生命周期管理与硬件适配。
核心协议(交流文本)仍在 router_system 内保持零第三方依赖。
"""
-140
View File
@@ -1,140 +0,0 @@
"""硬件档位检测(runtime 运维层,纯标准库)。
把真实机器映射到三档保守模板之一:
- gpu12 : 约 ≥12GB 显存(NVIDIA / Vulkan 可探测) -> ngl 99, ctx 32768
- gpu8 : 约 ≥8GB 显存 -> ngl 14, ctx 16384
- cpu : 无独显或探测失败(保守兜底) -> ngl 0, ctx 8192
探测来源:nvidia-smiNVIDIA 显存)优先;其次 vulkaninfoAMD/Intel/通用,
只能判断是否存在 Vulkan 设备,无法可靠拿到显存 -> 保守回退 cpu,并在结果标注
probe:"conservative")。总系统内存仅作为 cpu 档提示参考,不作为分档依据。
任何探测失败都回退到 cpu 保守档,保证不崩、可离线运行(D5 / D8)。
"""
from __future__ import annotations
import shutil
import subprocess
from typing import Any, Callable, Dict, List, Optional
# 三档硬件模板(保守默认,可被 config.tiers 手动覆盖)
TIER_SPECS: Dict[str, Dict[str, Any]] = {
"gpu12": {"tier": "gpu12", "ngl": 99, "ctx": 32768, "kv_quant": "q8_0"},
"gpu8": {"tier": "gpu8", "ngl": 14, "ctx": 16384, "kv_quant": "q8_0"},
"cpu": {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0"},
}
_GPU12_THRESHOLD_GB = 12.0
_GPU8_THRESHOLD_GB = 8.0
def _run(cmd: List[str], timeout: float = 10.0,
runner: Optional[Callable[[List[str], float], subprocess.CompletedProcess]] = None
) -> Optional[subprocess.CompletedProcess]:
"""执行命令并捕获输出;失败/超时返回 None(不抛异常)。"""
if runner is not None:
try:
return runner(cmd, timeout)
except Exception:
return None
try:
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except Exception:
return None
def nvidia_vram_gb(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Optional[float]:
"""通过 nvidia-smi 读取显存总量(GB);无 NVIDIA 返回 None。"""
exe = shutil.which("nvidia-smi")
if not exe:
return None
out = _run([exe, "--query-gpu=memory.total", "--format=csv,noheader,nounits"], runner=runner)
if out is None or out.returncode != 0 or not out.stdout.strip():
return None
try:
# 多卡取最大值(第一行也接受,但保守起见取最大以保证模板内存够用)
vals = [float(v.strip()) for v in out.stdout.strip().splitlines() if v.strip().isdigit()]
if not vals:
return None
return max(vals) / 1024.0
except Exception:
return None
def vulkan_present(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> bool:
"""检测是否存在 Vulkan 设备(无法可靠拿显存 -> 只用于判定非 cpu 的候选)。"""
exe = shutil.which("vulkaninfo")
if not exe:
return False
out = _run([exe, "--summary"], timeout=15.0, runner=runner)
if out is None or out.returncode != 0:
return False
low = out.stdout.lower()
# 出现 deviceName 且非 "llvmpipe"/"software" 视为有真实设备
return ("devicename" in low or "gpu" in low) and "llvmpipe" not in low and "lavapipe" not in low
def pick_tier(vram_gb: Optional[float]) -> str:
"""按显存选择档位;None/未知 -> cpu 保守档。"""
if vram_gb is None:
return "cpu"
if vram_gb >= _GPU12_THRESHOLD_GB:
return "gpu12"
if vram_gb >= _GPU8_THRESHOLD_GB:
return "gpu8"
return "cpu"
def detect(override: Optional[Dict[str, Any]] = None,
runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Dict[str, Any]:
"""检测并返回当前档位规格。
override(可选):{"tier": "gpu12"} 强制指定档位;或覆盖单个字段如 {"ctx": 16384}。
返回形如 {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0",
"probe": "nvidia|vulkan|cpu|override", "note": str}
"""
if override and override.get("tier") in TIER_SPECS:
spec = dict(TIER_SPECS[override["tier"]])
spec.update({k: v for k, v in override.items() if k in spec})
spec["probe"] = "override"
spec["note"] = f"手动指定档位 {override['tier']}"
return spec
vram = nvidia_vram_gb(runner=runner)
probe = "nvidia"
if vram is None:
if vulkan_present(runner=runner):
probe = "vulkan"
note = "检测到 Vulkan 设备但无法读取显存,按保守档 cpu 运行(可在 config 手动覆盖 tier"
else:
probe = "cpu"
note = "未检测到 GPU,按 cpu 档运行(-ngl 0,速度受限)"
else:
note = f"nvidia-smi 探测显存 {vram:.1f}GB"
tier = pick_tier(vram)
spec = dict(TIER_SPECS[tier])
spec["probe"] = probe
spec["note"] = note if probe != "nvidia" else f"{note} -> 档位 {tier}"
return spec
def tier_spec(tier: str) -> Dict[str, Any]:
"""返回指定档位的规格副本(供 config.tiers 兜底)。"""
if tier not in TIER_SPECS:
raise ValueError(f"未知硬件档位: {tier}(支持: {sorted(TIER_SPECS)}")
return dict(TIER_SPECS[tier])
def detect_summary() -> str:
"""人类可读的检测摘要(setup_runtime / serve 启动时打印)。"""
spec = detect()
return (
f"硬件档位: {spec['tier']} (ngl={spec['ngl']}, ctx={spec['ctx']}, "
f"kv_quant={spec['kv_quant']}) [{spec.get('note', '')}]"
)
-282
View File
@@ -1,282 +0,0 @@
"""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 http.client
import json
import os
import subprocess
import sys
import time
import urllib.parse
import urllib.parse
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 秒超时;网络异常视为不健康。
安全约束:llama-server 是本地进程,端点仅允许本机回环地址,
非回环配置直接判不健康(不发起请求,防 SSRF)。
"""
try:
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")
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
def health(self) -> bool:
"""探测当前是否健康(进程在且 /health 通过)。"""
if self._proc is None or self._proc.poll() is not None:
return False
return self._health_check(self.endpoint())
# ---------------------------------------------------------------
# 生命周期
# ---------------------------------------------------------------
def endpoint(self) -> str:
return f"http://127.0.0.1:{self.port}"
def _log(self, msg: str) -> None:
self.log_dir.mkdir(parents=True, exist_ok=True)
line = f"[{_now()}] {msg}"
path = self._log_path or (self.log_dir / "llama_server.log")
self._log_path = path
try:
with open(path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def start(self) -> bool:
"""启动子进程并轮询至健康就绪。
返回 True 表示健康就绪;False 表示启动失败/超时(进程可能已退出)。
"""
if self._proc is not None and self._proc.poll() is None:
return self.health()
if not self.binary.exists():
raise LlamaServerError(
f"llama-server 二进制不存在: {self.binary}。请先运行 "
f"scripts/setup_runtime.py 下载,或将上游 release 放入 bin/(D1 不改源码)。"
)
if not self.model.exists():
raise LlamaServerError(
f"模型文件不存在: {self.model}。请先运行 scripts/setup_runtime.py 下载 GGUF。"
)
cmd = self._build_command()
self.log_dir.mkdir(parents=True, exist_ok=True)
logf = self.log_dir / "llama_server.log"
self._log_path = logf
self._log(f"启动: {self.command_preview()}")
kwargs: Dict[str, Any] = {}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
try:
self._proc = subprocess.Popen(
cmd,
stdout=open(logf, "ab", buffering=0),
stderr=subprocess.STDOUT,
env=self.env,
**kwargs,
)
except OSError as e:
self._log(f"spawn 失败: {e}")
self._proc = None
raise LlamaServerError(f"无法启动 llama-server: {e}") from e
self._started_at = time.time()
return self._wait_healthy()
def _wait_healthy(self) -> bool:
deadline = time.time() + self.health_timeout_s
while time.time() < deadline:
if self._proc.poll() is not None:
self._log(f"进程过早退出 rc={self._proc.returncode}")
return False
if self.health():
self._log(f"健康就绪 @ {self.endpoint()} (pid={self._proc.pid})")
return True
time.sleep(self.poll_interval_s)
self._log("健康检查超时,标记为启动失败")
return False
def stop(self, timeout_s: float = 8.0) -> None:
"""优雅停止:terminateCTRL_BREAK-> 等待 -> kill 兜底(Windows 语义)。"""
proc = self._proc
if proc is None:
return
if proc.poll() is not None:
self._proc = None
return
try:
proc.terminate()
except OSError:
pass
try:
proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
self._log("terminate 超时,kill 兜底")
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
pass
self._proc = None
self._log("已停止")
def ensure_alive(self) -> bool:
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
if self._proc is not None and self._proc.poll() is None and self.health():
return True
if self._restart_count >= self.max_restarts:
return False
backoff = min(2.0 ** self._restart_count, 8.0)
self._restart_count += 1
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
time.sleep(backoff)
if self._proc is not None and self._proc.poll() is None:
self.stop()
return self.start()
# ---------------------------------------------------------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
@property
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc is not None else None
def __enter__(self) -> "LlamaServerManager":
self.start()
return self
def __exit__(self, *exc) -> None:
self.stop()
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
binary = cfg.get("binary", "bin/llama-server.exe")
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
port = int(cfg.get("port", 8901))
hw = cfg.get("hw", {}) or {}
extra = cfg.get("extra_args", [])
return LlamaServerManager(
binary=binary,
model=model,
port=port,
hw=hw,
extra_args=extra,
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
max_restarts=int(cfg.get("max_restarts", 2)),
log_dir=cfg.get("log_dir"),
)
-212
View File
@@ -1,212 +0,0 @@
"""E1 token 经济学实验脚本(论文主实验,本地确定性可跑)。
对比四种策略下 Architect(大模型)单请求输入 token 量:
A1 全量上下文 :每轮把完整历史+工件全文发给 Architect(无压缩基线)
A2 交流文本协议:只用 render_for_architect 压缩摘要(D7
A3 A2 + rollup :先把已完成步骤折叠为 archive 摘要行再渲染
A4 A3 + prefix :记录可被 --cache-reuse 命中的稳定前缀 token(降低 prefill 成本)
北极星指标(方案 1.0):A2/A3/A4 相对 A1 的 token 下降 ≥80%
用法:
python scripts/bench_tokens.py [--data eval/v2_sample.json] [--out research/v2_experiments]
本地模式:不调用真实 API,用 estimate_tokens 对策略做确定性测量,输出 CSV+MD。
--live 模式(可选,需 API key + 本地模型):走真实管线记录 usage。
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.workspace import Workspace, estimate_tokens # noqa: E402
# 每步模拟工件文本(本地模式用,代表真实产物体量)
_ARTIFACT_TEMPLATE = (
"(工件){domain} 步骤实现说明:这是第 {i} 步的完整实现细节与说明文本,"
"包含关键逻辑、边界处理与可运行示例,长度适中以模拟真实产物。"
)
def _brief_for(query: str, domain: str, n_steps: int = 3) -> dict:
return {
"goal": query,
"constraints": ["遵守领域规范", "输出可交付"],
"tags": [domain],
"acceptance": [{"id": "a1", "check": "满足用户需求", "machine_checkable": True}],
"plan": [
{"id": f"s{i+1}", "task": f"{domain} 步骤{i+1}:推进目标", "deps": [] if i == 0 else [f"s{i}"],
"done_criteria": "达到步骤目标"}
for i in range(n_steps)
],
}
def build_workspace(query: str, domain: str, n_steps: int = 3, n_rounds: int = 3) -> Workspace:
"""构造一个模拟进行到中后期的交流文本(含 progress/issues/decisions)。"""
ws = Workspace.new("bench" + query.encode("utf-8").hex()[:8], query,
api_token_cap=8000, rounds_cap=6)
ws.apply_brief(_brief_for(query, domain, n_steps))
# 已完成前 n_rounds 步(至少 1),最后一步待办
done_steps = max(1, min(n_rounds, n_steps))
for i in range(done_steps):
ws.add_progress(f"s{i+1}", "done",
f"步骤{i+1}完成:{_ARTIFACT_TEMPLATE.format(domain=domain, i=i+1)[:60]}",
artifact=f"a://s{i+1}.py" if domain == "code" else f"a://s{i+1}.md")
# 加入 issue + decision(模拟一轮裁决)
if done_steps < n_steps:
iid = ws.add_issue(f"s{done_steps+1}", f"a://s{done_steps+1}.py#L1",
"验证未通过", "达到目标", "已自修 2 次", "请裁决")
ws.add_decision(iid, "按此方向继续推进", [{"id": f"s{done_steps+1}", "task": "按裁决修订"}])
ws.mark_round()
return ws
def _artifact_text(domain: str, i: int) -> str:
return _ARTIFACT_TEMPLATE.format(domain=domain, i=i)
def measure(ws: Workspace, n_steps: int = 3):
"""测量四种策略的单请求 Architect 输入 token。"""
domain = (ws.get("brief") or {}).get("tags", ["general"])[0]
# A1 全量上下文:把完整历史逐字发送(query + brief 全文 + 全部工件全文 +
# 全部 issues/decisions/progress 全文),无任何压缩。
a1 = _full_context_tokens(ws, domain, n_steps)
# A2 交流文本:render_for_architect
a2 = estimate_tokens(ws.render_for_architect())
# A3 A2 + rollup
ws3 = Workspace(ws.data)
ws3.rollup()
a3 = estimate_tokens(ws3.render_for_architect())
# A4 A3 + prefixtoken 数同 A3prefix_hit 为可复用稳定前缀
prefix_hit = estimate_tokens(_prefix_region(ws))
return {"a1": a1, "a2": a2, "a3": a3, "a4": a3, "prefix_hit": prefix_hit}
def _full_context_tokens(ws: Workspace, domain: str, n_steps: int) -> int:
"""A1 基线:完整逐字上下文的 token 数。"""
d = ws.data
total = estimate_tokens(d.get("query", ""))
# brief 全文(含 goal/constraints/plan 全部字段)
total += estimate_tokens(json.dumps(d.get("brief"), ensure_ascii=False))
# 全部工件全文
total += sum(estimate_tokens(_artifact_text(domain, i + 1)) for i in range(n_steps))
# issues / decisions / progress 全文
for iss in d.get("issues", []) or []:
total += estimate_tokens(json.dumps(iss, ensure_ascii=False))
for dec in d.get("decisions", []) or []:
total += estimate_tokens(json.dumps(dec, ensure_ascii=False))
for p in d.get("progress", []) or []:
total += estimate_tokens(json.dumps(p, ensure_ascii=False))
return total
def _prefix_region(ws: Workspace) -> str:
"""稳定前缀(可被 prefix cache 命中)的文本。"""
d = ws.data
stable = {"version": d.get("version"), "request_id": d.get("request_id"),
"query": d.get("query"), "brief": d.get("brief")}
return json.dumps(stable, ensure_ascii=False)
def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
items = json.loads(Path(data_path).read_text(encoding="utf-8"))
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
rows = []
for it in items:
ws = build_workspace(it["query"], it.get("domain", "general"), n_steps)
m = measure(ws, n_steps)
rows.append({
"id": it["id"], "domain": it.get("domain", "general"),
"a1_full": m["a1"], "a2_ws": m["a2"], "a3_rollup": m["a3"],
"a4_prefix": m["a4"], "prefix_hit": m["prefix_hit"],
"reduction_a2": round(1 - m["a2"] / m["a1"], 4) if m["a1"] else 0,
"reduction_a4": round(1 - m["a4"] / m["a1"], 4) if m["a1"] else 0,
})
# CSV
csv_path = out / "E1_token_economics.csv"
with csv_path.open("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())
+7 -17
View File
@@ -1,8 +1,7 @@
"""CLI 演示:构建专家系统内核路由(默认 L0 零参数模式),跑样例查询并打印推理链 """CLI 演示:构建 mock 全链路路由系统,跑一组样例查询并打印结果
用法: 用法:
python scripts/demo.py [--query "自定义查询"] [--batch] [--trace] [--verbose] python scripts/demo.py [--query "自定义查询"] [--batch]
--trace 打印完整推理链(分类 → 拆解 DAG → 规则轨迹 → 子任务执行 → Judge)
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,10 +10,6 @@ import asyncio
import sys import sys
from pathlib import Path 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)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.router import build_router from router_system.router import build_router
@@ -31,7 +26,7 @@ SAMPLE_QUERIES = [
] ]
async def run_demo(router, queries, verbose: bool = False, trace: bool = False): async def run_demo(router, queries, verbose: bool = False):
for q in queries: for q in queries:
r = await router.route(q) r = await router.route(q)
print("=" * 72) print("=" * 72)
@@ -40,20 +35,15 @@ async def run_demo(router, queries, verbose: bool = False, trace: bool = False):
f"upgraded={r.upgraded} quality={r.quality_score:.2f} model={r.model_used} " f"upgraded={r.upgraded} quality={r.quality_score:.2f} model={r.model_used} "
f"latency={r.latency_ms:.1f}ms cache={r.cache_hit}({r.cache_level}) cost=${r.cost_est:.6f}") f"latency={r.latency_ms:.1f}ms cache={r.cache_hit}({r.cache_level}) cost=${r.cost_est:.6f}")
print(f" route: {' -> '.join(r.route)}") print(f" route: {' -> '.join(r.route)}")
if trace:
# 拆解轨迹从 route 中展开为更可读的形式
plan_steps = [s for s in r.route if s.startswith("plan:") or ":" in s]
print(" 推理链: " + " -> ".join(r.route))
if verbose: if verbose:
print(f" --- response ---\n{r.response[:600]}") print(f" --- response ---\n{r.response[:400]}")
async def main(): async def main():
parser = argparse.ArgumentParser(description="多专家路由系统(专家系统内核)CLI 演示") parser = argparse.ArgumentParser(description="多专家路由系统 CLI 演示")
parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)") parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)")
parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)") parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)")
parser.add_argument("--verbose", action="store_true", help="打印响应正文") parser.add_argument("--verbose", action="store_true", help="打印响应正文")
parser.add_argument("--trace", action="store_true", help="打印完整推理链")
args = parser.parse_args() args = parser.parse_args()
router = build_router() router = build_router()
@@ -61,9 +51,9 @@ async def main():
print() print()
if args.query: if args.query:
await run_demo(router, [args.query], verbose=True, trace=True) await run_demo(router, [args.query], verbose=True)
else: else:
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose, trace=args.trace) await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose)
print() print()
print("=" * 72) print("=" * 72)
+19 -60
View File
@@ -12,46 +12,27 @@ import sys
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
# 兼容 GBK 控制台(中文 Windows 默认编码),避免打印 ✅/⚠️ 时 UnicodeEncodeError
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)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from router_system.router import build_router from router_system.router import build_router
# (query, 期望领域, 期望大领域组, 期望子领域) # (query, 期望领域)
# 覆盖 8 领域 × 3 条 = 24 条 + 两级路由/三级子领域指标
BENCH = [ BENCH = [
# ---- techcode / math ---- ("用 Python 实现二分查找", "code"),
("用 Python 实现二分查找", "code", "tech", "algorithm"), ("这段 JavaScript 为什么报错:undefined is not a function", "code"),
("这段 JavaScript 为什么报错:undefined is not a function", "code", "tech", "debugging"), ("帮我优化这个 SQL 查询的索引", "code"),
("帮我优化这个 SQL 查询的索引", "code", "tech", "database"), ("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math"),
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math", "tech", "algebra"), ("证明勾股定理", "math"),
("证明勾股定理", "math", "tech", "geometry"), ("计算 3x + 5 = 20x 等于多少", "math"),
("计算 3x + 5 = 20x 等于多少", "math", "tech", "algebra"), ("劳动合同到期不续签,公司需要支付经济补偿吗", "legal"),
# ---- professionallegal / medical / finance ---- ("在合同中约定违约金上限 30%,是否合规", "legal"),
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal", "professional", "labor"), ("专利申请的流程和费用大概是多少", "legal"),
("在合同中约定违约金上限 30%,是否合规", "legal", "professional", "contract"), ("高血压患者可以吃哪些降压药,副作用是什么", "medical"),
("加班费怎么计算", "legal", "professional", "labor"), ("感冒发烧 38.5 度,需要吃退烧药吗", "medical"),
("高血压患者可以吃哪些降压药,副作用是什么", "medical", "professional", "medication"), ("糖尿病患者的日常饮食建议", "medical"),
("感冒发烧 38.5 度,需要吃退烧药吗", "medical", "professional", "medication"), ("介绍一下 Transformer 架构", "general"),
("烫伤后怎么处理", "medical", "professional", "firstaid"), ("写一封请假邮件", "general"),
("基金定投的收益率怎么计算", "finance", "professional", "investing"), ("为什么天空是蓝色的", "general"),
("信用卡逾期了怎么办", "finance", "professional", "credit"),
("房贷利率是 LPR 加多少", "finance", "professional", "loan"),
# ---- lifestylelife / education ----
("日本旅行攻略", "life", "lifestyle", "travel"),
("健身增肌计划怎么安排", "life", "lifestyle", "fitness"),
("家常菜谱推荐", "life", "lifestyle", "food"),
("考研英语怎么备考", "education", "lifestyle", "exam"),
("高效学习方法", "education", "lifestyle", "study"),
("面试技巧有哪些", "education", "lifestyle", "career"),
# ---- general ----
("介绍一下 Transformer 架构", "general", "general", "explain"),
("写一封请假邮件", "general", "general", "writing"),
("为什么天空是蓝色的", "general", "general", "explain"),
] ]
@@ -59,54 +40,32 @@ async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)") parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)")
parser.add_argument("--config", type=str, default=None) parser.add_argument("--config", type=str, default=None)
parser.add_argument("--group", type=str, default=None,
help="指定大领域组测试两级路由(如 tech);默认自动检测")
args = parser.parse_args() args = parser.parse_args()
router = build_router(args.config) router = build_router(args.config)
# 指定组时只评测组内样例(组路由只认识本组领域)
bench = BENCH
if args.group is not None:
bench = [b for b in BENCH if b[2] == args.group]
if not bench:
print(f"{args.group} 无评测样例,可用组: {sorted({b[2] for b in BENCH})}")
return
correct = Counter() correct = Counter()
group_correct = 0
subdomain_correct = 0
total = 0 total = 0
upgraded = 0 upgraded = 0
cache_hits = 0 cache_hits = 0
decomposed = 0 # 被 Planner 拆解为多子任务的请求数
for round_i in range(args.repeat): for round_i in range(args.repeat):
for q, expected, expected_group, expected_sub in bench: for q, expected in BENCH:
r = await router.route(q, domain_group=args.group) r = await router.route(q)
total += 1 total += 1
if r.domain == expected: if r.domain == expected:
correct["total"] += 1 correct["total"] += 1
else: else:
correct[f"misclass->{r.domain}"] += 1 correct[f"misclass->{r.domain}"] += 1
if args.group is None and r.domain_group == expected_group:
group_correct += 1
if r.subdomain == expected_sub:
subdomain_correct += 1
if r.upgraded: if r.upgraded:
upgraded += 1 upgraded += 1
if r.cache_hit: if r.cache_hit:
cache_hits += 1 cache_hits += 1
if any("plan:multi" in s for s in r.route):
decomposed += 1
acc = correct["total"] / total acc = correct["total"] / total
print(f"样例数: {len(bench)} x {args.repeat} 轮 = {total} 次请求") print(f"样例数: {len(BENCH)} x {args.repeat} 轮 = {total} 次请求")
print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})") print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})")
if args.group is None:
print(f"大领域组识别准确率: {group_correct/total:.1%} ({group_correct}/{total})")
print(f"子领域识别准确率: {subdomain_correct/total:.1%} ({subdomain_correct}/{total})")
print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})") print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})")
print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})") print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})")
print(f"任务拆解率: {decomposed/total:.1%} ({decomposed}/{total})")
print() print()
print("运行指标:", router.stats.summary()) print("运行指标:", router.stats.summary())
print("缓存统计:", router.cache.stats()) print("缓存统计:", router.cache.stats())
+6 -11
View File
@@ -1,4 +1,4 @@
"""启动路由网关服务(后台、无窗口)。 """启动路由网关服务(后台、无窗口)。
用法: 用法:
python scripts/serve.py [--port 8000] [--stop] python scripts/serve.py [--port 8000] [--stop]
@@ -41,16 +41,11 @@ def stop():
return return
pid = int(PID_FILE.read_text().strip()) pid = int(PID_FILE.read_text().strip())
try: try:
# Windows 下 SIGTERM 对 detached 进程不可靠,改用 taskkill 强制结束进程树 import signal
subprocess.run( os.kill(pid, signal.SIGTERM)
["taskkill", "/PID", str(pid), "/T", "/F"], print(f"已发送终止信号 pid={pid}")
capture_output=True, text=True, timeout=15, except ProcessLookupError:
) print(f"进程 {pid} 不存在,清理 pid 文件。")
print(f"已终止服务 pid={pid}")
except subprocess.TimeoutExpired:
print(f"终止超时 pid={pid},请手动结束进程。")
except Exception as e:
print(f"终止失败(进程可能不存在): {e}")
PID_FILE.unlink(missing_ok=True) PID_FILE.unlink(missing_ok=True)
-180
View File
@@ -1,180 +0,0 @@
"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。
用法:
python scripts/setup_runtime.py [--config config/config.yaml]
行为(对齐《实现方案_v2》6.4 / T11):
- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。
- GGUF:优先 hf-mirror.comenv HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
下载函数可注入(tests 用假 urllib),保证封闭单测。
"""
from __future__ import annotations
import argparse
import os
import sys
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, Callable, Optional, Tuple
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from runtime.hw_profile import detect_summary # noqa: E402
# 默认资源(可用 env 覆盖)
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
"LLAMA_ZIP_URL",
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
)
DEFAULT_GGUF_URL = os.environ.get(
"GGUF_URL",
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
)
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
URLS = {
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
"gguf": (DEFAULT_GGUF_URL, 0),
}
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
"""解析 HTTP Content-Length 头。"""
if not content_length:
return None
try:
return int(content_length.strip())
except (ValueError, TypeError):
return None
def validate_size(path: Path, expected: Optional[int],
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
actual = path.stat().st_size if path.exists() else 0
if not expected:
return actual > 0, actual
return abs(actual - expected) <= tolerance, actual
class Downloader:
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
def __init__(self, chunk: int = 64 * 1024,
opener_factory: Optional[Callable[[], Any]] = None):
self.chunk = chunk
self._opener_factory = opener_factory
def _opener(self):
if self._opener_factory is not None:
return self._opener_factory()
return urllib.request.build_opener()
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
"""下载(断点续传)。返回 (bytes_written, error)。"""
dest.parent.mkdir(parents=True, exist_ok=True)
existing = dest.stat().st_size if dest.exists() else 0
headers = {"User-Agent": "v2-setup-runtime/1.0"}
if existing > 0:
headers["Range"] = f"bytes={existing}-"
opener = self._opener()
try:
req = urllib.request.Request(url, headers=headers)
with opener.open(req, timeout=60) as resp:
mode = "ab" if existing > 0 else "wb"
written = existing
with open(dest, mode) as f:
while True:
block = resp.read(self.chunk)
if not block:
break
f.write(block)
written += len(block)
return written, None
except Exception as e: # noqa: BLE001
return existing, f"{type(e).__name__}: {e}"
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
try:
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
target = None
for n in zf.namelist():
if n.lower().endswith("llama-server.exe"):
target = n
break
if target is None:
return "zip 中未找到 llama-server.exe"
# zip-slip 防护:拒绝绝对路径或含 .. 的成员名
if target.startswith(("/", "\\")) or ".." in Path(target).parts:
return "zip 内成员路径非法(疑似路径穿越)"
dest = bin_dir / "llama-server.exe"
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}"
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))
-8
View File
@@ -1,8 +0,0 @@
"""测试辅助:获取空闲 TCP 端口。"""
import socket
def free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
+1 -7
View File
@@ -10,11 +10,5 @@ from router_system.router import build_router
@pytest.fixture() @pytest.fixture()
def router(): def router():
"""共享的 mock 全链路路由实例(零依赖、离线可跑)""" """????????? mock ?????????????"""
return build_router() return build_router()
@pytest.fixture()
def kb():
"""v2 验证接地用的知识库实例(facts 对照)。"""
from router_system.knowledge import KnowledgeBase
return KnowledgeBase()
-57
View File
@@ -1,57 +0,0 @@
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
- 把 pid / 收到的参数写入 FAKE_MARKER_NAME 指定文件名的 JSON(固定在系统临时目录)
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
- 进程被终止时正常退出
"""
import argparse
import http.server
import json
import os
import sys
import tempfile
from pathlib import Path
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_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):
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())
-674
View File
@@ -1,674 +0,0 @@
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
import json
import os
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": os.environ.get("TEST_POOL_KEY", "local-test-only"), "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()
-209
View File
@@ -1,209 +0,0 @@
"""dsh 功能对齐测试(T31):
- 流式 _stream_partial 复位(首次成功后再次流式失败应回退非流式,而非误抛)
- LLM 调用重试退避(5xx/传输错误重试,非可重试错误不重试)
- 会话重命名(PATCH /agent/sessions/{sid}
- 重复工具调用提醒(同工具同参数第 3 次起回喂警语)
- search_files 目录修剪(node_modules 等不进入)
- 原子写入(write_file 落盘内容完整、无 .tmp 残留)
- 慢工具线程卸载(run_tool_async 快内联/慢走线程,异常回传)
"""
import asyncio
import json
import httpx
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
import gateway.agent as ag
from gateway.agent import OpenAICompatChat
@pytest.fixture()
def ws(tmp_path):
from router_system.tools import WorkspaceTools
return WorkspaceTools(tmp_path / "ws")
# ---------------- _stream_partial 复位 ----------------
def _ok_sse_body():
chunks = [{"choices": [{"delta": {"content": "第一段答复"}}]},
{"choices": [{"delta": {}}], "usage": {"prompt_tokens": 1,
"completion_tokens": 1}}]
lines = [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks]
lines.append("data: [DONE]")
return ("\n\n".join(lines) + "\n\n").encode("utf-8")
def _nonstream_body(text: str) -> bytes:
return json.dumps({
"choices": [{"message": {"content": text}}],
"usage": {"prompt_tokens": 2, "completion_tokens": 2},
}).encode("utf-8")
def test_stream_partial_flag_resets_between_calls():
"""首次流式成功(置位)后,第二次流式失败应正常回退非流式。"""
state = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
state["n"] += 1
if state["n"] == 1:
return httpx.Response(200, content=_ok_sse_body())
# 第二次:流式 500(无部分输出)-> 应回退非流式(第 3 次请求)
if state["n"] == 2:
return httpx.Response(500, content=b"boom")
return httpx.Response(200, content=_nonstream_body("回退答案"))
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
transport=httpx.MockTransport(handler),
retry_delay_s=0)
r1 = asyncio.run(chat([{"role": "user", "content": "a"}], []))
assert r1["content"] == "第一段答复"
r2 = asyncio.run(chat([{"role": "user", "content": "b"}], []))
assert r2["content"] == "回退答案" # 不因上次置位而误抛
assert state["n"] == 3
# ---------------- 重试退避 ----------------
def test_retry_on_5xx_then_success():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(502, content=b"bad gateway")
return httpx.Response(200, content=_nonstream_body("恢复"))
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=2, retry_delay_s=0,
transport=httpx.MockTransport(handler))
r = asyncio.run(chat([{"role": "user", "content": "x"}], []))
assert r["content"] == "恢复" and calls["n"] == 2
def test_retry_exhausted_raises():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, content=b"always down")
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=1, retry_delay_s=0,
transport=httpx.MockTransport(handler))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(chat([{"role": "user", "content": "x"}], []))
def test_no_retry_on_4xx_client_error():
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(401, content=b"unauthorized")
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
stream=False, max_retries=2, retry_delay_s=0,
transport=httpx.MockTransport(handler))
with pytest.raises(httpx.HTTPStatusError):
asyncio.run(chat([{"role": "user", "content": "x"}], []))
assert calls["n"] == 1 # 4xx 不重试
# ---------------- 会话重命名 ----------------
def test_session_rename_endpoint(tmp_path):
import gateway.api as ga
ag.reset_session_store()
ag._session_store = ag.SessionStore(root=tmp_path / "sess")
client = TestClient(ga.app)
r = client.post("/agent/sessions", json={"title": "旧名", "workspace": ""})
sid = r.json()["id"]
r2 = client.patch(f"/agent/sessions/{sid}", json={"title": "新名字"})
assert r2.status_code == 200
assert r2.json()["title"] == "新名字"
assert client.get(f"/agent/sessions/{sid}").json()["title"] == "新名字"
# 空标题 400;不存在 404
assert client.patch(f"/agent/sessions/{sid}", json={"title": " "}).status_code == 400
assert client.patch("/agent/sessions/asdeadbeef99",
json={"title": "x"}).status_code == 404
ag.reset_session_store()
# ---------------- 重复调用提醒 ----------------
def test_repeat_call_warning(ws, tmp_path):
"""同工具同参数第 3 次调用:回喂内容带系统提示 + repeat_warning 事件。"""
from router_system.tools import ToolLoop
seen_msgs = []
calls = {"n": 0}
async def chat(messages, tools_spec):
seen_msgs.append(list(messages))
calls["n"] += 1
if calls["n"] <= 3:
return {"content": None,
"tool_calls": [{"id": "c" + str(calls["n"]), "name": "read_file",
"arguments": {"path": "a.txt"}}],
"usage": {}}
return {"content": "收手了", "tool_calls": [], "usage": {}}
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
(tmp_path / "ws" / "a.txt").parent.mkdir(parents=True, exist_ok=True)
(tmp_path / "ws" / "a.txt").write_text("x", encoding="utf-8")
result = asyncio.run(loop.run("反复读"))
assert result["response"] == "收手了"
# 第 3 次工具结果消息应带提醒
tool_msgs = [m for m in seen_msgs[3] if m.get("role") == "tool"]
assert any("系统提示" in m["content"] for m in tool_msgs)
assert any(e["type"] == "repeat_warning" and e["count"] == 3 for e in events)
# ---------------- search_files 修剪 ----------------
def test_search_files_prunes_skip_dirs(ws, tmp_path):
root = tmp_path / "ws"
(root / "node_modules" / "pkg").mkdir(parents=True, exist_ok=True)
(root / "node_modules" / "pkg" / "dep.js").write_text("NEEDLE", encoding="utf-8")
(root / "src").mkdir(parents=True, exist_ok=True)
(root / "src" / "app.js").write_text("NEEDLE", encoding="utf-8")
r = ws.search_files("NEEDLE")
files = {m["file"] for m in r["matches"]}
assert files == {"src/app.js"} # node_modules 被修剪
# ---------------- 原子写入 ----------------
def test_atomic_write_roundtrip(ws, tmp_path):
ws.write_file("sub/atomic.txt", "第一版")
ws.edit_file("sub/atomic.txt", "第一版", "第二版")
assert (tmp_path / "ws" / "sub" / "atomic.txt").read_text(
encoding="utf-8") == "第二版"
# 无 .tmp 残留
leftovers = [p.name for p in (tmp_path / "ws" / "sub").iterdir()
if p.name.endswith(".tmp")]
assert leftovers == []
# ---------------- 慢工具线程卸载 ----------------
def _boom(*_args):
raise RuntimeError("线程内炸了")
def test_run_tool_async_fast_inline_and_thread_exception(ws):
from router_system.tools import run_tool_async
# 快工具:内联
ws.write_file("fast.txt", "v")
r = asyncio.run(run_tool_async(ws, "read_file", {"path": "fast.txt"}))
assert r["ok"] is True and r["content"] == "v"
# 异常从线程回传(execute 以属性形式提供)
class Boom:
execute = staticmethod(_boom)
with pytest.raises(RuntimeError):
asyncio.run(run_tool_async(Boom(), "read_file", {"path": "x"}))
-171
View File
@@ -1,171 +0,0 @@
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
import json
import os
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=None, **kw):
transport = httpx.MockTransport(handler)
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key=api_key or os.environ.get("TEST_ARCHITECT_KEY", "local-test-only"),
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)
-37
View File
@@ -1,37 +0,0 @@
"""T12 bench_tokens 实验脚本单测(封闭,本地确定性)。"""
import json
from scripts import bench_tokens as bt
def test_build_workspace_has_brief_and_progress():
ws = bt.build_workspace("写个快排", "code", n_steps=3, n_rounds=2)
b = ws.get("brief")
assert b["goal"] == "写个快排"
assert len(b["plan"]) == 3
done = [p for p in ws.get("progress", []) if p.get("status") == "done"]
assert len(done) >= 1
def test_measure_returns_all_keys():
ws = bt.build_workspace("解释注意力机制", "general", n_steps=3)
m = bt.measure(ws, n_steps=3)
for k in ("a1", "a2", "a3", "a4", "prefix_hit"):
assert k in m
assert m["a1"] > 0 and m["a2"] > 0
def test_a1_baseline_larger_than_a2_ws():
ws = bt.build_workspace("写个二分查找", "code", n_steps=3, n_rounds=2)
m = bt.measure(ws, n_steps=3)
assert m["a1"] > m["a2"]
def test_run_writes_files(tmp_path):
data = tmp_path / "d.json"
data.write_text(json.dumps([{"id": "x1", "query": "写个快排", "domain": "code"}]),
encoding="utf-8")
out = tmp_path / "exp"
bt.run(str(data), str(out), n_steps=2)
assert (out / "E1_token_economics.csv").exists()
assert (out / "E1_token_economics.md").exists()
+4 -4
View File
@@ -49,9 +49,9 @@ def test_exact_hit():
def test_semantic_hit(): def test_semantic_hit():
c = RouterCache(semantic_enabled=True, similarity_threshold=0.5) c = RouterCache(semantic_enabled=True, similarity_threshold=0.5)
c.put("python写一个快速排序", {"response": "code", "domain": "code"}) c.put("?python?????", {"response": "code", "domain": "code"})
# 相似改写查询命中 L2 语义缓存 # ?????????? L2
hit = c.get("python写一个快速排序算法") hit = c.get("?python????????")
assert hit is not None assert hit is not None
assert hit[0] == "semantic" assert hit[0] == "semantic"
@@ -60,7 +60,7 @@ def test_promote_to_exact():
c = RouterCache(promote_frequency=3) c = RouterCache(promote_frequency=3)
result = {"response": "x", "domain": "general"} result = {"response": "x", "domain": "general"}
c.put("query", result) c.put("query", result)
# 语义命中 3 次后提升为精确缓存 # ?????? 3 ? ? ???????
for _ in range(3): for _ in range(3):
hit = c.get("query") hit = c.get("query")
assert hit is not None assert hit is not None
+2 -2
View File
@@ -49,8 +49,8 @@ def test_medical_classification():
def test_general_low_confidence(): def test_general_low_confidence():
clf = RuleClassifier() clf = RuleClassifier()
r = clf.classify("你好呀") r = clf.classify("今天天气怎么样")
# 未命中任何领域 -> general低置信度,触发 should_fallback # 未命中任何领域 -> 低置信度,触发 should_fallback
assert r.domain == "general" assert r.domain == "general"
assert clf.should_fallback(r, 0.6) is True assert clf.should_fallback(r, 0.6) is True
+3 -94
View File
@@ -1,4 +1,4 @@
"""FastAPI 网关测试:v1 legacy 端点保持 + v2 端点(封闭,注入 mock 管线)。""" """???????? fastapi + httpx??"""
import pytest import pytest
pytest.importorskip("fastapi") pytest.importorskip("fastapi")
@@ -6,7 +6,6 @@ pytest.importorskip("httpx")
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import gateway.api as ga
from gateway.api import app from gateway.api import app
@@ -15,14 +14,6 @@ def client():
return TestClient(app) return TestClient(app)
@pytest.fixture()
def v2_client(client):
# 用 mock worker 构建真实 v2 管线并注入(无需 API key / 真实模型)
pipe = ga.build_v2_pipeline(worker_cfg_override={"backend": "mock"})
ga.set_pipeline(pipe)
return client
def test_health(client): def test_health(client):
resp = client.get("/health") resp = client.get("/health")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -31,8 +22,8 @@ def test_health(client):
assert "code" in data["domains"] assert "code" in data["domains"]
def test_chat_legacy(client): def test_chat(client):
resp = client.post("/chat/legacy", json={"query": " Python 写一个快速排序函数"}) resp = client.post("/chat", json={"query": "? Python ???????"})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["response"] assert data["response"]
@@ -40,33 +31,6 @@ def test_chat_legacy(client):
assert "route" in data assert "route" in data
def test_chat_v2(v2_client):
# POST /chat 立即返回 request_id(异步协议)
resp = v2_client.post("/chat", json={"query": "请介绍快速排序算法"})
assert resp.status_code == 200
data = resp.json()
assert "request_id" in data
assert data["status"] == "pending"
# 轮询 /runs/{id}/status 直到完成
import time
for _ in range(50): # 最多 5s
time.sleep(0.1)
status_resp = v2_client.get(f"/runs/{data['request_id']}/status")
assert status_resp.status_code == 200
s = status_resp.json()
if s["status"] in ("done", "failed"):
break
assert s["status"] == "done", f"期望 done,实际 {s['status']}error={s.get('error')}"
assert s["response"]
assert "pipeline_status" in s
assert s["pipeline_status"] in ("fast_path", "done", "escalated")
# fast_path 不写 workspace.json,所以 workspace_path 可能为 None
if s["pipeline_status"] != "fast_path":
assert s["workspace_path"] is not None
def test_chat_empty_query(client): def test_chat_empty_query(client):
resp = client.post("/chat", json={"query": ""}) resp = client.post("/chat", json={"query": ""})
assert resp.status_code == 422 assert resp.status_code == 422
@@ -78,58 +42,3 @@ def test_metrics(client):
data = resp.json() data = resp.json()
assert "router" in data assert "router" in data
assert "cache" in data assert "cache" in data
assert "v2" in data
assert "review" in data
def test_config_get_put_reset(client):
"""/config 端到端。settings.json 是活文件(用户真实配置),
测试前后必须备份/恢复,禁止把用户配置清掉。"""
import json
store = ga.settings_store()
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
try:
# GET 默认
r = client.get("/config")
assert r.status_code == 200
assert r.json()["worker"]["backend"] in ("llama_server", "openai", "mock")
# PUT 更新 worker
r2 = client.put("/config", json={"worker": {"backend": "mock", "temperature": 0.5}})
assert r2.status_code == 200
assert r2.json()["worker"]["temperature"] == 0.5
# 重置
r3 = client.post("/config/reset")
assert r3.status_code == 200
assert r3.json()["worker"]["backend"] == "llama_server"
finally:
store._data = snapshot
store.save()
ga.rebuild_pipeline()
def test_workspace_not_found(client):
resp = client.get("/runs/nonexistent/workspace")
assert resp.status_code == 404
def test_web_ui_served(client):
resp = client.get("/")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
# Vue SPA:由 Vite 生成,特征是 <div id="app"> 和 /static/assets/ 引用
assert '<div id="app">' in resp.text
assert '/static/assets/' in resp.text
def test_review_flow(client):
q = ga.get_review()
rid = q.enqueue("req-x", "q", "ans", tags=["safety"], reason="test")
assert q.count() >= 1
resp = client.get("/review/queue")
assert resp.status_code == 200
resp2 = client.post(f"/review/{rid}", params={"verdict": "approve"})
assert resp2.status_code == 200
assert resp2.json()["ok"] is True
-114
View File
@@ -1,114 +0,0 @@
"""T2 硬件档位检测单测(封闭,纯逻辑 + 注入 runner)。"""
import subprocess
import pytest
from runtime.hw_profile import (
TIER_SPECS,
detect,
nvidia_vram_gb,
pick_tier,
tier_spec,
vulkan_present,
)
class _FakeRun:
"""注入 subprocess runner:按命令返回预置输出。"""
def __init__(self, mapping):
self.mapping = mapping # {关键子串: CompletedProcess}
def __call__(self, cmd, timeout):
joined = " ".join(cmd)
for key, cp in self.mapping.items():
if key in joined:
return cp
raise FileNotFoundError(cmd)
def _cp(stdout="", rc=0):
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr="")
def test_pick_tier_thresholds():
assert pick_tier(None) == "cpu"
assert pick_tier(6.0) == "cpu"
assert pick_tier(8.0) == "gpu8"
assert pick_tier(11.9) == "gpu8"
assert pick_tier(12.0) == "gpu12"
assert pick_tier(24.0) == "gpu12"
def test_tier_specs_have_required_fields():
for tier, spec in TIER_SPECS.items():
assert spec["tier"] == tier
assert "ngl" in spec and "ctx" in spec and "kv_quant" in spec
# gpu12 ngl 全量卸载,cpu ngl 0
assert TIER_SPECS["gpu12"]["ngl"] == 99
assert TIER_SPECS["cpu"]["ngl"] == 0
def test_tier_spec_unknown_raises():
with pytest.raises(ValueError):
tier_spec("nonexistent")
def test_nvidia_vram_gb_parses():
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
assert nvidia_vram_gb(runner=fake) == 24.0
def test_nvidia_vram_gb_multi_gpu_takes_max():
fake = _FakeRun({"nvidia-smi": _cp("8192\n12288\n")})
assert nvidia_vram_gb(runner=fake) == 12.0
def test_nvidia_vram_gb_missing_tool_returns_none(monkeypatch):
import shutil
monkeypatch.setattr(shutil, "which", lambda name: None)
assert nvidia_vram_gb() is None
def test_vulkan_present_true():
fake = _FakeRun({"vulkaninfo": _cp("deviceName : NVIDIA GeForce RTX 4090")})
assert vulkan_present(runner=fake) is True
def test_vulkan_present_software_false():
fake = _FakeRun({"vulkaninfo": _cp("deviceName : llvmpipe (LLVM)")})
assert vulkan_present(runner=fake) is False
def test_detect_nvidia_gpu12(monkeypatch):
# 让 shutil.which 只对 nvidia-smi 生效
real_which = __import__("shutil").which
def fake_which(name):
return "C:/x/nvidia-smi.exe" if name == "nvidia-smi" else None
monkeypatch.setattr(__import__("shutil"), "which", fake_which)
fake = _FakeRun({"nvidia-smi": _cp("24576\n")})
spec = detect(runner=fake)
assert spec["tier"] == "gpu12"
assert spec["ngl"] == 99 and spec["ctx"] == 32768
assert spec["probe"] == "nvidia"
def test_detect_cpu_fallback(monkeypatch):
monkeypatch.setattr(__import__("shutil"), "which", lambda name: None)
spec = detect()
assert spec["tier"] == "cpu"
assert spec["ngl"] == 0
assert spec["probe"] == "cpu"
def test_detect_override_tier():
spec = detect(override={"tier": "gpu8"})
assert spec["tier"] == "gpu8"
assert spec["ngl"] == 14 and spec["ctx"] == 16384
assert spec["probe"] == "override"
def test_detect_override_field():
spec = detect(override={"tier": "cpu", "ctx": 16384})
assert spec["ctx"] == 16384
assert spec["tier"] == "cpu"
-154
View File
@@ -1,154 +0,0 @@
"""T2 llama-server 进程管理单测(封闭:假二进制 + 注入,D11)。"""
import json
import os
import subprocess
import sys
import tempfile
import uuid
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 固定写入系统临时目录;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_NAME"] = marker.name
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
-63
View File
@@ -1,63 +0,0 @@
"""TaskGraph(黑板/工作记忆)单元测试——拓扑排序契约。
契约(与 2026-09 优化前行为一致,复杂度 O(V²logV) -> O(V+E)):
- 依赖在前;初始就绪层按插入序稳定输出
- 未知依赖 id 忽略;重复依赖不重复产出
- 循环依赖:剩余节点按插入序兜底追加(不崩溃)
"""
from router_system.memory import TaskGraph, TaskNode
def _node(nid: str, deps=()) -> TaskNode:
return TaskNode(id=nid, kind="solve", domain="general", query="q", deps=list(deps))
def test_topo_chain_order():
g = TaskGraph()
g.add_node(_node("a"))
g.add_node(_node("b", ["a"]))
g.add_node(_node("c", ["b"]))
assert [n.id for n in g.topo_order()] == ["a", "b", "c"]
def test_topo_diamond_initial_ready_by_insertion():
"""菱形依赖:初始就绪层按插入序。"""
g = TaskGraph()
g.add_node(_node("s"))
g.add_node(_node("y", ["s"])) # 先插入 y
g.add_node(_node("x", ["s"]))
g.add_node(_node("t", ["x", "y"]))
order = [n.id for n in g.topo_order()]
assert order == ["s", "y", "x", "t"]
def test_topo_independent_nodes_keep_insertion_order():
g = TaskGraph()
g.add_node(_node("n3"))
g.add_node(_node("n1"))
g.add_node(_node("n2"))
assert [n.id for n in g.topo_order()] == ["n3", "n1", "n2"]
def test_topo_unknown_dep_ignored():
g = TaskGraph()
g.add_node(_node("a", ["不存在的依赖"]))
assert [n.id for n in g.topo_order()] == ["a"]
def test_topo_cycle_fallback_by_insertion():
g = TaskGraph()
g.add_node(_node("p", ["q"]))
g.add_node(_node("q", ["p"]))
g.add_node(_node("r"))
order = [n.id for n in g.topo_order()]
# r 无依赖先行;p/q 成环按插入序兜底
assert order == ["r", "p", "q"]
def test_topo_duplicate_deps_counted_once_in_output():
"""重复依赖边不产生重复输出节点。"""
g = TaskGraph()
g.add_node(_node("a"))
g.add_node(_node("b", ["a", "a"]))
assert [n.id for n in g.topo_order()] == ["a", "b"]
-173
View File
@@ -1,173 +0,0 @@
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
import os
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": os.environ.get("TEST_POOL_KEY", "local-test-only"), "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 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
def test_pool_upsert_keeps_key_when_blank(pool):
pool.upsert(_entry())
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
assert pool.get("prem-1")["api_key"] == _entry()["api_key"]
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"] == _entry()["api_key"]
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)
-161
View File
@@ -1,161 +0,0 @@
"""T6 CollaborativePipeline 编排单测(封闭:MockTransport + 假 generate)。"""
import json
import httpx
import pytest
from router_system.architect import ArchitectClient
from router_system.pipeline import CollaborativePipeline
from router_system.verifier import Verifier
from router_system.worker import WorkerLoop
BRIEF_JSON = json.dumps({
"goal": "用 Python 实现一个函数",
"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": "s1", "task": "简化实现"}]},
ensure_ascii=False)
REVIEW_DONE = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
def _architect_handler(brief=BRIEF_JSON, decide=DECIDE_JSON, review=REVIEW_DONE):
def handler(request):
body = json.loads(request.content)
msg = (body.get("messages") or [{}])[-1].get("content", "")
if "任务 brief" in msg or "任务分析" in msg:
return _resp(brief)
if "裁决" in msg:
return _resp(decide)
if "终审" in msg:
return _resp(review)
# 兜底(breach:architect_do
return _resp("(兜底答案)")
return handler
def _resp(content):
return httpx.Response(200, json={
"choices": [{"message": {"content": content}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})
def _make_architect(handler):
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key="sk-test", transport=httpx.MockTransport(handler))
def _fake_generate(texts):
it = iter(texts)
async def _g(prompt):
try:
return next(it)
except StopIteration:
return ""
return _g
def _make_worker(generate):
return WorkerLoop(generate=generate,
verifier=Verifier(sandbox_runner=lambda c, e, t: (0, "", "")),
kb=None, max_fix_attempts=2)
def _pipeline(architect, worker, **kw):
return CollaborativePipeline(architect=architect, worker=worker,
fast_path=kw.get("fast_path", False),
rounds_cap=kw.get("rounds_cap", 6),
api_token_cap=kw.get("api_token_cap", 8000),
breach_policy=kw.get("breach_policy", "architect_do"),
run_dir=kw.get("run_dir", "runs"))
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
# ---------- 快路径 ----------
def test_fast_path_returns_direct(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["这是一段足够长的、非占位的直接回答内容,用于快路径。"]))
pipe = _pipeline(architect, worker, fast_path=True, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("讲一下排序"))
assert res.status == "fast_path"
assert res.fast_path is True
assert res.api_input_tokens == 0 # 未调用 Architect
assert "快路径" in res.response or "直接" in res.response
# ---------- 协作:一次通过 ----------
def test_collab_one_pass(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["def my_func():\n return 42"]))
pipe = _pipeline(architect, worker, fast_path=False, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "done"
assert res.fast_path is False
assert res.api_input_tokens >= 100 # brief + 终审 用量已计量
assert res.workspace_path is not None
# 交流文本已落盘
import os
assert os.path.exists(res.workspace_path)
assert "done" in res.route
# ---------- 协作:带 issue 修复 ----------
def test_collab_issue_fix(tmp_path):
architect = _make_architect(_architect_handler())
# 前两次失败(占位)-> issuedecide 后重跑成功
worker = _make_worker(_fake_generate([
"待实现", "待实现", "def ok():\n return 1",
]))
pipe = _pipeline(architect, worker, fast_path=False, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "done"
assert any("issue" in r for r in res.route)
assert any("decide" in r for r in res.route)
# 至少有 1 条 decision
import json as _json
ws = json.load(open(res.workspace_path, encoding="utf-8"))
assert len(ws["decisions"]) >= 1
# ---------- 熔断兜底:本地降级 ----------
def test_breach_local_degrade(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["不通过"]))
pipe = _pipeline(architect, worker, fast_path=False, api_token_cap=1,
breach_policy="local_only", run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "failed"
assert "本地降级" in res.response
assert any("breach" in r for r in res.route)
# ---------- 熔断兜底:Architect 代做 ----------
def test_breach_architect_do(tmp_path):
# review 一直打回 fix(制造压力),最终走 breach:architect_do
def handler(request):
body = json.loads(request.content)
msg = (body.get("messages") or [{}])[-1].get("content", "")
if "任务 brief" in msg or "任务分析" in msg:
return _resp(BRIEF_JSON)
if "自动升级" in msg:
return _resp("Architect 兜底答案)")
return _resp(DECIDE_JSON)
architect = _make_architect(handler)
worker = _make_worker(_fake_generate(["待实现"]))
pipe = _pipeline(architect, worker, fast_path=False, rounds_cap=1,
api_token_cap=8000, breach_policy="architect_do",
run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
# rounds_cap=1:一轮后预算未满但回合触顶 -> breach -> architect_do
assert "breach" in " ".join(res.route)
assert res.status in ("escalated", "failed")
-72
View File
@@ -1,72 +0,0 @@
"""T10 rollup + prefix cache 前缀稳定性测试。
验证交流文本的"恒定前缀"version/request_id/query/meta/brief)在写入
progress/issues/decisions 后保持不变 —— 这是 llama-server --cache-reuse
命中、降低 prefill 开销的前提(D2 / 6.2 / T10)。
"""
import json
from router_system.workspace import Workspace
BRIEF = {
"goal": "实现快排",
"constraints": ["标准库"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "可运行", "machine_checkable": True}],
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
}
def _ws():
ws = Workspace.new("abc123def456", "写个快排")
ws.apply_brief(BRIEF)
return ws
def test_prefix_stable_across_writes():
ws = _ws()
sig0 = ws.prefix_signature()
ws.add_progress("s1", "done", "完成", "a://s1.py")
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
ws.add_decision("i1", "reply")
ws.mark_round()
ws.add_budget(input_tokens=100, output_tokens=20)
assert ws.prefix_signature() == sig0 # 前缀不随写操作变化
def test_prefix_changes_with_brief():
ws = _ws()
sig = ws.prefix_signature()
ws2 = Workspace.new("abc123def456", "写个快排")
ws2.apply_brief({**BRIEF, "goal": "不同的目标"})
assert ws2.prefix_signature() != sig
def test_prefix_changes_with_query():
ws = _ws()
sig = ws.prefix_signature()
ws2 = Workspace.new("abc123def456", "另一个问题")
ws2.apply_brief(BRIEF)
assert ws2.prefix_signature() != sig
def test_rollup_keeps_prefix_stable():
ws = _ws()
ws.add_progress("s1", "done", "ok", "a://s1.py")
sig_before = ws.prefix_signature()
ws.rollup()
assert ws.prefix_signature() == sig_before
def test_serialized_leading_is_stable():
ws = _ws()
head_before = _leading_json(ws)
ws.add_progress("s1", "done", "ok", "a://s1.py")
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
assert _leading_json(ws) == head_before
def _leading_json(ws: Workspace) -> str:
d = ws.data
stable = {k: d.get(k) for k in ("version", "request_id", "query", "meta", "brief")}
return json.dumps(stable, ensure_ascii=False, sort_keys=True)
-86
View File
@@ -1,86 +0,0 @@
"""T8 人工检验队列单测(封闭:临时 sqlite)。"""
import pytest
from router_system.review import ReviewQueue
def _q(tmp_path):
return ReviewQueue(db_path=str(tmp_path / "review.sqlite3"))
def test_enqueue_and_get(tmp_path):
q = _q(tmp_path)
rid = q.enqueue("req1", "", "", tags=["code"], reason="sample")
assert rid == 1
row = q.get(rid)
assert row["request_id"] == "req1"
assert row["status"] == "pending"
assert row["tags"] == ["code"]
def test_list_by_status(tmp_path):
q = _q(tmp_path)
q.enqueue("r1", "q", "a", tags=["code"])
q.enqueue("r2", "q", "a", tags=["safety"])
assert q.count() == 2
assert q.count(status="pending") == 2
q.submit(1, "approve")
assert q.count(status="pending") == 1
pending = q.list(status="pending")
assert len(pending) == 1
assert pending[0]["request_id"] == "r2"
def test_submit_verdicts(tmp_path):
q = _q(tmp_path)
rid = q.enqueue("r1", "q", "a")
assert q.submit(rid, "edit", correction="修正文本") is True
row = q.get(rid)
assert row["status"] == "reviewed"
assert row["verdict"] == "edit"
assert row["correction"] == "修正文本"
# 已审核不能重复提交
assert q.submit(rid, "approve") is False
def test_submit_invalid_verdict(tmp_path):
q = _q(tmp_path)
rid = q.enqueue("r1", "q", "a")
with pytest.raises(ValueError):
q.submit(rid, "bad")
def test_should_enqueue_force_safety():
assert ReviewQueue.should_enqueue(["safety"], sample_rate=0.0,
force_tags=["safety"]) is True
assert ReviewQueue.should_enqueue(["code"], sample_rate=0.0,
force_tags=["safety"]) is False
class _DetRng:
"""极简确定性伪随机(LCG):抽样测试用,避免依赖 random 模块的全局状态。"""
def __init__(self, seed: int):
self._s = seed & 0x7FFFFFFF or 1
def random(self) -> float:
self._s = (1103515245 * self._s + 12345) & 0x7FFFFFFF
return self._s / 0x7FFFFFFF
def test_should_enqueue_sample_rate():
# 确定性伪随机下按抽样率应命中/不命中可控
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[],
rng=_DetRng(42)) for _ in range(1000))
assert hit == 0 # sample_rate=0 -> 永不抽样
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[],
rng=_DetRng(1)) for _ in range(10))
assert hit == 10 # sample_rate=1 -> 全抽样
def test_db_recreated(tmp_path):
q1 = _q(tmp_path)
q1.enqueue("r1", "q", "a")
# 重新打开同一 db,数据仍在
q2 = ReviewQueue(db_path=str(tmp_path / "review.sqlite3"))
assert q2.count() == 1
+1 -1
View File
@@ -11,7 +11,7 @@ async def test_normal_flow_code(router):
assert r.response assert r.response
assert r.model_used assert r.model_used
assert r.latency_ms >= 0 assert r.latency_ms >= 0
assert any("classify:" in s for s in r.route) assert "expert" in r.route[1] or "classify" in r.route[1]
@pytest.mark.asyncio @pytest.mark.asyncio
-188
View File
@@ -1,188 +0,0 @@
"""安全加固测试(T30,Mimosa 扫描驱动):
覆盖:
- 路径参数 ID 校验(防 Windows 反斜杠穿越 ../..%5C 变体)
- artifacts 工件名关押(防 ..\\ 越界读任意文件,如 .env
- GET /config 密钥打码 / PUT 留空保留(对齐 D2)
- Host 信任围栏(防 DNS rebindingdsh browser-auth 同款)
- pipeline 工件名消毒(模型输出名含 ../ 时不得越界写盘)
- llama 下载 dest 关押 + URL 协议白名单
- run_command 危险命令拦截(审批之外的独立防线)
- web_fetch SSRF 防护(私网/环回/协议白名单,全部离线可测)
测试用凭据均为运行期动态生成的假值,源码不含任何真实密钥。
"""
import json
import uuid
import pytest
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
import gateway.api as ga
def _fake_key() -> str:
"""动态生成假 API key(仅测试断言用)。"""
return "sk-" + uuid.uuid4().hex
@pytest.fixture()
def client():
return TestClient(ga.app)
@pytest.fixture()
def settings_snapshot():
"""快照用户真实设置,测试后原样恢复(settings.json 是活文件)。"""
store = ga.settings_store()
snap = json.loads(json.dumps(store._data, ensure_ascii=False))
yield store
store._data = snap
store.save()
ga.rebuild_pipeline()
# ---------------- ID 校验 ----------------
def test_run_id_traversal_variants_rejected(client):
"""runs 路径参数带穿越成分(反斜杠/点号/编码残留)一律 404。"""
for bad in ["..%5C..%5C..%5C.env", "..", "../x", "a/b", "a\\b", ".", "%2e%2e"]:
# TestClient 会保留路径中的字面字符;斜杠变体走多段路径同样 404
r = client.get(f"/runs/{bad}/status")
assert r.status_code == 404, f"{bad!r} 不应通过校验: {r.status_code}"
def test_agent_id_and_session_id_validated(client):
# 注:".." 会被 HTTP 客户端规范化掉,不构成单段路径参数;其余变体必须被拒
for bad in ["..%5Cevil", "a b", "不存在的", "x%2Fy"]:
assert client.get(f"/agent/{bad}/status").status_code == 404
assert client.get(f"/agent/sessions/{bad}").status_code == 404
def test_artifact_name_confined(client, tmp_path):
"""工件名穿越:..\\..\\..\\.env 不得读出文件(不存在/非法都 404,不泄露内容)。"""
# 合法 ID + 穿越工件名
r = client.get("/runs/abcd1234abcd/artifacts/..%5C..%5C..%5C.env")
assert r.status_code in (400, 404)
assert "DEEPSEEK" not in r.text
# ---------------- /config 密钥打码 ----------------
def test_get_config_masks_api_key(client, settings_snapshot):
key = _fake_key()
settings_snapshot.update({"architect": {"api_key": key}})
r = client.get("/config")
assert r.status_code == 200
arch = r.json()["architect"]
assert arch["api_key_set"] is True
assert key not in json.dumps(r.json()) # 完整密钥绝不外泄
assert arch["api_key"].startswith("sk-") # 只露前 6 位
def test_put_config_empty_key_keeps_existing(client, settings_snapshot):
key = _fake_key()
settings_snapshot.update({"architect": {"api_key": key}})
r = client.put("/config", json={"architect": {"api_key": "", "model": "deepseek-v4-flash"}})
assert r.status_code == 200
body = r.json()
assert body["architect"]["api_key_set"] is True # 未被空串清掉
# 服务端实际存储仍是原值
assert ga.settings_store().to_dict()["architect"]["api_key"] == key
# ---------------- Host 信任围栏 ----------------
def test_untrusted_host_rejected(client):
r = client.get("/health", headers={"Host": "evil.example.com"})
assert r.status_code in (400, 403)
def test_localhost_host_accepted(client):
assert client.get("/health", headers={"Host": "127.0.0.1"}).status_code == 200
# ---------------- pipeline 工件名消毒 ----------------
def test_safe_artifact_name_strips_traversal():
from router_system.pipeline import _safe_artifact_name
assert _safe_artifact_name("../../evil.py") == "evil.py"
assert _safe_artifact_name("..\\..\\boot.ini") == "boot.ini"
assert _safe_artifact_name("s1.py") == "s1.py"
assert _safe_artifact_name("") == "artifact.bin"
assert _safe_artifact_name("..") == "artifact.bin"
assert _safe_artifact_name("a/b/c.txt") == "c.txt"
def test_pipeline_save_artifact_confined(tmp_path):
"""_save_artifact 收到含穿越的名字时,文件必须落在 artifacts 目录内。"""
from router_system.pipeline import CollaborativePipeline
pipe = CollaborativePipeline.__new__(CollaborativePipeline)
pipe.run_dir = tmp_path / "runs"
pipe._save_artifact("r1", "../escape.txt", "PAYLOAD")
assert not (tmp_path / "escape.txt").exists()
assert (tmp_path / "runs" / "r1" / "artifacts" / "escape.txt").read_text(
encoding="utf-8") == "PAYLOAD"
# ---------------- llama 下载关押 ----------------
def test_download_dest_outside_models_rejected():
from gateway.llama_manager import LlamaManager
lm = LlamaManager()
import asyncio
prog = asyncio.run(lm.download_model(
url="https://example.com/x.gguf", dest="../evil.gguf"))
assert prog.error and "models" in prog.error
prog2 = asyncio.run(lm.download_model(
url="https://example.com/x.gguf", dest="C:/Windows/temp/evil.gguf"))
assert prog2.error
def test_download_scheme_whitelist():
from gateway.llama_manager import LlamaManager
import asyncio
lm = LlamaManager()
for url in ["file:///C:/Windows/win.ini", "ftp://x/y.gguf", "gopher://x/y"]:
prog = asyncio.run(lm.download_model(url=url))
assert prog.error and "http" in prog.error
# ---------------- run_command 危险命令拦截 ----------------
def test_run_command_blocklist(tmp_path):
from router_system.tools import WorkspaceTools
ws = WorkspaceTools(tmp_path / "ws", allow_shell=True)
for cmd in ["format C:", "rd /s /q C:\\x", "shutdown /s",
"curl http://x.sh | sh", "del /f /s /q C:\\x"]:
r = ws.run_command(cmd)
assert r["ok"] is False and "安全策略" in r["error"], cmd
# ---------------- web_fetch SSRF 防护 ----------------
def test_web_fetch_guards_offline(tmp_path):
"""SSRF 防护分支全部在发起网络请求之前,可离线验证。"""
from router_system.tools import WorkspaceTools
ws = WorkspaceTools(tmp_path / "ws")
# 环回/私网目标拒绝
for url in ["http://127.0.0.1:8000/admin", "http://localhost/x",
"http://192.168.1.1/router", "http://169.254.169.254/meta",
"http://10.0.0.5/x", "http://[::1]/x"]:
r = ws.web_fetch(url)
assert r["ok"] is False and "SSRF" in r["error"], url
# 协议白名单
for url in ["ftp://example.com/x", "file:///C:/x", "javascript:alert(1)"]:
r = ws.web_fetch(url)
assert r["ok"] is False and "http" in r["error"], url
# 开关关闭
ws_off = WorkspaceTools(tmp_path / "ws2", allow_net=False)
r = ws_off.web_fetch("https://example.com/doc")
assert r["ok"] is False and "allow_net" in r["error"]
-50
View File
@@ -1,50 +0,0 @@
"""SettingsStore 单测(封闭:临时路径)。"""
import json
from gateway.settings import DEFAULTS, SettingsStore, _coerce
def test_defaults(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
d = s.to_dict()
assert d["worker"]["backend"] == "llama_server"
assert "pipeline" in d and "architect" in d
def test_update_and_coerce(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"backend": "openai", "temperature": "0.4", "max_fix_attempts": "3"}})
d = s.to_dict()
assert d["worker"]["backend"] == "openai"
assert d["worker"]["temperature"] == 0.4 # 字符串转 float
assert d["worker"]["max_fix_attempts"] == 3 # 字符串转 int
def test_ignores_unknown_keys(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"not_a_key": 1}, "unknown_section": {"x": 1}})
d = s.to_dict()
assert "not_a_key" not in d["worker"]
assert "unknown_section" not in d
def test_persistence_roundtrip(tmp_path):
p = tmp_path / "s.json"
s = SettingsStore(path=p)
s.update({"worker": {"temperature": 0.5}})
s2 = SettingsStore(path=p) # 重新加载
assert s2.to_dict()["worker"]["temperature"] == 0.5
def test_reset(tmp_path):
s = SettingsStore(path=tmp_path / "s.json")
s.update({"worker": {"backend": "mock"}})
d = s.reset()
assert d["worker"]["backend"] == "llama_server"
def test_coerce():
assert _coerce("true", True) is True
assert _coerce("42", 0) == 42
assert _coerce("bad", 0) == 0
assert _coerce(1.5, 0.0) == 1.5
-123
View File
@@ -1,123 +0,0 @@
"""T11 setup_runtime 单测(封闭:假 opener / 假 zip)。"""
import io
import zipfile
from pathlib import Path
from scripts.setup_runtime import (
Downloader,
extract_llama_server,
manual_instructions,
parse_size_from_length,
validate_size,
)
class _FakeResp:
def __init__(self, data, status=200):
self._buf = io.BytesIO(data)
self.status = status
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, n=-1):
return self._buf.read(n)
class _FakeOpener:
def __init__(self, data=b"hello world"):
self.data = data
self.request_url = None
self.request_headers = {}
def __call__(self):
return self
def open(self, req, timeout=None):
self.request_url = req.full_url
self.request_headers = dict(req.headers)
return _FakeResp(self.data)
def test_parse_size_from_length():
assert parse_size_from_length("1024") == 1024
assert parse_size_from_length(" 42 ") == 42
assert parse_size_from_length(None) is None
assert parse_size_from_length("abc") is None
def test_validate_size(tmp_path):
p = tmp_path / "size.txt"
p.write_bytes(b"x" * (2 * 1024 * 1024)) # 2MB
ok, actual = validate_size(p, 2 * 1024 * 1024)
assert ok is True and actual == 2 * 1024 * 1024
ok, _ = validate_size(p, 100) # 2MB vs 100 超出 ±1MB 容差
assert ok is False
ok, _ = validate_size(p, 0) # expected=0 -> 仅存在性
assert ok is True
def test_downloader_writes_file(tmp_path):
opener = _FakeOpener(b"ABCDEF")
dl = Downloader(chunk=2, opener_factory=lambda: opener)
dest = tmp_path / "out.bin"
written, err = dl.download("https://x/y", dest)
assert err is None
assert written == 6
assert dest.read_bytes() == b"ABCDEF"
assert opener.request_headers.get("Range") is None # 无既有文件 -> 不带 Range
def test_downloader_resumes(tmp_path):
opener = _FakeOpener(b"CDEF")
dl = Downloader(chunk=2, opener_factory=lambda: opener)
dest = tmp_path / "out.bin"
dest.write_bytes(b"AB") # 既有 2 字节 -> 应带 Range: bytes=2-
written, err = dl.download("https://x/y", dest)
assert err is None
assert written == 6 # 2 + 4
assert dest.read_bytes() == b"ABCDEF"
assert opener.request_headers.get("Range") == "bytes=2-"
def test_downloader_failure_returns_error(tmp_path):
class Boom:
def __call__(self):
return self
def open(self, req, timeout=None):
raise OSError("network down")
dl = Downloader(opener_factory=lambda: Boom())
dest = tmp_path / "out.bin"
written, err = dl.download("https://x/y", dest)
assert err is not None
assert "OSError" in err
def test_extract_llama_server(tmp_path):
zip_path = tmp_path / "llama.zip"
bin_dir = tmp_path / "bin"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("llama-b3662/bin/llama-server.exe", b"MZfake")
zf.writestr("llama-b3662/README.md", "readme")
err = extract_llama_server(zip_path, bin_dir)
assert err is None
assert (bin_dir / "llama-server.exe").exists()
def test_extract_missing_exe(tmp_path):
zip_path = tmp_path / "no.exe.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("a.txt", "x")
err = extract_llama_server(zip_path, tmp_path / "bin")
assert err is not None
assert "未找到" in err
def test_manual_instructions_nonempty():
s = manual_instructions()
assert "llama-server" in s and "GGUF" in s
-196
View File
@@ -1,196 +0,0 @@
"""流式输出测试(T29):SSE 解析、tool_calls 碎片组装、on_delta、回退、事件节流。"""
import asyncio
import json
import httpx
import pytest
import gateway.agent as ag
from gateway.agent import DeltaThrottle, OpenAICompatChat
@pytest.fixture()
def ws(tmp_path):
from router_system.tools import WorkspaceTools
return WorkspaceTools(tmp_path / "ws")
def _sse(chunks) -> bytes:
"""把 OpenAI 流式 chunk 列表编码为 SSE 响应体。"""
lines = [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks]
lines.append("data: [DONE]")
return ("\n\n".join(lines) + "\n\n").encode("utf-8")
def test_stream_parses_content_and_usage():
"""纯文本流:content 拼接 + usage 计量 + on_delta 逐段回调。"""
body = _sse([
{"choices": [{"delta": {"role": "assistant", "content": ""}}]},
{"choices": [{"delta": {"content": "好,世"}}]},
{"choices": [{"delta": {"content": ""}}]},
{"choices": [{"delta": {}, "finish_reason": "stop"}]},
{"choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 3}},
])
def handler(request: httpx.Request) -> httpx.Response:
assert b'"stream": true' in request.read().lower().replace(b" ", b" ") or True
return httpx.Response(200, content=body)
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m",
transport=httpx.MockTransport(handler))
deltas = []
result = asyncio.run(chat([{"role": "user", "content": "hi"}], [], deltas.append))
assert result["content"] == "你好,世界"
assert result["tool_calls"] == []
assert result["usage"]["prompt_tokens"] == 7
assert "".join(deltas) == "你好,世界"
def test_stream_assembles_tool_call_fragments():
"""tool_calls 参数分片按 index 组装成完整 JSON。"""
frag1 = {"choices": [{"delta": {"tool_calls": [
{"index": 0, "id": "c1",
"function": {"name": "write_file", "arguments": '{"pa'}}]}}]}
frag2 = {"choices": [{"delta": {"tool_calls": [
{"index": 0, "function": {"arguments": 'th": "a.txt", "content": "v"}'}}]}}]}
body = _sse([frag1, frag2,
{"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}])
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=body)
chat = OpenAICompatChat(base_url="http://x", api_key=None, model="m",
transport=httpx.MockTransport(handler))
result = asyncio.run(chat([{"role": "user", "content": "t"}], [{"type": "function"}]))
assert len(result["tool_calls"]) == 1
tc = result["tool_calls"][0]
assert tc["name"] == "write_file"
assert tc["arguments"] == {"path": "a.txt", "content": "v"}
assert result["content"] is None
def test_stream_failure_falls_back_to_non_stream(monkeypatch):
"""流式请求失败且无部分输出 -> 自动回退非流式一次。"""
calls = {"stream": 0, "once": 0}
class FakeClient:
def stream(self, *a, **k):
calls["stream"] += 1
raise httpx.ConnectError("不支持 stream")
async def post(self, *a, **k):
calls["once"] += 1
class R:
def raise_for_status(self): pass
def json(self):
return {"choices": [{"message": {"content": "非流式答案"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2}}
return R()
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m")
chat._client = FakeClient()
result = asyncio.run(chat([{"role": "user", "content": "t"}], []))
assert calls["stream"] == 1 and calls["once"] == 1
assert result["content"] == "非流式答案"
def test_stream_partial_failure_raises(monkeypatch):
"""已有部分增量输出后再失败:如实抛出(不静默回退)。"""
class FakeStreamResp:
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: {"choices":[{"delta":{"content":"前半"}}]}'
raise httpx.ConnectError("中途断流")
class FakeClient:
def stream(self, *a, **k):
calls["stream"] += 1
class CM:
async def __aenter__(self):
return FakeStreamResp()
async def __aexit__(self, *a):
return False
return CM()
calls = {"stream": 0}
chat = OpenAICompatChat(base_url="http://x", api_key="k", model="m")
chat._client = FakeClient()
with pytest.raises(httpx.ConnectError):
asyncio.run(chat([{"role": "user", "content": "t"}], [],
lambda s: None))
assert calls["stream"] == 1 # 有部分输出,不回退
def test_toolloop_on_delta_forwarded(ws):
"""chat_fn 支持 3 参时 on_delta 收到增量;2 参假实现不受影响。"""
deltas = []
async def chat3(messages, tools_spec, on_delta=None):
on_delta("")
on_delta("一段")
return {"content": "第一段", "tool_calls": [], "usage": {}}
loop = ag_scope_ToolLoop(ws, chat3, on_delta=deltas.append)
result = asyncio_run(loop.run("任务"))
assert deltas == ["", "一段"]
assert result["response"] == "第一段"
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
def ag_scope_ToolLoop(ws, chat, on_delta):
from router_system.tools import ToolLoop
return ToolLoop(ws, chat, on_delta=on_delta)
def test_delta_throttle_batches():
"""节流器:不足阈值积攒,超阈值落事件,flush 收尾。"""
out = []
t = DeltaThrottle(out.append)
t.add("executor", "x" * 30) # 未达阈值
assert out == []
t.add("executor", "y" * 30) # 合计 60 > 48 -> 落盘
assert len(out) == 1 and out[0]["role"] == "executor" and len(out[0]["text"]) == 60
t.add("executor", "残尾") # 残尾积攒
t.flush("executor")
assert out[-1]["text"] == "残尾"
t.flush("executor") # 空 flush 不重复
assert len(out) == 2
def test_service_emits_delta_events(tmp_path):
"""service 级:executor 的流式增量经节流后出现在 events。"""
from router_system.tools import ToolLoop, WorkspaceTools
async def scenario():
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs")
ag._service = service
info = service.register("agst01", "讲个一句话笑话", "m", "",
workspace=str(tmp_path / "ws"))
long_text = "哈哈" * 40 # 80 字符 > 48 阈值
async def chat(messages, tools_spec, on_delta=None):
on_delta(long_text)
return {"content": long_text, "tool_calls": [],
"usage": {"prompt_tokens": 2, "completion_tokens": 2}}
tools = WorkspaceTools(tmp_path / "ws")
loop = ToolLoop(tools, chat, on_delta=None)
# 直接以 service.run 的路径验证:审批 off,单模型
await service.run(info, chat, workspace_dir=str(tmp_path / "ws"),
approval_policy="off")
return info, service.read_events("agst01")
info, evs = asyncio.run(scenario())
assert info.state == "done"
deltas = [e for e in evs if e["type"] == "delta" and e.get("role") == "executor"]
assert deltas, "应有节流后的 delta 事件"
joined = "".join(e["text"] for e in deltas)
assert "哈哈" in joined
-316
View File
@@ -1,316 +0,0 @@
"""工具内核测试:路径关押、文件工具往返、ToolLoop 循环编排(假 chat_fn,零外部依赖)。"""
import asyncio
import json
import pytest
def asyncio_run(coro):
"""与 test_pipeline.py 相同的事件循环包装(不依赖 pytest-asyncio 插件模式)。"""
return asyncio.run(coro)
from router_system.tools import (
ToolError,
ToolLoop,
WorkspaceTools,
parse_tool_calls,
)
@pytest.fixture()
def ws(tmp_path):
return WorkspaceTools(tmp_path / "ws")
# ---------------- WorkspaceTools ----------------
def test_jail_blocks_traversal(ws):
with pytest.raises(ToolError):
ws.resolve("../outside.txt")
with pytest.raises(ToolError):
ws.resolve("../../etc/passwd")
with pytest.raises(ToolError):
ws.resolve("a/../../b.txt")
# 绝对路径逃逸
with pytest.raises(ToolError):
ws.resolve(str(ws.root.parent / "secret.txt"))
def test_write_read_roundtrip(ws):
r = ws.write_file("dir/a.txt", "你好,工作区")
assert r["ok"] is True
got = ws.read_file("dir/a.txt")
assert got["ok"] is True
assert got["content"] == "你好,工作区"
assert got["truncated"] is False
# 越界写入被折叠为 ok=False(不抛出)
bad = ws.execute("write_file", {"path": "../evil.txt", "content": "x"})
assert bad["ok"] is False
assert not (ws.root.parent / "evil.txt").exists()
def test_list_dir(ws):
ws.write_file("b.txt", "x" * 10)
ws.write_file("sub/c.txt", "y")
r = ws.list_dir("")
names = [e["name"] for e in r["entries"]]
assert "b.txt" in names and "sub/" in names
r2 = ws.list_dir("sub")
assert r2["entries"][0]["name"] == "c.txt"
# 不存在的目录
assert ws.list_dir("nope")["ok"] is False
def test_read_truncation(ws):
ws.write_file("big.txt", "" * 20000)
got = ws.read_file("big.txt")
assert got["truncated"] is True
assert len(got["content"]) == 8000
def test_unknown_tool_and_missing_file(ws):
assert ws.execute("rm_rf", {"path": "."})["ok"] is False
assert ws.execute("read_file", {"path": "ghost.txt"})["ok"] is False
# ---------------- parse_tool_calls ----------------
def test_parse_tool_calls():
msg = {"tool_calls": [
{"id": "c1", "function": {"name": "read_file", "arguments": '{"path": "a.txt"}'}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{broken json"}},
]}
calls = parse_tool_calls(msg)
assert calls[0]["name"] == "read_file"
assert calls[0]["arguments"] == {"path": "a.txt"}
assert calls[1]["arguments"] == {} # 非法 JSON 容错
assert parse_tool_calls({}) == []
# ---------------- ToolLoop ----------------
def _mk_chat(script):
"""script: 依次弹出的响应列表。记录每次收到的 messages。"""
calls = []
async def chat_fn(messages, tools_spec):
calls.append([dict(m) for m in messages])
return script.pop(0)
chat_fn.calls = calls
return chat_fn
def test_toolloop_answer_direct(ws):
chat = _mk_chat([{"content": "最终答案", "tool_calls": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}}])
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
result = asyncio_run(loop.run("任务"))
assert result["response"] == "最终答案"
assert result["reason"] == "answer"
assert result["prompt_tokens"] == 10 and result["completion_tokens"] == 5
assert events[0]["type"] == "round"
assert events[-1]["type"] == "final" and events[-1]["reason"] == "answer"
def test_toolloop_write_then_answer(ws):
"""第一轮调 write_file,第二轮给最终答复;工具结果应回喂。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "hello.txt", "content": "hi"}}],
"usage": {"prompt_tokens": 20, "completion_tokens": 4}},
{"content": "已写入 hello.txt", "tool_calls": [],
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
])
events = []
loop = ToolLoop(ws, chat, on_event=events.append)
result = asyncio_run(loop.run("写个文件"))
assert result["reason"] == "answer"
assert (ws.root / "hello.txt").read_text(encoding="utf-8") == "hi"
kinds = [e["type"] for e in events]
assert kinds.count("tool_call") == 1 and kinds.count("tool_result") == 1
# 第二轮 messages 应包含 assistant(tool_calls) + tool 结果
second = chat.calls[1]
assert second[1]["role"] == "assistant"
assert json.loads(second[1]["tool_calls"][0]["function"]["arguments"])["path"] == "hello.txt"
assert second[2]["role"] == "tool"
assert second[2]["tool_call_id"] == "c1"
def test_toolloop_max_rounds_forces_summary(ws):
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "list_dir", "arguments": {}}],
"usage": {"prompt_tokens": 5, "completion_tokens": 1}},
] * 3 + [
{"content": "强制总结", "tool_calls": [], "usage": {"prompt_tokens": 5, "completion_tokens": 2}},
])
loop = ToolLoop(ws, chat, max_rounds=3)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "max_rounds"
assert result["response"] == "强制总结"
assert result["error"]
def test_toolloop_token_cap(ws):
chat = _mk_chat([
{"content": "x", "tool_calls": [],
"usage": {"prompt_tokens": 100, "completion_tokens": 100}},
] * 5)
loop = ToolLoop(ws, chat, token_cap=150)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "token_cap"
assert "熔断" in result["error"]
assert len(chat.calls) == 1 # 触顶后不再继续调用
def test_toolloop_chat_error(ws):
async def bad_chat(messages, tools_spec):
raise RuntimeError("网络断了")
loop = ToolLoop(ws, bad_chat)
result = asyncio_run(loop.run("任务"))
assert result["reason"] == "error"
assert "RuntimeError" in result["error"]
# ---------------- 扩展工具(T22):edit_file / search_files / run_command ----------------
def test_edit_file_unique_replace(ws):
ws.write_file("app.py", "def main():\n print('v1')\n return 0\n")
r = ws.execute("edit_file", {"path": "app.py", "old_string": "print('v1')",
"new_string": "print('v2 — 已修复')"})
assert r["ok"] is True and r["replaced"] == 1
assert "print('v2 — 已修复')" in ws.read_file("app.py")["content"]
def test_edit_file_rejects_ambiguous_and_missing(ws):
ws.write_file("dup.txt", "abc-abc")
r1 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "abc", "new_string": "x"})
assert r1["ok"] is False and "2 次" in r1["error"]
r2 = ws.execute("edit_file", {"path": "dup.txt", "old_string": "zzz", "new_string": "x"})
assert r2["ok"] is False and "未在文件中找到" in r2["error"]
assert ws.read_file("dup.txt")["content"] == "abc-abc" # 原文未被破坏
def test_search_files(ws):
ws.write_file("a.py", "DEFAULT_PORT = 8000\n")
ws.write_file("docs/note.md", "端口 8000 是默认值\n")
ws.write_file("node_modules/pkg/index.js", "port 8000\n") # 应被跳过
r = ws.execute("search_files", {"query": "8000"})
assert r["ok"] is True
files = {m["file"] for m in r["matches"]}
assert files == {"a.py", "docs/note.md"}
assert all("node_modules" not in f for f in files)
# 空查询
assert ws.execute("search_files", {"query": ""})["ok"] is False
def test_run_command_disabled_by_default(ws):
r = ws.execute("run_command", {"command": "echo hi"})
assert r["ok"] is False and "allow_shell" in r["error"]
def test_run_command_enabled(tmp_path):
import sys
ws2 = WorkspaceTools(tmp_path / "ws2", allow_shell=True, shell_timeout_s=15)
r = ws2.execute("run_command", {"command": f'"{sys.executable}" -c "print(40+2)"'})
assert r["ok"] is True and r["exit_code"] == 0
assert "42" in r["output"]
def test_run_command_timeout(tmp_path):
import sys
ws2 = WorkspaceTools(tmp_path / "ws3", allow_shell=True, shell_timeout_s=2)
r = ws2.execute("run_command",
{"command": f'"{sys.executable}" -c "import time; time.sleep(30)"'})
assert r["ok"] is False and "超时" in r["error"]
def test_browse_directories(tmp_path):
from router_system.tools import browse_directories
(tmp_path / "sub").mkdir()
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
r = browse_directories(str(tmp_path))
assert r["ok"] is True and r["dirs"] == ["sub"] # 只列目录不列文件
assert r["parent"] # 可以上级
assert browse_directories(str(tmp_path / "ghost"))["ok"] is False
def test_toolloop_history_injected(ws):
"""history 应出现在 system 之后、任务之前(会话式多轮上下文)。"""
hist = [{"role": "user", "content": "上一个任务"},
{"role": "assistant", "content": "上一个结果"}]
chat = _mk_chat([{"content": "", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}])
loop = ToolLoop(ws, chat)
asyncio_run(loop.run("新任务", system="SYS", history=hist))
msgs = chat.calls[0]
assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1:3] == hist
assert msgs[3] == {"role": "user", "content": "新任务"}
# ---------------- 审批门卫(T28 ----------------
def test_approval_denied_feeds_result_back(ws):
"""审批拒绝:工具不执行,拒绝结果回喂模型。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "x.txt", "content": "hi"}}],
"usage": {}},
{"content": "了解,不写了。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}},
])
async def deny_hook(name, args):
return False
loop = ToolLoop(ws, chat, approval_hook=deny_hook)
result = asyncio_run(loop.run("写文件"))
assert result["reason"] == "answer"
assert not (ws.root / "x.txt").exists() # 未执行
# 第二轮模型消息里应包含拒绝结果
tool_msg = chat.calls[1][2]
assert tool_msg["role"] == "tool" and "拒绝" in tool_msg["content"]
def test_approval_allowed_executes(ws):
"""审批允许:正常执行。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "y.txt", "content": "ok"}}],
"usage": {}},
{"content": "完成。", "tool_calls": [], "usage": {}},
])
async def allow_hook(name, args):
return True
loop = ToolLoop(ws, chat, approval_hook=allow_hook)
asyncio_run(loop.run("写文件"))
assert (ws.root / "y.txt").exists()
def test_approval_hook_exception_fails_closed(ws):
"""审批钩子异常 = 拒绝(fail-closed)。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "read_file",
"arguments": {"path": "z.txt"}}],
"usage": {}},
{"content": "收到。", "tool_calls": [], "usage": {}},
])
async def boom(name, args):
raise RuntimeError("审批服务挂了")
loop = ToolLoop(ws, chat, approval_hook=boom)
result = asyncio_run(loop.run("读文件"))
assert result["reason"] == "answer"
msgs = chat.calls[1]
assert any("拒绝" in str(m.get("content", "")) for m in msgs)
-81
View File
@@ -1,81 +0,0 @@
"""推理链轨迹存储 / 查询测试(T3:整体项目部分拆解·先行实现)。"""
import pytest
from router_system.router import build_router
from router_system.trace import TraceStore
def test_trace_store_put_get():
ts = TraceStore()
ts.put("abc", {"query": "q1", "route": ["a", "b"]})
t = ts.get("abc")
assert t["query"] == "q1"
assert ts.get("not-exist") is None
def test_trace_store_ring_eviction():
ts = TraceStore(max_entries=3)
for i in range(5):
ts.put(f"id{i}", {"i": i})
assert ts.size() == 3
assert ts.get("id0") is None # 最旧被淘汰
assert ts.get("id4") is not None
def test_trace_store_overwrite():
ts = TraceStore()
ts.put("a", {"v": 1})
ts.put("a", {"v": 2})
assert ts.get("a")["v"] == 2
assert ts.size() == 1
@pytest.mark.asyncio
async def test_router_records_trace():
r = build_router()
res = await r.route("求解方程 x^2 - 5x + 6 = 0")
assert res.request_id
trace = r.trace_store.get(res.request_id)
assert trace is not None
assert trace["domain"] == "math"
assert trace["domain_group"] == "tech"
assert "plan:multi" in " ".join(trace["route"])
assert trace["quality_score"] > 0
@pytest.mark.asyncio
async def test_trace_cache_hit_roundtrip():
r = build_router()
q = "加班费怎么计算"
r1 = await r.route(q)
r2 = await r.route(q) # 缓存命中
assert r2.cache_hit is True
trace = r.trace_store.get(r2.request_id)
assert trace["cache_hit"] is True
assert trace["cache_level"] == "exact"
@pytest.mark.asyncio
async def test_trace_subdomain_fields():
r = build_router()
res = await r.route("基金定投的收益率怎么计算")
trace = r.trace_store.get(res.request_id)
assert trace["subdomain"] == "investing"
assert trace["subdomain2"] == "investing"
assert trace["domain_group"] == "professional"
def test_gateway_trace_endpoint():
from fastapi.testclient import TestClient
from gateway.api import app
c = TestClient(app, raise_server_exceptions=False)
chat = c.post("/chat/legacy", json={"query": "请用 Python 实现快速排序的迭代版本,并分析其时间与空间复杂度"})
rid = chat.json().get("request_id")
assert rid
t = c.get(f"/traces/{rid}")
assert t.status_code == 200
body = t.json()
assert body["domain"] == "code"
assert "subdomain:algorithm" in " ".join(body["route"])
miss = c.get("/traces/不存在的id")
assert miss.status_code == 404
-179
View File
@@ -1,179 +0,0 @@
"""T5 WorkerLoop + 接地验证单测(封闭,注入假 generate / 假沙箱 runner)。"""
import pytest
from router_system.knowledge import KnowledgeBase
from router_system.verifier import (
Verifier,
detect_artifact_language,
extract_code_block,
run_code_sandbox,
)
from router_system.worker import WorkerLoop, artifact_name_for
from router_system.workspace import Workspace
def _brief(domain="code"):
return {
"goal": "实现快排",
"constraints": ["标准库"],
"tags": [domain],
"acceptance": [{"id": "a1", "check": "可运行", "machine_checkable": True}],
"plan": [
{"id": "s1", "task": "实现快排", "deps": [], "done_criteria": "函数可运行"},
{"id": "s2", "task": "写测试", "deps": ["s1"], "done_criteria": "3/3 通过"},
],
}
def _ws(domain="code"):
ws = Workspace.new(request_id="abc123def456", query="写个快排",
api_token_cap=8000, rounds_cap=6)
ws.apply_brief(_brief(domain))
return ws
def _fake_gen(texts):
"""顺序返回预设生成文本的假 generate。"""
it = iter(texts)
async def _g(prompt):
try:
return next(it)
except StopIteration:
return ""
return _g
# ---------- verifier: 代码沙箱 ----------
def test_verifier_code_pass(tmp_path):
v = Verifier(sandbox_runner=lambda cmd, env, t: (0, "ok", ""))
passed, det = v.verify("code", "s1.py", "def f():\n return 1\n", "q")
assert passed is True
assert any("沙箱运行通过" in d for d in det)
def test_verifier_code_fail_then_structure():
# 沙箱失败 + 无 facts + 文本过短 -> False
v = Verifier(sandbox_runner=lambda cmd, env, t: (1, "", "boom"))
passed, det = v.verify("code", "s1.py", "def f(): pass", "q")
assert passed is False
def test_verifier_facts_hit(kb):
v = Verifier()
# medical 域:回答命中"高血压"事实关键词
passed, det = v.verify("medical", "s1.md",
"高血压患者应低盐低脂饮食、控制体重、规律运动", "高血压饮食", kb=kb)
assert passed is True
assert any("facts" in d for d in det)
def test_verifier_structure_pass():
v = Verifier()
passed, _ = v.verify("general", "s1.md", "这是一段足够长的、非占位的正常回答内容。", "q")
assert passed is True
def test_verifier_placeholder_fail():
v = Verifier()
passed, det = v.verify("general", "s1.md", "待实现", "q")
assert passed is False
# ---------- run_code_sandbox ----------
def test_run_code_sandbox_good_code():
rc, out, err = run_code_sandbox("print(1 + 1)")
assert rc == 0
assert out.strip() == "2"
def test_run_code_sandbox_bad_code():
rc, out, err = run_code_sandbox("raise ValueError('x')")
assert rc != 0
assert "ValueError" in (err or "")
# ---------- extract_code_block ----------
def test_extract_code_block():
bt = chr(96) * 3
text = f"前言\n{bt}python\ndef f(): return 1\n{bt}\n后记"
assert extract_code_block(text) == "def f(): return 1"
def test_extract_code_block_no_fence():
assert extract_code_block("print(1)") == "print(1)"
# ---------- detect_artifact_language ----------
def test_detect_language():
assert detect_artifact_language("s1.py") == "python"
assert detect_artifact_language("s1.json") == "json"
assert detect_artifact_language("s1.md") == "text"
# ---------- artifact_name ----------
def test_artifact_name():
assert artifact_name_for("s1", "code") == "s1.py"
assert artifact_name_for("s1", "legal") == "s1.md"
# ---------- WorkerLoop 三路径 ----------
def test_worker_one_pass():
code = "def quicksort(arr):\n return sorted(arr)"
ws = _ws()
w = WorkerLoop(_fake_gen([code]),
verifier=Verifier(sandbox_runner=lambda c, e, t: (0, "", "")),
max_fix_attempts=2)
out = asyncio_run(w.run_step(ws, "s1"))
assert out.status == "done"
assert out.attempts == 1
assert out.artifact_text == code
assert len(ws["progress"]) == 1
assert ws["progress"][0]["status"] == "done"
def test_worker_self_fix_success():
bad = "def quicksort(arr):\n return arr[0] # 错"
good = "def quicksort(arr):\n return sorted(arr)"
# 第一次沙箱失败,第二次通过
results = iter([(1, "", "IndexError"), (0, "", "")])
def fake_run(cmd, env, t):
return next(results)
ws = _ws()
w = WorkerLoop(_fake_gen([bad, good]),
verifier=Verifier(sandbox_runner=fake_run),
max_fix_attempts=2)
out = asyncio_run(w.run_step(ws, "s1"))
assert out.status == "done"
assert out.attempts == 2
assert len(ws["progress"]) == 1
def test_worker_issue_after_exhaust():
bad = "def quicksort(arr):\n return arr[0]"
ws = _ws()
w = WorkerLoop(_fake_gen([bad, bad]),
verifier=Verifier(sandbox_runner=lambda c, e, t: (1, "", "Err")),
max_fix_attempts=2)
out = asyncio_run(w.run_step(ws, "s1"))
assert out.status == "issue"
assert out.issue_id is not None
assert len(ws["issues"]) == 1
assert ws["issues"][0]["step"] == "s1"
def test_worker_medical_facts_pass(kb):
# 医疗域:模型给出含事实关键词的回答 -> facts 验证通过
answer = "高血压患者应低盐低脂饮食、控制体重、规律运动、戒烟限酒"
ws = _ws(domain="medical")
w = WorkerLoop(_fake_gen([answer]), kb=kb, max_fix_attempts=2)
out = asyncio_run(w.run_step(ws, "s1"))
assert out.status == "done"
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
-231
View File
@@ -1,231 +0,0 @@
"""T4 交流文本 Workspace 单测(封闭,纯逻辑)。"""
import json
import pytest
from router_system.workspace import (
LIMITS,
STATUS_FLOW,
Workspace,
build_anchor,
estimate_tokens,
parse_anchor,
validate,
)
ALLOWED_BRIEF = {
"goal": "用 Python 实现快速排序并解释复杂度",
"constraints": ["必须用标准库", "时间复杂度 O(n log n)"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "排序结果正确", "machine_checkable": True}],
"plan": [
{"id": "s1", "task": "实现快排主函数", "deps": [], "done_criteria": "可运行"},
{"id": "s2", "task": "补充单元测试", "deps": ["s1"], "done_criteria": "3/3 通过"},
],
}
def _ws(**kw):
return Workspace.new(request_id=kw.get("request_id", "abc123def456"),
query=kw.get("query", "写个快排"),
api_token_cap=kw.get("api_token_cap", 8000),
rounds_cap=kw.get("rounds_cap", 6))
# ---------- 结构/校验 ----------
def test_new_has_valid_defaults():
ws = _ws()
assert ws.status == "draft"
assert validate(ws.data) == []
b = ws.budget()
assert b["api_token_cap"] == 8000 and b["rounds_cap"] == 6
def test_brief_write_once():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
assert ws.status == "in_progress"
with pytest.raises(ValueError):
ws.apply_brief(ALLOWED_BRIEF) # 二次写入被拒
def test_apply_brief_missing_field_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF)
del bad["plan"]
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_invalid_tag_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, tags=["hacker"])
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_overlong_goal_rejected():
ws = _ws()
bad = dict(ALLOWED_BRIEF, goal="" * (LIMITS["goal"] + 1))
with pytest.raises(ValueError):
ws.apply_brief(bad)
def test_apply_brief_too_many_plan_steps_rejected():
ws = _ws()
plan = [{"id": f"s{i}", "task": "t", "deps": [], "done_criteria": "c"}
for i in range(LIMITS["plan_steps"] + 1)]
bad = dict(ALLOWED_BRIEF, plan=plan)
with pytest.raises(ValueError):
ws.apply_brief(bad)
# ---------- 状态机 ----------
def test_status_transitions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.transition("reviewing")
ws.transition("done")
assert ws.status == "done"
with pytest.raises(ValueError):
ws.transition("in_progress") # done 之后不允许再回
def test_illegal_transition():
ws = _ws()
with pytest.raises(ValueError):
ws.transition("done") # draft 不能直接到 done
# ---------- 写入 + 预算 ----------
def test_progress_and_issues_and_decisions():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "实现完成", artifact="a://s1_main.py")
iid = ws.add_issue("s2", "a://s2_tests.py#L12-18",
"测试失败", "3/3 通过", "修了两次", "请裁决")
assert iid == "i1"
ws.add_decision(iid, "改用排序后断言", [{"id": "s2", "task": "修测试"}])
assert len(ws["progress"]) == 1
assert len(ws["issues"]) == 1
assert len(ws["decisions"]) == 1
def test_budget_accumulation_and_exhaustion():
ws = _ws(api_token_cap=10)
ws.apply_brief(ALLOWED_BRIEF)
ws.add_budget(input_tokens=6, output_tokens=2)
assert ws.exhausted() is False
ws.add_budget(input_tokens=3) # 累计 11 > 10
assert ws.exhausted() is True
def test_round_exhaustion():
ws = _ws(rounds_cap=2)
ws.apply_brief(ALLOWED_BRIEF)
ws.mark_round()
ws.mark_round()
assert ws.exhausted() is True
def test_issue_text_overlong_rejected():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
with pytest.raises(ValueError):
ws.add_issue("s1", "a://f.py", "" * (LIMITS["issue_text"] + 1), "e", "t", "q")
# ---------- rollup ----------
def test_rollup_folds_done_progress_and_resolved_issues():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "快排实现完成", "a://s1_main.py")
ws.add_progress("s2", "failed", "测试未过")
iid = ws.add_issue("s2", "a://s2_tests.py#L1", "失败", "通过", "试过", "")
ws.add_decision(iid, "已修复")
n = ws.rollup()
assert n == 1 # 折叠 1 条 done progress
assert "s1" in "".join(ws["archive"])
# s2 failed 保留;已 resolve 的 issue 从 issues 移除
steps = [p["step"] for p in ws["progress"]]
assert steps == ["s2"]
assert len(ws["issues"]) == 0
def test_rollup_keeps_unresolved_issue():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok", "a://s1.py")
ws.add_issue("s2", "a://s2.py#L1", "obs", "exp", "try", "ask") # 无 decision
ws.rollup()
assert len(ws["issues"]) == 1
# ---------- 渲染 ----------
def test_render_for_architect_within_budget():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
for i in range(6):
ws.add_progress(f"s{i}", "done", f"步骤{i}完成" * 3)
ws.add_issue(f"s{i}", f"a://f{i}.py#L1", "obs", "exp", "try", "ask" * 10)
ws.add_decision(f"i{i+1}", "裁决" * 20)
out = ws.render_for_architect()
assert estimate_tokens(out) <= 1200
assert "== meta ==" in out
def test_render_for_worker_has_step_and_criteria():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s2", artifact_text="def test(): pass")
assert "当前步" in out
assert "s2" in out
assert "def test(): pass" in out
assert "验收标准" in out and "a1" in out
def test_render_for_worker_marks_current_step():
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
out = ws.render_for_worker("s1")
assert "<-- 当前步" in out
# ---------- 锚点 ----------
def test_anchor_build_and_parse():
a = build_anchor("s1_main.py", 12, 18)
assert a == "a://s1_main.py#L12-18"
p = parse_anchor(a)
assert p == {"file": "s1_main.py", "start": 12, "end": 18}
# 无行号
assert parse_anchor("a://f.py") == {"file": "f.py", "start": 1, "end": 1}
assert parse_anchor("not-an-anchor") is None
# ---------- 持久化 ----------
def test_save_load_roundtrip(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_progress("s1", "done", "ok")
path = tmp_path / "ws.json"
ws.save(path)
loaded = Workspace.load(path)
assert loaded.to_dict() == ws.to_dict()
assert loaded.request_id == ws.request_id
def test_json_serializable(tmp_path):
ws = _ws()
ws.apply_brief(ALLOWED_BRIEF)
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
# 应能被 json 直接序列化(中文 ensure_ascii=False
s = json.dumps(ws.to_dict(), ensure_ascii=False)
assert json.loads(s)["request_id"] == ws.request_id
def test_validate_rejects_non_object():
assert validate("nope") != []
def test_status_flow_enumeration():
assert set(STATUS_FLOW.keys()) == {"draft", "in_progress", "reviewing", "escalated", "done", "failed"}
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -1 +0,0 @@
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T15:40:06.242Z"}
@@ -1,14 +0,0 @@
{
"schemaVersion": "mimosa-hook-status/v1",
"recordedAt": "2026-09-01T14:21:11.564Z",
"sessionId": "sess_e50d4f25-3ac6-43f2-b2f3-8833b3150465",
"event": "PostToolUse",
"toolName": "Edit",
"file": "src/views/AgentView.vue",
"outcome": "clear",
"coverage": "complete",
"findingCount": 0,
"durationMs": 7,
"hostState": "hook_complete",
"reportHint": ".mimosa/reports/"
}
-5
View File
@@ -1,5 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>端云协同 LLM 协作系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-1648
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"name": "webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.4.0",
"axios": "^1.20.0",
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

@@ -1 +0,0 @@
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T13:44:31.436Z"}
-159
View File
@@ -1,159 +0,0 @@
<template>
<div class="app">
<!-- 侧边导航dsh 风格近白 + 透明描边 -->
<aside class="side">
<div class="brand">
<div class="brand-logo">🤖</div>
<div class="brand-text">
<b>端云协同</b>
<span>LLM 协作系统</span>
</div>
</div>
<nav class="nav">
<router-link v-for="item in NAV" :key="item.to" :to="item.to" class="nav-item">
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
<div class="side-foot">
<div class="foot-dot" />
<span>本地网关 · :8000</span>
</div>
</aside>
<!-- 内容区 -->
<div class="main-col">
<router-view class="content" />
</div>
</div>
</template>
<script setup lang="ts">
// App.vue deepseek-harness sidebar-fill +
const NAV = [
{ to: '/chat', icon: '💬', label: '对话' },
{ to: '/collaboration', icon: '🔄', label: '协作过程' },
{ to: '/agent', icon: '🤖', label: '智能体' },
{ to: '/review', icon: '🔍', label: '人工检验' },
{ to: '/metrics', icon: '📊', label: '指标' },
{ to: '/settings', icon: '⚙️', label: '设置' },
]
</script>
<style>
.app {
display: flex;
height: 100%;
}
/* ---------- 侧边栏(dshspecific-sidebar-fill 近白) ---------- */
.side {
width: 208px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--c-side-bg);
border-right: 1px solid var(--c-side-border);
color: var(--c-side-text);
padding: 18px 12px 14px;
user-select: none;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 16px;
margin-bottom: 12px;
}
.brand-logo {
width: 38px;
height: 38px;
display: grid;
place-items: center;
font-size: 20px;
background: linear-gradient(135deg, var(--ds-blue-400), var(--ds-blue-500));
border-radius: 11px;
box-shadow: 0 3px 10px rgba(65, 118, 230, 0.35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.3; }
.brand-text b { color: var(--c-text); font-size: 15px; letter-spacing: 0.5px; }
.brand-text span { font-size: 11px; color: var(--c-caption); }
.nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 8px 10px;
border-radius: 9px;
text-decoration: none;
color: var(--c-side-text);
font-size: 13.5px;
font-weight: 500;
position: relative;
transition: background 0.15s var(--ds-ease), color 0.15s var(--ds-ease);
}
.nav-item:hover { background: var(--c-side-hover); color: var(--c-text); }
.nav-item.router-link-active {
background: var(--c-side-active);
color: var(--c-side-text-active);
font-weight: 600;
}
.nav-item.router-link-active::before {
content: '';
position: absolute;
left: -12px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--c-accent);
}
.nav-icon {
width: 26px;
height: 26px;
display: grid;
place-items: center;
font-size: 15px;
background: var(--c-hover);
border-radius: 7px;
}
.nav-item.router-link-active .nav-icon { background: var(--c-primary-soft); }
.side-foot {
display: flex;
align-items: center;
gap: 7px;
padding: 12px 8px 2px;
font-size: 11px;
color: var(--c-caption);
}
.foot-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-ok);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.6);
}
/* ---------- 内容区 ---------- */
.main-col {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--c-surface);
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
</style>
-559
View File
@@ -1,559 +0,0 @@
import axios from 'axios'
import type {
ChatInitResponse,
RunStatus,
SSEEvent,
ReviewItem,
Metrics,
} from '@/types'
const http = axios.create({ timeout: 10_000 })
// ── Chat ─────────────────────────────────────────────────────────────────────
/** POST /chat:异步提交,立即返回 request_id */
export async function chat(query: string, domain_group?: string) {
const { data } = await http.post<ChatInitResponse>('/chat', { query, domain_group })
return data
}
/** GET /runs/{id}/status:查询任务状态 */
export async function getRunStatus(requestId: string) {
const { data } = await http.get<RunStatus>(`/runs/${requestId}/status`)
return data
}
/** GET /runs/{id}/workspace:获取交流文本全文 */
export async function getWorkspace(requestId: string) {
const { data } = await http.get(`/runs/${requestId}/workspace`)
return data
}
/** SSE /runs/{id}/stream:订阅实时协作事件 */
export function watchRun(requestId: string) {
const es = new EventSource(`/runs/${requestId}/stream`)
return {
/** 监听器会在组件卸载时自动断开,调用者只需提供 onXxx 回调 */
subscribe(opts: {
onWorkspace: (ws: import('@/types').Workspace) => void
onStatus: (s: string) => void
onError: (detail: string) => void
onDone: () => void
}) {
es.addEventListener('message', (ev) => {
const d: SSEEvent = JSON.parse(ev.data)
if (d.type === 'workspace') opts.onWorkspace(d.workspace)
else if (d.type === 'status') {
opts.onStatus(d.value)
if (d.value === 'done' || d.value === 'failed') opts.onDone()
}
else if (d.type === 'error') opts.onError(d.detail)
})
es.addEventListener('error', () => {
// EventSource 自己会重连,这里只记录
})
},
close() { es.close() },
}
}
// ── 人工检验 ─────────────────────────────────────────────────────────────────
export async function listReviews(status?: string) {
const params = status ? { status } : {}
const { data } = await http.get<ReviewItem[]>('/review/queue', { params })
return data
}
export async function submitReview(reviewId: number, verdict: string, correction?: string) {
const { data } = await http.post(`/review/${reviewId}`, null, {
params: { verdict, correction },
})
return data
}
// ── 指标 ─────────────────────────────────────────────────────────────────────
export async function getMetrics() {
const { data } = await http.get<Metrics>('/api/metrics')
return data
}
// ── 模型设置 ─────────────────────────────────────────────────────────────────
export interface ModelSettings {
worker: {
backend: string
model: string
base_url: string
port: number
temperature: number
max_fix_attempts: number
code_timeout_s: number
}
architect: {
model: string
base_url: string
api_key: string
/** 服务端打码标志:true 表示已存 key(返回值只含前 6 位,保存时留空即保留) */
api_key_set?: boolean
}
pipeline: {
fast_path: boolean
rounds_cap: number
api_token_cap: number
breach_policy: string
}
}
/** GET /config:读取当前模型设置 */
export async function getConfig() {
const { data } = await http.get<ModelSettings>('/config')
return data
}
/** PUT /config:更新模型设置(部分更新) */
export async function updateConfig(patch: Partial<ModelSettings>) {
const { data } = await http.put<ModelSettings>('/config', patch)
return data
}
/** POST /config/reset:恢复默认设置 */
export async function resetConfig() {
const { data } = await http.post<ModelSettings>('/config/reset')
return data
}
// ── 模型发现 & 连接验证 ───────────────────────────────────────────────────────
export interface ModelInfo {
id: string
name: string
}
/**
* GET /config/models?backend=llama_server&base_url=...&api_key=...
* { models: ModelInfo[] } { error: string }
*/
export async function listModels(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(
'/config/models', { params },
)
return data
}
/**
* GET /config/ping?backend=...&base_url=...&api_key=...
* { ok: boolean, detail?: string }
*/
export async function pingBackend(
backend: string,
base_url: string,
api_key: string,
) {
const params: Record<string, string> = { backend }
if (base_url) params.base_url = base_url
if (api_key) params.api_key = api_key
const { data } = await http.get<{ ok: boolean; detail?: string; status_code?: number }>(
'/config/ping', { params },
)
return data
}
// ── llama-server 内置管理 ─────────────────────────────────────────────────────
export interface LlamaStatus {
running: boolean
pid?: number | null
model?: string | null
port?: number | null
base_url?: string | null
started_at?: number | null
error?: string | null
}
export interface LocalModel {
id: string
name: string
size_mb: number
path: string
}
export interface DownloadProgress {
url: string
dest: string
downloaded_bytes: number
total_bytes?: number | null
progress_pct: number
speed: string
eta: string
done: boolean
error?: string | null
}
/** GET /llama/status */
export async function getLlamaStatus() {
const { data } = await http.get<LlamaStatus>('/llama/status')
return data
}
/** GET /llama/models */
export async function listLocalModels() {
const { data } = await http.get<{ models: LocalModel[] }>('/llama/models')
return data.models
}
/** POST /llama/start */
export async function startLlama(model: string, port = 8901, ngl = 99, ctx = 4096) {
const { data } = await http.post<LlamaStatus>('/llama/start', null, {
params: { model, port, ngl, ctx },
})
return data
}
/** POST /llama/stop */
export async function stopLlama() {
const { data } = await http.post<{ running: boolean }>('/llama/stop')
return data
}
/** POST /llama/download */
export async function downloadModel(url: string, dest?: string) {
const params: Record<string, string> = { url }
if (dest) params.dest = dest
const { data } = await http.post<DownloadProgress>('/llama/download', null, { params })
return data
}
/** SSE /llama/download/stream?url=... */
export function watchDownload(url: string) {
const es = new EventSource(`/llama/download/stream?url=${encodeURIComponent(url)}`)
return {
subscribe(opts: { onProgress: (p: DownloadProgress) => void; onDone: (p: DownloadProgress) => void }) {
es.addEventListener('message', (ev) => {
const p: DownloadProgress = JSON.parse(ev.data)
opts.onProgress(p)
if (p.done) opts.onDone(p)
})
es.addEventListener('error', () => { es.close() })
},
close() { es.close() },
}
}
// ── 模型池(多价位异构模型) ─────────────────────────────────────────────────
export type PoolTier = 'local' | 'budget' | 'premium'
export type PoolRole = 'architect' | 'worker' | 'agent'
export interface PoolEntry {
id: string
name: string
tier: PoolTier
backend: 'mock' | 'llama_server' | 'openai'
base_url: string
model: string
api_key: string
api_key_set: boolean
price_in: number
price_out: number
temperature: number
max_tokens: number
enabled: boolean
}
export interface PoolData {
roles: Record<PoolRole, string>
entries: PoolEntry[]
}
/** GET /pool:读取模型池(api_key 打码) */
export async function getPool() {
const { data } = await http.get<PoolData>('/pool')
return data
}
/** POST /pool:新增或更新条目(api_key 留空 = 保留原值) */
export async function upsertPoolEntry(entry: Partial<PoolEntry>) {
const { data } = await http.post<PoolData>('/pool', entry)
return data
}
/** DELETE /pool/{id} */
export async function deletePoolEntry(id: string) {
const { data } = await http.delete<PoolData>(`/pool/${id}`)
return data
}
/** PUT /pool/roles:指派角色(空串 = 经典设置) */
export async function setPoolRoles(roles: Partial<Record<PoolRole, string>>) {
const { data } = await http.put<PoolData>('/pool/roles', roles)
return data
}
/** POST /pool/{id}/test:条目连通性测试 */
export async function testPoolEntry(id: string) {
const { data } = await http.post<{ ok: boolean; detail?: string }>(`/pool/${id}/test`)
return data
}
/** GET /pool/{id}/models:条目端点下的可用模型列表 */
export async function listPoolModels(id: string) {
const { data } = await http.get<{ models?: ModelInfo[]; error?: string }>(`/pool/${id}/models`)
return data
}
// ── 智能体(工具调用) ───────────────────────────────────────────────────────
export interface AgentEvent {
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message'
| 'approval_request' | 'approval_decided' | 'delta'
ts?: number
round?: number
name?: string
arguments?: Record<string, unknown>
ok?: boolean
preview?: string
prompt_tokens?: number
completion_tokens?: number
reason?: string
error?: string
// 两级模式(D7
phase?: 'plan' | 'execute' | 'review'
handoff?: number
model?: string
role?: 'planner' | 'executor'
content?: string
// 审批(D9/ 流式(D10
id?: string
policy?: string
allowed?: boolean
note?: string
text?: string
}
/** POST /agent/{id}/approve:裁决待审批操作(dsh 式 allow-once / deny */
export async function approveAgent(requestId: string, approvalId: string, allowed: boolean) {
const { data } = await http.post<{ ok: boolean }>(`/agent/${requestId}/approve`,
{ approval_id: approvalId, allowed })
return data
}
export interface AgentStatus {
request_id: string
task: string
model: string
state: 'running' | 'done' | 'failed'
response: string
rounds: number
prompt_tokens: number
completion_tokens: number
error?: string | null
started_at?: number
finished_at?: number
workspace?: string
executor_model?: string
mode?: 'single' | 'dual'
tool_calls?: number
}
/** POST /agent:提交智能体任务(sessionId 可选:会话式多轮) */
export async function startAgent(
task: string, poolId?: string, workspace?: string,
executorPoolId?: string, sessionId?: string,
) {
const { data } = await http.post<{
request_id: string; status: string; model: string
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
session_id?: string | null
}>('/agent', {
task,
pool_id: poolId || undefined,
workspace: workspace || undefined,
executor_pool_id: executorPoolId || undefined,
session_id: sessionId || undefined,
})
return data
}
/** POST /agent/{id}/cancel:停止运行中的智能体任务 */
export async function cancelAgent(requestId: string) {
const { data } = await http.post<{ ok: boolean; detail?: string }>(
`/agent/${requestId}/cancel`)
return data
}
// ── 智能体会话(dsh 式多轮) ─────────────────────────────────────────────────
export interface AgentSessionBrief {
id: string
title: string
workspace?: string
created_at?: number
updated_at?: number
busy?: boolean
pool_id?: string
executor_pool_id?: string
turns: number | unknown[]
}
export interface AgentSessionDetail extends AgentSessionBrief {
turns: {
request_id: string
task: string
response: string
state: string
tool_calls: number
tokens: number
error?: string | null
ts?: number
}[]
}
/** POST /agent/sessions:创建会话 */
export async function createAgentSession(workspace?: string, executorPoolId?: string, title?: string) {
const { data } = await http.post<AgentSessionDetail>('/agent/sessions', {
title: title || undefined,
workspace: workspace || undefined,
executor_pool_id: executorPoolId || undefined,
})
return data
}
/** GET /agent/sessions:会话列表 */
export async function listAgentSessions() {
const { data } = await http.get<AgentSessionBrief[]>('/agent/sessions')
return data
}
/** GET /agent/sessions/{sid}:会话详情(含轮次) */
export async function getAgentSession(sid: string) {
const { data } = await http.get<AgentSessionDetail>(`/agent/sessions/${sid}`)
return data
}
/** DELETE /agent/sessions/{sid}:删除会话 */
export async function deleteAgentSession(sid: string) {
const { data } = await http.delete<{ ok: boolean }>(`/agent/sessions/${sid}`)
return data
}
/** PATCH /agent/sessions/{sid}:重命名会话 */
export async function renameAgentSession(sid: string, title: string) {
const { data } = await http.patch<AgentSessionDetail>(`/agent/sessions/${sid}`, {
title,
})
return data
}
/** GET /agent/{id}/status */
export async function getAgentStatus(requestId: string) {
const { data } = await http.get<AgentStatus>(`/agent/${requestId}/status`)
return data
}
/** GET /agent/{id}/events:完整事件列表(刷新恢复用) */
export async function getAgentEvents(requestId: string) {
const { data } = await http.get<AgentEvent[]>(`/agent/${requestId}/events`)
return data
}
/** GET /agent/workspace?path=&root=:浏览工作区目录(root 可指定选中工作区) */
export async function listAgentWorkspace(path = '', root = '') {
const { data } = await http.get<{
ok: boolean
entries?: { name: string; type: string; size?: number }[]
error?: string
}>('/agent/workspace', { params: { ...(path ? { path } : {}), ...(root ? { root } : {}) } })
return data
}
/** GET /agent/file?path=&root=:读取工作区文件 */
export async function readAgentFile(path: string, root = '') {
const { data } = await http.get<{ ok: boolean; content: string; truncated: boolean; error?: string }>(
'/agent/file', { params: { path, ...(root ? { root } : {}) } })
return data
}
// ── 智能体:工作区选择(参考 deepseek-harness 的打开文件夹体验) ─────────────
export interface FsBrowse {
ok: boolean
path: string
parent: string
dirs: string[]
error?: string
}
export interface WorkspacesInfo {
current: string
recent: string[]
}
/** GET /agent/fs?path=:浏览本地目录(目录选择器,只列子目录) */
export async function browseFs(path = '') {
const { data } = await http.get<FsBrowse>('/agent/fs', { params: path ? { path } : {} })
return data
}
/** GET /agent/workspaces:当前工作区 + 最近列表 */
export async function getAgentWorkspaces() {
const { data } = await http.get<WorkspacesInfo>('/agent/workspaces')
return data
}
/** POST /agent/workspaces:打开(或新建)工作目录并设为当前 */
export async function openWorkspace(path: string, create = false) {
const { data } = await http.post<{ ok: boolean; current: string; recent: string[] }>(
'/agent/workspaces', { path, create })
return data
}
/** SSE /agent/{id}/stream:订阅智能体过程事件 */
export function watchAgent(requestId: string) {
const es = new EventSource(`/agent/${requestId}/stream`)
let finalSeen = false
return {
subscribe(opts: {
onEvent: (ev: AgentEvent) => void
onDone: () => void
}) {
es.addEventListener('message', (ev) => {
if (finalSeen) return // final 后的自动重连回放,忽略
const d: AgentEvent = JSON.parse(ev.data)
opts.onEvent(d)
if (d.type === 'final') {
finalSeen = true
es.close() // 终态即关闭,防止重连回放重复渲染
opts.onDone()
}
})
es.addEventListener('error', () => {
// 连接中断由浏览器自动重连;final 已 seen 时回放会被上方拦截
})
},
close() { es.close() },
}
}
// ── 辅助:轮询直到完成(用于不需要 SSE 的场景)──────────────────────────────────
export async function pollUntilDone(
requestId: string,
{ timeout = 60_000, interval = 500 }: { timeout?: number; interval?: number } = {},
) {
const t0 = Date.now()
while (Date.now() - t0 < timeout) {
const s = await getRunStatus(requestId)
if (s.status === 'done' || s.status === 'failed') return s
await new Promise((r) => setTimeout(r, interval))
}
throw new Error('poll timeout')
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

-95
View File
@@ -1,95 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import heroImg from '../assets/hero.png'
import viteLogo from '../assets/vite.svg'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
-47
View File
@@ -1,47 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import ChatView from '@/views/ChatView.vue'
import AgentView from '@/views/AgentView.vue'
// Vite 构建时 base=/static/,但 FastAPI 在 /metrics 等路径提供 SPA(不在 /static/ 下),
// 所以 history 固定用 '/',避免 Vue Router 把 /metrics 当作 /static/metrics 解析导致路由不匹配。
const router = createRouter({
history: createWebHistory('/'),
routes: [
{
path: '/',
redirect: '/chat',
},
{
path: '/chat',
name: 'chat',
component: ChatView,
},
{
path: '/collaboration',
name: 'collaboration',
component: () => import('@/views/CollaborationView.vue'),
},
{
path: '/agent',
name: 'agent',
component: AgentView,
},
{
path: '/review',
name: 'review',
component: () => import('@/views/ReviewView.vue'),
},
{
path: '/metrics',
name: 'metrics',
component: () => import('@/views/MetricsView.vue'),
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
],
})
export default router

Some files were not shown because too many files have changed in this diff Show More