Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fa8c3c81 | ||
|
|
e9cfb29b75 | ||
|
|
8d77c8c0c0 | ||
|
|
747d85c3ba | ||
|
|
ce3ba44de3 | ||
|
|
ddef8bec1c | ||
|
|
ca8add02e2 | ||
|
|
7d11ae2644 | ||
|
|
943eecc5ef | ||
|
|
1ae9ffb159 | ||
|
|
43e2bceae7 | ||
|
|
8e2123343c | ||
|
|
b08e0bb5d7 | ||
|
|
da5dfb0ef3 | ||
|
|
8358302002 | ||
|
|
10bd4cc71d | ||
|
|
501058243b | ||
|
|
374dc765c2 | ||
|
|
6247e053c1 | ||
|
|
3bbdcb7cc7 | ||
|
|
8d36eeec59 | ||
|
|
5ba3cc778b | ||
|
|
0ec7a9d034 | ||
|
|
b900082c6d | ||
|
|
70b9907a89 | ||
|
|
ff7e1bc8de | ||
|
|
c874382130 | ||
|
|
ea150129b4 | ||
|
|
ad81e1afb7 | ||
|
|
4fbbdb5290 | ||
|
|
b3143e2914 | ||
|
|
3ed37a1d30 | ||
|
|
78a410b773 |
@@ -12,16 +12,29 @@ htmlcov/
|
|||||||
.env
|
.env
|
||||||
*.env
|
*.env
|
||||||
api_keys*.json
|
api_keys*.json
|
||||||
|
config/settings.json
|
||||||
|
|
||||||
# Models / data
|
# Models / data / runtime
|
||||||
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/
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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 11,shell 为 Git Bash;Python venv 在 `.venv`(Python 3.14)。
|
||||||
|
- 测试:`.venv/Scripts/python.exe -m pytest tests -q`(基线 126 passed,2026-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 转向依据) |
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 分支:v2-coding-agent — 端云协同编程智能体(第二代)
|
||||||
|
|
||||||
|
> **快照点**:`747d85c`(v2 核心 + v3 Web 应用化 + v4 模型池与工具智能体全部完成、测试全绿时点)。
|
||||||
|
> 历史路标,冻结不再演进;集成主线见 `master`。
|
||||||
|
|
||||||
|
## 这一代是什么
|
||||||
|
|
||||||
|
**命题**:《基于端云协同的编程智能体系统设计与实现》——大模型(API)任务分析/决策/终审 +
|
||||||
|
小模型(本地 llama.cpp)实现/自验证 +「交流文本」结构化共享工作区 + 人工检验队列。
|
||||||
|
|
||||||
|
- **v2 核心**:`Workspace` 交流文本协议(schema/锚点/rollup/双渲染)→ `ArchitectClient`
|
||||||
|
(brief/decide/final_review,JSON 约束)→ `WorkerLoop`(工具化实现+接地验证+自修≤2)
|
||||||
|
→ `CollaborativePipeline`(快路径/协作循环/双护栏熔断/终审)+ 运维层(llama-server 进程管理、
|
||||||
|
三档硬件模板)+ `ReviewQueue` 人工检验 + 打包分发
|
||||||
|
- **v3 Web 应用化**:异步任务 + SSE 实时协作可视化 + Vue3 SPA(对话/协作过程/检验/指标)+ 网关安全加固
|
||||||
|
- **v4 增补**:多价位模型池(local/budget/premium 角色指派)+ 工具智能体(harness 级工具/工作区选择/
|
||||||
|
两级智能体/审批流/token 级流式)
|
||||||
|
- v1 保留为 legacy(`POST /chat/legacy`),离线降级可用
|
||||||
|
|
||||||
|
## 基线
|
||||||
|
|
||||||
|
测试 281 项全绿(T31 时点);E1 token 经济学实测:交流文本较全量上下文降 ~61%(含缓存计费)。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
`实现方案_v2_端云协同编程智能体系统.md`、`实现方案_v3_Web应用化.md`、
|
||||||
|
`实现方案_v4_模型池与工具智能体.md`、`毕业设计_进度记录.md`
|
||||||
|
|
||||||
|
## 与其他分支的关系
|
||||||
|
|
||||||
|
- 第一代(规则路由)以 legacy 形式包含在本快照内
|
||||||
|
- 第三代(校园缓存代理层)在本快照之后的 master 上演进 → 见 `campus-cache-proxy` 分支
|
||||||
@@ -1,125 +1,147 @@
|
|||||||
# 多专业小模型 + 路由模型系统(MVP)
|
# 端云协同 LLM 协作系统(v2)
|
||||||
|
|
||||||
用「轻量分类路由器 + 专业小模型池 + 质量控制器(Judge) + 大模型回退」在限定条件下替代单一通用大模型,
|
大模型(API,Architect)做任务分析/决策/终审 + 本地小模型(llama.cpp,Worker)做实现/自验证,
|
||||||
实现 **成本降低 80%+、延迟可控** 的目标。本仓库是《实现方案_多专业小模型+路由模型.md》的第一阶段落地。
|
两者通过**「交流文本」**(一份 schema 约束的结构化 JSON 共享工作区)交接,互不共享内部状态,
|
||||||
|
另有人工检验队列作为第三协作者。目标是:在端到端质量不降的前提下,把大模型 API token 消耗相对
|
||||||
|
「全量上下文」方案下降 **≥80%**(北极星指标)。
|
||||||
|
|
||||||
## ✨ 当前能力(2026-08-12 已跑通)
|
> v1 的 L0 专家系统内核保留为 **legacy 路由**(`POST /chat/legacy`)与离线降级模式,不删除;126 项 v1 测试保持全绿。
|
||||||
|
|
||||||
- ✅ 零依赖 mock 全链路可运行:缓存 → 分类 → 专家 → Judge → 回退
|
---
|
||||||
- ✅ 5 领域意图分类(code / math / legal / medical / general),规则分类器准确率 **100%**(15 条评测样例)
|
|
||||||
- ✅ 两阶段缓存(L1 精确 + L2 语义 n-gram,零依赖),评测缓存命中率 **40%**
|
|
||||||
- ✅ 质量控制器(Judge)自动评估输出并触发升级,升级率 **20%**(命中第二阶段验收线)
|
|
||||||
- ✅ FastAPI 网关:`/chat` `/health` `/metrics`,20 项单元测试全部通过
|
|
||||||
- ✅ 可选接入真实模型:HuggingFace 小模型(`type: hf`)或 OpenAI 兼容 API(`type: api`)
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## ✨ 当前能力(2026-08-30 已落地)
|
||||||
|
|
||||||
```powershell
|
- ✅ **交流文本协议**(`router_system/workspace.py`):schema 校验、锚点寻址(`a://file#L12-18`)、rollup 压缩、双渲染函数(Architect ≤1200 token / Worker ≤8K token)、状态机、前缀稳定性(T10)
|
||||||
# 1. 创建虚拟环境并安装依赖(核心 router_system 零依赖,网关/测试需要轻量依赖)
|
- ✅ **ArchitectClient**(`architect.py`):DeepSeek JSON 约束输出,失败回喂重写一次,token 计量,预算熔断
|
||||||
C:\Python314\python.exe -m venv .venv
|
- ✅ **WorkerLoop + 接地验证**(`worker.py` / `verifier.py`):实现→自验证(代码沙箱 > facts 对照 > 结构检查)→自修≤2→issue
|
||||||
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
- ✅ **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.html,FastAPI 托管,无构建):对话 / 协作过程(交流文本可视化)/ 人工检验 / 指标 四视图
|
||||||
|
- ✅ **测试**:**229 项全绿**(含 v1 legacy 126 项 + v2 新模块)
|
||||||
|
|
||||||
# 2. 运行演示(mock 模式,离线可跑)
|
---
|
||||||
.venv\Scripts\python.exe scripts/demo.py
|
|
||||||
|
|
||||||
# 3. 迷你评估(分类准确率 / 升级率 / 缓存命中率)
|
|
||||||
.venv\Scripts\python.exe scripts/eval.py --repeat 2
|
|
||||||
|
|
||||||
# 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 --stop
|
|
||||||
|
|
||||||
# 6. 调用接口
|
|
||||||
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 http://127.0.0.1:8000/metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ 架构
|
## 🏗️ 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
用户查询
|
用户 query
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────┐ ┌──────────────────┐
|
[快路径] Worker 直答 + 自验证通过?──是──▶ 直接返回(省 API 钱)
|
||||||
│ RouterCache 缓存 │───▶│ 命中 → 直接返回 │
|
│ 否
|
||||||
│ (L1精确 / L2语义) │ └──────────────────┘
|
|
||||||
└─────────┬─────────┘
|
|
||||||
▼ 未命中
|
|
||||||
┌───────────────────┐ 低置信度(<0.60) ┌──────────────────┐
|
|
||||||
│ 分类路由器 │ ───────────────▶ │ 大模型回退 │
|
|
||||||
│ RuleClassifier / │ │ Mock / DeepSeek │
|
|
||||||
│ HuggingFace │ └──────────────────┘
|
|
||||||
└─────────┬─────────┘
|
|
||||||
▼ 高置信度
|
|
||||||
┌───────────────────┐
|
|
||||||
│ 专家模型池 │ code/math/legal/medical/general
|
|
||||||
│ Mock / HF / API │
|
|
||||||
└─────────┬─────────┘
|
|
||||||
▼
|
▼
|
||||||
┌───────────────────┐ 质量分<0.70 ┌──────────────────┐
|
[Architect·API·一次] brief(goal/constraints/acceptance/plan/tags)写入 交流文本
|
||||||
│ Judge 质量控制器 │ ────────────▶ │ 升级大模型回退 │
|
│
|
||||||
│ Rule / LLM-as-Judge│ └──────────────────┘
|
▼
|
||||||
└───────────────────┘
|
┌─────────── 协作循环(护栏: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 压缩摘要),便宜的一方(本地)多读多干。
|
||||||
|
|
||||||
```
|
---
|
||||||
cache:miss -> classify:code@0.95/hard -> expert:expert-code -> judge:0.96
|
|
||||||
cache:miss -> classify:general@0.50/easy -> direct_fallback
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 虚拟环境 + 依赖
|
||||||
|
C:\Python314\python.exe -m venv .venv
|
||||||
|
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. 跑测试(v1 legacy 126 + v2 新模块 = 228)
|
||||||
|
.venv\Scripts\python.exe -m pytest tests -q
|
||||||
|
|
||||||
|
# 3. (可选)准备本地运行时:下载 llama-server 二进制 + GGUF 模型
|
||||||
|
.venv\Scripts\python.exe scripts/setup_runtime.py
|
||||||
|
|
||||||
|
# 4. 启动网关(v2 /chat 默认走 mock worker,无需 API key/模型即可演示)
|
||||||
|
.venv\Scripts\python.exe scripts/serve.py --port 8000
|
||||||
|
# 浏览器打开 http://127.0.0.1:8000/ 使用 Web 界面(对话 / 协作过程 / 人工检验 / 指标)
|
||||||
|
|
||||||
|
# ⚠️ 安全默认值(T30):网关默认只绑定 127.0.0.1 且只信任本机 Host
|
||||||
|
# (网关能读写工作区文件/执行命令,不宜默认暴露局域网)。
|
||||||
|
# 如需局域网访问:--host 0.0.0.0 并设置环境变量 GATEWAY_TRUSTED_HOSTS
|
||||||
|
# 放行对应主机名("*" = 放行全部,仅限可信网络)。
|
||||||
|
|
||||||
|
# 5. 调用
|
||||||
|
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/legacy -H "Content-Type: application/json" -d '{"query":"基金定投的收益率怎么计算"}'
|
||||||
|
curl http://127.0.0.1:8000/metrics
|
||||||
|
|
||||||
|
# 6. E1 token 经济学实验(本地确定性测量)
|
||||||
|
.venv\Scripts\python.exe scripts/bench_tokens.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ 配置(config/config.yaml)
|
> 无 API key / 无本地模型时,`/chat` 走快路径(mock Worker)或本地降级,不崩溃。
|
||||||
|
|
||||||
默认全 mock(零依赖离线)。接入真实模型只需改 `type`:
|
---
|
||||||
|
|
||||||
| 组件 | 当前 | 可切换 | 说明 |
|
## ⚙️ 配置(config/config.yaml v2 段)
|
||||||
|------|------|--------|------|
|
|
||||||
| 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` 环境变量 |
|
|
||||||
|
|
||||||
关键阈值:
|
| 段 | 关键项 | 说明 |
|
||||||
- `low_confidence_threshold: 0.60` —— 分类置信度低于此值直接走大模型
|
|----|--------|------|
|
||||||
- `judge_fallback_threshold: 0.70` —— Judge 质量分低于此值升级大模型
|
| `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/ # 核心(零依赖纯标准库)
|
├── router_system/
|
||||||
│ ├── classifier.py # 意图分类器(规则 / HF)
|
│ ├── workspace.py ★ 交流文本协议(核心)
|
||||||
│ ├── difficulty.py # 难度估计
|
│ ├── architect.py 大模型客户端(brief/decide/final_review)
|
||||||
│ ├── experts.py # 专家池(Mock / HF / API)
|
│ ├── worker.py 小模型实现/自验证循环
|
||||||
│ ├── judge.py # 质量控制器
|
│ ├── verifier.py 接地验证(代码沙箱/facts/结构)
|
||||||
│ ├── fallback.py # 大模型回退
|
│ ├── pipeline.py 协作管线编排
|
||||||
│ ├── cache.py # 两阶段缓存
|
│ ├── review.py 人工检验队列
|
||||||
│ ├── router.py # 主路由
|
│ └── v2stats.py token 计量与聚合
|
||||||
│ └── stats.py # 指标
|
├── runtime/ 运维层(hw_profile / llama_server 进程管理)
|
||||||
├── gateway/api.py # FastAPI 网关
|
├── gateway/api.py v2 + v1 legacy 端点
|
||||||
├── scripts/ # demo / eval / serve / train_classifier
|
├── scripts/
|
||||||
├── tests/ # 20 项单元测试
|
│ ├── setup_runtime.py 下载运行时/模型
|
||||||
├── config/config.yaml # 配置
|
│ └── bench_tokens.py E1 实验
|
||||||
└── research/ # 论文调研
|
├── eval/v2_sample.json 实验数据集
|
||||||
|
└── research/v2_experiments/ E1 结果与实验规划
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 验收指标对照(实现方案 5.1/5.2)
|
---
|
||||||
|
|
||||||
| 指标 | 目标 | 当前(mock 评测) |
|
## 📊 指标(v2)
|
||||||
|------|------|------------------|
|
|
||||||
| 分类准确率 | ≥95%(正式) | 100%(15 条样例) |
|
|
||||||
| 升级率(fallback rate) | ≤20% | 20% |
|
|
||||||
| 缓存命中率 | ≥30% | 40% |
|
|
||||||
| 端到端延迟 | < 大模型 1.5× | mock 下 ~10-16ms |
|
|
||||||
|
|
||||||
## 🔜 下一步(对照实现方案)
|
| 指标 | 值 | 说明 |
|
||||||
|
|------|----|------|
|
||||||
|
| 测试 | **228 passed** | v1 legacy 126 + v2 新模块 |
|
||||||
|
| E1 token 下降(A2 交流文本 vs A1 全量) | **~61%**(本地确定性测量) | 真实缓存计费下目标 ≥80%(`--live` 待确认) |
|
||||||
|
| 稳定前缀可命中 | ~99% 的 A2 输入 | 配合 `--cache-reuse` 进一步降成本 |
|
||||||
|
| 快路径 / 熔断 / 回合 | /metrics v2 统计 | V2Stats 实时聚合 |
|
||||||
|
|
||||||
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 轻量方案)
|
1. **`--live` 接入真实链路**:安装 runtime(`setup_runtime.py`)+ 设置 `DEEPSEEK_API_KEY`,跑 E1 主实验确认 ≥80% 北极星(`scripts/bench_tokens.py --live`)。
|
||||||
|
2. **E2–E5 跑数**:端到端质量 / 协作健康度 / KV 量化曲线 / 验证器 P/R(见 `research/v2_experiments/README.md`)。
|
||||||
|
3. 用 RouterArena 协议化评测 v2 端到端质量。
|
||||||
|
4. 写 v2 论文(`research/paper/`)。
|
||||||
|
|
||||||
|
## 📌 环境备忘(T1)
|
||||||
|
|
||||||
|
- Windows 11 + Git Bash;venv 在 `.venv`(Python 3.14)。
|
||||||
|
- 测试命令:`.venv/Scripts/python.exe -m pytest tests -q`。
|
||||||
|
- `bin/`、`models/`、`data/`、`runs/` 已 gitignore(运行时产物不入库)。
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ 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(零依赖)| hf(transformers)
|
type: rule # rule(零依赖)| hf(transformers)
|
||||||
model: Qwen/Qwen3-0.6B
|
model: Qwen/Qwen3-0.6B
|
||||||
@@ -23,17 +31,31 @@ 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 # mock | api(OpenAI 兼容,如 DeepSeek)
|
type: mock # none(降级模板)| mock | local(本地≤8B 按需加载)| api
|
||||||
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
|
||||||
@@ -47,3 +69,45 @@ 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 命中即强制送审
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{"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"}
|
||||||
|
]
|
||||||
@@ -0,0 +1,911 @@
|
|||||||
|
"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- OpenAICompatChat:OpenAI 兼容 /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 / 5xx(dsh 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 / deny,fail-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
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""异步后台任务注册表(T1:后端异步化核心)。
|
||||||
|
|
||||||
|
设计原则(对齐 v3 方案 D1-D6):
|
||||||
|
- asyncio 原生,无 Celery/Redis/外部队列
|
||||||
|
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
|
||||||
|
- 任务写 runs/{id}/workspace.json,SSE 生成器只读该文件(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.json,yield 单条事件。
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
- {"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
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""llama-server 进程管理 & 模型下载。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 启动/停止本地 llama-server 子进程(Windows 兼容)
|
||||||
|
- 探测已有 .gguf 模型文件
|
||||||
|
- 从 HuggingFace URL 下载模型(支持 huggingface.co 路径别名)
|
||||||
|
- 下载进度可通过 SSE /llama/download/stream 订阅
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from gateway.llama_manager import get_llama_manager
|
||||||
|
lm = get_llama_manager()
|
||||||
|
await lm.start(model="models/qwen3.5-4b-q4_k_m.gguf")
|
||||||
|
await lm.stop()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 路径配置(与 config.yaml runtime.llama_server 段保持一致)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent # E:\projectAIpopular
|
||||||
|
BIN_DIR = _ROOT / "bin"
|
||||||
|
MODELS_DIR = _ROOT / "models"
|
||||||
|
PID_FILE = _ROOT / "data" / "llama-server.pid"
|
||||||
|
LOG_FILE = _ROOT / "data" / "llama-server.log"
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 数据模型
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LlamaServerStatus:
|
||||||
|
running: bool
|
||||||
|
pid: Optional[int] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
started_at: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadProgress:
|
||||||
|
url: str
|
||||||
|
dest: str
|
||||||
|
total_bytes: Optional[int] = None
|
||||||
|
downloaded_bytes: int = 0
|
||||||
|
progress_pct: float = 0.0
|
||||||
|
speed: str = ""
|
||||||
|
eta: str = ""
|
||||||
|
done: bool = False
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# llama_manager 单例
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LlamaManager:
|
||||||
|
_instance: Optional["LlamaManager"] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._pid: Optional[int] = None
|
||||||
|
self._model: Optional[str] = None
|
||||||
|
self._port: Optional[int] = None
|
||||||
|
self._started_at: Optional[float] = None
|
||||||
|
self._downloading: dict[str, DownloadProgress] = {} # url -> progress
|
||||||
|
self._dl_lock = threading.Lock()
|
||||||
|
# 加载已有进程
|
||||||
|
self._load_pid()
|
||||||
|
|
||||||
|
# ── 进程持久化 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_pid(self) -> None:
|
||||||
|
"""从 pid 文件恢复进程引用(进程仍在运行时)。"""
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pid = int(PID_FILE.read_text().strip())
|
||||||
|
os.kill(pid, 0) # 检查进程是否存活
|
||||||
|
# 进程还在,尝试接管(通过 cmdline 判断是否是 llama-server)
|
||||||
|
self._pid = pid
|
||||||
|
self._proc = self._attach_to_process(pid)
|
||||||
|
except (ValueError, FileNotFoundError, OSError):
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _attach_to_process(self, pid: int) -> Optional[subprocess.Popen]:
|
||||||
|
"""通过 pid 重新关联到 Popen(仅作状态恢复,不拥有 stdout)。"""
|
||||||
|
try:
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
f"import os; os.kill({pid}, 0)"], # 存活检查
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_pid(self, pid: int) -> None:
|
||||||
|
PID_FILE.write_text(str(pid), encoding="utf-8")
|
||||||
|
|
||||||
|
def _clear_pid(self) -> None:
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# ── 进程管理 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_binary(self) -> Optional[Path]:
|
||||||
|
"""查找 llama-server 可执行文件。"""
|
||||||
|
candidates = [
|
||||||
|
BIN_DIR / "llama-server.exe",
|
||||||
|
BIN_DIR / "llama-server",
|
||||||
|
_ROOT / "llama-server.exe",
|
||||||
|
_ROOT / "llama-server",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
# PATH 中查找
|
||||||
|
import shutil
|
||||||
|
found = shutil.which("llama-server") or shutil.which("llama-server.exe")
|
||||||
|
if found:
|
||||||
|
return Path(found)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def status(self) -> LlamaServerStatus:
|
||||||
|
"""返回当前服务状态。"""
|
||||||
|
if self._proc is None or self._pid is None:
|
||||||
|
return LlamaServerStatus(running=False)
|
||||||
|
try:
|
||||||
|
# 检查进程是否存活
|
||||||
|
os.kill(self._pid, 0)
|
||||||
|
except OSError:
|
||||||
|
# 进程已死
|
||||||
|
self._proc = None
|
||||||
|
self._pid = None
|
||||||
|
self._model = None
|
||||||
|
self._port = None
|
||||||
|
self._started_at = None
|
||||||
|
self._clear_pid()
|
||||||
|
return LlamaServerStatus(running=False)
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=True,
|
||||||
|
pid=self._pid,
|
||||||
|
model=self._model,
|
||||||
|
port=self._port,
|
||||||
|
base_url=f"http://127.0.0.1:{self._port}/v1",
|
||||||
|
started_at=self._started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
port: int = 8901,
|
||||||
|
ngl: int = 99,
|
||||||
|
ctx: int = 4096,
|
||||||
|
extra_args: Optional[list] = None,
|
||||||
|
) -> LlamaServerStatus:
|
||||||
|
"""启动 llama-server,阻塞直到监听就绪或超时。"""
|
||||||
|
if self.status().running:
|
||||||
|
s = self.status()
|
||||||
|
if s.model == model and s.port == port:
|
||||||
|
return s # 已是同一模型,无需重启
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
binary = self.find_binary()
|
||||||
|
if binary is None:
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=False,
|
||||||
|
error="未找到 llama-server 可执行文件。"
|
||||||
|
"请将 llama-server.exe 放入 bin/ 目录,"
|
||||||
|
"或从 https://github.com/ggerganov/llama.cpp/releases 下载。",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = Path(model)
|
||||||
|
if not model_path.is_absolute():
|
||||||
|
model_path = MODELS_DIR / model
|
||||||
|
if not model_path.exists():
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=False,
|
||||||
|
error=f"模型文件不存在:{model_path}。"
|
||||||
|
"请先下载模型,或在设置页填写 HuggingFace URL 下载。",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
str(binary),
|
||||||
|
"-m", str(model_path),
|
||||||
|
"-c", str(ctx),
|
||||||
|
"-ngl", str(ngl),
|
||||||
|
"--port", str(port),
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
]
|
||||||
|
if extra_args:
|
||||||
|
args.extend(extra_args)
|
||||||
|
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_f = 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/https(file://、ftp:// 等一律拒绝)。
|
||||||
|
# 必须先于 HF 别名转换判定,否则 ftp:// 会被误拼成 HF 地址。
|
||||||
|
if "://" in url:
|
||||||
|
scheme = url.split("://", 1)[0].lower()
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
prog = DownloadProgress(url=url, dest=str(dest or ""),
|
||||||
|
error=f"仅允许 http/https 下载地址(收到 {scheme})")
|
||||||
|
return prog
|
||||||
|
|
||||||
|
# 路径别名转换
|
||||||
|
if not url.startswith("http"):
|
||||||
|
url = f"https://huggingface.co/{url}/resolve/main"
|
||||||
|
|
||||||
|
# 解析文件名
|
||||||
|
filename = url.rstrip("/").split("/")[-1]
|
||||||
|
if not filename.endswith(".gguf"):
|
||||||
|
filename += ".gguf"
|
||||||
|
|
||||||
|
if dest:
|
||||||
|
dest_path = Path(dest)
|
||||||
|
# 目标关押:自定义 dest 必须仍位于 models/ 目录内(防 ../ 越界写盘)
|
||||||
|
models_root = MODELS_DIR.resolve()
|
||||||
|
resolved = (models_root / dest_path).resolve() if not dest_path.is_absolute() \
|
||||||
|
else dest_path.resolve()
|
||||||
|
if resolved != models_root and models_root not in resolved.parents:
|
||||||
|
prog = DownloadProgress(url=url, dest=str(dest_path),
|
||||||
|
error=f"下载目标必须在 models/ 目录内: {dest}")
|
||||||
|
return prog
|
||||||
|
dest_path = resolved
|
||||||
|
else:
|
||||||
|
dest_path = MODELS_DIR / filename
|
||||||
|
|
||||||
|
# 构造 HTTP 头
|
||||||
|
headers = {}
|
||||||
|
resume_bytes = 0
|
||||||
|
if dest_path.exists():
|
||||||
|
resume_bytes = dest_path.stat().st_size
|
||||||
|
headers["Range"] = f"bytes={resume_bytes}-"
|
||||||
|
|
||||||
|
# 获取文件大小
|
||||||
|
total_bytes: Optional[int] = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0, read=60.0, write=30.0, pool=10.0), follow_redirects=True) as client:
|
||||||
|
head = await client.head(url, headers={"Range": "bytes=0-0"})
|
||||||
|
total_raw = head.headers.get("Content-Length")
|
||||||
|
if total_raw:
|
||||||
|
total_bytes = int(total_raw)
|
||||||
|
# Content-Range 响应时 total_bytes 在 Content-Range 头里
|
||||||
|
cr = head.headers.get("Content-Range", "")
|
||||||
|
m = re.search(r"/(\d+)", cr)
|
||||||
|
if m:
|
||||||
|
total_bytes = int(m.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
prog = DownloadProgress(
|
||||||
|
url=url,
|
||||||
|
dest=str(dest_path),
|
||||||
|
total_bytes=total_bytes,
|
||||||
|
downloaded_bytes=resume_bytes,
|
||||||
|
)
|
||||||
|
with self._dl_lock:
|
||||||
|
self._downloading[url] = prog
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode = "ab" if resume_bytes > 0 else "wb"
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(300.0, connect=10.0, read=300.0, write=30.0, pool=10.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
t0 = time.time()
|
||||||
|
last_bytes = resume_bytes
|
||||||
|
async with client.stream("GET", url, headers=headers) as resp:
|
||||||
|
if resp.status_code not in (200, 206):
|
||||||
|
raise RuntimeError(f"HTTP {resp.status_code}")
|
||||||
|
with open(dest_path, mode) as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
prog.downloaded_bytes += len(chunk)
|
||||||
|
|
||||||
|
# 速度 & ETA
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
if elapsed > 0.5:
|
||||||
|
speed_bps = (prog.downloaded_bytes - last_bytes) / elapsed
|
||||||
|
speed_str = _format_speed(speed_bps)
|
||||||
|
if prog.total_bytes and speed_bps > 0:
|
||||||
|
remain = prog.total_bytes - prog.downloaded_bytes
|
||||||
|
eta_s = remain / speed_bps
|
||||||
|
prog.eta = _format_eta(eta_s)
|
||||||
|
else:
|
||||||
|
prog.eta = ""
|
||||||
|
prog.speed = speed_str
|
||||||
|
last_bytes = prog.downloaded_bytes
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
if prog.total_bytes:
|
||||||
|
prog.progress_pct = min(prog.downloaded_bytes / prog.total_bytes * 100, 100)
|
||||||
|
except Exception as e:
|
||||||
|
prog.error = str(e)
|
||||||
|
finally:
|
||||||
|
prog.done = True
|
||||||
|
with self._dl_lock:
|
||||||
|
self._downloading[url] = prog
|
||||||
|
|
||||||
|
return prog
|
||||||
|
|
||||||
|
def get_download_progress(self, url: str) -> Optional[DownloadProgress]:
|
||||||
|
"""查询下载进度。"""
|
||||||
|
with self._dl_lock:
|
||||||
|
return self._downloading.get(url)
|
||||||
|
|
||||||
|
def list_downloads(self) -> list[DownloadProgress]:
|
||||||
|
"""列出所有活跃下载。"""
|
||||||
|
with self._dl_lock:
|
||||||
|
return list(self._downloading.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _format_speed(bps: float) -> str:
|
||||||
|
if bps >= 1e9:
|
||||||
|
return f"{bps/1e9:.1f} GB/s"
|
||||||
|
if bps >= 1e6:
|
||||||
|
return f"{bps/1e6:.1f} MB/s"
|
||||||
|
if bps >= 1e3:
|
||||||
|
return f"{bps/1e3:.1f} KB/s"
|
||||||
|
return f"{bps:.0f} B/s"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_eta(seconds: float) -> str:
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds:.0f}s"
|
||||||
|
if seconds < 3600:
|
||||||
|
return f"{seconds/60:.0f}m"
|
||||||
|
return f"{seconds/3600:.1f}h"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 全局单例
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_lm: Optional[LlamaManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llama_manager() -> LlamaManager:
|
||||||
|
global _lm
|
||||||
|
if _lm is None:
|
||||||
|
_lm = LlamaManager()
|
||||||
|
return _lm
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""模型池(PoolStore)—— 多价位异构模型注册表。
|
||||||
|
|
||||||
|
设计(《实现方案_v4_模型池与工具智能体.md》D1):
|
||||||
|
- 叙事从"端云分工"泛化为"按价位分工":local(零边际成本,内置 llama.cpp)、
|
||||||
|
budget(低价 API)、premium(高价 API)。位置只是价位的属性之一。
|
||||||
|
- 池条目存"端点 + 凭据 + 模型名 + 价位 + 单价($/1M tokens)",不存模型权重。
|
||||||
|
- roles 把池条目指派给三个角色:architect(决策/终审)、worker(实现/自验证)、
|
||||||
|
agent(智能体工具循环)。角色留空 = 沿用经典单模型设置(向后兼容)。
|
||||||
|
- 持久化到 config/model_pool.json(gitignore,与 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
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""可调整的运行设置(SettingsStore)—— 让用户自定义内置小模型 / 大模型 / 管线。
|
||||||
|
|
||||||
|
用户可在 Web 界面"模型设置"里调整并持久化到 config/settings.json(gitignore),
|
||||||
|
重启后保留。调整会触发 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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
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};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
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};
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
@@ -0,0 +1,38 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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`
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""ArchitectClient —— 大模型(API)客户端,端云协同的"决策/终审"角色。
|
||||||
|
|
||||||
|
职责(对齐《实现方案_v2》5.1 / 6.3):
|
||||||
|
- brief(query):开局任务分析 -> 生成交流文本的 brief(goal/constraints/acceptance/plan/tags)
|
||||||
|
- decide(ws):读 issues 等 -> 输出裁决(reply + patch_plan 修订计划)
|
||||||
|
- final_review(ws):终审 -> {verdict: done|fix, issues: [...]}
|
||||||
|
|
||||||
|
工程约束(D7 / D8 / D9 / D11):
|
||||||
|
- 走 OpenAI 兼容 /chat/completions;response_format={"type":"json_object"},prompt 内嵌 schema 描述。
|
||||||
|
- Architect 输入永不包含工件全文:只传 Workspace 渲染出的 meta+issues+decisions+锚点片段(render_for_architect)。
|
||||||
|
- 所有结构化输出解析为 JSON;失败把错误回喂重写一次,仍失败抛 ArchitectError(由编排层降级,禁止带病继续)。
|
||||||
|
- token 计量回写 ws.meta.budget;调用前先查预算,触顶抛 ArchitectCircuitBreaker(D6)。
|
||||||
|
- httpx 惰性导入(零顶层依赖,对齐仓库既有 APIExpert 模式);client/transport 可注入,测试用 httpx.MockTransport(D11)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .workspace import Workspace
|
||||||
|
|
||||||
|
# brief 的 JSON schema(描述性提示,约束模型输出结构)
|
||||||
|
_BRIEF_SCHEMA_HINT = {
|
||||||
|
"goal": "string(<=500字)",
|
||||||
|
"constraints": "string[](<=8条)",
|
||||||
|
"tags": "string[](code/math/legal/medical/finance/life/education/general/safety 之一)",
|
||||||
|
"acceptance": "list[{id, check(string), machine_checkable(bool)}]",
|
||||||
|
"plan": "list[{id, task(string<=300字), deps(string[]), done_criteria(string)}](<=5步, 有依赖序)",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DECIDE_SCHEMA_HINT = {
|
||||||
|
"reply": "string(<=600字)",
|
||||||
|
"patch_plan": "list[{id, task(string)}]",
|
||||||
|
}
|
||||||
|
|
||||||
|
_REVIEW_SCHEMA_HINT = {
|
||||||
|
"verdict": "enum(done|fix)",
|
||||||
|
"notes": "string(<=300字)",
|
||||||
|
"fix_issues": "list[string]",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = (
|
||||||
|
"你是任务分析架构师。你的输入是不含工件全文的协作摘要(交流文本),"
|
||||||
|
"你的输出必须是合法 JSON 对象(不要用 markdown 代码块包裹)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArchitectError(RuntimeError):
|
||||||
|
"""Architect 调用失败(网络/超时/JSON 解析失败/服务错误)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ArchitectCircuitBreaker(RuntimeError):
|
||||||
|
"""预算熔断(D6):api_token_cap / rounds_cap 触顶。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ArchitectClient:
|
||||||
|
"""DeepSeek(或任意 OpenAI 兼容)大模型客户端。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
base_url: str = "https://api.deepseek.com",
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
temperature: float = 0.2,
|
||||||
|
timeout_s: float = 60.0,
|
||||||
|
max_tokens: int = 2048,
|
||||||
|
transport: Any = None,
|
||||||
|
_client: Any = None,
|
||||||
|
):
|
||||||
|
self.model = model
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.temperature = temperature
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.max_tokens = max_tokens
|
||||||
|
self._transport = transport
|
||||||
|
self._client = _client # 注入的 AsyncClient(测试用 MockTransport)
|
||||||
|
self._owns_client = _client is None
|
||||||
|
|
||||||
|
def _get_client(self):
|
||||||
|
if self._client is None:
|
||||||
|
import httpx
|
||||||
|
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
|
||||||
|
if self._transport is not None:
|
||||||
|
kwargs["transport"] = self._transport
|
||||||
|
self._client = httpx.AsyncClient(**kwargs)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._owns_client and self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 三个对外能力
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
async def brief(self, query: str, ws: Workspace) -> Dict[str, Any]:
|
||||||
|
"""生成 brief。返回解析后的 brief dict;token 计量写入 ws。"""
|
||||||
|
user = (
|
||||||
|
"用户原始需求:" + "\n" + query + "\n\n"
|
||||||
|
"请生成任务 brief,仅输出符合如下结构的 JSON 对象:" + "\n"
|
||||||
|
+ json.dumps(_BRIEF_SCHEMA_HINT, ensure_ascii=False)
|
||||||
|
+ "\n注意:plan 中的 id 用 s1..sn,deps 引用已完成步骤 id;"
|
||||||
|
"acceptance 尽量 machine_checkable。"
|
||||||
|
)
|
||||||
|
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
|
||||||
|
|
||||||
|
async def decide(self, ws: Workspace) -> Dict[str, Any]:
|
||||||
|
"""根据交流文本当前状态做裁决。返回 {reply, patch_plan}。"""
|
||||||
|
context = ws.render_for_architect()
|
||||||
|
user = (
|
||||||
|
"以下是交流文本摘要(不含工件全文):" + "\n\n" + context + "\n\n"
|
||||||
|
"请针对未解决 issues 做出裁决,仅输出符合如下结构的 JSON:" + "\n"
|
||||||
|
+ json.dumps(_DECIDE_SCHEMA_HINT, ensure_ascii=False)
|
||||||
|
+ "\nreply 给 Worker 具体可执行指示;patch_plan 列出需要修订的 step 与任务。"
|
||||||
|
)
|
||||||
|
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
|
||||||
|
|
||||||
|
async def final_review(self, ws: Workspace) -> Dict[str, Any]:
|
||||||
|
"""终审。返回 {verdict: done|fix, notes, fix_issues}。"""
|
||||||
|
context = ws.render_for_architect()
|
||||||
|
user = (
|
||||||
|
"以下是待终审的交流文本摘要:" + "\n\n" + context + "\n\n"
|
||||||
|
"对照 brief 的 acceptance 做终审,仅输出符合如下结构的 JSON:" + "\n"
|
||||||
|
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False)
|
||||||
|
+ "\nverdict=done 表示验收通过;fix 表示打回,fix_issues 列出需修正项。"
|
||||||
|
)
|
||||||
|
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 底层
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
async def _chat_with_retry(self, ws: Workspace,
|
||||||
|
msgs: List[tuple]) -> Dict[str, Any]:
|
||||||
|
"""调用 + JSON 解析;解析失败回喂一次重写,再失败抛 ArchitectError。"""
|
||||||
|
if not self.api_key:
|
||||||
|
raise ArchitectError(
|
||||||
|
"Architect 未配置 API Key(env: 见 config.architect.api_key_env)。"
|
||||||
|
"请设置密钥,或使用本地降级模式(pipeline.breach_policy: local_only)。"
|
||||||
|
)
|
||||||
|
messages = [{"role": r, "content": c} for r, c in msgs]
|
||||||
|
for attempt in (1, 2):
|
||||||
|
content = await self._chat_once(ws, messages)
|
||||||
|
try:
|
||||||
|
return self._parse_json(content)
|
||||||
|
except ValueError as e:
|
||||||
|
if attempt == 1:
|
||||||
|
# 4.6:把原始输出与错误回喂重写一次
|
||||||
|
messages = messages + [
|
||||||
|
{"role": "assistant", "content": content},
|
||||||
|
{"role": "user",
|
||||||
|
"content": f"你的输出不是合法 JSON({e})。请重新只输出合法 JSON 对象。"},
|
||||||
|
]
|
||||||
|
continue
|
||||||
|
raise ArchitectError(f"Architect 输出非合法 JSON,重试后仍失败: {e}") from e
|
||||||
|
raise ArchitectError("未预期:_chat_with_retry 未返回") # 不可达
|
||||||
|
|
||||||
|
async def _chat_once(self, ws: Workspace, messages: List[Dict[str, Any]]) -> str:
|
||||||
|
if ws.exhausted():
|
||||||
|
raise ArchitectCircuitBreaker(
|
||||||
|
f"预算熔断:api_tokens={ws.budget()['api_input_tokens'] + ws.budget()['api_output_tokens']}"
|
||||||
|
f"/{ws.budget()['api_token_cap']}, round={ws.meta()['round']}/{ws.budget()['rounds_cap']}"
|
||||||
|
)
|
||||||
|
client = self._get_client()
|
||||||
|
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"max_tokens": self.max_tokens,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
raise ArchitectError(f"Architect API 调用失败: {type(e).__name__}: {e}") from e
|
||||||
|
data = resp.json()
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
ws.add_budget(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
|
||||||
|
try:
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
raise ArchitectError(f"Architect 响应缺少 choices/content: {e}") from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json(content: str) -> Dict[str, Any]:
|
||||||
|
"""从模型输出解析 JSON:剥除 markdown 代码围栏,取首个 JSON 对象。"""
|
||||||
|
text = content.strip()
|
||||||
|
_bt = "\x60" # 反引号(避免与构建脚本的字符串定界冲突)
|
||||||
|
fence = _bt * 3
|
||||||
|
text = text.replace(fence + "json", fence).replace(fence, "").strip()
|
||||||
|
try:
|
||||||
|
obj = json.loads(text)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
raise ValueError("顶层不是 object")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
try:
|
||||||
|
obj = json.loads(text[start:end + 1])
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise ValueError("无法解析为 JSON 对象")
|
||||||
|
|
||||||
|
|
||||||
|
def build_architect(cfg: Dict[str, Any],
|
||||||
|
get_env: Callable[[str], Optional[str]] = None) -> ArchitectClient:
|
||||||
|
"""cfg 为 config.architect 段。get_env 可注入(默认读 os.environ)。"""
|
||||||
|
import os
|
||||||
|
_env = get_env or os.environ.get
|
||||||
|
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
|
||||||
|
return ArchitectClient(
|
||||||
|
model=cfg.get("model", "deepseek-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)),
|
||||||
|
)
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
高频语义命中会提升为 O(1) 的精确缓存条目。
|
高频语义命中会提升为 O(1) 的精确缓存条目。
|
||||||
|
|
||||||
只缓存"未升级"的结果(升级路径每次都走大模型,不缓存,避免陈旧)。
|
只缓存"未升级"的结果(升级路径每次都走大模型,不缓存,避免陈旧)。
|
||||||
|
|
||||||
|
性能设计(2026-09 优化):
|
||||||
|
- 每条语义缓存条目在写入时预计算并缓存向量范数,查询时免重复计算(原来每对比较都重算)
|
||||||
|
- 语义查找单遍完成:扫描即跟踪最优条目与命中计数,命中后不再二次线性查找
|
||||||
|
- 相似度达到 1.0(完全相同查询)时提前终止扫描(余弦相似度上界,不可能更优)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,18 +34,6 @@ def _ngrams(text: str, n: int = 3) -> List[str]:
|
|||||||
return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
|
return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
|
||||||
|
|
||||||
|
|
||||||
def _cosine(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float:
|
|
||||||
if not vec_a or not vec_b:
|
|
||||||
return 0.0
|
|
||||||
common = set(vec_a) & set(vec_b)
|
|
||||||
dot = sum(vec_a[k] * vec_b[k] for k in common)
|
|
||||||
na = sum(v * v for v in vec_a.values()) ** 0.5
|
|
||||||
nb = sum(v * v for v in vec_b.values()) ** 0.5
|
|
||||||
if na == 0 or nb == 0:
|
|
||||||
return 0.0
|
|
||||||
return dot / (na * nb)
|
|
||||||
|
|
||||||
|
|
||||||
def _tf_vector(grams: List[str]) -> Dict[str, float]:
|
def _tf_vector(grams: List[str]) -> Dict[str, float]:
|
||||||
vec: Dict[str, float] = {}
|
vec: Dict[str, float] = {}
|
||||||
for g in grams:
|
for g in grams:
|
||||||
@@ -48,6 +41,17 @@ def _tf_vector(grams: List[str]) -> Dict[str, float]:
|
|||||||
return vec
|
return vec
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(vec: Dict[str, float]) -> float:
|
||||||
|
return sum(v * v for v in vec.values()) ** 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float:
|
||||||
|
"""点积:遍历较小的一方,另一侧用 get 兜底。"""
|
||||||
|
if len(vec_a) > len(vec_b):
|
||||||
|
vec_a, vec_b = vec_b, vec_a
|
||||||
|
return sum(v * vec_b.get(k, 0.0) for k, v in vec_a.items())
|
||||||
|
|
||||||
|
|
||||||
class RouterCache:
|
class RouterCache:
|
||||||
"""L1 精确缓存 + L2 语义缓存。"""
|
"""L1 精确缓存 + L2 语义缓存。"""
|
||||||
|
|
||||||
@@ -61,6 +65,7 @@ class RouterCache:
|
|||||||
self._exact: Dict[str, CacheEntry] = {}
|
self._exact: Dict[str, CacheEntry] = {}
|
||||||
self._semantic: List[Tuple[str, CacheEntry]] = [] # (query, entry)
|
self._semantic: List[Tuple[str, CacheEntry]] = [] # (query, entry)
|
||||||
self._sem_vecs: Dict[str, Dict[str, float]] = {}
|
self._sem_vecs: Dict[str, Dict[str, float]] = {}
|
||||||
|
self._sem_norms: Dict[str, float] = {} # 预计算范数,避免查询期重算
|
||||||
self.hits = {"exact": 0, "semantic": 0}
|
self.hits = {"exact": 0, "semantic": 0}
|
||||||
self.misses = 0
|
self.misses = 0
|
||||||
|
|
||||||
@@ -74,36 +79,41 @@ class RouterCache:
|
|||||||
|
|
||||||
if self.semantic_enabled:
|
if self.semantic_enabled:
|
||||||
q_vec = _tf_vector(_ngrams(query))
|
q_vec = _tf_vector(_ngrams(query))
|
||||||
|
q_norm = _norm(q_vec)
|
||||||
best_sim = 0.0
|
best_sim = 0.0
|
||||||
best_query: Optional[str] = None
|
best_idx = -1
|
||||||
best_result: Optional[Dict[str, Any]] = None
|
if q_norm > 0.0:
|
||||||
for q, e in self._semantic:
|
# 单遍扫描:同时跟踪最优相似度与条目位置
|
||||||
sim = _cosine(q_vec, self._sem_vecs.get(q, {}))
|
for i, (q, _e) in enumerate(self._semantic):
|
||||||
|
n_q = self._sem_norms.get(q, 0.0)
|
||||||
|
if n_q <= 0.0:
|
||||||
|
continue
|
||||||
|
sim = _dot(q_vec, self._sem_vecs.get(q, {})) / (q_norm * n_q)
|
||||||
if sim > best_sim:
|
if sim > best_sim:
|
||||||
best_sim = sim
|
best_sim = sim
|
||||||
best_query = q
|
best_idx = i
|
||||||
best_result = e.result
|
if sim >= 1.0:
|
||||||
if best_query is not None and best_sim >= self.similarity_threshold:
|
break # 余弦相似度上界:完全相同查询,提前终止
|
||||||
|
if best_idx >= 0 and best_sim >= self.similarity_threshold:
|
||||||
|
best_q, best_entry = self._semantic[best_idx]
|
||||||
# 完全相同查询(相似度=1.0)计为 exact 命中
|
# 完全相同查询(相似度=1.0)计为 exact 命中
|
||||||
is_exact = best_sim >= 0.999
|
is_exact = best_sim >= 0.999
|
||||||
level = "exact" if is_exact else "semantic"
|
level = "exact" if is_exact else "semantic"
|
||||||
self.hits[level] += 1
|
self.hits[level] += 1
|
||||||
self._semantic_hit(best_query)
|
self._bump_semantic(best_idx, best_q, best_entry)
|
||||||
return (level, best_result)
|
return (level, best_entry.result)
|
||||||
|
|
||||||
self.misses += 1
|
self.misses += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _semantic_hit(self, query: str):
|
def _bump_semantic(self, idx: int, query: str, entry: CacheEntry):
|
||||||
"""语义命中:累计命中次数,达到阈值提升为精确缓存。"""
|
"""语义命中:累计命中次数,达到阈值提升为精确缓存(O(1),无需二次查找)。"""
|
||||||
for i, (q, e) in enumerate(self._semantic):
|
entry.hits += 1
|
||||||
if q == query:
|
if entry.hits >= self.promote_frequency:
|
||||||
e.hits += 1
|
self._exact[query] = entry
|
||||||
if e.hits >= self.promote_frequency:
|
self._semantic.pop(idx)
|
||||||
self._exact[query] = e
|
|
||||||
self._semantic.pop(i)
|
|
||||||
self._sem_vecs.pop(query, None)
|
self._sem_vecs.pop(query, None)
|
||||||
break
|
self._sem_norms.pop(query, None)
|
||||||
|
|
||||||
# ---- 写入 ----
|
# ---- 写入 ----
|
||||||
def put(self, query: str, result: Dict[str, Any]):
|
def put(self, query: str, result: Dict[str, Any]):
|
||||||
@@ -114,8 +124,11 @@ class RouterCache:
|
|||||||
if len(self._semantic) >= self.max_semantic:
|
if len(self._semantic) >= self.max_semantic:
|
||||||
old_q, _ = self._semantic.pop(0)
|
old_q, _ = self._semantic.pop(0)
|
||||||
self._sem_vecs.pop(old_q, None)
|
self._sem_vecs.pop(old_q, None)
|
||||||
|
self._sem_norms.pop(old_q, None)
|
||||||
self._semantic.append((query, entry))
|
self._semantic.append((query, entry))
|
||||||
self._sem_vecs[query] = _tf_vector(_ngrams(query))
|
vec = _tf_vector(_ngrams(query))
|
||||||
|
self._sem_vecs[query] = vec
|
||||||
|
self._sem_norms[query] = _norm(vec)
|
||||||
else:
|
else:
|
||||||
self._exact[query] = entry
|
self._exact[query] = entry
|
||||||
if len(self._exact) > self.max_exact:
|
if len(self._exact) > self.max_exact:
|
||||||
@@ -137,5 +150,6 @@ class RouterCache:
|
|||||||
self._exact.clear()
|
self._exact.clear()
|
||||||
self._semantic.clear()
|
self._semantic.clear()
|
||||||
self._sem_vecs.clear()
|
self._sem_vecs.clear()
|
||||||
|
self._sem_norms.clear()
|
||||||
self.hits = {"exact": 0, "semantic": 0}
|
self.hits = {"exact": 0, "semantic": 0}
|
||||||
self.misses = 0
|
self.misses = 0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63,s=2 -> 0.86,s=3 -> 0.95。
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from .difficulty import estimate_difficulty
|
from .difficulty import estimate_difficulty
|
||||||
from .models import Classification
|
from .models import Classification
|
||||||
@@ -51,6 +51,14 @@ 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),
|
||||||
@@ -61,11 +69,17 @@ 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),
|
("infection", 0.9), ("first aid", 0.9), ("insomnia", 0.9),
|
||||||
],
|
],
|
||||||
"general": [
|
"general": [
|
||||||
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
|
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
|
||||||
@@ -74,6 +88,40 @@ 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 = {
|
||||||
@@ -92,11 +140,21 @@ class BaseClassifier:
|
|||||||
|
|
||||||
|
|
||||||
class RuleClassifier(BaseClassifier):
|
class RuleClassifier(BaseClassifier):
|
||||||
"""基于关键词规则的分类器(零依赖)。"""
|
"""基于关键词规则的分类器(零依赖)。
|
||||||
|
|
||||||
def __init__(self, confidence_floor: float = 0.55):
|
domains 参数(可选):限定只对部分领域打分 —— 两级路由中,
|
||||||
|
每个大领域的组内路由模型用 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()
|
||||||
@@ -128,7 +186,8 @@ class RuleClassifier(BaseClassifier):
|
|||||||
matched_rules=[],
|
matched_rules=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
best_domain = max(raw, key=raw.get)
|
# 同分决胜:按领域名字典序,保证与规则表排列顺序无关的确定性
|
||||||
|
best_domain = max(sorted(raw), key=lambda d: raw[d])
|
||||||
best_score = raw[best_domain]
|
best_score = raw[best_domain]
|
||||||
confidence = 1.0 - math.exp(-best_score)
|
confidence = 1.0 - math.exp(-best_score)
|
||||||
|
|
||||||
@@ -138,7 +197,7 @@ class RuleClassifier(BaseClassifier):
|
|||||||
|
|
||||||
# 与次高分的差距影响置信度(区分度)
|
# 与次高分的差距影响置信度(区分度)
|
||||||
if len(raw) > 1:
|
if len(raw) > 1:
|
||||||
second = sorted(raw.values(), reverse=True)[1]
|
second = max(v for d, v in raw.items() if d != best_domain)
|
||||||
if second > 0.7 * best_score:
|
if second > 0.7 * best_score:
|
||||||
confidence *= 0.85
|
confidence *= 0.85
|
||||||
|
|
||||||
@@ -159,7 +218,7 @@ class HuggingFaceClassifier(BaseClassifier):
|
|||||||
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
|
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model_name: str, num_labels: int = 5, confidence_floor: float = 0.55):
|
def __init__(self, model_name: str, num_labels: int = 8, 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:
|
||||||
@@ -170,7 +229,8 @@ 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:
|
||||||
|
|||||||
@@ -15,23 +15,41 @@ 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", # rule(L0 零参数)| 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", "general"],
|
"domains": ["code", "math", "legal", "medical", "finance", "life", "education", "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-chat",
|
"model": "deepseek-v4-flash",
|
||||||
"base_url": "https://api.deepseek.com/v1",
|
"base_url": "https://api.deepseek.com",
|
||||||
"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"},
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"""执行器体系(L0 默认专家 + NodeExecutor 后端抽象)。
|
||||||
|
|
||||||
|
设计对齐"专家系统风格"(《可行性调研与落地实现路线报告》第八章):
|
||||||
|
- 输出 = 结构化模板填充(回显查询、知识库事实、领域结构),不追求自然语言流畅度
|
||||||
|
- 确定性:同输入 → 同输出(无采样随机)
|
||||||
|
- 最小参数:零模型参数;L2 模式下同一节点可改由本地小模型执行(Router 按配置切换)
|
||||||
|
|
||||||
|
kind(子任务动作类型)与模板对应:
|
||||||
|
analyze 需求/条件分析 | design 方案设计 | implement 代码实现 | solve 数学求解
|
||||||
|
diagnose 错误定位 | fix 修复方案 | retrieve 知识检索 | conclude 结论
|
||||||
|
advise 一般建议 | explain 展开解释 | disclaimer 免责/警示 | verify 自检
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .experts import Expert, extract_content_terms
|
||||||
|
from .knowledge import KnowledgeBase
|
||||||
|
from .memory import TaskNode, WorkingMemory
|
||||||
|
from .models import ExpertResponse
|
||||||
|
|
||||||
|
# 各领域"分析"步骤的目标描述
|
||||||
|
_GOALS = {
|
||||||
|
"code": "输出可运行的代码实现",
|
||||||
|
"math": "得到问题的解并给出推导",
|
||||||
|
"legal": "给出法律结论与依据",
|
||||||
|
"medical": "给出科普性建议",
|
||||||
|
"finance": "给出理财/金融建议与风险提示",
|
||||||
|
"life": "给出实用生活建议",
|
||||||
|
"education": "给出学习/行动方案",
|
||||||
|
"general": "给出结构化说明",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 各领域"约束/边界"提示
|
||||||
|
_CONSTRAINTS = {
|
||||||
|
"code": "边界条件(空输入、极端值);复杂度目标",
|
||||||
|
"math": "定义域、无解/多解情况、特殊值",
|
||||||
|
"legal": "以现行有效法律为准,个案需咨询律师",
|
||||||
|
"medical": "个体差异;非诊断,请遵医嘱",
|
||||||
|
"finance": "市场有风险,投资需谨慎;不构成投资建议",
|
||||||
|
"life": "结合个人实际情况,安全第一",
|
||||||
|
"education": "结合个人基础与目标,循序渐进",
|
||||||
|
"general": "围绕核心问题,避免无关展开",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 各领域"验证"清单
|
||||||
|
_VERIFY_CHECKS = {
|
||||||
|
"code": ["输入输出覆盖", "边界条件", "复杂度合理", "可运行性"],
|
||||||
|
"math": ["中间步骤正确", "结果代入验证", "边界/特殊值", "单位与符号"],
|
||||||
|
"legal": ["法条依据充分", "事实对应", "免责提示", "结论可执行"],
|
||||||
|
"medical": ["建议有依据", "警示信号明确", "免责提示", "不构成诊断"],
|
||||||
|
"finance": ["风险提示完整", "数据/规则准确", "免责提示", "建议可执行"],
|
||||||
|
"life": ["建议实用", "安全提示", "贴合场景"],
|
||||||
|
"education": ["方案可执行", "目标可衡量", "符合个人基础"],
|
||||||
|
"general": ["要点覆盖", "逻辑连贯", "无事实错误"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kw(query: str, n: int = 6) -> str:
|
||||||
|
terms = extract_content_terms(query)
|
||||||
|
return "、".join(terms[:n]) if terms else "该主题"
|
||||||
|
|
||||||
|
|
||||||
|
class RuleExecutor(Expert):
|
||||||
|
"""规则执行器:实现 Expert 接口;L0 模式的默认领域执行器。"""
|
||||||
|
|
||||||
|
name = "rule-executor"
|
||||||
|
|
||||||
|
def __init__(self, name: str = "rule-executor", domain: str = "general",
|
||||||
|
kb: Optional[KnowledgeBase] = None):
|
||||||
|
self.name = name
|
||||||
|
self.domain = domain
|
||||||
|
self.kb = kb
|
||||||
|
|
||||||
|
async def generate(self, query: str, difficulty: str,
|
||||||
|
memory: Optional[WorkingMemory] = None,
|
||||||
|
node: Optional[TaskNode] = None) -> ExpertResponse:
|
||||||
|
"""按节点 kind 生成确定性输出。兼容 Expert 基类签名(后两参可选)。"""
|
||||||
|
kind = node.kind if node is not None else "explain"
|
||||||
|
domain = node.domain if node is not None else self.domain
|
||||||
|
text = self._template(kind, domain, query, difficulty, memory)
|
||||||
|
tokens = max(8, int(len(text) / 2.2))
|
||||||
|
return ExpertResponse(
|
||||||
|
text=text,
|
||||||
|
model_used=f"rule:{domain}:{kind}",
|
||||||
|
latency_ms=0.0,
|
||||||
|
tokens=tokens,
|
||||||
|
cost_est=0.0, # 零参数执行器无推理成本
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _template(self, kind: str, domain: str, query: str, difficulty: str,
|
||||||
|
memory: Optional[WorkingMemory]) -> str:
|
||||||
|
facts: Dict[str, Any] = memory.facts if memory else {}
|
||||||
|
goal = _GOALS.get(domain, _GOALS["general"])
|
||||||
|
constraints = _CONSTRAINTS.get(domain, _CONSTRAINTS["general"])
|
||||||
|
kw = _kw(query)
|
||||||
|
|
||||||
|
if kind == "analyze":
|
||||||
|
return (
|
||||||
|
f"【{domain} 分析】\n"
|
||||||
|
f"- 任务:{query}\n"
|
||||||
|
f"- 关键要素:{kw}\n"
|
||||||
|
f"- 目标:{goal}\n"
|
||||||
|
f"- 约束/边界:{constraints}\n"
|
||||||
|
f"- 难度评估:{difficulty}"
|
||||||
|
)
|
||||||
|
if kind == "design":
|
||||||
|
return (
|
||||||
|
f"【{domain} 方案设计】\n"
|
||||||
|
f"针对「{query}」的设计思路:\n"
|
||||||
|
f"1. 明确核心目标与验收标准\n"
|
||||||
|
f"2. 选择合适的方法/数据结构(依据:{kw})\n"
|
||||||
|
f"3. 拆解实现步骤并标注复杂度\n"
|
||||||
|
f"4. 预留边界处理与异常路径\n"
|
||||||
|
f"5. 设计自测用例(正常/边界/异常)"
|
||||||
|
)
|
||||||
|
if kind == "implement":
|
||||||
|
return (
|
||||||
|
f"【{domain} 实现】\n"
|
||||||
|
f"```python\n"
|
||||||
|
f"def solve() -> None:\n"
|
||||||
|
f" # 关键点:{kw}\n"
|
||||||
|
f" # 1. 校验输入与边界条件\n"
|
||||||
|
f" # 2. 核心逻辑(依据 design 步骤)\n"
|
||||||
|
f" # 3. 输出结果\n"
|
||||||
|
f" pass\n"
|
||||||
|
f"```\n"
|
||||||
|
f"要点:{kw};复杂度与边界说明见 design/verify 步骤。"
|
||||||
|
)
|
||||||
|
if kind == "solve":
|
||||||
|
return (
|
||||||
|
f"【{domain} 求解】\n"
|
||||||
|
f"题目:{query}\n"
|
||||||
|
f"步骤:\n"
|
||||||
|
f"1. 提取已知条件({kw})\n"
|
||||||
|
f"2. 选择方法:代数变形/公式代入/逐步推导\n"
|
||||||
|
f"3. 求解并化简中间结果\n"
|
||||||
|
f"4. 检查特殊值与边界\n"
|
||||||
|
f"结论:在标准假设下可得到闭合形式解;完整推导见正式解答。"
|
||||||
|
)
|
||||||
|
if kind == "diagnose":
|
||||||
|
return (
|
||||||
|
f"【{domain} 诊断】\n"
|
||||||
|
f"错误现象:{query}\n"
|
||||||
|
f"排查步骤:\n"
|
||||||
|
f"1. 复现并定位出错行\n"
|
||||||
|
f"2. 检查变量类型与取值(重点:{kw})\n"
|
||||||
|
f"3. 核对函数签名、作用域与返回值\n"
|
||||||
|
f"4. 打印中间变量验证假设\n"
|
||||||
|
f"5. 用最小样例隔离问题"
|
||||||
|
)
|
||||||
|
if kind == "fix":
|
||||||
|
return (
|
||||||
|
f"【{domain} 修复方案】\n"
|
||||||
|
f"针对「{query}」:\n"
|
||||||
|
f"1. 根因:见 diagnose 步骤\n"
|
||||||
|
f"2. 修复:调整类型/增加空值判断/修正逻辑分支\n"
|
||||||
|
f"```python\n"
|
||||||
|
f"def fixed() -> None:\n"
|
||||||
|
f" # 修复点:{kw}\n"
|
||||||
|
f" pass\n"
|
||||||
|
f"```\n"
|
||||||
|
f"3. 回归:补充对应单测后重跑"
|
||||||
|
)
|
||||||
|
if kind == "retrieve":
|
||||||
|
return self._retrieve(domain, query, memory)
|
||||||
|
if kind == "conclude":
|
||||||
|
return (
|
||||||
|
f"【{domain} 结论】\n"
|
||||||
|
f"综合「{query}」:\n"
|
||||||
|
f"1. 事实梳理:{kw}\n"
|
||||||
|
f"2. 适用规则/依据(见 retrieve 步骤)\n"
|
||||||
|
f"3. 结论:在所述前提下,按上述规则处理\n"
|
||||||
|
f"4. 注意事项:个案差异,必要时咨询专业人士"
|
||||||
|
)
|
||||||
|
if kind == "advise":
|
||||||
|
return (
|
||||||
|
f"【{domain} 建议】\n"
|
||||||
|
f"关于「{query}」的一般性建议:\n"
|
||||||
|
f"1. 基础注意事项({kw})\n"
|
||||||
|
f"2. 可操作建议:分步执行并观察效果\n"
|
||||||
|
f"3. 警示信号:出现下列情况应及时就医(见 warning 步骤)"
|
||||||
|
)
|
||||||
|
if kind == "explain":
|
||||||
|
if domain == "code":
|
||||||
|
return (
|
||||||
|
f"【code 代码讲解】\n"
|
||||||
|
f"代码/片段:{query}\n"
|
||||||
|
f"讲解结构:\n"
|
||||||
|
f"1. 整体目的:这段代码要解决什么问题({kw})\n"
|
||||||
|
f"2. 执行流程:按行/按函数梳理数据流与调用链\n"
|
||||||
|
f"3. 关键点:数据结构、边界处理、异常路径\n"
|
||||||
|
f"4. 可改进点:命名/复杂度/可读性建议"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"【{domain} 说明】\n"
|
||||||
|
f"主题:{query}\n"
|
||||||
|
f"1. 背景与定义\n"
|
||||||
|
f"2. 核心要点:{kw}\n"
|
||||||
|
f"3. 分类/维度/机制\n"
|
||||||
|
f"4. 实际应用与注意事项\n"
|
||||||
|
f"如需更深入分析,可补充上下文。"
|
||||||
|
)
|
||||||
|
if kind == "disclaimer":
|
||||||
|
if domain == "legal":
|
||||||
|
return (
|
||||||
|
"⚠️ 提示:以上为一般性法律分析,不构成正式法律意见;"
|
||||||
|
"个案请咨询执业律师。"
|
||||||
|
)
|
||||||
|
if domain == "medical":
|
||||||
|
return (
|
||||||
|
"⚠️ 提示:以上内容仅供健康科普,不能替代医生诊断;"
|
||||||
|
"如有不适请及时就医。"
|
||||||
|
)
|
||||||
|
if domain == "finance":
|
||||||
|
return (
|
||||||
|
"⚠️ 提示:以上为一般性金融科普,不构成投资建议;"
|
||||||
|
"投资有风险,决策前请结合自身情况并咨询专业人士。"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
if kind == "verify":
|
||||||
|
checks = _VERIFY_CHECKS.get(domain, _VERIFY_CHECKS["general"])
|
||||||
|
items = "\n".join(f"- {c}" for c in checks)
|
||||||
|
return f"【{domain} 自检】\n{items}"
|
||||||
|
if kind == "refactor":
|
||||||
|
return (
|
||||||
|
f"【code 重构方案】\n"
|
||||||
|
f"针对「{query}」:\n"
|
||||||
|
f"1. 现状问题:重复代码/长函数/命名不清/耦合({kw})\n"
|
||||||
|
f"2. 重构手法:提取函数、消除魔法数字、引入类或模块、统一命名\n"
|
||||||
|
f"3. 目标结构:单一职责、清晰分层、可测试性\n"
|
||||||
|
f"4. 验证:重构前后行为等价(跑通全部测试)"
|
||||||
|
)
|
||||||
|
if kind == "testcase":
|
||||||
|
return (
|
||||||
|
f"【code 测试用例】\n"
|
||||||
|
f"针对「{query}」设计测试:\n"
|
||||||
|
f"```python\n"
|
||||||
|
f"def test_xxx():\n"
|
||||||
|
f" # 正常路径:{kw}\n"
|
||||||
|
f" pass\n\n"
|
||||||
|
f"def test_edge():\n"
|
||||||
|
f" # 边界:空输入/极值/None\n"
|
||||||
|
f" pass\n\n"
|
||||||
|
f"def test_error():\n"
|
||||||
|
f" # 异常路径:非法参数\n"
|
||||||
|
f" pass\n"
|
||||||
|
f"```\n"
|
||||||
|
f"覆盖策略:正常 + 边界 + 异常三组,断言明确"
|
||||||
|
)
|
||||||
|
if kind == "complexity":
|
||||||
|
return (
|
||||||
|
f"【code 复杂度分析】\n"
|
||||||
|
f"针对「{query}」:\n"
|
||||||
|
f"1. 时间复杂度:核心循环/递归层数 → 平均与最坏情况({kw})\n"
|
||||||
|
f"2. 空间复杂度:辅助数据结构占用\n"
|
||||||
|
f"3. 优化建议:若可接受,给出降复杂度的替代思路"
|
||||||
|
)
|
||||||
|
if kind == "optimize":
|
||||||
|
return (
|
||||||
|
f"【math 最优化求解】\n"
|
||||||
|
f"问题:{query}\n"
|
||||||
|
f"步骤:\n"
|
||||||
|
f"1. 建立目标函数与约束({kw})\n"
|
||||||
|
f"2. 求导/配方/不等式法找候选极值点\n"
|
||||||
|
f"3. 比较候选值并与边界比较\n"
|
||||||
|
f"4. 结论:给出最大值/最小值及取到条件"
|
||||||
|
)
|
||||||
|
if kind == "draft":
|
||||||
|
return (
|
||||||
|
f"【写作初稿】\n"
|
||||||
|
f"主题:{query}\n"
|
||||||
|
f"结构:\n"
|
||||||
|
f"1. 开头:点明主题与背景({kw})\n"
|
||||||
|
f"2. 主体:分点展开,每点配一个例子或依据\n"
|
||||||
|
f"3. 结尾:总结观点 + 行动建议\n"
|
||||||
|
f"(初稿完成,待 polish 步骤润色)"
|
||||||
|
)
|
||||||
|
if kind == "polish":
|
||||||
|
return (
|
||||||
|
f"【写作润色】\n"
|
||||||
|
f"基于初稿检查:\n"
|
||||||
|
f"1. 语法与错别字\n"
|
||||||
|
f"2. 逻辑衔接与段落过渡\n"
|
||||||
|
f"3. 语气统一(正式/亲切)与受众匹配\n"
|
||||||
|
f"4. 长度控制与重点突出({kw})"
|
||||||
|
)
|
||||||
|
# 未知 kind 兜底
|
||||||
|
return f"(规则执行器)「{query}」:{kw}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _retrieve(self, domain: str, query: str,
|
||||||
|
memory: Optional[WorkingMemory]) -> str:
|
||||||
|
"""知识检索:从知识库事实表取命中的条目;无命中则给出查阅建议。"""
|
||||||
|
if self.kb is None:
|
||||||
|
return (
|
||||||
|
f"【{domain} 知识检索】\n"
|
||||||
|
f"未配置知识库,建议查阅权威资料({_kw(query)})。"
|
||||||
|
)
|
||||||
|
facts = self.kb.facts(domain)
|
||||||
|
hits = [f for f in facts if any(k in query for k in f.get("keywords", []))]
|
||||||
|
if hits:
|
||||||
|
lines = [f"- {f['statement']}" for f in hits]
|
||||||
|
return f"【{domain} 知识检索】\n" + "\n".join(lines)
|
||||||
|
return (
|
||||||
|
f"【{domain} 知识检索】\n"
|
||||||
|
f"未命中知识库条目;建议以现行有效法规/最新指南为准,"
|
||||||
|
f"并结合个案情况分析({_kw(query)})。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===============================================================
|
||||||
|
# NodeExecutor:子任务执行后端抽象(T1:整体项目部分拆解·先行实现)
|
||||||
|
#
|
||||||
|
# Router._execute_node 不再内联 if-else 分支,而是依赖 NodeExecutor 接口:
|
||||||
|
# - RuleNodeExecutor :L0 规则执行器(零参数、确定性)
|
||||||
|
# - ModelNodeExecutor:L2 专家池小模型(≤8B,按需加载)
|
||||||
|
# - 未来可加:多路采样执行器、API 执行器、组内模型执行器……
|
||||||
|
# 工厂按配置选择后端,新增后端无需改动 Router。
|
||||||
|
# ===============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class NodeExecutor:
|
||||||
|
"""子任务执行后端抽象接口。"""
|
||||||
|
|
||||||
|
name: str = "node-executor"
|
||||||
|
|
||||||
|
async def execute(self, node: TaskNode, domain: str, difficulty: str,
|
||||||
|
memory: WorkingMemory) -> ExpertResponse:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class RuleNodeExecutor(NodeExecutor):
|
||||||
|
"""L0:规则执行器后端(零参数、确定性、零成本)。"""
|
||||||
|
|
||||||
|
name = "rule"
|
||||||
|
|
||||||
|
def __init__(self, kb: Optional[KnowledgeBase] = None):
|
||||||
|
self._rule = RuleExecutor("rule-executor", "general", kb=kb)
|
||||||
|
|
||||||
|
async def execute(self, node: TaskNode, domain: str, difficulty: str,
|
||||||
|
memory: WorkingMemory) -> ExpertResponse:
|
||||||
|
return await self._rule.generate(node.query, difficulty, memory, node)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNodeExecutor(NodeExecutor):
|
||||||
|
"""L2:专家池小模型后端(≤8B;组内模型按需加载,用完即卸载由推理服务管理)。"""
|
||||||
|
|
||||||
|
name = "model"
|
||||||
|
|
||||||
|
def __init__(self, experts: Dict[str, Expert]):
|
||||||
|
self._experts = experts
|
||||||
|
|
||||||
|
async def execute(self, node: TaskNode, domain: str, difficulty: str,
|
||||||
|
memory: WorkingMemory) -> ExpertResponse:
|
||||||
|
expert = self._experts.get(node.domain) or self._experts.get("general")
|
||||||
|
return await expert.generate(node.query, difficulty)
|
||||||
|
|
||||||
|
|
||||||
|
def build_node_executor(backend: str, kb: Optional[KnowledgeBase] = None,
|
||||||
|
experts: Optional[Dict[str, Expert]] = None) -> NodeExecutor:
|
||||||
|
"""按配置选择子任务执行后端。"""
|
||||||
|
if backend == "rule":
|
||||||
|
return RuleNodeExecutor(kb=kb)
|
||||||
|
if backend in ("hf", "api", "model"):
|
||||||
|
if not experts:
|
||||||
|
raise ValueError("ModelNodeExecutor 需要专家池(experts)")
|
||||||
|
return ModelNodeExecutor(experts)
|
||||||
|
raise ValueError(f"未知执行后端: {backend}(支持 rule | hf | api | model)")
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
"""大模型回退层:Mock 与 OpenAI 兼容 API 两种后端。"""
|
"""大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API。
|
||||||
|
|
||||||
|
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
|
||||||
|
- MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
|
||||||
|
- LocalFallback :本地小模型(≤8B,Q4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
|
||||||
|
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -14,6 +20,30 @@ 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,便于测试升级路径。"""
|
||||||
|
|
||||||
@@ -40,6 +70,54 @@ class MockFallback(FallbackProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFallback(FallbackProvider):
|
||||||
|
"""本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
|
||||||
|
|
||||||
|
通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1,vLLM 默认 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)。"""
|
||||||
|
|
||||||
@@ -85,9 +163,17 @@ 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-chat")
|
model = cfg.get("model", "deepseek-v4-flash")
|
||||||
|
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"))
|
||||||
@@ -95,5 +181,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/v1"), api_key)
|
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key)
|
||||||
raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api)")␍
|
raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api)")
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""前向链推理机:知识库规则驱动的工作记忆演化(专家系统推理核心,零依赖)。
|
||||||
|
|
||||||
|
流程(经典前向链 forward chaining):
|
||||||
|
1. 初始化黑板:写入领域/难度/置信度等事实
|
||||||
|
2. 循环:在领域内匹配规则(未触发过的)→ 按优先级执行
|
||||||
|
- 命中即记录轨迹 rule:<id>@<priority>
|
||||||
|
- 规则带 output 模板 → 渲染后写入黑板章节(部分解)
|
||||||
|
- 规则带 actions → 执行动作(写事实/写章节)
|
||||||
|
3. 终止:无新规则可触发 / 达到步数上限(防死循环)
|
||||||
|
|
||||||
|
确定性保证:规则匹配基于子串包含,无随机性;同输入 → 同轨迹。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .knowledge import KnowledgeBase, Rule
|
||||||
|
from .memory import WorkingMemory
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(template: str, query: str, facts: Dict[str, Any]) -> str:
|
||||||
|
"""渲染输出模板:替换 {query} 与 {facts.<key>} 占位符;缺失以 [未提供] 占位,不抛异常。"""
|
||||||
|
out = template.replace("{query}", query)
|
||||||
|
for key, value in facts.items():
|
||||||
|
out = out.replace(f"{{facts.{key}}}", str(value))
|
||||||
|
# 剩余占位符兜底
|
||||||
|
while "{" in out and "}" in out:
|
||||||
|
start = out.find("{")
|
||||||
|
end = out.find("}", start)
|
||||||
|
if end == -1:
|
||||||
|
break
|
||||||
|
out = out[:start] + "[未提供]" + out[end + 1:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceEngine:
|
||||||
|
"""前向链推理机。"""
|
||||||
|
|
||||||
|
def __init__(self, kb: KnowledgeBase, max_steps: int = 20):
|
||||||
|
self.kb = kb
|
||||||
|
self.max_steps = max_steps
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def initialize(self, query: str, domain: str, difficulty: str,
|
||||||
|
confidence: float, memory: WorkingMemory) -> None:
|
||||||
|
"""把分类结果写入黑板(事实初始化)。"""
|
||||||
|
memory.write_fact("query", query)
|
||||||
|
memory.write_fact("domain", domain)
|
||||||
|
memory.write_fact("difficulty", difficulty)
|
||||||
|
memory.write_fact("confidence", round(confidence, 4))
|
||||||
|
memory.add_trace(f"init:domain={domain},difficulty={difficulty},conf={confidence:.2f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def run(self, query: str, domain: str, memory: WorkingMemory,
|
||||||
|
max_steps: Optional[int] = None) -> List[str]:
|
||||||
|
"""前向链主循环。返回触发规则 id 列表(按触发顺序)。"""
|
||||||
|
steps = max_steps or self.max_steps
|
||||||
|
fired: List[str] = []
|
||||||
|
for _ in range(steps):
|
||||||
|
rules = self.kb.match(query, domain=domain)
|
||||||
|
# 选第一个"未触发过"的规则
|
||||||
|
target: Optional[Rule] = None
|
||||||
|
for r in rules:
|
||||||
|
if r.id not in fired:
|
||||||
|
target = r
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
break # 无新规则可触发 → 终止
|
||||||
|
fired.append(target.id)
|
||||||
|
self._fire(target, query, memory)
|
||||||
|
return fired
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _fire(self, rule: Rule, query: str, memory: WorkingMemory) -> None:
|
||||||
|
"""执行一条规则:记录轨迹 + 写事实 + 产出章节。"""
|
||||||
|
memory.add_trace(f"rule:{rule.id}@{rule.priority}")
|
||||||
|
# 规则动作
|
||||||
|
for action in rule.actions:
|
||||||
|
self._apply_action(action, rule, query, memory)
|
||||||
|
# 规则输出模板 → 章节
|
||||||
|
if rule.output:
|
||||||
|
text = render_template(rule.output, query, memory.facts)
|
||||||
|
memory.write_section(rule.id, text)
|
||||||
|
|
||||||
|
def _apply_action(self, action: str, rule: Rule, query: str,
|
||||||
|
memory: WorkingMemory) -> None:
|
||||||
|
"""动作格式:write_fact:key=value(value 支持 {query} 占位)。"""
|
||||||
|
if action.startswith("write_fact:"):
|
||||||
|
kv = action[len("write_fact:"):]
|
||||||
|
key, _, value = kv.partition("=")
|
||||||
|
value = value.replace("{query}", query)
|
||||||
|
memory.write_fact(key.strip(), value.strip(), rule_id=rule.id)
|
||||||
|
# 其他动作类型暂不实现(保留扩展位)
|
||||||
@@ -56,8 +56,9 @@ class BaseJudge:
|
|||||||
class RuleJudge(BaseJudge):
|
class RuleJudge(BaseJudge):
|
||||||
"""启发式质量评估(零依赖)。"""
|
"""启发式质量评估(零依赖)。"""
|
||||||
|
|
||||||
def __init__(self, fallback_threshold: float = 0.70):
|
def __init__(self, fallback_threshold: float = 0.70, kb=None):
|
||||||
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] = {}
|
||||||
@@ -103,7 +104,25 @@ class RuleJudge(BaseJudge):
|
|||||||
else:
|
else:
|
||||||
scores["safety"] = 1.0
|
scores["safety"] = 1.0
|
||||||
|
|
||||||
weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2}
|
# 5) 领域知识引用(facts 维度):法律/医疗响应应覆盖查询命中的知识条目
|
||||||
|
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:
|
||||||
@@ -157,17 +176,17 @@ class LLMJudge(BaseJudge):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge:
|
def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> 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)
|
return RuleJudge(fallback_threshold=fallback_threshold, kb=kb)
|
||||||
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-chat"),
|
cfg.get("model", "deepseek-v4-flash"),
|
||||||
cfg.get("base_url", "https://api.deepseek.com/v1"),
|
cfg.get("base_url", "https://api.deepseek.com"),
|
||||||
api_key or "",
|
api_key or "",
|
||||||
fallback_threshold=fallback_threshold,
|
fallback_threshold=fallback_threshold,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""知识库:专家系统风格的规则与知识表示(零依赖,纯标准库)。
|
||||||
|
|
||||||
|
设计原则(对齐《可行性调研与落地实现路线报告》第八章"专家系统内核"):
|
||||||
|
- 领域知识显式化:写在规则文件里(config/knowledge/<domain>.yaml),不藏在模型参数中
|
||||||
|
- 确定性:规则匹配 = 子串包含(大小写不敏感),同输入同输出
|
||||||
|
- 可解释:每次命中都记录规则 id,形成推理轨迹
|
||||||
|
- 最小参数:L0 模式零模型参数,规则即知识
|
||||||
|
|
||||||
|
规则文件格式(YAML;若 pyyaml 不可用,可提供同名 .json):
|
||||||
|
domain: code
|
||||||
|
rules:
|
||||||
|
- id: code-sort
|
||||||
|
priority: 90 # 越大越先触发
|
||||||
|
patterns: ["排序", "sort"] # 任一子串命中即触发
|
||||||
|
template: code-implement # 可选:Planner 任务模板 id
|
||||||
|
output: | # 可选:输出模板({query} 等占位符)
|
||||||
|
(规则输出)...
|
||||||
|
facts: # 领域事实表(Judge 校验 / retrieve 执行器用)
|
||||||
|
- id: legal-nc
|
||||||
|
keywords: ["竞业"]
|
||||||
|
statement: "竞业限制期限不得超过二年"
|
||||||
|
|
||||||
|
任务模板(config/knowledge/tasks.yaml):
|
||||||
|
task_templates:
|
||||||
|
code-implement:
|
||||||
|
steps:
|
||||||
|
- {id: analyze, kind: analyze, domain: code}
|
||||||
|
- {id: design, kind: design, domain: code, deps: [analyze]}
|
||||||
|
|
||||||
|
加载顺序:内置默认规则(代码内兜底)→ 文件规则按 id 合并覆盖。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
DEFAULT_RULES_DIR = Path(__file__).resolve().parent.parent / "config" / "knowledge"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rule:
|
||||||
|
"""一条领域规则。"""
|
||||||
|
id: str
|
||||||
|
domain: str
|
||||||
|
priority: int = 50
|
||||||
|
patterns: List[str] = field(default_factory=list)
|
||||||
|
template: Optional[str] = None # 引用的任务模板 id
|
||||||
|
output: Optional[str] = None # 输出模板
|
||||||
|
actions: List[str] = field(default_factory=list) # 保留字段:动作扩展
|
||||||
|
subdomain: Optional[str] = None # 二级子领域(如 investing/labor/calculus)
|
||||||
|
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime/sorting)
|
||||||
|
|
||||||
|
def matches(self, text: str) -> bool:
|
||||||
|
"""任一 pattern 是 text 的子串即命中(大小写不敏感)。"""
|
||||||
|
if not self.patterns:
|
||||||
|
return False
|
||||||
|
q = text.lower()
|
||||||
|
return any(p.lower() in q for p in self.patterns)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 三级子领域映射(rule_id -> subdomain2)
|
||||||
|
# 集中维护:新增规则时在此加一行即可完成三级细化标注
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
SUBDOMAIN2_MAP: Dict[str, str] = {
|
||||||
|
# ---- code ----
|
||||||
|
"code-sort": "sorting",
|
||||||
|
"code-debug": "error-analysis",
|
||||||
|
"code-algorithm": "algorithm-general",
|
||||||
|
"code-refactor": "code-quality",
|
||||||
|
"code-database": "sql",
|
||||||
|
"code-explain": "code-reading",
|
||||||
|
"code-test": "unit-test",
|
||||||
|
"code-web": "web-dev",
|
||||||
|
"code-implement-general": "implementation",
|
||||||
|
"code-git-knowledge": "git",
|
||||||
|
"code-docker-knowledge": "container",
|
||||||
|
"code-python-knowledge": "python-env",
|
||||||
|
# ---- math ----
|
||||||
|
"math-equation": "equation",
|
||||||
|
"math-calculus": "calculus",
|
||||||
|
"math-algebra": "algebra",
|
||||||
|
"math-geometry": "geometry",
|
||||||
|
"math-proof": "proof",
|
||||||
|
"math-probability": "probability",
|
||||||
|
"math-number-theory": "number-theory",
|
||||||
|
"math-trigonometry": "trigonometry",
|
||||||
|
"math-optimization": "optimization",
|
||||||
|
"math-general": "math-general",
|
||||||
|
# ---- legal ----
|
||||||
|
"legal-contract": "contract",
|
||||||
|
"legal-labor": "labor",
|
||||||
|
"legal-ip": "intellectual-property",
|
||||||
|
"legal-housing": "housing",
|
||||||
|
"legal-marriage": "family-law",
|
||||||
|
"legal-tax": "tax",
|
||||||
|
"legal-consumer": "consumer-rights",
|
||||||
|
"legal-litigation": "litigation",
|
||||||
|
"legal-compliance": "compliance",
|
||||||
|
"legal-general": "legal-general",
|
||||||
|
# ---- medical ----
|
||||||
|
"medical-hypertension": "hypertension",
|
||||||
|
"medical-drug": "medication",
|
||||||
|
"medical-common": "common-illness",
|
||||||
|
"medical-chronic": "chronic-disease",
|
||||||
|
"medical-digestive": "digestive",
|
||||||
|
"medical-nutrition": "nutrition",
|
||||||
|
"medical-mental": "mental-health",
|
||||||
|
"medical-firstaid": "first-aid",
|
||||||
|
"medical-pediatrics": "pediatrics",
|
||||||
|
"medical-general": "medical-general",
|
||||||
|
# ---- finance ----
|
||||||
|
"finance-investing": "investing",
|
||||||
|
"finance-saving": "saving",
|
||||||
|
"finance-loan": "loan",
|
||||||
|
"finance-insurance": "insurance",
|
||||||
|
"finance-credit-card": "credit",
|
||||||
|
"finance-personal-budget": "budgeting",
|
||||||
|
"finance-general": "finance-general",
|
||||||
|
# ---- life ----
|
||||||
|
"life-food": "cooking",
|
||||||
|
"life-travel": "travel",
|
||||||
|
"life-home": "home",
|
||||||
|
"life-pet": "pet",
|
||||||
|
"life-fitness": "fitness",
|
||||||
|
"life-weather": "weather",
|
||||||
|
"life-general": "life-general",
|
||||||
|
# ---- education ----
|
||||||
|
"edu-study-method": "study-method",
|
||||||
|
"edu-exam": "exam",
|
||||||
|
"edu-language": "language",
|
||||||
|
"edu-course": "course",
|
||||||
|
"edu-career": "career",
|
||||||
|
"edu-general": "education-general",
|
||||||
|
# ---- general ----
|
||||||
|
"general-explain": "explain",
|
||||||
|
"general-writing": "writing",
|
||||||
|
"general-compare": "compare",
|
||||||
|
"general-translate": "translate",
|
||||||
|
"general-knowledge": "explain",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 二级子领域映射(rule_id -> subdomain)
|
||||||
|
# 三级 subdomain2 的父级类别;与 SUBDOMAIN2_MAP 按 rule_id 对齐维护。
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
SUBDOMAIN_MAP: Dict[str, str] = {
|
||||||
|
# ---- code ----
|
||||||
|
"code-sort": "algorithm",
|
||||||
|
"code-debug": "debugging",
|
||||||
|
"code-algorithm": "algorithm",
|
||||||
|
"code-refactor": "quality",
|
||||||
|
"code-database": "data",
|
||||||
|
"code-explain": "reading",
|
||||||
|
"code-test": "quality",
|
||||||
|
"code-web": "web",
|
||||||
|
"code-implement-general": "implementation",
|
||||||
|
"code-git-knowledge": "tooling",
|
||||||
|
"code-docker-knowledge": "tooling",
|
||||||
|
"code-python-knowledge": "tooling",
|
||||||
|
# ---- math ----
|
||||||
|
"math-equation": "algebra",
|
||||||
|
"math-calculus": "analysis",
|
||||||
|
"math-algebra": "algebra",
|
||||||
|
"math-geometry": "geometry",
|
||||||
|
"math-proof": "proof",
|
||||||
|
"math-probability": "probability",
|
||||||
|
"math-number-theory": "number-theory",
|
||||||
|
"math-trigonometry": "trigonometry",
|
||||||
|
"math-optimization": "optimization",
|
||||||
|
"math-general": "general",
|
||||||
|
# ---- legal ----
|
||||||
|
"legal-contract": "contract",
|
||||||
|
"legal-labor": "labor",
|
||||||
|
"legal-ip": "ip",
|
||||||
|
"legal-housing": "civil",
|
||||||
|
"legal-marriage": "civil",
|
||||||
|
"legal-tax": "tax",
|
||||||
|
"legal-consumer": "consumer",
|
||||||
|
"legal-litigation": "procedure",
|
||||||
|
"legal-compliance": "compliance",
|
||||||
|
"legal-general": "general",
|
||||||
|
# ---- medical ----
|
||||||
|
"medical-hypertension": "chronic",
|
||||||
|
"medical-drug": "medication",
|
||||||
|
"medical-common": "common",
|
||||||
|
"medical-chronic": "chronic",
|
||||||
|
"medical-digestive": "common",
|
||||||
|
"medical-nutrition": "nutrition",
|
||||||
|
"medical-mental": "mental",
|
||||||
|
"medical-firstaid": "emergency",
|
||||||
|
"medical-pediatrics": "pediatrics",
|
||||||
|
"medical-general": "general",
|
||||||
|
# ---- finance ----
|
||||||
|
"finance-investing": "investing",
|
||||||
|
"finance-saving": "personal-finance",
|
||||||
|
"finance-loan": "credit",
|
||||||
|
"finance-insurance": "insurance",
|
||||||
|
"finance-credit-card": "credit",
|
||||||
|
"finance-personal-budget": "personal-finance",
|
||||||
|
"finance-general": "general",
|
||||||
|
# ---- life ----
|
||||||
|
"life-food": "daily",
|
||||||
|
"life-travel": "daily",
|
||||||
|
"life-home": "daily",
|
||||||
|
"life-pet": "daily",
|
||||||
|
"life-fitness": "health",
|
||||||
|
"life-weather": "daily",
|
||||||
|
"life-general": "general",
|
||||||
|
# ---- education ----
|
||||||
|
"edu-study-method": "learning",
|
||||||
|
"edu-exam": "learning",
|
||||||
|
"edu-language": "language",
|
||||||
|
"edu-course": "learning",
|
||||||
|
"edu-career": "development",
|
||||||
|
"edu-general": "general",
|
||||||
|
# ---- general ----
|
||||||
|
"general-explain": "explanation",
|
||||||
|
"general-writing": "writing",
|
||||||
|
"general-compare": "analysis",
|
||||||
|
"general-translate": "language",
|
||||||
|
"general-knowledge": "explanation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 内置默认规则(兜底:即使规则文件缺失/损坏,系统仍可运行)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
BUILTIN_RULES: List[Dict[str, Any]] = [
|
||||||
|
# ---- code ----
|
||||||
|
{"id": "code-sort", "domain": "code", "priority": 90,
|
||||||
|
"patterns": ["排序", "快速排序", "排序算法", "sort", "quicksort"],
|
||||||
|
"template": "code-implement"},
|
||||||
|
{"id": "code-debug", "domain": "code", "priority": 85,
|
||||||
|
"patterns": ["报错", "错误", "调试", "bug", "debug", "typeerror", "异常", "报 TypeError"],
|
||||||
|
"template": "code-debug"},
|
||||||
|
{"id": "code-implement-general", "domain": "code", "priority": 50,
|
||||||
|
"patterns": ["实现", "编写", "写一个", "函数", "代码", "编程", "用 python", "用 java",
|
||||||
|
"用 javascript", "sql", "接口", "算法"],
|
||||||
|
"template": "code-implement"},
|
||||||
|
# ---- math ----
|
||||||
|
{"id": "math-equation", "domain": "math", "priority": 90,
|
||||||
|
"patterns": ["方程", "求解", "求根", "solve", "equation", "解方程"],
|
||||||
|
"template": "math-solve"},
|
||||||
|
{"id": "math-calculus", "domain": "math", "priority": 85,
|
||||||
|
"patterns": ["积分", "导数", "微积分", "求导", "integral", "derivative", "∫"],
|
||||||
|
"template": "math-solve"},
|
||||||
|
{"id": "math-general", "domain": "math", "priority": 50,
|
||||||
|
"patterns": ["数学", "证明", "定理", "概率", "统计", "计算", "等于", "math", "不等式"],
|
||||||
|
"template": "math-solve"},
|
||||||
|
# ---- legal ----
|
||||||
|
{"id": "legal-contract", "domain": "legal", "priority": 90,
|
||||||
|
"patterns": ["合同", "条款", "违约", "离职", "竞业", "劳动", "contract", "clause", "赔偿"],
|
||||||
|
"template": "legal-advice"},
|
||||||
|
{"id": "legal-ip", "domain": "legal", "priority": 85,
|
||||||
|
"patterns": ["专利", "版权", "商标", "知识产权", "patent", "copyright", "trademark"],
|
||||||
|
"template": "legal-advice"},
|
||||||
|
{"id": "legal-general", "domain": "legal", "priority": 50,
|
||||||
|
"patterns": ["法律", "合规", "诉讼", "仲裁", "法条", "law", "legal", "法规"],
|
||||||
|
"template": "legal-advice"},
|
||||||
|
# ---- medical ----
|
||||||
|
{"id": "medical-hypertension", "domain": "medical", "priority": 90,
|
||||||
|
"patterns": ["高血压", "hypertension", "血压"],
|
||||||
|
"template": "medical-advice"},
|
||||||
|
{"id": "medical-drug", "domain": "medical", "priority": 85,
|
||||||
|
"patterns": ["药物", "吃药", "剂量", "副作用", "退烧药", "降压药", "dosage", "prescription"],
|
||||||
|
"template": "medical-advice"},
|
||||||
|
{"id": "medical-general", "domain": "medical", "priority": 50,
|
||||||
|
"patterns": ["医疗", "症状", "诊断", "治疗", "感冒", "发烧", "糖尿病", "医生", "患者",
|
||||||
|
"体检", "疫苗", "medical", "symptom", "disease"],
|
||||||
|
"template": "medical-advice"},
|
||||||
|
# ---- finance ----
|
||||||
|
{"id": "finance-investing", "domain": "finance", "priority": 90,
|
||||||
|
"patterns": ["基金", "定投", "收益率", "股票", "投资", "炒股", "证券", "invest", "stock"]},
|
||||||
|
{"id": "finance-saving", "domain": "finance", "priority": 85,
|
||||||
|
"patterns": ["存款", "储蓄", "利息", "零钱通", "余额宝", "saving"]},
|
||||||
|
{"id": "finance-loan", "domain": "finance", "priority": 80,
|
||||||
|
"patterns": ["贷款", "房贷", "借款", "按揭", "loan"]},
|
||||||
|
{"id": "finance-insurance", "domain": "finance", "priority": 75,
|
||||||
|
"patterns": ["保险", "理赔", "保单", "投保", "insurance"]},
|
||||||
|
{"id": "finance-credit-card", "domain": "finance", "priority": 70,
|
||||||
|
"patterns": ["信用卡", "花呗", "白条", "credit card"]},
|
||||||
|
{"id": "finance-personal-budget", "domain": "finance", "priority": 60,
|
||||||
|
"patterns": ["预算", "记账", "开销", "省钱", "budget"]},
|
||||||
|
{"id": "finance-general", "domain": "finance", "priority": 50,
|
||||||
|
"patterns": ["金融", "财务", "外汇", "汇率", "finance"]},
|
||||||
|
# ---- life ----
|
||||||
|
{"id": "life-food", "domain": "life", "priority": 90,
|
||||||
|
"patterns": ["做饭", "做菜", "菜谱", "食谱", "烹饪", "cooking"]},
|
||||||
|
{"id": "life-travel", "domain": "life", "priority": 85,
|
||||||
|
"patterns": ["旅游", "旅行", "攻略", "景点", "签证", "travel"]},
|
||||||
|
{"id": "life-home", "domain": "life", "priority": 80,
|
||||||
|
"patterns": ["装修", "租房", "家电", "清洁", "搬家", "home"]},
|
||||||
|
{"id": "life-pet", "domain": "life", "priority": 75,
|
||||||
|
"patterns": ["宠物", "养猫", "养狗", "撸猫", "pet"]},
|
||||||
|
{"id": "life-fitness", "domain": "life", "priority": 70,
|
||||||
|
"patterns": ["健身", "减肥", "跑步", "锻炼", "fitness"]},
|
||||||
|
{"id": "life-weather", "domain": "life", "priority": 65,
|
||||||
|
"patterns": ["天气", "下雨", "台风", "降温", "weather"]},
|
||||||
|
{"id": "life-general", "domain": "life", "priority": 50,
|
||||||
|
"patterns": ["生活", "日常", "家居", "life"]},
|
||||||
|
# ---- education ----
|
||||||
|
{"id": "edu-study-method", "domain": "education", "priority": 90,
|
||||||
|
"patterns": ["学习方法", "记忆", "做笔记", "笔记法", "专注力"]},
|
||||||
|
{"id": "edu-exam", "domain": "education", "priority": 85,
|
||||||
|
"patterns": ["考试", "考研", "复习", "真题", "四六级", "exam"]},
|
||||||
|
{"id": "edu-language", "domain": "education", "priority": 80,
|
||||||
|
"patterns": ["英语", "单词", "口语", "语法", "english"]},
|
||||||
|
{"id": "edu-course", "domain": "education", "priority": 75,
|
||||||
|
"patterns": ["课程", "网课", "慕课", "选修", "course"]},
|
||||||
|
{"id": "edu-career", "domain": "education", "priority": 70,
|
||||||
|
"patterns": ["职业规划", "求职", "面试", "简历", "校招", "career"]},
|
||||||
|
{"id": "edu-general", "domain": "education", "priority": 50,
|
||||||
|
"patterns": ["教育", "大学", "专业选择", "education"]},
|
||||||
|
# ---- general ----
|
||||||
|
{"id": "general-explain", "domain": "general", "priority": 30,
|
||||||
|
"patterns": ["总结", "介绍", "解释", "为什么", "优缺点", "是什么", "翻译", "邮件",
|
||||||
|
"summarize", "explain", "what is", "写一封"],
|
||||||
|
"template": "general-explain"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 内置默认任务模板(兜底)
|
||||||
|
BUILTIN_TASKS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"code-implement": {"steps": [
|
||||||
|
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "需求与约束分析"},
|
||||||
|
{"id": "design", "kind": "design", "domain": "code", "deps": ["analyze"], "desc": "算法与数据结构设计"},
|
||||||
|
{"id": "implement", "kind": "implement", "domain": "code", "deps": ["design"], "desc": "实现代码"},
|
||||||
|
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["implement"], "desc": "自测校验"},
|
||||||
|
]},
|
||||||
|
"code-debug": {"steps": [
|
||||||
|
{"id": "analyze", "kind": "analyze", "domain": "code", "desc": "错误现象与复现分析"},
|
||||||
|
{"id": "diagnose", "kind": "diagnose", "domain": "code", "deps": ["analyze"], "desc": "定位错误根因"},
|
||||||
|
{"id": "fix", "kind": "fix", "domain": "code", "deps": ["diagnose"], "desc": "给出修复方案"},
|
||||||
|
{"id": "verify", "kind": "verify", "domain": "code", "deps": ["fix"], "desc": "修复后验证"},
|
||||||
|
]},
|
||||||
|
"math-solve": {"steps": [
|
||||||
|
{"id": "conditions", "kind": "analyze", "domain": "math", "desc": "明确已知条件与目标"},
|
||||||
|
{"id": "solve", "kind": "solve", "domain": "math", "deps": ["conditions"], "desc": "选择方法并求解"},
|
||||||
|
{"id": "verify", "kind": "verify", "domain": "math", "deps": ["solve"], "desc": "检查边界与验证"},
|
||||||
|
]},
|
||||||
|
"legal-advice": {"steps": [
|
||||||
|
{"id": "facts", "kind": "analyze", "domain": "legal", "desc": "梳理事实与法律问题"},
|
||||||
|
{"id": "retrieve", "kind": "retrieve", "domain": "legal", "deps": ["facts"], "desc": "检索适用法规"},
|
||||||
|
{"id": "conclude", "kind": "conclude", "domain": "legal", "deps": ["retrieve"], "desc": "给出法律意见"},
|
||||||
|
{"id": "disclaimer", "kind": "disclaimer", "domain": "legal", "deps": ["conclude"], "desc": "免责提示"},
|
||||||
|
]},
|
||||||
|
"medical-advice": {"steps": [
|
||||||
|
{"id": "symptoms", "kind": "analyze", "domain": "medical", "desc": "梳理症状与背景"},
|
||||||
|
{"id": "advise", "kind": "advise", "domain": "medical", "deps": ["symptoms"], "desc": "给出一般建议"},
|
||||||
|
{"id": "warning", "kind": "disclaimer", "domain": "medical", "deps": ["advise"], "desc": "就医警示"},
|
||||||
|
]},
|
||||||
|
"general-explain": {"steps": [
|
||||||
|
{"id": "outline", "kind": "analyze", "domain": "general", "desc": "梳理主题要点"},
|
||||||
|
{"id": "explain", "kind": "explain", "domain": "general", "deps": ["outline"], "desc": "展开解释"},
|
||||||
|
{"id": "conclude", "kind": "conclude", "domain": "general", "deps": ["explain"], "desc": "总结"},
|
||||||
|
]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 内置默认事实表(兜底)
|
||||||
|
BUILTIN_FACTS: Dict[str, List[Dict[str, Any]]] = {
|
||||||
|
"legal": [
|
||||||
|
{"id": "legal-noncompete", "keywords": ["竞业", "离职", "同业"],
|
||||||
|
"statement": "竞业限制期限不得超过二年,且用人单位应在限制期内按月给予经济补偿"},
|
||||||
|
{"id": "legal-renew-compensation", "keywords": ["不续签", "经济补偿", "劳动合同"],
|
||||||
|
"statement": "劳动合同期满用人单位不续签的,通常应支付经济补偿(每满一年一个月工资)"},
|
||||||
|
],
|
||||||
|
"medical": [
|
||||||
|
{"id": "medical-hypertension-diet", "keywords": ["高血压", "饮食"],
|
||||||
|
"statement": "高血压患者应低盐低脂饮食、控制体重、规律运动、戒烟限酒,并在医生指导下用药"},
|
||||||
|
{"id": "medical-fever-drug", "keywords": ["发烧", "退烧"],
|
||||||
|
"statement": "体温超过 38.5℃ 可在药师指导下使用退烧药;持续发热或出现严重症状应及时就医"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
import yaml # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
json_path = path.with_suffix(".json")
|
||||||
|
if not json_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase:
|
||||||
|
"""知识库:加载规则文件,提供规则匹配、任务模板、事实表查询。"""
|
||||||
|
|
||||||
|
def __init__(self, rules_dir: Optional[str | Path] = None):
|
||||||
|
self.rules_dir = Path(rules_dir) if rules_dir else DEFAULT_RULES_DIR
|
||||||
|
self._rules: Dict[str, Rule] = {}
|
||||||
|
self._tasks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._facts: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
# ---- 加载 ----
|
||||||
|
def load(self) -> None:
|
||||||
|
"""内置默认 + 规则文件合并(文件规则按 id 覆盖内置)。"""
|
||||||
|
self._rules = {}
|
||||||
|
self._tasks = dict(BUILTIN_TASKS)
|
||||||
|
for item in BUILTIN_RULES:
|
||||||
|
self._register_rule(item)
|
||||||
|
self._facts = {d: [dict(f) for f in facts] for d, facts in BUILTIN_FACTS.items()}
|
||||||
|
|
||||||
|
if self.rules_dir.is_dir():
|
||||||
|
for f in sorted(self.rules_dir.glob("*.yaml")):
|
||||||
|
data = _try_load_yaml(f)
|
||||||
|
if data is not None:
|
||||||
|
self._load_file_data(f, data)
|
||||||
|
for f in sorted(self.rules_dir.glob("*.json")):
|
||||||
|
if f.name not in {p.name for p in self.rules_dir.glob("*.yaml")}:
|
||||||
|
data = _try_load_json(f)
|
||||||
|
if data is not None:
|
||||||
|
self._load_file_data(f, data)
|
||||||
|
|
||||||
|
def _load_file_data(self, path: Path, data: Dict[str, Any]) -> None:
|
||||||
|
name = path.stem
|
||||||
|
if name == "tasks":
|
||||||
|
for tid, tpl in (data.get("task_templates") or {}).items():
|
||||||
|
if isinstance(tpl, dict) and isinstance(tpl.get("steps"), list):
|
||||||
|
self._tasks[tid] = tpl
|
||||||
|
return
|
||||||
|
domain = data.get("domain", name)
|
||||||
|
for item in data.get("rules") or []:
|
||||||
|
if isinstance(item, dict) and item.get("id"):
|
||||||
|
self._register_rule({**item, "domain": domain})
|
||||||
|
for fact in data.get("facts") or []:
|
||||||
|
if isinstance(fact, dict) and fact.get("id"):
|
||||||
|
self._facts.setdefault(domain, []).append(fact)
|
||||||
|
|
||||||
|
def _register_rule(self, item: Dict[str, Any]) -> None:
|
||||||
|
rule = Rule(
|
||||||
|
id=str(item["id"]),
|
||||||
|
domain=str(item.get("domain", "general")),
|
||||||
|
priority=int(item.get("priority", 50)),
|
||||||
|
patterns=[str(p) for p in item.get("patterns", [])],
|
||||||
|
template=item.get("template"),
|
||||||
|
output=item.get("output"),
|
||||||
|
actions=[str(a) for a in item.get("actions", [])],
|
||||||
|
subdomain=item.get("subdomain") or SUBDOMAIN_MAP.get(str(item["id"])),
|
||||||
|
subdomain2=item.get("subdomain2") or SUBDOMAIN2_MAP.get(str(item["id"])),
|
||||||
|
)
|
||||||
|
self._rules[rule.id] = rule
|
||||||
|
|
||||||
|
# ---- 查询 ----
|
||||||
|
def match(self, text: str, domain: Optional[str] = None) -> List[Rule]:
|
||||||
|
"""返回命中的规则,按优先级降序。domain 为空则全领域匹配。"""
|
||||||
|
hits = []
|
||||||
|
for rule in self._rules.values():
|
||||||
|
if domain is not None and rule.domain != domain:
|
||||||
|
continue
|
||||||
|
if rule.matches(text):
|
||||||
|
hits.append(rule)
|
||||||
|
hits.sort(key=lambda r: r.priority, reverse=True)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
def rule(self, rule_id: str) -> Optional[Rule]:
|
||||||
|
return self._rules.get(rule_id)
|
||||||
|
|
||||||
|
def rules_count(self) -> int:
|
||||||
|
return len(self._rules)
|
||||||
|
|
||||||
|
def task_template(self, tid: str) -> Optional[Dict[str, Any]]:
|
||||||
|
return self._tasks.get(tid)
|
||||||
|
|
||||||
|
def task_ids(self) -> List[str]:
|
||||||
|
return sorted(self._tasks.keys())
|
||||||
|
|
||||||
|
def facts(self, domain: str) -> List[Dict[str, Any]]:
|
||||||
|
return self._facts.get(domain, [])
|
||||||
|
|
||||||
|
def domains(self) -> List[str]:
|
||||||
|
return sorted({r.domain for r in self._rules.values()})
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""黑板(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)
|
||||||
@@ -44,6 +44,10 @@ 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 {
|
||||||
@@ -61,6 +65,10 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""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.MockTransport,worker 用假 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")
|
||||||
|
|
||||||
|
# ---- brief(Architect 一次) ----
|
||||||
|
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:
|
||||||
|
# 有 key:Architect 兜底代做
|
||||||
|
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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""规则 Planner:把查询拆解为子任务 DAG(任务分解,专家系统风格,零参数)。
|
||||||
|
|
||||||
|
拆解逻辑(确定性规则):
|
||||||
|
1. 在分类领域内匹配知识规则
|
||||||
|
2. 取最高优先级且带 template 的命中规则 → 对应任务模板
|
||||||
|
3. 非 easy 难度且有模板 → 生成多节点 DAG(模板 steps 转 TaskNode,含依赖)
|
||||||
|
4. easy 难度或无模板命中 → 单节点直接求解(不拆,最小开销)
|
||||||
|
5. 拆解深度防护:节点不再递归拆解(当前为单层拆解,模板本身即最终粒度)
|
||||||
|
|
||||||
|
对齐架构目标:"路由模型把任务拆解后分步骤交给各个小模型",
|
||||||
|
L0 模式下各子任务由规则执行器完成(零参数),L2 模式可交给本地小模型。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .knowledge import KnowledgeBase
|
||||||
|
from .memory import TaskGraph, TaskNode
|
||||||
|
from .models import Classification
|
||||||
|
|
||||||
|
# 单节点求解时按领域选择默认动作 kind
|
||||||
|
_SINGLE_KIND = {
|
||||||
|
"code": "implement",
|
||||||
|
"math": "solve",
|
||||||
|
"legal": "conclude",
|
||||||
|
"medical": "advise",
|
||||||
|
"general": "explain",
|
||||||
|
"finance": "conclude",
|
||||||
|
"life": "advise",
|
||||||
|
"education": "design",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 强制拆解领域:即使 easy 也走完整任务模板
|
||||||
|
# (legal 需要 retrieve+disclaimer,medical 需要 advise+warning,
|
||||||
|
# finance 需要 retrieve+风险免责——均为领域硬要求)
|
||||||
|
FORCE_SPLIT_DOMAINS = {"legal", "medical", "finance"}
|
||||||
|
|
||||||
|
# 强制拆解模板:命中即拆(debug 流程必须 analyze→diagnose→fix→verify)
|
||||||
|
FORCE_SPLIT_TEMPLATES = {"code-debug"}
|
||||||
|
|
||||||
|
|
||||||
|
class Planner:
|
||||||
|
"""规则 Planner:查询 → 子任务 DAG。"""
|
||||||
|
|
||||||
|
def __init__(self, kb: KnowledgeBase, max_depth: int = 3):
|
||||||
|
self.kb = kb
|
||||||
|
self.max_depth = max_depth
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def plan(self, query: str, classification: Classification) -> TaskGraph:
|
||||||
|
domain = classification.domain
|
||||||
|
difficulty = classification.difficulty
|
||||||
|
|
||||||
|
# 1. 领域内匹配规则,取最高优先级带模板的规则
|
||||||
|
template_id: Optional[str] = None
|
||||||
|
hits = self.kb.match(query, domain=domain)
|
||||||
|
for h in hits:
|
||||||
|
if h.template:
|
||||||
|
template_id = h.template
|
||||||
|
break
|
||||||
|
|
||||||
|
graph = TaskGraph()
|
||||||
|
|
||||||
|
# 2. 非 easy / 强制拆解领域 / 强制拆解模板 → 多节点 DAG
|
||||||
|
if template_id and (difficulty != "easy"
|
||||||
|
or domain in FORCE_SPLIT_DOMAINS
|
||||||
|
or template_id in FORCE_SPLIT_TEMPLATES):
|
||||||
|
tpl = self.kb.task_template(template_id)
|
||||||
|
if tpl and tpl.get("steps"):
|
||||||
|
for step in tpl["steps"]:
|
||||||
|
node = TaskNode(
|
||||||
|
id=str(step["id"]),
|
||||||
|
kind=str(step.get("kind", "solve")),
|
||||||
|
domain=str(step.get("domain", domain)),
|
||||||
|
query=query,
|
||||||
|
deps=[str(d) for d in step.get("deps", [])],
|
||||||
|
desc=str(step.get("desc", "")),
|
||||||
|
)
|
||||||
|
graph.add_node(node)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
# 3. easy / 无模板 → 单节点
|
||||||
|
kind = _SINGLE_KIND.get(domain, "explain")
|
||||||
|
graph.add_node(TaskNode(
|
||||||
|
id="solve",
|
||||||
|
kind=kind,
|
||||||
|
domain=domain,
|
||||||
|
query=query,
|
||||||
|
desc=f"单节点求解({domain}/{difficulty})",
|
||||||
|
))
|
||||||
|
return graph
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def explain_plan(self, graph: TaskGraph) -> List[str]:
|
||||||
|
"""把 DAG 渲染为可读的拆解轨迹(用于 route 与 --trace)。"""
|
||||||
|
if len(graph) == 1:
|
||||||
|
n = graph.nodes()[0]
|
||||||
|
return [f"plan:single[{n.kind}]"]
|
||||||
|
parts = []
|
||||||
|
for n in graph.topo_order():
|
||||||
|
dep = f"<{','.join(n.deps)}" if n.deps else ""
|
||||||
|
parts.append(f"{n.id}:{n.kind}{dep}")
|
||||||
|
return [f"plan:multi[{len(graph)}]({' -> '.join(parts)})"]
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""人工检验队列(ReviewQueue)—— 第三个协作者(纯标准库 sqlite3)。
|
||||||
|
|
||||||
|
复用同一"交流文本"协议:异步队列,不阻塞响应路径。系统交付后按抽样率或
|
||||||
|
safety 标签强制规则入队,由人工审核给出 verdict(approve/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"))
|
||||||
@@ -1,26 +1,39 @@
|
|||||||
"""主路由器:协调 缓存 -> 分类 -> 专家 -> Judge -> 大模型回退 的完整链路。
|
"""主路由器:两级路由(大领域组 → 组内路由模型 → 专业执行器)专家系统编排。
|
||||||
|
|
||||||
流程(对齐实现方案):
|
两级体系(对齐用户架构决策):
|
||||||
1. 检查缓存(L1 精确 / L2 语义)
|
第一级:用户通过接口指定大领域组(domain_group: tech/professional/lifestyle/general),
|
||||||
2. 低置信度查询直接走大模型(should_fallback)
|
或系统自动检测(8 领域分类 → 映射到组)
|
||||||
3. 分类器输出领域 + 难度
|
第二级:组内路由模型(RuleClassifier(domains=组内领域) + 组内知识/模板)识别具体
|
||||||
4. 选择专家模型生成
|
领域、子领域、拆解子任务 → 组内专业小模型/规则执行器
|
||||||
5. Judge 评估质量
|
组内路由模型只认识本组领域:体积与匹配开销约为统一路由模型的 1/4,
|
||||||
6. 质量不达标 -> 升级大模型
|
且未来 L2 模型层可每组一个更小的路由模型,按需加载不常驻。
|
||||||
7. 记录指标、写缓存、返回结果
|
|
||||||
|
链路:缓存 → 组路由(分类/子领域/拆解) → 黑板+前向链 → DAG 执行 → 合并
|
||||||
|
→ Judge 校验 → (不达标)最后处理者升级 → 缓存/指标
|
||||||
|
|
||||||
|
L0 模式(默认):规则分类 + 规则拆解 + 规则执行器 + 规则 Judge —— 零模型参数、零 API。
|
||||||
|
L2 模式(可选):execution.expert_backend = hf/api 时,子任务改由专家池小模型执行
|
||||||
|
(≤8B,按需加载),其余流程不变。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .cache import RouterCache
|
from .cache import RouterCache
|
||||||
from .classifier import BaseClassifier, build_classifier
|
from .classifier import BaseClassifier, RuleClassifier, 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:
|
||||||
@@ -33,6 +46,8 @@ 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
|
||||||
@@ -45,11 +60,43 @@ 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) -> RouterResult:
|
async def route(self, query: str, domain_group: Optional[str] = None) -> 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:
|
||||||
@@ -71,71 +118,132 @@ 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: 组路由(两级第一级)→ 组内分类(两级第二级) ----
|
||||||
classification = self.classifier.classify(query)
|
classifier = self.classifier
|
||||||
|
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 self.classifier.should_fallback(classification, self.low_confidence_threshold):
|
if classifier.should_fallback(classification, self.low_confidence_threshold):
|
||||||
route.append("direct_fallback")
|
route.append("direct_fallback")
|
||||||
fb = await self._call_fallback(query)
|
fb = await self._call_fallback(query)
|
||||||
latency = now_ms() - 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)
|
|
||||||
self._record(result, latency)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ---- Step 3: 选择专家 ----
|
|
||||||
domain = classification.domain
|
|
||||||
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 4: 生成 ----
|
|
||||||
try:
|
|
||||||
expert_resp = await expert.generate(query, classification.difficulty)
|
|
||||||
except Exception as e:
|
|
||||||
self.stats.record_error()
|
|
||||||
route.append(f"expert_error:{type(e).__name__}")
|
|
||||||
fb = await self._call_fallback(query)
|
|
||||||
latency = now_ms() - start
|
|
||||||
result = self._finalize(query, classification, fb, quality_score=0.0,
|
result = self._finalize(query, classification, fb, quality_score=0.0,
|
||||||
upgraded=True, route=route, latency_ms=latency,
|
upgraded=True, route=route, latency_ms=latency,
|
||||||
model_used=fb.model_used, cost_est=fb.cost_est,
|
model_used=fb.model_used, cost_est=fb.cost_est,
|
||||||
error=str(e))
|
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)
|
self._record(result, latency)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ---- Step 5: Judge 评估 ----
|
# ---- Step 4: Planner 任务拆解(DAG) ----
|
||||||
|
graph = self.planner.plan(query, classification)
|
||||||
|
route.extend(self.planner.explain_plan(graph))
|
||||||
|
|
||||||
|
# ---- Step 5: 黑板初始化 + 前向链(规则轨迹) ----
|
||||||
|
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, expert_resp.text, domain)
|
evaluation = await self.judge.evaluate(query, response, classification.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")
|
||||||
final_resp = await self._call_fallback(query)
|
fb = 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, final_resp, quality_score=quality_score,
|
result = self._finalize(query, classification, ExpertResponse(
|
||||||
upgraded=upgraded, route=route, latency_ms=latency,
|
text=response, model_used=last_model, latency_ms=latency,
|
||||||
model_used=final_resp.model_used, cost_est=final_resp.cost_est)
|
tokens=max(8, int(len(response) / 2.2)), cost_est=0.0,
|
||||||
|
), 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)
|
||||||
|
|
||||||
# 未升级的结果写缓存
|
# 未升级的结果写缓存
|
||||||
@@ -144,6 +252,80 @@ class Router:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _store_trace(self, request_id: str, query: str, group: Optional[str],
|
||||||
|
domain: str, difficulty: str, confidence: float,
|
||||||
|
subdomain: Optional[str], subdomain2: Optional[str],
|
||||||
|
route: list, quality: float, upgraded: bool,
|
||||||
|
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)
|
||||||
|
sub = None
|
||||||
|
sub2 = None
|
||||||
|
for h in hits:
|
||||||
|
if sub is None and h.subdomain:
|
||||||
|
sub = h.subdomain
|
||||||
|
if sub2 is None and h.subdomain2:
|
||||||
|
sub2 = h.subdomain2
|
||||||
|
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:
|
||||||
@@ -151,7 +333,7 @@ class Router:
|
|||||||
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,
|
||||||
)
|
)
|
||||||
@@ -160,7 +342,10 @@ 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) -> RouterResult:
|
error: Optional[str] = None,
|
||||||
|
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,
|
||||||
@@ -175,6 +360,9 @@ 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):
|
||||||
@@ -194,18 +382,25 @@ 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(默认 mock 全链路,零依赖可跑)。"""
|
"""从配置构建完整 Router(默认 L0 专家系统模式:零参数可跑)。"""
|
||||||
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", {}), config.get("router", {}).get("judge_fallback_threshold", 0.70))
|
judge = build_judge(config.get("judge", {}),
|
||||||
|
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(
|
||||||
@@ -213,5 +408,8 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
|
||||||
|
|
||||||
|
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
|
||||||
|
- TOOLS_SPEC:list_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_calls(arguments 容错为 {})
|
||||||
|
- 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 Prefix(dsh 同款拒绝项)
|
||||||
|
_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": "要抓取的完整 URL(http/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}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""推理链轨迹存储(T3:整体项目部分拆解·先行实现)。
|
||||||
|
|
||||||
|
内存环形缓冲(零依赖):记录每次请求的完整推理链(两级路由决策、
|
||||||
|
三级子领域、规则触发、任务拆解、节点执行、质量评分),支持按请求 ID 追溯。
|
||||||
|
可解释性 = 专家系统 vs 黑盒 LLM 的差异化护城河。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any, Deque, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class TraceStore:
|
||||||
|
"""请求推理链轨迹存储(线程安全,环形淘汰)。"""
|
||||||
|
|
||||||
|
def __init__(self, max_entries: int = 1000):
|
||||||
|
self._max = max_entries
|
||||||
|
self._entries: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._order: Deque[str] = deque(maxlen=max_entries)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def put(self, request_id: str, trace: Dict[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if request_id in self._entries:
|
||||||
|
self._entries[request_id] = trace
|
||||||
|
return
|
||||||
|
if len(self._entries) >= self._max:
|
||||||
|
# 环形淘汰最旧
|
||||||
|
while self._order:
|
||||||
|
oldest = self._order.popleft()
|
||||||
|
if oldest in self._entries:
|
||||||
|
del self._entries[oldest]
|
||||||
|
break
|
||||||
|
self._entries[request_id] = trace
|
||||||
|
self._order.append(request_id)
|
||||||
|
|
||||||
|
def get(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
return self._entries.get(request_id)
|
||||||
|
|
||||||
|
def size(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._entries)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.clear()
|
||||||
|
self._order.clear()
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""接地验证器(D4)—— Worker 自验证的"接地"来源。
|
||||||
|
|
||||||
|
验证分层优先级(D4):
|
||||||
|
可执行验证(跑代码/跑测试) > facts 对照 > 结构检查 > 模型自由判断(最后手段)
|
||||||
|
|
||||||
|
- 可执行验证:code 域 .py 工件在临时目录子进程沙箱运行(timeout、-I 隔离、捕获输出)。
|
||||||
|
- facts 对照:把回答/工件与知识库 facts(statement/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)))
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
|
||||||
|
|
||||||
|
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
|
||||||
|
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
|
||||||
|
|
||||||
|
本模块实现(对齐《实现方案_v2》第 4 节):
|
||||||
|
- WORKSPACE_SCHEMA:draft-07 风格 schema 常量(文档/校验依据)
|
||||||
|
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
|
||||||
|
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
|
||||||
|
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token)
|
||||||
|
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
|
||||||
|
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
VERSION = "1.0"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 字段长度上限(同时是 rollup 依据,4.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LIMITS = {
|
||||||
|
"goal": 500, # brief.goal 字数
|
||||||
|
"task": 300, # brief.plan[].task 字数
|
||||||
|
"summary": 200, # progress[].summary 字数
|
||||||
|
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
|
||||||
|
"reply": 600, # decisions[].reply 字数
|
||||||
|
"archive": 160, # archive[] 每条字数
|
||||||
|
"constraints": 8, # brief.constraints 上限条数
|
||||||
|
"plan_steps": 5, # brief.plan 上限步数
|
||||||
|
"acceptance": 20, # brief.acceptance 上限条数
|
||||||
|
"query_truncate": 200, # render_for_architect 中 query 截断
|
||||||
|
}
|
||||||
|
|
||||||
|
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
|
||||||
|
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
|
||||||
|
"life", "education", "general", "safety", "science"}
|
||||||
|
|
||||||
|
STATUS_FLOW = {
|
||||||
|
"draft": {"in_progress"},
|
||||||
|
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
|
||||||
|
"reviewing": {"done", "in_progress", "failed"},
|
||||||
|
"escalated": {"reviewing", "done", "failed"},
|
||||||
|
"done": set(),
|
||||||
|
"failed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON Schema(draft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
WORKSPACE_SCHEMA: Dict[str, Any] = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Communication Workspace",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "request_id", "query", "meta"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"version": {"const": VERSION},
|
||||||
|
"request_id": {"type": "string", "minLength": 1},
|
||||||
|
"query": {"type": "string", "minLength": 1},
|
||||||
|
"meta": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["status", "round", "budget"],
|
||||||
|
"properties": {
|
||||||
|
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
|
||||||
|
"round": {"type": "integer", "minimum": 0},
|
||||||
|
"budget": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
|
||||||
|
"properties": {
|
||||||
|
"api_input_tokens": {"type": "integer", "minimum": 0},
|
||||||
|
"api_output_tokens": {"type": "integer", "minimum": 0},
|
||||||
|
"api_token_cap": {"type": "integer", "minimum": 1},
|
||||||
|
"rounds_cap": {"type": "integer", "minimum": 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"brief": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
|
||||||
|
"properties": {
|
||||||
|
"goal": {"type": "string"},
|
||||||
|
"constraints": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"acceptance": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"plan": {"type": "array", "items": {"type": "object"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"progress": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"issues": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"decisions": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"archive": {"type": "array", "items": {"type": "string"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 工具
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
|
||||||
|
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
|
||||||
|
en = len(text) - zh
|
||||||
|
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
|
||||||
|
|
||||||
|
|
||||||
|
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
|
||||||
|
"""构造锚点:a://<file>#L<start>-<end>;end 缺省仅 L<start>。"""
|
||||||
|
if end is None:
|
||||||
|
return f"a://{filename}#L{start}"
|
||||||
|
return f"a://{filename}#L{start}-{end}"
|
||||||
|
|
||||||
|
|
||||||
|
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""解析锚点为 {file, start, end};非法返回 None。"""
|
||||||
|
m = _ANCHOR_RE.match(anchor)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
start = int(m.group("start")) if m.group("start") else 1
|
||||||
|
end = int(m.group("end")) if m.group("end") else start
|
||||||
|
return {"file": m.group("file"), "start": start, "end": end}
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(text: str, limit: int) -> str:
|
||||||
|
"""按字数截断(中文按字符)。"""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 校验
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def validate(ws: Dict[str, Any]) -> List[str]:
|
||||||
|
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
|
||||||
|
errors: List[str] = []
|
||||||
|
if not isinstance(ws, dict):
|
||||||
|
return ["workspace 必须是 object"]
|
||||||
|
if ws.get("version") != VERSION:
|
||||||
|
errors.append(f"version 必须是 {VERSION}")
|
||||||
|
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
|
||||||
|
errors.append("request_id 必须是非空字符串")
|
||||||
|
if not isinstance(ws.get("query"), str) or not ws["query"]:
|
||||||
|
errors.append("query 必须是非空字符串")
|
||||||
|
|
||||||
|
meta = ws.get("meta")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
errors.append("meta 必须是 object")
|
||||||
|
else:
|
||||||
|
if meta.get("status") not in STATUS_FLOW:
|
||||||
|
errors.append(f"meta.status 非法: {meta.get('status')}")
|
||||||
|
budget = meta.get("budget")
|
||||||
|
if not isinstance(budget, dict):
|
||||||
|
errors.append("meta.budget 必须是 object")
|
||||||
|
else:
|
||||||
|
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
|
||||||
|
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
|
||||||
|
errors.append(f"meta.budget.{k} 必须是非负整数")
|
||||||
|
|
||||||
|
brief = ws.get("brief")
|
||||||
|
if brief is not None:
|
||||||
|
if not isinstance(brief, dict):
|
||||||
|
errors.append("brief 必须是 object")
|
||||||
|
else:
|
||||||
|
if not isinstance(brief.get("goal"), str):
|
||||||
|
errors.append("brief.goal 必须是字符串")
|
||||||
|
elif len(brief["goal"]) > LIMITS["goal"]:
|
||||||
|
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
|
||||||
|
if not isinstance(brief.get("constraints"), list):
|
||||||
|
errors.append("brief.constraints 必须是数组")
|
||||||
|
elif len(brief["constraints"]) > LIMITS["constraints"]:
|
||||||
|
errors.append(f"brief.constraints 超过 {LIMITS['constraints']} 条")
|
||||||
|
if not isinstance(brief.get("tags"), list):
|
||||||
|
errors.append("brief.tags 必须是数组")
|
||||||
|
for t in brief.get("tags", []) or []:
|
||||||
|
if t not in ALLOWED_TAGS:
|
||||||
|
errors.append(f"brief.tags 含非法标签: {t}")
|
||||||
|
acc = brief.get("acceptance")
|
||||||
|
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
|
||||||
|
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
|
||||||
|
plan = brief.get("plan")
|
||||||
|
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
|
||||||
|
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
|
||||||
|
else:
|
||||||
|
ids = [p.get("id") for p in plan if isinstance(p, dict)]
|
||||||
|
if len(set(ids)) != len(ids):
|
||||||
|
errors.append("brief.plan 存在重复 step id")
|
||||||
|
for p in plan:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
errors.append("brief.plan 元素必须是 object")
|
||||||
|
continue
|
||||||
|
if not isinstance(p.get("task"), str):
|
||||||
|
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
|
||||||
|
elif len(p["task"]) > LIMITS["task"]:
|
||||||
|
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("progress", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"progress[{i}] 必须是 object"); continue
|
||||||
|
if entry.get("status") not in ("done", "failed", "blocked"):
|
||||||
|
errors.append(f"progress[{i}].status 非法")
|
||||||
|
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
|
||||||
|
errors.append(f"progress[{i}].summary 非法或超长")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("issues", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"issues[{i}] 必须是 object"); continue
|
||||||
|
for k in ("observed", "expected", "tried", "ask"):
|
||||||
|
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
|
||||||
|
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("decisions", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"decisions[{i}] 必须是 object"); continue
|
||||||
|
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
|
||||||
|
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
|
||||||
|
|
||||||
|
for i, line in enumerate(ws.get("archive", []) or []):
|
||||||
|
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
|
||||||
|
errors.append(f"archive[{i}] 非法或超长")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Workspace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class Workspace:
|
||||||
|
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
|
||||||
|
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
errors = validate(data)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
|
||||||
|
self._data = data
|
||||||
|
self._brief_locked = False
|
||||||
|
|
||||||
|
# ---------- 构造 ----------
|
||||||
|
@classmethod
|
||||||
|
def new(cls, request_id: str, query: str,
|
||||||
|
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
|
||||||
|
data = {
|
||||||
|
"version": VERSION,
|
||||||
|
"request_id": request_id,
|
||||||
|
"query": query,
|
||||||
|
"meta": {
|
||||||
|
"status": "draft",
|
||||||
|
"round": 0,
|
||||||
|
"budget": {
|
||||||
|
"api_input_tokens": 0,
|
||||||
|
"api_output_tokens": 0,
|
||||||
|
"api_token_cap": api_token_cap,
|
||||||
|
"rounds_cap": rounds_cap,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"brief": None,
|
||||||
|
"progress": [],
|
||||||
|
"issues": [],
|
||||||
|
"decisions": [],
|
||||||
|
"archive": [],
|
||||||
|
}
|
||||||
|
return cls(data)
|
||||||
|
|
||||||
|
# ---------- 访问 ----------
|
||||||
|
@property
|
||||||
|
def request_id(self) -> str:
|
||||||
|
return self._data["request_id"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self._data["meta"]["status"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self) -> Dict[str, Any]:
|
||||||
|
return copy.deepcopy(self._data)
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def meta(self) -> Dict[str, Any]:
|
||||||
|
return self._data["meta"]
|
||||||
|
|
||||||
|
def budget(self) -> Dict[str, int]:
|
||||||
|
return self._data["meta"]["budget"]
|
||||||
|
|
||||||
|
# ---------- 写入(均先校验) ----------
|
||||||
|
def _commit(self, data: Dict[str, Any]) -> None:
|
||||||
|
errors = validate(data)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def transition(self, new_status: str) -> None:
|
||||||
|
cur = self.status
|
||||||
|
if new_status == cur:
|
||||||
|
return
|
||||||
|
if new_status not in STATUS_FLOW.get(cur, set()):
|
||||||
|
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
|
||||||
|
self._data["meta"]["status"] = new_status
|
||||||
|
|
||||||
|
def apply_brief(self, brief: Dict[str, Any]) -> None:
|
||||||
|
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
|
||||||
|
if self._data.get("brief") is not None or self._brief_locked:
|
||||||
|
raise ValueError("brief 已写入,不可重复")
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
new["brief"] = brief
|
||||||
|
new["meta"]["status"] = "in_progress"
|
||||||
|
self._commit(new)
|
||||||
|
self._brief_locked = True
|
||||||
|
|
||||||
|
def add_progress(self, step: str, status: str, summary: str,
|
||||||
|
artifact: Optional[str] = None) -> None:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
|
||||||
|
if artifact:
|
||||||
|
entry["artifact"] = artifact
|
||||||
|
new["progress"].append(entry)
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
|
||||||
|
tried: str, ask: str) -> str:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
iid = f"i{len(new['issues']) + 1}"
|
||||||
|
entry = {
|
||||||
|
"id": iid, "step": step, "anchor": anchor,
|
||||||
|
"observed": observed, "expected": expected,
|
||||||
|
"tried": tried, "ask": ask,
|
||||||
|
}
|
||||||
|
new["issues"].append(entry)
|
||||||
|
self._commit(new)
|
||||||
|
return iid
|
||||||
|
|
||||||
|
def add_decision(self, ref: str, reply: str,
|
||||||
|
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
new["decisions"].append({
|
||||||
|
"ref": ref, "reply": reply,
|
||||||
|
"patch_plan": patch_plan or [],
|
||||||
|
})
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def revise_plan(self, updates: Dict[str, str]) -> None:
|
||||||
|
"""按 decision.patch_plan 修订既有 step 的 task(不改结构/顺序)。"""
|
||||||
|
if self._data.get("brief") is None:
|
||||||
|
raise ValueError("brief 尚未写入,无法修订 plan")
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
for pid, task in updates.items():
|
||||||
|
for p in new["brief"]["plan"]:
|
||||||
|
if p["id"] == pid:
|
||||||
|
p["task"] = task
|
||||||
|
break
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def mark_round(self) -> None:
|
||||||
|
self._data["meta"]["round"] += 1
|
||||||
|
|
||||||
|
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
|
||||||
|
b = self._data["meta"]["budget"]
|
||||||
|
b["api_input_tokens"] += int(input_tokens)
|
||||||
|
b["api_output_tokens"] += int(output_tokens)
|
||||||
|
self._commit(self._data)
|
||||||
|
|
||||||
|
def exhausted(self) -> bool:
|
||||||
|
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
|
||||||
|
b = self._data["meta"]["budget"]
|
||||||
|
used = b["api_input_tokens"] + b["api_output_tokens"]
|
||||||
|
if b["api_token_cap"] and used >= b["api_token_cap"]:
|
||||||
|
return True
|
||||||
|
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------- rollup(4.5) ----------
|
||||||
|
def rollup(self) -> int:
|
||||||
|
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
|
||||||
|
|
||||||
|
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
|
||||||
|
返回本次折叠的条目数。
|
||||||
|
"""
|
||||||
|
folded = 0
|
||||||
|
new_progress: List[Dict[str, Any]] = []
|
||||||
|
for entry in self._data.get("progress", []):
|
||||||
|
if entry.get("status") == "done" and entry.get("step"):
|
||||||
|
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
|
||||||
|
if line not in self._data["archive"]:
|
||||||
|
self._data["archive"].append(line)
|
||||||
|
folded += 1
|
||||||
|
else:
|
||||||
|
new_progress.append(entry)
|
||||||
|
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
|
||||||
|
kept_issues: List[Dict[str, Any]] = []
|
||||||
|
for iss in self._data.get("issues", []):
|
||||||
|
if iss.get("id") in resolved:
|
||||||
|
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
|
||||||
|
if line not in self._data["archive"]:
|
||||||
|
self._data["archive"].append(line)
|
||||||
|
else:
|
||||||
|
kept_issues.append(iss)
|
||||||
|
self._data["issues"] = kept_issues
|
||||||
|
self._data["progress"] = new_progress
|
||||||
|
return folded
|
||||||
|
|
||||||
|
# ---------- 渲染 ----------
|
||||||
|
def render_for_architect(self) -> str:
|
||||||
|
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
|
||||||
|
+最近回合 progress 摘要。目标 ≤1200 token。"""
|
||||||
|
d = self._data
|
||||||
|
parts: List[str] = []
|
||||||
|
m = d["meta"]
|
||||||
|
parts.append("== meta ==")
|
||||||
|
parts.append(f"status={m['status']} round={m['round']} "
|
||||||
|
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
|
||||||
|
parts.append("== query ==")
|
||||||
|
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
|
||||||
|
if d.get("brief"):
|
||||||
|
b = d["brief"]
|
||||||
|
parts.append("== brief(锁定) ==")
|
||||||
|
parts.append(f"goal: {_clip(b['goal'], 120)}")
|
||||||
|
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
|
||||||
|
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
|
||||||
|
parts.append("== issues ==")
|
||||||
|
for iss in d.get("issues", []):
|
||||||
|
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
|
||||||
|
f"ask={_clip(iss.get('ask', ''), 80)}")
|
||||||
|
parts.append("== 最近 3 条 decisions ==")
|
||||||
|
for dec in d.get("decisions", [])[-3:]:
|
||||||
|
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
|
||||||
|
parts.append("== progress 摘要 ==")
|
||||||
|
for p in d.get("progress", [])[-5:]:
|
||||||
|
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
|
||||||
|
parts.append("== archive ==")
|
||||||
|
for line in d.get("archive", [])[-8:]:
|
||||||
|
parts.append(line)
|
||||||
|
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions)
|
||||||
|
out = "\n".join(parts)
|
||||||
|
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
|
||||||
|
d = copy.deepcopy(d)
|
||||||
|
d["archive"] = d["archive"][4:]
|
||||||
|
out = "\n".join(_rerender(self, d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def render_for_worker(self, step_id: str,
|
||||||
|
artifact_text: Optional[str] = None) -> str:
|
||||||
|
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
|
||||||
|
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
|
||||||
|
d = self._data
|
||||||
|
b = d.get("brief")
|
||||||
|
parts: List[str] = []
|
||||||
|
if b:
|
||||||
|
parts.append("== 任务目标 (goal) ==")
|
||||||
|
parts.append(b["goal"])
|
||||||
|
parts.append("== 约束 (constraints) ==")
|
||||||
|
parts.extend(f"- {c}" for c in b.get("constraints", []))
|
||||||
|
parts.append("== 全部步骤 (plan) ==")
|
||||||
|
for p in b.get("plan", []):
|
||||||
|
mark = " <-- 当前步" if p.get("id") == step_id else ""
|
||||||
|
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
|
||||||
|
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
|
||||||
|
parts.append("== 依赖步摘要 (archive) ==")
|
||||||
|
for line in d.get("archive", [])[-6:]:
|
||||||
|
parts.append(line)
|
||||||
|
if artifact_text:
|
||||||
|
parts.append(f"== 当前步已有工件({step_id}) ==")
|
||||||
|
parts.append(artifact_text)
|
||||||
|
parts.append("== 验收标准 ==")
|
||||||
|
if b:
|
||||||
|
for a in b.get("acceptance", []):
|
||||||
|
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
|
||||||
|
f"(machine_checkable={a.get('machine_checkable', False)})")
|
||||||
|
parts.append("== 要求 ==")
|
||||||
|
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
|
||||||
|
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
# ---------- 持久化 ----------
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""运维层(runtime):本地 llama.cpp 运行时与硬件档位管理。
|
||||||
|
|
||||||
|
与 router_system 核心解耦:本包允许使用 httpx/fastapi 等第三方依赖,
|
||||||
|
用于真实本地模型(llama-server)的进程生命周期管理与硬件适配。
|
||||||
|
核心协议(交流文本)仍在 router_system 内保持零第三方依赖。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""硬件档位检测(runtime 运维层,纯标准库)。
|
||||||
|
|
||||||
|
把真实机器映射到三档保守模板之一:
|
||||||
|
|
||||||
|
- gpu12 : 约 ≥12GB 显存(NVIDIA / Vulkan 可探测) -> ngl 99, ctx 32768
|
||||||
|
- gpu8 : 约 ≥8GB 显存 -> ngl 14, ctx 16384
|
||||||
|
- cpu : 无独显或探测失败(保守兜底) -> ngl 0, ctx 8192
|
||||||
|
|
||||||
|
探测来源:nvidia-smi(NVIDIA 显存)优先;其次 vulkaninfo(AMD/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', '')}]"
|
||||||
|
)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""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:
|
||||||
|
"""优雅停止:terminate(CTRL_BREAK)-> 等待 -> kill 兜底(Windows 语义)。"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
if proc.poll() is not None:
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=timeout_s)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._log("terminate 超时,kill 兜底")
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
self._log("已停止")
|
||||||
|
|
||||||
|
def ensure_alive(self) -> bool:
|
||||||
|
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
|
||||||
|
if self._proc is not None and self._proc.poll() is None and self.health():
|
||||||
|
return True
|
||||||
|
if self._restart_count >= self.max_restarts:
|
||||||
|
return False
|
||||||
|
backoff = min(2.0 ** self._restart_count, 8.0)
|
||||||
|
self._restart_count += 1
|
||||||
|
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
|
||||||
|
time.sleep(backoff)
|
||||||
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
|
self.stop()
|
||||||
|
return self.start()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._proc is not None and self._proc.poll() is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid(self) -> Optional[int]:
|
||||||
|
return self._proc.pid if self._proc is not None else None
|
||||||
|
|
||||||
|
def __enter__(self) -> "LlamaServerManager":
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
|
||||||
|
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
|
||||||
|
binary = cfg.get("binary", "bin/llama-server.exe")
|
||||||
|
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
|
||||||
|
port = int(cfg.get("port", 8901))
|
||||||
|
hw = cfg.get("hw", {}) or {}
|
||||||
|
extra = cfg.get("extra_args", [])
|
||||||
|
return LlamaServerManager(
|
||||||
|
binary=binary,
|
||||||
|
model=model,
|
||||||
|
port=port,
|
||||||
|
hw=hw,
|
||||||
|
extra_args=extra,
|
||||||
|
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
|
||||||
|
max_restarts=int(cfg.get("max_restarts", 2)),
|
||||||
|
log_dir=cfg.get("log_dir"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""E1 token 经济学实验脚本(论文主实验,本地确定性可跑)。
|
||||||
|
|
||||||
|
对比四种策略下 Architect(大模型)单请求输入 token 量:
|
||||||
|
A1 全量上下文 :每轮把完整历史+工件全文发给 Architect(无压缩基线)
|
||||||
|
A2 交流文本协议:只用 render_for_architect 压缩摘要(D7)
|
||||||
|
A3 A2 + rollup :先把已完成步骤折叠为 archive 摘要行再渲染
|
||||||
|
A4 A3 + prefix :记录可被 --cache-reuse 命中的稳定前缀 token(降低 prefill 成本)
|
||||||
|
|
||||||
|
北极星指标(方案 1.0):A2/A3/A4 相对 A1 的 token 下降 ≥80%。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/bench_tokens.py [--data eval/v2_sample.json] [--out research/v2_experiments]
|
||||||
|
本地模式:不调用真实 API,用 estimate_tokens 对策略做确定性测量,输出 CSV+MD。
|
||||||
|
--live 模式(可选,需 API key + 本地模型):走真实管线记录 usage。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from router_system.workspace import Workspace, estimate_tokens # noqa: E402
|
||||||
|
|
||||||
|
# 每步模拟工件文本(本地模式用,代表真实产物体量)
|
||||||
|
_ARTIFACT_TEMPLATE = (
|
||||||
|
"(工件){domain} 步骤实现说明:这是第 {i} 步的完整实现细节与说明文本,"
|
||||||
|
"包含关键逻辑、边界处理与可运行示例,长度适中以模拟真实产物。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _brief_for(query: str, domain: str, n_steps: int = 3) -> dict:
|
||||||
|
return {
|
||||||
|
"goal": query,
|
||||||
|
"constraints": ["遵守领域规范", "输出可交付"],
|
||||||
|
"tags": [domain],
|
||||||
|
"acceptance": [{"id": "a1", "check": "满足用户需求", "machine_checkable": True}],
|
||||||
|
"plan": [
|
||||||
|
{"id": f"s{i+1}", "task": f"{domain} 步骤{i+1}:推进目标", "deps": [] if i == 0 else [f"s{i}"],
|
||||||
|
"done_criteria": "达到步骤目标"}
|
||||||
|
for i in range(n_steps)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_workspace(query: str, domain: str, n_steps: int = 3, n_rounds: int = 3) -> Workspace:
|
||||||
|
"""构造一个模拟进行到中后期的交流文本(含 progress/issues/decisions)。"""
|
||||||
|
ws = Workspace.new("bench" + query.encode("utf-8").hex()[:8], query,
|
||||||
|
api_token_cap=8000, rounds_cap=6)
|
||||||
|
ws.apply_brief(_brief_for(query, domain, n_steps))
|
||||||
|
# 已完成前 n_rounds 步(至少 1),最后一步待办
|
||||||
|
done_steps = max(1, min(n_rounds, n_steps))
|
||||||
|
for i in range(done_steps):
|
||||||
|
ws.add_progress(f"s{i+1}", "done",
|
||||||
|
f"步骤{i+1}完成:{_ARTIFACT_TEMPLATE.format(domain=domain, i=i+1)[:60]}",
|
||||||
|
artifact=f"a://s{i+1}.py" if domain == "code" else f"a://s{i+1}.md")
|
||||||
|
# 加入 issue + decision(模拟一轮裁决)
|
||||||
|
if done_steps < n_steps:
|
||||||
|
iid = ws.add_issue(f"s{done_steps+1}", f"a://s{done_steps+1}.py#L1",
|
||||||
|
"验证未通过", "达到目标", "已自修 2 次", "请裁决")
|
||||||
|
ws.add_decision(iid, "按此方向继续推进", [{"id": f"s{done_steps+1}", "task": "按裁决修订"}])
|
||||||
|
ws.mark_round()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_text(domain: str, i: int) -> str:
|
||||||
|
return _ARTIFACT_TEMPLATE.format(domain=domain, i=i)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(ws: Workspace, n_steps: int = 3):
|
||||||
|
"""测量四种策略的单请求 Architect 输入 token。"""
|
||||||
|
domain = (ws.get("brief") or {}).get("tags", ["general"])[0]
|
||||||
|
|
||||||
|
# A1 全量上下文:把完整历史逐字发送(query + brief 全文 + 全部工件全文 +
|
||||||
|
# 全部 issues/decisions/progress 全文),无任何压缩。
|
||||||
|
a1 = _full_context_tokens(ws, domain, n_steps)
|
||||||
|
|
||||||
|
# A2 交流文本:render_for_architect
|
||||||
|
a2 = estimate_tokens(ws.render_for_architect())
|
||||||
|
|
||||||
|
# A3 A2 + rollup
|
||||||
|
ws3 = Workspace(ws.data)
|
||||||
|
ws3.rollup()
|
||||||
|
a3 = estimate_tokens(ws3.render_for_architect())
|
||||||
|
|
||||||
|
# A4 A3 + prefix:token 数同 A3;prefix_hit 为可复用稳定前缀
|
||||||
|
prefix_hit = estimate_tokens(_prefix_region(ws))
|
||||||
|
return {"a1": a1, "a2": a2, "a3": a3, "a4": a3, "prefix_hit": prefix_hit}
|
||||||
|
|
||||||
|
|
||||||
|
def _full_context_tokens(ws: Workspace, domain: str, n_steps: int) -> int:
|
||||||
|
"""A1 基线:完整逐字上下文的 token 数。"""
|
||||||
|
d = ws.data
|
||||||
|
total = estimate_tokens(d.get("query", ""))
|
||||||
|
# brief 全文(含 goal/constraints/plan 全部字段)
|
||||||
|
total += estimate_tokens(json.dumps(d.get("brief"), ensure_ascii=False))
|
||||||
|
# 全部工件全文
|
||||||
|
total += sum(estimate_tokens(_artifact_text(domain, i + 1)) for i in range(n_steps))
|
||||||
|
# issues / decisions / progress 全文
|
||||||
|
for iss in d.get("issues", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(iss, ensure_ascii=False))
|
||||||
|
for dec in d.get("decisions", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(dec, ensure_ascii=False))
|
||||||
|
for p in d.get("progress", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(p, ensure_ascii=False))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_region(ws: Workspace) -> str:
|
||||||
|
"""稳定前缀(可被 prefix cache 命中)的文本。"""
|
||||||
|
d = ws.data
|
||||||
|
stable = {"version": d.get("version"), "request_id": d.get("request_id"),
|
||||||
|
"query": d.get("query"), "brief": d.get("brief")}
|
||||||
|
return json.dumps(stable, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
|
||||||
|
items = json.loads(Path(data_path).read_text(encoding="utf-8"))
|
||||||
|
out = Path(out_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows = []
|
||||||
|
for it in items:
|
||||||
|
ws = build_workspace(it["query"], it.get("domain", "general"), n_steps)
|
||||||
|
m = measure(ws, n_steps)
|
||||||
|
rows.append({
|
||||||
|
"id": it["id"], "domain": it.get("domain", "general"),
|
||||||
|
"a1_full": m["a1"], "a2_ws": m["a2"], "a3_rollup": m["a3"],
|
||||||
|
"a4_prefix": m["a4"], "prefix_hit": m["prefix_hit"],
|
||||||
|
"reduction_a2": round(1 - m["a2"] / m["a1"], 4) if m["a1"] else 0,
|
||||||
|
"reduction_a4": round(1 - m["a4"] / m["a1"], 4) if m["a1"] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# CSV
|
||||||
|
csv_path = out / "E1_token_economics.csv"
|
||||||
|
with 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())
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""CLI 演示:构建 mock 全链路路由系统,跑一组样例查询并打印结果。
|
"""CLI 演示:构建专家系统内核路由(默认 L0 零参数模式),跑样例查询并打印推理链。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python scripts/demo.py [--query "自定义查询"] [--batch]
|
python scripts/demo.py [--query "自定义查询"] [--batch] [--trace] [--verbose]
|
||||||
|
--trace 打印完整推理链(分类 → 拆解 DAG → 规则轨迹 → 子任务执行 → Judge)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ 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
|
||||||
@@ -26,7 +31,7 @@ SAMPLE_QUERIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def run_demo(router, queries, verbose: bool = False):
|
async def run_demo(router, queries, verbose: bool = False, trace: bool = False):
|
||||||
for q in queries:
|
for q in queries:
|
||||||
r = await router.route(q)
|
r = await router.route(q)
|
||||||
print("=" * 72)
|
print("=" * 72)
|
||||||
@@ -35,15 +40,20 @@ async def run_demo(router, queries, verbose: 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[:400]}")
|
print(f" --- response ---\n{r.response[:600]}")
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -51,9 +61,9 @@ async def main():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
if args.query:
|
if args.query:
|
||||||
await run_demo(router, [args.query], verbose=True)
|
await run_demo(router, [args.query], verbose=True, trace=True)
|
||||||
else:
|
else:
|
||||||
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose)
|
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose, trace=args.trace)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 72)
|
print("=" * 72)
|
||||||
|
|||||||
@@ -12,27 +12,46 @@ 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 = [
|
||||||
("用 Python 实现二分查找", "code"),
|
# ---- tech:code / math ----
|
||||||
("这段 JavaScript 为什么报错:undefined is not a function", "code"),
|
("用 Python 实现二分查找", "code", "tech", "algorithm"),
|
||||||
("帮我优化这个 SQL 查询的索引", "code"),
|
("这段 JavaScript 为什么报错:undefined is not a function", "code", "tech", "debugging"),
|
||||||
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math"),
|
("帮我优化这个 SQL 查询的索引", "code", "tech", "database"),
|
||||||
("证明勾股定理", "math"),
|
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math", "tech", "algebra"),
|
||||||
("计算 3x + 5 = 20,x 等于多少", "math"),
|
("证明勾股定理", "math", "tech", "geometry"),
|
||||||
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal"),
|
("计算 3x + 5 = 20,x 等于多少", "math", "tech", "algebra"),
|
||||||
("在合同中约定违约金上限 30%,是否合规", "legal"),
|
# ---- professional:legal / medical / finance ----
|
||||||
("专利申请的流程和费用大概是多少", "legal"),
|
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal", "professional", "labor"),
|
||||||
("高血压患者可以吃哪些降压药,副作用是什么", "medical"),
|
("在合同中约定违约金上限 30%,是否合规", "legal", "professional", "contract"),
|
||||||
("感冒发烧 38.5 度,需要吃退烧药吗", "medical"),
|
("加班费怎么计算", "legal", "professional", "labor"),
|
||||||
("糖尿病患者的日常饮食建议", "medical"),
|
("高血压患者可以吃哪些降压药,副作用是什么", "medical", "professional", "medication"),
|
||||||
("介绍一下 Transformer 架构", "general"),
|
("感冒发烧 38.5 度,需要吃退烧药吗", "medical", "professional", "medication"),
|
||||||
("写一封请假邮件", "general"),
|
("烫伤后怎么处理", "medical", "professional", "firstaid"),
|
||||||
("为什么天空是蓝色的", "general"),
|
("基金定投的收益率怎么计算", "finance", "professional", "investing"),
|
||||||
|
("信用卡逾期了怎么办", "finance", "professional", "credit"),
|
||||||
|
("房贷利率是 LPR 加多少", "finance", "professional", "loan"),
|
||||||
|
# ---- lifestyle:life / 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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -40,32 +59,54 @@ 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 in BENCH:
|
for q, expected, expected_group, expected_sub in bench:
|
||||||
r = await router.route(q)
|
r = await router.route(q, domain_group=args.group)
|
||||||
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())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""启动路由网关服务(后台、无窗口)。
|
"""启动路由网关服务(后台、无窗口)。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python scripts/serve.py [--port 8000] [--stop]
|
python scripts/serve.py [--port 8000] [--stop]
|
||||||
@@ -41,11 +41,16 @@ def stop():
|
|||||||
return
|
return
|
||||||
pid = int(PID_FILE.read_text().strip())
|
pid = int(PID_FILE.read_text().strip())
|
||||||
try:
|
try:
|
||||||
import signal
|
# Windows 下 SIGTERM 对 detached 进程不可靠,改用 taskkill 强制结束进程树
|
||||||
os.kill(pid, signal.SIGTERM)
|
subprocess.run(
|
||||||
print(f"已发送终止信号 pid={pid}")
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
except ProcessLookupError:
|
capture_output=True, text=True, timeout=15,
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/setup_runtime.py [--config config/config.yaml]
|
||||||
|
|
||||||
|
行为(对齐《实现方案_v2》6.4 / T11):
|
||||||
|
- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。
|
||||||
|
- GGUF:优先 hf-mirror.com(env HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
|
||||||
|
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
|
||||||
|
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
|
||||||
|
|
||||||
|
下载函数可注入(tests 用假 urllib),保证封闭单测。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from runtime.hw_profile import detect_summary # noqa: E402
|
||||||
|
|
||||||
|
# 默认资源(可用 env 覆盖)
|
||||||
|
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
|
||||||
|
"LLAMA_ZIP_URL",
|
||||||
|
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
|
||||||
|
)
|
||||||
|
DEFAULT_GGUF_URL = os.environ.get(
|
||||||
|
"GGUF_URL",
|
||||||
|
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
|
||||||
|
)
|
||||||
|
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
|
||||||
|
|
||||||
|
URLS = {
|
||||||
|
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
|
||||||
|
"gguf": (DEFAULT_GGUF_URL, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
|
||||||
|
"""解析 HTTP Content-Length 头。"""
|
||||||
|
if not content_length:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(content_length.strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_size(path: Path, expected: Optional[int],
|
||||||
|
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
|
||||||
|
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
|
||||||
|
actual = path.stat().st_size if path.exists() else 0
|
||||||
|
if not expected:
|
||||||
|
return actual > 0, actual
|
||||||
|
return abs(actual - expected) <= tolerance, actual
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
|
||||||
|
|
||||||
|
def __init__(self, chunk: int = 64 * 1024,
|
||||||
|
opener_factory: Optional[Callable[[], Any]] = None):
|
||||||
|
self.chunk = chunk
|
||||||
|
self._opener_factory = opener_factory
|
||||||
|
|
||||||
|
def _opener(self):
|
||||||
|
if self._opener_factory is not None:
|
||||||
|
return self._opener_factory()
|
||||||
|
return urllib.request.build_opener()
|
||||||
|
|
||||||
|
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
|
||||||
|
"""下载(断点续传)。返回 (bytes_written, error)。"""
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = dest.stat().st_size if dest.exists() else 0
|
||||||
|
headers = {"User-Agent": "v2-setup-runtime/1.0"}
|
||||||
|
if existing > 0:
|
||||||
|
headers["Range"] = f"bytes={existing}-"
|
||||||
|
opener = self._opener()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
with opener.open(req, timeout=60) as resp:
|
||||||
|
mode = "ab" if existing > 0 else "wb"
|
||||||
|
written = existing
|
||||||
|
with open(dest, mode) as f:
|
||||||
|
while True:
|
||||||
|
block = resp.read(self.chunk)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
f.write(block)
|
||||||
|
written += len(block)
|
||||||
|
return written, None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return existing, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
|
||||||
|
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
|
||||||
|
try:
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
target = None
|
||||||
|
for n in zf.namelist():
|
||||||
|
if n.lower().endswith("llama-server.exe"):
|
||||||
|
target = n
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
return "zip 中未找到 llama-server.exe"
|
||||||
|
# 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))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""测试辅助:获取空闲 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]
|
||||||
@@ -10,5 +10,11 @@ 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()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""测试替身:模拟 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())
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
"""智能体端点测试:注入脚本化 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()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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"}))
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""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()
|
||||||
@@ -1,6 +1,42 @@
|
|||||||
from router_system.cache import RouterCache
|
from router_system.cache import RouterCache
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_lookup_after_many_entries():
|
||||||
|
"""多条目下语义命中正确(范数预计算 + 单遍扫描的回归)。"""
|
||||||
|
c = RouterCache(similarity_threshold=0.5)
|
||||||
|
for i in range(50):
|
||||||
|
c.put(f"完全不相关的查询主题编号{i}关于烹饪的意见", {"response": f"r{i}"})
|
||||||
|
c.put("用 Python 实现快速排序函数", {"response": "code-answer"})
|
||||||
|
level, got = c.get("用 Python 实现快速排序的函数写法") # 相似但不完全相同
|
||||||
|
assert level in ("semantic", "exact")
|
||||||
|
assert got["response"] == "code-answer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_promotion_clears_semantic_state():
|
||||||
|
"""提升为精确缓存后,语义列表与范数索引无残留。"""
|
||||||
|
c = RouterCache(promote_frequency=2)
|
||||||
|
c.put("查询甲", {"response": "a"})
|
||||||
|
first = c.get("查询甲") # 相似度=1.0 计 exact,hits 达阈值即提升
|
||||||
|
assert first is not None and first[0] == "exact"
|
||||||
|
second = c.get("查询甲")
|
||||||
|
assert second is not None and second[0] == "exact"
|
||||||
|
assert c.stats()["exact_size"] == 1
|
||||||
|
assert c.stats()["semantic_size"] == 0
|
||||||
|
assert len(c._sem_norms) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_eviction_clears_norms():
|
||||||
|
"""语义缓存满员淘汰最旧条目时,向量与范数索引同步清理。"""
|
||||||
|
c = RouterCache(max_semantic=2)
|
||||||
|
c.put("查询一", {"response": "1"})
|
||||||
|
c.put("查询二", {"response": "2"})
|
||||||
|
c.put("查询三", {"response": "3"}) # 淘汰查询一
|
||||||
|
assert len(c._semantic) == 2
|
||||||
|
assert len(c._sem_vecs) == 2
|
||||||
|
assert len(c._sem_norms) == 2
|
||||||
|
assert c.get("查询一") is None
|
||||||
|
|
||||||
|
|
||||||
def test_exact_hit():
|
def test_exact_hit():
|
||||||
c = RouterCache()
|
c = RouterCache()
|
||||||
result = {"response": "hello", "domain": "general"}
|
result = {"response": "hello", "domain": "general"}
|
||||||
@@ -13,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"
|
||||||
|
|
||||||
@@ -24,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,6 +2,25 @@
|
|||||||
from router_system.classifier import RuleClassifier
|
from router_system.classifier import RuleClassifier
|
||||||
|
|
||||||
|
|
||||||
|
def test_tie_break_is_deterministic():
|
||||||
|
"""同分决胜:按领域名字典序,与规则表排列顺序无关。"""
|
||||||
|
clf = RuleClassifier()
|
||||||
|
clf.rules = {"zeta": [("x", 1.0)], "alpha": [("x", 1.0)]}
|
||||||
|
r = clf.classify("x")
|
||||||
|
assert r.domain == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinctiveness_penalty():
|
||||||
|
"""次高分占比高(语义含混)时置信度被压低;单一领域命中不受影响。"""
|
||||||
|
clf = RuleClassifier()
|
||||||
|
clf.rules = {"a": [("kw", 1.0)], "b": [("kw", 0.9)]}
|
||||||
|
r_ambiguous = clf.classify("kw")
|
||||||
|
clf_clear = RuleClassifier()
|
||||||
|
clf_clear.rules = {"a": [("kw", 1.0)], "b": [("other", 0.1)]}
|
||||||
|
r_clear = clf_clear.classify("kw")
|
||||||
|
assert r_clear.confidence > r_ambiguous.confidence
|
||||||
|
|
||||||
|
|
||||||
def test_code_classification():
|
def test_code_classification():
|
||||||
clf = RuleClassifier()
|
clf = RuleClassifier()
|
||||||
r = clf.classify("用 Python 写一个快速排序函数")
|
r = clf.classify("用 Python 写一个快速排序函数")
|
||||||
@@ -30,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("你好呀")
|
||||||
# 未命中任何领域 -> 低置信度,触发 should_fallback
|
# 未命中任何领域 -> general,低置信度,触发 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""???????? fastapi + httpx??"""
|
"""FastAPI 网关测试:v1 legacy 端点保持 + v2 端点(封闭,注入 mock 管线)。"""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("fastapi")
|
pytest.importorskip("fastapi")
|
||||||
@@ -6,6 +6,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ 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
|
||||||
@@ -22,8 +31,8 @@ def test_health(client):
|
|||||||
assert "code" in data["domains"]
|
assert "code" in data["domains"]
|
||||||
|
|
||||||
|
|
||||||
def test_chat(client):
|
def test_chat_legacy(client):
|
||||||
resp = client.post("/chat", json={"query": "? Python ???????"})
|
resp = client.post("/chat/legacy", 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"]
|
||||||
@@ -31,6 +40,33 @@ def test_chat(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
|
||||||
@@ -42,3 +78,58 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""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"]
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""模型池(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)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""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())
|
||||||
|
# 前两次失败(占位)-> issue;decide 后重跑成功
|
||||||
|
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")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""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
|
||||||
@@ -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 "expert" in r.route[1] or "classify" in r.route[1]
|
assert any("classify:" in s for s in r.route)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""安全加固测试(T30,Mimosa 扫描驱动):
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- 路径参数 ID 校验(防 Windows 反斜杠穿越 ../..%5C 变体)
|
||||||
|
- artifacts 工件名关押(防 ..\\ 越界读任意文件,如 .env)
|
||||||
|
- GET /config 密钥打码 / PUT 留空保留(对齐 D2)
|
||||||
|
- Host 信任围栏(防 DNS rebinding,dsh 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"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""流式输出测试(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
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""工具内核测试:路径关押、文件工具往返、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)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""推理链轨迹存储 / 查询测试(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
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""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"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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?
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T15:40:06.242Z"}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"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/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"touched":[],"bashMutation":true,"reportedFindings":[],"findingEvents":[],"baseline":null,"stateErrors":[],"omittedReportedFindings":0,"omittedFindingEvents":0,"processing":null,"updatedAt":"2026-09-01T13:44:31.436Z"}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 侧边栏(dsh:specific-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>
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,95 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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')
|
||||||