Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,26 @@ 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/
|
||||||
|
|||||||
@@ -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 转向依据) |
|
||||||
@@ -1,125 +1,142 @@
|
|||||||
# 多专业小模型 + 路由模型系统(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 界面(对话 / 协作过程 / 人工检验 / 指标)
|
||||||
|
|
||||||
|
# 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,511 @@
|
|||||||
|
"""智能体服务(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, 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 时可用,否则不要尝试)。"
|
||||||
|
"像编程助手一样工作:先列目录/搜索了解项目结构,读文件核对原文后再用 edit_file 小步修改"
|
||||||
|
"(或 write_file 新建),需要时运行命令验证。任务完成或给出结论后,"
|
||||||
|
"直接输出给用户的最终答复(中文,不要再调用工具)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 两级智能体(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,
|
||||||
|
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._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]]) -> 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 {}
|
||||||
|
# tool_calls 解析放这里(网关层),内核 tools.parse_tool_calls 供其他调用方复用
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
executor_chat: Any = None,
|
||||||
|
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> None:
|
||||||
|
"""执行智能体任务(由调用方包成后台协程)。
|
||||||
|
|
||||||
|
executor_chat 为空 = 单模型模式(chat 全程包办);
|
||||||
|
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
max_handoffs=max_handoffs)
|
||||||
|
else:
|
||||||
|
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
|
||||||
|
shell_timeout_s=shell_timeout_s)
|
||||||
|
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
|
||||||
|
on_event=self._make_event_writer(info))
|
||||||
|
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> Dict[str, Any]:
|
||||||
|
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
|
||||||
|
info.mode = "dual"
|
||||||
|
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
|
||||||
|
shell_timeout_s=shell_timeout_s)
|
||||||
|
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;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
|
||||||
|
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user_msg}]
|
||||||
|
content = ""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
resp = await planner_chat(messages, [])
|
||||||
|
_account(resp.get("usage"))
|
||||||
|
content = resp.get("content") or ""
|
||||||
|
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)
|
||||||
|
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:
|
||||||
|
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]
|
||||||
@@ -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,453 @@
|
|||||||
|
"""llama-server 进程管理 & 模型下载。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 启动/停止本地 llama-server 子进程(Windows 兼容)
|
||||||
|
- 探测已有 .gguf 模型文件
|
||||||
|
- 从 HuggingFace URL 下载模型(支持 huggingface.co 路径别名)
|
||||||
|
- 下载进度可通过 SSE /llama/download/stream 订阅
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from gateway.llama_manager import get_llama_manager
|
||||||
|
lm = get_llama_manager()
|
||||||
|
await lm.start(model="models/qwen3.5-4b-q4_k_m.gguf")
|
||||||
|
await lm.stop()
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 路径配置(与 config.yaml runtime.llama_server 段保持一致)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent # E:\projectAIpopular
|
||||||
|
BIN_DIR = _ROOT / "bin"
|
||||||
|
MODELS_DIR = _ROOT / "models"
|
||||||
|
PID_FILE = _ROOT / "data" / "llama-server.pid"
|
||||||
|
LOG_FILE = _ROOT / "data" / "llama-server.log"
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 数据模型
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LlamaServerStatus:
|
||||||
|
running: bool
|
||||||
|
pid: Optional[int] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
base_url: Optional[str] = None
|
||||||
|
started_at: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadProgress:
|
||||||
|
url: str
|
||||||
|
dest: str
|
||||||
|
total_bytes: Optional[int] = None
|
||||||
|
downloaded_bytes: int = 0
|
||||||
|
progress_pct: float = 0.0
|
||||||
|
speed: str = ""
|
||||||
|
eta: str = ""
|
||||||
|
done: bool = False
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# llama_manager 单例
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LlamaManager:
|
||||||
|
_instance: Optional["LlamaManager"] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._pid: Optional[int] = None
|
||||||
|
self._model: Optional[str] = None
|
||||||
|
self._port: Optional[int] = None
|
||||||
|
self._started_at: Optional[float] = None
|
||||||
|
self._downloading: dict[str, DownloadProgress] = {} # url -> progress
|
||||||
|
self._dl_lock = threading.Lock()
|
||||||
|
# 加载已有进程
|
||||||
|
self._load_pid()
|
||||||
|
|
||||||
|
# ── 进程持久化 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_pid(self) -> None:
|
||||||
|
"""从 pid 文件恢复进程引用(进程仍在运行时)。"""
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pid = int(PID_FILE.read_text().strip())
|
||||||
|
os.kill(pid, 0) # 检查进程是否存活
|
||||||
|
# 进程还在,尝试接管(通过 cmdline 判断是否是 llama-server)
|
||||||
|
self._pid = pid
|
||||||
|
self._proc = self._attach_to_process(pid)
|
||||||
|
except (ValueError, FileNotFoundError, OSError):
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _attach_to_process(self, pid: int) -> Optional[subprocess.Popen]:
|
||||||
|
"""通过 pid 重新关联到 Popen(仅作状态恢复,不拥有 stdout)。"""
|
||||||
|
try:
|
||||||
|
return subprocess.Popen(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
f"import os; os.kill({pid}, 0)"], # 存活检查
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_pid(self, pid: int) -> None:
|
||||||
|
PID_FILE.write_text(str(pid), encoding="utf-8")
|
||||||
|
|
||||||
|
def _clear_pid(self) -> None:
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# ── 进程管理 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def find_binary(self) -> Optional[Path]:
|
||||||
|
"""查找 llama-server 可执行文件。"""
|
||||||
|
candidates = [
|
||||||
|
BIN_DIR / "llama-server.exe",
|
||||||
|
BIN_DIR / "llama-server",
|
||||||
|
_ROOT / "llama-server.exe",
|
||||||
|
_ROOT / "llama-server",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
# PATH 中查找
|
||||||
|
import shutil
|
||||||
|
found = shutil.which("llama-server") or shutil.which("llama-server.exe")
|
||||||
|
if found:
|
||||||
|
return Path(found)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def status(self) -> LlamaServerStatus:
|
||||||
|
"""返回当前服务状态。"""
|
||||||
|
if self._proc is None or self._pid is None:
|
||||||
|
return LlamaServerStatus(running=False)
|
||||||
|
try:
|
||||||
|
# 检查进程是否存活
|
||||||
|
os.kill(self._pid, 0)
|
||||||
|
except OSError:
|
||||||
|
# 进程已死
|
||||||
|
self._proc = None
|
||||||
|
self._pid = None
|
||||||
|
self._model = None
|
||||||
|
self._port = None
|
||||||
|
self._started_at = None
|
||||||
|
self._clear_pid()
|
||||||
|
return LlamaServerStatus(running=False)
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=True,
|
||||||
|
pid=self._pid,
|
||||||
|
model=self._model,
|
||||||
|
port=self._port,
|
||||||
|
base_url=f"http://127.0.0.1:{self._port}/v1",
|
||||||
|
started_at=self._started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
port: int = 8901,
|
||||||
|
ngl: int = 99,
|
||||||
|
ctx: int = 4096,
|
||||||
|
extra_args: Optional[list] = None,
|
||||||
|
) -> LlamaServerStatus:
|
||||||
|
"""启动 llama-server,阻塞直到监听就绪或超时。"""
|
||||||
|
if self.status().running:
|
||||||
|
s = self.status()
|
||||||
|
if s.model == model and s.port == port:
|
||||||
|
return s # 已是同一模型,无需重启
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
binary = self.find_binary()
|
||||||
|
if binary is None:
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=False,
|
||||||
|
error="未找到 llama-server 可执行文件。"
|
||||||
|
"请将 llama-server.exe 放入 bin/ 目录,"
|
||||||
|
"或从 https://github.com/ggerganov/llama.cpp/releases 下载。",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = Path(model)
|
||||||
|
if not model_path.is_absolute():
|
||||||
|
model_path = MODELS_DIR / model
|
||||||
|
if not model_path.exists():
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=False,
|
||||||
|
error=f"模型文件不存在:{model_path}。"
|
||||||
|
"请先下载模型,或在设置页填写 HuggingFace URL 下载。",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
str(binary),
|
||||||
|
"-m", str(model_path),
|
||||||
|
"-c", str(ctx),
|
||||||
|
"-ngl", str(ngl),
|
||||||
|
"--port", str(port),
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
]
|
||||||
|
if extra_args:
|
||||||
|
args.extend(extra_args)
|
||||||
|
|
||||||
|
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_f = open(LOG_FILE, "w", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
stdout=log_f,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=str(_ROOT),
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
log_f.close()
|
||||||
|
return LlamaServerStatus(running=False, error=f"启动失败:{e}")
|
||||||
|
|
||||||
|
self._pid = self._proc.pid
|
||||||
|
self._model = str(model_path)
|
||||||
|
self._port = port
|
||||||
|
self._started_at = time.time()
|
||||||
|
self._write_pid(self._pid)
|
||||||
|
|
||||||
|
# 等待服务就绪
|
||||||
|
ok = await self._wait_until_ready(port, timeout=30)
|
||||||
|
if not ok:
|
||||||
|
await self.stop()
|
||||||
|
return LlamaServerStatus(
|
||||||
|
running=False,
|
||||||
|
error=f"llama-server 启动后 {port} 端口在 30 秒内未响应",
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""优雅停止 llama-server。"""
|
||||||
|
if self._pid is None:
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Windows: CTRL_BREAK_EVENT 或 taskkill
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(self._pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.kill(self._pid, 15) # SIGTERM
|
||||||
|
time.sleep(1)
|
||||||
|
try:
|
||||||
|
os.kill(self._pid, 0)
|
||||||
|
os.kill(self._pid, 9)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
self._pid = None
|
||||||
|
self._model = None
|
||||||
|
self._port = None
|
||||||
|
self._started_at = None
|
||||||
|
self._clear_pid()
|
||||||
|
|
||||||
|
async def _wait_until_ready(self, port: int, timeout: float = 30) -> bool:
|
||||||
|
"""轮询检查端口是否开始监听。"""
|
||||||
|
import httpx
|
||||||
|
url = f"http://127.0.0.1:{port}/v1/models"
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
r = await client.get(url)
|
||||||
|
if r.status_code < 500:
|
||||||
|
return True
|
||||||
|
except (httpx.ConnectError, httpx.ReadTimeout, OSError):
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── 模型列表 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_local_models(self) -> list[dict[str, str]]:
|
||||||
|
"""列出 models/ 目录下所有 .gguf 文件。"""
|
||||||
|
models = []
|
||||||
|
for p in MODELS_DIR.glob("*.gguf"):
|
||||||
|
size_mb = p.stat().st_size // (1024 * 1024)
|
||||||
|
models.append({
|
||||||
|
"id": p.name,
|
||||||
|
"name": p.name,
|
||||||
|
"size_mb": size_mb,
|
||||||
|
"path": str(p),
|
||||||
|
})
|
||||||
|
return sorted(models, key=lambda m: m["name"])
|
||||||
|
|
||||||
|
# ── 模型下载 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def download_model(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
dest: Optional[str] = None,
|
||||||
|
) -> DownloadProgress:
|
||||||
|
"""从 HuggingFace 或直链下载 .gguf 模型文件。
|
||||||
|
|
||||||
|
HuggingFace 路径别名:用户输入 "Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"
|
||||||
|
自动转换为 "https://huggingface.co/<repo>/resolve/main/<file>"
|
||||||
|
|
||||||
|
支持断点续传(Content-Range)。
|
||||||
|
|
||||||
|
返回 DownloadProgress 对象(含当前进度),进度通过 get_download_progress() 查询。
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# 路径别名转换
|
||||||
|
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)
|
||||||
|
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,124 @@
|
|||||||
|
"""可调整的运行设置(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 命令超时
|
||||||
|
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 @@
|
|||||||
|
.metrics-view[data-v-ba641559]{height:100%;padding:20px 24px;overflow-y:auto}.by-model[data-v-ba641559]{border-collapse:collapse;width:100%;font-size:12px}.by-model th[data-v-ba641559],.by-model td[data-v-ba641559]{text-align:left;border-bottom:1px solid #f3f4f6;padding:4px 8px}.by-model th[data-v-ba641559]{color:#6b7280;font-weight:600}.by-model td.mono[data-v-ba641559]{font-family:ui-monospace,Consolas,monospace}.hint[data-v-ba641559]{color:#9ca3af;margin-top:8px;font-size:11px}.metrics-header[data-v-ba641559]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.metrics-header h2[data-v-ba641559]{margin:0;font-size:20px}.refresh[data-v-ba641559]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px}.loading[data-v-ba641559],.error[data-v-ba641559]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-ba641559]{color:#dc2626}.card-grid[data-v-ba641559]{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin-bottom:24px;display:grid}.metric-card[data-v-ba641559]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.metric-card.highlight[data-v-ba641559]{background:#eff6ff;border-color:#2563eb}.metric-card h3[data-v-ba641559]{color:#374151;margin:0 0 12px;font-size:14px}.kv-list[data-v-ba641559]{grid-template-columns:1fr 1fr;gap:6px 12px;font-size:13px;display:grid}.kv-list span[data-v-ba641559]{color:#6b7280}.kv-list b[data-v-ba641559]{color:#111;text-align:right}.review-card[data-v-ba641559]{grid-column:span 2}.review-stats[data-v-ba641559]{gap:24px;margin-bottom:12px;display:flex}.stat-item[data-v-ba641559]{flex-direction:column;align-items:center;display:flex}.stat-num[data-v-ba641559]{color:#2563eb;font-size:28px;font-weight:700}.stat-label[data-v-ba641559]{color:#6b7280;font-size:12px}.progress-wrap[data-v-ba641559]{background:#e5e7eb;border-radius:99px;height:8px;margin-bottom:6px;overflow:hidden}.reviewed-bar[data-v-ba641559]{background:#16a34a;height:100%;transition:width .5s}.review-rate[data-v-ba641559]{color:#6b7280;margin:0;font-size:13px}.raw-json[data-v-ba641559]{background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px}.raw-json summary[data-v-ba641559]{cursor:pointer;color:#6b7280;padding:10px 14px;font-size:13px}.raw-json pre[data-v-ba641559]{white-space:pre-wrap;border-top:1px solid #e5e7eb;margin:0;padding:10px 14px;font-size:12px}
|
||||||
@@ -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-DtjeaX4S.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-ba641559`]]);export{R as default};
|
||||||
@@ -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-DtjeaX4S.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-19c16eff`]]);export{L as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.review-view[data-v-19c16eff]{height:100%;padding:20px 24px;overflow-y:auto}.review-header[data-v-19c16eff]{justify-content:space-between;align-items:center;margin-bottom:20px;display:flex}.review-header h2[data-v-19c16eff]{margin:0;font-size:20px}.controls[data-v-19c16eff]{gap:8px;display:flex}button[data-v-19c16eff]{cursor:pointer;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:6px 14px;font-size:13px}button.active[data-v-19c16eff]{color:#fff;background:#2563eb;border-color:#2563eb}.refresh-btn[data-v-19c16eff]{margin-left:auto}.loading[data-v-19c16eff],.error[data-v-19c16eff],.empty[data-v-19c16eff]{text-align:center;color:#9ca3af;padding:40px}.error[data-v-19c16eff]{color:#dc2626}.queue-list[data-v-19c16eff]{flex-direction:column;gap:16px;display:flex}.review-card[data-v-19c16eff]{background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:16px}.card-header[data-v-19c16eff]{flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:10px;display:flex}.card-id[data-v-19c16eff]{color:#6b7280;font-family:monospace;font-size:12px}.verdict-badge[data-v-19c16eff]{border-radius:99px;padding:2px 8px;font-size:12px;font-weight:600}.verdict-badge.pending[data-v-19c16eff]{color:#92400e;background:#fef3c7}.verdict-badge.approved[data-v-19c16eff]{color:#16a34a;background:#dcfce7}.verdict-badge.rejected[data-v-19c16eff]{color:#dc2626;background:#fee2e2}.tags[data-v-19c16eff]{gap:4px;display:flex}.tag[data-v-19c16eff]{color:#3730a3;background:#e0e7ff;border-radius:4px;padding:1px 6px;font-size:11px}.date[data-v-19c16eff]{color:#9ca3af;margin-left:auto;font-size:11px}.query-block[data-v-19c16eff],.response-block[data-v-19c16eff]{margin-bottom:8px;font-size:13px;line-height:1.6}.query-block pre[data-v-19c16eff],.response-block pre[data-v-19c16eff]{white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;margin:4px 0 0;padding:6px 10px;font-size:13px}.actions[data-v-19c16eff]{flex-direction:column;gap:8px;margin-top:10px;display:flex}textarea[data-v-19c16eff]{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-19c16eff]{gap:8px;display:flex}.approve[data-v-19c16eff]{color:#16a34a;background:#dcfce7;border-color:#86efac}.reject[data-v-19c16eff]{color:#dc2626;background:#fee2e2;border-color:#fca5a5}.correction[data-v-19c16eff]{background:#fffbeb;border:1px solid #fcd34d;border-radius:4px;margin-top:8px;padding:6px 10px;font-size:13px}
|
||||||
|
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="en">
|
||||||
|
<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>webapp</title>
|
||||||
|
<script type="module" crossorigin src="/static/assets/index-DtjeaX4S.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/static/assets/index-DPz6YNpx.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)),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -159,7 +217,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 +228,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"},
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,315 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
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" / 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,160 @@
|
|||||||
|
"""人工检验队列(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")
|
||||||
|
|
||||||
|
|
||||||
|
_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 抽样。"""
|
||||||
|
force = force_tags or []
|
||||||
|
if any(t in force for t in tags):
|
||||||
|
return True
|
||||||
|
rng = rng or random.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,525 @@
|
|||||||
|
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
|
||||||
|
|
||||||
|
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
|
||||||
|
- TOOLS_SPEC:list_dir / read_file / write_file 三个工具的 OpenAI tools 声明
|
||||||
|
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib)
|
||||||
|
- parse_tool_calls:解析 OpenAI 响应里的 tool_calls(arguments 容错为 {})
|
||||||
|
- ToolLoop:通用智能体循环。chat_fn 注入(网关传 OpenAI 兼容客户端,测试传假实现),
|
||||||
|
本模块只负责循环编排:调用 -> 执行工具 -> 回喂结果 -> 直到模型给出最终答复。
|
||||||
|
|
||||||
|
工程约束:
|
||||||
|
- 纯标准库(router_system 零第三方依赖不变)
|
||||||
|
- 工具结果回喂前截断(防止上下文爆炸),轮数与 token 双上限(金额护栏)
|
||||||
|
- 事件回调 on_event 逐条产出过程事件(供 SSE 透出"智能体在做什么")
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 默认循环上限
|
||||||
|
DEFAULT_MAX_ROUNDS = 8
|
||||||
|
|
||||||
|
# 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""工具执行失败(路径越界/不存在/参数非法)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceTools:
|
||||||
|
"""被限制在根目录内的文件工具(智能体的"手")。
|
||||||
|
|
||||||
|
安全:所有路径先 join 再 resolve,解析结果必须仍位于根目录内
|
||||||
|
(根目录自身允许),否则抛 ToolError——防 ../ 越界与绝对路径逃逸。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, root: str | Path,
|
||||||
|
allow_shell: bool = False, shell_timeout_s: int = 20):
|
||||||
|
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))
|
||||||
|
|
||||||
|
# ---------- 路径关押 ----------
|
||||||
|
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)
|
||||||
|
p.write_text(content, encoding="utf-8")
|
||||||
|
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)
|
||||||
|
p.write_text(new_text, encoding="utf-8")
|
||||||
|
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]:
|
||||||
|
"""跨文件文本搜索(跳过依赖/构建目录与二进制大文件,限量返回)。"""
|
||||||
|
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
|
||||||
|
for p in sorted(base.rglob("*")):
|
||||||
|
if len(matches) >= SEARCH_MAX_MATCHES:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
rel_parts = p.relative_to(base).parts
|
||||||
|
if any(part in SEARCH_SKIP_DIRS for part in rel_parts):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if p.stat().st_size > SEARCH_MAX_FILE_BYTES:
|
||||||
|
continue
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue # 二进制/不可读,跳过
|
||||||
|
scanned += 1
|
||||||
|
if scanned > SEARCH_MAX_FILES:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
if query in line:
|
||||||
|
rel = Path(*p.relative_to(self.root).parts).as_posix()
|
||||||
|
matches.append({
|
||||||
|
"file": rel, "line": lineno,
|
||||||
|
"text": line.strip()[:300],
|
||||||
|
})
|
||||||
|
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 开启后可用)。"""
|
||||||
|
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 不能为空"}
|
||||||
|
import subprocess
|
||||||
|
creationflags = 0x08000000 if __import__("os").name == "nt" else 0 # CREATE_NO_WINDOW
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
command, shell=True, 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 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", "")))
|
||||||
|
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 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,
|
||||||
|
):
|
||||||
|
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,由外层统一收尾
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
async def run(self, task: str, system: str = "") -> Dict[str, Any]:
|
||||||
|
"""执行任务直到模型给出最终答复或触顶。返回最终结果与账目。"""
|
||||||
|
messages: List[Dict[str, Any]] = []
|
||||||
|
if system:
|
||||||
|
messages.append({"role": "system", "content": system})
|
||||||
|
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.chat_fn(messages, TOOLS_SPEC)
|
||||||
|
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"]})
|
||||||
|
result = self.tools.execute(c["name"], c["arguments"])
|
||||||
|
preview = json.dumps(result, ensure_ascii=False)
|
||||||
|
if len(preview) > self.result_preview_chars:
|
||||||
|
preview = preview[:self.result_preview_chars] + "…(截断)"
|
||||||
|
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,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,545 @@
|
|||||||
|
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
|
||||||
|
|
||||||
|
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
|
||||||
|
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
|
||||||
|
|
||||||
|
本模块实现(对齐《实现方案_v2》第 4 节):
|
||||||
|
- WORKSPACE_SCHEMA:draft-07 风格 schema 常量(文档/校验依据)
|
||||||
|
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
|
||||||
|
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
|
||||||
|
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token)
|
||||||
|
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
|
||||||
|
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
VERSION = "1.0"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 字段长度上限(同时是 rollup 依据,4.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LIMITS = {
|
||||||
|
"goal": 500, # brief.goal 字数
|
||||||
|
"task": 300, # brief.plan[].task 字数
|
||||||
|
"summary": 200, # progress[].summary 字数
|
||||||
|
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
|
||||||
|
"reply": 600, # decisions[].reply 字数
|
||||||
|
"archive": 160, # archive[] 每条字数
|
||||||
|
"constraints": 8, # brief.constraints 上限条数
|
||||||
|
"plan_steps": 5, # brief.plan 上限步数
|
||||||
|
"acceptance": 20, # brief.acceptance 上限条数
|
||||||
|
"query_truncate": 200, # render_for_architect 中 query 截断
|
||||||
|
}
|
||||||
|
|
||||||
|
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
|
||||||
|
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
|
||||||
|
"life", "education", "general", "safety", "science"}
|
||||||
|
|
||||||
|
STATUS_FLOW = {
|
||||||
|
"draft": {"in_progress"},
|
||||||
|
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
|
||||||
|
"reviewing": {"done", "in_progress", "failed"},
|
||||||
|
"escalated": {"reviewing", "done", "failed"},
|
||||||
|
"done": set(),
|
||||||
|
"failed": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON Schema(draft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
WORKSPACE_SCHEMA: Dict[str, Any] = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Communication Workspace",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "request_id", "query", "meta"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"version": {"const": VERSION},
|
||||||
|
"request_id": {"type": "string", "minLength": 1},
|
||||||
|
"query": {"type": "string", "minLength": 1},
|
||||||
|
"meta": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["status", "round", "budget"],
|
||||||
|
"properties": {
|
||||||
|
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
|
||||||
|
"round": {"type": "integer", "minimum": 0},
|
||||||
|
"budget": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
|
||||||
|
"properties": {
|
||||||
|
"api_input_tokens": {"type": "integer", "minimum": 0},
|
||||||
|
"api_output_tokens": {"type": "integer", "minimum": 0},
|
||||||
|
"api_token_cap": {"type": "integer", "minimum": 1},
|
||||||
|
"rounds_cap": {"type": "integer", "minimum": 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"brief": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
|
||||||
|
"properties": {
|
||||||
|
"goal": {"type": "string"},
|
||||||
|
"constraints": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"acceptance": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"plan": {"type": "array", "items": {"type": "object"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"progress": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"issues": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"decisions": {"type": "array", "items": {"type": "object"}},
|
||||||
|
"archive": {"type": "array", "items": {"type": "string"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 工具
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
|
||||||
|
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
|
||||||
|
en = len(text) - zh
|
||||||
|
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
|
||||||
|
|
||||||
|
|
||||||
|
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
|
||||||
|
"""构造锚点:a://<file>#L<start>-<end>;end 缺省仅 L<start>。"""
|
||||||
|
if end is None:
|
||||||
|
return f"a://{filename}#L{start}"
|
||||||
|
return f"a://{filename}#L{start}-{end}"
|
||||||
|
|
||||||
|
|
||||||
|
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""解析锚点为 {file, start, end};非法返回 None。"""
|
||||||
|
m = _ANCHOR_RE.match(anchor)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
start = int(m.group("start")) if m.group("start") else 1
|
||||||
|
end = int(m.group("end")) if m.group("end") else start
|
||||||
|
return {"file": m.group("file"), "start": start, "end": end}
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(text: str, limit: int) -> str:
|
||||||
|
"""按字数截断(中文按字符)。"""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 校验
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def validate(ws: Dict[str, Any]) -> List[str]:
|
||||||
|
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
|
||||||
|
errors: List[str] = []
|
||||||
|
if not isinstance(ws, dict):
|
||||||
|
return ["workspace 必须是 object"]
|
||||||
|
if ws.get("version") != VERSION:
|
||||||
|
errors.append(f"version 必须是 {VERSION}")
|
||||||
|
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
|
||||||
|
errors.append("request_id 必须是非空字符串")
|
||||||
|
if not isinstance(ws.get("query"), str) or not ws["query"]:
|
||||||
|
errors.append("query 必须是非空字符串")
|
||||||
|
|
||||||
|
meta = ws.get("meta")
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
errors.append("meta 必须是 object")
|
||||||
|
else:
|
||||||
|
if meta.get("status") not in STATUS_FLOW:
|
||||||
|
errors.append(f"meta.status 非法: {meta.get('status')}")
|
||||||
|
budget = meta.get("budget")
|
||||||
|
if not isinstance(budget, dict):
|
||||||
|
errors.append("meta.budget 必须是 object")
|
||||||
|
else:
|
||||||
|
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
|
||||||
|
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
|
||||||
|
errors.append(f"meta.budget.{k} 必须是非负整数")
|
||||||
|
|
||||||
|
brief = ws.get("brief")
|
||||||
|
if brief is not None:
|
||||||
|
if not isinstance(brief, dict):
|
||||||
|
errors.append("brief 必须是 object")
|
||||||
|
else:
|
||||||
|
if not isinstance(brief.get("goal"), str):
|
||||||
|
errors.append("brief.goal 必须是字符串")
|
||||||
|
elif len(brief["goal"]) > LIMITS["goal"]:
|
||||||
|
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
|
||||||
|
if not isinstance(brief.get("constraints"), list):
|
||||||
|
errors.append("brief.constraints 必须是数组")
|
||||||
|
elif len(brief["constraints"]) > LIMITS["constraints"]:
|
||||||
|
errors.append(f"brief.constraints 超过 {LIMITS['constraints']} 条")
|
||||||
|
if not isinstance(brief.get("tags"), list):
|
||||||
|
errors.append("brief.tags 必须是数组")
|
||||||
|
for t in brief.get("tags", []) or []:
|
||||||
|
if t not in ALLOWED_TAGS:
|
||||||
|
errors.append(f"brief.tags 含非法标签: {t}")
|
||||||
|
acc = brief.get("acceptance")
|
||||||
|
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
|
||||||
|
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
|
||||||
|
plan = brief.get("plan")
|
||||||
|
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
|
||||||
|
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
|
||||||
|
else:
|
||||||
|
ids = [p.get("id") for p in plan if isinstance(p, dict)]
|
||||||
|
if len(set(ids)) != len(ids):
|
||||||
|
errors.append("brief.plan 存在重复 step id")
|
||||||
|
for p in plan:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
errors.append("brief.plan 元素必须是 object")
|
||||||
|
continue
|
||||||
|
if not isinstance(p.get("task"), str):
|
||||||
|
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
|
||||||
|
elif len(p["task"]) > LIMITS["task"]:
|
||||||
|
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("progress", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"progress[{i}] 必须是 object"); continue
|
||||||
|
if entry.get("status") not in ("done", "failed", "blocked"):
|
||||||
|
errors.append(f"progress[{i}].status 非法")
|
||||||
|
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
|
||||||
|
errors.append(f"progress[{i}].summary 非法或超长")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("issues", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"issues[{i}] 必须是 object"); continue
|
||||||
|
for k in ("observed", "expected", "tried", "ask"):
|
||||||
|
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
|
||||||
|
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
|
||||||
|
|
||||||
|
for i, entry in enumerate(ws.get("decisions", []) or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
errors.append(f"decisions[{i}] 必须是 object"); continue
|
||||||
|
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
|
||||||
|
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
|
||||||
|
|
||||||
|
for i, line in enumerate(ws.get("archive", []) or []):
|
||||||
|
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
|
||||||
|
errors.append(f"archive[{i}] 非法或超长")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Workspace
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class Workspace:
|
||||||
|
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
|
||||||
|
|
||||||
|
def __init__(self, data: Dict[str, Any]):
|
||||||
|
errors = validate(data)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
|
||||||
|
self._data = data
|
||||||
|
self._brief_locked = False
|
||||||
|
|
||||||
|
# ---------- 构造 ----------
|
||||||
|
@classmethod
|
||||||
|
def new(cls, request_id: str, query: str,
|
||||||
|
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
|
||||||
|
data = {
|
||||||
|
"version": VERSION,
|
||||||
|
"request_id": request_id,
|
||||||
|
"query": query,
|
||||||
|
"meta": {
|
||||||
|
"status": "draft",
|
||||||
|
"round": 0,
|
||||||
|
"budget": {
|
||||||
|
"api_input_tokens": 0,
|
||||||
|
"api_output_tokens": 0,
|
||||||
|
"api_token_cap": api_token_cap,
|
||||||
|
"rounds_cap": rounds_cap,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"brief": None,
|
||||||
|
"progress": [],
|
||||||
|
"issues": [],
|
||||||
|
"decisions": [],
|
||||||
|
"archive": [],
|
||||||
|
}
|
||||||
|
return cls(data)
|
||||||
|
|
||||||
|
# ---------- 访问 ----------
|
||||||
|
@property
|
||||||
|
def request_id(self) -> str:
|
||||||
|
return self._data["request_id"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self._data["meta"]["status"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self) -> Dict[str, Any]:
|
||||||
|
return copy.deepcopy(self._data)
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def meta(self) -> Dict[str, Any]:
|
||||||
|
return self._data["meta"]
|
||||||
|
|
||||||
|
def budget(self) -> Dict[str, int]:
|
||||||
|
return self._data["meta"]["budget"]
|
||||||
|
|
||||||
|
# ---------- 写入(均先校验) ----------
|
||||||
|
def _commit(self, data: Dict[str, Any]) -> None:
|
||||||
|
errors = validate(data)
|
||||||
|
if errors:
|
||||||
|
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def transition(self, new_status: str) -> None:
|
||||||
|
cur = self.status
|
||||||
|
if new_status == cur:
|
||||||
|
return
|
||||||
|
if new_status not in STATUS_FLOW.get(cur, set()):
|
||||||
|
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
|
||||||
|
self._data["meta"]["status"] = new_status
|
||||||
|
|
||||||
|
def apply_brief(self, brief: Dict[str, Any]) -> None:
|
||||||
|
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
|
||||||
|
if self._data.get("brief") is not None or self._brief_locked:
|
||||||
|
raise ValueError("brief 已写入,不可重复")
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
new["brief"] = brief
|
||||||
|
new["meta"]["status"] = "in_progress"
|
||||||
|
self._commit(new)
|
||||||
|
self._brief_locked = True
|
||||||
|
|
||||||
|
def add_progress(self, step: str, status: str, summary: str,
|
||||||
|
artifact: Optional[str] = None) -> None:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
|
||||||
|
if artifact:
|
||||||
|
entry["artifact"] = artifact
|
||||||
|
new["progress"].append(entry)
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
|
||||||
|
tried: str, ask: str) -> str:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
iid = f"i{len(new['issues']) + 1}"
|
||||||
|
entry = {
|
||||||
|
"id": iid, "step": step, "anchor": anchor,
|
||||||
|
"observed": observed, "expected": expected,
|
||||||
|
"tried": tried, "ask": ask,
|
||||||
|
}
|
||||||
|
new["issues"].append(entry)
|
||||||
|
self._commit(new)
|
||||||
|
return iid
|
||||||
|
|
||||||
|
def add_decision(self, ref: str, reply: str,
|
||||||
|
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
new["decisions"].append({
|
||||||
|
"ref": ref, "reply": reply,
|
||||||
|
"patch_plan": patch_plan or [],
|
||||||
|
})
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def revise_plan(self, updates: Dict[str, str]) -> None:
|
||||||
|
"""按 decision.patch_plan 修订既有 step 的 task(不改结构/顺序)。"""
|
||||||
|
if self._data.get("brief") is None:
|
||||||
|
raise ValueError("brief 尚未写入,无法修订 plan")
|
||||||
|
new = copy.deepcopy(self._data)
|
||||||
|
for pid, task in updates.items():
|
||||||
|
for p in new["brief"]["plan"]:
|
||||||
|
if p["id"] == pid:
|
||||||
|
p["task"] = task
|
||||||
|
break
|
||||||
|
self._commit(new)
|
||||||
|
|
||||||
|
def mark_round(self) -> None:
|
||||||
|
self._data["meta"]["round"] += 1
|
||||||
|
|
||||||
|
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
|
||||||
|
b = self._data["meta"]["budget"]
|
||||||
|
b["api_input_tokens"] += int(input_tokens)
|
||||||
|
b["api_output_tokens"] += int(output_tokens)
|
||||||
|
self._commit(self._data)
|
||||||
|
|
||||||
|
def exhausted(self) -> bool:
|
||||||
|
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
|
||||||
|
b = self._data["meta"]["budget"]
|
||||||
|
used = b["api_input_tokens"] + b["api_output_tokens"]
|
||||||
|
if b["api_token_cap"] and used >= b["api_token_cap"]:
|
||||||
|
return True
|
||||||
|
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---------- rollup(4.5) ----------
|
||||||
|
def rollup(self) -> int:
|
||||||
|
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
|
||||||
|
|
||||||
|
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
|
||||||
|
返回本次折叠的条目数。
|
||||||
|
"""
|
||||||
|
folded = 0
|
||||||
|
new_progress: List[Dict[str, Any]] = []
|
||||||
|
for entry in self._data.get("progress", []):
|
||||||
|
if entry.get("status") == "done" and entry.get("step"):
|
||||||
|
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
|
||||||
|
if line not in self._data["archive"]:
|
||||||
|
self._data["archive"].append(line)
|
||||||
|
folded += 1
|
||||||
|
else:
|
||||||
|
new_progress.append(entry)
|
||||||
|
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
|
||||||
|
kept_issues: List[Dict[str, Any]] = []
|
||||||
|
for iss in self._data.get("issues", []):
|
||||||
|
if iss.get("id") in resolved:
|
||||||
|
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
|
||||||
|
if line not in self._data["archive"]:
|
||||||
|
self._data["archive"].append(line)
|
||||||
|
else:
|
||||||
|
kept_issues.append(iss)
|
||||||
|
self._data["issues"] = kept_issues
|
||||||
|
self._data["progress"] = new_progress
|
||||||
|
return folded
|
||||||
|
|
||||||
|
# ---------- 渲染 ----------
|
||||||
|
def render_for_architect(self) -> str:
|
||||||
|
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
|
||||||
|
+最近回合 progress 摘要。目标 ≤1200 token。"""
|
||||||
|
d = self._data
|
||||||
|
parts: List[str] = []
|
||||||
|
m = d["meta"]
|
||||||
|
parts.append("== meta ==")
|
||||||
|
parts.append(f"status={m['status']} round={m['round']} "
|
||||||
|
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
|
||||||
|
parts.append("== query ==")
|
||||||
|
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
|
||||||
|
if d.get("brief"):
|
||||||
|
b = d["brief"]
|
||||||
|
parts.append("== brief(锁定) ==")
|
||||||
|
parts.append(f"goal: {_clip(b['goal'], 120)}")
|
||||||
|
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
|
||||||
|
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
|
||||||
|
parts.append("== issues ==")
|
||||||
|
for iss in d.get("issues", []):
|
||||||
|
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
|
||||||
|
f"ask={_clip(iss.get('ask', ''), 80)}")
|
||||||
|
parts.append("== 最近 3 条 decisions ==")
|
||||||
|
for dec in d.get("decisions", [])[-3:]:
|
||||||
|
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
|
||||||
|
parts.append("== progress 摘要 ==")
|
||||||
|
for p in d.get("progress", [])[-5:]:
|
||||||
|
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
|
||||||
|
parts.append("== archive ==")
|
||||||
|
for line in d.get("archive", [])[-8:]:
|
||||||
|
parts.append(line)
|
||||||
|
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions)
|
||||||
|
out = "\n".join(parts)
|
||||||
|
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
|
||||||
|
d = copy.deepcopy(d)
|
||||||
|
d["archive"] = d["archive"][4:]
|
||||||
|
out = "\n".join(_rerender(self, d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def render_for_worker(self, step_id: str,
|
||||||
|
artifact_text: Optional[str] = None) -> str:
|
||||||
|
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
|
||||||
|
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
|
||||||
|
d = self._data
|
||||||
|
b = d.get("brief")
|
||||||
|
parts: List[str] = []
|
||||||
|
if b:
|
||||||
|
parts.append("== 任务目标 (goal) ==")
|
||||||
|
parts.append(b["goal"])
|
||||||
|
parts.append("== 约束 (constraints) ==")
|
||||||
|
parts.extend(f"- {c}" for c in b.get("constraints", []))
|
||||||
|
parts.append("== 全部步骤 (plan) ==")
|
||||||
|
for p in b.get("plan", []):
|
||||||
|
mark = " <-- 当前步" if p.get("id") == step_id else ""
|
||||||
|
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
|
||||||
|
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
|
||||||
|
parts.append("== 依赖步摘要 (archive) ==")
|
||||||
|
for line in d.get("archive", [])[-6:]:
|
||||||
|
parts.append(line)
|
||||||
|
if artifact_text:
|
||||||
|
parts.append(f"== 当前步已有工件({step_id}) ==")
|
||||||
|
parts.append(artifact_text)
|
||||||
|
parts.append("== 验收标准 ==")
|
||||||
|
if b:
|
||||||
|
for a in b.get("acceptance", []):
|
||||||
|
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
|
||||||
|
f"(machine_checkable={a.get('machine_checkable', False)})")
|
||||||
|
parts.append("== 要求 ==")
|
||||||
|
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
|
||||||
|
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
# ---------- 持久化 ----------
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> "Workspace":
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return cls(data)
|
||||||
|
|
||||||
|
def prefix_signature(self) -> str:
|
||||||
|
"""返回稳定前缀的签名(T10 prefix cache)。
|
||||||
|
|
||||||
|
交流文本的"恒定位于前部"的部分(version + request_id + query + meta + brief)
|
||||||
|
应不随 progress/issues/decisions 追加而变化,从而使 llama-server 的
|
||||||
|
--cache-reuse 能命中该前缀、降低 prefill 开销。用紧凑 JSON 的哈希度量稳定性。
|
||||||
|
"""
|
||||||
|
stable = {
|
||||||
|
"version": self._data.get("version"),
|
||||||
|
"request_id": self._data.get("request_id"),
|
||||||
|
"query": self._data.get("query"),
|
||||||
|
"brief": self._data.get("brief"),
|
||||||
|
}
|
||||||
|
import hashlib
|
||||||
|
s = json.dumps(stable, ensure_ascii=False, sort_keys=True)
|
||||||
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
|
||||||
|
def _rerender(ws: "Workspace", d: Dict[str, Any]) -> List[str]:
|
||||||
|
"""用裁剪后的数据重建 Architect 渲染(供超限压缩内部用)。"""
|
||||||
|
parts: List[str] = []
|
||||||
|
m = d["meta"]
|
||||||
|
parts.append("== meta ==")
|
||||||
|
parts.append(f"status={m['status']} round={m['round']}")
|
||||||
|
parts.append("== query ==")
|
||||||
|
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
|
||||||
|
parts.append("== issues ==")
|
||||||
|
for iss in d.get("issues", []):
|
||||||
|
parts.append(f"{iss['id']} step={iss.get('step')} ask={_clip(iss.get('ask', ''), 80)}")
|
||||||
|
parts.append("== decisions(最近3) ==")
|
||||||
|
for dec in d.get("decisions", [])[-3:]:
|
||||||
|
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
|
||||||
|
parts.append("== progress ==")
|
||||||
|
for p in d.get("progress", [])[-5:]:
|
||||||
|
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
|
||||||
|
parts.append("== archive ==")
|
||||||
|
for line in d.get("archive", [])[-6:]:
|
||||||
|
parts.append(line)
|
||||||
|
return parts
|
||||||
@@ -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,264 @@
|
|||||||
|
"""llama-server 子进程生命周期管理(runtime 运维层)。
|
||||||
|
|
||||||
|
LlamaServerManager 负责:
|
||||||
|
- 按硬件档位/配置拼装启动命令(-m/-c/-ngl/额外参数)
|
||||||
|
- 启动子进程(Windows 下 CREATE_NEW_PROCESS_GROUP,便于组内终止)
|
||||||
|
- /health 轮询就绪、崩溃指数退避重启、优雅停止(terminate -> kill 兜底)
|
||||||
|
- 日志落盘 runs/llama_server.log
|
||||||
|
|
||||||
|
设计(D1 / D8 / D11):
|
||||||
|
- 不修改 llama.cpp 源码,只捆绑上游 release 二进制。
|
||||||
|
- 本模块可用第三方依赖(httpx),但健康检查默认用 urllib 保持轻量、可注入。
|
||||||
|
- 一切外部副作用(health 探测、进程 spawn)均可注入替身,保证封闭单测。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .hw_profile import tier_spec
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
class LlamaServerError(RuntimeError):
|
||||||
|
"""llama-server 启动/运行异常。"""
|
||||||
|
|
||||||
|
|
||||||
|
class LlamaServerManager:
|
||||||
|
"""管理单个 llama-server 子进程(单模型单实例,D5)。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
binary: str,
|
||||||
|
model: str,
|
||||||
|
port: int = 8901,
|
||||||
|
hw: Optional[Dict[str, Any]] = None,
|
||||||
|
extra_args: Optional[List[str]] = None,
|
||||||
|
health_timeout_s: float = 120.0,
|
||||||
|
poll_interval_s: float = 1.0,
|
||||||
|
max_restarts: int = 2,
|
||||||
|
log_dir: Optional[str] = None,
|
||||||
|
env: Optional[Dict[str, str]] = None,
|
||||||
|
health_check: Optional[Callable[[str], bool]] = None,
|
||||||
|
):
|
||||||
|
self.binary = Path(binary)
|
||||||
|
self.model = Path(model)
|
||||||
|
self.port = int(port)
|
||||||
|
# 档位规格:默认取 config 传入的 hw;缺少时按 tier 从内置表补全
|
||||||
|
self.hw = dict(hw or {"tier": "cpu"})
|
||||||
|
self.extra_args = list(extra_args or [])
|
||||||
|
self.health_timeout_s = health_timeout_s
|
||||||
|
self.poll_interval_s = poll_interval_s
|
||||||
|
self.max_restarts = max_restarts
|
||||||
|
self.log_dir = Path(log_dir) if log_dir else Path("runs")
|
||||||
|
self.env = dict(env) if env else None
|
||||||
|
self._health_check = health_check or self._default_health_check
|
||||||
|
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._log_path: Optional[Path] = None
|
||||||
|
self._started_at: Optional[float] = None
|
||||||
|
self._restart_count = 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 命令拼装(纯函数,便于单测)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _build_command(self) -> List[str]:
|
||||||
|
spec = tier_spec(self.hw.get("tier", "cpu"))
|
||||||
|
ngl = self.hw.get("ngl", spec["ngl"])
|
||||||
|
ctx = self.hw.get("ctx", spec["ctx"])
|
||||||
|
kv = self.hw.get("kv_quant", spec["kv_quant"])
|
||||||
|
cmd = [
|
||||||
|
str(self.binary),
|
||||||
|
"-m", str(self.model),
|
||||||
|
"--port", str(self.port),
|
||||||
|
"-ngl", str(ngl),
|
||||||
|
"-c", str(ctx),
|
||||||
|
"-ctk", kv,
|
||||||
|
"-ctv", kv,
|
||||||
|
]
|
||||||
|
cmd.extend(self.extra_args)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def command_preview(self) -> str:
|
||||||
|
"""启动命令预览(供日志/诊断打印,不执行)。"""
|
||||||
|
return " ".join(self._build_command())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 健康检查
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _default_health_check(self, endpoint: str) -> bool:
|
||||||
|
"""GET {endpoint}/health,2 秒超时;网络异常视为不健康。"""
|
||||||
|
url = f"{endpoint}/health"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=2.0) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return False
|
||||||
|
body = resp.read(200).decode("utf-8", errors="replace")
|
||||||
|
data = json.loads(body) if body else {}
|
||||||
|
return data.get("status", "").lower() == "ok" or "llama" in body.lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def health(self) -> bool:
|
||||||
|
"""探测当前是否健康(进程在且 /health 通过)。"""
|
||||||
|
if self._proc is None or self._proc.poll() is not None:
|
||||||
|
return False
|
||||||
|
return self._health_check(self.endpoint())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 生命周期
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def endpoint(self) -> str:
|
||||||
|
return f"http://127.0.0.1:{self.port}"
|
||||||
|
|
||||||
|
def _log(self, msg: str) -> None:
|
||||||
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
line = f"[{_now()}] {msg}"
|
||||||
|
path = self._log_path or (self.log_dir / "llama_server.log")
|
||||||
|
self._log_path = path
|
||||||
|
try:
|
||||||
|
with open(path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""启动子进程并轮询至健康就绪。
|
||||||
|
|
||||||
|
返回 True 表示健康就绪;False 表示启动失败/超时(进程可能已退出)。
|
||||||
|
"""
|
||||||
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
|
return self.health()
|
||||||
|
if not self.binary.exists():
|
||||||
|
raise LlamaServerError(
|
||||||
|
f"llama-server 二进制不存在: {self.binary}。请先运行 "
|
||||||
|
f"scripts/setup_runtime.py 下载,或将上游 release 放入 bin/(D1 不改源码)。"
|
||||||
|
)
|
||||||
|
if not self.model.exists():
|
||||||
|
raise LlamaServerError(
|
||||||
|
f"模型文件不存在: {self.model}。请先运行 scripts/setup_runtime.py 下载 GGUF。"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = self._build_command()
|
||||||
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logf = self.log_dir / "llama_server.log"
|
||||||
|
self._log_path = logf
|
||||||
|
self._log(f"启动: {self.command_preview()}")
|
||||||
|
|
||||||
|
kwargs: Dict[str, Any] = {}
|
||||||
|
if os.name == "nt":
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=open(logf, "ab", buffering=0),
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
env=self.env,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
self._log(f"spawn 失败: {e}")
|
||||||
|
self._proc = None
|
||||||
|
raise LlamaServerError(f"无法启动 llama-server: {e}") from e
|
||||||
|
|
||||||
|
self._started_at = time.time()
|
||||||
|
return self._wait_healthy()
|
||||||
|
|
||||||
|
def _wait_healthy(self) -> bool:
|
||||||
|
deadline = time.time() + self.health_timeout_s
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
self._log(f"进程过早退出 rc={self._proc.returncode}")
|
||||||
|
return False
|
||||||
|
if self.health():
|
||||||
|
self._log(f"健康就绪 @ {self.endpoint()} (pid={self._proc.pid})")
|
||||||
|
return True
|
||||||
|
time.sleep(self.poll_interval_s)
|
||||||
|
self._log("健康检查超时,标记为启动失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self, timeout_s: float = 8.0) -> None:
|
||||||
|
"""优雅停止:terminate(CTRL_BREAK)-> 等待 -> kill 兜底(Windows 语义)。"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
if proc.poll() is not None:
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=timeout_s)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._log("terminate 超时,kill 兜底")
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
self._log("已停止")
|
||||||
|
|
||||||
|
def ensure_alive(self) -> bool:
|
||||||
|
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
|
||||||
|
if self._proc is not None and self._proc.poll() is None and self.health():
|
||||||
|
return True
|
||||||
|
if self._restart_count >= self.max_restarts:
|
||||||
|
return False
|
||||||
|
backoff = min(2.0 ** self._restart_count, 8.0)
|
||||||
|
self._restart_count += 1
|
||||||
|
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
|
||||||
|
time.sleep(backoff)
|
||||||
|
if self._proc is not None and self._proc.poll() is None:
|
||||||
|
self.stop()
|
||||||
|
return self.start()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._proc is not None and self._proc.poll() is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid(self) -> Optional[int]:
|
||||||
|
return self._proc.pid if self._proc is not None else None
|
||||||
|
|
||||||
|
def __enter__(self) -> "LlamaServerManager":
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
|
||||||
|
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
|
||||||
|
binary = cfg.get("binary", "bin/llama-server.exe")
|
||||||
|
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
|
||||||
|
port = int(cfg.get("port", 8901))
|
||||||
|
hw = cfg.get("hw", {}) or {}
|
||||||
|
extra = cfg.get("extra_args", [])
|
||||||
|
return LlamaServerManager(
|
||||||
|
binary=binary,
|
||||||
|
model=model,
|
||||||
|
port=port,
|
||||||
|
hw=hw,
|
||||||
|
extra_args=extra,
|
||||||
|
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
|
||||||
|
max_restarts=int(cfg.get("max_restarts", 2)),
|
||||||
|
log_dir=cfg.get("log_dir"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""E1 token 经济学实验脚本(论文主实验,本地确定性可跑)。
|
||||||
|
|
||||||
|
对比四种策略下 Architect(大模型)单请求输入 token 量:
|
||||||
|
A1 全量上下文 :每轮把完整历史+工件全文发给 Architect(无压缩基线)
|
||||||
|
A2 交流文本协议:只用 render_for_architect 压缩摘要(D7)
|
||||||
|
A3 A2 + rollup :先把已完成步骤折叠为 archive 摘要行再渲染
|
||||||
|
A4 A3 + prefix :记录可被 --cache-reuse 命中的稳定前缀 token(降低 prefill 成本)
|
||||||
|
|
||||||
|
北极星指标(方案 1.0):A2/A3/A4 相对 A1 的 token 下降 ≥80%。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/bench_tokens.py [--data eval/v2_sample.json] [--out research/v2_experiments]
|
||||||
|
本地模式:不调用真实 API,用 estimate_tokens 对策略做确定性测量,输出 CSV+MD。
|
||||||
|
--live 模式(可选,需 API key + 本地模型):走真实管线记录 usage。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from router_system.workspace import Workspace, estimate_tokens # noqa: E402
|
||||||
|
|
||||||
|
# 每步模拟工件文本(本地模式用,代表真实产物体量)
|
||||||
|
_ARTIFACT_TEMPLATE = (
|
||||||
|
"(工件){domain} 步骤实现说明:这是第 {i} 步的完整实现细节与说明文本,"
|
||||||
|
"包含关键逻辑、边界处理与可运行示例,长度适中以模拟真实产物。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _brief_for(query: str, domain: str, n_steps: int = 3) -> dict:
|
||||||
|
return {
|
||||||
|
"goal": query,
|
||||||
|
"constraints": ["遵守领域规范", "输出可交付"],
|
||||||
|
"tags": [domain],
|
||||||
|
"acceptance": [{"id": "a1", "check": "满足用户需求", "machine_checkable": True}],
|
||||||
|
"plan": [
|
||||||
|
{"id": f"s{i+1}", "task": f"{domain} 步骤{i+1}:推进目标", "deps": [] if i == 0 else [f"s{i}"],
|
||||||
|
"done_criteria": "达到步骤目标"}
|
||||||
|
for i in range(n_steps)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_workspace(query: str, domain: str, n_steps: int = 3, n_rounds: int = 3) -> Workspace:
|
||||||
|
"""构造一个模拟进行到中后期的交流文本(含 progress/issues/decisions)。"""
|
||||||
|
ws = Workspace.new("bench" + query.encode("utf-8").hex()[:8], query,
|
||||||
|
api_token_cap=8000, rounds_cap=6)
|
||||||
|
ws.apply_brief(_brief_for(query, domain, n_steps))
|
||||||
|
# 已完成前 n_rounds 步(至少 1),最后一步待办
|
||||||
|
done_steps = max(1, min(n_rounds, n_steps))
|
||||||
|
for i in range(done_steps):
|
||||||
|
ws.add_progress(f"s{i+1}", "done",
|
||||||
|
f"步骤{i+1}完成:{_ARTIFACT_TEMPLATE.format(domain=domain, i=i+1)[:60]}",
|
||||||
|
artifact=f"a://s{i+1}.py" if domain == "code" else f"a://s{i+1}.md")
|
||||||
|
# 加入 issue + decision(模拟一轮裁决)
|
||||||
|
if done_steps < n_steps:
|
||||||
|
iid = ws.add_issue(f"s{done_steps+1}", f"a://s{done_steps+1}.py#L1",
|
||||||
|
"验证未通过", "达到目标", "已自修 2 次", "请裁决")
|
||||||
|
ws.add_decision(iid, "按此方向继续推进", [{"id": f"s{done_steps+1}", "task": "按裁决修订"}])
|
||||||
|
ws.mark_round()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_text(domain: str, i: int) -> str:
|
||||||
|
return _ARTIFACT_TEMPLATE.format(domain=domain, i=i)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(ws: Workspace, n_steps: int = 3):
|
||||||
|
"""测量四种策略的单请求 Architect 输入 token。"""
|
||||||
|
domain = (ws.get("brief") or {}).get("tags", ["general"])[0]
|
||||||
|
|
||||||
|
# A1 全量上下文:把完整历史逐字发送(query + brief 全文 + 全部工件全文 +
|
||||||
|
# 全部 issues/decisions/progress 全文),无任何压缩。
|
||||||
|
a1 = _full_context_tokens(ws, domain, n_steps)
|
||||||
|
|
||||||
|
# A2 交流文本:render_for_architect
|
||||||
|
a2 = estimate_tokens(ws.render_for_architect())
|
||||||
|
|
||||||
|
# A3 A2 + rollup
|
||||||
|
ws3 = Workspace(ws.data)
|
||||||
|
ws3.rollup()
|
||||||
|
a3 = estimate_tokens(ws3.render_for_architect())
|
||||||
|
|
||||||
|
# A4 A3 + prefix:token 数同 A3;prefix_hit 为可复用稳定前缀
|
||||||
|
prefix_hit = estimate_tokens(_prefix_region(ws))
|
||||||
|
return {"a1": a1, "a2": a2, "a3": a3, "a4": a3, "prefix_hit": prefix_hit}
|
||||||
|
|
||||||
|
|
||||||
|
def _full_context_tokens(ws: Workspace, domain: str, n_steps: int) -> int:
|
||||||
|
"""A1 基线:完整逐字上下文的 token 数。"""
|
||||||
|
d = ws.data
|
||||||
|
total = estimate_tokens(d.get("query", ""))
|
||||||
|
# brief 全文(含 goal/constraints/plan 全部字段)
|
||||||
|
total += estimate_tokens(json.dumps(d.get("brief"), ensure_ascii=False))
|
||||||
|
# 全部工件全文
|
||||||
|
total += sum(estimate_tokens(_artifact_text(domain, i + 1)) for i in range(n_steps))
|
||||||
|
# issues / decisions / progress 全文
|
||||||
|
for iss in d.get("issues", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(iss, ensure_ascii=False))
|
||||||
|
for dec in d.get("decisions", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(dec, ensure_ascii=False))
|
||||||
|
for p in d.get("progress", []) or []:
|
||||||
|
total += estimate_tokens(json.dumps(p, ensure_ascii=False))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_region(ws: Workspace) -> str:
|
||||||
|
"""稳定前缀(可被 prefix cache 命中)的文本。"""
|
||||||
|
d = ws.data
|
||||||
|
stable = {"version": d.get("version"), "request_id": d.get("request_id"),
|
||||||
|
"query": d.get("query"), "brief": d.get("brief")}
|
||||||
|
return json.dumps(stable, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
|
||||||
|
items = json.loads(Path(data_path).read_text(encoding="utf-8"))
|
||||||
|
out = Path(out_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows = []
|
||||||
|
for it in items:
|
||||||
|
ws = build_workspace(it["query"], it.get("domain", "general"), n_steps)
|
||||||
|
m = measure(ws, n_steps)
|
||||||
|
rows.append({
|
||||||
|
"id": it["id"], "domain": it.get("domain", "general"),
|
||||||
|
"a1_full": m["a1"], "a2_ws": m["a2"], "a3_rollup": m["a3"],
|
||||||
|
"a4_prefix": m["a4"], "prefix_hit": m["prefix_hit"],
|
||||||
|
"reduction_a2": round(1 - m["a2"] / m["a1"], 4) if m["a1"] else 0,
|
||||||
|
"reduction_a4": round(1 - m["a4"] / m["a1"], 4) if m["a1"] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# CSV
|
||||||
|
csv_path = out / "E1_token_economics.csv"
|
||||||
|
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
|
||||||
|
# 聚合
|
||||||
|
n = len(rows)
|
||||||
|
avg = {k: round(sum(r[k] for r in rows) / n, 2) for k in
|
||||||
|
("a1_full", "a2_ws", "a3_rollup", "a4_prefix", "prefix_hit")}
|
||||||
|
red_a2 = round(1 - avg["a2_ws"] / avg["a1_full"], 4)
|
||||||
|
red_a4 = round(1 - avg["a4_prefix"] / avg["a1_full"], 4)
|
||||||
|
|
||||||
|
md = _render_md(rows, avg, red_a2, red_a4)
|
||||||
|
(out / "E1_token_economics.md").write_text(md, encoding="utf-8")
|
||||||
|
print(f"写入: {csv_path}")
|
||||||
|
print(f"写入: {out / 'E1_token_economics.md'}")
|
||||||
|
print(f"汇总: A1={avg['a1_full']} A2={avg['a2_ws']} A3={avg['a3_rollup']} "
|
||||||
|
f"A4={avg['a4_prefix']} prefix_hit={avg['prefix_hit']}")
|
||||||
|
print(f"token 下降: A2 相对 A1 = {red_a2*100:.1f}% | A4 相对 A1 = {red_a4*100:.1f}%")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_md(rows, avg, red_a2, red_a4) -> str:
|
||||||
|
lines = [
|
||||||
|
"# E1 token 经济学(本地确定性测量)",
|
||||||
|
"",
|
||||||
|
"> 模式:本地 estimate_tokens 测量(不调用真实 API)。真实数据需 --live + API key + 本地模型。",
|
||||||
|
"",
|
||||||
|
f"- 样例数:{len(rows)}",
|
||||||
|
f"- A1 全量上下文均值:**{avg['a1_full']} token**",
|
||||||
|
f"- A2 交流文本均值:**{avg['a2_ws']} token**",
|
||||||
|
f"- A3 A2+rollup 均值:**{avg['a3_rollup']} token**",
|
||||||
|
f"- A4 A3+prefix 均值:**{avg['a4_prefix']} token**(prefix 可命中 {avg['prefix_hit']} token)",
|
||||||
|
"",
|
||||||
|
f"## 北极星指标(token 下降 ≥80%)",
|
||||||
|
"",
|
||||||
|
f"- A2 相对 A1:**{red_a2*100:.1f}%**",
|
||||||
|
f"- A4 相对 A1:**{red_a4*100:.1f}%**",
|
||||||
|
"",
|
||||||
|
"### 说明(诚实解读)",
|
||||||
|
"",
|
||||||
|
"1. 本报告为本地确定性测量(estimate_tokens),未调用真实 API。",
|
||||||
|
"2. A3(rollup)收益为规模相关:小样例下 archive 增量可能抵消收益,长会话才显现。",
|
||||||
|
"3. 前缀稳定性(T10)已验证,配合 llama-server --cache-reuse 可复用稳定前缀。",
|
||||||
|
"4. 北极星 ≥80% 需在 --live 模式(API key + 本地模型)下由 E1 实验确认。",
|
||||||
|
"",
|
||||||
|
"## 明细",
|
||||||
|
"",
|
||||||
|
"| id | domain | A1 | A2 | A3 | A4 | prefix_hit |",
|
||||||
|
"|----|--------|----|----|----|----|----|",
|
||||||
|
]
|
||||||
|
for r in rows:
|
||||||
|
lines.append(f"| {r['id']} | {r['domain']} | {r['a1_full']} | {r['a2_ws']} | "
|
||||||
|
f"{r['a3_rollup']} | {r['a4_prefix']} | {r['prefix_hit']} |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--data", default="eval/v2_sample.json")
|
||||||
|
ap.add_argument("--out", default="research/v2_experiments")
|
||||||
|
ap.add_argument("--steps", type=int, default=3)
|
||||||
|
ap.add_argument("--live", action="store_true", help="真实 API(需 key + 本地模型)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.live:
|
||||||
|
print("[warn] --live 需 API key + 本地 llama-server;当前未实现自动跑数,请接入后使用。")
|
||||||
|
run(args.data, args.out, args.steps)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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,177 @@
|
|||||||
|
"""一键准备 v2 本地运行时:下载 llama-server 二进制与默认 GGUF 模型。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/setup_runtime.py [--config config/config.yaml]
|
||||||
|
|
||||||
|
行为(对齐《实现方案_v2》6.4 / T11):
|
||||||
|
- llama-server:从 GitHub releases 拉 Windows Vulkan 版 zip,解压 llama-server.exe 到 bin/。
|
||||||
|
- GGUF:优先 hf-mirror.com(env HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
|
||||||
|
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
|
||||||
|
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
|
||||||
|
|
||||||
|
下载函数可注入(tests 用假 urllib),保证封闭单测。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from runtime.hw_profile import detect_summary # noqa: E402
|
||||||
|
|
||||||
|
# 默认资源(可用 env 覆盖)
|
||||||
|
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
|
||||||
|
"LLAMA_ZIP_URL",
|
||||||
|
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
|
||||||
|
)
|
||||||
|
DEFAULT_GGUF_URL = os.environ.get(
|
||||||
|
"GGUF_URL",
|
||||||
|
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
|
||||||
|
)
|
||||||
|
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
|
||||||
|
|
||||||
|
URLS = {
|
||||||
|
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
|
||||||
|
"gguf": (DEFAULT_GGUF_URL, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
|
||||||
|
"""解析 HTTP Content-Length 头。"""
|
||||||
|
if not content_length:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(content_length.strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_size(path: Path, expected: Optional[int],
|
||||||
|
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
|
||||||
|
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
|
||||||
|
actual = path.stat().st_size if path.exists() else 0
|
||||||
|
if not expected:
|
||||||
|
return actual > 0, actual
|
||||||
|
return abs(actual - expected) <= tolerance, actual
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
|
||||||
|
|
||||||
|
def __init__(self, chunk: int = 64 * 1024,
|
||||||
|
opener_factory: Optional[Callable[[], Any]] = None):
|
||||||
|
self.chunk = chunk
|
||||||
|
self._opener_factory = opener_factory
|
||||||
|
|
||||||
|
def _opener(self):
|
||||||
|
if self._opener_factory is not None:
|
||||||
|
return self._opener_factory()
|
||||||
|
return urllib.request.build_opener()
|
||||||
|
|
||||||
|
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
|
||||||
|
"""下载(断点续传)。返回 (bytes_written, error)。"""
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = dest.stat().st_size if dest.exists() else 0
|
||||||
|
headers = {"User-Agent": "v2-setup-runtime/1.0"}
|
||||||
|
if existing > 0:
|
||||||
|
headers["Range"] = f"bytes={existing}-"
|
||||||
|
opener = self._opener()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
with opener.open(req, timeout=60) as resp:
|
||||||
|
mode = "ab" if existing > 0 else "wb"
|
||||||
|
written = existing
|
||||||
|
with open(dest, mode) as f:
|
||||||
|
while True:
|
||||||
|
block = resp.read(self.chunk)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
f.write(block)
|
||||||
|
written += len(block)
|
||||||
|
return written, None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return existing, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
|
||||||
|
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
|
||||||
|
try:
|
||||||
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
target = None
|
||||||
|
for n in zf.namelist():
|
||||||
|
if n.lower().endswith("llama-server.exe"):
|
||||||
|
target = n
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
return "zip 中未找到 llama-server.exe"
|
||||||
|
dest = bin_dir / "llama-server.exe"
|
||||||
|
with zf.open(target) as src, open(dest, "wb") as out:
|
||||||
|
out.write(src.read())
|
||||||
|
return None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return f"解压失败: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def manual_instructions() -> str:
|
||||||
|
return (
|
||||||
|
"网络下载失败。请手动准备:\n"
|
||||||
|
" 1. llama-server.exe:从 llama.cpp 官方 releases 下载 Windows Vulkan 版,放到 bin/\n"
|
||||||
|
" 2. GGUF 模型:从 hf-mirror.com 下载 qwen3.5-4b-q4_k_m.gguf,放到 models/\n"
|
||||||
|
"完成后重新运行 python scripts/serve.py 即可。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(config_path: Optional[str] = None) -> int:
|
||||||
|
from router_system.config import load_config
|
||||||
|
cfg = load_config(config_path)
|
||||||
|
runtime_cfg = cfg.get("runtime", {}).get("llama_server", {})
|
||||||
|
bin_dir = Path(runtime_cfg.get("binary", "bin/llama-server.exe")).parent
|
||||||
|
model_path = Path(runtime_cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf"))
|
||||||
|
|
||||||
|
print(detect_summary())
|
||||||
|
print("=== 准备运行时 ===")
|
||||||
|
|
||||||
|
dl = Downloader()
|
||||||
|
|
||||||
|
zip_path = Path("bin") / "llama-server.zip"
|
||||||
|
print(f"[1/2] 下载 llama-server -> {bin_dir / 'llama-server.exe'}")
|
||||||
|
_, err = dl.download(URLS["llama_zip"][0], zip_path)
|
||||||
|
if err:
|
||||||
|
print(f" llama-server 下载失败: {err}")
|
||||||
|
print(manual_instructions())
|
||||||
|
return 1
|
||||||
|
ex = extract_llama_server(zip_path, bin_dir)
|
||||||
|
if ex:
|
||||||
|
print(f" {ex}")
|
||||||
|
print(manual_instructions())
|
||||||
|
return 1
|
||||||
|
print(f" 已解压到 {bin_dir / 'llama-server.exe'}")
|
||||||
|
|
||||||
|
print(f"[2/2] 下载模型 -> {model_path}")
|
||||||
|
_, err2 = dl.download(URLS["gguf"][0], model_path)
|
||||||
|
if err2:
|
||||||
|
print(f" 模型下载失败: {err2}")
|
||||||
|
print(manual_instructions())
|
||||||
|
return 1
|
||||||
|
ok, actual = validate_size(model_path, URLS["gguf"][1])
|
||||||
|
print(f" 模型就绪,大小 {actual} 字节(校验: {'通过' if ok else '未校验'})")
|
||||||
|
print("=== 完成 === 可运行 python scripts/serve.py 启动端云协同服务")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ap = argparse.ArgumentParser(description="准备 v2 本地运行时")
|
||||||
|
ap.add_argument("--config", default=None, help="config 路径")
|
||||||
|
args = ap.parse_args()
|
||||||
|
sys.exit(main(args.config))
|
||||||
@@ -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,54 @@
|
|||||||
|
"""测试替身:模拟 llama-server(供 LlamaServerManager 封闭单测,D11)。
|
||||||
|
|
||||||
|
- 解析 --port / -m / -ngl / -c(与真实 llama-server 参数对齐)
|
||||||
|
- 把 pid / 收到的参数写入环境变量 FAKE_MARKER 指向的 JSON 文件
|
||||||
|
- 在本机端口起一个最小 http 服务:/health 返回 {"status":"ok"}
|
||||||
|
- 进程被终止时正常退出
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="fake-llama-server")
|
||||||
|
parser.add_argument("--port", type=int, default=8901)
|
||||||
|
parser.add_argument("-m", dest="model", default="")
|
||||||
|
parser.add_argument("-ngl", dest="ngl", default="0")
|
||||||
|
parser.add_argument("-c", dest="ctx", default="8192")
|
||||||
|
parser.add_argument("-ctk", dest="ctk", default="")
|
||||||
|
parser.add_argument("-ctv", dest="ctv", default="")
|
||||||
|
args, _ = parser.parse_known_args()
|
||||||
|
|
||||||
|
marker = os.environ.get("FAKE_MARKER")
|
||||||
|
if marker:
|
||||||
|
os.makedirs(os.path.dirname(marker) or ".", exist_ok=True)
|
||||||
|
with open(marker, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"pid": os.getpid(), "port": args.port,
|
||||||
|
"model": args.model, "args": sys.argv[1:]}, f)
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/health"):
|
||||||
|
body = json.dumps({"status": "ok", "server": "fake-llama"}).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||||
|
srv.serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""智能体端点测试:注入脚本化 chat_fn,不依赖真实模型/API key。"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
pytest.importorskip("httpx")
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import gateway.agent as ag
|
||||||
|
import gateway.api as ga
|
||||||
|
from gateway.model_pool import PoolStore
|
||||||
|
import gateway.model_pool as mp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def agent_env(tmp_path, monkeypatch):
|
||||||
|
"""隔离:池/服务/工作区全部指向临时目录,chat_fn 用脚本替身。"""
|
||||||
|
mp.reset_pool()
|
||||||
|
mp._store = PoolStore(path=tmp_path / "pool.json")
|
||||||
|
ag.reset_agent_service()
|
||||||
|
service = ag.AgentService(run_dir=tmp_path / "agent_runs")
|
||||||
|
ag._service = service
|
||||||
|
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
|
||||||
|
store = ga.settings_store()
|
||||||
|
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||||||
|
# 工作区指向临时目录 + 给经典回退一个假 key(防止 .env 缺失时 400)
|
||||||
|
store.update({"agent": {"workspace_dir": str(tmp_path / "ws")},
|
||||||
|
"architect": {"api_key": "sk-fake-test"}})
|
||||||
|
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()
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
return TestClient(ga.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_done(service, rid, timeout=10.0):
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
info = service.get(rid)
|
||||||
|
if info and info.state in ("done", "failed"):
|
||||||
|
return info
|
||||||
|
time.sleep(0.05)
|
||||||
|
return service.get(rid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_full_flow(agent_env, client):
|
||||||
|
"""写文件 -> 最终答复:验证事件、工作区落盘、状态终态。"""
|
||||||
|
agent_env["set_script"]([
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "c1", "name": "write_file",
|
||||||
|
"arguments": {"path": "notes.md", "content": "# 笔记"}}],
|
||||||
|
"usage": {"prompt_tokens": 30, "completion_tokens": 6}},
|
||||||
|
{"content": "已创建 notes.md,任务完成。", "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 40, "completion_tokens": 8}},
|
||||||
|
])
|
||||||
|
r = client.post("/agent", json={"task": "帮我建一个 notes.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
assert r.json()["status"] == "running"
|
||||||
|
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done", info.error
|
||||||
|
assert "notes.md" in info.response
|
||||||
|
|
||||||
|
# 工作区真实落盘
|
||||||
|
assert (agent_env["ws"] / "notes.md").read_text(encoding="utf-8") == "# 笔记"
|
||||||
|
|
||||||
|
# 事件序列
|
||||||
|
events = client.get(f"/agent/{rid}/events").json()
|
||||||
|
kinds = [e["type"] for e in events]
|
||||||
|
assert "tool_call" in kinds and "tool_result" in kinds and "final" in kinds
|
||||||
|
assert events[-1]["reason"] == "answer"
|
||||||
|
|
||||||
|
# 状态端点
|
||||||
|
st = client.get(f"/agent/{rid}/status").json()
|
||||||
|
assert st["state"] == "done"
|
||||||
|
assert st["prompt_tokens"] == 70 and st["completion_tokens"] == 14
|
||||||
|
|
||||||
|
# 工作区浏览端点
|
||||||
|
ls = client.get("/agent/workspace").json()
|
||||||
|
assert ls["ok"] is True
|
||||||
|
assert any(e["name"] == "notes.md" for e in ls["entries"])
|
||||||
|
f = client.get("/agent/file", params={"path": "notes.md"}).json()
|
||||||
|
assert f["content"] == "# 笔记"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_jail_via_api(agent_env, client):
|
||||||
|
"""工具结果为 ok=False(越界被拒),循环仍能继续到最终答复。"""
|
||||||
|
agent_env["set_script"]([
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "c1", "name": "read_file",
|
||||||
|
"arguments": {"path": "../../secret.txt"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
|
||||||
|
{"content": "越界访问被拒绝。", "tool_calls": [], "usage": {}},
|
||||||
|
])
|
||||||
|
r = client.post("/agent", json={"task": "读一下上级目录"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done"
|
||||||
|
events = agent_env["service"].read_events(rid)
|
||||||
|
tool_result = next(e for e in events if e["type"] == "tool_result")
|
||||||
|
assert tool_result["ok"] is False
|
||||||
|
|
||||||
|
# 文件读取 API 直接越界 -> 404/400
|
||||||
|
r2 = client.get("/agent/file", params={"path": "../../x.txt"})
|
||||||
|
assert r2.status_code in (400, 404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_model_from_pool(agent_env, client, monkeypatch):
|
||||||
|
"""池 agent 角色(或显式 pool_id)应被采用;mock 池模型拒绝。"""
|
||||||
|
from gateway.agent import OpenAICompatChat
|
||||||
|
captured = {}
|
||||||
|
real_factory = None
|
||||||
|
|
||||||
|
# 先放一个 openai 池条目并指派 agent 角色
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "ag-1", "name": "智能体模型", "tier": "premium", "backend": "openai",
|
||||||
|
"base_url": "https://api.example.com", "model": "big-model-x",
|
||||||
|
"api_key": "sk-abc1234567", "enabled": True,
|
||||||
|
})
|
||||||
|
client.put("/pool/roles", json={"agent": "ag-1"})
|
||||||
|
|
||||||
|
# /agent 不带 pool_id -> 用池 agent 角色
|
||||||
|
r = client.post("/agent", json={"task": "hi"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["model"] == "big-model-x"
|
||||||
|
|
||||||
|
# mock 条目 -> 400
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "mk-1", "name": "mock", "tier": "local", "backend": "mock",
|
||||||
|
"model": "mock", "enabled": True,
|
||||||
|
})
|
||||||
|
r2 = client.post("/agent", json={"task": "hi", "pool_id": "mk-1"})
|
||||||
|
assert r2.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_task_validation(agent_env, client):
|
||||||
|
assert client.post("/agent", json={"task": ""}).status_code == 400
|
||||||
|
assert client.post("/agent", json={}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_404(agent_env, client):
|
||||||
|
assert client.get("/agent/ghost/status").status_code == 404
|
||||||
|
assert client.get("/agent/ghost/events").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 工作区选择(T23) ----------------
|
||||||
|
|
||||||
|
def test_agent_run_with_selected_workspace(agent_env, client, tmp_path):
|
||||||
|
"""显式 workspace 应成为本次运行的工作目录(文件写进去,状态记录目录)。"""
|
||||||
|
target = tmp_path / "my_project"
|
||||||
|
target.mkdir()
|
||||||
|
agent_env["set_script"]([
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "c1", "name": "write_file",
|
||||||
|
"arguments": {"path": "build.py", "content": "print('ok')"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 2}},
|
||||||
|
{"content": "已写入 build.py。", "tool_calls": [], "usage": {}},
|
||||||
|
])
|
||||||
|
r = client.post("/agent", json={"task": "写 build.py", "workspace": str(target)})
|
||||||
|
assert r.status_code == 200
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done"
|
||||||
|
assert (target / "build.py").read_text(encoding="utf-8") == "print('ok')"
|
||||||
|
st = client.get(f"/agent/{rid}/status").json()
|
||||||
|
assert st["workspace"] == str(target.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_workspace_not_exists(agent_env, client, tmp_path):
|
||||||
|
r = client.post("/agent", json={"task": "t", "workspace": str(tmp_path / "ghost")})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "不存在" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_open_and_recent(agent_env, client, tmp_path):
|
||||||
|
"""打开目录:设为当前 + 记入最近列表;支持 create 新建。"""
|
||||||
|
d1 = tmp_path / "proj_a"
|
||||||
|
d1.mkdir()
|
||||||
|
r1 = client.post("/agent/workspaces", json={"path": str(d1)})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["current"] == str(d1.resolve())
|
||||||
|
assert str(d1.resolve()) in r1.json()["recent"]
|
||||||
|
# create 新建
|
||||||
|
new_dir = tmp_path / "proj_b" / "nested"
|
||||||
|
r2 = client.post("/agent/workspaces", json={"path": str(new_dir), "create": True})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert new_dir.is_dir()
|
||||||
|
assert r2.json()["current"] == str(new_dir.resolve())
|
||||||
|
# 不存在且不建 -> 400
|
||||||
|
r3 = client.post("/agent/workspaces", json={"path": str(tmp_path / "nope")})
|
||||||
|
assert r3.status_code == 400
|
||||||
|
# 列表端点
|
||||||
|
lst = client.get("/agent/workspaces").json()
|
||||||
|
assert lst["current"] == str(new_dir.resolve())
|
||||||
|
assert len(lst["recent"]) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_fs_browse_endpoint(agent_env, client, tmp_path):
|
||||||
|
r = client.get("/agent/fs", params={"path": str(tmp_path)})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["ok"] is True
|
||||||
|
assert "dirs" in r.json()
|
||||||
|
r2 = client.get("/agent/fs", params={"path": str(tmp_path / "nope")})
|
||||||
|
assert r2.json()["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_workspace_and_file_accept_root(agent_env, client, tmp_path):
|
||||||
|
"""浏览/读取端点可指定 root(选中工作区)。"""
|
||||||
|
other = tmp_path / "other_ws"
|
||||||
|
other.mkdir()
|
||||||
|
(other / "x.txt").write_text("外部工作区", encoding="utf-8")
|
||||||
|
ls = client.get("/agent/workspace", params={"root": str(other)}).json()
|
||||||
|
assert ls["ok"] is True
|
||||||
|
assert any(e["name"] == "x.txt" for e in ls["entries"])
|
||||||
|
f = client.get("/agent/file", params={"path": "x.txt", "root": str(other)}).json()
|
||||||
|
assert f["content"] == "外部工作区"
|
||||||
|
# 非法 root -> 400
|
||||||
|
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 两级智能体(T26):规划者 + 执行者 ----------------
|
||||||
|
|
||||||
|
def _planner_resp(obj=None, raw=""):
|
||||||
|
content = raw or json.dumps(obj, ensure_ascii=False)
|
||||||
|
return {"content": content, "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 20}}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_dual(agent_env, monkeypatch, planner_script, executor_script):
|
||||||
|
"""注入假规划者(build_agent_chat)与假执行者(OpenAICompatChat)。"""
|
||||||
|
|
||||||
|
class FakePlanner:
|
||||||
|
api_key = "sk-fake"
|
||||||
|
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.script = list(planner_script)
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
if self.script:
|
||||||
|
return self.script.pop(0)
|
||||||
|
return _planner_resp({"verdict": "done", "final_answer": "(兜底)完成。"})
|
||||||
|
|
||||||
|
class FakeExecutorChat:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.script = list(executor_script)
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
if self.script:
|
||||||
|
return self.script.pop(0)
|
||||||
|
return {"content": "(执行者兜底)没有更多动作。", "tool_calls": [], "usage": {}}
|
||||||
|
|
||||||
|
def fake_chat_factory(acfg):
|
||||||
|
return FakePlanner()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
|
||||||
|
monkeypatch.setattr(ag, "OpenAICompatChat", FakeExecutorChat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_done_flow(agent_env, client, monkeypatch, tmp_path):
|
||||||
|
"""规划 -> 执行(写文件) -> 审查 done:事件/交接文档/状态全部落位。"""
|
||||||
|
_install_dual(
|
||||||
|
agent_env, monkeypatch,
|
||||||
|
planner_script=[
|
||||||
|
_planner_resp({"instructions": "在 data 目录创建 report.json",
|
||||||
|
"acceptance": "文件存在且内容为合法 JSON"}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "执行者已按指令创建数据文件,验收通过。"}),
|
||||||
|
],
|
||||||
|
executor_script=[
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||||
|
"arguments": {"path": "data/report.json",
|
||||||
|
"content": '{"ok": true}'}}],
|
||||||
|
"usage": {"prompt_tokens": 100, "completion_tokens": 10}},
|
||||||
|
{"content": "汇报:已创建 data/report.json,内容 {\"ok\": true}。",
|
||||||
|
"tool_calls": [], "usage": {"prompt_tokens": 120, "completion_tokens": 15}},
|
||||||
|
])
|
||||||
|
r = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "no-such"})
|
||||||
|
# 执行者条目不存在 -> 400
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# 先放一个合法 llama_server 条目作为执行者
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||||
|
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||||
|
"model": "qwen-0.8b", "enabled": True})
|
||||||
|
r2 = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "local-x"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["mode"] == "dual"
|
||||||
|
assert "本地小模型" in r2.json()["executor_model"]
|
||||||
|
|
||||||
|
rid = r2.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done", info.error
|
||||||
|
assert info.mode == "dual"
|
||||||
|
assert info.response == "执行者已按指令创建数据文件,验收通过。"
|
||||||
|
|
||||||
|
# 事件序列:规划 -> 执行(含工具) -> 审查 -> final
|
||||||
|
evs = agent_env["service"].read_events(rid)
|
||||||
|
phases = [e["phase"] for e in evs if e["type"] == "phase"]
|
||||||
|
assert phases == ["plan", "execute", "review"]
|
||||||
|
kinds = [e["type"] for e in evs]
|
||||||
|
assert "message" in kinds and "tool_call" in kinds
|
||||||
|
# 交接文档(智能体版交流文本)
|
||||||
|
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||||
|
assert ho["instructions"]
|
||||||
|
assert ho["exchanges"][0]["verdict"] == "done"
|
||||||
|
assert ho["executor_model"] == "本地小模型(qwen-0.8b)"
|
||||||
|
|
||||||
|
st = client.get(f"/agent/{rid}/status").json()
|
||||||
|
assert st["mode"] == "dual" and st["executor_model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_redo_then_done(agent_env, client, monkeypatch):
|
||||||
|
"""第一轮裁决 redo -> 执行者带补充指令再跑 -> 第二轮 done。"""
|
||||||
|
_install_dual(
|
||||||
|
agent_env, monkeypatch,
|
||||||
|
planner_script=[
|
||||||
|
_planner_resp({"instructions": "写 hello.txt"}),
|
||||||
|
_planner_resp({"verdict": "redo", "reply_to_executor": "文件内容不对,请写入 DONE",
|
||||||
|
"final_answer": ""}),
|
||||||
|
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||||
|
"final_answer": "第二轮通过。"}),
|
||||||
|
],
|
||||||
|
executor_script=[
|
||||||
|
{"content": "汇报:已写 hello.txt(内容空白)", "tool_calls": [],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
{"content": None,
|
||||||
|
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||||
|
"arguments": {"path": "hello.txt", "content": "DONE"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
{"content": "汇报:已按补充指令重写 hello.txt 内容为 DONE",
|
||||||
|
"tool_calls": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||||
|
])
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||||
|
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||||
|
"model": "qwen-0.8b", "enabled": True})
|
||||||
|
r = client.post("/agent", json={"task": "写 hello.txt", "executor_pool_id": "local-x"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "done"
|
||||||
|
assert info.response == "第二轮通过。"
|
||||||
|
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||||
|
assert [x["verdict"] for x in ho["exchanges"]] == ["redo", "done"]
|
||||||
|
# 第二轮执行者应收到 redo 补充指令(消息历史含 reply_to_executor 内容)
|
||||||
|
evs = agent_env["service"].read_events(rid)
|
||||||
|
exec_phases = [e for e in evs if e["type"] == "phase" and e["phase"] == "execute"]
|
||||||
|
assert len(exec_phases) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_agent_executor_error(agent_env, client, monkeypatch):
|
||||||
|
"""执行者客户端异常 -> 任务 failed,错误透出。"""
|
||||||
|
class BoomChat:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
raise RuntimeError("本地模型连不上")
|
||||||
|
|
||||||
|
class PlanOK:
|
||||||
|
api_key = "sk-fake"
|
||||||
|
|
||||||
|
async def __call__(self, messages, tools_spec):
|
||||||
|
return _planner_resp({"instructions": "随便执行"})
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: PlanOK())
|
||||||
|
monkeypatch.setattr(ag, "OpenAICompatChat", BoomChat)
|
||||||
|
client.post("/pool", json={
|
||||||
|
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||||
|
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||||
|
"model": "qwen-0.8b", "enabled": True})
|
||||||
|
r = client.post("/agent", json={"task": "t", "executor_pool_id": "local-x"})
|
||||||
|
rid = r.json()["request_id"]
|
||||||
|
info = _wait_done(agent_env["service"], rid)
|
||||||
|
assert info.state == "failed"
|
||||||
|
assert "RuntimeError" in (info.error or "")
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from router_system.architect import (
|
||||||
|
ArchitectCircuitBreaker,
|
||||||
|
ArchitectClient,
|
||||||
|
ArchitectError,
|
||||||
|
build_architect,
|
||||||
|
)
|
||||||
|
from router_system.workspace import Workspace
|
||||||
|
|
||||||
|
BRIEF_JSON = json.dumps({
|
||||||
|
"goal": "实现快排",
|
||||||
|
"constraints": ["标准库"],
|
||||||
|
"tags": ["code"],
|
||||||
|
"acceptance": [{"id": "a1", "check": "排序正确", "machine_checkable": True}],
|
||||||
|
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "task": "修"}]}, ensure_ascii=False)
|
||||||
|
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(handler, api_key="test-key", **kw):
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
|
||||||
|
api_key=api_key, transport=transport, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _resp_json(content, usage=None):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"choices": [{"message": {"content": content}}],
|
||||||
|
"usage": usage or {"prompt_tokens": 100, "completion_tokens": 20},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ws(**kw):
|
||||||
|
return Workspace.new(request_id="a1b2c3d4e5f6", query=kw.get("query", "写个快排"),
|
||||||
|
api_token_cap=kw.get("cap", 8000), rounds_cap=kw.get("rounds", 6))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- brief 成功 ----------
|
||||||
|
def test_brief_success_records_budget():
|
||||||
|
calls = []
|
||||||
|
def handler(request):
|
||||||
|
calls.append(request.url.path)
|
||||||
|
return _resp_json(BRIEF_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
brief = asyncio_run(client.brief("写个快排", ws))
|
||||||
|
assert brief["goal"] == "实现快排"
|
||||||
|
assert brief["plan"][0]["id"] == "s1"
|
||||||
|
assert calls == ["/v1/chat/completions"]
|
||||||
|
# token 计量回写
|
||||||
|
assert ws.budget()["api_input_tokens"] == 100
|
||||||
|
assert ws.budget()["api_output_tokens"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 缺 key ----------
|
||||||
|
def test_no_key_raises():
|
||||||
|
client = ArchitectClient(model="deepseek-chat", api_key=None)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 坏 JSON 重试一次成功 ----------
|
||||||
|
def test_bad_json_retry_once_success():
|
||||||
|
seq = [{"body": "不是json{{", "ok": False}, {"body": BRIEF_JSON, "ok": True}]
|
||||||
|
calls = []
|
||||||
|
def handler(request):
|
||||||
|
calls.append(1)
|
||||||
|
item = seq[len(calls) - 1]
|
||||||
|
return _resp_json(item["body"])
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
brief = asyncio_run(client.brief("hi", ws))
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert brief["goal"] == "实现快排"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 坏 JSON 两次失败 ----------
|
||||||
|
def test_bad_json_twice_raises():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json("垃圾输出{")
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API 错误(非 2xx) ----------
|
||||||
|
def test_http_error_raises():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(500, text="server error")
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 缺 choices ----------
|
||||||
|
def test_missing_choices_raises():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={"usage": {}})
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
with pytest.raises(ArchitectError):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 熔断:预算触顶,不再调用 transport ----------
|
||||||
|
def test_circuit_breaker_before_transport():
|
||||||
|
called = []
|
||||||
|
def handler(request):
|
||||||
|
called.append(1)
|
||||||
|
return _resp_json(BRIEF_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws(cap=1)
|
||||||
|
ws.add_budget(input_tokens=1, output_tokens=0) # used=1 >= cap=1
|
||||||
|
with pytest.raises(ArchitectCircuitBreaker):
|
||||||
|
asyncio_run(client.brief("hi", ws))
|
||||||
|
assert called == [] # 未触达 API
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- decide / final_review ----------
|
||||||
|
def test_decide():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json(DECIDE_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||||
|
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||||
|
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
|
||||||
|
out = asyncio_run(client.decide(ws))
|
||||||
|
assert out["reply"] == "改用断言"
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_review_done():
|
||||||
|
def handler(request):
|
||||||
|
return _resp_json(REVIEW_JSON)
|
||||||
|
client = _make_client(handler)
|
||||||
|
ws = _ws()
|
||||||
|
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||||||
|
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||||||
|
out = asyncio_run(client.final_review(ws))
|
||||||
|
assert out["verdict"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- build_architect 工厂 ----------
|
||||||
|
def test_build_architect_reads_env(monkeypatch):
|
||||||
|
cfg = {"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY"}
|
||||||
|
client = build_architect(cfg, get_env=lambda name: "sk-fake")
|
||||||
|
assert client.api_key == "sk-fake"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_architect_no_key():
|
||||||
|
cfg = {"api_key_env": "DEEPSEEK_API_KEY"}
|
||||||
|
client = build_architect(cfg, get_env=lambda name: None)
|
||||||
|
assert client.api_key is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 小工具 ----------
|
||||||
|
def asyncio_run(coro):
|
||||||
|
import asyncio
|
||||||
|
return asyncio.run(coro)
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -13,9 +13,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 +24,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
|
||||||
|
|||||||
@@ -30,8 +30,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,152 @@
|
|||||||
|
"""T2 llama-server 进程管理单测(封闭:假二进制 + 注入,D11)。"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from runtime.llama_server import LlamaServerManager, LlamaServerError, build_llama_server
|
||||||
|
from tests._ports import free_port
|
||||||
|
|
||||||
|
FAKE_SCRIPT = Path(__file__).parent / "fixtures" / "fake_llama_server.py"
|
||||||
|
PYTHON = sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_binary(tmp: Path) -> Path:
|
||||||
|
"""生成一个 .cmd 包装器:把 venv python + 假脚本当作"二进制"启动。"""
|
||||||
|
cmd = tmp / "fake-llama-server.cmd"
|
||||||
|
cmd.write_text(
|
||||||
|
f'@echo off\r\n"{PYTHON}" "{FAKE_SCRIPT}" %*\r\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _make_manager(tmp, binary, port, model, **kw):
|
||||||
|
marker = tmp / "marker.json"
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["FAKE_MARKER"] = str(marker)
|
||||||
|
return LlamaServerManager(
|
||||||
|
binary=str(binary),
|
||||||
|
model=str(model),
|
||||||
|
port=port,
|
||||||
|
hw={"tier": "cpu"},
|
||||||
|
health_timeout_s=15.0,
|
||||||
|
poll_interval_s=0.2,
|
||||||
|
log_dir=str(tmp / "runs"),
|
||||||
|
env=env,
|
||||||
|
**kw,
|
||||||
|
), marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_command_uses_hw_tier():
|
||||||
|
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=8901,
|
||||||
|
hw={"tier": "gpu12"})
|
||||||
|
cmd = m._build_command()
|
||||||
|
assert Path(cmd[0]) == Path("bin/x.exe")
|
||||||
|
assert cmd[1] == "-m" and Path(cmd[2]) == Path("models/m.gguf")
|
||||||
|
assert "--port" in cmd and "8901" in cmd
|
||||||
|
assert cmd[cmd.index("-ngl") + 1] == "99"
|
||||||
|
assert cmd[cmd.index("-c") + 1] == "32768"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_command_extra_args_appended():
|
||||||
|
m = LlamaServerManager(binary="bin/x.exe", model="models/m.gguf", port=1,
|
||||||
|
hw={"tier": "cpu"}, extra_args=["--cache-reuse", "256"])
|
||||||
|
cmd = m._build_command()
|
||||||
|
assert cmd[-2:] == ["--cache-reuse", "256"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_missing_binary_raises(tmp_path):
|
||||||
|
m = LlamaServerManager(binary=str(tmp_path / "nope.exe"), model=str(tmp_path / "m.gguf"),
|
||||||
|
port=free_port())
|
||||||
|
with pytest.raises(LlamaServerError):
|
||||||
|
m.start()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_missing_model_raises(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
m = LlamaServerManager(binary=str(binary), model=str(tmp_path / "missing.gguf"),
|
||||||
|
port=free_port())
|
||||||
|
with pytest.raises(LlamaServerError):
|
||||||
|
m.start()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_lifecycle(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
model = tmp_path / "model.gguf"
|
||||||
|
model.write_bytes(b"fake")
|
||||||
|
port = free_port()
|
||||||
|
m, marker = _make_manager(tmp_path, binary, port, model)
|
||||||
|
|
||||||
|
assert m.running is False
|
||||||
|
assert m.health() is False # 无进程时不健康
|
||||||
|
|
||||||
|
ok = m.start()
|
||||||
|
assert ok is True
|
||||||
|
assert m.running is True
|
||||||
|
assert m.health() is True
|
||||||
|
|
||||||
|
# 假脚本确实收到了参数
|
||||||
|
data = json.loads(marker.read_text(encoding="utf-8"))
|
||||||
|
assert data["port"] == port
|
||||||
|
assert data["model"] == str(model)
|
||||||
|
|
||||||
|
# 再 start 幂等(已运行返回健康)
|
||||||
|
assert m.start() is True
|
||||||
|
|
||||||
|
m.stop()
|
||||||
|
assert m.running is False
|
||||||
|
assert m.health() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_idempotent(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
model = tmp_path / "model.gguf"
|
||||||
|
model.write_bytes(b"fake")
|
||||||
|
m, _ = _make_manager(tmp_path, binary, free_port(), model)
|
||||||
|
m.stop() # 未启动时 stop 不抛
|
||||||
|
assert m.running is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_alive_healthy_no_restart(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
model = tmp_path / "model.gguf"
|
||||||
|
model.write_bytes(b"fake")
|
||||||
|
m, _ = _make_manager(tmp_path, binary, free_port(), model)
|
||||||
|
m.start()
|
||||||
|
assert m.ensure_alive() is True
|
||||||
|
# 不应触发重启
|
||||||
|
assert m._restart_count == 0
|
||||||
|
m.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_alive_restart_exhausted(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
model = tmp_path / "model.gguf"
|
||||||
|
model.write_bytes(b"fake")
|
||||||
|
m, _ = _make_manager(tmp_path, binary, free_port(), model, max_restarts=0)
|
||||||
|
# 未启动:restart 上限 0 -> False
|
||||||
|
assert m.ensure_alive() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_format():
|
||||||
|
m = LlamaServerManager(binary="x", model="m", port=8901, hw={"tier": "cpu"})
|
||||||
|
assert m.endpoint() == "http://127.0.0.1:8901"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_from_config(tmp_path):
|
||||||
|
binary = _make_fake_binary(tmp_path)
|
||||||
|
cfg = {
|
||||||
|
"binary": str(binary),
|
||||||
|
"model": str(tmp_path / "m.gguf"),
|
||||||
|
"port": 8999,
|
||||||
|
"hw": {"tier": "cpu"},
|
||||||
|
"extra_args": ["-fa"],
|
||||||
|
}
|
||||||
|
m = build_llama_server(cfg)
|
||||||
|
assert m.port == 8999
|
||||||
|
assert "-fa" in m.extra_args
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""模型池(PoolStore)测试:条目校验/CRUD/角色指派/管线解析/成本分账。"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import gateway.api as ga
|
||||||
|
import gateway.model_pool as mp
|
||||||
|
from gateway.model_pool import PoolStore, compute_cost, entry_to_architect_cfg, entry_to_worker_cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pool(tmp_path):
|
||||||
|
"""独立文件的全局池(不污染 config/model_pool.json)。"""
|
||||||
|
mp.reset_pool()
|
||||||
|
store = PoolStore(path=tmp_path / "model_pool.json")
|
||||||
|
mp._store = store
|
||||||
|
yield store
|
||||||
|
mp.reset_pool()
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
return TestClient(ga.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(**over):
|
||||||
|
base = {
|
||||||
|
"id": "prem-1", "name": "旗舰模型", "tier": "premium", "backend": "openai",
|
||||||
|
"base_url": "https://api.deepseek.com", "model": "deepseek-v4-pro",
|
||||||
|
"api_key": "sk-test-1234567890", "price_in": 1.0, "price_out": 2.0,
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- PoolStore 单元 ----------------
|
||||||
|
|
||||||
|
def test_pool_upsert_and_mask(pool):
|
||||||
|
masked = pool.upsert(_entry())
|
||||||
|
assert masked["api_key_set"] is True
|
||||||
|
assert "sk-test" not in masked["api_key"] # 明文不打回
|
||||||
|
data = pool.list()
|
||||||
|
assert data["entries"][0]["model"] == "deepseek-v4-pro"
|
||||||
|
assert data["entries"][0]["api_key_set"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_upsert_keeps_key_when_blank(pool):
|
||||||
|
pool.upsert(_entry())
|
||||||
|
pool.upsert(_entry(api_key="")) # 前端不回传明文 -> 保留
|
||||||
|
assert pool.get("prem-1")["api_key"] == "sk-test-1234567890"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_validation(pool):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.upsert(_entry(tier="超豪华"))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.upsert(_entry(backend="magic"))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.upsert(_entry(backend="openai", base_url="")) # 非 mock 缺端点
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.upsert(_entry(hack="x")) # 未知字段
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.upsert(_entry(price_in=-1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_roles_and_resolve(pool):
|
||||||
|
pool.upsert(_entry())
|
||||||
|
pool.upsert(_entry(id="local-1", tier="local", backend="llama_server",
|
||||||
|
base_url="http://127.0.0.1:8901/v1", model="qwen3.5-4b",
|
||||||
|
price_in=0, price_out=0))
|
||||||
|
assert pool.resolve("architect") is None # 未指派
|
||||||
|
pool.set_roles({"architect": "prem-1", "worker": "local-1"})
|
||||||
|
assert pool.resolve("architect")["id"] == "prem-1"
|
||||||
|
assert pool.resolve("worker")["id"] == "local-1"
|
||||||
|
assert pool.resolve("agent") is None
|
||||||
|
# 指派不存在的条目
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pool.set_roles({"agent": "ghost"})
|
||||||
|
# 删除条目 -> 角色自动清空
|
||||||
|
pool.delete("prem-1")
|
||||||
|
assert pool.resolve("architect") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_disabled_entry_not_resolved(pool):
|
||||||
|
pool.upsert(_entry(enabled=False))
|
||||||
|
pool.set_roles({"architect": "prem-1"})
|
||||||
|
assert pool.resolve("architect") is None # 禁用 -> 回退经典设置
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_cfg_mapping(pool):
|
||||||
|
e = pool.get("prem-1") or _entry()
|
||||||
|
acfg = entry_to_architect_cfg(_entry())
|
||||||
|
assert acfg["model"] == "deepseek-v4-pro"
|
||||||
|
assert acfg["api_key"] == "sk-test-1234567890"
|
||||||
|
wcfg = entry_to_worker_cfg(_entry())
|
||||||
|
assert wcfg["backend"] == "openai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_cost():
|
||||||
|
e = {"price_in": 1.0, "price_out": 2.0}
|
||||||
|
assert compute_cost(e, 1_000_000, 500_000) == pytest.approx(2.0)
|
||||||
|
assert compute_cost({"price_in": 0, "price_out": 0}, 999, 999) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- API 端点 ----------------
|
||||||
|
|
||||||
|
def test_pool_api_crud(pool, client):
|
||||||
|
r = client.get("/pool")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["roles"]["architect"] == ""
|
||||||
|
r2 = client.post("/pool", json=_entry())
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert len(r2.json()["entries"]) == 1
|
||||||
|
# 非法条目 -> 400
|
||||||
|
r3 = client.post("/pool", json=_entry(tier="bad"))
|
||||||
|
assert r3.status_code == 400
|
||||||
|
# 角色指派
|
||||||
|
r4 = client.put("/pool/roles", json={"architect": "prem-1"})
|
||||||
|
assert r4.status_code == 200
|
||||||
|
assert r4.json()["roles"]["architect"] == "prem-1"
|
||||||
|
# 删除
|
||||||
|
r5 = client.delete("/pool/prem-1")
|
||||||
|
assert r5.status_code == 200
|
||||||
|
assert r5.json()["roles"]["architect"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pipeline_uses_pool(pool, monkeypatch):
|
||||||
|
"""池指派应覆盖经典设置,测试 override 最后生效。"""
|
||||||
|
pool.upsert(_entry())
|
||||||
|
pool.set_roles({"architect": "prem-1"})
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_build_architect(cfg):
|
||||||
|
captured["architect"] = dict(cfg)
|
||||||
|
from router_system.architect import ArchitectClient
|
||||||
|
return ArchitectClient(model=cfg.get("model", "m"), api_key="k")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ga, "build_architect", fake_build_architect)
|
||||||
|
pipe = ga.build_v2_pipeline(worker_cfg_override={"backend": "mock"})
|
||||||
|
assert pipe is not None
|
||||||
|
assert captured["architect"]["model"] == "deepseek-v4-pro" # 池条目生效
|
||||||
|
ga.rebuild_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2stats_by_model():
|
||||||
|
from router_system.v2stats import V2Stats
|
||||||
|
|
||||||
|
class R:
|
||||||
|
request_id = "x"
|
||||||
|
fast_path = False
|
||||||
|
status = "done"
|
||||||
|
rounds_used = 1
|
||||||
|
api_input_tokens = 1000
|
||||||
|
api_output_tokens = 500
|
||||||
|
cost_est = 0.002
|
||||||
|
model_used = "deepseek-v4-pro"
|
||||||
|
route = []
|
||||||
|
|
||||||
|
s = V2Stats()
|
||||||
|
s.record(R())
|
||||||
|
summary = s.summary()
|
||||||
|
bucket = summary["by_model"]["deepseek-v4-pro"]
|
||||||
|
assert bucket["requests"] == 1
|
||||||
|
assert bucket["input_tokens"] == 1000
|
||||||
|
assert bucket["cost_est_usd"] == pytest.approx(0.002)
|
||||||
@@ -0,0 +1,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,76 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_enqueue_sample_rate():
|
||||||
|
import random
|
||||||
|
# 固定随机种子下按 10% 抽样应命中/不命中可控
|
||||||
|
rng = random.Random(42)
|
||||||
|
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=0.0, force_tags=[], rng=rng) for _ in range(1000))
|
||||||
|
assert hit == 0 # sample_rate=0 -> 永不抽样
|
||||||
|
rng = random.Random(1)
|
||||||
|
hit = sum(ReviewQueue.should_enqueue(["code"], sample_rate=1.0, force_tags=[], rng=rng) 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,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,239 @@
|
|||||||
|
"""工具内核测试:路径关押、文件工具往返、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
|
||||||
@@ -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,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="en">
|
||||||
|
<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>webapp</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,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<nav class="topbar">
|
||||||
|
<span class="brand">🤖 端云协同 LLM 系统</span>
|
||||||
|
<div class="nav-links">
|
||||||
|
<router-link to="/chat">💬 对话</router-link>
|
||||||
|
<router-link to="/collaboration">🔄 协作</router-link>
|
||||||
|
<router-link to="/agent">🤖 智能体</router-link>
|
||||||
|
<router-link to="/review">🔍 检验</router-link>
|
||||||
|
<router-link to="/metrics">📊 指标</router-link>
|
||||||
|
<router-link to="/settings">⚙️ 设置</router-link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 路由视图 -->
|
||||||
|
<router-view class="content" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// App.vue — 根布局,导航栏 + 路由出口
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 52px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e40af;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-links a:hover { background: #f3f4f6; }
|
||||||
|
.nav-links a.router-link-active {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden; /* 限制自己高度,不被子内容撑开 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /agent:提交智能体任务(executorPoolId 可选:两级模式的执行者/本地小模型) */
|
||||||
|
export async function startAgent(task: string, poolId?: string, workspace?: string, executorPoolId?: string) {
|
||||||
|
const { data } = await http.post<{
|
||||||
|
request_id: string; status: string; model: string
|
||||||
|
workspace?: string; mode?: 'single' | 'dual'; executor_model?: string
|
||||||
|
}>('/agent', {
|
||||||
|
task,
|
||||||
|
pool_id: poolId || undefined,
|
||||||
|
workspace: workspace || undefined,
|
||||||
|
executor_pool_id: executorPoolId || undefined,
|
||||||
|
})
|
||||||
|
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,9 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import ChatView from '@/views/ChatView.vue'
|
||||||
|
import AgentView from '@/views/AgentView.vue'
|
||||||
|
|
||||||
|
// Vite 构建时 base=/static/,但 FastAPI 在 /metrics 等路径提供 SPA(不在 /static/ 下),
|
||||||
|
// 所以 history 固定用 '/',避免 Vue Router 把 /metrics 当作 /static/metrics 解析导致路由不匹配。
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory('/'),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chat',
|
||||||
|
name: 'chat',
|
||||||
|
component: ChatView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/collaboration',
|
||||||
|
name: 'collaboration',
|
||||||
|
component: () => import('@/views/CollaborationView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/agent',
|
||||||
|
name: 'agent',
|
||||||
|
component: AgentView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/review',
|
||||||
|
name: 'review',
|
||||||
|
component: () => import('@/views/ReviewView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/metrics',
|
||||||
|
name: 'metrics',
|
||||||
|
component: () => import('@/views/MetricsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { chat, getRunStatus, pollUntilDone } from '@/api'
|
||||||
|
import type { ChatInitResponse, RunStatus, Workspace } from '@/types'
|
||||||
|
|
||||||
|
// 单次会话记录
|
||||||
|
export interface ChatSession {
|
||||||
|
requestId: string
|
||||||
|
query: string
|
||||||
|
init: ChatInitResponse
|
||||||
|
status: RunStatus | null
|
||||||
|
workspace: Workspace | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = defineStore('chat', () => {
|
||||||
|
const sessions = ref<ChatSession[]>([])
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const current = () => sessions.value.find((s) => s.requestId === currentId.value) ?? null
|
||||||
|
|
||||||
|
async function sendQuery(query: string) {
|
||||||
|
const init = await chat(query)
|
||||||
|
const session: ChatSession = {
|
||||||
|
requestId: init.request_id,
|
||||||
|
query,
|
||||||
|
init,
|
||||||
|
status: null,
|
||||||
|
workspace: null,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
sessions.value.unshift(session)
|
||||||
|
currentId.value = init.request_id
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatus(requestId: string) {
|
||||||
|
const s = await getRunStatus(requestId)
|
||||||
|
const session = sessions.value.find((x) => x.requestId === requestId)
|
||||||
|
if (session) session.status = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDone(requestId: string) {
|
||||||
|
const s = await pollUntilDone(requestId)
|
||||||
|
const session = sessions.value.find((x) => x.requestId === requestId)
|
||||||
|
if (session) session.status = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorkspace(requestId: string, ws: Workspace) {
|
||||||
|
const session = sessions.value.find((x) => x.requestId === requestId)
|
||||||
|
if (session) session.workspace = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrent(id: string | null) {
|
||||||
|
currentId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessions, currentId, current, sendQuery, pollStatus, waitForDone, updateWorkspace, setCurrent }
|
||||||
|
})
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// ── 协作任务状态 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** POST /chat 立即返回 */
|
||||||
|
export interface ChatInitResponse {
|
||||||
|
request_id: string
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /runs/{id}/status 返回 */
|
||||||
|
export interface RunStatus {
|
||||||
|
request_id: string
|
||||||
|
status: 'pending' | 'running' | 'done' | 'failed' // JobStore 任务状态
|
||||||
|
ws_state: string | null // workspace.json 内 state
|
||||||
|
started_at: number
|
||||||
|
finished_at: number
|
||||||
|
error: string | null
|
||||||
|
// PipelineResult 等效字段(扁平时 TaskInfo)
|
||||||
|
response: string | null
|
||||||
|
pipeline_status: string | null // done | fast_path | escalated | failed
|
||||||
|
fast_path: boolean
|
||||||
|
rounds_used: number
|
||||||
|
api_input_tokens: number
|
||||||
|
api_output_tokens: number
|
||||||
|
cost_est: number
|
||||||
|
model_used: string | null
|
||||||
|
latency_ms: number | null
|
||||||
|
route: string[]
|
||||||
|
workspace_path: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE 事件 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type SSEEvent =
|
||||||
|
| { type: 'workspace'; version: number; state: string; request_id: string; workspace: Workspace }
|
||||||
|
| { type: 'status'; value: string; request_id: string }
|
||||||
|
| { type: 'error'; detail: string }
|
||||||
|
|
||||||
|
// ── 交流文本(workspace.json)────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
request_id: string
|
||||||
|
query: string
|
||||||
|
brief: Brief | null
|
||||||
|
plan: PlanStep[]
|
||||||
|
progress: ProgressStep[]
|
||||||
|
issues: Issue[]
|
||||||
|
decisions: Decision[]
|
||||||
|
archive: string[]
|
||||||
|
meta: WsMeta
|
||||||
|
status?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Brief {
|
||||||
|
goal: string
|
||||||
|
tags: string[]
|
||||||
|
domain: string
|
||||||
|
constraints: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStep {
|
||||||
|
id: string
|
||||||
|
task: string
|
||||||
|
deps: string[]
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressStep {
|
||||||
|
step: string
|
||||||
|
status: 'pending' | 'running' | 'done'
|
||||||
|
artifact?: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Issue {
|
||||||
|
id: string
|
||||||
|
step: string
|
||||||
|
description: string
|
||||||
|
suggestion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Decision {
|
||||||
|
ref: string
|
||||||
|
reply: string
|
||||||
|
patch_plan?: PlanStep[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsMeta {
|
||||||
|
state: 'draft' | 'in_progress' | 'reviewing' | 'done' | 'failed'
|
||||||
|
round: number
|
||||||
|
rounds_cap: number
|
||||||
|
version?: number
|
||||||
|
api_input_tokens: number
|
||||||
|
api_output_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 人工检验 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReviewItem {
|
||||||
|
id: number
|
||||||
|
request_id: string
|
||||||
|
query: string
|
||||||
|
response: string
|
||||||
|
verdict: 'pending' | 'approved' | 'rejected'
|
||||||
|
tags: string[]
|
||||||
|
created_at: string
|
||||||
|
correction?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 指标 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Metrics {
|
||||||
|
router: Record<string, unknown>
|
||||||
|
cache: Record<string, unknown>
|
||||||
|
v2?: Record<string, unknown>
|
||||||
|
review?: { pending: number; total: number }
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<template>
|
||||||
|
<div class="agent-view">
|
||||||
|
<!-- 左:工作区文件 -->
|
||||||
|
<aside class="ws-panel">
|
||||||
|
<div class="ws-head">
|
||||||
|
<h3>📁 工作区</h3>
|
||||||
|
<button class="btn-refresh" :disabled="loadingWs" @click="refreshWs">
|
||||||
|
{{ loadingWs ? '…' : '🔄' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ws-root" :title="selectedRoot">{{ rootName(selectedRoot) }}</div>
|
||||||
|
<ul class="ws-list">
|
||||||
|
<li v-for="f in wsFiles" :key="f.name"
|
||||||
|
:class="{ dir: f.type === 'dir' }"
|
||||||
|
@click="f.type === 'file' && openFile(f.name)">
|
||||||
|
<span class="ws-name">{{ f.name }}</span>
|
||||||
|
<span v-if="f.type === 'file'" class="ws-size">{{ fmtSize(f.size) }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="!wsFiles.length && !loadingWs" class="ws-empty">(空)智能体读写的文件会出现在这里</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="filePreview" class="file-preview">
|
||||||
|
<div class="fp-head">
|
||||||
|
<span class="fp-name">{{ filePreview.path }}</span>
|
||||||
|
<button class="btn-refresh" @click="filePreview = null">✕</button>
|
||||||
|
</div>
|
||||||
|
<pre class="fp-body">{{ filePreview.content }}<template v-if="filePreview.truncated">…(已截断)</template></pre>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右:任务与过程 -->
|
||||||
|
<main class="agent-main">
|
||||||
|
<header class="agent-head">
|
||||||
|
<h2>🤖 智能体</h2>
|
||||||
|
<p class="sub">像编程助手一样操作你选择的工作目录:列目录 / 读 / 写 / 精确编辑 / 搜索 / 执行命令,全程实时可视化。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 工作区选择栏 -->
|
||||||
|
<div class="ws-bar">
|
||||||
|
<span class="ws-current" :title="selectedRoot">📁 {{ rootName(selectedRoot) || '未选择' }}</span>
|
||||||
|
<button class="btn-refresh" @click="openPicker">选择目录</button>
|
||||||
|
<label class="shell-toggle" title="开启后智能体可执行 shell 命令(run_command),在工作区内运行">
|
||||||
|
<input type="checkbox" v-model="allowShell" @change="toggleShell" />
|
||||||
|
允许执行命令
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-bar">
|
||||||
|
<select v-model="selectedPoolId" class="model-select">
|
||||||
|
<option value="">默认模型(模型池 Agent 角色 / Architect 设置)</option>
|
||||||
|
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}({{ e.model }})</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="selectedExecutorId" class="model-select executor-select"
|
||||||
|
title="两级模式:大模型拆解/审查,本地小模型执行工具轮">
|
||||||
|
<option value="">单模型模式(规划者全程包办)</option>
|
||||||
|
<option v-for="e in executorCandidates" :key="e.id" :value="e.id">
|
||||||
|
🔧 执行者:{{ e.name }}({{ e.model }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="task-bar">
|
||||||
|
<textarea v-model="task" class="task-input" rows="3"
|
||||||
|
placeholder="给智能体一个任务,例如:浏览这个项目,找到入口文件并在 README 里补充运行说明"
|
||||||
|
:disabled="running" />
|
||||||
|
<button class="btn-run" :disabled="running || !task.trim()" @click="run">
|
||||||
|
{{ running ? '运行中…' : '▶ 运行' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="statusLine" :class="['status-line', statusClass]">{{ statusLine }}</div>
|
||||||
|
|
||||||
|
<!-- 过程时间轴 -->
|
||||||
|
<div class="timeline" ref="timelineEl">
|
||||||
|
<template v-for="(ev, i) in events" :key="i">
|
||||||
|
<div v-if="ev.type === 'round'" class="ev-round">— 第 {{ ev.round }} 轮 —</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'phase'" class="ev-phase" :class="'ph-' + ev.phase">
|
||||||
|
{{ phaseLabel(ev) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'message'" class="ev-msg">
|
||||||
|
<div class="ev-title">
|
||||||
|
{{ ev.role === 'planner' ? '🧠 规划者' : '🔧 执行者' }}{{ ev.handoff ? `(第 ${ev.handoff} 轮交接)` : '' }}
|
||||||
|
</div>
|
||||||
|
<pre class="ev-msgbody" :class="ev.role === 'planner' ? 'msg-planner' : 'msg-executor'">{{ ev.content }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'tool_call' && ev.name === 'edit_file'" class="ev-card">
|
||||||
|
<div class="ev-title">✏️ 精确编辑 <b>{{ (ev.arguments as any)?.path }}</b></div>
|
||||||
|
<pre class="diff-old">- {{ (ev.arguments as any)?.old_string }}</pre>
|
||||||
|
<pre class="diff-new">+ {{ (ev.arguments as any)?.new_string }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'tool_call' && ev.name === 'run_command'" class="ev-card">
|
||||||
|
<div class="ev-title">⌨️ 执行命令</div>
|
||||||
|
<pre class="ev-args">$ {{ (ev.arguments as any)?.command }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'tool_call'" class="ev-card">
|
||||||
|
<div class="ev-title">🛠️ 调用工具 <b>{{ ev.name }}</b></div>
|
||||||
|
<pre class="ev-args">{{ pretty(ev.arguments) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'tool_result'" class="ev-card"
|
||||||
|
:class="ev.ok ? 'res-ok' : 'res-err'">
|
||||||
|
<div class="ev-title">{{ ev.ok ? '✅' : '❌' }} 结果({{ ev.name }})</div>
|
||||||
|
<pre class="ev-args">{{ short(ev.preview) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'usage'" class="ev-usage">
|
||||||
|
tokens:{{ ev.prompt_tokens }} 入 / {{ ev.completion_tokens }} 出
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ev.type === 'final'" class="ev-final">
|
||||||
|
<div class="ev-title">
|
||||||
|
{{ ev.reason === 'answer' ? '🏁 最终答复' : '⚠️ 结束(' + reasonLabel(ev.reason) + ')' }}
|
||||||
|
</div>
|
||||||
|
<pre v-if="finalResponse" class="ev-answer">{{ finalResponse }}</pre>
|
||||||
|
<div v-if="ev.error" class="ev-error">{{ ev.error }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="running" class="ev-round">⏳ 智能体正在工作…</div>
|
||||||
|
<div v-if="!events.length && !running" class="empty-hint">
|
||||||
|
选择工作目录,输入任务并运行——每一步工具调用都会实时显示在这里。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 目录选择器模态 -->
|
||||||
|
<div v-if="pickerOpen" class="modal-mask" @click.self="pickerOpen = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">选择工作目录</div>
|
||||||
|
<div class="crumb">{{ browse.path || '(此电脑 — 点击选择盘符)' }}</div>
|
||||||
|
<div class="dir-list">
|
||||||
|
<div v-if="browse.parent" class="dir-item up" @click="gotoDir(browse.parent)">
|
||||||
|
↰ 上级:{{ browse.parent }}
|
||||||
|
</div>
|
||||||
|
<div v-for="d in browse.dirs" :key="d" class="dir-item" @click="gotoDir(joinDir(browse.path, d))">
|
||||||
|
📂 {{ d }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!browse.dirs.length" class="dir-item empty">(无子目录——可直接点下方"打开此目录")</div>
|
||||||
|
</div>
|
||||||
|
<div class="path-row">
|
||||||
|
<input v-model="manualPath" placeholder="或直接输入绝对路径,如 E:\my-project" />
|
||||||
|
<label class="create-toggle">
|
||||||
|
<input type="checkbox" v-model="createIfMissing" /> 不存在则新建
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="recent.length" class="recent-row">
|
||||||
|
<span class="recent-label">最近:</span>
|
||||||
|
<button v-for="w in recent" :key="w" class="recent-chip" :title="w"
|
||||||
|
@click="chooseDir(w)">{{ rootName(w) }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="pickerMsg" class="picker-msg">{{ pickerMsg }}</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-primary" @click="chooseDir(manualPath.trim() || browse.path)">
|
||||||
|
打开此目录
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" @click="pickerOpen = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
startAgent, getAgentStatus, watchAgent,
|
||||||
|
listAgentWorkspace, readAgentFile, getPool,
|
||||||
|
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
|
||||||
|
} from '@/api'
|
||||||
|
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
|
||||||
|
|
||||||
|
const task = ref('')
|
||||||
|
const running = ref(false)
|
||||||
|
const events = ref<AgentEvent[]>([])
|
||||||
|
const statusInfo = ref<{ state: string; error?: string | null } | null>(null)
|
||||||
|
const selectedPoolId = ref('')
|
||||||
|
const poolEntries = ref<PoolEntry[]>([])
|
||||||
|
|
||||||
|
// 工作区选择
|
||||||
|
const selectedRoot = ref('')
|
||||||
|
const recent = ref<string[]>([])
|
||||||
|
const pickerOpen = ref(false)
|
||||||
|
const pickerMsg = ref('')
|
||||||
|
const browse = ref<FsBrowse>({ ok: true, path: '', parent: '', dirs: [] })
|
||||||
|
const manualPath = ref('')
|
||||||
|
const createIfMissing = ref(false)
|
||||||
|
const allowShell = ref(false)
|
||||||
|
|
||||||
|
// 两级模式
|
||||||
|
const selectedExecutorId = ref('')
|
||||||
|
|
||||||
|
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
|
||||||
|
const loadingWs = ref(false)
|
||||||
|
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
|
||||||
|
const timelineEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let _watch: ReturnType<typeof watchAgent> | null = null
|
||||||
|
|
||||||
|
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
|
||||||
|
|
||||||
|
const executorCandidates = computed(() =>
|
||||||
|
poolEntries.value
|
||||||
|
.filter(e => e.enabled && e.backend !== 'mock')
|
||||||
|
.sort((a, b) => (a.backend === 'llama_server' ? -1 : 0) - (b.backend === 'llama_server' ? -1 : 0)))
|
||||||
|
|
||||||
|
function phaseLabel(ev: AgentEvent) {
|
||||||
|
const m = ev.model ? ` · ${ev.model}` : ''
|
||||||
|
if (ev.phase === 'plan') return `🧠 规划者 · 拆解任务${m}`
|
||||||
|
if (ev.phase === 'execute') return `🔧 执行者 · 工具执行(第 ${ev.handoff} 轮交接)${m}`
|
||||||
|
return `🔍 规划者 · 审查裁决${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
|
||||||
|
|
||||||
|
const statusLine = computed(() => {
|
||||||
|
if (running.value) return '● 运行中'
|
||||||
|
const s = statusInfo.value
|
||||||
|
if (!s) return ''
|
||||||
|
if (s.state === 'done') return '✅ 已完成'
|
||||||
|
if (s.state === 'failed') return '❌ 失败' + (s.error ? ':' + s.error : '')
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const statusClass = computed(() => {
|
||||||
|
if (running.value) return 'st-running'
|
||||||
|
return statusInfo.value?.state === 'done' ? 'st-ok' : 'st-err'
|
||||||
|
})
|
||||||
|
|
||||||
|
function reasonLabel(r?: string) {
|
||||||
|
return ({ max_rounds: '轮次达上限', token_cap: 'token 熔断', error: '出错' } as Record<string, string>)[r || ''] || r
|
||||||
|
}
|
||||||
|
function pretty(o: unknown) { return JSON.stringify(o, null, 2) ?? '' }
|
||||||
|
function short(s?: string) { return (s || '').length > 600 ? s!.slice(0, 600) + '…' : (s || '') }
|
||||||
|
function fmtSize(n?: number) {
|
||||||
|
if (n == null) return ''
|
||||||
|
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB'
|
||||||
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB'
|
||||||
|
return n + ' B'
|
||||||
|
}
|
||||||
|
function rootName(p: string) {
|
||||||
|
if (!p) return ''
|
||||||
|
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/)
|
||||||
|
return parts[parts.length - 1] || p
|
||||||
|
}
|
||||||
|
function joinDir(p: string, d: string) {
|
||||||
|
if (!p) return d
|
||||||
|
const sep = p.includes('\\') ? '\\' : '/'
|
||||||
|
return p.endsWith('\\') || p.endsWith('/') ? p + d : p + sep + d
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => timelineEl.value?.scrollTo({ top: timelineEl.value.scrollHeight }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshWs() {
|
||||||
|
loadingWs.value = true
|
||||||
|
try {
|
||||||
|
const r = await listAgentWorkspace('', selectedRoot.value)
|
||||||
|
wsFiles.value = r.entries || []
|
||||||
|
} catch { wsFiles.value = [] } finally { loadingWs.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFile(name: string) {
|
||||||
|
try {
|
||||||
|
const r = await readAgentFile(name, selectedRoot.value)
|
||||||
|
if (r.ok) filePreview.value = { path: name, content: r.content, truncated: r.truncated }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 目录选择器 ──────────────────────────────────────────────────────────
|
||||||
|
async function openPicker() {
|
||||||
|
pickerOpen.value = true
|
||||||
|
pickerMsg.value = ''
|
||||||
|
manualPath.value = selectedRoot.value
|
||||||
|
try {
|
||||||
|
browse.value = await browseFs(selectedRoot.value && '')
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoDir(p: string) {
|
||||||
|
try {
|
||||||
|
const r = await browseFs(p)
|
||||||
|
if (r.ok) { browse.value = r; manualPath.value = r.path }
|
||||||
|
else pickerMsg.value = r.error || '路径不可用'
|
||||||
|
} catch { pickerMsg.value = '浏览失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDir(p: string) {
|
||||||
|
p = (p || '').trim()
|
||||||
|
if (!p) { pickerMsg.value = '请先选择或输入目录路径'; return }
|
||||||
|
try {
|
||||||
|
const r = await openWorkspace(p, createIfMissing.value)
|
||||||
|
selectedRoot.value = r.current
|
||||||
|
recent.value = r.recent
|
||||||
|
pickerOpen.value = false
|
||||||
|
refreshWs()
|
||||||
|
} catch (e: any) {
|
||||||
|
pickerMsg.value = e?.response?.data?.detail || e?.message || String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleShell() {
|
||||||
|
try {
|
||||||
|
await updateConfig({ agent: { allow_shell: allowShell.value } } as any)
|
||||||
|
} catch { allowShell.value = !allowShell.value } // 失败回滚
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 运行 ────────────────────────────────────────────────────────────────
|
||||||
|
async function run() {
|
||||||
|
if (!task.value.trim() || running.value) return
|
||||||
|
running.value = true
|
||||||
|
events.value = []
|
||||||
|
statusInfo.value = null
|
||||||
|
try {
|
||||||
|
const init = await startAgent(task.value.trim(), selectedPoolId.value, selectedRoot.value,
|
||||||
|
selectedExecutorId.value)
|
||||||
|
if (init.workspace) selectedRoot.value = init.workspace
|
||||||
|
_watch = watchAgent(init.request_id)
|
||||||
|
_watch.subscribe({
|
||||||
|
onEvent: (ev) => {
|
||||||
|
events.value.push(ev)
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
|
onDone: async () => {
|
||||||
|
running.value = false
|
||||||
|
try { statusInfo.value = await getAgentStatus(init.request_id) } catch { /* ignore */ }
|
||||||
|
refreshWs()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
running.value = false
|
||||||
|
statusInfo.value = { state: 'failed', error: e?.response?.data?.detail || e?.message || String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
refreshWs()
|
||||||
|
try {
|
||||||
|
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()])
|
||||||
|
poolEntries.value = pool.entries
|
||||||
|
selectedPoolId.value = pool.roles.agent || ''
|
||||||
|
selectedRoot.value = ws.current || ''
|
||||||
|
recent.value = ws.recent || []
|
||||||
|
refreshWs()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agent-view {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧工作区 */
|
||||||
|
.ws-panel {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ws-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.ws-head h3 { font-size: 14px; color: #1f2937; }
|
||||||
|
.ws-root {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
direction: rtl; /* 长路径优先显示末尾(项目名) */
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ws-list { list-style: none; overflow-y: auto; flex: 1; }
|
||||||
|
.ws-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.ws-list li:hover { background: #eff6ff; }
|
||||||
|
.ws-list li.dir { color: #92400e; font-weight: 500; cursor: default; }
|
||||||
|
.ws-empty { color: #9ca3af; cursor: default; font-size: 12px; }
|
||||||
|
.ws-empty:hover { background: transparent; }
|
||||||
|
.ws-size { color: #9ca3af; font-size: 11px; }
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
max-height: 45%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.fp-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.fp-body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧主区 */
|
||||||
|
.agent-main { flex: 1; display: flex; flex-direction: column; padding: 20px 24px; min-width: 0; }
|
||||||
|
.agent-head { margin-bottom: 10px; }
|
||||||
|
.agent-head h2 { font-size: 20px; font-weight: 700; color: #111827; }
|
||||||
|
.sub { font-size: 13px; color: #6b7280; margin-top: 2px; }
|
||||||
|
|
||||||
|
.ws-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.ws-current {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
max-width: 420px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.shell-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-bar { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
|
||||||
|
.model-select {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.task-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.task-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb26; }
|
||||||
|
.btn-run {
|
||||||
|
color: #fff;
|
||||||
|
background: #2563eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.btn-run:hover:not(:disabled) { background: #1d4ed8; }
|
||||||
|
.btn-run:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.status-line { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
|
||||||
|
.st-running { color: #2563eb; }
|
||||||
|
.st-ok { color: #16a34a; }
|
||||||
|
.st-err { color: #dc2626; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.ev-round {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.ev-phase {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
margin: 12px auto 8px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 90%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ph-plan { color: #1e40af; background: #dbeafe; }
|
||||||
|
.ph-execute { color: #166534; background: #dcfce7; }
|
||||||
|
.ph-review { color: #7c2d12; background: #ffedd5; }
|
||||||
|
.ev-msg { margin-bottom: 8px; }
|
||||||
|
.ev-msgbody {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.msg-planner { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; }
|
||||||
|
.msg-executor { background: #f0fdf4; color: #14532d; border: 1px solid #bbf7d0; }
|
||||||
|
.executor-select { flex: 1; max-width: 340px; }
|
||||||
|
.ev-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.ev-card.res-ok { border-left: 3px solid #16a34a; }
|
||||||
|
.ev-card.res-err { border-left: 3px solid #dc2626; }
|
||||||
|
.ev-title { font-size: 13px; color: #1f2937; margin-bottom: 6px; }
|
||||||
|
.ev-args {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #374151;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.diff-old, .diff-new {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.diff-old { background: #fef2f2; color: #b91c1c; }
|
||||||
|
.diff-new { background: #f0fdf4; color: #15803d; }
|
||||||
|
.ev-usage { text-align: right; color: #9ca3af; font-size: 11px; margin: 4px 0; }
|
||||||
|
.ev-final {
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.ev-answer {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: #111827;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ev-error { color: #dc2626; font-size: 13px; margin-top: 6px; }
|
||||||
|
.empty-hint { text-align: center; color: #9ca3af; font-size: 13px; margin-top: 40px; }
|
||||||
|
|
||||||
|
/* 目录选择器模态 */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #0006;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 620px;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 10px 40px #0003;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 16px; font-weight: 700; color: #111827; margin-bottom: 10px; }
|
||||||
|
.crumb {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dir-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.dir-item {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.dir-item:hover { background: #eff6ff; }
|
||||||
|
.dir-item.up { color: #2563eb; font-weight: 500; }
|
||||||
|
.dir-item.empty { color: #9ca3af; cursor: default; }
|
||||||
|
.dir-item.empty:hover { background: transparent; }
|
||||||
|
.path-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||||||
|
.path-row input[type="text"], .path-row input:not([type]) {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.create-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #6b7280; cursor: pointer; white-space: nowrap; }
|
||||||
|
.recent-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
|
||||||
|
.recent-label { font-size: 12px; color: #9ca3af; }
|
||||||
|
.recent-chip {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 180px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.recent-chip:hover { background: #eff6ff; border-color: #bfdbfe; }
|
||||||
|
.picker-msg { color: #d97706; font-size: 12px; margin-top: 8px; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-view">
|
||||||
|
<!-- 历史会话侧边栏 -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h3>会话记录</h3>
|
||||||
|
<ul class="session-list">
|
||||||
|
<li
|
||||||
|
v-for="s in chatStore.sessions"
|
||||||
|
:key="s.requestId"
|
||||||
|
:class="['session-item', { active: s.requestId === chatStore.currentId }]"
|
||||||
|
@click="chatStore.setCurrent(s.requestId)"
|
||||||
|
>
|
||||||
|
<span class="s-query">{{ s.query.slice(0, 28) }}{{ s.query.length > 28 ? '…' : '' }}</span>
|
||||||
|
<span class="s-status" :class="s.status?.status ?? 'pending'">
|
||||||
|
{{ s.status?.status ?? 'pending' }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 主聊天区 -->
|
||||||
|
<main class="main">
|
||||||
|
<div v-if="!chatStore.current()" class="empty">
|
||||||
|
<p>输入问题,开启端云协同协作之旅。</p>
|
||||||
|
<p class="hint">结果通过 SSE 实时推送,可切换「协作」页面查看交流文本可视化。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 用户问题 -->
|
||||||
|
<div class="user-msg">
|
||||||
|
<span class="role-label">你</span>
|
||||||
|
<p>{{ chatStore.current()!.query }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 状态指示器 -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span v-if="runStatus === 'pending'" class="badge pending">⏳ 排队中…</span>
|
||||||
|
<span v-else-if="runStatus === 'running'" class="badge running">
|
||||||
|
🔄 协作中 ({{ ws?.meta.round ?? 0 }}/{{ ws?.meta.rounds_cap ?? 6 }})
|
||||||
|
</span>
|
||||||
|
<span v-else-if="runStatus === 'done'" class="badge done">✅ 完成</span>
|
||||||
|
<span v-else-if="runStatus === 'failed'" class="badge failed">❌ 失败</span>
|
||||||
|
|
||||||
|
<span v-if="ws" class="route-path">
|
||||||
|
路由:{{ chatStore.current()!.status?.route?.join(' → ') ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 协作元信息 -->
|
||||||
|
<div v-if="ws" class="ws-meta">
|
||||||
|
<span>tokens: {{ ws.meta.api_input_tokens }} in / {{ ws.meta.api_output_tokens }} out</span>
|
||||||
|
<span>延迟: {{ chatStore.current()!.status?.latency_ms?.toFixed(0) }} ms</span>
|
||||||
|
<span>模型: {{ chatStore.current()!.status?.model_used ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 响应正文 -->
|
||||||
|
<div v-if="response" class="assistant-msg">
|
||||||
|
<span class="role-label">系统</span>
|
||||||
|
<pre>{{ response }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 交流文本预览(紧凑折叠) -->
|
||||||
|
<details v-if="ws" class="ws-preview">
|
||||||
|
<summary>📄 交流文本预览</summary>
|
||||||
|
<div v-if="ws.brief" class="brief-block">
|
||||||
|
<strong>Brief:</strong> {{ ws.brief.goal }}
|
||||||
|
<span class="tags">{{ ws.brief.tags.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
<ul v-if="ws.plan?.length" class="plan-list">
|
||||||
|
<li v-for="p in ws.plan" :key="p.id" :class="p.status">
|
||||||
|
<span class="step-id">{{ p.id }}</span>
|
||||||
|
<span>{{ p.task }}</span>
|
||||||
|
<span class="deps" v-if="p.deps.length">←{{ p.deps.join(',') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul v-if="ws.progress?.length" class="progress-list">
|
||||||
|
<li v-for="pg in ws.progress" :key="pg.step" :class="pg.status">
|
||||||
|
{{ pg.step }}: {{ pg.status }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- 错误 -->
|
||||||
|
<div v-if="chatStore.current()!.error" class="error-msg">
|
||||||
|
{{ chatStore.current()!.error }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<form class="input-bar" @submit.prevent="handleSend">
|
||||||
|
<input
|
||||||
|
v-model="input"
|
||||||
|
placeholder="输入你的问题…"
|
||||||
|
:disabled="sending"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit" :disabled="sending || !input.trim()">
|
||||||
|
{{ sending ? '发送中…' : '发送' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { watchRun } from '@/api'
|
||||||
|
import type { Workspace } from '@/types'
|
||||||
|
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const input = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
const current = computed(() => chatStore.current())
|
||||||
|
const runStatus = computed(() => current.value?.status?.status ?? 'pending')
|
||||||
|
const ws = computed(() => current.value?.workspace)
|
||||||
|
const response = computed(() => current.value?.status?.response ?? null)
|
||||||
|
|
||||||
|
// SSE 订阅
|
||||||
|
let sseHandle: ReturnType<typeof watchRun> | null = null
|
||||||
|
|
||||||
|
function subscribeSSE(requestId: string) {
|
||||||
|
sseHandle?.close()
|
||||||
|
sseHandle = watchRun(requestId)
|
||||||
|
sseHandle.subscribe({
|
||||||
|
onWorkspace(workspace: Workspace) {
|
||||||
|
chatStore.updateWorkspace(requestId, workspace)
|
||||||
|
},
|
||||||
|
onStatus(_state: string) {
|
||||||
|
chatStore.pollStatus(requestId)
|
||||||
|
},
|
||||||
|
onError(detail: string) {
|
||||||
|
const s = chatStore.sessions.find((x) => x.requestId === requestId)
|
||||||
|
if (s) s.error = detail
|
||||||
|
},
|
||||||
|
onDone() {
|
||||||
|
chatStore.pollStatus(requestId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => chatStore.currentId,
|
||||||
|
(id) => {
|
||||||
|
if (id) {
|
||||||
|
subscribeSSE(id)
|
||||||
|
// 若已完成立即拉一次状态
|
||||||
|
if (current.value?.status?.status === 'done') {
|
||||||
|
chatStore.pollStatus(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!input.value.trim() || sending.value) return
|
||||||
|
const query = input.value.trim()
|
||||||
|
input.value = ''
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const session = await chatStore.sendQuery(query)
|
||||||
|
subscribeSSE(session.requestId)
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-view {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sidebar h3 {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.session-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.session-item:hover { background: #e5e7eb; }
|
||||||
|
.session-item.active { background: #dbeafe; }
|
||||||
|
.s-query { color: #111; }
|
||||||
|
.s-status { font-size: 11px; color: #9ca3af; }
|
||||||
|
.s-status.done { color: #16a34a; }
|
||||||
|
.s-status.failed { color: #dc2626; }
|
||||||
|
.s-status.running { color: #2563eb; }
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 20px 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.hint { font-size: 13px; }
|
||||||
|
|
||||||
|
.user-msg, .assistant-msg {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.assistant-msg { background: #eff6ff; }
|
||||||
|
.role-label {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.user-msg pre, .assistant-msg pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge.pending { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.badge.running { background: #dbeafe; color: #2563eb; }
|
||||||
|
.badge.done { background: #dcfce7; color: #16a34a; }
|
||||||
|
.badge.failed { background: #fee2e2; color: #dc2626; }
|
||||||
|
.route-path { font-size: 12px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.ws-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-preview {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.brief-block { margin-bottom: 8px; }
|
||||||
|
.tags { margin-left: 8px; color: #6b7280; font-size: 12px; }
|
||||||
|
.plan-list, .progress-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.plan-list li, .progress-list li {
|
||||||
|
padding: 2px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.step-id { font-family: monospace; color: #6b7280; min-width: 48px; }
|
||||||
|
.deps { color: #9ca3af; font-size: 12px; }
|
||||||
|
.done { color: #16a34a; }
|
||||||
|
.pending { color: #9ca3af; }
|
||||||
|
.running { color: #2563eb; }
|
||||||
|
|
||||||
|
.error-msg { color: #dc2626; font-size: 13px; background: #fee2e2; padding: 8px 12px; border-radius: 6px; }
|
||||||
|
|
||||||
|
.input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.input-bar input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input-bar input:focus { border-color: #2563eb; }
|
||||||
|
.input-bar button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.input-bar button:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div class="collab-view">
|
||||||
|
<!-- 左侧:会话选择 + 协作流程图 -->
|
||||||
|
<aside class="collab-sidebar">
|
||||||
|
<h3>协作会话</h3>
|
||||||
|
<ul class="session-list">
|
||||||
|
<li
|
||||||
|
v-for="s in chatStore.sessions"
|
||||||
|
:key="s.requestId"
|
||||||
|
:class="['session-item', { active: s.requestId === activeId }]"
|
||||||
|
@click="selectSession(s.requestId)"
|
||||||
|
>
|
||||||
|
<span>{{ s.query.slice(0, 24) }}{{ s.query.length > 24 ? '…' : '' }}</span>
|
||||||
|
<span class="meta">
|
||||||
|
{{ s.status?.rounds_used ?? '?' }} 轮
|
||||||
|
· {{ s.status?.model_used ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- 当前会话统计 -->
|
||||||
|
<div v-if="currentSession" class="stats">
|
||||||
|
<h4>运行统计</h4>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<span>输入 Token</span><b>{{ currentSession.status?.api_input_tokens ?? 0 }}</b>
|
||||||
|
<span>输出 Token</span><b>{{ currentSession.status?.api_output_tokens ?? 0 }}</b>
|
||||||
|
<span>延迟</span><b>{{ currentSession.status?.latency_ms?.toFixed(0) ?? '—' }} ms</b>
|
||||||
|
<span>快路径</span><b>{{ currentSession.status?.fast_path ? '是' : '否' }}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>路由路径</h4>
|
||||||
|
<div class="route-flow">
|
||||||
|
<span
|
||||||
|
v-for="(r, i) in currentSession.status?.route ?? []"
|
||||||
|
:key="i"
|
||||||
|
class="route-node"
|
||||||
|
>{{ r }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧:交流文本实时可视化 -->
|
||||||
|
<main class="collab-main">
|
||||||
|
<div v-if="!ws" class="empty">
|
||||||
|
<p>从左侧选择一个会话,或在「对话」页发起新提问。</p>
|
||||||
|
<p>协作过程通过 SSE 实时推送,无需刷新。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Brief 阶段 -->
|
||||||
|
<section class="section brief-section">
|
||||||
|
<h2 class="section-title">📋 Brief(任务简报)</h2>
|
||||||
|
<div class="brief-card">
|
||||||
|
<div class="goal">{{ ws.brief?.goal ?? '(未生成)' }}</div>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="t in ws.brief?.tags ?? []" :key="t" class="tag">{{ t }}</span>
|
||||||
|
<span v-if="ws.brief?.domain" class="domain">{{ ws.brief.domain }}</span>
|
||||||
|
</div>
|
||||||
|
<ul v-if="ws.brief?.constraints?.length" class="constraints">
|
||||||
|
<li v-for="(c, i) in ws.brief.constraints" :key="i">{{ c }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Plan 阶段 -->
|
||||||
|
<section class="section plan-section">
|
||||||
|
<h2 class="section-title">📌 Plan(执行计划)</h2>
|
||||||
|
<div class="plan-timeline">
|
||||||
|
<div
|
||||||
|
v-for="step in ws.plan ?? []"
|
||||||
|
:key="step.id"
|
||||||
|
:class="['plan-step', getStepStatus(step.id)]"
|
||||||
|
>
|
||||||
|
<div class="step-dot" />
|
||||||
|
<div class="step-content">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-id">{{ step.id }}</span>
|
||||||
|
<span class="step-status">{{ getStepStatus(step.id) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="step-task">{{ step.task }}</p>
|
||||||
|
<div v-if="step.deps.length" class="step-deps">
|
||||||
|
依赖:<span v-for="d in step.deps" :key="d" class="dep">{{ d }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 步骤产出工件 -->
|
||||||
|
<div v-if="getStepArtifact(step.id)" class="step-artifact">
|
||||||
|
<details>
|
||||||
|
<summary>📄 工件内容</summary>
|
||||||
|
<pre>{{ getStepArtifact(step.id) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Progress 实时滚动 -->
|
||||||
|
<section class="section progress-section">
|
||||||
|
<h2 class="section-title">🔄 进度(实时)</h2>
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-label">
|
||||||
|
第 {{ ws.meta.round }} / {{ ws.meta.rounds_cap }} 轮
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: `${(ws.meta.round / ws.meta.rounds_cap) * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-steps">
|
||||||
|
<div
|
||||||
|
v-for="pg in ws.progress ?? []"
|
||||||
|
:key="pg.step"
|
||||||
|
:class="['pg-step', pg.status]"
|
||||||
|
>
|
||||||
|
<span class="pg-icon">
|
||||||
|
{{ pg.status === 'done' ? '✅' : pg.status === 'running' ? '⏳' : '⭕' }}
|
||||||
|
</span>
|
||||||
|
<span>{{ pg.step }}</span>
|
||||||
|
<span v-if="pg.note" class="pg-note">{{ pg.note }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Issues + Decisions -->
|
||||||
|
<section v-if="ws.issues?.length" class="section issues-section">
|
||||||
|
<h2 class="section-title">⚠️ Issues & 裁决</h2>
|
||||||
|
<div v-for="issue in ws.issues" :key="issue.id" class="issue-card">
|
||||||
|
<div class="issue-header">
|
||||||
|
<span class="issue-id">{{ issue.id }}</span>
|
||||||
|
<span class="issue-step">Step: {{ issue.step }}</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ issue.description }}</p>
|
||||||
|
<div v-if="getDecision(issue.id)" class="decision">
|
||||||
|
<strong>Architect 裁决:</strong>{{ getDecision(issue.id)?.reply }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Archive -->
|
||||||
|
<section v-if="ws.archive?.length" class="section archive-section">
|
||||||
|
<h2 class="section-title">📦 Archive(摘要归档)</h2>
|
||||||
|
<ul class="archive-list">
|
||||||
|
<li v-for="(item, i) in ws.archive" :key="i">{{ item }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 最终交付 -->
|
||||||
|
<section v-if="ws.meta.state === 'done'" class="section deliver-section">
|
||||||
|
<h2 class="section-title">🎉 交付</h2>
|
||||||
|
<div class="response-block">
|
||||||
|
<pre>{{ currentSession?.status?.response ?? '(无内容)' }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/chat'
|
||||||
|
import { watchRun } from '@/api'
|
||||||
|
import type { Workspace } from '@/types'
|
||||||
|
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const activeId = ref<string | null>(chatStore.currentId)
|
||||||
|
const liveWs = ref<Workspace | null>(null)
|
||||||
|
|
||||||
|
const currentSession = computed(() =>
|
||||||
|
chatStore.sessions.find((s) => s.requestId === activeId.value) ?? null,
|
||||||
|
)
|
||||||
|
const ws = computed(() => liveWs.value ?? currentSession.value?.workspace ?? null)
|
||||||
|
|
||||||
|
function selectSession(id: string) {
|
||||||
|
activeId.value = id
|
||||||
|
liveWs.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE 实时推送
|
||||||
|
let sse: ReturnType<typeof watchRun> | null = null
|
||||||
|
|
||||||
|
function startSSE(requestId: string) {
|
||||||
|
sse?.close()
|
||||||
|
sse = watchRun(requestId)
|
||||||
|
sse.subscribe({
|
||||||
|
onWorkspace(workspace: Workspace) {
|
||||||
|
if (workspace.request_id === activeId.value) {
|
||||||
|
liveWs.value = workspace
|
||||||
|
chatStore.updateWorkspace(requestId, workspace)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStatus() {
|
||||||
|
chatStore.pollStatus(requestId)
|
||||||
|
},
|
||||||
|
onDone() {
|
||||||
|
chatStore.pollStatus(requestId)
|
||||||
|
},
|
||||||
|
onError() {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => chatStore.sessions,
|
||||||
|
(sessions) => {
|
||||||
|
if (activeId.value) {
|
||||||
|
const found = sessions.find((s) => s.requestId === activeId.value)
|
||||||
|
if (!found) activeId.value = sessions[0]?.requestId ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
activeId,
|
||||||
|
(id) => {
|
||||||
|
if (!id) return
|
||||||
|
const s = chatStore.sessions.find((x) => x.requestId === id)
|
||||||
|
if (s?.workspace) liveWs.value = s.workspace
|
||||||
|
startSSE(id)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function getStepStatus(stepId: string) {
|
||||||
|
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.status ?? 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStepArtifact(stepId: string) {
|
||||||
|
return ws.value?.progress?.find((p: import('@/types').ProgressStep) => p.step === stepId)?.artifact ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecision(issueId: string) {
|
||||||
|
return ws.value?.decisions?.find((d: import('@/types').Decision) => d.ref === issueId) ?? null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.collab-view {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.collab-sidebar h3 {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 35%;
|
||||||
|
}
|
||||||
|
.session-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.session-item:hover { background: #e5e7eb; }
|
||||||
|
.session-item.active { background: #dbeafe; }
|
||||||
|
.meta { font-size: 11px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.stats h4 { margin: 0 0 6px; color: #374151; font-size: 12px; }
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.stat-grid span { color: #6b7280; }
|
||||||
|
.stat-grid b { color: #111; text-align: right; }
|
||||||
|
|
||||||
|
.route-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.route-node {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section { margin-bottom: 28px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #111;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 2px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brief-card {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.goal { font-size: 14px; margin-bottom: 8px; color: #1e40af; font-weight: 600; }
|
||||||
|
.tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.tag { background: #dbeafe; color: #1d4ed8; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
|
||||||
|
.domain { background: #fce7f3; color: #9d174d; padding: 2px 8px; border-radius: 99px; font-size: 12px; }
|
||||||
|
.constraints { list-style: disc inside; font-size: 13px; color: #374151; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Plan timeline */
|
||||||
|
.plan-timeline { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.plan-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.plan-step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 16px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.plan-step:last-child::before { display: none; }
|
||||||
|
.step-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.plan-step.done .step-dot { background: #16a34a; border-color: #16a34a; }
|
||||||
|
.plan-step.running .step-dot { background: #2563eb; border-color: #2563eb; }
|
||||||
|
.plan-step.pending .step-dot { background: #fff; }
|
||||||
|
.step-content { flex: 1; }
|
||||||
|
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.step-id { font-family: monospace; font-size: 12px; color: #6b7280; font-weight: 700; }
|
||||||
|
.step-status { font-size: 11px; padding: 1px 6px; border-radius: 99px; }
|
||||||
|
.plan-step.done .step-status { background: #dcfce7; color: #16a34a; }
|
||||||
|
.plan-step.running .step-status { background: #dbeafe; color: #2563eb; }
|
||||||
|
.plan-step.pending .step-status { background: #f3f4f6; color: #9ca3af; }
|
||||||
|
.step-task { margin: 0; font-size: 13px; color: #374151; }
|
||||||
|
.step-deps { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||||
|
.dep { background: #f3f4f6; padding: 0 4px; border-radius: 3px; font-family: monospace; margin-right: 4px; }
|
||||||
|
.step-artifact { margin-top: 6px; }
|
||||||
|
.step-artifact details { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 4px; }
|
||||||
|
.step-artifact summary { padding: 4px 8px; cursor: pointer; font-size: 12px; color: #6b7280; }
|
||||||
|
.step-artifact pre { padding: 6px 10px; font-size: 12px; margin: 0; white-space: pre-wrap; max-height: 120px; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress-bar-wrap { margin-bottom: 10px; }
|
||||||
|
.progress-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; }
|
||||||
|
.progress-bar { height: 6px; background: #e5e7eb; border-radius: 99px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #2563eb; border-radius: 99px; transition: width 0.4s ease; }
|
||||||
|
.progress-steps { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.pg-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.pg-step.done { background: #dcfce7; border-color: #bbf7d0; }
|
||||||
|
.pg-step.running { background: #dbeafe; border-color: #bfdbfe; }
|
||||||
|
.pg-note { color: #9ca3af; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Issues */
|
||||||
|
.issue-card {
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
background: #fff5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.issue-header { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.issue-id { font-family: monospace; font-size: 12px; color: #dc2626; font-weight: 700; }
|
||||||
|
.issue-step { font-size: 11px; color: #6b7280; }
|
||||||
|
.decision {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archive */
|
||||||
|
.archive-list { list-style: disc inside; font-size: 13px; color: #374151; }
|
||||||
|
|
||||||
|
/* Deliver */
|
||||||
|
.response-block {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.response-block pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div class="metrics-view">
|
||||||
|
<header class="metrics-header">
|
||||||
|
<h2>系统指标</h2>
|
||||||
|
<button class="refresh" @click="load">🔄 刷新</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">加载中…</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<!-- 卡片网格 -->
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>路由器(v1)</h3>
|
||||||
|
<div class="kv-list">
|
||||||
|
<template v-for="(v, k) in data.router" :key="k">
|
||||||
|
<span>{{ k }}</span><b>{{ v }}</b>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>缓存</h3>
|
||||||
|
<div class="kv-list">
|
||||||
|
<template v-for="(v, k) in data.cache" :key="k">
|
||||||
|
<span>{{ k }}</span><b>{{ v }}</b>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.v2" class="metric-card highlight">
|
||||||
|
<h3>协作管线(v2)</h3>
|
||||||
|
<div class="kv-list">
|
||||||
|
<template v-for="(v, k) in data.v2" :key="k">
|
||||||
|
<span v-if="k !== 'by_model'">{{ k }}</span>
|
||||||
|
<b v-if="k !== 'by_model'">{{ v }}</b>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="byModel && Object.keys(byModel).length" class="metric-card">
|
||||||
|
<h3>按模型分账(token / 成本)</h3>
|
||||||
|
<table class="by-model">
|
||||||
|
<thead>
|
||||||
|
<tr><th>模型</th><th>次数</th><th>入</th><th>出</th><th>成本 $</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(b, m) in byModel" :key="m">
|
||||||
|
<td class="mono">{{ m }}</td>
|
||||||
|
<td>{{ b.requests }}</td>
|
||||||
|
<td>{{ b.input_tokens }}</td>
|
||||||
|
<td>{{ b.output_tokens }}</td>
|
||||||
|
<td>{{ b.cost_est_usd }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="hint">单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.review" class="metric-card review-card">
|
||||||
|
<h3>人工检验</h3>
|
||||||
|
<div class="review-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-num">{{ data.review.pending }}</span>
|
||||||
|
<span class="stat-label">待审核</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-num">{{ data.review.total }}</span>
|
||||||
|
<span class="stat-label">总提交</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.review.total > 0" class="progress-wrap">
|
||||||
|
<div
|
||||||
|
class="reviewed-bar"
|
||||||
|
:style="{
|
||||||
|
width: `${((data.review.total - data.review.pending) / data.review.total) * 100}%`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="review-rate">
|
||||||
|
通过率:
|
||||||
|
{{ (((data.review.total - data.review.pending) / data.review.total) * 100).toFixed(1) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 原始 JSON -->
|
||||||
|
<details class="raw-json">
|
||||||
|
<summary>原始 JSON</summary>
|
||||||
|
<pre>{{ JSON.stringify(data, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getMetrics } from '@/api'
|
||||||
|
import type { Metrics } from '@/types'
|
||||||
|
|
||||||
|
const data = ref<Metrics | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
interface ModelBucket {
|
||||||
|
requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cost_est_usd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const byModel = computed(() => {
|
||||||
|
const v2 = data.value?.v2 as Record<string, unknown> | undefined
|
||||||
|
return (v2?.by_model as Record<string, ModelBucket>) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
data.value = await getMetrics()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// 网络超时或服务端错误:显示友好错误而不是无限 loading
|
||||||
|
error.value = e instanceof Error ? e.message : '指标加载失败,请检查后端服务'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metrics-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
|
||||||
|
|
||||||
|
.by-model { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.by-model th, .by-model td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
.by-model th { color: #6b7280; font-weight: 600; }
|
||||||
|
.by-model td.mono { font-family: ui-monospace, Consolas, monospace; }
|
||||||
|
.hint { color: #9ca3af; font-size: 11px; margin-top: 8px; }
|
||||||
|
|
||||||
|
.metrics-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.metrics-header h2 { margin: 0; font-size: 20px; }
|
||||||
|
.refresh { padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; }
|
||||||
|
|
||||||
|
.loading, .error { text-align: center; padding: 40px; color: #9ca3af; }
|
||||||
|
.error { color: #dc2626; }
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.metric-card.highlight { border-color: #2563eb; background: #eff6ff; }
|
||||||
|
.metric-card h3 { margin: 0 0 12px; font-size: 14px; color: #374151; }
|
||||||
|
|
||||||
|
.kv-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.kv-list span { color: #6b7280; }
|
||||||
|
.kv-list b { color: #111; text-align: right; }
|
||||||
|
|
||||||
|
.review-card { grid-column: span 2; }
|
||||||
|
.review-stats { display: flex; gap: 24px; margin-bottom: 12px; }
|
||||||
|
.stat-item { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.stat-num { font-size: 28px; font-weight: 700; color: #2563eb; }
|
||||||
|
.stat-label { font-size: 12px; color: #6b7280; }
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
height: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.reviewed-bar { height: 100%; background: #16a34a; transition: width 0.5s ease; }
|
||||||
|
.review-rate { font-size: 13px; color: #6b7280; margin: 0; }
|
||||||
|
|
||||||
|
.raw-json {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.raw-json summary { padding: 10px 14px; cursor: pointer; font-size: 13px; color: #6b7280; }
|
||||||
|
.raw-json pre {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div class="review-view">
|
||||||
|
<header class="review-header">
|
||||||
|
<h2>人工检验队列</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button>
|
||||||
|
<button :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待审核</button>
|
||||||
|
<button :class="{ active: filter === 'approved' }" @click="filter = 'approved'">已通过</button>
|
||||||
|
<button :class="{ active: filter === 'rejected' }" @click="filter = 'rejected'">已拒绝</button>
|
||||||
|
<button class="refresh-btn" @click="load">🔄 刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">加载中…</div>
|
||||||
|
<div v-else-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-else class="queue-list">
|
||||||
|
<div v-if="!filtered.length" class="empty">队列为空。</div>
|
||||||
|
|
||||||
|
<div v-for="item in filtered" :key="item.id" class="review-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-id">#{{ item.id }}</span>
|
||||||
|
<span class="verdict-badge" :class="item.verdict">{{ item.verdict }}</span>
|
||||||
|
<span class="tags">
|
||||||
|
<span v-for="t in item.tags" :key="t" class="tag">{{ t }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="date">{{ item.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="query-block">
|
||||||
|
<strong>Query:</strong>{{ item.query }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-block">
|
||||||
|
<strong>Response:</strong>
|
||||||
|
<pre>{{ item.response }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="item.verdict === 'pending'" class="actions">
|
||||||
|
<textarea
|
||||||
|
v-model="correctionInputs[item.id]"
|
||||||
|
placeholder="修正意见(可选)"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="approve" @click="submit(item.id, 'approved')">✅ 通过</button>
|
||||||
|
<button class="reject" @click="submit(item.id, 'rejected')">❌ 拒绝</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.correction" class="correction">
|
||||||
|
<strong>修正:</strong>{{ item.correction }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { listReviews, submitReview } from '@/api'
|
||||||
|
import type { ReviewItem } from '@/types'
|
||||||
|
|
||||||
|
const items = ref<ReviewItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const filter = ref<'all' | 'pending' | 'approved' | 'rejected'>('pending')
|
||||||
|
const correctionInputs = ref<Record<number, string>>({})
|
||||||
|
|
||||||
|
const filtered = computed(() =>
|
||||||
|
filter.value === 'all'
|
||||||
|
? items.value
|
||||||
|
: items.value.filter((i) => i.verdict === filter.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
items.value = await listReviews()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(id: number, verdict: 'approved' | 'rejected') {
|
||||||
|
try {
|
||||||
|
await submitReview(id, verdict, correctionInputs.value[id] || undefined)
|
||||||
|
await load()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
|
||||||
|
|
||||||
|
.review-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.review-header h2 { margin: 0; font-size: 20px; }
|
||||||
|
|
||||||
|
.controls { display: flex; gap: 8px; }
|
||||||
|
button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
.refresh-btn { margin-left: auto; }
|
||||||
|
|
||||||
|
.loading, .error, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.error { color: #dc2626; }
|
||||||
|
|
||||||
|
.queue-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-id { font-family: monospace; font-size: 12px; color: #6b7280; }
|
||||||
|
.verdict-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.verdict-badge.pending { background: #fef3c7; color: #92400e; }
|
||||||
|
.verdict-badge.approved { background: #dcfce7; color: #16a34a; }
|
||||||
|
.verdict-badge.rejected { background: #fee2e2; color: #dc2626; }
|
||||||
|
.tags { display: flex; gap: 4px; }
|
||||||
|
.tag { background: #e0e7ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 11px; }
|
||||||
|
.date { margin-left: auto; font-size: 11px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.query-block, .response-block {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.query-block pre, .response-block pre {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-row { display: flex; gap: 8px; }
|
||||||
|
.approve { background: #dcfce7; border-color: #86efac; color: #16a34a; }
|
||||||
|
.reject { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
|
||||||
|
|
||||||
|
.correction {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/static/',
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// 开发时:前端同源请求(无 /api 前缀)代理到 FastAPI
|
||||||
|
'/chat': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/runs': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/review': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/api/metrics':{ target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/config': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/health': { target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
'/traces':{ target: 'http://localhost:8000', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../gateway/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 任务拆解与执行计划
|
||||||
|
|
||||||
|
> 任务体系:主任务(整体项目)→ 附加任务(先行实现:整体项目部分拆解)
|
||||||
|
> 建立日期:2026-08-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、任务体系总览
|
||||||
|
|
||||||
|
```
|
||||||
|
主任务:多专业小模型 + 路由模型系统(整体项目)
|
||||||
|
│
|
||||||
|
├─ 已完成部分(专家系统内核):
|
||||||
|
│ · 知识库(8 领域 67 规则 + 17 模板 + 45 事实)
|
||||||
|
│ · 黑板/前向链/Planner/DAG 路由(L0 零参数)
|
||||||
|
│ · 三级子领域(domain → subdomain → subdomain2)
|
||||||
|
│ · 两级路由体系(domain_group → 组内路由模型)
|
||||||
|
│ · 92 项单元测试全绿
|
||||||
|
│
|
||||||
|
└─ 附加任务(★ 先行实现):整体项目部分拆解
|
||||||
|
· 目标:把整体项目剩余工作拆解为可独立执行的部分任务,
|
||||||
|
并优先实现第一批(P0),为后续铺路
|
||||||
|
· 状态:进行中
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、整体项目剩余工作拆解清单
|
||||||
|
|
||||||
|
| # | 任务 | 内容 | 依赖 | 优先级 | 预估 | 状态 |
|
||||||
|
|---|------|------|------|--------|------|------|
|
||||||
|
| T1 | NodeExecutor 接口抽象 | 解耦 `_execute_node` 的 if-else;rule/model/未来后端统一接口 + 工厂 | — | **P0** | 0.5 天 | ✅ 完成 |
|
||||||
|
| T2 | 评测基准扩充 | eval 扩到 8 领域 24 样例 + 组路由/子领域识别指标 | — | **P0** | 0.5 天 | ✅ 完成 |
|
||||||
|
| T3 | 推理链查询接口 | 请求 ID 化 + 内存轨迹存储 + `GET /traces/{id}` | T1 | **P0** | 1 天 | ✅ 完成 |
|
||||||
|
| T12 | **Agent-Skill 路由器** | 路由器独立:Skill 注册表(21 技能)+ RouteAgent 自主分析需求→技能调用计划→执行,无需用户指定领域/模型 | T1,T2,T3 | **P0** | 1-2 天 | ✅ 完成 |
|
||||||
|
| T4 | 知识库 CRUD + 热重载 | `/knowledge/rules|facts` CRUD + `POST /config/reload` | T1 | P1 | 1-2 天 | ⬜ 待办 |
|
||||||
|
| T5 | 本地模型推理接入(L2) | Ollama/vLLM 封装 + `fallback.local` 验证 + 组内模型按需加载 | T1 | P1 | 2-3 天 | ⬜ 待办 |
|
||||||
|
| T6 | embedding 语义缓存 | BGE 本地向量化替代 n-gram L2 | — | P1 | 1-2 天 | ⬜ 待办 |
|
||||||
|
| T7 | 分类器训练流水线 | 数据构建 + 0.6B QLoRA 训练(8 领域) | — | P1 | 2-3 天 | ⬜ 待办 |
|
||||||
|
| T8 | 领域专家微调 | QLoRA 微调 5-8 领域专家 + 数据收集 | T7 | P2 | 3-4 周 | ⬜ 待办 |
|
||||||
|
| T9 | RouterArena 评测 | 标准 5 维评测接入 | T2 | P2 | 2-3 天 | ⬜ 待办 |
|
||||||
|
| T10 | 监控 + 模型注册表 | Prometheus 指标 + 模型版本/热替换 | T4,T5 | P2 | 2-3 天 | ⬜ 待办 |
|
||||||
|
| T11 | 部署生产化 | Docker/Compose + 灰度 + 安全 | T5,T10 | P2 | 3-5 天 | ⬜ 待办 |
|
||||||
|
|
||||||
|
## 三、先行实现批次(P0)—— 已完成 ✅
|
||||||
|
|
||||||
|
1. **T1 NodeExecutor 接口抽象** —— 子任务执行后端统一接口(rule/model 两实现 + 工厂),L2 模型接入无需改 Router
|
||||||
|
2. **T2 评测基准扩充** —— 24 样例 × 8 领域,指标:分类 100% / 大领域组识别 100% / 子领域识别 100%
|
||||||
|
3. **T3 推理链查询接口** —— `GET /traces/{request_id}`:完整推理链可追溯(两级路由 → 三级子领域 → 拆解 → 规则 → 评分)
|
||||||
|
4. **T12 Agent-Skill 路由器** —— 路由器独立为"技能注册表 + Agent 规划器":
|
||||||
|
- 21 个内置技能(es.* 模板 ×17、kb.retrieve/kb.answer、judge.evaluate、fallback.call)
|
||||||
|
- RouteAgent 自行分析需求 → 规划技能调用(多技能组合/依赖)→ 执行 → 校验 → 升级
|
||||||
|
- **用户只提供 query,无需指定领域/模型**;技能调用轨迹可追溯(skill:es.analyze@facts)
|
||||||
|
- 实测:法律咨询自动组合 es.analyze + kb.retrieve + es.conclude + es.disclaimer
|
||||||
|
|
||||||
|
P0 完成后的能力:干净的后端抽象 + 可量化的评测 + 可追溯的推理链 + 技能化 Agent 路由(118 项测试全绿)。
|
||||||
|
|
||||||
|
## 四、执行规则
|
||||||
|
|
||||||
|
- 每任务独立验收(测试 + 文档),完成后更新状态 ✅
|
||||||
|
- 依赖任务未完成时,先做无依赖任务
|
||||||
|
- P1/P2 任务在 P0 完成后按序推进,不阻塞主线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、v2 任务登记(端云协同 LLM 协作系统,见《实现方案_v2_端云协同LLM协作系统.md》)
|
||||||
|
|
||||||
|
> 每个任务一个 commit(`feat(v2): Tn 描述`),交付含封闭单测;v1 的 126 项测试保持全绿。
|
||||||
|
|
||||||
|
| T | 内容 | 状态 | commit |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| T1 | 环境与基线确认(126 测试全绿;README 环境备忘) | ✅ 完成 | (并入 T2 commit) |
|
||||||
|
| T2 | 运维层:hw_profile + llama_server 进程管理 | ✅ 完成 | T2 |
|
||||||
|
| T3 | ArchitectClient(DeepSeek API,JSON 约束) | ✅ 完成 | T3 |
|
||||||
|
| T4 | Workspace(交流文本 schema/校验/渲染/rollup) | ✅ 完成 | T4 |
|
||||||
|
| T5 | WorkerLoop + 接地验证 | ✅ 完成 | T5 |
|
||||||
|
| T6 | CollaborativePipeline 编排 | ✅ 完成 | T6 |
|
||||||
|
| T7 | 网关扩展(/chat 切 v2,/chat/legacy) | ✅ 完成 | T7 |
|
||||||
|
| T8 | 人工检验队列 ReviewQueue | ✅ 完成 | T8 |
|
||||||
|
| T9 | token 计量与账单 | ✅ 完成 | T9 |
|
||||||
|
| T10 | rollup + prefix cache 调优 | ✅ 完成 | T10 |
|
||||||
|
| T11 | 打包分发 setup_runtime.py | ✅ 完成 | T11 |
|
||||||
|
| T12 | 实验脚本 bench_tokens.py + 数据集 | ✅ 完成 | T12 |
|
||||||
|
| T13 | E1 本地跑数完成(E2–E5 待 live 接入) | ✅ 完成 | T13 |
|
||||||
|
| T14 | 文档收口(README v2 改写) | ✅ 完成 | T14 |
|
||||||
|
| T15 | Pipeline 死锁修复(_deps_done 依赖过滤 + pending-empty break)+ /metrics SPA 路由冲突修复 | ✅ 完成 | T15 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、v3 任务登记(Web 应用化,见《实现方案_v3_Web应用化.md》)
|
||||||
|
|
||||||
|
> 每任务一个 commit(`feat(v3): Tn 描述`);`router_system` 核心不动(D3),229 项基线测试保持全绿。
|
||||||
|
|
||||||
|
| T | 内容 | 状态 | commit |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| T1 | 后端异步化(jobs.py + /chat 后台任务 + /runs/{id}/status) | ✅ 完成 | v3 基线 |
|
||||||
|
| T2 | 后端 SSE(/runs/{id}/stream 监视 workspace.json) | ✅ 完成 | v3 基线 |
|
||||||
|
| T3 | 前端脚手架(Vue3+Vite+TS,outDir=gateway/static) | ✅ 完成 | v3 基线 |
|
||||||
|
| T4 | 对话页 + SSE 实时可视化 | ✅ 完成 | v3 基线 |
|
||||||
|
| T5 | 协作过程页 + 检验队列页 + 指标页 | ✅ 完成 | v3 基线 |
|
||||||
|
| T6 | llama-server 内置管理(/llama/* + 下载 SSE)+ 设置页整页滚动修复 | ✅ 完成 | v3 基线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、模型池与工具智能体任务登记(T16–T21,见《实现方案_v4_模型池与工具智能体.md》)
|
||||||
|
|
||||||
|
> 「端云」叙事泛化为多价位模型池(local/budget/premium 三档 + 角色指派),新增 zcode 式工具智能体。
|
||||||
|
> `router_system` 保持零第三方依赖;全量测试 262 项全绿(基线 229 + 新增 33)。
|
||||||
|
|
||||||
|
| T | 内容 | 状态 | commit |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| T16 | 工具内核:WorkspaceTools(路径关押)+ ToolLoop(轮数/token 双护栏) | ✅ 完成 | T16 |
|
||||||
|
| T17 | 模型池:PoolStore + /pool 端点 + 管线池解析 + 按模型成本分账(by_model) | ✅ 完成 | T17-T18 |
|
||||||
|
| T18 | 智能体:OpenAI 兼容工具调用客户端 + AgentService + /agent 端点(SSE) | ✅ 完成 | T17-T18 |
|
||||||
|
| T19 | 前端:API 层 + 设置页模型池 UI(角色指派/条目 CRUD/连通测试) | ✅ 完成 | T19-T20 |
|
||||||
|
| T20 | 前端:智能体页 AgentView + 指标页分账卡 + SSE 终态去重 | ✅ 完成 | T19-T20 |
|
||||||
|
| T21 | 集成验证:真跑 3 轮工具任务(write/list/read)+ 浏览器实测 + 262 测试全绿 | ✅ 完成 | T21 |
|
||||||
|
|
||||||
|
| T22 | 工具扩展:edit_file(唯一替换)/ search_files(跳过依赖目录)/ run_command(allow_shell 默认关) | ✅ 完成 | T22 |
|
||||||
|
| T23 | 工作区选择:/agent/fs 目录浏览 + /agent/workspaces 最近列表 + /agent 带 workspace | ✅ 完成 | T23 |
|
||||||
|
| T24 | 前端:工作区选择栏(目录浏览器/最近/shell 开关)+ edit diff 卡 + 命令卡 | ✅ 完成 | T24-T25 |
|
||||||
|
| T25 | 集成验证:选定真实目录"读→精确编辑→运行验证"全链路 + 274 测试全绿 | ✅ 完成 | T24-T25 |
|
||||||
|
| T26 | 两级智能体:规划者(大模型)拆解/审查 + 执行者(本地小模型)工具轮,handoff.json 交接,/agent 带 executor_pool_id | ✅ 完成 | T26 |
|
||||||