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.
@@ -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
 
@@ -189,10 +290,11 @@ uv lock && uv build && uv publish
189
290
 
190
291
  | # | 任务 | 说明 | 状态 |
191
292
  |---|------|------|------|
192
- | 3.1 | 7 个 MCP 工具实现 | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
193
- | 3.2 | 工具级测试 | `pytest` 36 用例全绿 | ✅ |
194
- | 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.1(2026-09-20);0.1.2 为 bundle 通道版本 | ✅ |
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) | 代码就绪并端到端验证;**服务器侧 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.2.0
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.2.0"
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 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
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.1.2"
443
+ version = "0.2.0"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
File without changes
File without changes
File without changes