ht32-code-mcp 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/DEPLOY.md +128 -26
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/PKG-INFO +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/ht32-code-mcp-update.md +37 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/pyproject.toml +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/server.py +73 -0
- ht32_code_mcp-0.1.3/src/ht32_code_mcp/usage.py +138 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/tests/test_tools.py +114 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/uv.lock +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/.gitignore +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/.gitkeep +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/README.md +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.1.3}/tests/conftest.py +0 -0
|
@@ -79,17 +79,33 @@ version`。根因是 supergateway 3.4.3 对 SDK 声明的是浮动依赖 `^1.18.
|
|
|
79
79
|
|
|
80
80
|
### 服务器侧落地(3 步)
|
|
81
81
|
|
|
82
|
+
本部署的 OpenResty 是**容器化**的(容器名 `1Panel-openresty-ih7J`),
|
|
83
|
+
已挂载 `WEBSITE_DIR=/opt/1panel/www` → 容器内 `/www`。
|
|
84
|
+
因此 bundle 目录直接落在网站根目录树下,**OpenResty 的 volumes 一行都不用改**——
|
|
85
|
+
两个容器通过宿主机上的同一目录交换文件:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
宿主机 /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles/ (站点 root 目录下 + /ht32-bundles)
|
|
89
|
+
├─ code-mcp 容器看到 /app/bundles
|
|
90
|
+
└─ OpenResty 容器看到 /www/sites/mcp.s621.work/index/ht32-bundles
|
|
91
|
+
公网 URL https://mcp.s621.work/ht32-bundles/<token>.zip
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
站点「网站目录」显示 root = `/opt/1panel/www/sites/mcp.s621.work/index`,
|
|
95
|
+
bundle 放其下子目录即被当作静态文件直接访问。
|
|
96
|
+
|
|
82
97
|
**第 1 步:宿主机建 bundle 目录**
|
|
83
98
|
|
|
84
99
|
```bash
|
|
85
|
-
mkdir -p /opt/ht32-bundles
|
|
100
|
+
mkdir -p /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles
|
|
101
|
+
chmod 755 /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles
|
|
86
102
|
```
|
|
87
103
|
|
|
88
|
-
**第 2 步:1Panel
|
|
104
|
+
**第 2 步:1Panel 给 code-mcp 容器补挂载与环境变量**
|
|
89
105
|
|
|
90
106
|
| 配置项 | 值 |
|
|
91
107
|
|--------|-----|
|
|
92
|
-
| 挂载 | `/opt/ht32-bundles:/app/bundles`(**不要加 `:ro`,需可写**) |
|
|
108
|
+
| 挂载 | `/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles:/app/bundles`(**不要加 `:ro`,需可写**) |
|
|
93
109
|
| 环境变量 | `BUNDLE_DIR=/app/bundles` |
|
|
94
110
|
| 环境变量 | `BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles` |
|
|
95
111
|
| 环境变量 | `BUNDLE_TTL_SECONDS=3600`(可选,默认即 3600) |
|
|
@@ -108,31 +124,31 @@ mkdir -p /opt/ht32-bundles
|
|
|
108
124
|
BUNDLE_DIR=/app/bundles
|
|
109
125
|
BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles
|
|
110
126
|
挂载: /opt/ht32-code:/app/code
|
|
111
|
-
/opt/ht32-bundles:/app/bundles
|
|
127
|
+
/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles:/app/bundles ← 宿主机路径,非容器内路径
|
|
112
128
|
```
|
|
113
129
|
|
|
114
|
-
**第 3 步:OpenResty
|
|
130
|
+
**第 3 步:OpenResty 无需任何配置**
|
|
115
131
|
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
站点 root = `/opt/1panel/www/sites/mcp.s621.work/index`,映射到容器内
|
|
133
|
+
`/www/sites/mcp.s621.work/index`。bundle 目录落在这个 root 之下子目录,
|
|
134
|
+
静态文件天然可访问,**不用建反向代理、不用手写 location、不用改 compose**。
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
default_type application/zip;
|
|
126
|
-
}
|
|
127
|
-
```
|
|
136
|
+
> **不要为 bundles 建反向代理条目**:1Panel 反代 UI 表达不了静态 location
|
|
137
|
+
> (后端代理地址必填,而静态目录没有后端)。若建了且后端留空,1Panel 生成的
|
|
138
|
+
> location 会抢走 `/ht32-bundles/` 却不知从哪读文件 → 404。已有此类条目请删除。
|
|
139
|
+
|
|
140
|
+
> **静态 location 无需 `proxy_*` 指令**:文件由 nginx 读盘直接返回,不用
|
|
141
|
+
> `proxy_pass`,也不用 `proxy_read_timeout`——这与 code 的代理 location 形态不同属正常。
|
|
128
142
|
|
|
129
|
-
|
|
143
|
+
> **权限**:1Panel 站点要求 `index` 目录下所有文件/目录属主与属组为 `1000`(见
|
|
144
|
+
> 「网站目录」页提示,PHP 容器环境)。nginx worker 需可读该目录;zip 文件 644 /
|
|
145
|
+
> 目录 755 正常可读。若验证时拿 403,先查属主属组与 SELinux 上下文。
|
|
130
146
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
重载(改了容器挂载或环境变量时需重建 code-mcp;仅改文件无需重载 nginx):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
docker exec 1Panel-openresty-ih7J nginx -t
|
|
151
|
+
```
|
|
136
152
|
|
|
137
153
|
### 验证生效
|
|
138
154
|
|
|
@@ -156,7 +172,92 @@ location ^~ /ht32-bundles/ {
|
|
|
156
172
|
3. 浏览器/curl 拉该 URL:应得约 1.5 MB zip;`sha256sum` 与报告一致;
|
|
157
173
|
解压后 455 文件、`project/MDK_ARMv5/ht32f5-template.uvprojx` 在,UV4 编译 0E0W。
|
|
158
174
|
|
|
159
|
-
4. 确认 `ls /opt/ht32-bundles` 只有 `*.zip`(无残留临时目录)。
|
|
175
|
+
4. 确认 `ls /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles` 只有 `*.zip`(无残留临时目录)。
|
|
176
|
+
|
|
177
|
+
## 反馈与用量日志(P1-5,0.1.3+)
|
|
178
|
+
|
|
179
|
+
两条通道写入同一目录、按 UTC 日期分文件(JSON Lines):
|
|
180
|
+
|
|
181
|
+
| 通道 | 触发 | 记录内容 |
|
|
182
|
+
|------|------|----------|
|
|
183
|
+
| `usage_YYYY-MM-DD.jsonl` | **服务端自动**(middleware 拦截所有工具调用) | 工具名、参数原文(剔除 `limit`/`max_length`/`out_dir`)、耗时、成败、错误、客户端 session |
|
|
184
|
+
| `feedback_YYYY-MM-DD.jsonl` | **Agent 主动**(`submit_feedback` 工具) | 语义性问题:答错 / 知识库缺口 / 模板缺陷,含 `issue_type`、`model_id`、`tools_called` |
|
|
185
|
+
|
|
186
|
+
自动通道保证调用事实完整(不依赖 Agent 配合);反馈通道补 middleware
|
|
187
|
+
判断不了的语义问题。`issue_type` 枚举:`wrong_api` / `missing_info` /
|
|
188
|
+
`wrong_path` / `export_defect` / `unclear` / `other`。
|
|
189
|
+
|
|
190
|
+
### 环境变量
|
|
191
|
+
|
|
192
|
+
| 变量 | 默认 | 说明 |
|
|
193
|
+
|------|------|------|
|
|
194
|
+
| `CODE_LOG_DIR` | 空(= **整体关闭**,本地开发默认不落盘) | 日志写入目录,须可写 |
|
|
195
|
+
|
|
196
|
+
> **隐私**:参数按原文记录,`query`/`question` 可能含用户项目信息。
|
|
197
|
+
> 该目录只应挂在受控服务器上,**不要提交进仓库**(定时脚本是
|
|
198
|
+
> `git reset --hard` 镜像模式,写进仓库也会被覆盖丢失)。
|
|
199
|
+
|
|
200
|
+
### 服务器侧落地(2 步)
|
|
201
|
+
|
|
202
|
+
**第 1 步:宿主机建日志目录**
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
mkdir -p /opt/ht32-code-logs
|
|
206
|
+
chmod 755 /opt/ht32-code-logs
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**第 2 步:1Panel 给 code-mcp 容器补挂载与环境变量**
|
|
210
|
+
|
|
211
|
+
| 配置项 | 值 |
|
|
212
|
+
|--------|-----|
|
|
213
|
+
| 挂载 | `/opt/ht32-code-logs:/app/logs`(**不要加 `:ro`,需可写**) |
|
|
214
|
+
| 环境变量 | `CODE_LOG_DIR=/app/logs` |
|
|
215
|
+
|
|
216
|
+
完整配置(含现有项):
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
类型: uvx
|
|
220
|
+
启动命令: uvx ht32-code-mcp
|
|
221
|
+
输出类型: streamableHttp
|
|
222
|
+
流式传输路径:/ht32-code
|
|
223
|
+
协议版本: 2025-06-18
|
|
224
|
+
镜像: supercorp/supergateway:next-uvx
|
|
225
|
+
参数: --stateful --sessionTimeout 3600000
|
|
226
|
+
环境变量: CODE_ROOT=/app/code
|
|
227
|
+
BUNDLE_DIR=/app/bundles
|
|
228
|
+
BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles
|
|
229
|
+
CODE_LOG_DIR=/app/logs
|
|
230
|
+
挂载: /opt/ht32-code:/app/code
|
|
231
|
+
/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles:/app/bundles
|
|
232
|
+
/opt/ht32-code-logs:/app/logs
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### 验证生效
|
|
236
|
+
|
|
237
|
+
1. 调 `get_code_version`,应出现 `用量日志:启用(/app/logs)`。
|
|
238
|
+
仍显示「未配置」→ 环境变量没注入(查变量名 / 容器是否重建)。
|
|
239
|
+
|
|
240
|
+
2. 随便调几个工具,然后看日志:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
tail -3 /opt/ht32-code-logs/usage_$(date -u +%Y-%m-%d).jsonl
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
3. 按日期分析需求分布(示例:统计被请求的芯片型号,指导 P2-1 排优先级):
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
grep -h export_project_bundle /opt/ht32-code-logs/usage_*.jsonl \
|
|
250
|
+
| python3 -c "
|
|
251
|
+
import sys, json, collections
|
|
252
|
+
c = collections.Counter()
|
|
253
|
+
for l in sys.stdin:
|
|
254
|
+
c.update([json.loads(l)['args'].get('chip', '?')])
|
|
255
|
+
for k, v in c.most_common():
|
|
256
|
+
print(f'{v:5d} {k}')
|
|
257
|
+
"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
4. 清理策略由定时同步脚本第 7 步负责(`LOG_KEEP_DAYS=30`,与同步日志同口径)。
|
|
160
261
|
|
|
161
262
|
## PyPI 包发布流程
|
|
162
263
|
|
|
@@ -191,8 +292,9 @@ uv lock && uv build && uv publish
|
|
|
191
292
|
|---|------|------|------|
|
|
192
293
|
| 3.1 | 7 个 MCP 工具实现 | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
|
|
193
294
|
| 3.2 | 工具级测试 | `pytest` 36 用例全绿 | ✅ |
|
|
194
|
-
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.
|
|
295
|
+
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.2(2026-09-21,含 bundle 通道) | ✅ |
|
|
195
296
|
| 3.4 | 云端 git clone + 挂载 | `/opt/ht32-code:/app/code` | ✅ |
|
|
196
297
|
| 3.5 | 1Panel 部署(uvx + supergateway → streamableHttp stateful,镜像 next-uvx) | `https://mcp.s621.work/ht32-code` | ✅ |
|
|
197
|
-
| 3.6 | OpenResty 超时修复(`proxy_read_timeout 3600s`) | 修复 60s
|
|
198
|
-
| 3.7 | 云端 zip 下载通道(P1-3) |
|
|
298
|
+
| 3.6 | OpenResty 超时修复(`proxy_read_timeout 3600s`) | 修复 60s 空闲断连;2026-09-21 复核两端空闲 100s 后调用仍 200,未被 1Panel 回退 | ✅ |
|
|
299
|
+
| 3.7 | 云端 zip 下载通道(P1-3) | 服务器侧已上线:下载 1608157 字节 / SHA256 一致 / 解压 455 文件 / UV4 0E0W | ✅ |
|
|
300
|
+
| 3.8 | 定时同步脚本含 bundle 清理(P1-2) | `ht32-code-mcp-update.md` 第 6 步 `find … -mmin +60 -delete` | ✅ |
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
|
|
27
27
|
# 日志:按天归档 /opt/ht32-code-sync/ht32-code-sync-YYYYMMDD.log,自动清理 30 天前的旧日志
|
|
28
28
|
|
|
29
|
+
# - 顺带清理 bundle 下载目录的过期 zip(P1-3)
|
|
30
|
+
|
|
31
|
+
# (server 每次 export 前也会 sweep 一次,此处为兜底:
|
|
32
|
+
|
|
33
|
+
# 若一段时间无人调用 export,TTL 过期的 zip 不会被那次 sweep 清掉)
|
|
34
|
+
|
|
35
|
+
# - 顺带清理超期的用量日志 / 反馈 JSONL(P1-5)
|
|
36
|
+
|
|
37
|
+
# (保留 LOG_KEEP_DAYS 天,与同步日志同口径)
|
|
38
|
+
|
|
29
39
|
# ============================================================
|
|
30
40
|
|
|
31
41
|
set -e
|
|
@@ -37,6 +47,15 @@ LOG_FILE="$LOG_DIR/ht32-code-sync-$(date '+%Y%m%d').log"
|
|
|
37
47
|
LOG_KEEP_DAYS=30
|
|
38
48
|
INDEX_FILE="$CODE_DIR/.work/mcp-server/index/code_index.json"
|
|
39
49
|
|
|
50
|
+
# bundle 目录(站点 root 下子目录,OpenResty 直接静态提供)与保留时长
|
|
51
|
+
|
|
52
|
+
BUNDLE_DIR="/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles"
|
|
53
|
+
BUNDLE_TTL_MIN=60
|
|
54
|
+
|
|
55
|
+
# 用量日志 / 反馈目录(1Panel 挂载到容器 /app/logs)
|
|
56
|
+
|
|
57
|
+
CODE_LOG_DIR="/opt/ht32-code-logs"
|
|
58
|
+
|
|
40
59
|
# 日志目录(不存在则创建)
|
|
41
60
|
|
|
42
61
|
mkdir -p "$LOG_DIR"
|
|
@@ -101,4 +120,22 @@ fi
|
|
|
101
120
|
|
|
102
121
|
find "$LOG_DIR" -name "ht32-code-sync-*.log" -mtime +$LOG_KEEP_DAYS -delete 2>/dev/null || true
|
|
103
122
|
|
|
123
|
+
# 6. 清理 bundle 目录中超过 TTL 的 zip(兜底,与 BUNDLE_TTL_SECONDS 保持一致)
|
|
124
|
+
|
|
125
|
+
if [ -d "$BUNDLE_DIR" ]; then
|
|
126
|
+
SWEPT=$(find "$BUNDLE_DIR" -maxdepth 1 -name "*.zip" -mmin +$BUNDLE_TTL_MIN -print -delete 2>/dev/null | wc -l)
|
|
127
|
+
log "bundle 清理:删除 $SWEPT 个过期 zip"
|
|
128
|
+
else
|
|
129
|
+
log "bundle 目录不存在,跳过清理:$BUNDLE_DIR"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# 7. 清理超期的用量日志 / 反馈(同样保留 LOG_KEEP_DAYS 天)
|
|
133
|
+
|
|
134
|
+
if [ -d "$CODE_LOG_DIR" ]; then
|
|
135
|
+
OLD_LOGS=$(find "$CODE_LOG_DIR" -maxdepth 1 \( -name "usage_*.jsonl" -o -name "feedback_*.jsonl" \) -mtime +$LOG_KEEP_DAYS -print -delete 2>/dev/null | wc -l)
|
|
136
|
+
log "用量日志清理:删除 $OLD_LOGS 个过期文件"
|
|
137
|
+
else
|
|
138
|
+
log "用量日志目录不存在,跳过清理:$CODE_LOG_DIR"
|
|
139
|
+
fi
|
|
140
|
+
|
|
104
141
|
log "本次同步完成"
|
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import sys
|
|
10
10
|
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
|
11
11
|
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
12
13
|
|
|
13
14
|
from fastmcp import FastMCP
|
|
14
15
|
|
|
@@ -20,6 +21,7 @@ from .exporter import (
|
|
|
20
21
|
)
|
|
21
22
|
from .index import CodeIndex, normalize_series
|
|
22
23
|
from .knowledge import KnowledgeSearch
|
|
24
|
+
from .usage import UsageMiddleware, record_feedback
|
|
23
25
|
|
|
24
26
|
# 知识库根目录(ht32-code 仓库根;src/ht32_code_mcp → 上溯 4 级)
|
|
25
27
|
DEFAULT_CODE_ROOT = Path(__file__).resolve().parents[4]
|
|
@@ -41,6 +43,10 @@ BUNDLE_BASE_URL = os.environ.get("BUNDLE_BASE_URL", "").strip()
|
|
|
41
43
|
BUNDLE_TTL = _env_int("BUNDLE_TTL_SECONDS", DEFAULT_BUNDLE_TTL)
|
|
42
44
|
CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
|
|
43
45
|
|
|
46
|
+
# 用量日志与反馈目录(1Panel 挂载可写卷到 /app/logs)。为空则整体关闭——
|
|
47
|
+
# 本地开发默认不落盘。注意:日志含 query 原文,勿提交进仓库。
|
|
48
|
+
LOG_DIR = os.environ.get("CODE_LOG_DIR", "").strip()
|
|
49
|
+
|
|
44
50
|
index = CodeIndex(CODE_ROOT).load()
|
|
45
51
|
knowledge = KnowledgeSearch(CODE_ROOT)
|
|
46
52
|
exporter = ProjectExporter(CODE_ROOT)
|
|
@@ -66,10 +72,22 @@ _INSTRUCTIONS = """HT32 代码知识库(函数库/例程/工程模板),当
|
|
|
66
72
|
- 路径一律为仓库相对 posix 路径(raw/... 开头),get_file 直接可用
|
|
67
73
|
- 型号大小写自动归一(ht32f52352 = HT32F52352)
|
|
68
74
|
- 知识页查不到时不要编造,改查 list_libraries + get_file 原码
|
|
75
|
+
|
|
76
|
+
## 反馈
|
|
77
|
+
出现以下情况调 submit_feedback 记录,帮助改进本知识库:
|
|
78
|
+
- 你给的 API/宏/路径与实际编译结果矛盾,或用户指出你答案有误
|
|
79
|
+
- 知识库缺了用户需要的内容,你只能用推测回答
|
|
80
|
+
- 导出工程有缺陷(编译失败、型号参数错、缺文件)
|
|
81
|
+
务必附 model_id;submit_feedback 之外的调用已由服务端自动记入用量日志,无需你重复上报。
|
|
69
82
|
"""
|
|
70
83
|
|
|
71
84
|
mcp = FastMCP("ht32-code", instructions=_INSTRUCTIONS)
|
|
72
85
|
|
|
86
|
+
# 用量日志:服务端自动记录所有工具调用(CODE_LOG_DIR 为空则不启用)
|
|
87
|
+
if LOG_DIR:
|
|
88
|
+
mcp.add_middleware(UsageMiddleware(
|
|
89
|
+
LOG_DIR, mcp_version=_MCP_VERSION, code_version=index.data.get("code_version", "")))
|
|
90
|
+
|
|
73
91
|
|
|
74
92
|
# ================================================================ MCP Tools
|
|
75
93
|
|
|
@@ -100,6 +118,8 @@ def get_code_version() -> str:
|
|
|
100
118
|
("云端 zip 下载:启用(TTL "
|
|
101
119
|
f"{BUNDLE_TTL}s,{BUNDLE_BASE_URL})" if CLOUD_BUNDLE
|
|
102
120
|
else "云端 zip 下载:未配置(本地目录模式,export 需提供 out_dir)"),
|
|
121
|
+
(f"用量日志:启用({LOG_DIR})" if LOG_DIR
|
|
122
|
+
else "用量日志:未配置(CODE_LOG_DIR 为空,不落盘)"),
|
|
103
123
|
"",
|
|
104
124
|
"VERSION 文件内容:",
|
|
105
125
|
version_text,
|
|
@@ -346,6 +366,59 @@ def export_project_bundle(chip: str, out_dir: str = "") -> str:
|
|
|
346
366
|
return f"导出失败:{e}"
|
|
347
367
|
|
|
348
368
|
|
|
369
|
+
# ================================================================ Feedback
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@mcp.tool()
|
|
373
|
+
def submit_feedback(
|
|
374
|
+
question: str,
|
|
375
|
+
issue_type: Literal[
|
|
376
|
+
"wrong_api", "missing_info", "wrong_path", "export_defect", "unclear", "other"
|
|
377
|
+
] = "wrong_api",
|
|
378
|
+
answer: str = "",
|
|
379
|
+
correction: str = "",
|
|
380
|
+
details: str = "",
|
|
381
|
+
model_id: str = "",
|
|
382
|
+
tools_called: list[str] | None = None,
|
|
383
|
+
) -> str:
|
|
384
|
+
"""提交纠正反馈,改进 HT32 代码知识库。
|
|
385
|
+
|
|
386
|
+
何时调:你给的 API/宏/路径与实际编译矛盾、用户指出你答错、
|
|
387
|
+
知识库缺了用户需要的内容、导出工程有缺陷(编译失败/型号参数错/缺文件)。
|
|
388
|
+
|
|
389
|
+
注意:工具调用已由服务端自动记入用量日志,本工具只用于记录
|
|
390
|
+
上述**语义性问题**,不需要为每次调用提交。
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
question: 用户原始需求/问题(必填,尽量完整抄录)
|
|
394
|
+
issue_type: 问题类型,枚举上述其一
|
|
395
|
+
answer: 你给出的回答原文
|
|
396
|
+
correction: 你认为正确的信息或用户指出的正确内容
|
|
397
|
+
details: 补充上下文(如编译报错原文)
|
|
398
|
+
model_id: 你的模型标识,如 deepseek-v4-flash
|
|
399
|
+
tools_called: 本次调过的查询工具列表(不含 submit_feedback 自身)
|
|
400
|
+
"""
|
|
401
|
+
if not question or not question.strip():
|
|
402
|
+
return "错误:question 参数不能为空,请提供用户原始需求。"
|
|
403
|
+
|
|
404
|
+
path = record_feedback(
|
|
405
|
+
LOG_DIR,
|
|
406
|
+
question=question,
|
|
407
|
+
issue_type=issue_type,
|
|
408
|
+
answer=answer,
|
|
409
|
+
correction=correction,
|
|
410
|
+
details=details,
|
|
411
|
+
model_id=model_id,
|
|
412
|
+
tools_called=tools_called,
|
|
413
|
+
mcp_version=_MCP_VERSION,
|
|
414
|
+
code_version=index.data.get("code_version", ""),
|
|
415
|
+
)
|
|
416
|
+
if path is None:
|
|
417
|
+
return ("错误:反馈未能写入。服务端可能未配置 CODE_LOG_DIR,"
|
|
418
|
+
"或该目录不可写——请联系管理员。")
|
|
419
|
+
return f"反馈已记录,感谢!问题类型:{issue_type},文件:{path.name}"
|
|
420
|
+
|
|
421
|
+
|
|
349
422
|
# ================================================================ 入口
|
|
350
423
|
|
|
351
424
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""用量日志与反馈落盘。
|
|
2
|
+
|
|
3
|
+
两条采集通道,写入同一目录、按 UTC 日期分文件(JSON Lines):
|
|
4
|
+
|
|
5
|
+
- **tool_call**(服务端自动,`UsageMiddleware` 拦截所有工具调用):
|
|
6
|
+
记录工具名、参数原文、耗时、成败。不依赖 Agent 配合,数据完整。
|
|
7
|
+
- **feedback**(Agent 主动,`submit_feedback` 工具):
|
|
8
|
+
记录语义性问题(答错 / 知识库缺口 / 模板缺陷),中间件无法判断这些。
|
|
9
|
+
|
|
10
|
+
目录由 `CODE_LOG_DIR` 指定,为空则整体关闭(本地开发默认关)。
|
|
11
|
+
|
|
12
|
+
隐私:参数按原文记录(`query` 可能含用户项目信息),故日志目录只应挂在
|
|
13
|
+
受控服务器上,不要提交进仓库。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
24
|
+
|
|
25
|
+
# 不记录的参数:分页/长度控制项无分析价值;out_dir 可能含用户本地路径
|
|
26
|
+
ARG_NOISE = frozenset({"max_length", "limit", "out_dir"})
|
|
27
|
+
|
|
28
|
+
USAGE_PREFIX = "usage"
|
|
29
|
+
FEEDBACK_PREFIX = "feedback"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now() -> str:
|
|
33
|
+
return datetime.now(timezone.utc).isoformat()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_record(log_dir: str, prefix: str, record: dict[str, Any]) -> Path | None:
|
|
37
|
+
"""追加一条 JSONL 记录。log_dir 为空或写盘失败时返回 None(绝不因日志中断服务)。"""
|
|
38
|
+
if not log_dir:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
d = Path(log_dir)
|
|
42
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
path = d / f"{prefix}_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.jsonl"
|
|
44
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
45
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
46
|
+
return path
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
52
|
+
"""取客户端标识(内存调用等无 session 的场景返回空值)。"""
|
|
53
|
+
ctx = getattr(context, "fastmcp_context", None)
|
|
54
|
+
if ctx is None:
|
|
55
|
+
return {}
|
|
56
|
+
out: dict[str, str] = {}
|
|
57
|
+
for key in ("client_id", "session_id"):
|
|
58
|
+
val = getattr(ctx, key, None)
|
|
59
|
+
if val:
|
|
60
|
+
out[key] = str(val)
|
|
61
|
+
transport = getattr(ctx, "transport", None)
|
|
62
|
+
if transport:
|
|
63
|
+
out["transport"] = str(transport)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UsageMiddleware(Middleware):
|
|
68
|
+
"""把所有工具调用落成 JSONL(服务端自动采集)。"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, log_dir: str, mcp_version: str = "", code_version: str = "") -> None:
|
|
71
|
+
self.log_dir = log_dir
|
|
72
|
+
self.mcp_version = mcp_version
|
|
73
|
+
self.code_version = code_version
|
|
74
|
+
|
|
75
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
76
|
+
if not self.log_dir:
|
|
77
|
+
return await call_next(context)
|
|
78
|
+
|
|
79
|
+
args = dict(context.message.arguments or {})
|
|
80
|
+
recorded = {k: v for k, v in args.items() if k not in ARG_NOISE}
|
|
81
|
+
started = time.perf_counter()
|
|
82
|
+
error = ""
|
|
83
|
+
ok = True
|
|
84
|
+
try:
|
|
85
|
+
result = await call_next(context)
|
|
86
|
+
# 协议级错误标记;工具内以字符串返回的软失败不体现在此
|
|
87
|
+
if getattr(result, "is_error", False):
|
|
88
|
+
ok = False
|
|
89
|
+
return result
|
|
90
|
+
except Exception as e:
|
|
91
|
+
error = f"{type(e).__name__}: {e}" # 异常上抛,但不能漏记
|
|
92
|
+
ok = False
|
|
93
|
+
raise
|
|
94
|
+
finally:
|
|
95
|
+
record = {
|
|
96
|
+
"timestamp": _now(),
|
|
97
|
+
"kind": "tool_call",
|
|
98
|
+
"tool": context.message.name,
|
|
99
|
+
"args": recorded,
|
|
100
|
+
"ok": ok,
|
|
101
|
+
"duration_ms": round((time.perf_counter() - started) * 1000),
|
|
102
|
+
"error": error,
|
|
103
|
+
"mcp_version": self.mcp_version,
|
|
104
|
+
"code_version": self.code_version,
|
|
105
|
+
**_client_info(context),
|
|
106
|
+
}
|
|
107
|
+
write_record(self.log_dir, USAGE_PREFIX, record)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_feedback(
|
|
111
|
+
log_dir: str,
|
|
112
|
+
*,
|
|
113
|
+
question: str,
|
|
114
|
+
issue_type: str,
|
|
115
|
+
answer: str = "",
|
|
116
|
+
correction: str = "",
|
|
117
|
+
details: str = "",
|
|
118
|
+
model_id: str = "",
|
|
119
|
+
tools_called: list[str] | None = None,
|
|
120
|
+
mcp_version: str = "",
|
|
121
|
+
code_version: str = "",
|
|
122
|
+
) -> Path | None:
|
|
123
|
+
"""记录一条 Agent 主动提交的反馈。"""
|
|
124
|
+
record = {
|
|
125
|
+
"timestamp": _now(),
|
|
126
|
+
"kind": "feedback",
|
|
127
|
+
"question": question.strip(),
|
|
128
|
+
"issue_type": issue_type,
|
|
129
|
+
"answer": answer.strip(),
|
|
130
|
+
"correction": correction.strip(),
|
|
131
|
+
"details": details.strip(),
|
|
132
|
+
"model_id": model_id.strip(),
|
|
133
|
+
# 过滤 submit_feedback 自身,只保留查询类工具
|
|
134
|
+
"tools_called": [t for t in (tools_called or []) if t and t != "submit_feedback"],
|
|
135
|
+
"mcp_version": mcp_version,
|
|
136
|
+
"code_version": code_version,
|
|
137
|
+
}
|
|
138
|
+
return write_record(log_dir, FEEDBACK_PREFIX, record)
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
8
9
|
import re
|
|
9
10
|
import time
|
|
@@ -13,6 +14,7 @@ from pathlib import Path
|
|
|
13
14
|
import pytest
|
|
14
15
|
|
|
15
16
|
from ht32_code_mcp import server as srv
|
|
17
|
+
from ht32_code_mcp.usage import UsageMiddleware
|
|
16
18
|
|
|
17
19
|
FWLIB = "raw/HT32_STD_5xxxx_FWLib_V1.21.1"
|
|
18
20
|
|
|
@@ -301,6 +303,118 @@ def test_version_reports_bundle_mode(monkeypatch):
|
|
|
301
303
|
assert "云端 zip 下载:启用" in srv.get_code_version()
|
|
302
304
|
|
|
303
305
|
|
|
306
|
+
# ---------------------------------------------------------------- 反馈与用量日志
|
|
307
|
+
def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
|
|
308
|
+
monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
|
|
309
|
+
out = srv.submit_feedback(
|
|
310
|
+
question="HT32F52352 怎么配 GPIO 输出?",
|
|
311
|
+
issue_type="wrong_api",
|
|
312
|
+
answer="我给的 GPIO_DirectionConfig 参数顺序错了",
|
|
313
|
+
correction="应为 GPIO_DirectionConfig(GPIO_PORT, GPIO_PIN, GPIO_DIR)",
|
|
314
|
+
details="编译报错 too few arguments",
|
|
315
|
+
model_id="deepseek-v4-flash",
|
|
316
|
+
tools_called=["search_code_knowledge", "submit_feedback", "get_file"],
|
|
317
|
+
)
|
|
318
|
+
assert "反馈已记录" in out
|
|
319
|
+
f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
|
|
320
|
+
rec = json.loads(f.read_text(encoding="utf-8").splitlines()[0])
|
|
321
|
+
assert rec["kind"] == "feedback"
|
|
322
|
+
assert rec["question"] == "HT32F52352 怎么配 GPIO 输出?"
|
|
323
|
+
assert rec["issue_type"] == "wrong_api"
|
|
324
|
+
assert rec["model_id"] == "deepseek-v4-flash"
|
|
325
|
+
# submit_feedback 自身被过滤,只留查询类工具
|
|
326
|
+
assert rec["tools_called"] == ["search_code_knowledge", "get_file"]
|
|
327
|
+
assert rec["mcp_version"] and rec["code_version"]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_submit_feedback_empty_question(tmp_path, monkeypatch):
|
|
331
|
+
monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
|
|
332
|
+
assert "不能为空" in srv.submit_feedback(question=" ")
|
|
333
|
+
assert not (tmp_path / "logs").exists() # 未落盘
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_submit_feedback_without_log_dir(monkeypatch):
|
|
337
|
+
monkeypatch.setattr(srv, "LOG_DIR", "")
|
|
338
|
+
out = srv.submit_feedback(question="x")
|
|
339
|
+
assert "错误" in out and "CODE_LOG_DIR" in out
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_version_reports_log_mode(monkeypatch):
|
|
343
|
+
monkeypatch.setattr(srv, "LOG_DIR", "")
|
|
344
|
+
assert "用量日志:未配置" in srv.get_code_version()
|
|
345
|
+
monkeypatch.setattr(srv, "LOG_DIR", "/app/logs")
|
|
346
|
+
assert "用量日志:启用(/app/logs)" in srv.get_code_version()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_usage_middleware_records_calls(tmp_path):
|
|
350
|
+
"""服务端自动采集:成功与失败调用都落盘,噪声参数被剔除。"""
|
|
351
|
+
import asyncio
|
|
352
|
+
|
|
353
|
+
from fastmcp import Client, FastMCP
|
|
354
|
+
|
|
355
|
+
app = FastMCP("mw-test")
|
|
356
|
+
app.add_middleware(UsageMiddleware(str(tmp_path / "logs"),
|
|
357
|
+
mcp_version="0.1.3", code_version="v0.1.0"))
|
|
358
|
+
|
|
359
|
+
@app.tool()
|
|
360
|
+
def probe(chip: str, limit: int = 5, out_dir: str = "") -> str:
|
|
361
|
+
return "ok:" + chip
|
|
362
|
+
|
|
363
|
+
@app.tool()
|
|
364
|
+
def boom() -> str:
|
|
365
|
+
raise ValueError("deliberate")
|
|
366
|
+
|
|
367
|
+
async def run():
|
|
368
|
+
async with Client(app) as c:
|
|
369
|
+
await c.call_tool("probe", {"chip": "HT32F52352", "limit": 9,
|
|
370
|
+
"out_dir": "D:/x"})
|
|
371
|
+
with pytest.raises(Exception):
|
|
372
|
+
await c.call_tool("boom", {})
|
|
373
|
+
|
|
374
|
+
asyncio.run(run())
|
|
375
|
+
recs = [json.loads(l) for l in
|
|
376
|
+
next((tmp_path / "logs").glob("usage_*.jsonl"))
|
|
377
|
+
.read_text(encoding="utf-8").splitlines()]
|
|
378
|
+
assert len(recs) == 2
|
|
379
|
+
ok_rec, fail_rec = recs
|
|
380
|
+
assert ok_rec["kind"] == "tool_call" and ok_rec["tool"] == "probe"
|
|
381
|
+
# limit / out_dir 属噪声被剔除,业务参数保留
|
|
382
|
+
assert ok_rec["args"] == {"chip": "HT32F52352"}
|
|
383
|
+
assert ok_rec["ok"] is True and ok_rec["error"] == ""
|
|
384
|
+
assert ok_rec["duration_ms"] >= 0
|
|
385
|
+
assert ok_rec["mcp_version"] == "0.1.3"
|
|
386
|
+
assert fail_rec["tool"] == "boom" and fail_rec["ok"] is False
|
|
387
|
+
assert "deliberate" in fail_rec["error"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_usage_log_disabled_and_resilient(tmp_path):
|
|
391
|
+
"""log_dir 为空不落盘;目录不可写时调用仍成功(日志绝不中断服务)。"""
|
|
392
|
+
import asyncio
|
|
393
|
+
|
|
394
|
+
from fastmcp import Client, FastMCP
|
|
395
|
+
|
|
396
|
+
from ht32_code_mcp.usage import UsageMiddleware, write_record
|
|
397
|
+
|
|
398
|
+
assert write_record("", "usage", {"x": 1}) is None
|
|
399
|
+
|
|
400
|
+
# 用一个"父路径是文件"的 log_dir 制造 mkdir 失败
|
|
401
|
+
blocker = tmp_path / "blocker"
|
|
402
|
+
blocker.write_text("i am a file", encoding="utf-8")
|
|
403
|
+
app = FastMCP("mw-bad")
|
|
404
|
+
app.add_middleware(UsageMiddleware(str(blocker / "logs")))
|
|
405
|
+
|
|
406
|
+
@app.tool()
|
|
407
|
+
def probe() -> str:
|
|
408
|
+
return "ok"
|
|
409
|
+
|
|
410
|
+
async def run():
|
|
411
|
+
async with Client(app) as c:
|
|
412
|
+
r = await c.call_tool("probe", {})
|
|
413
|
+
assert not r.is_error
|
|
414
|
+
|
|
415
|
+
asyncio.run(run()) # 写盘失败不抛异常
|
|
416
|
+
|
|
417
|
+
|
|
304
418
|
# ---------------------------------------------------------------- 索引可重复构建
|
|
305
419
|
def test_index_rebuild_deterministic():
|
|
306
420
|
from ht32_code_mcp.index import CodeIndex
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|