ht32-code-mcp 0.1.2__tar.gz → 0.2.0__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.2.0}/DEPLOY.md +130 -28
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/PKG-INFO +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/ht32-code-mcp-update.md +37 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/pyproject.toml +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/server.py +77 -0
- ht32_code_mcp-0.2.0/src/ht32_code_mcp/usage.py +179 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/tests/test_tools.py +136 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/uv.lock +1 -1
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/.gitignore +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/.gitkeep +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/README.md +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/exporter.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.1.2 → ht32_code_mcp-0.2.0}/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
|
|
|
@@ -189,10 +290,11 @@ uv lock && uv build && uv publish
|
|
|
189
290
|
|
|
190
291
|
| # | 任务 | 说明 | 状态 |
|
|
191
292
|
|---|------|------|------|
|
|
192
|
-
| 3.1 | 7
|
|
193
|
-
| 3.2 | 工具级测试 | `pytest`
|
|
194
|
-
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.
|
|
293
|
+
| 3.1 | MCP 工具实现(7 检索/导出 + 1 反馈) | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
|
|
294
|
+
| 3.2 | 工具级测试 | `pytest` 42 用例全绿 | ✅ |
|
|
295
|
+
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.3(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 SERVICE_CODE, UsageMiddleware, record_feedback, resolve_log_dir
|
|
23
25
|
|
|
24
26
|
# 知识库根目录(ht32-code 仓库根;src/ht32_code_mcp → 上溯 4 级)
|
|
25
27
|
DEFAULT_CODE_ROOT = Path(__file__).resolve().parents[4]
|
|
@@ -41,6 +43,12 @@ 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
|
+
# 反馈与用量日志:FEEDBACK_ROOT(宿主机挂载的可写卷,与 wiki 同一套约定)
|
|
47
|
+
# 下按服务名分子目录,本服务写 <FEEDBACK_ROOT>/code/。
|
|
48
|
+
# 为空则整体关闭——本地开发默认不落盘。注意:日志含 query 原文,勿提交进仓库。
|
|
49
|
+
FEEDBACK_ROOT = os.environ.get("FEEDBACK_ROOT", "").strip()
|
|
50
|
+
LOG_DIR = resolve_log_dir(FEEDBACK_ROOT, SERVICE_CODE)
|
|
51
|
+
|
|
44
52
|
index = CodeIndex(CODE_ROOT).load()
|
|
45
53
|
knowledge = KnowledgeSearch(CODE_ROOT)
|
|
46
54
|
exporter = ProjectExporter(CODE_ROOT)
|
|
@@ -66,10 +74,23 @@ _INSTRUCTIONS = """HT32 代码知识库(函数库/例程/工程模板),当
|
|
|
66
74
|
- 路径一律为仓库相对 posix 路径(raw/... 开头),get_file 直接可用
|
|
67
75
|
- 型号大小写自动归一(ht32f52352 = HT32F52352)
|
|
68
76
|
- 知识页查不到时不要编造,改查 list_libraries + get_file 原码
|
|
77
|
+
|
|
78
|
+
## 反馈
|
|
79
|
+
出现以下情况调 submit_feedback 记录,帮助改进本知识库:
|
|
80
|
+
- 你给的 API/宏/路径与实际编译结果矛盾,或用户指出你答案有误
|
|
81
|
+
- 知识库缺了用户需要的内容,你只能用推测回答
|
|
82
|
+
- 导出工程有缺陷(编译失败、型号参数错、缺文件)
|
|
83
|
+
务必附 model_id;submit_feedback 之外的调用已由服务端自动记入用量日志,无需你重复上报。
|
|
69
84
|
"""
|
|
70
85
|
|
|
71
86
|
mcp = FastMCP("ht32-code", instructions=_INSTRUCTIONS)
|
|
72
87
|
|
|
88
|
+
# 用量日志:服务端自动记录所有工具调用(FEEDBACK_ROOT 为空则不启用)
|
|
89
|
+
if LOG_DIR:
|
|
90
|
+
mcp.add_middleware(UsageMiddleware(
|
|
91
|
+
LOG_DIR, SERVICE_CODE,
|
|
92
|
+
mcp_version=_MCP_VERSION, kb_version=index.data.get("code_version", "")))
|
|
93
|
+
|
|
73
94
|
|
|
74
95
|
# ================================================================ MCP Tools
|
|
75
96
|
|
|
@@ -100,6 +121,8 @@ def get_code_version() -> str:
|
|
|
100
121
|
("云端 zip 下载:启用(TTL "
|
|
101
122
|
f"{BUNDLE_TTL}s,{BUNDLE_BASE_URL})" if CLOUD_BUNDLE
|
|
102
123
|
else "云端 zip 下载:未配置(本地目录模式,export 需提供 out_dir)"),
|
|
124
|
+
(f"反馈与用量日志:启用({LOG_DIR})" if LOG_DIR
|
|
125
|
+
else "反馈与用量日志:未配置(FEEDBACK_ROOT 为空,不落盘)"),
|
|
103
126
|
"",
|
|
104
127
|
"VERSION 文件内容:",
|
|
105
128
|
version_text,
|
|
@@ -346,6 +369,60 @@ def export_project_bundle(chip: str, out_dir: str = "") -> str:
|
|
|
346
369
|
return f"导出失败:{e}"
|
|
347
370
|
|
|
348
371
|
|
|
372
|
+
# ================================================================ Feedback
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@mcp.tool()
|
|
376
|
+
def submit_feedback(
|
|
377
|
+
question: str,
|
|
378
|
+
issue_type: Literal[
|
|
379
|
+
"wrong_api", "missing_info", "wrong_path", "export_defect", "unclear", "other"
|
|
380
|
+
] = "wrong_api",
|
|
381
|
+
answer: str = "",
|
|
382
|
+
correction: str = "",
|
|
383
|
+
details: str = "",
|
|
384
|
+
model_id: str = "",
|
|
385
|
+
tools_called: list[str] | None = None,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""提交纠正反馈,改进 HT32 代码知识库。
|
|
388
|
+
|
|
389
|
+
何时调:你给的 API/宏/路径与实际编译矛盾、用户指出你答错、
|
|
390
|
+
知识库缺了用户需要的内容、导出工程有缺陷(编译失败/型号参数错/缺文件)。
|
|
391
|
+
|
|
392
|
+
注意:工具调用已由服务端自动记入用量日志,本工具只用于记录
|
|
393
|
+
上述**语义性问题**,不需要为每次调用提交。
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
question: 用户原始需求/问题(必填,尽量完整抄录)
|
|
397
|
+
issue_type: 问题类型,枚举上述其一
|
|
398
|
+
answer: 你给出的回答原文
|
|
399
|
+
correction: 你认为正确的信息或用户指出的正确内容
|
|
400
|
+
details: 补充上下文(如编译报错原文)
|
|
401
|
+
model_id: 你的模型标识,如 deepseek-v4-flash
|
|
402
|
+
tools_called: 本次调过的查询工具列表(不含 submit_feedback 自身)
|
|
403
|
+
"""
|
|
404
|
+
if not question or not question.strip():
|
|
405
|
+
return "错误:question 参数不能为空,请提供用户原始需求。"
|
|
406
|
+
|
|
407
|
+
path = record_feedback(
|
|
408
|
+
LOG_DIR,
|
|
409
|
+
SERVICE_CODE,
|
|
410
|
+
question=question,
|
|
411
|
+
issue_type=issue_type,
|
|
412
|
+
answer=answer,
|
|
413
|
+
correction=correction,
|
|
414
|
+
details=details,
|
|
415
|
+
model_id=model_id,
|
|
416
|
+
tools_called=tools_called,
|
|
417
|
+
mcp_version=_MCP_VERSION,
|
|
418
|
+
kb_version=index.data.get("code_version", ""),
|
|
419
|
+
)
|
|
420
|
+
if path is None:
|
|
421
|
+
return ("错误:反馈未能写入。服务端可能未配置 FEEDBACK_ROOT,"
|
|
422
|
+
"或该目录不可写——请联系管理员。")
|
|
423
|
+
return f"反馈已记录,感谢!问题类型:{issue_type},文件:{path.name}"
|
|
424
|
+
|
|
425
|
+
|
|
349
426
|
# ================================================================ 入口
|
|
350
427
|
|
|
351
428
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""反馈与用量日志:统一实现(ht32-wiki-mcp / ht32-code-mcp 共用同一份代码)。
|
|
2
|
+
|
|
3
|
+
> **本文件在两个仓库存有逐字节相同的副本**(`ht32-wiki` 与 `ht32-code` 各一份)。
|
|
4
|
+
> 修改时**必须同步另一仓库的同名文件**,并同步更新下方的 `_IMPL_VERSION`;
|
|
5
|
+
> 两仓各有一条测试校验该版本号与目录布局,防止只改一边造成漂移。
|
|
6
|
+
|
|
7
|
+
两条采集通道,写入同一目录树:
|
|
8
|
+
|
|
9
|
+
- **tool_call**(`UsageMiddleware`,服务端自动):拦截所有工具调用,
|
|
10
|
+
记录工具名、参数原文、耗时、成败。不依赖 Agent 配合,数据完整。
|
|
11
|
+
- **feedback**(`record_feedback`,由各 server 的 `submit_feedback` 工具调用):
|
|
12
|
+
记录 Agent 主动上报的语义性问题,middleware 无法判断这些。
|
|
13
|
+
|
|
14
|
+
目录布局(同根 + 服务子目录):
|
|
15
|
+
|
|
16
|
+
<FEEDBACK_ROOT>/
|
|
17
|
+
├─ wiki/
|
|
18
|
+
│ ├─ usage_YYYY-MM-DD.jsonl
|
|
19
|
+
│ └─ feedback_YYYY-MM-DD.jsonl
|
|
20
|
+
└─ code/
|
|
21
|
+
├─ usage_YYYY-MM-DD.jsonl
|
|
22
|
+
└─ feedback_YYYY-MM-DD.jsonl
|
|
23
|
+
|
|
24
|
+
两个服务挂同一个宿主机目录,各写自己的子目录——清理、备份、查看都是
|
|
25
|
+
单一入口。每条记录另带 `service` 字段作双保险。
|
|
26
|
+
|
|
27
|
+
隐私:参数按原文记录(`query` / `question` 可能含用户项目信息),目录只应
|
|
28
|
+
挂在受控服务器上,不要提交进仓库。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import time
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
39
|
+
|
|
40
|
+
# 实现版本号:两仓库副本必须一致(各仓测试会校验,防止只改一边造成漂移)
|
|
41
|
+
_IMPL_VERSION = "1.0.0"
|
|
42
|
+
|
|
43
|
+
# 服务名 → 子目录名。新增服务在此登记。
|
|
44
|
+
SERVICE_WIKI = "wiki"
|
|
45
|
+
SERVICE_CODE = "code"
|
|
46
|
+
|
|
47
|
+
# 不记录的工具参数:分页/长度控制项无分析价值;out_dir 可能含用户本地路径
|
|
48
|
+
ARG_NOISE = frozenset({"limit", "max_length", "out_dir"})
|
|
49
|
+
|
|
50
|
+
USAGE_PREFIX = "usage"
|
|
51
|
+
FEEDBACK_PREFIX = "feedback"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _now() -> str:
|
|
55
|
+
return datetime.now(timezone.utc).isoformat()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_log_dir(feedback_root: str, service: str) -> str:
|
|
59
|
+
"""由反馈根目录 + 服务名算出该服务的日志目录。
|
|
60
|
+
|
|
61
|
+
feedback_root 为空时返回空字符串 = 关闭(本地开发默认不落盘)。
|
|
62
|
+
始终用 '/' 拼接(Windows 亦接受正斜杠),避免同一条配置在不同平台
|
|
63
|
+
产生不同字符串、影响日志路径的可比对性。
|
|
64
|
+
"""
|
|
65
|
+
root = (feedback_root or "").strip().rstrip("/")
|
|
66
|
+
svc = (service or "").strip().strip("/")
|
|
67
|
+
if not root:
|
|
68
|
+
return ""
|
|
69
|
+
return f"{root}/{svc}" if svc else root
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_record(log_dir: str, prefix: str, record: dict[str, Any]) -> Path | None:
|
|
73
|
+
"""追加一条 JSONL 记录。log_dir 为空或写盘失败时返回 None(绝不因日志中断服务)。"""
|
|
74
|
+
if not log_dir:
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
d = Path(log_dir)
|
|
78
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
path = d / f"{prefix}_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.jsonl"
|
|
80
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
81
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
82
|
+
return path
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _client_info(context: MiddlewareContext) -> dict[str, str]:
|
|
88
|
+
"""取客户端标识(内存调用等无 session 的场景返回空值)。"""
|
|
89
|
+
ctx = getattr(context, "fastmcp_context", None)
|
|
90
|
+
if ctx is None:
|
|
91
|
+
return {}
|
|
92
|
+
out: dict[str, str] = {}
|
|
93
|
+
for key in ("client_id", "session_id"):
|
|
94
|
+
val = getattr(ctx, key, None)
|
|
95
|
+
if val:
|
|
96
|
+
out[key] = str(val)
|
|
97
|
+
transport = getattr(ctx, "transport", None)
|
|
98
|
+
if transport:
|
|
99
|
+
out["transport"] = str(transport)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UsageMiddleware(Middleware):
|
|
104
|
+
"""把所有工具调用落成 JSONL(服务端自动采集)。"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, log_dir: str, service: str,
|
|
107
|
+
mcp_version: str = "", kb_version: str = "") -> None:
|
|
108
|
+
self.log_dir = log_dir
|
|
109
|
+
self.service = service
|
|
110
|
+
self.mcp_version = mcp_version
|
|
111
|
+
self.kb_version = kb_version
|
|
112
|
+
|
|
113
|
+
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
|
114
|
+
if not self.log_dir:
|
|
115
|
+
return await call_next(context)
|
|
116
|
+
|
|
117
|
+
args = dict(context.message.arguments or {})
|
|
118
|
+
recorded = {k: v for k, v in args.items() if k not in ARG_NOISE}
|
|
119
|
+
started = time.perf_counter()
|
|
120
|
+
error = ""
|
|
121
|
+
ok = True
|
|
122
|
+
try:
|
|
123
|
+
result = await call_next(context)
|
|
124
|
+
# 协议级错误标记;工具内以字符串返回的软失败不体现在此
|
|
125
|
+
if getattr(result, "is_error", False):
|
|
126
|
+
ok = False
|
|
127
|
+
return result
|
|
128
|
+
except Exception as e:
|
|
129
|
+
error = f"{type(e).__name__}: {e}" # 异常上抛,但不能漏记
|
|
130
|
+
ok = False
|
|
131
|
+
raise
|
|
132
|
+
finally:
|
|
133
|
+
record = {
|
|
134
|
+
"timestamp": _now(),
|
|
135
|
+
"kind": "tool_call",
|
|
136
|
+
"service": self.service,
|
|
137
|
+
"tool": context.message.name,
|
|
138
|
+
"args": recorded,
|
|
139
|
+
"ok": ok,
|
|
140
|
+
"duration_ms": round((time.perf_counter() - started) * 1000),
|
|
141
|
+
"error": error,
|
|
142
|
+
"mcp_version": self.mcp_version,
|
|
143
|
+
"kb_version": self.kb_version,
|
|
144
|
+
**_client_info(context),
|
|
145
|
+
}
|
|
146
|
+
write_record(self.log_dir, USAGE_PREFIX, record)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_feedback(
|
|
150
|
+
log_dir: str,
|
|
151
|
+
service: str,
|
|
152
|
+
*,
|
|
153
|
+
question: str,
|
|
154
|
+
issue_type: str,
|
|
155
|
+
answer: str = "",
|
|
156
|
+
correction: str = "",
|
|
157
|
+
details: str = "",
|
|
158
|
+
model_id: str = "",
|
|
159
|
+
tools_called: list[str] | None = None,
|
|
160
|
+
mcp_version: str = "",
|
|
161
|
+
kb_version: str = "",
|
|
162
|
+
) -> Path | None:
|
|
163
|
+
"""记录一条 Agent 主动提交的反馈。"""
|
|
164
|
+
record = {
|
|
165
|
+
"timestamp": _now(),
|
|
166
|
+
"kind": "feedback",
|
|
167
|
+
"service": service,
|
|
168
|
+
"question": question.strip(),
|
|
169
|
+
"issue_type": issue_type,
|
|
170
|
+
"answer": answer.strip(),
|
|
171
|
+
"correction": correction.strip(),
|
|
172
|
+
"details": details.strip(),
|
|
173
|
+
"model_id": model_id.strip(),
|
|
174
|
+
# 过滤 submit_feedback 自身,只保留查询类工具
|
|
175
|
+
"tools_called": [t for t in (tools_called or []) if t and t != "submit_feedback"],
|
|
176
|
+
"mcp_version": mcp_version,
|
|
177
|
+
"kb_version": kb_version,
|
|
178
|
+
}
|
|
179
|
+
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,140 @@ def test_version_reports_bundle_mode(monkeypatch):
|
|
|
301
303
|
assert "云端 zip 下载:启用" in srv.get_code_version()
|
|
302
304
|
|
|
303
305
|
|
|
306
|
+
# ---------------------------------------------------------------- 反馈与用量日志
|
|
307
|
+
def test_usage_impl_version_pinned():
|
|
308
|
+
"""两仓库的 usage.py 是逐字节相同副本,实现版本号用于防止只改一边。"""
|
|
309
|
+
from ht32_code_mcp import usage
|
|
310
|
+
assert usage._IMPL_VERSION == "1.0.0"
|
|
311
|
+
assert usage.SERVICE_CODE == "code"
|
|
312
|
+
assert usage.SERVICE_WIKI == "wiki"
|
|
313
|
+
# 噪声参数含 out_dir(code 特有:可能含用户本地路径)
|
|
314
|
+
assert {"limit", "max_length", "out_dir"} <= usage.ARG_NOISE
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_resolve_log_dir_layout():
|
|
318
|
+
from ht32_code_mcp.usage import resolve_log_dir
|
|
319
|
+
# 同根 + 服务子目录
|
|
320
|
+
assert resolve_log_dir("/app/feedback", "code") == "/app/feedback/code"
|
|
321
|
+
assert resolve_log_dir("/app/feedback/", "code") == "/app/feedback/code"
|
|
322
|
+
# 根未配置 = 关闭
|
|
323
|
+
assert resolve_log_dir("", "code") == ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
|
|
327
|
+
monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
|
|
328
|
+
out = srv.submit_feedback(
|
|
329
|
+
question="HT32F52352 怎么配 GPIO 输出?",
|
|
330
|
+
issue_type="wrong_api",
|
|
331
|
+
answer="我给的 GPIO_DirectionConfig 参数顺序错了",
|
|
332
|
+
correction="应为 GPIO_DirectionConfig(GPIO_PORT, GPIO_PIN, GPIO_DIR)",
|
|
333
|
+
details="编译报错 too few arguments",
|
|
334
|
+
model_id="deepseek-v4-flash",
|
|
335
|
+
tools_called=["search_code_knowledge", "submit_feedback", "get_file"],
|
|
336
|
+
)
|
|
337
|
+
assert "反馈已记录" in out
|
|
338
|
+
f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
|
|
339
|
+
rec = json.loads(f.read_text(encoding="utf-8").splitlines()[0])
|
|
340
|
+
assert rec["kind"] == "feedback"
|
|
341
|
+
assert rec["service"] == "code"
|
|
342
|
+
assert rec["question"] == "HT32F52352 怎么配 GPIO 输出?"
|
|
343
|
+
assert rec["issue_type"] == "wrong_api"
|
|
344
|
+
assert rec["model_id"] == "deepseek-v4-flash"
|
|
345
|
+
# submit_feedback 自身被过滤,只留查询类工具
|
|
346
|
+
assert rec["tools_called"] == ["search_code_knowledge", "get_file"]
|
|
347
|
+
assert rec["mcp_version"] and rec["kb_version"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_submit_feedback_empty_question(tmp_path, monkeypatch):
|
|
351
|
+
monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
|
|
352
|
+
assert "不能为空" in srv.submit_feedback(question=" ")
|
|
353
|
+
assert not (tmp_path / "logs").exists() # 未落盘
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_submit_feedback_without_log_dir(monkeypatch):
|
|
357
|
+
monkeypatch.setattr(srv, "LOG_DIR", "")
|
|
358
|
+
out = srv.submit_feedback(question="x")
|
|
359
|
+
assert "错误" in out and "FEEDBACK_ROOT" in out
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_version_reports_log_mode(monkeypatch):
|
|
363
|
+
monkeypatch.setattr(srv, "LOG_DIR", "")
|
|
364
|
+
assert "反馈与用量日志:未配置" in srv.get_code_version()
|
|
365
|
+
monkeypatch.setattr(srv, "LOG_DIR", "/app/feedback/code")
|
|
366
|
+
assert "反馈与用量日志:启用(/app/feedback/code)" in srv.get_code_version()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_usage_middleware_records_calls(tmp_path):
|
|
370
|
+
"""服务端自动采集:成功与失败调用都落盘,噪声参数被剔除。"""
|
|
371
|
+
import asyncio
|
|
372
|
+
|
|
373
|
+
from fastmcp import Client, FastMCP
|
|
374
|
+
|
|
375
|
+
app = FastMCP("mw-test")
|
|
376
|
+
app.add_middleware(UsageMiddleware(str(tmp_path / "logs"), "code",
|
|
377
|
+
mcp_version="0.1.4", kb_version="v0.1.0"))
|
|
378
|
+
|
|
379
|
+
@app.tool()
|
|
380
|
+
def probe(chip: str, limit: int = 5, out_dir: str = "") -> str:
|
|
381
|
+
return "ok:" + chip
|
|
382
|
+
|
|
383
|
+
@app.tool()
|
|
384
|
+
def boom() -> str:
|
|
385
|
+
raise ValueError("deliberate")
|
|
386
|
+
|
|
387
|
+
async def run():
|
|
388
|
+
async with Client(app) as c:
|
|
389
|
+
await c.call_tool("probe", {"chip": "HT32F52352", "limit": 9,
|
|
390
|
+
"out_dir": "D:/x"})
|
|
391
|
+
with pytest.raises(Exception):
|
|
392
|
+
await c.call_tool("boom", {})
|
|
393
|
+
|
|
394
|
+
asyncio.run(run())
|
|
395
|
+
recs = [json.loads(l) for l in
|
|
396
|
+
next((tmp_path / "logs").glob("usage_*.jsonl"))
|
|
397
|
+
.read_text(encoding="utf-8").splitlines()]
|
|
398
|
+
assert len(recs) == 2
|
|
399
|
+
ok_rec, fail_rec = recs
|
|
400
|
+
assert ok_rec["kind"] == "tool_call" and ok_rec["tool"] == "probe"
|
|
401
|
+
assert ok_rec["service"] == "code"
|
|
402
|
+
# limit / out_dir 属噪声被剔除,业务参数保留
|
|
403
|
+
assert ok_rec["args"] == {"chip": "HT32F52352"}
|
|
404
|
+
assert ok_rec["ok"] is True and ok_rec["error"] == ""
|
|
405
|
+
assert ok_rec["duration_ms"] >= 0
|
|
406
|
+
assert ok_rec["mcp_version"] == "0.1.4"
|
|
407
|
+
assert ok_rec["kb_version"] == "v0.1.0"
|
|
408
|
+
assert fail_rec["tool"] == "boom" and fail_rec["ok"] is False
|
|
409
|
+
assert "deliberate" in fail_rec["error"]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_usage_log_disabled_and_resilient(tmp_path):
|
|
413
|
+
"""log_dir 为空不落盘;目录不可写时调用仍成功(日志绝不中断服务)。"""
|
|
414
|
+
import asyncio
|
|
415
|
+
|
|
416
|
+
from fastmcp import Client, FastMCP
|
|
417
|
+
|
|
418
|
+
from ht32_code_mcp.usage import write_record
|
|
419
|
+
|
|
420
|
+
assert write_record("", "usage", {"x": 1}) is None
|
|
421
|
+
|
|
422
|
+
# 用一个"父路径是文件"的 log_dir 制造 mkdir 失败
|
|
423
|
+
blocker = tmp_path / "blocker"
|
|
424
|
+
blocker.write_text("i am a file", encoding="utf-8")
|
|
425
|
+
app = FastMCP("mw-bad")
|
|
426
|
+
app.add_middleware(UsageMiddleware(str(blocker / "logs"), "code"))
|
|
427
|
+
|
|
428
|
+
@app.tool()
|
|
429
|
+
def probe() -> str:
|
|
430
|
+
return "ok"
|
|
431
|
+
|
|
432
|
+
async def run():
|
|
433
|
+
async with Client(app) as c:
|
|
434
|
+
r = await c.call_tool("probe", {})
|
|
435
|
+
assert not r.is_error
|
|
436
|
+
|
|
437
|
+
asyncio.run(run()) # 写盘失败不抛异常
|
|
438
|
+
|
|
439
|
+
|
|
304
440
|
# ---------------------------------------------------------------- 索引可重复构建
|
|
305
441
|
def test_index_rebuild_deterministic():
|
|
306
442
|
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
|