Author SHA1 Message Date
tzt 43e2bceae7 feat(v3): T26 两级智能体(大模型规划/审查 + 本地小模型执行,D7)
- run_dual 编排:规划者两阶段 JSON(plan/review,失败回喂重试一次再降级),
  redo 时裁决意见回喂执行者,交接上限 agent.max_handoffs(默认 2)
- 交接文档 agent_runs/{id}/handoff.json(智能体版交流文本:instructions/acceptance/exchanges)
- /agent 新增 executor_pool_id;规划者==执行者条目拒绝;整体 token_cap 覆盖两级调用
- ToolLoop 增 emit_final 开关(内层循环不发终态,防前端 SSE 提前收口)
- 前端:执行者选择器 + phase/message 事件渲染(阶段徽标 + 双色消息卡)
- 测试 +3(done/redo/执行者故障),全量 277 passed
- fix(tests): test_config_get_put_reset 增加设置备份/恢复隔离,防止清掉用户真实配置
2026-09-01 11:45:33 +08:00
tzt 8e2123343c fix(v3): 设置页 API Key 文案改为与实现一致(明文存 settings.json,不入库) 2026-09-01 10:57:38 +08:00
tzt b08e0bb5d7 docs(v3): T25 文档收口(工作区选择 + harness 工具,274 测试全绿) 2026-09-01 10:16:58 +08:00
tzt da5dfb0ef3 feat(v3): T24 智能体工作区选择 UI + harness 风格工具卡
- 工作区选择栏(当前目录徽标 + 选择目录按钮 + 允许执行命令开关)
- 目录选择器模态:盘符→逐级浏览、上级、手输路径、不存在可新建、最近工作区快选
- edit_file 渲染为红/绿 diff 卡;run_command 渲染 $ 命令卡;文件面板随所选目录切换
2026-09-01 10:16:58 +08:00
tzt 8358302002 feat(v3): T22-T23 harness 级工具扩展 + 工作区选择后端
- T22 工具内核:edit_file(old_string 唯一命中才替换,防误改)、search_files
  (跨文件内容搜索,跳过 .git/node_modules 与二进制大文件)、run_command
  (allow_shell 默认关;超时+输出截断+Windows CREATE_NO_WINDOW)
- T23 工作区选择(参考 deepseek-harness 打开文件夹体验):/agent/fs 磁盘目录浏览
  (空 path 列 Windows 盘符)、/agent/workspaces 最近列表持久化、
  POST /agent 接受 workspace(须存在目录),运行状态记录所用工作区
- 新增测试 12 项,全量 274 passed
2026-09-01 10:16:58 +08:00
tzt 10bd4cc71d docs(v3): T21 集成验证与文档收口
- 新增《实现方案_v4_模型池与工具智能体.md》(D1-D6 决策、接口清单、T16-T21、实验设计)
- 任务登记:v3 Web 应用化 T1-T6 + 模型池/智能体 T16-T21
- 进度记录:叙事泛化决策(价位轴收编端云轴)、端到端实测、论文下一步
2026-09-01 08:52:07 +08:00
tzt 501058243b feat(v3): T19-T20 模型池设置 UI + 智能体页 + 指标分账卡
- 设置页「模型池」区块:三角色指派下拉、条目表格(档位徽标/单价/启用/测试/编辑/删除)、
  内联添加表单(模型列表探测 datalist,api_key 留空保留原值)
- 新增「🤖 智能体」页 /agent:任务输入 + 池模型选择、工具调用时间轴
  (round/tool_call/tool_result/usage/final)、左侧工作区文件浏览与预览
- 指标页「按模型分账」卡片(v2.by_model 表格)
- 修复:SSE final 后 EventSource 自动重连回放导致的事件重复渲染(终态即关闭+回放拦截)
2026-09-01 08:51:52 +08:00
tzt 374dc765c2 feat(v3): T17-T18 模型池 + 工具智能体后端
- T17 模型池:PoolStore(local/budget/premium 条目 + architect/worker/agent 角色指派),
  /pool CRUD+连通测试+模型探测端点;build_v2_pipeline 池指派优先(测试 override 最后);
  V2Stats 新增 by_model 按 token/成本分账
- T18 智能体:OpenAI 兼容工具调用客户端(transport 可注入)+ AgentService
  (事件落盘 agent_runs/{id}/events.jsonl)+ /agent 提交/status/events/SSE stream
  + 工作区浏览/读取端点(越界 400);轮数与 token 双护栏,模型经池 agent 角色或经典回退
- 新增测试 16 项,全量 262 passed(httpx 假注入,不依赖真实模型/key)
2026-09-01 08:51:52 +08:00
tzt 6247e053c1 feat(v3): T16 工具内核(WorkspaceTools 路径关押 + ToolLoop 通用循环,纯标准库)
- list_dir/read_file/write_file 三工具,OpenAI tools 声明与 tool_calls 解析
- 路径 join 后 resolve 必须仍位于工作区根内(防 ../ 与绝对路径逃逸)
- ToolLoop:轮数上限 + token 熔断双护栏、事件回调逐条产出、触顶强制总结
- 测试 11 项:关押/往返/截断/循环编排/熔断/错误折叠(假 chat_fn,零外部依赖)
2026-09-01 08:51:52 +08:00
tzt 3bbdcb7cc7 feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复) 2026-09-01 08:31:47 +08:00
tzt 8d36eeec59 feat(v2): 模型设置(用户可调小模型/大模型/管线,/config + UI) 2026-08-30 21:58:41 +08:00
tzt 5ba3cc778b feat(v2): Web 界面(对话/协作过程/人工检验/指标)+ worker 优雅降级 2026-08-30 21:26:16 +08:00
tzt 0ec7a9d034 feat(v2): T14 README v2 改写(架构/快速开始/指标) 2026-08-30 21:21:00 +08:00
tzt b900082c6d feat(v2): T12+T13 实验脚本 bench_tokens 与 E1 token 经济学跑数 2026-08-30 21:20:00 +08:00
tzt 70b9907a89 feat(v2): T11 打包分发 setup_runtime(下载/续传/解压/硬件档位) 2026-08-30 21:17:22 +08:00
tzt ff7e1bc8de feat(v2): T10 rollup + prefix cache 前缀稳定性(--cache-reuse 对齐) 2026-08-30 21:16:02 +08:00
tzt c874382130 feat(v2): T7 网关扩展 + T9 token 计量与账单(/chat v2,/chat/legacy,/runs,/review,/metrics) 2026-08-30 21:15:19 +08:00
tzt ea150129b4 feat(v2): T8 人工检验队列 ReviewQueue(sqlite/抽样/审核) 2026-08-30 21:12:52 +08:00
tzt ad81e1afb7 feat(v2): T6 CollaborativePipeline 编排(快路径/协作循环/熔断/终审) 2026-08-30 21:12:03 +08:00
tzt 4fbbdb5290 feat(v2): T5 WorkerLoop + 接地验证(代码沙箱/facts对照/结构检查) 2026-08-30 21:08:45 +08:00
tzt b3143e2914 feat(v2): T3 ArchitectClient(DeepSeek API,JSON 约束输出) 2026-08-30 21:06:34 +08:00
tzt 3ed37a1d30 feat(v2): T4 Workspace 交流文本协议(schema/校验/渲染/rollup) 2026-08-30 21:03:52 +08:00
tzt 78a410b773 feat(v2): T2 运维层 hw_profile + llama_server 进程管理 2026-08-30 21:00:52 +08:00
104 changed files with 16489 additions and 1210 deletions
+10 -3
View File
@@ -12,19 +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/
# 安全扫描器工作目录(不入库) # 模型池与智能体运行时
.mimosa/ config/model_pool.json
agent_runs/
agent_workspace/
+47
View File
@@ -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 11shell 为 Git BashPython venv 在 `.venv`Python 3.14)。
- 测试:`.venv/Scripts/python.exe -m pytest tests -q`(基线 126 passed2026-08-30)。
- 运行 demo / 评测 / 网关的命令见 README「快速开始」。
## 硬性约束(违反即返工)
1. `router_system/` 核心包零第三方依赖(纯标准库);`runtime/``gateway/` 可用
httpx / fastapi / pydantic / uvicorn;新增任何依赖需说明理由并登记 requirements*.txt。
2. 所有 LLM 结构化输出必须过 JSON Schema 校验;解析失败重试一次后降级,禁止带病继续。
3. Architect(大模型 API)输入永不包含工件全文,只用锚点+片段(方案 5.1 / D7)。
4. 一切 LLM 调用在测试中用 `httpx.MockTransport` 注入,测试不依赖真实模型或 API key。
5. 不修改 llama.cpp 源码;只捆绑上游 release 二进制(`bin/`gitignore)。
6. Windows 兼容:`pathlib` 路径、CLI 出口 UTF-8 reconfigure(先例 `scripts/eval.py`)、
子进程 terminate→kill 兜底。
7. 金额敏感:API 调用必须计量并受 `api_token_cap` 熔断约束。
## 代码风格
- 中文 docstring;配置构造用 `build_xxx(cfg)` 工厂;与现有文件排版/命名一致。
- 每任务一个 commit,格式 `feat(v2): Tn 描述`
## 文档地图
| 文档 | 用途 |
|---|---|
| 实现方案_v2_端云协同LLM协作系统.md | **当前权威规划**(架构/协议/任务分解/实验设计) |
| 任务拆解与执行计划.md | 任务状态登记表(v1 历史任务 + v2 新任务追加处) |
| 实现方案_多专业小模型+路由模型.md | v1 方案(已被 v2 取代方向,作历史参考) |
| 可行性调研与落地实现路线报告.md | v1 期可行性论证(历史参考) |
| research/2026_papers_survey.md | 文献调研(LLM 路由/级联/验证器) |
| research/routerarena/01_results_and_gap_analysis.md | v1 实测数据(74.4% 准确率、68.9% 升级率——v2 转向依据) |
-29
View File
@@ -1,29 +0,0 @@
# 分支:v1-model-routing — 本地多智能体协作模型路由(第一代)
> **快照点**`1e51167`v1 MVP 完成时点,仓库首个提交)。
> 此分支为**历史路标**,冻结不再演进;集成主线见 `master`。
## 这一代是什么
- **核心命题**:用"规则知识库 + 任务拆解 Planner + 专业执行器池 + 质量控制器(Judge) + 最后处理者"
在限定条件下替代单一通用大模型——**本地多智能体协作路由**
- 架构:8 领域规则知识库 → 规则分类器(置信度 1−e^−s)→ Planner 任务拆解(DAG
→ 黑板/前向链 → 规则执行器(L0 零参数、确定性模板)→ RuleJudge 五维评分 → mock 兜底
- 两级路由:`domain_groups`tech/professional/lifestyle/general)→ 组内 RuleClassifier;三级子领域识别
- 接口:FastAPI `/chat` `/health` `/metrics`;核心包 `router_system/` 零第三方依赖
## 如何运行
```powershell
.venv\Scripts\python.exe scripts/demo.py --trace # L0 演示(离线可跑)
.venv\Scripts\python.exe scripts/eval.py # 迷你评估
.venv\Scripts\python.exe -m pytest tests -q # 测试(初版 20 项)
.venv\Scripts\python.exe scripts/serve.py --port 8000 # 网关
```
## 定位与结论
- **方向结论**:规则路由在 9 域平衡集实测 74.4% 准确率、68.9% 升级率(`research/routerarena/`)——
假阳性过高,**被 v2 端云协作风取代**;本代代码在主线保留为 legacy(`POST /chat/legacy`
- 设计文档:`实现方案_多专业小模型+路由模型.md``可行性调研与落地实现路线报告.md`
`research/2026_papers_survey.md``research/routerarena/01_results_and_gap_analysis.md`
+114 -97
View File
@@ -1,125 +1,142 @@
# 多专业小模型 + 路由模型系统(MVP # 端云协同 LLM 协作系统(v2
用「轻量分类路由器 + 专业小模型池 + 质量控制器(Judge) + 大模型回退」在限定条件下替代单一通用大模型 大模型(APIArchitect)做任务分析/决策/终审 本地小模型(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.htmlFastAPI 托管,无构建):对话 / 协作过程(交流文本可视化)/ 人工检验 / 指标 四视图
-**测试****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语义) │ └──────────────────┘
└─────────┬─────────┘ [Architect·API·一次] briefgoal/constraints/acceptance/plan/tags)写入 交流文本
▼ 未命中
┌───────────────────┐ 低置信度(<0.60) ┌──────────────────┐
│ 分类路由器 │ ───────────────▶ │ 大模型回退 │ ┌─────────── 协作循环(护栏:rounds_cap / api_token_cap 熔断)───────────┐
RuleClassifier / │ │ Mock / DeepSeek [Worker·本地] 读 brief+当前步 → 实现 → 接地验证 → 通过→progress;失败自修≤2→issue
HuggingFace └──────────────────┘ [Architect·API·按需] 读 issues → decide → 修订 plan / 兜底代做
└──────────────────┘ └──────────────────────────────────────────────────────────────────────┘
▼ 高置信度 │ 全步 done
┌───────────────────┐
│ 专家模型池 │ code/math/legal/medical/general [Architect·API·一次] final_review → done / 打回
│ Mock / HF / API │
└─────────┬─────────┘ 交付 + 入人工检验队列(抽样 / safety 强制)
┌───────────────────┐ 质量分<0.70 ┌──────────────────┐
│ Judge 质量控制器 │ ────────────▶ │ 升级大模型回退 │
│ Rule / LLM-as-Judge│ └──────────────────┘
└───────────────────┘
``` ```
一次请求的完整路由轨迹示例: **核心经济学**:贵的一方(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. **E2E5 跑数**:端到端质量 / 协作健康度 / KV 量化曲线 / 验证器 P/R(见 `research/v2_experiments/README.md`)。
3. 用 RouterArena 协议化评测 v2 端到端质量。
4. 写 v2 论文(`research/paper/`)。
## 📌 环境备忘(T1
- Windows 11 + Git Bashvenv 在 `.venv`Python 3.14)。
- 测试命令:`.venv/Scripts/python.exe -m pytest tests -q`
- `bin/``models/``data/``runs/` 已 gitignore(运行时产物不入库)。
+113 -49
View File
@@ -1,49 +1,113 @@
# ============================================================ # ============================================================
# 多专业小模型 + 路由模型系统 — 配置 # 多专业小模型 + 路由模型系统 — 配置
# 默认全 mock 模式(零依赖、离线可跑)。要接入真实模型,把对应 # 默认全 mock 模式(零依赖、离线可跑)。要接入真实模型,把对应
# 后端 type 改为 hf / api 即可(见 README)。 # 后端 type 改为 hf / api 即可(见 README)。
# ============================================================ # ============================================================
system: system:
name: multi-expert-router name: multi-expert-router
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
classifier: # 专家系统内核执行配置(L0 默认零参数)
type: rule # rule(零依赖)| hftransformers execution:
model: Qwen/Qwen3-0.6B mode: rule # rule(默认,L0 零参数)| hybrid
confidence_floor: 0.55 planner: rule # rule(规则拆解)| hf(可选小模型拆解)
expert_backend: rule # rule(规则执行器)| hf | api(本地小模型按需加载)
domains: model_level: L0 # L0 纯规则 | L1 分类/Planner增强 | L2 领域生成
- code max_plan_depth: 3 # 任务拆解深度上限
- math
- legal classifier:
- medical type: rule # rule(零依赖)| hftransformers
- general model: Qwen/Qwen3-0.6B
confidence_floor: 0.55
experts:
code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct } domains:
math: { type: mock, model: Qwen/Qwen3-4B-Instruct } - code
legal: { type: mock, model: Qwen/Qwen3-4B-Instruct } - math
medical: { type: mock, model: Qwen/Qwen3-4B-Instruct } - legal
general: { type: mock, model: Qwen/Qwen3-1.7B-Instruct } - medical
- finance
fallback: - life
type: mock # mock | apiOpenAI 兼容,如 DeepSeek - education
model: deepseek-chat - general
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY # 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
judge: domain_groups:
type: rule # rule(零依赖)| llm tech: [code, math]
model: Qwen/Qwen3-1.7B-Instruct professional: [legal, medical, finance]
lifestyle: [life, education]
cache: general: [general]
enabled: true
semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖) experts:
similarity_threshold: 0.88 code: { type: mock, model: Qwen/Qwen2.5-Coder-7B-Instruct }
promote_frequency: 5 # 命中 N 次后提升为精确缓存␍ math: { type: mock, model: Qwen/Qwen3-4B-Instruct }
legal: { 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 }
fallback:
type: mock # none(降级模板)| mock | local(本地≤8B 按需加载)| api
model: deepseek-chat
base_url: https://api.deepseek.com/v1
api_key_env: DEEPSEEK_API_KEY
judge:
type: rule # rule(零依赖)| llm
model: Qwen/Qwen3-1.7B-Instruct
cache:
enabled: true
semantic_enabled: true # 语义缓存(字符 n-gram 相似度,零依赖)
similarity_threshold: 0.88
promote_frequency: 5 # 命中 N 次后提升为精确缓存
# ============================================================
# v2:端云协同 LLM 协作系统(《实现方案_v2》)配置段
# v1 段(上方)保留,供 legacy 路由(POST /chat/legacy)使用。
# ============================================================
runtime:
llama_server:
binary: bin/llama-server.exe # 捆绑上游 release,不改源码(D1)
model: models/qwen3.5-4b-q4_k_m.gguf
port: 8901
hw_profile: auto # auto | gpu12 | gpu8 | cpu
extra_args: ["-fa", "-ctk", "q8_0", "-ctv", "q8_0", "--cache-reuse", "256"]
tiers: # 三档硬件模板(保守默认,可手动覆盖)
gpu12: {ngl: 99, ctx: 32768}
gpu8: {ngl: 14, ctx: 16384}
cpu: {ngl: 0, ctx: 8192}
architect: # 大模型(API
model: deepseek-v4-flash
base_url: https://api.deepseek.com
api_key_env: DEEPSEEK_API_KEY
temperature: 0.2
timeout_s: 60
worker: # 小模型(本地)
backend: llama_server
temperature: 0.3
max_fix_attempts: 2
per_step_timeout_s: 300
pipeline:
fast_path: true
rounds_cap: 6
api_token_cap: 8000
breach_policy: architect_do # architect_do | local_only
review:
queue_db: data/review.sqlite3
sample_rate: 0.10 # 随机抽样送审
force_tags: [safety] # brief.tags 命中即强制送审
+14
View File
@@ -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"}
]
+511
View File
@@ -0,0 +1,511 @@
"""智能体服务(AgentService)—— zcode 式"模型操作工作区文件"的网关侧封装。
职责:
- OpenAICompatChatOpenAI 兼容 /chat/completions 的工具调用客户端(ToolLoop 的 chat_fn),
支持 httpx transport/client 注入(测试用 MockTransport,对齐 D11 封闭性)。
- AgentService:运行一次智能体任务——事件逐条落盘 agent_runs/{id}/events.jsonl
终态写 status.json;SSE 端点轮询事件文件增量推送(与 v3 workspace 监视同思路,
不侵入 router_system)。
- 模型来源:模型池 agent 角色(或显式 pool_id),否则回退经典 Architect 设置。
安全与护栏:
- 文件操作被 WorkspaceTools 关押在工作区根目录内
- 轮数上限(agent.max_rounds)与 token 熔断(agent.token_cap)双护栏
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, 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]
+1017 -94
View File
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
"""异步后台任务注册表(T1:后端异步化核心)。
设计原则(对齐 v3 方案 D1-D6):
- asyncio 原生,无 Celery/Redis/外部队列
- 每个 request_id -> TaskInfo(状态/开始时间/结果或错误)
- 任务写 runs/{id}/workspace.jsonSSE 生成器只读该文件(D3:不改 pipeline)
- 定期清理已完成任务(防内存泄漏)
"""
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, Optional
# runs/ 目录(与 pipeline.py 默认一致)
RUNS_DIR = Path("runs")
# 任务状态
STATE_PENDING = "pending"
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_FAILED = "failed"
@dataclass
class TaskInfo:
"""一个后台任务的状态快照。"""
request_id: str
state: str = STATE_PENDING # pending | running | done | failed
started_at: float = 0.0 # time.time()
finished_at: float = 0.0 # time.time()done/failed 时)
error: Optional[str] = None # failed 时错误信息
# PipelineResult 字段(done 时填充)
response: Optional[str] = None
status: Optional[str] = None # done | fast_path | escalated | failed
fast_path: bool = False
rounds_used: int = 0
api_input_tokens: int = 0
api_output_tokens: int = 0
cost_est: float = 0.0
model_used: Optional[str] = None
latency_ms: float = 0.0
workspace_path: Optional[str] = None
error_detail: Optional[str] = None
route: list = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"state": self.state,
"started_at": self.started_at,
"finished_at": self.finished_at,
"error": self.error,
"response": self.response,
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"workspace_path": self.workspace_path,
"route": self.route,
"error_detail": self.error_detail,
}
def to_result_event(self) -> Dict[str, Any]:
"""终态 SSE result 事件 payload。"""
return {
"type": "result",
"request_id": self.request_id,
"response": self.response or "",
"status": self.status,
"fast_path": self.fast_path,
"rounds_used": self.rounds_used,
"api_input_tokens": self.api_input_tokens,
"api_output_tokens": self.api_output_tokens,
"cost_est": self.cost_est,
"model_used": self.model_used,
"latency_ms": round(self.latency_ms, 2),
"route": self.route,
"error": self.error,
}
class JobStore:
"""asyncio 后台任务注册表。
线程安全(asyncio 事件循环单线程,不需要额外锁)。
任务由 register() 注册、由 _task_done() 填充结果。
SSE 生成器调用 watch_file() 轮询 workspace.json 变化。
"""
def __init__(self, max_age_seconds: float = 3600.0, max_running: int = 50):
"""
Args:
max_age_seconds: 已完成任务在内存中保留时间(秒),超时后自动清理
max_running: 最大同时运行任务数,超出后拒绝新任务
"""
self._tasks: Dict[str, TaskInfo] = {}
self._asyncio_tasks: Dict[str, "asyncio.Task[None]"] = {} # request_id -> asyncio.Task
self._cancel_events: Dict[str, asyncio.Event] = {} # request_id -> cancel Event
self.max_age_seconds = max_age_seconds
self.max_running = max_running
self._poll_interval = 0.3 # workspace.json 轮询间隔(秒)
# ─────────────────────────────────────────────────────────────────
# 公共 API
# ─────────────────────────────────────────────────────────────────
def register(self, request_id: str) -> tuple[bool, str]:
"""注册一个 pending 任务。返回 (True, "") 成功,(False, reason) 容量满。"""
running = [t for t in self._tasks.values() if t.state == STATE_RUNNING]
if len(running) >= self.max_running:
return False, f"同时运行任务已达上限 {self.max_running},请稍后重试"
if request_id in self._tasks:
return False, f"任务 {request_id} 已存在"
self._tasks[request_id] = TaskInfo(
request_id=request_id,
state=STATE_PENDING,
started_at=time.time(),
)
return True, ""
def get(self, request_id: str) -> Optional[TaskInfo]:
"""查询任务状态。"""
return self._tasks.get(request_id)
def list_all(self) -> Dict[str, TaskInfo]:
"""列出所有任务(含已完成)。"""
return dict(self._tasks)
def list_running(self) -> Dict[str, TaskInfo]:
"""只列出运行中任务。"""
return {k: v for k, v in self._tasks.items() if v.state == STATE_RUNNING}
def submit(
self,
request_id: str,
coro, # type: asyncio.coroutine
) -> "asyncio.Task[None]":
"""提交协程到后台运行;内部注册 asyncio.Task。"""
task = asyncio.create_task(self._run_wrapper(request_id, coro))
self._asyncio_tasks[request_id] = task
return task
def new_cancel(self, request_id: str) -> asyncio.Event:
"""为 SSE 连接创建一个取消事件。"""
evt = asyncio.Event()
self._cancel_events[request_id] = evt
return evt
def get_cancel(self, request_id: str) -> Optional[asyncio.Event]:
"""获取已有取消事件。"""
return self._cancel_events.get(request_id)
# ─────────────────────────────────────────────────────────────────
# 内部:任务运行包装
# ─────────────────────────────────────────────────────────────────
async def _run_wrapper(self, request_id: str, coro):
"""把用户协程包装成可追踪的任务:更新状态、捕获异常、清理。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_RUNNING
try:
await coro
except Exception as exc: # pragma: no cover
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = f"unhandled:{type(exc).__name__}:{exc}"
finally:
# 清理 asyncio task 引用
self._asyncio_tasks.pop(request_id, None)
# 定期 GC 已完成任务
self._cleanup_aged()
def _task_done(
self,
request_id: str,
result: "PipelineResult", # from pipeline.PipelineResult
) -> None:
"""任务正常完成时由调用方调用,写入结果。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_DONE
info.finished_at = time.time()
info.response = result.response
info.status = result.status
info.fast_path = result.fast_path
info.rounds_used = result.rounds_used
info.api_input_tokens = result.api_input_tokens
info.api_output_tokens = result.api_output_tokens
info.cost_est = result.cost_est
info.model_used = result.model_used
info.latency_ms = result.latency_ms
info.workspace_path = result.workspace_path
info.route = list(result.route) if result.route else []
if result.error:
info.error_detail = result.error
def _task_failed(
self,
request_id: str,
error: str,
detail: Optional[str] = None,
) -> None:
"""任务异常结束时调用。"""
info = self._tasks.get(request_id)
if info is None:
return
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = error
info.error_detail = detail
# ─────────────────────────────────────────────────────────────────
# SSE 专用:workspace.json 文件轮询
# ─────────────────────────────────────────────────────────────────
async def watch_workspace(
self,
request_id: str,
cancel_event: asyncio.Event,
) -> AsyncGenerator[Dict[str, Any], None]:
"""监视 runs/{id}/workspace.jsonyield 单条事件。
事件类型:
- {"type": "status", "value": <state>, "request_id": <id>}
- {"type": "workspace", "version": <n>, "workspace": <dict>}
- {"type": "error", "detail": <str>}
当任务进入 done/failed 状态或 cancel_event 被 set 时停止。
"""
ws_path = RUNS_DIR / request_id / "workspace.json"
seen_mtime: float = 0.0
seen_size: int = 0
while not cancel_event.is_set():
info = self.get(request_id)
# 检查终态
if info and info.state in (STATE_DONE, STATE_FAILED):
if info.state == STATE_FAILED:
yield {"type": "error", "detail": info.error or "任务失败"}
# result 由 /runs/{id}/status 提供,此处只推送终态 status
yield {"type": "status", "value": info.state, "request_id": request_id}
break
# 读文件变化(mtime + size 双检)
if ws_path.exists():
try:
stat = ws_path.stat()
if stat.st_mtime != seen_mtime or stat.st_size != seen_size:
raw = ws_path.read_text(encoding="utf-8")
data = json.loads(raw)
version = int(data.get("meta", {}).get("round", 0))
seen_mtime = stat.st_mtime
seen_size = stat.st_size
yield {
"type": "workspace",
"version": version,
"state": info.state if info else STATE_RUNNING,
"request_id": request_id,
"workspace": data,
}
except (json.JSONDecodeError, OSError):
pass # 文件正在写入,忽略
await asyncio.sleep(self._poll_interval)
# 连接关闭前最后推一次终态
info = self.get(request_id)
if info:
yield {"type": "status", "value": info.state, "request_id": request_id}
# ─────────────────────────────────────────────────────────────────
# 内存清理
# ─────────────────────────────────────────────────────────────────
def _cleanup_aged(self) -> None:
"""删除超过 max_age_seconds 的已完成任务引用。"""
now = time.time()
to_remove = [
rid for rid, info in self._tasks.items()
if info.state in (STATE_DONE, STATE_FAILED)
and (now - info.finished_at) > self.max_age_seconds
]
for rid in to_remove:
self._tasks.pop(rid, None)
def cancel(self, request_id: str) -> bool:
"""取消运行中的任务。返回 True 找到并取消,False 未找到。"""
task = self._asyncio_tasks.get(request_id)
if task is None:
return False
task.cancel()
info = self.get(request_id)
if info:
info.state = STATE_FAILED
info.finished_at = time.time()
info.error = "cancelled_by_user"
return True
# ─────────────────────────────────────────────────────────────────────────────
# 全局单例(gateway 进程内共享)
# ─────────────────────────────────────────────────────────────────────────────
_store: Optional[JobStore] = None
def get_job_store() -> JobStore:
global _store
if _store is None:
_store = JobStore(max_age_seconds=3600.0, max_running=50)
return _store
def reset_job_store() -> None:
"""测试用:重置全局注册表。"""
global _store
_store = None
+453
View File
@@ -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
+270
View File
@@ -0,0 +1,270 @@
"""模型池(PoolStore)—— 多价位异构模型注册表。
设计(《实现方案_v4_模型池与工具智能体.md》D1):
- 叙事从"端云分工"泛化为"按价位分工":local(零边际成本,内置 llama.cpp)、
budget(低价 API)、premium(高价 API)。位置只是价位的属性之一。
- 池条目存"端点 + 凭据 + 模型名 + 价位 + 单价($/1M tokens",不存模型权重。
- roles 把池条目指派给三个角色:architect(决策/终审)、worker(实现/自验证)、
agent(智能体工具循环)。角色留空 = 沿用经典单模型设置(向后兼容)。
- 持久化到 config/model_pool.jsongitignore,与 settings.json 同级)。
"""
import json
import re
import threading
from pathlib import Path
from typing import Any, Dict, Optional
_POOL_PATH = Path(__file__).resolve().parent.parent / "config" / "model_pool.json"
# 合法取值
TIERS = ("local", "budget", "premium")
BACKENDS = ("mock", "llama_server", "openai")
ROLES = ("architect", "worker", "agent")
# 池条目允许的字段(其余字段拒绝写入)
ENTRY_FIELDS = {
"id", "name", "tier", "backend", "base_url", "model", "api_key",
"price_in", "price_out", "temperature", "max_tokens", "enabled",
}
# 单价默认值($/1M tokens);local 档为 0
PRICE_DEFAULTS = {"local": 0.0, "budget": 0.1, "premium": 1.0}
def _empty_pool() -> Dict[str, Any]:
return {
"roles": {"architect": "", "worker": "", "agent": ""},
"entries": [],
}
class PoolError(ValueError):
"""池条目/角色配置非法。"""
class PoolStore:
"""模型池注册表(内存 + model_pool.json 持久化,线程安全)。"""
def __init__(self, path: Optional[Path] = None):
self._path = Path(path) if path else _POOL_PATH
self._lock = threading.Lock()
self._data = _empty_pool()
self.load()
# ---------- 持久化 ----------
def load(self) -> None:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
self._data = {
"roles": {**_empty_pool()["roles"],
**(raw.get("roles") or {})},
"entries": list(raw.get("entries") or []),
}
except Exception:
self._data = _empty_pool()
else:
self._data = _empty_pool()
def save(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._path.write_text(
json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------- 条目 CRUD ----------
def list(self) -> Dict[str, Any]:
"""返回完整池(api_key 打码)。"""
with self._lock:
return {
"roles": dict(self._data["roles"]),
"entries": [self._masked(e) for e in self._data["entries"]],
}
def get(self, entry_id: str) -> Optional[Dict[str, Any]]:
with self._lock:
for e in self._data["entries"]:
if e.get("id") == entry_id:
return dict(e)
return None
def upsert(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""新增或更新条目(按 id)。返回打码后的条目。"""
clean = self._validate(entry)
with self._lock:
entries = self._data["entries"]
for i, e in enumerate(entries):
if e.get("id") == clean["id"]:
# 空 api_key 表示保留原值(前端不回传明文)
if not clean.get("api_key"):
clean["api_key"] = e.get("api_key", "")
entries[i] = clean
self.save()
return self._masked(clean)
entries.append(clean)
self.save()
return self._masked(clean)
def delete(self, entry_id: str) -> bool:
with self._lock:
before = len(self._data["entries"])
self._data["entries"] = [
e for e in self._data["entries"] if e.get("id") != entry_id]
changed = len(self._data["entries"]) != before
if changed:
# 清空指向被删条目的角色指派
for role, rid in self._data["roles"].items():
if rid == entry_id:
self._data["roles"][role] = ""
self.save()
return changed
# ---------- 角色指派 ----------
def set_roles(self, roles: Dict[str, str]) -> Dict[str, str]:
"""指派角色 -> 池条目 id(空串 = 沿用经典设置)。"""
with self._lock:
ids = {e.get("id") for e in self._data["entries"]}
for role, rid in roles.items():
if role not in ROLES:
raise PoolError(f"未知角色: {role}")
if rid and rid not in ids:
raise PoolError(f"角色 {role} 指向不存在的模型条目: {rid}")
self._data["roles"][role] = rid or ""
self.save()
return dict(self._data["roles"])
def resolve(self, role: str) -> Optional[Dict[str, Any]]:
"""解析角色当前生效的池条目(未指派/条目禁用时返回 None = 用经典设置)。"""
if role not in ROLES:
return None
with self._lock:
rid = self._data["roles"].get(role, "")
for e in self._data["entries"]:
if e.get("id") == rid:
return dict(e) if e.get("enabled", True) else None
return None
def find_by_model(self, model: str) -> Optional[Dict[str, Any]]:
"""按模型名找条目(用于按模型计价分账)。"""
with self._lock:
for e in self._data["entries"]:
if e.get("model") == model:
return dict(e)
return None
# ---------- 校验与工具 ----------
def _validate(self, entry: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(entry, dict):
raise PoolError("条目必须是对象")
unknown = set(entry) - ENTRY_FIELDS
if unknown:
raise PoolError(f"非法字段: {sorted(unknown)}")
eid = str(entry.get("id") or "").strip()
if not eid:
# 未提供 id 时按名称生成 slug
base = re.sub(r"[^a-zA-Z0-9_-]+", "-",
str(entry.get("name") or entry.get("model") or "model")).strip("-").lower()
eid = base or "model"
with self._lock:
exist = {e.get("id") for e in self._data["entries"]}
if eid in exist:
n = 2
while f"{eid}-{n}" in exist:
n += 1
eid = f"{eid}-{n}"
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", eid):
raise PoolError("id 只允许字母/数字/-/_,长度 1-64")
backend = entry.get("backend", "openai")
if backend not in BACKENDS:
raise PoolError(f"backend 必须是 {BACKENDS} 之一")
tier = entry.get("tier", "budget")
if tier not in TIERS:
raise PoolError(f"tier 必须是 {TIERS} 之一")
if backend != "mock" and not str(entry.get("base_url") or "").strip():
raise PoolError("非 mock 后端必须填写 base_url")
if backend != "mock" and not str(entry.get("model") or "").strip():
raise PoolError("非 mock 后端必须填写 model")
try:
price_in = float(entry.get("price_in", PRICE_DEFAULTS[tier]))
price_out = float(entry.get("price_out", PRICE_DEFAULTS[tier]))
except (TypeError, ValueError):
raise PoolError("price_in/price_out 必须是数字")
if price_in < 0 or price_out < 0:
raise PoolError("单价不能为负")
try:
temperature = float(entry.get("temperature", 0.3))
except (TypeError, ValueError):
temperature = 0.3
try:
max_tokens = int(entry.get("max_tokens", 4096))
except (TypeError, ValueError):
max_tokens = 4096
return {
"id": eid,
"name": str(entry.get("name") or entry.get("model") or eid),
"tier": tier,
"backend": backend,
"base_url": str(entry.get("base_url") or "").strip(),
"model": str(entry.get("model") or "").strip(),
"api_key": str(entry.get("api_key") or ""),
"price_in": price_in,
"price_out": price_out,
"temperature": temperature,
"max_tokens": max_tokens,
"enabled": bool(entry.get("enabled", True)),
}
@staticmethod
def _masked(entry: Dict[str, Any]) -> Dict[str, Any]:
out = dict(entry)
key = out.get("api_key") or ""
out["api_key_set"] = bool(key)
out["api_key"] = (key[:6] + "") if key else ""
return out
def entry_to_architect_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_architect 配置段。"""
cfg: Dict[str, Any] = {
"model": entry.get("model") or "local",
"base_url": entry.get("base_url") or "http://127.0.0.1:8901/v1",
"temperature": float(entry.get("temperature", 0.2)),
}
if entry.get("api_key"):
cfg["api_key"] = entry["api_key"]
if entry.get("max_tokens"):
cfg["max_tokens"] = int(entry["max_tokens"])
return cfg
def entry_to_worker_cfg(entry: Dict[str, Any]) -> Dict[str, Any]:
"""池条目 -> build_worker 配置段。"""
return {
"backend": entry.get("backend") or "openai",
"base_url": entry.get("base_url") or "",
"model": entry.get("model") or "",
"temperature": float(entry.get("temperature", 0.3)),
}
def compute_cost(entry: Dict[str, Any], input_tokens: int, output_tokens: int) -> float:
"""按条目单价估算成本(USD)。price 单位:$/1M tokens。"""
return (input_tokens / 1e6) * float(entry.get("price_in", 0.0)) + \
(output_tokens / 1e6) * float(entry.get("price_out", 0.0))
# ---------- 全局单例 ----------
_store: Optional[PoolStore] = None
def get_pool() -> PoolStore:
global _store
if _store is None:
_store = PoolStore()
return _store
def reset_pool() -> None:
"""测试用:重置全局池单例。"""
global _store
_store = None
+124
View File
@@ -0,0 +1,124 @@
"""可调整的运行设置(SettingsStore)—— 让用户自定义内置小模型 / 大模型 / 管线。
用户可在 Web 界面"模型设置"里调整并持久化到 config/settings.jsongitignore),
重启后保留。调整会触发 v2 管线重建(gateway.api.build_v2_pipeline 重新读取)。
默认值与 config/config.yaml 的 v2 段一致;settings.json 只存用户改动覆盖项。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Optional
_SETTINGS_PATH = Path(__file__).resolve().parent.parent / "config" / "settings.json"
# 可调整项(含默认值);用户改动存这里
DEFAULTS: Dict[str, Any] = {
"worker": {
"backend": "llama_server", # mock | openai/api | llama_server
"model": "models/qwen3.5-4b-q4_k_m.gguf",
"base_url": "", # openai 后端填 http://127.0.0.1:11434/v1 等
"port": 8901, # llama_server 端口
"temperature": 0.3,
"max_fix_attempts": 2,
"per_step_timeout_s": 15,
"code_timeout_s": 10,
},
"architect": {
"model": "deepseek-v4-flash",
"base_url": "https://api.deepseek.com",
"api_key": "",
},
"pipeline": {
"fast_path": True,
"rounds_cap": 6,
"api_token_cap": 8000,
"breach_policy": "architect_do",
},
"agent": {
"workspace_dir": "agent_workspace", # 智能体工作区根目录(越界即拒)
"recent_workspaces": [], # 最近打开的工作区(供快速切换)
"max_rounds": 8, # 工具循环轮数上限
"token_cap": 20000, # 单次智能体任务 token 熔断
"allow_shell": False, # 允许 run_command 执行 shell(默认关)
"shell_timeout_s": 20, # shell 命令超时
"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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+14
View File
@@ -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
1 id domain a1_full a2_ws a3_rollup a4_prefix prefix_hit reduction_a2 reduction_a4
2 code_01 code 408 169 209 209 163 0.5858 0.4877
3 code_02 code 402 162 202 202 156 0.597 0.4975
4 code_03 code 406 166 206 206 161 0.5911 0.4926
5 math_01 math 395 155 195 195 150 0.6076 0.5063
6 math_02 math 392 153 193 193 147 0.6097 0.5077
7 legal_01 legal 408 169 209 209 166 0.5858 0.4877
8 medical_01 medical 403 157 197 197 158 0.6104 0.5112
9 finance_01 finance 399 153 193 193 154 0.6165 0.5163
10 life_01 life 392 152 192 192 146 0.6122 0.5102
11 education_01 education 396 148 189 189 154 0.6263 0.5227
12 general_01 general 403 157 197 197 158 0.6104 0.5112
13 general_02 general 396 149 190 190 150 0.6237 0.5202
@@ -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 |
+32
View File
@@ -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`
+228
View File
@@ -0,0 +1,228 @@
"""ArchitectClient —— 大模型(API)客户端,端云协同的"决策/终审"角色。
职责(对齐《实现方案_v2》5.1 / 6.3):
- brief(query):开局任务分析 -> 生成交流文本的 briefgoal/constraints/acceptance/plan/tags
- decide(ws):读 issues 等 -> 输出裁决(reply + patch_plan 修订计划)
- final_review(ws):终审 -> {verdict: done|fix, issues: [...]}
工程约束(D7 / D8 / D9 / D11):
- 走 OpenAI 兼容 /chat/completionsresponse_format={"type":"json_object"}prompt 内嵌 schema 描述。
- Architect 输入永不包含工件全文:只传 Workspace 渲染出的 meta+issues+decisions+锚点片段(render_for_architect)。
- 所有结构化输出解析为 JSON;失败把错误回喂重写一次,仍失败抛 ArchitectError(由编排层降级,禁止带病继续)。
- token 计量回写 ws.meta.budget;调用前先查预算,触顶抛 ArchitectCircuitBreakerD6)。
- httpx 惰性导入(零顶层依赖,对齐仓库既有 APIExpert 模式);client/transport 可注入,测试用 httpx.MockTransportD11)。
"""
from __future__ import annotations
import json
from typing import Any, Callable, Dict, List, Optional
from .workspace import Workspace
# brief 的 JSON schema(描述性提示,约束模型输出结构)
_BRIEF_SCHEMA_HINT = {
"goal": "string(<=500字)",
"constraints": "string[](<=8条)",
"tags": "string[](code/math/legal/medical/finance/life/education/general/safety 之一)",
"acceptance": "list[{id, check(string), machine_checkable(bool)}]",
"plan": "list[{id, task(string<=300字), deps(string[]), done_criteria(string)}](<=5步, 有依赖序)",
}
_DECIDE_SCHEMA_HINT = {
"reply": "string(<=600字)",
"patch_plan": "list[{id, task(string)}]",
}
_REVIEW_SCHEMA_HINT = {
"verdict": "enum(done|fix)",
"notes": "string(<=300字)",
"fix_issues": "list[string]",
}
_SYSTEM_PROMPT = (
"你是任务分析架构师。你的输入是不含工件全文的协作摘要(交流文本),"
"你的输出必须是合法 JSON 对象(不要用 markdown 代码块包裹)。"
)
class ArchitectError(RuntimeError):
"""Architect 调用失败(网络/超时/JSON 解析失败/服务错误)。"""
class ArchitectCircuitBreaker(RuntimeError):
"""预算熔断(D6):api_token_cap / rounds_cap 触顶。"""
class ArchitectClient:
"""DeepSeek(或任意 OpenAI 兼容)大模型客户端。"""
def __init__(
self,
model: str,
base_url: str = "https://api.deepseek.com",
api_key: Optional[str] = None,
temperature: float = 0.2,
timeout_s: float = 60.0,
max_tokens: int = 2048,
transport: Any = None,
_client: Any = None,
):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.temperature = temperature
self.timeout_s = timeout_s
self.max_tokens = max_tokens
self._transport = transport
self._client = _client # 注入的 AsyncClient(测试用 MockTransport
self._owns_client = _client is None
def _get_client(self):
if self._client is None:
import httpx
kwargs: Dict[str, Any] = {"timeout": self.timeout_s}
if self._transport is not None:
kwargs["transport"] = self._transport
self._client = httpx.AsyncClient(**kwargs)
return self._client
async def aclose(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
# ---------------------------------------------------------------
# 三个对外能力
# ---------------------------------------------------------------
async def brief(self, query: str, ws: Workspace) -> Dict[str, Any]:
"""生成 brief。返回解析后的 brief dicttoken 计量写入 ws。"""
user = (
"用户原始需求:" + "\n" + query + "\n\n"
"请生成任务 brief,仅输出符合如下结构的 JSON 对象:" + "\n"
+ json.dumps(_BRIEF_SCHEMA_HINT, ensure_ascii=False)
+ "\n注意:plan 中的 id 用 s1..sndeps 引用已完成步骤 id"
"acceptance 尽量 machine_checkable。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def decide(self, ws: Workspace) -> Dict[str, Any]:
"""根据交流文本当前状态做裁决。返回 {reply, patch_plan}。"""
context = ws.render_for_architect()
user = (
"以下是交流文本摘要(不含工件全文):" + "\n\n" + context + "\n\n"
"请针对未解决 issues 做出裁决,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_DECIDE_SCHEMA_HINT, ensure_ascii=False)
+ "\nreply 给 Worker 具体可执行指示;patch_plan 列出需要修订的 step 与任务。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
async def final_review(self, ws: Workspace) -> Dict[str, Any]:
"""终审。返回 {verdict: done|fix, notes, fix_issues}。"""
context = ws.render_for_architect()
user = (
"以下是待终审的交流文本摘要:" + "\n\n" + context + "\n\n"
"对照 brief 的 acceptance 做终审,仅输出符合如下结构的 JSON:" + "\n"
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False)
+ "\nverdict=done 表示验收通过;fix 表示打回,fix_issues 列出需修正项。"
)
return await self._chat_with_retry(ws, [("system", _SYSTEM_PROMPT), ("user", user)])
# ---------------------------------------------------------------
# 底层
# ---------------------------------------------------------------
async def _chat_with_retry(self, ws: Workspace,
msgs: List[tuple]) -> Dict[str, Any]:
"""调用 + JSON 解析;解析失败回喂一次重写,再失败抛 ArchitectError。"""
if not self.api_key:
raise ArchitectError(
"Architect 未配置 API Keyenv: 见 config.architect.api_key_env)。"
"请设置密钥,或使用本地降级模式(pipeline.breach_policy: local_only)。"
)
messages = [{"role": r, "content": c} for r, c in msgs]
for attempt in (1, 2):
content = await self._chat_once(ws, messages)
try:
return self._parse_json(content)
except ValueError as e:
if attempt == 1:
# 4.6:把原始输出与错误回喂重写一次
messages = messages + [
{"role": "assistant", "content": content},
{"role": "user",
"content": f"你的输出不是合法 JSON{e})。请重新只输出合法 JSON 对象。"},
]
continue
raise ArchitectError(f"Architect 输出非合法 JSON,重试后仍失败: {e}") from e
raise ArchitectError("未预期:_chat_with_retry 未返回") # 不可达
async def _chat_once(self, ws: Workspace, messages: List[Dict[str, Any]]) -> str:
if ws.exhausted():
raise ArchitectCircuitBreaker(
f"预算熔断:api_tokens={ws.budget()['api_input_tokens'] + ws.budget()['api_output_tokens']}"
f"/{ws.budget()['api_token_cap']}, round={ws.meta()['round']}/{ws.budget()['rounds_cap']}"
)
client = self._get_client()
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
body = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"response_format": {"type": "json_object"},
}
try:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=body,
)
resp.raise_for_status()
except Exception as e:
raise ArchitectError(f"Architect API 调用失败: {type(e).__name__}: {e}") from e
data = resp.json()
usage = data.get("usage", {})
ws.add_budget(usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0))
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError) as e:
raise ArchitectError(f"Architect 响应缺少 choices/content: {e}") from e
@staticmethod
def _parse_json(content: str) -> Dict[str, Any]:
"""从模型输出解析 JSON:剥除 markdown 代码围栏,取首个 JSON 对象。"""
text = content.strip()
_bt = "\x60" # 反引号(避免与构建脚本的字符串定界冲突)
fence = _bt * 3
text = text.replace(fence + "json", fence).replace(fence, "").strip()
try:
obj = json.loads(text)
if isinstance(obj, dict):
return obj
raise ValueError("顶层不是 object")
except json.JSONDecodeError:
pass
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
try:
obj = json.loads(text[start:end + 1])
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
raise ValueError("无法解析为 JSON 对象")
def build_architect(cfg: Dict[str, Any],
get_env: Callable[[str], Optional[str]] = None) -> ArchitectClient:
"""cfg 为 config.architect 段。get_env 可注入(默认读 os.environ)。"""
import os
_env = get_env or os.environ.get
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
return ArchitectClient(
model=cfg.get("model", "deepseek-v4-flash"),
base_url=cfg.get("base_url", "https://api.deepseek.com"),
api_key=key,
temperature=float(cfg.get("temperature", 0.2)),
timeout_s=float(cfg.get("timeout_s", 60)),
)
+141 -155
View File
@@ -1,155 +1,141 @@
"""两阶段路由缓存(对齐实现方案): """两阶段路由缓存(对齐实现方案):
- L1 精确缓存:完全相同的查询 -> 直接命中 - L1 精确缓存:完全相同的查询 -> 直接命中
- L2 语义缓存:字符 n-gram 余弦相似度(零依赖)-> 相似查询命中 - L2 语义缓存:字符 n-gram 余弦相似度(零依赖)-> 相似查询命中
- 命中 N 次(promote_frequency)后提升为精确缓存 - 命中 N 次(promote_frequency)后提升为精确缓存
说明:语义缓存中的"完全相同查询"(相似度=1.0)直接计为 exact 命中; 说明:语义缓存中的"完全相同查询"(相似度=1.0)直接计为 exact 命中;
高频语义命中会提升为 O(1) 的精确缓存条目。 高频语义命中会提升为 O(1) 的精确缓存条目。
只缓存"未升级"的结果(升级路径每次都走大模型,不缓存,避免陈旧)。 只缓存"未升级"的结果(升级路径每次都走大模型,不缓存,避免陈旧)。
"""
性能设计(2026-09 优化): from __future__ import annotations
- 每条语义缓存条目在写入时预计算并缓存向量范数,查询时免重复计算(原来每对比较都重算)
- 语义查找单遍完成:扫描即跟踪最优条目与命中计数,命中后不再二次线性查找 import re
- 相似度达到 1.0(完全相同查询)时提前终止扫描(余弦相似度上界,不可能更优) from dataclasses import dataclass
""" from typing import Any, Dict, List, Optional, Tuple
from __future__ import annotations
import re @dataclass
from dataclasses import dataclass class CacheEntry:
from typing import Any, Dict, List, Optional, Tuple result: Dict[str, Any]
hits: int = 1
@dataclass
class CacheEntry: def _ngrams(text: str, n: int = 3) -> List[str]:
result: Dict[str, Any] """字符 n-gram(去空白、小写),用于轻量语义相似度。"""
hits: int = 1 cleaned = re.sub(r"\s+", "", text.lower())
if len(cleaned) < n:
return [cleaned]
def _ngrams(text: str, n: int = 3) -> List[str]: return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
"""字符 n-gram(去空白、小写),用于轻量语义相似度。"""
cleaned = re.sub(r"\s+", "", text.lower())
if len(cleaned) < n: def _cosine(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float:
return [cleaned] if not vec_a or not vec_b:
return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)] return 0.0
common = set(vec_a) & set(vec_b)
dot = sum(vec_a[k] * vec_b[k] for k in common)
def _tf_vector(grams: List[str]) -> Dict[str, float]: na = sum(v * v for v in vec_a.values()) ** 0.5
vec: Dict[str, float] = {} nb = sum(v * v for v in vec_b.values()) ** 0.5
for g in grams: if na == 0 or nb == 0:
vec[g] = vec.get(g, 0.0) + 1.0 return 0.0
return vec return dot / (na * nb)
def _norm(vec: Dict[str, float]) -> float: def _tf_vector(grams: List[str]) -> Dict[str, float]:
return sum(v * v for v in vec.values()) ** 0.5 vec: Dict[str, float] = {}
for g in grams:
vec[g] = vec.get(g, 0.0) + 1.0
def _dot(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float: return vec
"""点积:遍历较小的一方,另一侧用 get 兜底。"""
if len(vec_a) > len(vec_b):
vec_a, vec_b = vec_b, vec_a class RouterCache:
return sum(v * vec_b.get(k, 0.0) for k, v in vec_a.items()) """L1 精确缓存 + L2 语义缓存。"""
def __init__(self, semantic_enabled: bool = True, similarity_threshold: float = 0.88,
class RouterCache: promote_frequency: int = 5, max_exact: int = 10000, max_semantic: int = 5000):
"""L1 精确缓存 + L2 语义缓存。""" self.semantic_enabled = semantic_enabled
self.similarity_threshold = similarity_threshold
def __init__(self, semantic_enabled: bool = True, similarity_threshold: float = 0.88, self.promote_frequency = promote_frequency
promote_frequency: int = 5, max_exact: int = 10000, max_semantic: int = 5000): self.max_exact = max_exact
self.semantic_enabled = semantic_enabled self.max_semantic = max_semantic
self.similarity_threshold = similarity_threshold self._exact: Dict[str, CacheEntry] = {}
self.promote_frequency = promote_frequency self._semantic: List[Tuple[str, CacheEntry]] = [] # (query, entry)
self.max_exact = max_exact self._sem_vecs: Dict[str, Dict[str, float]] = {}
self.max_semantic = max_semantic self.hits = {"exact": 0, "semantic": 0}
self._exact: Dict[str, CacheEntry] = {} self.misses = 0
self._semantic: List[Tuple[str, CacheEntry]] = [] # (query, entry)
self._sem_vecs: Dict[str, Dict[str, float]] = {} # ---- 查询 ----
self._sem_norms: Dict[str, float] = {} # 预计算范数,避免查询期重算 def get(self, query: str) -> Optional[Tuple[Optional[str], Dict[str, Any]]]:
self.hits = {"exact": 0, "semantic": 0} """返回 (level, result);未命中返回 None。level: 'exact' | 'semantic'"""
self.misses = 0 entry = self._exact.get(query)
if entry is not None:
# ---- 查询 ---- self.hits["exact"] += 1
def get(self, query: str) -> Optional[Tuple[Optional[str], Dict[str, Any]]]: return ("exact", entry.result)
"""返回 (level, result);未命中返回 None。level: 'exact' | 'semantic'"""
entry = self._exact.get(query) if self.semantic_enabled:
if entry is not None: q_vec = _tf_vector(_ngrams(query))
self.hits["exact"] += 1 best_sim = 0.0
return ("exact", entry.result) best_query: Optional[str] = None
best_result: Optional[Dict[str, Any]] = None
if self.semantic_enabled: for q, e in self._semantic:
q_vec = _tf_vector(_ngrams(query)) sim = _cosine(q_vec, self._sem_vecs.get(q, {}))
q_norm = _norm(q_vec) if sim > best_sim:
best_sim = 0.0 best_sim = sim
best_idx = -1 best_query = q
if q_norm > 0.0: best_result = e.result
# 单遍扫描:同时跟踪最优相似度与条目位置 if best_query is not None and best_sim >= self.similarity_threshold:
for i, (q, _e) in enumerate(self._semantic): # 完全相同查询(相似度=1.0)计为 exact 命中
n_q = self._sem_norms.get(q, 0.0) is_exact = best_sim >= 0.999
if n_q <= 0.0: level = "exact" if is_exact else "semantic"
continue self.hits[level] += 1
sim = _dot(q_vec, self._sem_vecs.get(q, {})) / (q_norm * n_q) self._semantic_hit(best_query)
if sim > best_sim: return (level, best_result)
best_sim = sim
best_idx = i self.misses += 1
if sim >= 1.0: return None
break # 余弦相似度上界:完全相同查询,提前终止
if best_idx >= 0 and best_sim >= self.similarity_threshold: def _semantic_hit(self, query: str):
best_q, best_entry = self._semantic[best_idx] """语义命中:累计命中次数,达到阈值提升为精确缓存。"""
# 完全相同查询(相似度=1.0)计为 exact 命中 for i, (q, e) in enumerate(self._semantic):
is_exact = best_sim >= 0.999 if q == query:
level = "exact" if is_exact else "semantic" e.hits += 1
self.hits[level] += 1 if e.hits >= self.promote_frequency:
self._bump_semantic(best_idx, best_q, best_entry) self._exact[query] = e
return (level, best_entry.result) self._semantic.pop(i)
self._sem_vecs.pop(query, None)
self.misses += 1 break
return None
# ---- 写入 ----
def _bump_semantic(self, idx: int, query: str, entry: CacheEntry): def put(self, query: str, result: Dict[str, Any]):
"""语义命中:累计命中次数,达到阈值提升为精确缓存(O(1),无需二次查找)。""" if query in self._exact:
entry.hits += 1 return
if entry.hits >= self.promote_frequency: entry = CacheEntry(result=result)
self._exact[query] = entry if self.semantic_enabled:
self._semantic.pop(idx) if len(self._semantic) >= self.max_semantic:
self._sem_vecs.pop(query, None) old_q, _ = self._semantic.pop(0)
self._sem_norms.pop(query, None) self._sem_vecs.pop(old_q, None)
self._semantic.append((query, entry))
# ---- 写入 ---- self._sem_vecs[query] = _tf_vector(_ngrams(query))
def put(self, query: str, result: Dict[str, Any]): else:
if query in self._exact: self._exact[query] = entry
return if len(self._exact) > self.max_exact:
entry = CacheEntry(result=result) self._exact.pop(next(iter(self._exact)))
if self.semantic_enabled:
if len(self._semantic) >= self.max_semantic: # ---- 统计 ----
old_q, _ = self._semantic.pop(0) def stats(self) -> Dict[str, Any]:
self._sem_vecs.pop(old_q, None) total = self.hits["exact"] + self.hits["semantic"] + self.misses
self._sem_norms.pop(old_q, None) return {
self._semantic.append((query, entry)) "exact_hits": self.hits["exact"],
vec = _tf_vector(_ngrams(query)) "semantic_hits": self.hits["semantic"],
self._sem_vecs[query] = vec "misses": self.misses,
self._sem_norms[query] = _norm(vec) "hit_rate": round((self.hits["exact"] + self.hits["semantic"]) / total, 4) if total else 0.0,
else: "exact_size": len(self._exact),
self._exact[query] = entry "semantic_size": len(self._semantic),
if len(self._exact) > self.max_exact: }
self._exact.pop(next(iter(self._exact)))
def clear(self):
# ---- 统计 ---- self._exact.clear()
def stats(self) -> Dict[str, Any]: self._semantic.clear()
total = self.hits["exact"] + self.hits["semantic"] + self.misses self._sem_vecs.clear()
return { self.hits = {"exact": 0, "semantic": 0}
"exact_hits": self.hits["exact"], self.misses = 0
"semantic_hits": self.hits["semantic"],
"misses": self.misses,
"hit_rate": round((self.hits["exact"] + self.hits["semantic"]) / total, 4) if total else 0.0,
"exact_size": len(self._exact),
"semantic_size": len(self._semantic),
}
def clear(self):
self._exact.clear()
self._semantic.clear()
self._sem_vecs.clear()
self._sem_norms.clear()
self.hits = {"exact": 0, "semantic": 0}
self.misses = 0
+68 -10
View File
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63s=2 -> 0.86s=3 -> 0.95。
from __future__ import annotations from __future__ import annotations
import math import math
from typing import Dict, List, 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
self.rules = DOMAIN_RULES if domains is None:
self.rules = DOMAIN_RULES
else:
self.rules = {d: DOMAIN_RULES[d] for d in domains if d in DOMAIN_RULES}
self.domains = list(self.rules.keys())
def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]: def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
q = query.lower() q = query.lower()
@@ -128,8 +186,7 @@ class RuleClassifier(BaseClassifier):
matched_rules=[], matched_rules=[],
) )
# 同分决胜:按领域名字典序,保证与规则表排列顺序无关的确定性 best_domain = max(raw, key=raw.get)
best_domain = max(sorted(raw), key=lambda d: raw[d])
best_score = raw[best_domain] best_score = raw[best_domain]
confidence = 1.0 - math.exp(-best_score) confidence = 1.0 - math.exp(-best_score)
@@ -139,7 +196,7 @@ class RuleClassifier(BaseClassifier):
# 与次高分的差距影响置信度(区分度) # 与次高分的差距影响置信度(区分度)
if len(raw) > 1: if len(raw) > 1:
second = max(v for d, v in raw.items() if d != best_domain) second = sorted(raw.values(), reverse=True)[1]
if second > 0.7 * best_score: if second > 0.7 * best_score:
confidence *= 0.85 confidence *= 0.85
@@ -160,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:
@@ -171,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:
+118 -100
View File
@@ -1,100 +1,118 @@
"""配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。 """配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。
设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。 设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。
默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。 默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml" DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml"
_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,
}, },
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55}, "execution": {
"domains": ["code", "math", "legal", "medical", "general"], "mode": "rule", # ruleL0 零参数)| hybrid
"experts": { "planner": "rule", # rule | hf
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"}, "expert_backend": "rule", # rule(规则执行器)| hf | api
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "model_level": "L0", # L0 | L1 | L2
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, "max_plan_depth": 3,
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"}, },
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"}, "classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
}, "domains": ["code", "math", "legal", "medical", "finance", "life", "education", "general"],
"fallback": { # 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
"type": "mock", # 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
"model": "deepseek-chat", "domain_groups": {
"base_url": "https://api.deepseek.com/v1", "tech": ["code", "math"],
"api_key_env": "DEEPSEEK_API_KEY", "professional": ["legal", "medical", "finance"],
}, "lifestyle": ["life", "education"],
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"}, "general": ["general"],
"cache": { },
"enabled": True, "experts": {
"semantic_enabled": True, "code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
"similarity_threshold": 0.88, "math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
"promote_frequency": 5, "legal": {"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"},
def load_defaults() -> Dict[str, Any]: "general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
return _DEFAULTS },
"fallback": {
"type": "mock",
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]: "model": "deepseek-v4-flash",
try: "base_url": "https://api.deepseek.com",
import yaml # type: ignore "api_key_env": "DEEPSEEK_API_KEY",
except ImportError: },
return None "judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
try: "cache": {
with open(path, "r", encoding="utf-8") as f: "enabled": True,
data = yaml.safe_load(f) "semantic_enabled": True,
return data if isinstance(data, dict) else None "similarity_threshold": 0.88,
except Exception: "promote_frequency": 5,
return None },
}
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
json_path = path.with_suffix(".json") def load_defaults() -> Dict[str, Any]:
if not json_path.exists(): return _DEFAULTS
return None
try:
with open(json_path, "r", encoding="utf-8") as f: def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
data = json.load(f) try:
return data if isinstance(data, dict) else None import yaml # type: ignore
except Exception: except ImportError:
return None return None
try:
with open(path, "r", encoding="utf-8") as f:
def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]: data = yaml.safe_load(f)
"""将用户配置与内置默认配置做一层合并(用户优先)。""" return data if isinstance(data, dict) else None
merged = dict(_DEFAULTS) except Exception:
for k, v in data.items(): return None
if isinstance(v, dict) and isinstance(merged.get(k), dict):
merged[k] = {**merged[k], **v}
else: def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
merged[k] = v json_path = path.with_suffix(".json")
return merged if not json_path.exists():
return None
try:
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]: with open(json_path, "r", encoding="utf-8") as f:
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。""" data = json.load(f)
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH return data if isinstance(data, dict) else None
if cfg_path.exists(): except Exception:
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path) return None
if data is not None:
return _merge_defaults(data)
return dict(_DEFAULTS) def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]:
"""将用户配置与内置默认配置做一层合并(用户优先)。"""
merged = dict(_DEFAULTS)
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]: for k, v in data.items():
"""从环境变量读取 API Key(用于 api 类型后端)。""" if isinstance(v, dict) and isinstance(merged.get(k), dict):
env_name = cfg.get("api_key_env") or "API_KEY" merged[k] = {**merged[k], **v}
return os.environ.get(env_name) or None else:
merged[k] = v
return merged
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]:
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。"""
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH
if cfg_path.exists():
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path)
if data is not None:
return _merge_defaults(data)
return dict(_DEFAULTS)
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]:
"""从环境变量读取 API Key(用于 api 类型后端)。"""
env_name = cfg.get("api_key_env") or "API_KEY"
return os.environ.get(env_name) or None
+185 -99
View File
@@ -1,99 +1,185 @@
"""大模型回退层Mock 与 OpenAI 兼容 API 两种后端。""" """大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API
from __future__ import annotations
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
import asyncio - MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
from typing import Dict, Optional - LocalFallback :本地小模型(≤8BQ4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
from .models import ExpertResponse """
from __future__ import annotations
class FallbackProvider: import asyncio
name: str = "fallback" from typing import Dict, Optional
async def generate(self, query: str) -> ExpertResponse: from .models import ExpertResponse
raise NotImplementedError
class FallbackProvider:
class MockFallback(FallbackProvider): name: str = "fallback"
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
async def generate(self, query: str) -> ExpertResponse:
def __init__(self, model: str = "mock-large"): raise NotImplementedError
self.model = model
self.name = f"fallback-{model}"
class NoneFallback(FallbackProvider):
async def generate(self, query: str) -> ExpertResponse: """降级模板:零参数兜底,明确告知查询超出知识库范围。"""
await asyncio.sleep(0.002)
body = ( def __init__(self, model: str = "none"):
f"(大模型回退)「{query}\n\n" self.model = model
"这是一条来自大模型回退路径的完整回答。\n" self.name = "fallback-none"
"要点:\n"
"1. 对复杂/跨域任务给出综合推理\n" async def generate(self, query: str) -> ExpertResponse:
"2. 补充领域专家未覆盖的上下文\n" body = (
"3. 给出可执行的后续建议\n" f"(降级响应)「{query}\n\n"
) "当前查询超出本地知识库可处理范围(低置信度或质量校验未通过)。\n"
return ExpertResponse( "可选处理:\n"
text=body, "1. 换个更明确的问法重试\n"
model_used=self.model, "2. 启用 L2 本地小模型或配置最后处理者(fallback.type: local\n"
latency_ms=2.0, )
tokens=120, return ExpertResponse(
cost_est=2.0 * 120 / 1_000_000, text=body,
) model_used=self.model,
latency_ms=0.0,
tokens=80,
class APIFallback(FallbackProvider): cost_est=0.0,
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。""" )
def __init__(self, model: str, base_url: str, api_key: str):
self.model = model class MockFallback(FallbackProvider):
self.base_url = base_url.rstrip("/") """确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
self.api_key = api_key
self.name = f"fallback-{model}" def __init__(self, model: str = "mock-large"):
self._client = None self.model = model
self.name = f"fallback-{model}"
def _get_client(self):
if self._client is None: async def generate(self, query: str) -> ExpertResponse:
import httpx await asyncio.sleep(0.002)
self._client = httpx.AsyncClient(timeout=90.0) body = (
return self._client f"(大模型回退)「{query}\n\n"
"这是一条来自大模型回退路径的完整回答。\n"
async def generate(self, query: str) -> ExpertResponse: "要点:\n"
client = self._get_client() "1. 对复杂/跨域任务给出综合推理\n"
resp = await client.post( "2. 补充领域专家未覆盖的上下文\n"
f"{self.base_url}/chat/completions", "3. 给出可执行的后续建议\n"
headers={"Authorization": f"Bearer {self.api_key}"}, )
json={ return ExpertResponse(
"model": self.model, text=body,
"messages": [{"role": "user", "content": query}], model_used=self.model,
"temperature": 0.3, latency_ms=2.0,
"max_tokens": 2048, tokens=120,
}, cost_est=2.0 * 120 / 1_000_000,
) )
resp.raise_for_status()
data = resp.json()
body = data["choices"][0]["message"]["content"] class LocalFallback(FallbackProvider):
usage = data.get("usage", {}) """本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
return ExpertResponse( 通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1vLLM 默认 8001/v1),
text=body, 模型按需加载、用完即卸载(由本地推理服务管理),不常驻显存。
model_used=self.model, """
latency_ms=0.0,
tokens=tokens, def __init__(self, model: str, base_url: str = "http://127.0.0.1:11434/v1",
cost_est=2.0 * tokens / 1_000_000, api_key: str = ""):
) self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
def build_fallback(cfg: Dict) -> FallbackProvider: self.name = f"fallback-local-{model}"
"""cfg 为 fallback 段配置。""" self._client = None
ftype = cfg.get("type", "mock")
model = cfg.get("model", "deepseek-chat") def _get_client(self):
if ftype == "mock": if self._client is None:
return MockFallback(model=model) import httpx
if ftype == "api": self._client = httpx.AsyncClient(timeout=120.0)
import os return self._client
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
if not api_key: async def generate(self, query: str) -> ExpertResponse:
raise RuntimeError( client = self._get_client()
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key" headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
) resp = await client.post(
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com/v1"), api_key) f"{self.base_url}/chat/completions",
raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api") 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):
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
def __init__(self, model: str, base_url: str, api_key: str):
self.model = model
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.name = f"fallback-{model}"
self._client = None
def _get_client(self):
if self._client is None:
import httpx
self._client = httpx.AsyncClient(timeout=90.0)
return self._client
async def generate(self, query: str) -> ExpertResponse:
client = self._get_client()
resp = await client.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
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=2.0 * tokens / 1_000_000,
)
def build_fallback(cfg: Dict) -> FallbackProvider:
"""cfg 为 fallback 段配置。"""
ftype = cfg.get("type", "mock")
model = cfg.get("model", "deepseek-v4-flash")
if ftype == "none":
return NoneFallback(model=model)
if ftype == "mock":
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":
import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
if not api_key:
raise RuntimeError(
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
)
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key)
raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api")
+193 -174
View File
@@ -1,174 +1,193 @@
"""质量控制器(Judge):评估专家输出,决定是否升级大模型。 """质量控制器(Judge):评估专家输出,决定是否升级大模型。
- RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示), - RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示),
稳定可测,适合 MVP 与离线演示。 稳定可测,适合 MVP 与离线演示。
- LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。 - LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。
设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。 设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from .experts import extract_content_terms from .experts import extract_content_terms
# 各领域期望的响应长度范围(字符数) # 各领域期望的响应长度范围(字符数)
_EXPECTED_LEN = { _EXPECTED_LEN = {
"code": (60, 2000), "code": (60, 2000),
"math": (60, 2000), "math": (60, 2000),
"legal": (80, 3000), "legal": (80, 3000),
"medical": (80, 3000), "medical": (80, 3000),
"general": (40, 2000), "general": (40, 2000),
} }
# 领域格式检查:响应应包含的标记 # 领域格式检查:响应应包含的标记
_DOMAIN_FORMAT_HINTS = { _DOMAIN_FORMAT_HINTS = {
"code": ["```", "def ", "function", "class "], "code": ["```", "def ", "function", "class "],
"math": ["步骤", "推导", "=", "", "step"], "math": ["步骤", "推导", "=", "", "step"],
"legal": ["", "法律", "意见", "合规", "contract", "law"], "legal": ["", "法律", "意见", "合规", "contract", "law"],
"medical": ["", "就医", "医生", "症状", "诊断", "symptom"], "medical": ["", "就医", "医生", "症状", "诊断", "symptom"],
"general": [], "general": [],
} }
@dataclass @dataclass
class QualityEvaluation: class QualityEvaluation:
overall_score: float overall_score: float
scores: Dict[str, float] = field(default_factory=dict) scores: Dict[str, float] = field(default_factory=dict)
needs_fallback: bool = False needs_fallback: bool = False
reasons: List[str] = field(default_factory=list) reasons: List[str] = field(default_factory=list)
def to_dict(self) -> Dict: def to_dict(self) -> Dict:
return { return {
"overall_score": round(self.overall_score, 4), "overall_score": round(self.overall_score, 4),
"scores": {k: round(v, 4) for k, v in self.scores.items()}, "scores": {k: round(v, 4) for k, v in self.scores.items()},
"needs_fallback": self.needs_fallback, "needs_fallback": self.needs_fallback,
"reasons": self.reasons, "reasons": self.reasons,
} }
class BaseJudge: class BaseJudge:
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation: async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
raise NotImplementedError raise NotImplementedError
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:
scores: Dict[str, float] = {} async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
reasons: List[str] = [] scores: Dict[str, float] = {}
reasons: List[str] = []
# 1) 内容覆盖度:查询中的内容词有多少出现在响应里
terms = extract_content_terms(query) # 1) 内容覆盖度:查询中的内容词有多少出现在响应里
if terms: terms = extract_content_terms(query)
hit = sum(1 for t in terms if t in response.lower()) if terms:
coverage = hit / len(terms) hit = sum(1 for t in terms if t in response.lower())
scores["coverage"] = coverage coverage = hit / len(terms)
if coverage < 0.4: scores["coverage"] = coverage
reasons.append(f"内容覆盖度低 ({coverage:.0%})") if coverage < 0.4:
else: reasons.append(f"内容覆盖度低 ({coverage:.0%})")
scores["coverage"] = 1.0 else:
scores["coverage"] = 1.0
# 2) 长度充分性
lo, hi = _EXPECTED_LEN.get(domain, (40, 2000)) # 2) 长度充分性
n = len(response) lo, hi = _EXPECTED_LEN.get(domain, (40, 2000))
if n < lo: n = len(response)
scores["length"] = max(0.0, n / lo) if n < lo:
reasons.append(f"响应过短 ({n} 字符)") scores["length"] = max(0.0, n / lo)
elif n > hi: reasons.append(f"响应过短 ({n} 字符)")
scores["length"] = 0.8 elif n > hi:
reasons.append(f"响应过长 ({n} 字符)") scores["length"] = 0.8
else: reasons.append(f"响应过长 ({n} 字符)")
scores["length"] = 1.0 else:
scores["length"] = 1.0
# 3) 领域格式检查
hints = _DOMAIN_FORMAT_HINTS.get(domain, []) # 3) 领域格式检查
if hints: hints = _DOMAIN_FORMAT_HINTS.get(domain, [])
hit_hints = sum(1 for h in hints if h in response) if hints:
scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints) hit_hints = sum(1 for h in hints if h in response)
if hit_hints == 0: scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints)
reasons.append("缺少领域格式特征") if hit_hints == 0:
else: reasons.append("缺少领域格式特征")
scores["format"] = 1.0 else:
scores["format"] = 1.0
# 4) 安全/免责提示(法律、医疗领域应有警示语)
if domain in ("legal", "medical") and ("" not in response and "提示" not in response): # 4) 安全/免责提示(法律、医疗领域应有警示语)
scores["safety"] = 0.6 if domain in ("legal", "medical") and ("" not in response and "提示" not in response):
reasons.append("缺少免责提示") scores["safety"] = 0.6
else: reasons.append("缺少免责提示")
scores["safety"] = 1.0 else:
scores["safety"] = 1.0
weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2}
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items()) # 5) 领域知识引用(facts 维度):法律/医疗响应应覆盖查询命中的知识条目
needs = overall < self.fallback_threshold if domain in ("legal", "medical") and self.kb is not None:
if needs: facts = self.kb.facts(domain)
reasons.append("质量分低于阈值,建议升级大模型") hit_facts = [f for f in facts if any(k in query for k in f.get("keywords", []))]
return QualityEvaluation( if hit_facts:
overall_score=round(overall, 4), ok = sum(
scores=scores, 1 for f in hit_facts
needs_fallback=needs, if any(k in response for k in f.get("keywords", []))
reasons=reasons, )
) scores["facts"] = ok / len(hit_facts)
if scores["facts"] < 0.6:
reasons.append(f"领域知识引用不足 ({scores['facts']:.0%})")
class LLMJudge(BaseJudge): else:
"""可选:LLM-as-JudgeAPI 后端)。""" scores["facts"] = 1.0
else:
def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70): scores["facts"] = 1.0
self.model = model
self.base_url = base_url.rstrip("/") weights = {"coverage": 0.35, "length": 0.15, "format": 0.15,
self.api_key = api_key "safety": 0.15, "facts": 0.2}
self.fallback_threshold = fallback_threshold overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
self._client = None needs = overall < self.fallback_threshold
if needs:
def _get_client(self): reasons.append("质量分低于阈值,建议升级大模型")
if self._client is None: return QualityEvaluation(
import httpx overall_score=round(overall, 4),
self._client = httpx.AsyncClient(timeout=60.0) scores=scores,
return self._client needs_fallback=needs,
reasons=reasons,
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation: )
client = self._get_client()
prompt = (
f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n" class LLMJudge(BaseJudge):
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n" """可选:LLM-as-JudgeAPI 后端)。"""
f"只输出一个 0 到 1 之间的数字。"
) def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70):
resp = await client.post( self.model = model
f"{self.base_url}/chat/completions", self.base_url = base_url.rstrip("/")
headers={"Authorization": f"Bearer {self.api_key}"}, self.api_key = api_key
json={"model": self.model, "messages": [{"role": "user", "content": prompt}]}, self.fallback_threshold = fallback_threshold
) self._client = None
resp.raise_for_status()
try: def _get_client(self):
score = float(resp.json()["choices"][0]["message"]["content"].strip()) if self._client is None:
score = max(0.0, min(1.0, score)) import httpx
except Exception: self._client = httpx.AsyncClient(timeout=60.0)
score = 0.5 return self._client
return QualityEvaluation(
overall_score=score, async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
scores={"llm_judge": score}, client = self._get_client()
needs_fallback=score < self.fallback_threshold, prompt = (
) f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n"
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n"
f"只输出一个 0 到 1 之间的数字。"
def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge: )
"""cfg 为 judge 段配置。""" resp = await client.post(
jtype = cfg.get("type", "rule") f"{self.base_url}/chat/completions",
if jtype == "rule": headers={"Authorization": f"Bearer {self.api_key}"},
return RuleJudge(fallback_threshold=fallback_threshold) json={"model": self.model, "messages": [{"role": "user", "content": prompt}]},
if jtype == "llm": )
import os resp.raise_for_status()
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY")) try:
return LLMJudge( score = float(resp.json()["choices"][0]["message"]["content"].strip())
cfg.get("model", "deepseek-chat"), score = max(0.0, min(1.0, score))
cfg.get("base_url", "https://api.deepseek.com/v1"), except Exception:
api_key or "", score = 0.5
fallback_threshold=fallback_threshold, return QualityEvaluation(
) overall_score=score,
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm") scores={"llm_judge": score},
needs_fallback=score < self.fallback_threshold,
)
def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> BaseJudge:
"""cfg 为 judge 段配置。"""
jtype = cfg.get("type", "rule")
if jtype == "rule":
return RuleJudge(fallback_threshold=fallback_threshold, kb=kb)
if jtype == "llm":
import os
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
return LLMJudge(
cfg.get("model", "deepseek-v4-flash"),
cfg.get("base_url", "https://api.deepseek.com"),
api_key or "",
fallback_threshold=fallback_threshold,
)
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm")
+76 -68
View File
@@ -1,68 +1,76 @@
"""核心数据模型(纯标准库,无外部依赖)""" """核心数据模型(纯标准库,无外部依赖)"""
from __future__ import annotations from __future__ import annotations
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@dataclass @dataclass
class Classification: class Classification:
"""分类器输出:领域 + 置信度 + 难度""" """分类器输出:领域 + 置信度 + 难度"""
domain: str domain: str
confidence: float confidence: float
difficulty: str # easy | medium | hard difficulty: str # easy | medium | hard
difficulty_score: float = 0.5 difficulty_score: float = 0.5
raw_scores: Dict[str, float] = field(default_factory=dict) raw_scores: Dict[str, float] = field(default_factory=dict)
matched_rules: List[str] = field(default_factory=list) matched_rules: List[str] = field(default_factory=list)
@dataclass @dataclass
class ExpertResponse: class ExpertResponse:
"""专家模型输出""" """专家模型输出"""
text: str text: str
model_used: str model_used: str
latency_ms: float = 0.0 latency_ms: float = 0.0
tokens: int = 0 tokens: int = 0
cost_est: float = 0.0 # 相对成本估计(美元,近似) cost_est: float = 0.0 # 相对成本估计(美元,近似)
@dataclass @dataclass
class RouterResult: class RouterResult:
"""一次路由的完整结果""" """一次路由的完整结果"""
query: str query: str
response: str response: str
domain: str domain: str
difficulty: str difficulty: str
confidence: float confidence: float
upgraded: bool # 是否升级到大模型 upgraded: bool # 是否升级到大模型
quality_score: float quality_score: float
model_used: str model_used: str
route: List[str] = field(default_factory=list) # 路由决策轨迹 route: List[str] = field(default_factory=list) # 路由决策轨迹
latency_ms: float = 0.0 latency_ms: float = 0.0
cache_hit: bool = False cache_hit: bool = False
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
def to_dict(self) -> Dict[str, Any]: subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime
return { domain_group: Optional[str] = None # 大领域组(两级路由第一级:tech/professional/...
"query": self.query, request_id: Optional[str] = None # 请求 ID(配合 /traces/{id} 查询完整推理链)
"response": self.response,
"domain": self.domain, def to_dict(self) -> Dict[str, Any]:
"difficulty": self.difficulty, return {
"confidence": round(self.confidence, 4), "query": self.query,
"upgraded": self.upgraded, "response": self.response,
"quality_score": round(self.quality_score, 4), "domain": self.domain,
"model_used": self.model_used, "difficulty": self.difficulty,
"route": self.route, "confidence": round(self.confidence, 4),
"latency_ms": round(self.latency_ms, 2), "upgraded": self.upgraded,
"cache_hit": self.cache_hit, "quality_score": round(self.quality_score, 4),
"cache_level": self.cache_level, "model_used": self.model_used,
"cost_est": round(self.cost_est, 6), "route": self.route,
"error": self.error, "latency_ms": round(self.latency_ms, 2),
} "cache_hit": self.cache_hit,
"cache_level": self.cache_level,
"cost_est": round(self.cost_est, 6),
def now_ms() -> float: "error": self.error,
return time.perf_counter() * 1000.0 "subdomain": self.subdomain,
"subdomain2": self.subdomain2,
"domain_group": self.domain_group,
"request_id": self.request_id,
}
def now_ms() -> float:
return time.perf_counter() * 1000.0
+315
View File
@@ -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.MockTransportworker 用假 generate
不依赖真实 llama-server 或 API key。
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from .architect import ArchitectCircuitBreaker, ArchitectClient, ArchitectError
from .worker import WorkerLoop
from .workspace import Workspace
@dataclass
class PipelineResult:
"""v2 协作管线一次运行的结果。"""
query: str
response: str
request_id: str
status: str # done | fast_path | escalated | failed
fast_path: bool
rounds_used: int
api_input_tokens: int
api_output_tokens: int
cost_est: float
model_used: str
latency_ms: float
route: List[str] = field(default_factory=list)
workspace_path: Optional[str] = None
error: Optional[str] = None
class CollaborativePipeline:
"""编排快路径、协作循环、熔断、终审。"""
def __init__(
self,
architect: ArchitectClient,
worker: WorkerLoop,
fast_path: bool = True,
rounds_cap: int = 6,
api_token_cap: int = 8000,
breach_policy: str = "architect_do", # architect_do | local_only
run_dir: str = "runs",
):
self.architect = architect
self.worker = worker
self.fast_path = fast_path
self.rounds_cap = rounds_cap
self.api_token_cap = api_token_cap
self.breach_policy = breach_policy
self.run_dir = Path(run_dir)
# ---------------------------------------------------------------
# 入口
# ---------------------------------------------------------------
async def run(self, query: str, request_id: Optional[str] = None) -> PipelineResult:
if request_id is None:
request_id = uuid.uuid4().hex[:12]
ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap)
route: List[str] = ["v2"]
t0 = time.perf_counter() * 1000.0
# ---- 快路径:小模型直答 + 自验证(省 API 钱,D3) ----
if self.fast_path:
direct = await self.worker.direct_answer(query)
dom = self._guess_domain(query)
passed, _det = self.worker.verifier.verify(dom, "answer.md", direct, query,
kb=self.worker.kb)
if passed:
route.append(f"fast_path@domain:{dom}")
self._save_artifact(request_id, "answer.md", direct)
return self._finalize(query, ws, response=direct, status="fast_path",
fast_path=True, route=route, model_used=self.worker.model_used,
t0=t0, save_ws=False)
route.append("fast_path:miss")
# ---- briefArchitect 一次) ----
try:
brief = await self.architect.brief(query, ws)
ws.apply_brief(brief)
route.append("brief")
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
# ---- 协作循环 ----
route.append("loop")
plan = brief.get("plan") or []
pending = [p.get("id") for p in plan]
plan_ids = {p.get("id") for p in plan} # 用于 _deps_done 过滤
while pending and not ws.exhausted():
progressed = False
for sid in list(pending):
step = next((p for p in plan if p.get("id") == sid), {})
deps = step.get("deps") or []
# 只检查在 plan 中的依赖;不在 plan 的 ID 视为"不存在"→自动满足
relevant = [d for d in deps if d in plan_ids]
if not self._deps_done(ws, relevant):
continue
existing = self._read_artifact(request_id, self._artifact_name(sid, ws))
outcome = await self.worker.run_step(ws, sid, existing_artifact=existing,
hint=self._last_decision_for(ws, sid))
if outcome.status == "done":
pending.remove(sid)
self._save_artifact(request_id, outcome.artifact_name, outcome.artifact_text)
ws.rollup()
route.append(f"step:{sid}:done")
progressed = True
if not pending: # 所有步骤完成,退出循环
break
else: # issue -> Architect 裁决
route.append(f"step:{sid}:issue")
try:
dec = await self.architect.decide(ws)
ws.add_decision(dec.get("ref") or outcome.issue_id or "",
dec.get("reply", ""), dec.get("patch_plan"))
self._apply_patch_plan(ws, dec.get("patch_plan"))
route.append(f"decide:{sid}")
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
progressed = True
ws.mark_round()
if not progressed:
# 死锁(依赖/裁决都推不动)-> 熔断兜底
return await self._handle_breach(query, ws, route, t0,
exc=RuntimeError("协作循环死锁:无进度"))
if ws.exhausted() and pending:
return await self._handle_breach(query, ws, route, t0,
exc=RuntimeError("预算/回合触顶"))
# ---- 终审 ----
ws.transition("reviewing")
route.append("reviewing")
try:
rev = await self.architect.final_review(ws)
if rev.get("verdict") == "done":
ws.transition("done")
route.append("done")
status = "done"
else:
# reviewing --fail--> in_progress(修正回合);MVP 返回 escalated 标记打回
ws.transition("in_progress")
route.append("review:fix")
status = "escalated"
except (ArchitectError, ArchitectCircuitBreaker) as e:
return await self._handle_breach(query, ws, route, t0, exc=e)
response = self._build_response(ws, request_id)
return self._finalize(query, ws, response=response, status=status, fast_path=False,
route=route, model_used=self.architect.model, t0=t0)
# ---------------------------------------------------------------
# 熔断兜底
# ---------------------------------------------------------------
async def _handle_breach(self, query: str, ws: Workspace, route: List[str],
t0: float, exc: BaseException) -> PipelineResult:
route.append("breach")
if self.breach_policy == "architect_do" and self.architect.api_key:
# 有 keyArchitect 兜底代做
try:
briefish = ws.render_for_architect()
answer = await self.architect._chat_once(
ws, [{"role": "user",
"content": "以下任务自动升级,请直接给出最终可交付答案(非 JSON):" + briefish}])
route.append("breach:architect_do")
return self._finalize(query, ws, response=answer, status="escalated",
fast_path=False, route=route,
model_used=self.architect.model, t0=t0, error=str(exc))
except Exception as e2:
route.append(f"breach:architect_do:fail:{type(e2).__name__}")
# 本地降级
route.append("breach:local_deg")
msg = ("(本地降级)当前请求超出本地可处理范围,且未配置大模型密钥或预算熔断。"
f"原因:{exc}")
return self._finalize(query, ws, response=msg, status="failed", fast_path=False,
route=route, model_used="none", t0=t0, error=str(exc))
# ---------------------------------------------------------------
# 结果组装
# ---------------------------------------------------------------
def _finalize(self, query: str, ws: Workspace, response: str, status: str,
fast_path: bool, route: List[str], model_used: str, t0: float,
save_ws: bool = True, error: Optional[str] = None) -> PipelineResult:
if save_ws:
path = self.run_dir / ws.request_id / "workspace.json"
ws.save(path)
ws_path = str(path)
else:
ws_path = None
b = ws.budget()
return PipelineResult(
query=query, response=response, request_id=ws.request_id, status=status,
fast_path=fast_path, rounds_used=ws.meta()["round"],
api_input_tokens=b["api_input_tokens"], api_output_tokens=b["api_output_tokens"],
cost_est=0.0, model_used=model_used,
latency_ms=time.perf_counter() * 1000.0 - t0,
route=route, workspace_path=ws_path, error=error,
)
# ---------------------------------------------------------------
# 辅助
# ---------------------------------------------------------------
def _guess_domain(self, query: str) -> str:
if self.worker.kb is not None:
hits = self.worker.kb.match(query)
if hits:
return hits[0].domain
return "general"
def _artifact_name(self, sid: str, ws: Workspace) -> str:
from .worker import artifact_name_for
domain = self._guess_domain(ws["query"])
# 用 brief.tags 优先
tags = (ws.get("brief") or {}).get("tags") or []
for t in tags:
if t != "safety":
domain = t
break
return artifact_name_for(sid, domain)
def _deps_done(self, ws: Workspace, deps: List[str]) -> bool:
# progress 中 done 的条目;done 条目 rollup 后移到 archive(字符串格式如 "s1: ..."
done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"}
for entry in ws.get("archive", []):
if isinstance(entry, dict):
sid = entry.get("step", "")
elif isinstance(entry, str):
sid = entry.split(":")[0].strip() if ":" in entry else ""
else:
sid = ""
if sid in deps:
done.add(sid)
return all(d in done for d in deps)
def _last_decision_for(self, ws: Workspace, sid: str) -> str:
"""取最近一条针对该 step 的决策 reply,作为 worker hint。"""
step_issues = {i.get("id") for i in ws.get("issues", []) if i.get("step") == sid}
for dec in reversed(ws.get("decisions", []) or []):
if dec.get("ref") in step_issues:
return dec.get("reply", "")
return ""
def _apply_patch_plan(self, ws: Workspace, patch_plan: Optional[List[Dict[str, Any]]]) -> None:
if not patch_plan:
return
updates = {}
for item in patch_plan:
if isinstance(item, dict) and item.get("id") and item.get("task"):
updates[item["id"]] = item["task"]
if updates:
ws.revise_plan(updates)
def _build_response(self, ws: Workspace, request_id: str) -> str:
# 汇总 archive 摘要 + 各已完成步骤工件
lines = list(ws.get("archive", []) or [])
brief = ws.get("brief") or {}
parts: List[str] = []
if brief.get("goal"):
parts.append("任务:" + brief["goal"])
if lines:
parts.append("完成情况:")
parts.extend(f"- {ln}" for ln in lines)
# 附上最后一个已完成步骤的工件全文
progress = ws.get("progress", []) or []
done_steps = [p for p in progress if p.get("status") == "done"]
if done_steps:
last = done_steps[-1]
art = self._read_artifact(request_id, self._artifact_name(last["step"], ws))
if art:
parts.append("产出:")
parts.append(art)
return "\n\n".join(parts) if parts else "(协作管线未产出有效内容)"
def _save_artifact(self, request_id: str, name: str, text: str) -> None:
if not text:
return
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"),
)
+160
View File
@@ -0,0 +1,160 @@
"""人工检验队列(ReviewQueue)—— 第三个协作者(纯标准库 sqlite3)。
复用同一"交流文本"协议:异步队列,不阻塞响应路径。系统交付后按抽样率或
safety 标签强制规则入队,由人工审核给出 verdictapprove/edit/reject)并可
回写修正数据(correction),用于后续质量分析(论文 E 实验的人工地基)。
对齐《实现方案_v2》5.1 T8
- SQLite 存储(data/review.sqlite3),零第三方依赖(sqlite3 为标准库)。
- enqueue / get / list / submit(verdict, correction)。
- 抽样规则:brief.tags 命中 force_tags(如 safety)强制入队,否则按 sample_rate 随机抽样。
"""
from __future__ import annotations
import random
import sqlite3
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
_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"))
+254 -60
View File
@@ -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,59 +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")
return await self._fallback_result(query, classification, route, start) fb = await self._call_fallback(query)
latency = now_ms() - start
result = self._finalize(query, classification, fb, quality_score=0.0,
upgraded=True, route=route, latency_ms=latency,
model_used=fb.model_used, cost_est=fb.cost_est,
subdomain=subdomain, subdomain2=subdomain2,
domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=0.0,
upgraded=True, model=fb.model_used, latency=latency,
)
self._record(result, latency)
return result
# ---- Step 3: 选择专家 ---- # ---- Step 4: Planner 任务拆解(DAG ----
domain = classification.domain graph = self.planner.plan(query, classification)
expert = self.experts.get(domain) route.extend(self.planner.explain_plan(graph))
if expert is None:
expert = self.experts.get("general")
route.append("expert:fallback-to-general")
else:
route.append(f"expert:{expert.name}")
# ---- Step 4: 生成 ---- # ---- 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:
expert_resp = await expert.generate(query, classification.difficulty) evaluation = await self.judge.evaluate(query, response, classification.domain)
except Exception as e:
self.stats.record_error()
route.append(f"expert_error:{type(e).__name__}")
return await self._fallback_result(query, classification, route, start,
error=str(e))
# ---- Step 5: Judge 评估 ----
try:
evaluation = await self.judge.evaluate(query, expert_resp.text, domain)
except Exception: except Exception:
evaluation = None evaluation = None
route.append("judge_error") route.append("judge_error")
quality_score = evaluation.overall_score if evaluation else 0.0 quality_score = evaluation.overall_score if evaluation else 0.0
route.append(f"judge:{quality_score:.2f}") route.append(f"judge:{quality_score:.2f}")
upgraded = False upgraded = False
final_resp = expert_resp
if evaluation is not None and evaluation.needs_fallback: if evaluation is not None and evaluation.needs_fallback:
route.append("upgrade") route.append("upgrade")
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)
# 未升级的结果写缓存 # 未升级的结果写缓存
@@ -133,29 +253,87 @@ class Router:
return result return result
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def _fallback_result(self, query: str, classification: Classification, def _store_trace(self, request_id: str, query: str, group: Optional[str],
route: list, start: float, domain: str, difficulty: str, confidence: float,
error: Optional[str] = None) -> RouterResult: subdomain: Optional[str], subdomain2: Optional[str],
"""兜底路径的公共收尾:调用大模型回退 -> finalize -> 记录指标。 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 的命中规则(可与二级来自不同规则)。
""" """
fb = await self._call_fallback(query) hits = self.kb.match(query, domain=domain)
latency = now_ms() - start sub = None
result = self._finalize(query, classification, fb, quality_score=0.0, sub2 = None
upgraded=True, route=route, latency_ms=latency, for h in hits:
model_used=fb.model_used, cost_est=fb.cost_est, if sub is None and h.subdomain:
error=error) sub = h.subdomain
self._record(result, latency) if sub2 is None and h.subdomain2:
return result 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:
return await self.fallback.generate(query) return await self.fallback.generate(query)
except Exception as e: except Exception as e:
# 回退也失败:返回错误占位响应 # 回退也失败:返回错误占位响应
return ExpertResponse( return ExpertResponse(
text=f"[系统错误] 专家与大模型回退均失败:{type(e).__name__}: {e}", text=f"[系统错误] 专家与最后处理者均失败:{type(e).__name__}: {e}",
model_used=f"error:{self.fallback.name}", model_used=f"error:{self.fallback.name}",
cost_est=0.0, cost_est=0.0,
) )
@@ -164,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,
@@ -179,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):
@@ -198,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(
@@ -217,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)
+525
View File
@@ -0,0 +1,525 @@
"""工具调用内核 —— 让 LLM 以 OpenAI function-calling 协议操作工作区文件。
组成(对齐《实现方案_v4_模型池与工具智能体.md》D4):
- TOOLS_SPEClist_dir / read_file / write_file 三个工具的 OpenAI tools 声明
- WorkspaceTools:被"关押"在根目录内的文件工具(路径越界一律拒绝,Windows pathlib
- parse_tool_calls:解析 OpenAI 响应里的 tool_callsarguments 容错为 {}
- 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}
+96
View File
@@ -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
+135
View File
@@ -0,0 +1,135 @@
"""接地验证器(D4)—— Worker 自验证的"接地"来源。
验证分层优先级(D4):
可执行验证(跑代码/跑测试) > facts 对照 > 结构检查 > 模型自由判断(最后手段)
- 可执行验证:code 域 .py 工件在临时目录子进程沙箱运行(timeout、-I 隔离、捕获输出)。
- facts 对照:把回答/工件与知识库 factsstatement/keywords)比对覆盖度。
- 结构检查:工件非空、长度达标、不含纯占位。
说明:沙箱目前做"临时目录 + 超时 + 解释器隔离",Windows 下真正禁网需系统级工具,
此处以超时与隔离为主要护栏(文档如实记录)。验证器为纯逻辑,可注入 runner 便于单测。
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
# 判定为"含代码"的启发标记
_CODE_HINTS = ("def ", "class ", "import ", "return ", "if __name__", "print(")
def detect_artifact_language(name: str) -> str:
"""按文件名推断工件语言:python / json / text。"""
suffix = Path(name).suffix.lower()
if suffix in (".py", ".pyw"):
return "python"
if suffix in (".json",):
return "json"
return "text"
def extract_code_block(text: str) -> str:
"""从模型输出提取 python 代码块(剥除 markdown 围栏),无则返回原文本。"""
t = text.strip()
fence = chr(96) * 3 # 三个反引号
marker = fence + "python"
start = t.find(marker)
if start == -1:
return t
body_start = start + len(marker)
end = t.find(fence, body_start)
if end == -1:
return t[body_start:].strip()
return t[body_start:end].strip()
def run_code_sandbox(code: str, timeout_s: float = 10.0,
runner: Optional[Callable[[List[str], Dict[str, str], float], Tuple[int, str, str]]] = None
) -> Tuple[int, str, str]:
"""在临时目录子进程运行 python 代码。返回 (returncode, stdout, stderr)。
隔离措施:临时工作目录、-I 隔离模式、timeout 超时强杀、捕获输出。
runner 可注入(测试用假执行器,避免真跑任意代码)。
"""
if runner is not None:
return runner([sys.executable, "-I"], {}, timeout_s)
with tempfile.TemporaryDirectory(prefix="v2_sandbox_") as tmp:
script = Path(tmp) / "main.py"
script.write_text(code, encoding="utf-8")
try:
proc = subprocess.run(
[sys.executable, "-I", str(script)],
capture_output=True, text=True, timeout=timeout_s,
cwd=tmp,
creationflags=subprocess.CREATE_NO_WINDOW,
)
return proc.returncode, proc.stdout or "", proc.stderr or ""
except subprocess.TimeoutExpired:
return -1, "", "timeout exceeded"
class Verifier:
"""按 D4 分层执行接地验证。"""
def __init__(self, code_timeout_s: float = 10.0,
sandbox_runner: Optional[Callable[..., Tuple[int, str, str]]] = None):
self.code_timeout_s = code_timeout_s
self._sandbox_runner = sandbox_runner
def verify(self, domain: str, artifact_name: str, artifact_text: str,
query: str, kb: Any = None) -> Tuple[bool, List[str]]:
"""返回 (passed, details)。kb 为 KnowledgeBase(可 None,跳过 facts 层)。"""
lang = detect_artifact_language(artifact_name)
details: List[str] = []
# 1) 可执行验证(D4 最高优先级):code + python 工件且含代码
if lang == "python" and any(h in artifact_text for h in _CODE_HINTS):
rc, out, err = run_code_sandbox(artifact_text, self.code_timeout_s,
runner=self._sandbox_runner)
if rc == 0:
details.append("代码沙箱运行通过 (rc=0)")
return True, details
# 可执行验证失败 -> 直接拒绝(不落回结构检查,避免"假通过")
details.append(f"代码沙箱运行失败 rc={rc}: {(err or out)[:120]}")
return False, details
elif lang == "python":
details.append("工件不含可执行代码(跳过沙箱,进入 facts/结构检查)")
# 2) facts 对照
if kb is not None:
fact_hits = self._check_facts(domain, artifact_text, kb)
if fact_hits:
details.append(f"facts 对照命中 {fact_hits}")
return True, details
# 3) 结构检查
text = artifact_text.strip()
if len(text) < 20:
details.append(f"工件过短({len(text)} 字符)")
return False, details
if text.lower() in ("pass", "none", "todo", "待实现", ""):
details.append("工件为占位内容")
return False, details
details.append("结构检查通过(非空、长度达标)")
return True, details
def _check_facts(self, domain: str, text: str, kb: Any) -> int:
"""统计工件文本命中知识库事实的数量。"""
facts = kb.facts(domain) or []
if not facts:
return 0
hits = 0
for f in facts:
kw = f.get("keywords") or []
if any(str(k) in text for k in kw):
hits += 1
return hits
def build_verifier(cfg: Dict[str, Any]) -> Verifier:
"""cfg 为 config.worker 段。"""
return Verifier(code_timeout_s=float(cfg.get("code_timeout_s", 10)))
+215
View File
@@ -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
+545
View File
@@ -0,0 +1,545 @@
"""交流文本(Workspace)—— 端云协同 LLM 协作系统的核心协议(零依赖)。
大模型(Architect)与小模型(Worker)互不共享内部状态,只通过这份
schema 约束的结构化 JSON 共享工作区交接(类比前后端通过 API 契约协作)。
本模块实现(对齐《实现方案_v2》第 4 节):
- WORKSPACE_SCHEMAdraft-07 风格 schema 常量(文档/校验依据)
- validate():结构 + 字段长度校验(写入前必过,D2/D9)
- 锚点寻址:a://<file>#L<start>-<end>(引用工件片段,替代全文复制)
- 双渲染函数:render_for_architect(≤1200 token)、render_for_worker(≤8K token
- rollup():已完成步骤折叠为 archive 摘要行;超限压缩(只减不删,4.5)
- 状态机:draft -> in_progress -> reviewing -> done / escalated / failed
"""
from __future__ import annotations
import copy
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
VERSION = "1.0"
# ---------------------------------------------------------------------------
# 字段长度上限(同时是 rollup 依据,4.2)
# ---------------------------------------------------------------------------
LIMITS = {
"goal": 500, # brief.goal 字数
"task": 300, # brief.plan[].task 字数
"summary": 200, # progress[].summary 字数
"issue_text": 300, # issues[].observed/expected/tried/ask 字数
"reply": 600, # decisions[].reply 字数
"archive": 160, # archive[] 每条字数
"constraints": 8, # brief.constraints 上限条数
"plan_steps": 5, # brief.plan 上限步数
"acceptance": 20, # brief.acceptance 上限条数
"query_truncate": 200, # render_for_architect 中 query 截断
}
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
"life", "education", "general", "safety", "science"}
STATUS_FLOW = {
"draft": {"in_progress"},
"in_progress": {"reviewing", "escalated", "failed", "in_progress"},
"reviewing": {"done", "in_progress", "failed"},
"escalated": {"reviewing", "done", "failed"},
"done": set(),
"failed": set(),
}
# ---------------------------------------------------------------------------
# JSON Schemadraft-07 风格,draft-07 依赖内嵌;供校验与文档参考)
# ---------------------------------------------------------------------------
WORKSPACE_SCHEMA: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Communication Workspace",
"type": "object",
"required": ["version", "request_id", "query", "meta"],
"additionalProperties": False,
"properties": {
"version": {"const": VERSION},
"request_id": {"type": "string", "minLength": 1},
"query": {"type": "string", "minLength": 1},
"meta": {
"type": "object",
"required": ["status", "round", "budget"],
"properties": {
"status": {"enum": ["draft", "in_progress", "reviewing", "escalated", "done", "failed"]},
"round": {"type": "integer", "minimum": 0},
"budget": {
"type": "object",
"required": ["api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"],
"properties": {
"api_input_tokens": {"type": "integer", "minimum": 0},
"api_output_tokens": {"type": "integer", "minimum": 0},
"api_token_cap": {"type": "integer", "minimum": 1},
"rounds_cap": {"type": "integer", "minimum": 1},
},
},
},
},
"brief": {
"type": "object",
"required": ["goal", "constraints", "tags", "acceptance", "plan"],
"properties": {
"goal": {"type": "string"},
"constraints": {"type": "array", "items": {"type": "string"}},
"tags": {"type": "array", "items": {"type": "string"}},
"acceptance": {"type": "array", "items": {"type": "object"}},
"plan": {"type": "array", "items": {"type": "object"}},
},
},
"progress": {"type": "array", "items": {"type": "object"}},
"issues": {"type": "array", "items": {"type": "object"}},
"decisions": {"type": "array", "items": {"type": "object"}},
"archive": {"type": "array", "items": {"type": "string"}},
},
}
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
_TOKEN_PER_CHAR_ZH = 1 / 1.6 # 中文约 1.6 字/token
_TOKEN_PER_CHAR_EN = 1 / 4.0 # 英文约 4 字/token
def estimate_tokens(text: str) -> int:
"""粗略 token 估算(中英混合,用于渲染预算校验)。"""
if not text:
return 0
zh = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
en = len(text) - zh
return max(1, int(zh * _TOKEN_PER_CHAR_ZH + en * _TOKEN_PER_CHAR_EN))
def build_anchor(filename: str, start: int = 1, end: Optional[int] = None) -> str:
"""构造锚点:a://<file>#L<start>-<end>end 缺省仅 L<start>。"""
if end is None:
return f"a://{filename}#L{start}"
return f"a://{filename}#L{start}-{end}"
_ANCHOR_RE = re.compile(r"^a://(?P<file>[^#]+?)(?:#L(?P<start>\d+)(?:-(?P<end>\d+))?)?$")
def parse_anchor(anchor: str) -> Optional[Dict[str, Any]]:
"""解析锚点为 {file, start, end};非法返回 None。"""
m = _ANCHOR_RE.match(anchor)
if not m:
return None
start = int(m.group("start")) if m.group("start") else 1
end = int(m.group("end")) if m.group("end") else start
return {"file": m.group("file"), "start": start, "end": end}
def _clip(text: str, limit: int) -> str:
"""按字数截断(中文按字符)。"""
if len(text) <= limit:
return text
return text[:limit] + ""
# ---------------------------------------------------------------------------
# 校验
# ---------------------------------------------------------------------------
def validate(ws: Dict[str, Any]) -> List[str]:
"""校验 workspace 结构 + 字段长度。返回错误列表(空 = 合法)。"""
errors: List[str] = []
if not isinstance(ws, dict):
return ["workspace 必须是 object"]
if ws.get("version") != VERSION:
errors.append(f"version 必须是 {VERSION}")
if not isinstance(ws.get("request_id"), str) or not ws["request_id"]:
errors.append("request_id 必须是非空字符串")
if not isinstance(ws.get("query"), str) or not ws["query"]:
errors.append("query 必须是非空字符串")
meta = ws.get("meta")
if not isinstance(meta, dict):
errors.append("meta 必须是 object")
else:
if meta.get("status") not in STATUS_FLOW:
errors.append(f"meta.status 非法: {meta.get('status')}")
budget = meta.get("budget")
if not isinstance(budget, dict):
errors.append("meta.budget 必须是 object")
else:
for k in ("api_input_tokens", "api_output_tokens", "api_token_cap", "rounds_cap"):
if not isinstance(budget.get(k), int) or budget.get(k) < 0:
errors.append(f"meta.budget.{k} 必须是非负整数")
brief = ws.get("brief")
if brief is not None:
if not isinstance(brief, dict):
errors.append("brief 必须是 object")
else:
if not isinstance(brief.get("goal"), str):
errors.append("brief.goal 必须是字符串")
elif len(brief["goal"]) > LIMITS["goal"]:
errors.append(f"brief.goal 超长(>{LIMITS['goal']}字)")
if not isinstance(brief.get("constraints"), list):
errors.append("brief.constraints 必须是数组")
elif len(brief["constraints"]) > LIMITS["constraints"]:
errors.append(f"brief.constraints 超过 {LIMITS['constraints']}")
if not isinstance(brief.get("tags"), list):
errors.append("brief.tags 必须是数组")
for t in brief.get("tags", []) or []:
if t not in ALLOWED_TAGS:
errors.append(f"brief.tags 含非法标签: {t}")
acc = brief.get("acceptance")
if not isinstance(acc, list) or len(acc) > LIMITS["acceptance"]:
errors.append(f"brief.acceptance 需为 ≤{LIMITS['acceptance']} 的数组")
plan = brief.get("plan")
if not isinstance(plan, list) or len(plan) > LIMITS["plan_steps"]:
errors.append(f"brief.plan 需为 ≤{LIMITS['plan_steps']} 步的数组")
else:
ids = [p.get("id") for p in plan if isinstance(p, dict)]
if len(set(ids)) != len(ids):
errors.append("brief.plan 存在重复 step id")
for p in plan:
if not isinstance(p, dict):
errors.append("brief.plan 元素必须是 object")
continue
if not isinstance(p.get("task"), str):
errors.append(f"brief.plan[{p.get('id')}].task 必须是字符串")
elif len(p["task"]) > LIMITS["task"]:
errors.append(f"brief.plan[{p.get('id')}].task 超长(>{LIMITS['task']}字)")
for i, entry in enumerate(ws.get("progress", []) or []):
if not isinstance(entry, dict):
errors.append(f"progress[{i}] 必须是 object"); continue
if entry.get("status") not in ("done", "failed", "blocked"):
errors.append(f"progress[{i}].status 非法")
if not isinstance(entry.get("summary"), str) or len(entry["summary"]) > LIMITS["summary"]:
errors.append(f"progress[{i}].summary 非法或超长")
for i, entry in enumerate(ws.get("issues", []) or []):
if not isinstance(entry, dict):
errors.append(f"issues[{i}] 必须是 object"); continue
for k in ("observed", "expected", "tried", "ask"):
if isinstance(entry.get(k), str) and len(entry[k]) > LIMITS["issue_text"]:
errors.append(f"issues[{i}].{k} 超长(>{LIMITS['issue_text']}字)")
for i, entry in enumerate(ws.get("decisions", []) or []):
if not isinstance(entry, dict):
errors.append(f"decisions[{i}] 必须是 object"); continue
if isinstance(entry.get("reply"), str) and len(entry["reply"]) > LIMITS["reply"]:
errors.append(f"decisions[{i}].reply 超长(>{LIMITS['reply']}字)")
for i, line in enumerate(ws.get("archive", []) or []):
if not isinstance(line, str) or len(line) > LIMITS["archive"]:
errors.append(f"archive[{i}] 非法或超长")
return errors
# ---------------------------------------------------------------------------
# Workspace
# ---------------------------------------------------------------------------
class Workspace:
"""交流文本对象:持有状态、执行写入前校验、渲染、rollup、持久化。"""
def __init__(self, data: Dict[str, Any]):
errors = validate(data)
if errors:
raise ValueError("workspace 校验失败: " + "; ".join(errors[:5]))
self._data = data
self._brief_locked = False
# ---------- 构造 ----------
@classmethod
def new(cls, request_id: str, query: str,
api_token_cap: int = 8000, rounds_cap: int = 6) -> "Workspace":
data = {
"version": VERSION,
"request_id": request_id,
"query": query,
"meta": {
"status": "draft",
"round": 0,
"budget": {
"api_input_tokens": 0,
"api_output_tokens": 0,
"api_token_cap": api_token_cap,
"rounds_cap": rounds_cap,
},
},
"brief": None,
"progress": [],
"issues": [],
"decisions": [],
"archive": [],
}
return cls(data)
# ---------- 访问 ----------
@property
def request_id(self) -> str:
return self._data["request_id"]
@property
def status(self) -> str:
return self._data["meta"]["status"]
@property
def data(self) -> Dict[str, Any]:
return copy.deepcopy(self._data)
def get(self, key: str, default: Any = None) -> Any:
return self._data.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._data[key]
def meta(self) -> Dict[str, Any]:
return self._data["meta"]
def budget(self) -> Dict[str, int]:
return self._data["meta"]["budget"]
# ---------- 写入(均先校验) ----------
def _commit(self, data: Dict[str, Any]) -> None:
errors = validate(data)
if errors:
raise ValueError("写入校验失败: " + "; ".join(errors[:5]))
self._data = data
def transition(self, new_status: str) -> None:
cur = self.status
if new_status == cur:
return
if new_status not in STATUS_FLOW.get(cur, set()):
raise ValueError(f"非法状态迁移: {cur} -> {new_status}")
self._data["meta"]["status"] = new_status
def apply_brief(self, brief: Dict[str, Any]) -> None:
"""写入 brief(写一次后锁定,D2:brief 恒定位于文档前部,prefix cache 友好)。"""
if self._data.get("brief") is not None or self._brief_locked:
raise ValueError("brief 已写入,不可重复")
new = copy.deepcopy(self._data)
new["brief"] = brief
new["meta"]["status"] = "in_progress"
self._commit(new)
self._brief_locked = True
def add_progress(self, step: str, status: str, summary: str,
artifact: Optional[str] = None) -> None:
new = copy.deepcopy(self._data)
entry: Dict[str, Any] = {"step": step, "status": status, "summary": summary}
if artifact:
entry["artifact"] = artifact
new["progress"].append(entry)
self._commit(new)
def add_issue(self, step: str, anchor: str, observed: str, expected: str,
tried: str, ask: str) -> str:
new = copy.deepcopy(self._data)
iid = f"i{len(new['issues']) + 1}"
entry = {
"id": iid, "step": step, "anchor": anchor,
"observed": observed, "expected": expected,
"tried": tried, "ask": ask,
}
new["issues"].append(entry)
self._commit(new)
return iid
def add_decision(self, ref: str, reply: str,
patch_plan: Optional[List[Dict[str, str]]] = None) -> None:
new = copy.deepcopy(self._data)
new["decisions"].append({
"ref": ref, "reply": reply,
"patch_plan": patch_plan or [],
})
self._commit(new)
def revise_plan(self, updates: Dict[str, str]) -> None:
"""按 decision.patch_plan 修订既有 step 的 task(不改结构/顺序)。"""
if self._data.get("brief") is None:
raise ValueError("brief 尚未写入,无法修订 plan")
new = copy.deepcopy(self._data)
for pid, task in updates.items():
for p in new["brief"]["plan"]:
if p["id"] == pid:
p["task"] = task
break
self._commit(new)
def mark_round(self) -> None:
self._data["meta"]["round"] += 1
def add_budget(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
b = self._data["meta"]["budget"]
b["api_input_tokens"] += int(input_tokens)
b["api_output_tokens"] += int(output_tokens)
self._commit(self._data)
def exhausted(self) -> bool:
"""预算熔断判定:API token 或回合任一触顶(D6)。"""
b = self._data["meta"]["budget"]
used = b["api_input_tokens"] + b["api_output_tokens"]
if b["api_token_cap"] and used >= b["api_token_cap"]:
return True
if b["rounds_cap"] and self._data["meta"]["round"] >= b["rounds_cap"]:
return True
return False
# ---------- rollup4.5 ----------
def rollup(self) -> int:
"""把 done 的 progress 折叠为 archive 摘要行,并清理解析完成的问题。
只减不删 archive 历史;progress 中 done 的条目折叠后移除(保留 failed/blocked)。
返回本次折叠的条目数。
"""
folded = 0
new_progress: List[Dict[str, Any]] = []
for entry in self._data.get("progress", []):
if entry.get("status") == "done" and entry.get("step"):
line = _clip(f"{entry['step']}: {entry.get('summary', '')}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
folded += 1
else:
new_progress.append(entry)
resolved = {d.get("ref") for d in self._data.get("decisions", [])}
kept_issues: List[Dict[str, Any]] = []
for iss in self._data.get("issues", []):
if iss.get("id") in resolved:
line = _clip(f"{iss['id']}: {iss.get('expected', '')[:60]}", LIMITS["archive"])
if line not in self._data["archive"]:
self._data["archive"].append(line)
else:
kept_issues.append(iss)
self._data["issues"] = kept_issues
self._data["progress"] = new_progress
return folded
# ---------- 渲染 ----------
def render_for_architect(self) -> str:
"""渲染 Architect 输入(D7):meta+query(截断)+全部 issues+最近3条 decisions
+最近回合 progress 摘要。目标 ≤1200 token。"""
d = self._data
parts: List[str] = []
m = d["meta"]
parts.append("== meta ==")
parts.append(f"status={m['status']} round={m['round']} "
f"budget={json.dumps(m['budget'], ensure_ascii=False)}")
parts.append("== query ==")
parts.append(_clip(d["query"], LIMITS["query_truncate"]))
if d.get("brief"):
b = d["brief"]
parts.append("== brief(锁定) ==")
parts.append(f"goal: {_clip(b['goal'], 120)}")
parts.append(f"plan: {[p['id'] for p in b.get('plan', [])]}")
parts.append(f"acceptance: {[a.get('id') for a in b.get('acceptance', [])]}")
parts.append("== issues ==")
for iss in d.get("issues", []):
parts.append(f"{iss['id']} step={iss.get('step')} anchor={iss.get('anchor')} "
f"ask={_clip(iss.get('ask', ''), 80)}")
parts.append("== 最近 3 条 decisions ==")
for dec in d.get("decisions", [])[-3:]:
parts.append(f"ref={dec.get('ref')} reply={_clip(dec.get('reply', ''), 80)}")
parts.append("== progress 摘要 ==")
for p in d.get("progress", [])[-5:]:
parts.append(f"{p.get('step')} [{p.get('status')}] {_clip(p.get('summary', ''), 40)}")
parts.append("== archive ==")
for line in d.get("archive", [])[-8:]:
parts.append(line)
# token 预算:超限先截断最旧 archive(已只保留 3 条 decisions
out = "\n".join(parts)
while estimate_tokens(out) > 1200 and len(d.get("archive", [])) > 4:
d = copy.deepcopy(d)
d["archive"] = d["archive"][4:]
out = "\n".join(_rerender(self, d))
return out
def render_for_worker(self, step_id: str,
artifact_text: Optional[str] = None) -> str:
"""渲染 Worker 输入(4.4):brief 全文+该 step 定义+依赖 step 的 archive 摘要行
+该 step 现有工件全文+验收标准。目标 ≤8K token。"""
d = self._data
b = d.get("brief")
parts: List[str] = []
if b:
parts.append("== 任务目标 (goal) ==")
parts.append(b["goal"])
parts.append("== 约束 (constraints) ==")
parts.extend(f"- {c}" for c in b.get("constraints", []))
parts.append("== 全部步骤 (plan) ==")
for p in b.get("plan", []):
mark = " <-- 当前步" if p.get("id") == step_id else ""
parts.append(f"{p['id']}: {p.get('task', '')}{mark}")
parts.append(f" done_criteria: {p.get('done_criteria', '')}")
parts.append("== 依赖步摘要 (archive) ==")
for line in d.get("archive", [])[-6:]:
parts.append(line)
if artifact_text:
parts.append(f"== 当前步已有工件({step_id} ==")
parts.append(artifact_text)
parts.append("== 验收标准 ==")
if b:
for a in b.get("acceptance", []):
parts.append(f"- {a.get('id')}: {a.get('check', '')} "
f"(machine_checkable={a.get('machine_checkable', False)})")
parts.append("== 要求 ==")
parts.append("请实现当前步,并用可执行验证/事实对照/结构检查自验证;"
"通过则写 progress(done),失败自修 ≤2 次,仍失败则写 issue。")
return "\n".join(parts)
# ---------- 持久化 ----------
def save(self, path: Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
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
+6
View File
@@ -0,0 +1,6 @@
"""运维层(runtime):本地 llama.cpp 运行时与硬件档位管理。
与 router_system 核心解耦:本包允许使用 httpx/fastapi 等第三方依赖,
用于真实本地模型(llama-server)的进程生命周期管理与硬件适配。
核心协议(交流文本)仍在 router_system 内保持零第三方依赖。
"""
+140
View File
@@ -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-smiNVIDIA 显存)优先;其次 vulkaninfoAMD/Intel/通用,
只能判断是否存在 Vulkan 设备,无法可靠拿到显存 -> 保守回退 cpu,并在结果标注
probe:"conservative")。总系统内存仅作为 cpu 档提示参考,不作为分档依据。
任何探测失败都回退到 cpu 保守档,保证不崩、可离线运行(D5 / D8)。
"""
from __future__ import annotations
import shutil
import subprocess
from typing import Any, Callable, Dict, List, Optional
# 三档硬件模板(保守默认,可被 config.tiers 手动覆盖)
TIER_SPECS: Dict[str, Dict[str, Any]] = {
"gpu12": {"tier": "gpu12", "ngl": 99, "ctx": 32768, "kv_quant": "q8_0"},
"gpu8": {"tier": "gpu8", "ngl": 14, "ctx": 16384, "kv_quant": "q8_0"},
"cpu": {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0"},
}
_GPU12_THRESHOLD_GB = 12.0
_GPU8_THRESHOLD_GB = 8.0
def _run(cmd: List[str], timeout: float = 10.0,
runner: Optional[Callable[[List[str], float], subprocess.CompletedProcess]] = None
) -> Optional[subprocess.CompletedProcess]:
"""执行命令并捕获输出;失败/超时返回 None(不抛异常)。"""
if runner is not None:
try:
return runner(cmd, timeout)
except Exception:
return None
try:
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout,
creationflags=subprocess.CREATE_NO_WINDOW,
)
except Exception:
return None
def nvidia_vram_gb(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Optional[float]:
"""通过 nvidia-smi 读取显存总量(GB);无 NVIDIA 返回 None。"""
exe = shutil.which("nvidia-smi")
if not exe:
return None
out = _run([exe, "--query-gpu=memory.total", "--format=csv,noheader,nounits"], runner=runner)
if out is None or out.returncode != 0 or not out.stdout.strip():
return None
try:
# 多卡取最大值(第一行也接受,但保守起见取最大以保证模板内存够用)
vals = [float(v.strip()) for v in out.stdout.strip().splitlines() if v.strip().isdigit()]
if not vals:
return None
return max(vals) / 1024.0
except Exception:
return None
def vulkan_present(runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> bool:
"""检测是否存在 Vulkan 设备(无法可靠拿显存 -> 只用于判定非 cpu 的候选)。"""
exe = shutil.which("vulkaninfo")
if not exe:
return False
out = _run([exe, "--summary"], timeout=15.0, runner=runner)
if out is None or out.returncode != 0:
return False
low = out.stdout.lower()
# 出现 deviceName 且非 "llvmpipe"/"software" 视为有真实设备
return ("devicename" in low or "gpu" in low) and "llvmpipe" not in low and "lavapipe" not in low
def pick_tier(vram_gb: Optional[float]) -> str:
"""按显存选择档位;None/未知 -> cpu 保守档。"""
if vram_gb is None:
return "cpu"
if vram_gb >= _GPU12_THRESHOLD_GB:
return "gpu12"
if vram_gb >= _GPU8_THRESHOLD_GB:
return "gpu8"
return "cpu"
def detect(override: Optional[Dict[str, Any]] = None,
runner: Optional[Callable[..., subprocess.CompletedProcess]] = None) -> Dict[str, Any]:
"""检测并返回当前档位规格。
override(可选):{"tier": "gpu12"} 强制指定档位;或覆盖单个字段如 {"ctx": 16384}。
返回形如 {"tier": "cpu", "ngl": 0, "ctx": 8192, "kv_quant": "q8_0",
"probe": "nvidia|vulkan|cpu|override", "note": str}
"""
if override and override.get("tier") in TIER_SPECS:
spec = dict(TIER_SPECS[override["tier"]])
spec.update({k: v for k, v in override.items() if k in spec})
spec["probe"] = "override"
spec["note"] = f"手动指定档位 {override['tier']}"
return spec
vram = nvidia_vram_gb(runner=runner)
probe = "nvidia"
if vram is None:
if vulkan_present(runner=runner):
probe = "vulkan"
note = "检测到 Vulkan 设备但无法读取显存,按保守档 cpu 运行(可在 config 手动覆盖 tier"
else:
probe = "cpu"
note = "未检测到 GPU,按 cpu 档运行(-ngl 0,速度受限)"
else:
note = f"nvidia-smi 探测显存 {vram:.1f}GB"
tier = pick_tier(vram)
spec = dict(TIER_SPECS[tier])
spec["probe"] = probe
spec["note"] = note if probe != "nvidia" else f"{note} -> 档位 {tier}"
return spec
def tier_spec(tier: str) -> Dict[str, Any]:
"""返回指定档位的规格副本(供 config.tiers 兜底)。"""
if tier not in TIER_SPECS:
raise ValueError(f"未知硬件档位: {tier}(支持: {sorted(TIER_SPECS)}")
return dict(TIER_SPECS[tier])
def detect_summary() -> str:
"""人类可读的检测摘要(setup_runtime / serve 启动时打印)。"""
spec = detect()
return (
f"硬件档位: {spec['tier']} (ngl={spec['ngl']}, ctx={spec['ctx']}, "
f"kv_quant={spec['kv_quant']}) [{spec.get('note', '')}]"
)
+264
View File
@@ -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:
"""优雅停止:terminateCTRL_BREAK-> 等待 -> kill 兜底(Windows 语义)。"""
proc = self._proc
if proc is None:
return
if proc.poll() is not None:
self._proc = None
return
try:
proc.terminate()
except OSError:
pass
try:
proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
self._log("terminate 超时,kill 兜底")
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=5.0)
except subprocess.TimeoutExpired:
pass
self._proc = None
self._log("已停止")
def ensure_alive(self) -> bool:
"""保活:不健康则按指数退避重启(最多 max_restarts 次)。"""
if self._proc is not None and self._proc.poll() is None and self.health():
return True
if self._restart_count >= self.max_restarts:
return False
backoff = min(2.0 ** self._restart_count, 8.0)
self._restart_count += 1
self._log(f"检测到异常,{backoff:.1f}s 后重启(第 {self._restart_count}/{self.max_restarts} 次)")
time.sleep(backoff)
if self._proc is not None and self._proc.poll() is None:
self.stop()
return self.start()
# ---------------------------------------------------------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
@property
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc is not None else None
def __enter__(self) -> "LlamaServerManager":
self.start()
return self
def __exit__(self, *exc) -> None:
self.stop()
def build_llama_server(cfg: Dict[str, Any]) -> LlamaServerManager:
"""从 config.runtime.llama_server 段构建管理器。cfg 含 binary/model/port/hw_profile/extra_args。"""
binary = cfg.get("binary", "bin/llama-server.exe")
model = cfg.get("model", "models/qwen3.5-4b-q4_k_m.gguf")
port = int(cfg.get("port", 8901))
hw = cfg.get("hw", {}) or {}
extra = cfg.get("extra_args", [])
return LlamaServerManager(
binary=binary,
model=model,
port=port,
hw=hw,
extra_args=extra,
health_timeout_s=float(cfg.get("health_timeout_s", 120)),
max_restarts=int(cfg.get("max_restarts", 2)),
log_dir=cfg.get("log_dir"),
)
+212
View File
@@ -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 + prefixtoken 数同 A3prefix_hit 为可复用稳定前缀
prefix_hit = estimate_tokens(_prefix_region(ws))
return {"a1": a1, "a2": a2, "a3": a3, "a4": a3, "prefix_hit": prefix_hit}
def _full_context_tokens(ws: Workspace, domain: str, n_steps: int) -> int:
"""A1 基线:完整逐字上下文的 token 数。"""
d = ws.data
total = estimate_tokens(d.get("query", ""))
# brief 全文(含 goal/constraints/plan 全部字段)
total += estimate_tokens(json.dumps(d.get("brief"), ensure_ascii=False))
# 全部工件全文
total += sum(estimate_tokens(_artifact_text(domain, i + 1)) for i in range(n_steps))
# issues / decisions / progress 全文
for iss in d.get("issues", []) or []:
total += estimate_tokens(json.dumps(iss, ensure_ascii=False))
for dec in d.get("decisions", []) or []:
total += estimate_tokens(json.dumps(dec, ensure_ascii=False))
for p in d.get("progress", []) or []:
total += estimate_tokens(json.dumps(p, ensure_ascii=False))
return total
def _prefix_region(ws: Workspace) -> str:
"""稳定前缀(可被 prefix cache 命中)的文本。"""
d = ws.data
stable = {"version": d.get("version"), "request_id": d.get("request_id"),
"query": d.get("query"), "brief": d.get("brief")}
return json.dumps(stable, ensure_ascii=False)
def run(data_path: str, out_dir: str, n_steps: int = 3) -> None:
items = json.loads(Path(data_path).read_text(encoding="utf-8"))
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
rows = []
for it in items:
ws = build_workspace(it["query"], it.get("domain", "general"), n_steps)
m = measure(ws, n_steps)
rows.append({
"id": it["id"], "domain": it.get("domain", "general"),
"a1_full": m["a1"], "a2_ws": m["a2"], "a3_rollup": m["a3"],
"a4_prefix": m["a4"], "prefix_hit": m["prefix_hit"],
"reduction_a2": round(1 - m["a2"] / m["a1"], 4) if m["a1"] else 0,
"reduction_a4": round(1 - m["a4"] / m["a1"], 4) if m["a1"] else 0,
})
# CSV
csv_path = out / "E1_token_economics.csv"
with 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())
+75 -65
View File
@@ -1,65 +1,75 @@
"""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
import argparse
import asyncio import argparse
import sys import asyncio
from pathlib import Path import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
if hasattr(sys.stdout, "reconfigure"):
from router_system.router import build_router sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
SAMPLE_QUERIES = [
"用 Python 写一个快速排序函数,并解释时间复杂度", sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
"求解方程 x^2 - 5x + 6 = 0",
"劳动合同里约定离职后两年内不得从事同行业,是否有效?", from router_system.router import build_router
"高血压患者日常饮食需要注意什么?",
"给我总结一下深度学习中注意力机制的优缺点", SAMPLE_QUERIES = [
"为什么天空是蓝色的?", "用 Python 写一个快速排序函数,并解释时间复杂度",
"帮我调试这段代码:def f(x): return x + 1 报 TypeError", "求解方程 x^2 - 5x + 6 = 0",
"求 ∫ x^2 dx 从 0 到 1 的定积分是多少", "劳动合同里约定离职后两年内不得从事同行业,是否有效",
] "高血压患者日常饮食需要注意什么?",
"给我总结一下深度学习中注意力机制的优缺点",
"为什么天空是蓝色的?",
async def run_demo(router, queries, verbose: bool = False): "帮我调试这段代码:def f(x): return x + 1 报 TypeError",
for q in queries: "求 ∫ x^2 dx 从 0 到 1 的定积分是多少?",
r = await router.route(q) ]
print("=" * 72)
print(f"Q: {q}")
print(f" domain={r.domain} difficulty={r.difficulty} conf={r.confidence:.2f} " async def run_demo(router, queries, verbose: bool = False, trace: bool = False):
f"upgraded={r.upgraded} quality={r.quality_score:.2f} model={r.model_used} " for q in queries:
f"latency={r.latency_ms:.1f}ms cache={r.cache_hit}({r.cache_level}) cost=${r.cost_est:.6f}") r = await router.route(q)
print(f" route: {' -> '.join(r.route)}") print("=" * 72)
if verbose: print(f"Q: {q}")
print(f" --- response ---\n{r.response[:400]}") print(f" domain={r.domain} difficulty={r.difficulty} conf={r.confidence:.2f} "
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}")
async def main(): print(f" route: {' -> '.join(r.route)}")
parser = argparse.ArgumentParser(description="多专家路由系统 CLI 演示") if trace:
parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)") # 拆解轨迹从 route 中展开为更可读的形式
parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)") plan_steps = [s for s in r.route if s.startswith("plan:") or ":" in s]
parser.add_argument("--verbose", action="store_true", help="打印响应正文") print(" 推理链: " + " -> ".join(r.route))
args = parser.parse_args() if verbose:
print(f" --- response ---\n{r.response[:600]}")
router = build_router()
print("系统组件:", router.health())
print() async def main():
parser = argparse.ArgumentParser(description="多专家路由系统(专家系统内核)CLI 演示")
if args.query: parser.add_argument("--query", type=str, default=None, help="单条查询(覆盖默认样例)")
await run_demo(router, [args.query], verbose=True) parser.add_argument("--batch", action="store_true", help="批量模式(打印全部响应)")
else: parser.add_argument("--verbose", action="store_true", help="打印响应正文")
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose) parser.add_argument("--trace", action="store_true", help="打印完整推理链")
args = parser.parse_args()
print()
print("=" * 72) router = build_router()
print("运行指标:", router.stats.summary()) print("系统组件:", router.health())
print("缓存统计:", router.cache.stats()) print()
if args.query:
if __name__ == "__main__": await run_demo(router, [args.query], verbose=True, trace=True)
asyncio.run(main()) else:
await run_demo(router, SAMPLE_QUERIES, verbose=args.verbose, trace=args.trace)
print()
print("=" * 72)
print("运行指标:", router.stats.summary())
print("缓存统计:", router.cache.stats())
if __name__ == "__main__":
asyncio.run(main())
+121 -80
View File
@@ -1,80 +1,121 @@
"""迷你评估:对带标注的样例查询评估分类准确率、升级率、成本。 """迷你评估:对带标注的样例查询评估分类准确率、升级率、成本。
用法: 用法:
python scripts/eval.py [--repeat 2] [--config path] python scripts/eval.py [--repeat 2] [--config path]
--repeat 用于把样例跑 N 遍,验证语义缓存命中与降本效果。 --repeat 用于把样例跑 N 遍,验证语义缓存命中与降本效果。
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import sys import sys
from collections import Counter from collections import Counter
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 兼容 GBK 控制台(中文 Windows 默认编码),避免打印 ✅/⚠️ 时 UnicodeEncodeError
if hasattr(sys.stdout, "reconfigure"):
from router_system.router import build_router sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# (query, 期望领域)
BENCH = [ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
("用 Python 实现二分查找", "code"),
("这段 JavaScript 为什么报错:undefined is not a function", "code"), from router_system.router import build_router
("帮我优化这个 SQL 查询的索引", "code"),
("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math"), # (query, 期望领域, 期望大领域组, 期望子领域)
("证明勾股定理", "math"), # 覆盖 8 领域 × 3 条 = 24 条 + 两级路由/三级子领域指标
("计算 3x + 5 = 20x 等于多少", "math"), BENCH = [
("劳动合同到期不续签,公司需要支付经济补偿吗", "legal"), # ---- techcode / math ----
("在合同中约定违约金上限 30%,是否合规", "legal"), ("用 Python 实现二分查找", "code", "tech", "algorithm"),
("专利申请的流程和费用大概是多少", "legal"), ("这段 JavaScript 为什么报错:undefined is not a function", "code", "tech", "debugging"),
("高血压患者可以吃哪些降压药,副作用是什么", "medical"), ("帮我优化这个 SQL 查询的索引", "code", "tech", "database"),
("感冒发烧 38.5 度,需要吃退烧药吗", "medical"), ("求解一元二次方程 ax^2+bx+c=0 的求根公式", "math", "tech", "algebra"),
("糖尿病患者的日常饮食建议", "medical"), ("证明勾股定理", "math", "tech", "geometry"),
("介绍一下 Transformer 架构", "general"), ("计算 3x + 5 = 20x 等于多少", "math", "tech", "algebra"),
("写一封请假邮件", "general"), # ---- professionallegal / medical / finance ----
("为什么天空是蓝色的", "general"), ("劳动合同到期不续签,公司需要支付经济补偿吗", "legal", "professional", "labor"),
] ("在合同中约定违约金上限 30%,是否合规", "legal", "professional", "contract"),
("加班费怎么计算", "legal", "professional", "labor"),
("高血压患者可以吃哪些降压药,副作用是什么", "medical", "professional", "medication"),
async def main(): ("感冒发烧 38.5 度,需要吃退烧药吗", "medical", "professional", "medication"),
parser = argparse.ArgumentParser() ("烫伤后怎么处理", "medical", "professional", "firstaid"),
parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)") ("基金定投的收益率怎么计算", "finance", "professional", "investing"),
parser.add_argument("--config", type=str, default=None) ("信用卡逾期了怎么办", "finance", "professional", "credit"),
args = parser.parse_args() ("房贷利率是 LPR 加多少", "finance", "professional", "loan"),
# ---- lifestylelife / education ----
router = build_router(args.config) ("日本旅行攻略", "life", "lifestyle", "travel"),
correct = Counter() ("健身增肌计划怎么安排", "life", "lifestyle", "fitness"),
total = 0 ("家常菜谱推荐", "life", "lifestyle", "food"),
upgraded = 0 ("考研英语怎么备考", "education", "lifestyle", "exam"),
cache_hits = 0 ("高效学习方法", "education", "lifestyle", "study"),
("面试技巧有哪些", "education", "lifestyle", "career"),
for round_i in range(args.repeat): # ---- general ----
for q, expected in BENCH: ("介绍一下 Transformer 架构", "general", "general", "explain"),
r = await router.route(q) ("写一封请假邮件", "general", "general", "writing"),
total += 1 ("为什么天空是蓝色的", "general", "general", "explain"),
if r.domain == expected: ]
correct["total"] += 1
else:
correct[f"misclass->{r.domain}"] += 1 async def main():
if r.upgraded: parser = argparse.ArgumentParser()
upgraded += 1 parser.add_argument("--repeat", type=int, default=2, help="重复轮数(验证缓存)")
if r.cache_hit: parser.add_argument("--config", type=str, default=None)
cache_hits += 1 parser.add_argument("--group", type=str, default=None,
help="指定大领域组测试两级路由(如 tech);默认自动检测")
acc = correct["total"] / total args = parser.parse_args()
print(f"样例数: {len(BENCH)} x {args.repeat} 轮 = {total} 次请求")
print(f"分类准确率: {acc:.1%} ({correct['total']}/{total})") router = build_router(args.config)
print(f"升级率: {upgraded/total:.1%} ({upgraded}/{total})") # 指定组时只评测组内样例(组路由只认识本组领域)
print(f"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})") bench = BENCH
print() if args.group is not None:
print("运行指标:", router.stats.summary()) bench = [b for b in BENCH if b[2] == args.group]
print("缓存统计:", router.cache.stats()) if not bench:
print() print(f"{args.group} 无评测样例,可用组: {sorted({b[2] for b in BENCH})}")
if acc < 0.8: return
print("⚠️ 准确率低于 80%,请检查分类规则。") correct = Counter()
else: group_correct = 0
print("✅ 分类准确率达标(≥80%)。") subdomain_correct = 0
total = 0
upgraded = 0
if __name__ == "__main__": cache_hits = 0
asyncio.run(main()) decomposed = 0 # 被 Planner 拆解为多子任务的请求数
for round_i in range(args.repeat):
for q, expected, expected_group, expected_sub in bench:
r = await router.route(q, domain_group=args.group)
total += 1
if r.domain == expected:
correct["total"] += 1
else:
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:
upgraded += 1
if r.cache_hit:
cache_hits += 1
if any("plan:multi" in s for s in r.route):
decomposed += 1
acc = correct["total"] / total
print(f"样例数: {len(bench)} x {args.repeat} 轮 = {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"缓存命中率: {cache_hits/total:.1%} ({cache_hits}/{total})")
print(f"任务拆解率: {decomposed/total:.1%} ({decomposed}/{total})")
print()
print("运行指标:", router.stats.summary())
print("缓存统计:", router.cache.stats())
print()
if acc < 0.8:
print("⚠️ 准确率低于 80%,请检查分类规则。")
else:
print("✅ 分类准确率达标(≥80%)。")
if __name__ == "__main__":
asyncio.run(main())
+11 -6
View File
@@ -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)
+177
View File
@@ -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.comenv HF_MIRROR 可覆盖),HTTP Range 断点续传,文件大小校验(±1MB)。
- 网络失败:打印手动下载指引后优雅退出(不崩溃)。
- 完成后打印三档硬件检测结果与所选档位(hw_profile.detect_summary())。
下载函数可注入(tests 用假 urllib),保证封闭单测。
"""
from __future__ import annotations
import argparse
import os
import sys
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, Callable, Optional, Tuple
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from runtime.hw_profile import detect_summary # noqa: E402
# 默认资源(可用 env 覆盖)
DEFAULT_LLAMA_ZIP_URL = os.environ.get(
"LLAMA_ZIP_URL",
"https://github.com/ggml-org/llama.cpp/releases/download/b3662/llama-b3662-bin-win-vulkan-x64.zip",
)
DEFAULT_GGUF_URL = os.environ.get(
"GGUF_URL",
"https://hf-mirror.com/Qwen/Qwen3.5-4B-GGUF/resolve/main/qwen3.5-4b-q4_k_m.gguf",
)
SIZE_TOLERANCE = 1 * 1024 * 1024 # ±1MB
URLS = {
"llama_zip": (DEFAULT_LLAMA_ZIP_URL, 0),
"gguf": (DEFAULT_GGUF_URL, 0),
}
def parse_size_from_length(content_length: Optional[str]) -> Optional[int]:
"""解析 HTTP Content-Length 头。"""
if not content_length:
return None
try:
return int(content_length.strip())
except (ValueError, TypeError):
return None
def validate_size(path: Path, expected: Optional[int],
tolerance: int = SIZE_TOLERANCE) -> Tuple[bool, int]:
"""校验文件大小与期望值偏差在容差内(expected 为 None/0 时仅返回存在性)。"""
actual = path.stat().st_size if path.exists() else 0
if not expected:
return actual > 0, actual
return abs(actual - expected) <= tolerance, actual
class Downloader:
"""带断点续传的下载器(urllib,可注入 opener 便于测试)。"""
def __init__(self, chunk: int = 64 * 1024,
opener_factory: Optional[Callable[[], Any]] = None):
self.chunk = chunk
self._opener_factory = opener_factory
def _opener(self):
if self._opener_factory is not None:
return self._opener_factory()
return urllib.request.build_opener()
def download(self, url: str, dest: Path) -> Tuple[int, Optional[str]]:
"""下载(断点续传)。返回 (bytes_written, error)。"""
dest.parent.mkdir(parents=True, exist_ok=True)
existing = dest.stat().st_size if dest.exists() else 0
headers = {"User-Agent": "v2-setup-runtime/1.0"}
if existing > 0:
headers["Range"] = f"bytes={existing}-"
opener = self._opener()
try:
req = urllib.request.Request(url, headers=headers)
with opener.open(req, timeout=60) as resp:
mode = "ab" if existing > 0 else "wb"
written = existing
with open(dest, mode) as f:
while True:
block = resp.read(self.chunk)
if not block:
break
f.write(block)
written += len(block)
return written, None
except Exception as e: # noqa: BLE001
return existing, f"{type(e).__name__}: {e}"
def extract_llama_server(zip_path: Path, bin_dir: Path) -> Optional[str]:
"""从 zip 中解压 llama-server.exe 到 bin_dir。返回错误或 None。"""
try:
bin_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
target = None
for n in zf.namelist():
if n.lower().endswith("llama-server.exe"):
target = n
break
if target is None:
return "zip 中未找到 llama-server.exe"
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))
+8
View File
@@ -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]
+7 -1
View File
@@ -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()
+54
View File
@@ -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())
+405
View File
@@ -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 "")
+169
View File
@@ -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)
+37
View File
@@ -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()
+4 -40
View File
@@ -1,42 +1,6 @@
from router_system.cache import RouterCache from router_system.cache import RouterCache
def test_semantic_lookup_after_many_entries():
"""多条目下语义命中正确(范数预计算 + 单遍扫描的回归)。"""
c = RouterCache(similarity_threshold=0.5)
for i in range(50):
c.put(f"完全不相关的查询主题编号{i}关于烹饪的意见", {"response": f"r{i}"})
c.put("用 Python 实现快速排序函数", {"response": "code-answer"})
level, got = c.get("用 Python 实现快速排序的函数写法") # 相似但不完全相同
assert level in ("semantic", "exact")
assert got["response"] == "code-answer"
def test_promotion_clears_semantic_state():
"""提升为精确缓存后,语义列表与范数索引无残留。"""
c = RouterCache(promote_frequency=2)
c.put("查询甲", {"response": "a"})
first = c.get("查询甲") # 相似度=1.0 计 exacthits 达阈值即提升
assert first is not None and first[0] == "exact"
second = c.get("查询甲")
assert second is not None and second[0] == "exact"
assert c.stats()["exact_size"] == 1
assert c.stats()["semantic_size"] == 0
assert len(c._sem_norms) == 0
def test_semantic_eviction_clears_norms():
"""语义缓存满员淘汰最旧条目时,向量与范数索引同步清理。"""
c = RouterCache(max_semantic=2)
c.put("查询一", {"response": "1"})
c.put("查询二", {"response": "2"})
c.put("查询三", {"response": "3"}) # 淘汰查询一
assert len(c._semantic) == 2
assert len(c._sem_vecs) == 2
assert len(c._sem_norms) == 2
assert c.get("查询一") is None
def test_exact_hit(): def test_exact_hit():
c = RouterCache() c = RouterCache()
result = {"response": "hello", "domain": "general"} result = {"response": "hello", "domain": "general"}
@@ -49,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"
@@ -60,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
+2 -21
View File
@@ -2,25 +2,6 @@
from router_system.classifier import RuleClassifier from router_system.classifier import RuleClassifier
def test_tie_break_is_deterministic():
"""同分决胜:按领域名字典序,与规则表排列顺序无关。"""
clf = RuleClassifier()
clf.rules = {"zeta": [("x", 1.0)], "alpha": [("x", 1.0)]}
r = clf.classify("x")
assert r.domain == "alpha"
def test_distinctiveness_penalty():
"""次高分占比高(语义含混)时置信度被压低;单一领域命中不受影响。"""
clf = RuleClassifier()
clf.rules = {"a": [("kw", 1.0)], "b": [("kw", 0.9)]}
r_ambiguous = clf.classify("kw")
clf_clear = RuleClassifier()
clf_clear.rules = {"a": [("kw", 1.0)], "b": [("other", 0.1)]}
r_clear = clf_clear.classify("kw")
assert r_clear.confidence > r_ambiguous.confidence
def test_code_classification(): def test_code_classification():
clf = RuleClassifier() clf = RuleClassifier()
r = clf.classify("用 Python 写一个快速排序函数") r = clf.classify("用 Python 写一个快速排序函数")
@@ -49,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
+94 -3
View File
@@ -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
+114
View File
@@ -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"
+152
View File
@@ -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
+171
View File
@@ -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)
+161
View File
@@ -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())
# 前两次失败(占位)-> issuedecide 后重跑成功
worker = _make_worker(_fake_generate([
"待实现", "待实现", "def ok():\n return 1",
]))
pipe = _pipeline(architect, worker, fast_path=False, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "done"
assert any("issue" in r for r in res.route)
assert any("decide" in r for r in res.route)
# 至少有 1 条 decision
import json as _json
ws = json.load(open(res.workspace_path, encoding="utf-8"))
assert len(ws["decisions"]) >= 1
# ---------- 熔断兜底:本地降级 ----------
def test_breach_local_degrade(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["不通过"]))
pipe = _pipeline(architect, worker, fast_path=False, api_token_cap=1,
breach_policy="local_only", run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "failed"
assert "本地降级" in res.response
assert any("breach" in r for r in res.route)
# ---------- 熔断兜底:Architect 代做 ----------
def test_breach_architect_do(tmp_path):
# review 一直打回 fix(制造压力),最终走 breach:architect_do
def handler(request):
body = json.loads(request.content)
msg = (body.get("messages") or [{}])[-1].get("content", "")
if "任务 brief" in msg or "任务分析" in msg:
return _resp(BRIEF_JSON)
if "自动升级" in msg:
return _resp("Architect 兜底答案)")
return _resp(DECIDE_JSON)
architect = _make_architect(handler)
worker = _make_worker(_fake_generate(["待实现"]))
pipe = _pipeline(architect, worker, fast_path=False, rounds_cap=1,
api_token_cap=8000, breach_policy="architect_do",
run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
# rounds_cap=1:一轮后预算未满但回合触顶 -> breach -> architect_do
assert "breach" in " ".join(res.route)
assert res.status in ("escalated", "failed")
+72
View File
@@ -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)
+76
View File
@@ -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
+56 -56
View File
@@ -1,56 +1,56 @@
"""路由主流程单元测试。""" """路由主流程单元测试。"""
import pytest import pytest
from router_system.router import build_router from router_system.router import build_router
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_normal_flow_code(router): async def test_normal_flow_code(router):
r = await router.route("用 Python 写一个快速排序函数") r = await router.route("用 Python 写一个快速排序函数")
assert r.domain == "code" assert r.domain == "code"
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
async def test_low_confidence_direct_fallback(router): async def test_low_confidence_direct_fallback(router):
r = await router.route("今天天气怎么样") r = await router.route("今天天气怎么样")
assert r.upgraded is True assert r.upgraded is True
assert "direct_fallback" in r.route assert "direct_fallback" in r.route
assert r.model_used == router.fallback.model assert r.model_used == router.fallback.model
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cache_second_round(router): async def test_cache_second_round(router):
q = "用 Python 写一个快速排序函数" q = "用 Python 写一个快速排序函数"
r1 = await router.route(q) r1 = await router.route(q)
assert r1.cache_hit is False assert r1.cache_hit is False
r2 = await router.route(q) r2 = await router.route(q)
assert r2.cache_hit is True assert r2.cache_hit is True
assert r2.cache_level in ("exact", "semantic") assert r2.cache_level in ("exact", "semantic")
assert r2.response == r1.response assert r2.response == r1.response
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_judge_route_present(router): async def test_judge_route_present(router):
r = await router.route("高血压患者日常饮食需要注意什么") r = await router.route("高血压患者日常饮食需要注意什么")
assert any(step.startswith("judge:") for step in r.route) assert any(step.startswith("judge:") for step in r.route)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stats_recorded(router): async def test_stats_recorded(router):
await router.route("写一个 python 函数") await router.route("写一个 python 函数")
await router.route("写一个 python 函数") await router.route("写一个 python 函数")
s = router.stats.summary() s = router.stats.summary()
assert s["total_requests"] == 2 assert s["total_requests"] == 2
assert s["domain_distribution"]["code"] == 2 assert s["domain_distribution"]["code"] == 2
assert s["cache_hit_rate"] > 0 assert s["cache_hit_rate"] > 0
def test_health(router): def test_health(router):
h = router.health() h = router.health()
assert h["status"] == "ok" assert h["status"] == "ok"
assert "code" in h["domains"] assert "code" in h["domains"]
assert "math" in h["domains"] assert "math" in h["domains"]
+50
View File
@@ -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
+123
View File
@@ -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
+239
View File
@@ -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
+81
View File
@@ -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
+179
View File
@@ -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)
+231
View File
@@ -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"}
+24
View File
@@ -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?
+5
View File
@@ -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).
+13
View File
@@ -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>
+1648
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+87
View File
@@ -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>
+467
View File
@@ -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')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -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

+95
View File
@@ -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>
+9
View File
@@ -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')
+47
View File
@@ -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
+61
View File
@@ -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 }
})
+296
View File
@@ -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);
}
}
+117
View File
@@ -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 }
}
+683
View File
@@ -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>
+338
View File
@@ -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>
+450
View File
@@ -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 &amp; 裁决</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>
+209
View File
@@ -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>
+196
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}

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