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.
@@ -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 加静态 location**
130
+ **第 3 步:OpenResty 无需任何配置**
115
131
 
116
- 配置里已有 `include /www/sites/mcp.s621.work/proxy/*.conf;`(server 级),
117
- 在该目录下新建 `bundles.conf`:
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
- ```nginx
120
- location ^~ /ht32-bundles/ {
121
- alias /opt/ht32-bundles/;
122
- autoindex off; # 禁止目录列举(防 token 泄露面扩大)
123
- add_header Cache-Control "no-store";
124
- types { application/zip zip; }
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
- 改完 `nginx -t && nginx -s reload`。
143
+ > **权限**:1Panel 站点要求 `index` 目录下所有文件/目录属主与属组为 `1000`(见
144
+ > 「网站目录」页提示,PHP 容器环境)。nginx worker 需可读该目录;zip 文件 644 /
145
+ > 目录 755 正常可读。若验证时拿 403,先查属主属组与 SELinux 上下文。
130
146
 
131
- > **注意(容器化 OpenResty)**:若 OpenResty 跑在容器里而非宿主机原生进程,
132
- > `/opt/ht32-bundles` 还必须**同时挂进 OpenResty 容器**(同一宿主机路径),
133
- > 否则 nginx 读到的是容器内空目录,下载必然 404。
134
- > 现有 `proxy_pass http://127.0.0.1:8001/ht32-code` 能通,说明二者网络可达;
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.1(2026-09-20);0.1.2 为 bundle 通道版本 | ✅ |
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) | 代码就绪并端到端验证;**服务器侧 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` | ✅ |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -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 "本次同步完成"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -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
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.1.2"
443
+ version = "0.1.3"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
File without changes
File without changes
File without changes