ht32-code-mcp 0.1.3__tar.gz → 0.2.1__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.
@@ -174,46 +174,61 @@ docker exec 1Panel-openresty-ih7J nginx -t
174
174
 
175
175
  4. 确认 `ls /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles` 只有 `*.zip`(无残留临时目录)。
176
176
 
177
- ## 反馈与用量日志(P1-5,0.1.3+)
177
+ ## 反馈与用量日志(P1-5,0.2.0+)
178
178
 
179
- 两条通道写入同一目录、按 UTC 日期分文件(JSON Lines):
179
+ > **与 ht32-wiki-mcp 统一**:两服务使用同一套实现(`usage.py`,两仓库逐字节
180
+ > 相同的副本)、同一套目录约定(同根 + 服务子目录)、同一套字段。
181
+ > 本服务写 `<FEEDBACK_ROOT>/code/`,wiki 写 `<FEEDBACK_ROOT>/wiki/`。
180
182
 
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` |
183
+ 两条通道,按 UTC 日期分文件(JSON Lines):
184
+
185
+ | 通道 | 文件 | 触发 | 记录内容 |
186
+ |------|------|------|----------|
187
+ | 用量日志 | `usage_*.jsonl` | **服务端自动**(middleware 拦截所有工具调用) | 工具名、参数原文(剔除 `limit`/`max_length`/`out_dir`)、耗时、成败、错误、客户端 session |
188
+ | 反馈 | `feedback_*.jsonl` | **Agent 主动**(`submit_feedback` 工具) | 语义性问题:API 错 / 缺口 / 路径错 / 模板缺陷 |
185
189
 
186
- 自动通道保证调用事实完整(不依赖 Agent 配合);反馈通道补 middleware
187
- 判断不了的语义问题。`issue_type` 枚举:`wrong_api` / `missing_info` /
188
- `wrong_path` / `export_defect` / `unclear` / `other`。
190
+ 自动通道保证调用事实完整(不依赖 Agent 配合),用于追踪用户需求与生成的模板;
191
+ 反馈通道补 middleware 判断不了的语义问题。每条记录都带 `service` 字段
192
+ (`code`),便于两服务日志合并分析。
193
+
194
+ ### 目录布局
195
+
196
+ ```
197
+ /opt/ht32-mcp-feedback/ ← 宿主机单一入口,两个容器挂同一卷
198
+ ├─ wiki/ ← ht32-wiki 容器写这里
199
+ └─ code/ ← ht32-code 容器写这里(本服务)
200
+ ├─ usage_2026-09-21.jsonl
201
+ └─ feedback_2026-09-21.jsonl
202
+ ```
189
203
 
190
204
  ### 环境变量
191
205
 
192
206
  | 变量 | 默认 | 说明 |
193
207
  |------|------|------|
194
- | `CODE_LOG_DIR` | 空(= **整体关闭**,本地开发默认不落盘) | 日志写入目录,须可写 |
208
+ | `FEEDBACK_ROOT` | 空(= **关闭**,本地开发默认不落盘) | 日志根目录,服务名子目录自动拼接 |
209
+ | `CODE_LOG_DIR` | — | 已废弃,改用 `FEEDBACK_ROOT` |
195
210
 
196
- > **隐私**:参数按原文记录,`query`/`question` 可能含用户项目信息。
211
+ > **隐私**:参数按原文记录,`query` / `question` 可能含用户项目信息。
197
212
  > 该目录只应挂在受控服务器上,**不要提交进仓库**(定时脚本是
198
213
  > `git reset --hard` 镜像模式,写进仓库也会被覆盖丢失)。
199
214
 
200
- ### 服务器侧落地(2 步)
215
+ ### 服务器侧落地
201
216
 
202
- **第 1 步:宿主机建日志目录**
217
+ **第 1 步:宿主机建目录**
203
218
 
204
219
  ```bash
205
- mkdir -p /opt/ht32-code-logs
206
- chmod 755 /opt/ht32-code-logs
220
+ mkdir -p /opt/ht32-mcp-feedback
221
+ chmod 755 /opt/ht32-mcp-feedback
207
222
  ```
208
223
 
209
- **第 2 步:1Panel 给 code-mcp 容器补挂载与环境变量**
224
+ **第 2 步:两个 MCP 容器都挂同一卷**
210
225
 
211
- | 配置项 | 值 |
212
- |--------|-----|
213
- | 挂载 | `/opt/ht32-code-logs:/app/logs`(**不要加 `:ro`,需可写**) |
214
- | 环境变量 | `CODE_LOG_DIR=/app/logs` |
226
+ | 服务 | 挂载 | 环境变量 |
227
+ |------|------|----------|
228
+ | ht32-wiki-mcp | `/opt/ht32-mcp-feedback:/app/feedback` | `FEEDBACK_ROOT=/app/feedback` |
229
+ | ht32-code-mcp | `/opt/ht32-mcp-feedback:/app/feedback` | `FEEDBACK_ROOT=/app/feedback` |
215
230
 
216
- 完整配置(含现有项):
231
+ 完整配置(code 侧,含现有项):
217
232
 
218
233
  ```
219
234
  类型: uvx
@@ -226,38 +241,37 @@ chmod 755 /opt/ht32-code-logs
226
241
  环境变量: CODE_ROOT=/app/code
227
242
  BUNDLE_DIR=/app/bundles
228
243
  BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles
229
- CODE_LOG_DIR=/app/logs
244
+ FEEDBACK_ROOT=/app/feedback
230
245
  挂载: /opt/ht32-code:/app/code
231
246
  /opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles:/app/bundles
232
- /opt/ht32-code-logs:/app/logs
247
+ /opt/ht32-mcp-feedback:/app/feedback
233
248
  ```
234
249
 
235
- ### 验证生效
250
+ ### 验证
236
251
 
237
- 1. 调 `get_code_version`,应出现 `用量日志:启用(/app/logs)`。
252
+ 1. 调 `get_code_version`,应出现
253
+ `反馈与用量日志:启用(/app/feedback/code)`。
238
254
  仍显示「未配置」→ 环境变量没注入(查变量名 / 容器是否重建)。
239
255
 
240
256
  2. 随便调几个工具,然后看日志:
241
257
 
242
258
  ```bash
243
- tail -3 /opt/ht32-code-logs/usage_$(date -u +%Y-%m-%d).jsonl
259
+ tail -3 /opt/ht32-mcp-feedback/code/usage_$(date -u +%Y-%m-%d).jsonl
244
260
  ```
245
261
 
246
- 3. 按日期分析需求分布(示例:统计被请求的芯片型号,指导 P2-1 排优先级):
262
+ 3. 统计被请求的芯片型号(指导 P2-1 排优先级)——用 ht32-skills 的分析工具:
247
263
 
248
264
  ```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
- "
265
+ # 克隆 ht32-skills 后(或用本地副本)
266
+ python ht32-skills/scripts/analyze_feedback.py /opt/ht32-mcp-feedback
258
267
  ```
259
268
 
260
- 4. 清理策略由定时同步脚本第 7 步负责(`LOG_KEEP_DAYS=30`,与同步日志同口径)。
269
+ 该工具(零依赖,仅标准库)合并 wiki + code 两服务日志,输出需求信号、
270
+ 调用分布与失败、反馈类型、耗时 p50/p95、最近失败与反馈明细;支持
271
+ `--days N` / `--service` / `--json`。详见 ht32-skills 的 README。
272
+
273
+ 4. 清理策略由定时同步脚本第 7 步负责(`LOG_KEEP_DAYS=30`,与同步日志同口径,
274
+ 目录变量为 `FEEDBACK_ROOT`)。
261
275
 
262
276
  ## PyPI 包发布流程
263
277
 
@@ -290,9 +304,9 @@ uv lock && uv build && uv publish
290
304
 
291
305
  | # | 任务 | 说明 | 状态 |
292
306
  |---|------|------|------|
293
- | 3.1 | 7 个 MCP 工具实现 | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
294
- | 3.2 | 工具级测试 | `pytest` 36 用例全绿 | ✅ |
295
- | 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.2(2026-09-21,含 bundle 通道) | ✅ |
307
+ | 3.1 | MCP 工具实现(7 检索/导出 + 1 反馈) | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
308
+ | 3.2 | 工具级测试 | `pytest` 44 用例全绿 | ✅ |
309
+ | 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.2.0(2026-09-21,含 bundle 通道 + 统一反馈/用量日志) | ✅ |
296
310
  | 3.4 | 云端 git clone + 挂载 | `/opt/ht32-code:/app/code` | ✅ |
297
311
  | 3.5 | 1Panel 部署(uvx + supergateway → streamableHttp stateful,镜像 next-uvx) | `https://mcp.s621.work/ht32-code` | ✅ |
298
312
  | 3.6 | OpenResty 超时修复(`proxy_read_timeout 3600s`) | 修复 60s 空闲断连;2026-09-21 复核两端空闲 100s 后调用仍 200,未被 1Panel 回退 | ✅ |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ht32-code-mcp
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出
5
5
  License-Expression: MIT
6
6
  Keywords: firmware,holtek,ht32,mcp,mcu
@@ -52,9 +52,9 @@ INDEX_FILE="$CODE_DIR/.work/mcp-server/index/code_index.json"
52
52
  BUNDLE_DIR="/opt/1panel/www/sites/mcp.s621.work/index/ht32-bundles"
53
53
  BUNDLE_TTL_MIN=60
54
54
 
55
- # 用量日志 / 反馈目录(1Panel 挂载到容器 /app/logs)
55
+ # 用量日志 / 反馈目录(1Panel 挂载到容器 /app/feedback,与 wiki 同一卷)
56
56
 
57
- CODE_LOG_DIR="/opt/ht32-code-logs"
57
+ FEEDBACK_DIR="/opt/ht32-mcp-feedback"
58
58
 
59
59
  # 日志目录(不存在则创建)
60
60
 
@@ -131,11 +131,13 @@ fi
131
131
 
132
132
  # 7. 清理超期的用量日志 / 反馈(同样保留 LOG_KEEP_DAYS 天)
133
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)
134
+ # 目录为同根 + 服务子目录(wiki/ 与 code/),故递归找两层下的 jsonl
135
+
136
+ if [ -d "$FEEDBACK_DIR" ]; then
137
+ OLD_LOGS=$(find "$FEEDBACK_DIR" -mindepth 2 -maxdepth 2 \( -name "usage_*.jsonl" -o -name "feedback_*.jsonl" \) -mtime +$LOG_KEEP_DAYS -print -delete 2>/dev/null | wc -l)
136
138
  log "用量日志清理:删除 $OLD_LOGS 个过期文件"
137
139
  else
138
- log "用量日志目录不存在,跳过清理:$CODE_LOG_DIR"
140
+ log "用量日志目录不存在,跳过清理:$FEEDBACK_DIR"
139
141
  fi
140
142
 
141
143
  log "本次同步完成"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ht32-code-mcp"
7
- version = "0.1.3"
7
+ version = "0.2.1"
8
8
  description = "HT32 Code MCP Server - 为 AI Agent 提供 HT32 函数库/例程/模板检索与工程导出"
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ from .exporter import (
21
21
  )
22
22
  from .index import CodeIndex, normalize_series
23
23
  from .knowledge import KnowledgeSearch
24
- from .usage import UsageMiddleware, record_feedback
24
+ from .usage import SERVICE_CODE, UsageMiddleware, record_feedback, resolve_log_dir
25
25
 
26
26
  # 知识库根目录(ht32-code 仓库根;src/ht32_code_mcp → 上溯 4 级)
27
27
  DEFAULT_CODE_ROOT = Path(__file__).resolve().parents[4]
@@ -43,9 +43,11 @@ BUNDLE_BASE_URL = os.environ.get("BUNDLE_BASE_URL", "").strip()
43
43
  BUNDLE_TTL = _env_int("BUNDLE_TTL_SECONDS", DEFAULT_BUNDLE_TTL)
44
44
  CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
45
45
 
46
- # 用量日志与反馈目录(1Panel 挂载可写卷到 /app/logs)。为空则整体关闭——
47
- # 本地开发默认不落盘。注意:日志含 query 原文,勿提交进仓库。
48
- LOG_DIR = os.environ.get("CODE_LOG_DIR", "").strip()
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)
49
51
 
50
52
  index = CodeIndex(CODE_ROOT).load()
51
53
  knowledge = KnowledgeSearch(CODE_ROOT)
@@ -83,10 +85,11 @@ _INSTRUCTIONS = """HT32 代码知识库(函数库/例程/工程模板),当
83
85
 
84
86
  mcp = FastMCP("ht32-code", instructions=_INSTRUCTIONS)
85
87
 
86
- # 用量日志:服务端自动记录所有工具调用(CODE_LOG_DIR 为空则不启用)
88
+ # 用量日志:服务端自动记录所有工具调用(FEEDBACK_ROOT 为空则不启用)
87
89
  if LOG_DIR:
88
90
  mcp.add_middleware(UsageMiddleware(
89
- LOG_DIR, mcp_version=_MCP_VERSION, code_version=index.data.get("code_version", "")))
91
+ LOG_DIR, SERVICE_CODE,
92
+ mcp_version=_MCP_VERSION, kb_version=index.data.get("code_version", "")))
90
93
 
91
94
 
92
95
  # ================================================================ MCP Tools
@@ -118,8 +121,8 @@ def get_code_version() -> str:
118
121
  ("云端 zip 下载:启用(TTL "
119
122
  f"{BUNDLE_TTL}s,{BUNDLE_BASE_URL})" if CLOUD_BUNDLE
120
123
  else "云端 zip 下载:未配置(本地目录模式,export 需提供 out_dir)"),
121
- (f"用量日志:启用({LOG_DIR})" if LOG_DIR
122
- else "用量日志:未配置(CODE_LOG_DIR 为空,不落盘)"),
124
+ (f"反馈与用量日志:启用({LOG_DIR})" if LOG_DIR
125
+ else "反馈与用量日志:未配置(FEEDBACK_ROOT 为空,不落盘)"),
123
126
  "",
124
127
  "VERSION 文件内容:",
125
128
  version_text,
@@ -403,6 +406,7 @@ def submit_feedback(
403
406
 
404
407
  path = record_feedback(
405
408
  LOG_DIR,
409
+ SERVICE_CODE,
406
410
  question=question,
407
411
  issue_type=issue_type,
408
412
  answer=answer,
@@ -411,10 +415,10 @@ def submit_feedback(
411
415
  model_id=model_id,
412
416
  tools_called=tools_called,
413
417
  mcp_version=_MCP_VERSION,
414
- code_version=index.data.get("code_version", ""),
418
+ kb_version=index.data.get("code_version", ""),
415
419
  )
416
420
  if path is None:
417
- return ("错误:反馈未能写入。服务端可能未配置 CODE_LOG_DIR,"
421
+ return ("错误:反馈未能写入。服务端可能未配置 FEEDBACK_ROOT,"
418
422
  "或该目录不可写——请联系管理员。")
419
423
  return f"反馈已记录,感谢!问题类型:{issue_type},文件:{path.name}"
420
424
 
@@ -0,0 +1,223 @@
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.1.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
+ # 工具内的"软失败"标记:两服务的工具用**字符串**返回错误而不抛异常,middleware
51
+ # 拿不到协议级 is_error,只能靠返回值文本判断。不识别会让真失败被记成 ok=true,
52
+ # 后果不只是统计失真——分析工具按 ok 过滤"需求信号",型号打错导致的
53
+ # "导出失败:不在模板参数表"会被当成一次真实的芯片需求,污染选型优先级结论。
54
+ #
55
+ # 判定口径 = **工具是否完成了它的工作**,故只收明确执行失败:
56
+ # - 错误: 参数校验/路径越界/IO 失败/反馈写盘失败(两服务)
57
+ # - 导出失败 code: export_project_bundle 无法产出工程
58
+ # - 请至少提供 wiki: compare_chips 参数校验
59
+ #
60
+ # 刻意**不算**失败的是"无数据"类回答(工具正常跑完,只是没结果):
61
+ # - 未找到匹配结果 / 未找到型号 'X' / 未找到外设 'X'
62
+ # - 不在支持列表 / 不在覆盖范围 / 暂无模板
63
+ # 它们记 ok=true 才能进入需求信号——"用户搜 BLDC 但知识库没有"正是
64
+ # 指导 P2-1 补内容的最强信号,标成失败会被分析工具当作噪声剔除。
65
+ SOFT_FAILURE_MARKERS = (
66
+ "错误:",
67
+ "导出失败",
68
+ "请至少提供",
69
+ )
70
+ # 只在返回值开头这段内匹配,避免正文里偶然出现标记词造成误判
71
+ _FAILURE_SCAN_CHARS = 80
72
+
73
+ USAGE_PREFIX = "usage"
74
+ FEEDBACK_PREFIX = "feedback"
75
+
76
+
77
+ def looks_like_failure(text: str) -> bool:
78
+ """判断工具返回值是否为"软失败"(字符串形式的错误提示)。"""
79
+ head = (text or "").lstrip()[:_FAILURE_SCAN_CHARS]
80
+ return any(m in head for m in SOFT_FAILURE_MARKERS)
81
+
82
+
83
+ def _result_text(result: Any) -> str:
84
+ """取工具返回值中的文本(供软失败判断)。"""
85
+ parts: list[str] = []
86
+ for block in getattr(result, "content", None) or []:
87
+ text = getattr(block, "text", None)
88
+ if isinstance(text, str):
89
+ parts.append(text)
90
+ return "\n".join(parts)
91
+
92
+
93
+ def _now() -> str:
94
+ return datetime.now(timezone.utc).isoformat()
95
+
96
+
97
+ def resolve_log_dir(feedback_root: str, service: str) -> str:
98
+ """由反馈根目录 + 服务名算出该服务的日志目录。
99
+
100
+ feedback_root 为空时返回空字符串 = 关闭(本地开发默认不落盘)。
101
+ 始终用 '/' 拼接(Windows 亦接受正斜杠),避免同一条配置在不同平台
102
+ 产生不同字符串、影响日志路径的可比对性。
103
+ """
104
+ root = (feedback_root or "").strip().rstrip("/")
105
+ svc = (service or "").strip().strip("/")
106
+ if not root:
107
+ return ""
108
+ return f"{root}/{svc}" if svc else root
109
+
110
+
111
+ def write_record(log_dir: str, prefix: str, record: dict[str, Any]) -> Path | None:
112
+ """追加一条 JSONL 记录。log_dir 为空或写盘失败时返回 None(绝不因日志中断服务)。"""
113
+ if not log_dir:
114
+ return None
115
+ try:
116
+ d = Path(log_dir)
117
+ d.mkdir(parents=True, exist_ok=True)
118
+ path = d / f"{prefix}_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.jsonl"
119
+ with open(path, "a", encoding="utf-8") as f:
120
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
121
+ return path
122
+ except OSError:
123
+ return None
124
+
125
+
126
+ def _client_info(context: MiddlewareContext) -> dict[str, str]:
127
+ """取客户端标识(内存调用等无 session 的场景返回空值)。"""
128
+ ctx = getattr(context, "fastmcp_context", None)
129
+ if ctx is None:
130
+ return {}
131
+ out: dict[str, str] = {}
132
+ for key in ("client_id", "session_id"):
133
+ val = getattr(ctx, key, None)
134
+ if val:
135
+ out[key] = str(val)
136
+ transport = getattr(ctx, "transport", None)
137
+ if transport:
138
+ out["transport"] = str(transport)
139
+ return out
140
+
141
+
142
+ class UsageMiddleware(Middleware):
143
+ """把所有工具调用落成 JSONL(服务端自动采集)。"""
144
+
145
+ def __init__(self, log_dir: str, service: str,
146
+ mcp_version: str = "", kb_version: str = "") -> None:
147
+ self.log_dir = log_dir
148
+ self.service = service
149
+ self.mcp_version = mcp_version
150
+ self.kb_version = kb_version
151
+
152
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
153
+ if not self.log_dir:
154
+ return await call_next(context)
155
+
156
+ args = dict(context.message.arguments or {})
157
+ recorded = {k: v for k, v in args.items() if k not in ARG_NOISE}
158
+ started = time.perf_counter()
159
+ error = ""
160
+ ok = True
161
+ try:
162
+ result = await call_next(context)
163
+ if getattr(result, "is_error", False):
164
+ # 协议级错误
165
+ ok = False
166
+ error = _result_text(result)[:500]
167
+ elif looks_like_failure(_result_text(result)):
168
+ # 工具以字符串返回的软失败(如"导出失败:…")
169
+ ok = False
170
+ error = _result_text(result)[:500]
171
+ return result
172
+ except Exception as e:
173
+ error = f"{type(e).__name__}: {e}" # 异常上抛,但不能漏记
174
+ ok = False
175
+ raise
176
+ finally:
177
+ record = {
178
+ "timestamp": _now(),
179
+ "kind": "tool_call",
180
+ "service": self.service,
181
+ "tool": context.message.name,
182
+ "args": recorded,
183
+ "ok": ok,
184
+ "duration_ms": round((time.perf_counter() - started) * 1000),
185
+ "error": error,
186
+ "mcp_version": self.mcp_version,
187
+ "kb_version": self.kb_version,
188
+ **_client_info(context),
189
+ }
190
+ write_record(self.log_dir, USAGE_PREFIX, record)
191
+
192
+
193
+ def record_feedback(
194
+ log_dir: str,
195
+ service: str,
196
+ *,
197
+ question: str,
198
+ issue_type: str,
199
+ answer: str = "",
200
+ correction: str = "",
201
+ details: str = "",
202
+ model_id: str = "",
203
+ tools_called: list[str] | None = None,
204
+ mcp_version: str = "",
205
+ kb_version: str = "",
206
+ ) -> Path | None:
207
+ """记录一条 Agent 主动提交的反馈。"""
208
+ record = {
209
+ "timestamp": _now(),
210
+ "kind": "feedback",
211
+ "service": service,
212
+ "question": question.strip(),
213
+ "issue_type": issue_type,
214
+ "answer": answer.strip(),
215
+ "correction": correction.strip(),
216
+ "details": details.strip(),
217
+ "model_id": model_id.strip(),
218
+ # 过滤 submit_feedback 自身,只保留查询类工具
219
+ "tools_called": [t for t in (tools_called or []) if t and t != "submit_feedback"],
220
+ "mcp_version": mcp_version,
221
+ "kb_version": kb_version,
222
+ }
223
+ return write_record(log_dir, FEEDBACK_PREFIX, record)
@@ -304,6 +304,57 @@ def test_version_reports_bundle_mode(monkeypatch):
304
304
 
305
305
 
306
306
  # ---------------------------------------------------------------- 反馈与用量日志
307
+ def test_usage_impl_version_pinned():
308
+ """两仓库的 usage.py 是逐字节相同副本,实现版本号用于防止只改一边。"""
309
+ from ht32_code_mcp import usage
310
+ assert usage._IMPL_VERSION == "1.1.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_soft_failure_marker_semantics():
318
+ """软失败判定口径 = 工具是否完成工作。
319
+
320
+ 真失败(执行没成功)→ 算失败;"无数据"(正常跑完但没结果)→ 不算失败,
321
+ 否则"用户搜 BLDC 但库里没有"这类最强缺口信号会被当噪声剔除。
322
+ """
323
+ from ht32_code_mcp.usage import looks_like_failure as bad
324
+
325
+ # 真失败:参数/路径/IO/导出
326
+ assert bad("错误:path 不能为空。")
327
+ assert bad("错误:路径越界(只允许 raw/ 下文件):x")
328
+ assert bad("导出失败:芯片 'X' 不在模板参数表")
329
+ assert bad("导出失败(打包或写盘失败,检查 bundle 目录权限/空间):boom")
330
+ assert bad("错误:反馈未能写入。服务端可能未配置 FEEDBACK_ROOT,")
331
+ assert bad("请至少提供两个型号对比。") # wiki: compare_chips 参数校验
332
+
333
+ # 无数据 ≠ 失败:必须记 ok=true 才能进需求信号
334
+ assert not bad("未找到匹配知识页。建议:换模块官方名小写重试…")
335
+ assert not bad("未找到外设 'xxx' 的例程。可用外设目录(44 个):…")
336
+ assert not bad("芯片 'HT32F99999' 不在支持列表(当前 79 个型号,…")
337
+ assert not bad("系列 'F9' 不在覆盖范围。当前覆盖:…")
338
+ assert not bad("raw/templates/ 下暂无模板。")
339
+ assert not bad("未找到型号 'zzz'。可用型号:…")
340
+
341
+ # 正常成功返回值
342
+ assert not bad("找到 6 个 'gpio' 例程:…")
343
+ assert not bad("已导出 455 个文件到 …")
344
+ assert not bad("反馈已记录,感谢!问题类型:other")
345
+ # 正文后段偶然出现标记词不应误判(只扫开头 80 字符)
346
+ assert not bad("找到 3 条结果:\n" + "填充" * 60 + "\n注意:错误:的用法见页尾")
347
+
348
+
349
+ def test_resolve_log_dir_layout():
350
+ from ht32_code_mcp.usage import resolve_log_dir
351
+ # 同根 + 服务子目录
352
+ assert resolve_log_dir("/app/feedback", "code") == "/app/feedback/code"
353
+ assert resolve_log_dir("/app/feedback/", "code") == "/app/feedback/code"
354
+ # 根未配置 = 关闭
355
+ assert resolve_log_dir("", "code") == ""
356
+
357
+
307
358
  def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
308
359
  monkeypatch.setattr(srv, "LOG_DIR", str(tmp_path / "logs"))
309
360
  out = srv.submit_feedback(
@@ -319,12 +370,13 @@ def test_submit_feedback_writes_jsonl(tmp_path, monkeypatch):
319
370
  f = next((tmp_path / "logs").glob("feedback_*.jsonl"))
320
371
  rec = json.loads(f.read_text(encoding="utf-8").splitlines()[0])
321
372
  assert rec["kind"] == "feedback"
373
+ assert rec["service"] == "code"
322
374
  assert rec["question"] == "HT32F52352 怎么配 GPIO 输出?"
323
375
  assert rec["issue_type"] == "wrong_api"
324
376
  assert rec["model_id"] == "deepseek-v4-flash"
325
377
  # submit_feedback 自身被过滤,只留查询类工具
326
378
  assert rec["tools_called"] == ["search_code_knowledge", "get_file"]
327
- assert rec["mcp_version"] and rec["code_version"]
379
+ assert rec["mcp_version"] and rec["kb_version"]
328
380
 
329
381
 
330
382
  def test_submit_feedback_empty_question(tmp_path, monkeypatch):
@@ -336,14 +388,14 @@ def test_submit_feedback_empty_question(tmp_path, monkeypatch):
336
388
  def test_submit_feedback_without_log_dir(monkeypatch):
337
389
  monkeypatch.setattr(srv, "LOG_DIR", "")
338
390
  out = srv.submit_feedback(question="x")
339
- assert "错误" in out and "CODE_LOG_DIR" in out
391
+ assert "错误" in out and "FEEDBACK_ROOT" in out
340
392
 
341
393
 
342
394
  def test_version_reports_log_mode(monkeypatch):
343
395
  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()
396
+ assert "反馈与用量日志:未配置" in srv.get_code_version()
397
+ monkeypatch.setattr(srv, "LOG_DIR", "/app/feedback/code")
398
+ assert "反馈与用量日志:启用(/app/feedback/code)" in srv.get_code_version()
347
399
 
348
400
 
349
401
  def test_usage_middleware_records_calls(tmp_path):
@@ -353,8 +405,8 @@ def test_usage_middleware_records_calls(tmp_path):
353
405
  from fastmcp import Client, FastMCP
354
406
 
355
407
  app = FastMCP("mw-test")
356
- app.add_middleware(UsageMiddleware(str(tmp_path / "logs"),
357
- mcp_version="0.1.3", code_version="v0.1.0"))
408
+ app.add_middleware(UsageMiddleware(str(tmp_path / "logs"), "code",
409
+ mcp_version="0.1.4", kb_version="v0.1.0"))
358
410
 
359
411
  @app.tool()
360
412
  def probe(chip: str, limit: int = 5, out_dir: str = "") -> str:
@@ -378,22 +430,60 @@ def test_usage_middleware_records_calls(tmp_path):
378
430
  assert len(recs) == 2
379
431
  ok_rec, fail_rec = recs
380
432
  assert ok_rec["kind"] == "tool_call" and ok_rec["tool"] == "probe"
433
+ assert ok_rec["service"] == "code"
381
434
  # limit / out_dir 属噪声被剔除,业务参数保留
382
435
  assert ok_rec["args"] == {"chip": "HT32F52352"}
383
436
  assert ok_rec["ok"] is True and ok_rec["error"] == ""
384
437
  assert ok_rec["duration_ms"] >= 0
385
- assert ok_rec["mcp_version"] == "0.1.3"
438
+ assert ok_rec["mcp_version"] == "0.1.4"
439
+ assert ok_rec["kb_version"] == "v0.1.0"
386
440
  assert fail_rec["tool"] == "boom" and fail_rec["ok"] is False
387
441
  assert "deliberate" in fail_rec["error"]
388
442
 
389
443
 
444
+ def test_usage_middleware_records_soft_failure(tmp_path):
445
+ """工具返回错误字符串(不抛异常)时,middleware 应记 ok=false 并留错误原文。"""
446
+ import asyncio
447
+
448
+ from fastmcp import Client, FastMCP
449
+
450
+ app = FastMCP("mw-soft")
451
+ app.add_middleware(UsageMiddleware(str(tmp_path / "logs"), "code",
452
+ mcp_version="0.2.1", kb_version="v0.1.0"))
453
+
454
+ @app.tool()
455
+ def soft_fail(chip: str) -> str:
456
+ return f"导出失败:芯片 '{chip}' 不在模板参数表"
457
+
458
+ @app.tool()
459
+ def no_data(peripheral: str) -> str:
460
+ return f"未找到外设 '{peripheral}' 的例程。可用外设目录(44 个):…"
461
+
462
+ async def run():
463
+ async with Client(app) as c:
464
+ await c.call_tool("soft_fail", {"chip": "HT32F99999"})
465
+ await c.call_tool("no_data", {"peripheral": "zzz"})
466
+
467
+ asyncio.run(run())
468
+ recs = [json.loads(l) for l in
469
+ next((tmp_path / "logs").glob("usage_*.jsonl"))
470
+ .read_text(encoding="utf-8").splitlines()]
471
+ fail_rec, nodata_rec = recs
472
+ # 真失败:ok=false 且留下错误原文
473
+ assert fail_rec["tool"] == "soft_fail" and fail_rec["ok"] is False
474
+ assert "不在模板参数表" in fail_rec["error"]
475
+ # 无数据:不算失败,保留在需求信号里
476
+ assert nodata_rec["tool"] == "no_data" and nodata_rec["ok"] is True
477
+ assert nodata_rec["error"] == ""
478
+
479
+
390
480
  def test_usage_log_disabled_and_resilient(tmp_path):
391
481
  """log_dir 为空不落盘;目录不可写时调用仍成功(日志绝不中断服务)。"""
392
482
  import asyncio
393
483
 
394
484
  from fastmcp import Client, FastMCP
395
485
 
396
- from ht32_code_mcp.usage import UsageMiddleware, write_record
486
+ from ht32_code_mcp.usage import write_record
397
487
 
398
488
  assert write_record("", "usage", {"x": 1}) is None
399
489
 
@@ -401,7 +491,7 @@ def test_usage_log_disabled_and_resilient(tmp_path):
401
491
  blocker = tmp_path / "blocker"
402
492
  blocker.write_text("i am a file", encoding="utf-8")
403
493
  app = FastMCP("mw-bad")
404
- app.add_middleware(UsageMiddleware(str(blocker / "logs")))
494
+ app.add_middleware(UsageMiddleware(str(blocker / "logs"), "code"))
405
495
 
406
496
  @app.tool()
407
497
  def probe() -> str:
@@ -440,7 +440,7 @@ wheels = [
440
440
 
441
441
  [[package]]
442
442
  name = "ht32-code-mcp"
443
- version = "0.1.3"
443
+ version = "0.2.1"
444
444
  source = { editable = "." }
445
445
  dependencies = [
446
446
  { name = "fastmcp" },
@@ -1,138 +0,0 @@
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)
File without changes
File without changes
File without changes