modelfunction-cli 0.1.2 → 0.2.0

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- npx -y modelfunction-cli@0.1.2 --version
8
+ npx -y modelfunction-cli@0.2.0 --version
9
9
  # 或
10
10
  npm i -D modelfunction-cli
11
11
  npx mf --version
@@ -64,18 +64,43 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
64
64
 
65
65
  | 命令 | 作用 |
66
66
  | --- | --- |
67
- | `mf scan` | 扫描工具定义;`--probe` 连 MCP server;`--from` 导入运行时导出的工具数组 |
68
- | `mf eval` | 跑评分卡;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁 |
67
+ | `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool`);`--probe` 连 MCP server;`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
68
+ | `mf eval` | 跑评分卡;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分 |
69
69
  | `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
70
- | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对 |
71
- | `mf lint` | 静态检查描述与参数 schema |
72
- | `mf compare` | 跨版本比较、干扰归因、混淆矩阵 |
73
- | `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具 |
70
+ | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
71
+ | `mf lint` | 静态检查描述与参数 schema;有 `profiles` 时给出「这些组不要合并挂载」 |
72
+ | `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
73
+ | `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
74
+ | `mf cluster` | 聚类工具,建议挂载分组;`--out` 把分组写成套件的 `profiles` |
74
75
  | `mf exec` | 单工具执行 + 指标(Node / Python),支持 `--allow-read/write/net`、超时、sideEffects 准入 |
75
76
  | `mf eval --execute` | 一次跑完选择 + 参数 + 运行断言(`toolInput` / `expect`) |
76
77
  | `mf sandbox check` | 探测本机可用隔离引擎与降级建议 |
77
78
  | `mf mcp` | stdio MCP server,供 Claude Code / Cursor / Kimi Code 调用 |
78
79
 
80
+ ## 单次结果会抖:用 `--repeat` 看稳定性
81
+
82
+ 实测 DeepSeek-V4-Flash:同一条用例、temperature=0,一次选对、下一次干脆不调用。单次评分卡里 ±1 条用例就是 ±8pp,小套件上足以把"分组有没有用"看反。
83
+
84
+ ```bash
85
+ mf eval --models dsv4-flash --repeat 3 # 每条跑 3 次取多数,并列出结果不稳定的用例
86
+ ```
87
+
88
+ `--repeat` 会让调用次数和成本乘以 n;正式基线建议 3 次,日常回归 1 次够用。
89
+
90
+ ## 挂载分组:验证"少挂一点"值不值
91
+
92
+ 平台通常按业务把工具拆给不同 agent(网优 / 故障 / 运维各挂各的)。分组到底降了多少误选,用同一批用例比一次就知道:
93
+
94
+ ```bash
95
+ mf cluster --suite mf.suite.json --out mf.suite.json # 写入建议分组(profiles),可手工修改
96
+ mf eval --suite mf.suite.json --models internal-ds \
97
+ --profile all --out report.md # 每组 vs 全量,一页 markdown
98
+ ```
99
+
100
+ 每组只跑"本组工具 + 本组用例",负例(不该调用工具)每组都跑;`all-in-one` 列是同一批用例在全部工具都挂时的结果。两列之差就是分组收益,按组给出,可直接拿去和平台方对账。工具很少时分组通常没有收益(模型本来就选得对),差异小于 10pp 请先加 `--repeat 3` 复核再下结论。
101
+
102
+ 配套的三个检查:`mf lint` 列出组间高风险对(相似工具分属不同组,合并挂载会互抢);`mf coverage` 按组看谁还缺用例;两次 `--profile` 报告之间用 `mf compare` 看各组升降(分组定义变了会明确标注不可比)。组内还有干扰时,用 `mf ablate --profile <组> --tool <工具>` 做组内留一法,看拆掉谁最划算。
103
+
79
104
  ## MCP 配置
80
105
 
81
106
  ```json
@@ -83,13 +108,13 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
83
108
  "mcpServers": {
84
109
  "modelfunction": {
85
110
  "command": "npx",
86
- "args": ["-y", "modelfunction-cli@0.1.2", "mcp"]
111
+ "args": ["-y", "modelfunction-cli@0.2.0", "mcp"]
87
112
  }
88
113
  }
89
114
  }
90
115
  ```
91
116
 
92
- 暴露:`scan_tools`、`list_suite`、`coverage`、`add_cases`、`run_eval`。
117
+ 暴露:`scan_tools`、`list_suite`、`coverage`、`add_cases`、`run_eval`(`run_eval` 支持 `profile` 参数,传 `all` 或组名即可按挂载组评分)。
93
118
 
94
119
  ## 数据边界
95
120
 
@@ -105,7 +130,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
105
130
 
106
131
  ```bash
107
132
  # 1. 把 mf 挂成 MCP server
108
- claude mcp add modelfunction -- npx -y modelfunction-cli@0.1.2 mcp
133
+ claude mcp add modelfunction -- npx -y modelfunction-cli@0.2.0 mcp
109
134
  ```
110
135
 
111
136
  ```text
@@ -146,5 +146,9 @@
146
146
  },
147
147
  "note": "有“简历”字样,但真实意图是薪资"
148
148
  }
149
- ]
149
+ ],
150
+ "profiles": {
151
+ "screening": ["resume_score", "interview_question"],
152
+ "offer": ["salary_band", "jd_rewrite"]
153
+ }
150
154
  }
package/lib/ablate.js CHANGED
@@ -64,8 +64,9 @@ export function analyzeAblation(model, full, reduced) {
64
64
  }
65
65
 
66
66
  export function formatAblation(payload) {
67
+ const scope = payload.profile ? `,组 ${payload.profile.name},${payload.profile.tools} 个工具` : "";
67
68
  const lines = [
68
- `ablate: ${payload.tool}(suite ${payload.suite},去掉后期望该工具的用例 ${payload.droppedCases} 条)`
69
+ `ablate: ${payload.tool}(suite ${payload.suite}${scope},去掉后期望该工具的用例 ${payload.droppedCases} 条)`
69
70
  ];
70
71
 
71
72
  for (const analysis of payload.models) {
package/lib/cases.js CHANGED
@@ -2,11 +2,30 @@ import { readFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { loadSuite } from "./suite.js";
4
4
  import { writeFileAtomic } from "./fsutil.js";
5
+ import { normalizeProfiles, profileCaseCoverage } from "./profile.js";
5
6
 
6
7
  const MIN_CASES_PER_TOOL = 3;
7
8
  const MIN_NEGATIVE_RATIO = 0.2;
8
9
  const PAIR_SIMILARITY = 0.35;
9
10
 
11
+ // `get_stock_data` and `get_news` share only the verb; that is not a family.
12
+ // These tokens carry no domain meaning, so they never count as a shared name
13
+ // signal (neither for confusability nor for clustering).
14
+ export const GENERIC_NAME_TOKENS = new Set(["tool", "tools", "api", "get", "set", "do"]);
15
+
16
+ // English boilerplate: function words plus the verbs every tool description
17
+ // starts with. Left in, "Retrieve data for a given ticker" makes any two tools
18
+ // look half-identical.
19
+ const STOPWORDS = new Set([
20
+ "the", "and", "for", "with", "from", "this", "that", "these", "those", "are", "was", "were",
21
+ "has", "have", "you", "your", "can", "will", "given", "based", "using", "into", "over", "per",
22
+ "via", "not", "all", "any", "each", "its", "their", "data",
23
+ "get", "gets", "retrieve", "retrieves", "retrieved", "fetch", "fetches", "query", "queries",
24
+ "search", "searches", "find", "finds", "read", "reads", "write", "writes", "create", "creates",
25
+ "update", "updates", "delete", "deletes", "list", "lists", "run", "runs", "call", "calls",
26
+ "use", "uses", "return", "returns", "tool", "tools", "api", "apis"
27
+ ]);
28
+
10
29
  export async function addCases(suitePath, incoming, { source = "agent", reviewed = false, dryRun = false } = {}) {
11
30
  const abs = resolve(suitePath);
12
31
  const raw = JSON.parse(await readFile(abs, "utf8"));
@@ -121,12 +140,30 @@ export function computeCoverage(suite) {
121
140
  }
122
141
  if (unreviewed > 0) warnings.push(`${unreviewed} 条未复核用例不计入正式分数`);
123
142
 
143
+ const skills = suite.skills?.length ?? 0;
144
+ if (skills > 0) warnings.push(`${skills} 个 skill 不参与工具评测(当前只覆盖 function / MCP 工具)`);
145
+
146
+ const profileList = suite.profiles
147
+ ? normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name))
148
+ : null;
149
+ const profiles = profileList ? profileCaseCoverage(profileList, suite.cases) : null;
150
+ if (profiles) {
151
+ for (const row of profiles) {
152
+ if (row.cases === 0) warnings.push(`组 ${row.name}: 0 条正例(只有负例),该组工具没被真正测试`);
153
+ else if (row.missing.length > 0) {
154
+ warnings.push(`组 ${row.name}: 工具 ${row.missing.join(", ")} 没有对应用例`);
155
+ }
156
+ }
157
+ }
158
+
124
159
  return {
125
160
  total,
126
161
  negative,
127
162
  negativeRatio,
128
163
  unreviewed,
164
+ skills,
129
165
  tools,
166
+ profiles,
130
167
  pairs: suspectedPairs(suite.tools),
131
168
  warnings
132
169
  };
@@ -134,16 +171,29 @@ export function computeCoverage(suite) {
134
171
 
135
172
  export function formatCoverage(report) {
136
173
  const lines = [
137
- `cases: ${report.total}(负例 ${report.negative},${Math.round(report.negativeRatio * 100)}%;未复核 ${report.unreviewed})`,
138
- "",
139
- "tool cases args reviewed",
140
- "---------------------- ----- ---- --------"
174
+ `cases: ${report.total}(负例 ${report.negative},${Math.round(report.negativeRatio * 100)}%;未复核 ${report.unreviewed})`
141
175
  ];
176
+ if (report.skills > 0) lines.push(`skills: ${report.skills}(不参与工具评测)`);
177
+ lines.push(
178
+ "",
179
+ "tool cases args reviewed",
180
+ "------------------------------ ----- ---- --------"
181
+ );
142
182
  for (const tool of report.tools) {
143
183
  lines.push(
144
- `${tool.name.padEnd(22)} ${String(tool.total).padStart(5)} ${String(tool.withArgs).padStart(4)} ${String(tool.reviewed).padStart(8)}`
184
+ `${tool.name.padEnd(30)} ${String(tool.total).padStart(5)} ${String(tool.withArgs).padStart(4)} ${String(tool.reviewed).padStart(8)}`
145
185
  );
146
186
  }
187
+ if (report.profiles && report.profiles.length > 0) {
188
+ lines.push("", "profiles(分组后可评分的用例;负例每组都跑):");
189
+ lines.push("group tools cases neg tools-without-cases");
190
+ lines.push("---------------------- ----- ----- --- -------------------");
191
+ for (const row of report.profiles) {
192
+ lines.push(
193
+ `${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${row.missing.length ? row.missing.join(", ") : "-"}`
194
+ );
195
+ }
196
+ }
147
197
  if (report.pairs.length > 0) {
148
198
  lines.push("", "suspected confusing pairs(建议各补 1 条歧义用例):");
149
199
  for (const pair of report.pairs) {
@@ -157,29 +207,100 @@ export function formatCoverage(report) {
157
207
  return lines.join("\n");
158
208
  }
159
209
 
210
+ export function pairSimilarity(x, y) {
211
+ const a = tokens(`${x.name} ${x.description}`);
212
+ const b = tokens(`${y.name} ${y.description}`);
213
+ const union = new Set([...a, ...b]);
214
+ let lexical = 0;
215
+ if (union.size > 0) {
216
+ let shared = 0;
217
+ for (const token of union) if (a.has(token) && b.has(token)) shared += 1;
218
+ lexical = shared / union.size;
219
+ }
220
+ const structural = structuralScore(x, y);
221
+ return {
222
+ similarity: Math.max(lexical, structural),
223
+ basis: structural > lexical ? "structural" : "lexical"
224
+ };
225
+ }
226
+
227
+ // Clustering wants "same family" (alarm_query / alarm_ack belong to one agent),
228
+ // which is looser than "confusable" (lint's question). Shared name token plus
229
+ // any parameter overlap is enough to belong together; two shared tokens are
230
+ // enough on their own (fault_alarm_query / fault_alarm_ack take different ids).
231
+ // One shared token with disjoint params is not: cross-domain tools often share
232
+ // a bare verb (query / list) and nothing else.
233
+ export function familyScore(x, y) {
234
+ const left = nameTokens(x.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
235
+ const right = nameTokens(y.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
236
+ const shared = left.filter((token) => right.includes(token));
237
+ if (shared.length === 0) return 0;
238
+ const paramOverlap = jaccard(paramNames(x), paramNames(y));
239
+ if (paramOverlap > 0) return 0.8;
240
+ if (shared.length >= 2) return 0.7;
241
+ return paramNames(x).size === 0 && paramNames(y).size === 0 ? 0.6 : 0.2;
242
+ }
243
+
160
244
  export function suspectedPairs(tools) {
161
245
  const pairs = [];
162
246
  for (let i = 0; i < tools.length; i += 1) {
163
247
  for (let j = i + 1; j < tools.length; j += 1) {
164
- const a = tokens(`${tools[i].name} ${tools[i].description}`);
165
- const b = tokens(`${tools[j].name} ${tools[j].description}`);
166
- const union = new Set([...a, ...b]);
167
- if (union.size === 0) continue;
168
- let shared = 0;
169
- for (const token of union) if (a.has(token) && b.has(token)) shared += 1;
170
- const similarity = shared / union.size;
248
+ const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
171
249
  if (similarity >= PAIR_SIMILARITY) {
172
- pairs.push({ a: tools[i].name, b: tools[j].name, similarity });
250
+ pairs.push({ a: tools[i].name, b: tools[j].name, similarity, basis });
173
251
  }
174
252
  }
175
253
  }
176
254
  return pairs.sort((x, y) => y.similarity - x.similarity);
177
255
  }
178
256
 
257
+ // Tools that share part of the name (search / file / read...) and the same
258
+ // parameter shape confuse the model even when the wording differs, which the
259
+ // lexical score alone misses. Name order is unreliable: `tavily_search` leads
260
+ // with the vendor, `search_docs` with the action.
261
+ function structuralScore(x, y) {
262
+ const left = nameTokens(x.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
263
+ const right = nameTokens(y.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
264
+ const sharedName = left.some((token) => right.includes(token));
265
+ if (!sharedName) return 0;
266
+ const params = paramNames(x);
267
+ const otherParams = paramNames(y);
268
+ if (params.size === 0 && otherParams.size === 0) return 0;
269
+ const paramOverlap = jaccard(params, otherParams);
270
+ if (paramOverlap < 0.34) return 0;
271
+ const requiredOverlap = jaccard(
272
+ new Set(x.parameters?.required ?? []),
273
+ new Set(y.parameters?.required ?? [])
274
+ );
275
+ return 0.5 + 0.5 * (0.6 * paramOverlap + 0.4 * requiredOverlap);
276
+ }
277
+
278
+ function nameTokens(name) {
279
+ return String(name ?? "")
280
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
281
+ .toLowerCase()
282
+ .split(/[^a-z0-9]+/)
283
+ .filter(Boolean);
284
+ }
285
+
286
+ function paramNames(tool) {
287
+ return new Set(Object.keys(tool.parameters?.properties ?? {}));
288
+ }
289
+
290
+ function jaccard(a, b) {
291
+ if (a.size === 0 && b.size === 0) return 1;
292
+ const union = new Set([...a, ...b]);
293
+ let shared = 0;
294
+ for (const item of union) if (a.has(item) && b.has(item)) shared += 1;
295
+ return shared / union.size;
296
+ }
297
+
179
298
  function tokens(text) {
180
299
  const set = new Set();
181
300
  const lowered = String(text ?? "").toLowerCase();
182
- for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) set.add(word);
301
+ for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) {
302
+ if (!STOPWORDS.has(word)) set.add(word);
303
+ }
183
304
  for (const chunk of lowered.match(/[一-鿿]+/g) ?? []) {
184
305
  if (chunk.length === 1) set.add(chunk);
185
306
  for (let i = 0; i + 1 < chunk.length; i += 1) set.add(chunk.slice(i, i + 2));
package/lib/cluster.js ADDED
@@ -0,0 +1,103 @@
1
+ import { pairSimilarity, familyScore, GENERIC_NAME_TOKENS } from "./cases.js";
2
+
3
+ export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
4
+ const scores = tools.map(() => new Array(tools.length).fill(0));
5
+ const pairs = [];
6
+ for (let i = 0; i < tools.length; i += 1) {
7
+ for (let j = i + 1; j < tools.length; j += 1) {
8
+ const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
9
+ const family = familyScore(tools[i], tools[j]);
10
+ const link = Math.max(similarity, family);
11
+ scores[i][j] = link;
12
+ scores[j][i] = link;
13
+ if (link >= threshold) {
14
+ pairs.push({ i, j, similarity, basis: family > similarity ? "family" : basis });
15
+ }
16
+ }
17
+ }
18
+
19
+ const degree = tools.map((_, index) =>
20
+ pairs.reduce((sum, pair) => sum + (pair.i === index || pair.j === index ? 1 : 0), 0)
21
+ );
22
+ const order = tools
23
+ .map((tool, index) => index)
24
+ .sort((a, b) => degree[b] - degree[a] || tools[a].name.localeCompare(tools[b].name));
25
+
26
+ const raw = [];
27
+ for (const index of order) {
28
+ let best = null;
29
+ for (const cluster of raw) {
30
+ if (cluster.members.length >= maxSize) continue;
31
+ const average =
32
+ cluster.members.reduce((sum, member) => sum + scores[index][member], 0) / cluster.members.length;
33
+ if (average >= threshold && (!best || average > best.average)) best = { cluster, average };
34
+ }
35
+ if (best) best.cluster.members.push(index);
36
+ else raw.push({ members: [index] });
37
+ }
38
+
39
+ const clusterOf = new Map();
40
+ raw.forEach((cluster, position) => cluster.members.forEach((member) => clusterOf.set(member, position)));
41
+
42
+ const usedLabels = new Set();
43
+ const clusters = raw.map((cluster, position) => {
44
+ const members = [...cluster.members].sort((a, b) => tools[a].name.localeCompare(tools[b].name));
45
+ const label = uniqueLabel(labelFor(members.map((index) => tools[index].name)), usedLabels, position);
46
+ return { label, tools: members.map((index) => tools[index].name) };
47
+ });
48
+
49
+ const riskyPairs = pairs
50
+ .filter((pair) => clusterOf.get(pair.i) !== clusterOf.get(pair.j))
51
+ .map((pair) => ({
52
+ a: tools[pair.i].name,
53
+ b: tools[pair.j].name,
54
+ similarity: Number(pair.similarity.toFixed(2)),
55
+ basis: pair.basis
56
+ }))
57
+ .sort((x, y) => y.similarity - x.similarity);
58
+
59
+ return { threshold, total: tools.length, clusters, riskyPairs };
60
+ }
61
+
62
+ function labelFor(names) {
63
+ const counts = new Map();
64
+ for (const name of names) {
65
+ for (const token of String(name).toLowerCase().split(/[^a-z0-9]+/)) {
66
+ if (!token || GENERIC_NAME_TOKENS.has(token)) continue;
67
+ counts.set(token, (counts.get(token) ?? 0) + 1);
68
+ }
69
+ }
70
+ let best = null;
71
+ for (const [token, count] of counts) {
72
+ if (!best || count > best.count || (count === best.count && token < best.token)) best = { token, count };
73
+ }
74
+ return best && best.count > 1 ? best.token : "";
75
+ }
76
+
77
+ function uniqueLabel(base, used, position) {
78
+ let label = base || `cluster-${position + 1}`;
79
+ let suffix = 2;
80
+ while (used.has(label)) {
81
+ label = `${base || `cluster-${position + 1}`}-${suffix}`;
82
+ suffix += 1;
83
+ }
84
+ used.add(label);
85
+ return label;
86
+ }
87
+
88
+ export function formatClusters(result) {
89
+ const lines = [
90
+ `tools ${result.total} → clusters ${result.clusters.length}(threshold ${result.threshold})`
91
+ ];
92
+ for (const cluster of result.clusters) {
93
+ lines.push(` [${cluster.label}] ${cluster.tools.length}: ${cluster.tools.join(", ")}`);
94
+ }
95
+ if (result.riskyPairs.length > 0) {
96
+ lines.push("", "跨组高风险(相似但被分到不同组,合并挂载会互抢):");
97
+ for (const pair of result.riskyPairs) {
98
+ const reason = pair.basis === "structural" ? "形状接近" : "描述相近";
99
+ lines.push(` ${pair.a} × ${pair.b} ${pair.similarity.toFixed(2)}(${reason})`);
100
+ }
101
+ }
102
+ return lines.join("\n");
103
+ }
package/lib/compare.js CHANGED
@@ -129,6 +129,55 @@ export function compareScorecards(before, after) {
129
129
  executionChanged,
130
130
  executionBefore,
131
131
  executionAfter,
132
+ models,
133
+ profiles: compareProfiles(before, after)
134
+ };
135
+ }
136
+
137
+ function compareProfiles(before, after) {
138
+ const beforeReport = before.profiles ?? null;
139
+ const afterReport = after.profiles ?? null;
140
+ if (!beforeReport && !afterReport) return null;
141
+ if (!beforeReport || !afterReport) {
142
+ return { comparable: false, reason: "只有一份报告包含分组结果", models: [] };
143
+ }
144
+ const comparable = beforeReport.hash === afterReport.hash;
145
+ const modelsAfter = new Map((afterReport.models ?? []).map((model) => [model.model, model]));
146
+ const models = [];
147
+ for (const modelBefore of beforeReport.models ?? []) {
148
+ const modelAfter = modelsAfter.get(modelBefore.model);
149
+ if (!modelAfter) {
150
+ models.push({ model: modelBefore.model, reason: "模型不在新报告中", profiles: [] });
151
+ continue;
152
+ }
153
+ const entriesAfter = new Map((modelAfter.profiles ?? []).map((entry) => [entry.profile, entry]));
154
+ const entries = [];
155
+ for (const entryBefore of modelBefore.profiles ?? []) {
156
+ const entryAfter = entriesAfter.get(entryBefore.profile);
157
+ if (!entryAfter) {
158
+ entries.push({ name: entryBefore.profile, reason: "新报告没有这一组" });
159
+ continue;
160
+ }
161
+ entries.push({
162
+ name: entryBefore.profile,
163
+ reviewed: entryAfter.reviewed,
164
+ mounted: {
165
+ before: entryBefore.mounted?.selectionAccuracy ?? null,
166
+ after: entryAfter.mounted?.selectionAccuracy ?? null
167
+ },
168
+ allInOne: {
169
+ before: entryBefore.allInOne?.selectionAccuracy ?? null,
170
+ after: entryAfter.allInOne?.selectionAccuracy ?? null
171
+ }
172
+ });
173
+ }
174
+ models.push({ model: modelBefore.model, profiles: entries });
175
+ }
176
+ return {
177
+ comparable,
178
+ reason: comparable ? null : "分组定义已变更(profiles hash 不同)",
179
+ beforeHash: beforeReport.hash,
180
+ afterHash: afterReport.hash,
132
181
  models
133
182
  };
134
183
  }
@@ -194,9 +243,44 @@ export function formatCompare(report) {
194
243
  }
195
244
  }
196
245
 
246
+ if (report.profiles) {
247
+ lines.push("");
248
+ if (!report.profiles.comparable) {
249
+ lines.push(`挂载分组: ${report.profiles.reason}`);
250
+ } else {
251
+ lines.push(`挂载分组(同一定义 ${report.profiles.beforeHash},组内准确率):`);
252
+ }
253
+ for (const model of report.profiles.models) {
254
+ if (model.reason) {
255
+ lines.push(` [${model.model}] ${model.reason}`);
256
+ continue;
257
+ }
258
+ lines.push(` [${model.model}]`);
259
+ for (const entry of model.profiles) {
260
+ if (entry.reason) {
261
+ lines.push(` ${entry.name}: ${entry.reason}`);
262
+ continue;
263
+ }
264
+ const delta =
265
+ entry.mounted.before === null || entry.mounted.after === null
266
+ ? ""
267
+ : `(${signed(entry.mounted.after - entry.mounted.before)})`;
268
+ lines.push(
269
+ ` ${entry.name} ${percent(entry.mounted.before)} → ${percent(entry.mounted.after)}${delta}` +
270
+ ` 全量 ${percent(entry.allInOne.before)} → ${percent(entry.allInOne.after)}`
271
+ );
272
+ }
273
+ }
274
+ }
275
+
197
276
  return lines.join("\n");
198
277
  }
199
278
 
279
+ function signed(value) {
280
+ const points = Math.round(value * 100);
281
+ return `${points > 0 ? "+" : ""}${points}pp`;
282
+ }
283
+
200
284
  function diffEntries(beforeList, afterList, key) {
201
285
  const before = new Map((beforeList ?? []).map((entry) => [entry[key], entry.hash]));
202
286
  const after = new Map((afterList ?? []).map((entry) => [entry[key], entry.hash]));
package/lib/lint.js CHANGED
Binary file
package/lib/mcp-server.js CHANGED
@@ -32,7 +32,7 @@ export async function startMcpServer() {
32
32
  {
33
33
  title: "Scan tools",
34
34
  description:
35
- "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核)。",
35
+ "扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
36
36
  inputSchema: {
37
37
  root: z.string().optional().describe("仓库目录,默认进程当前目录"),
38
38
  probe: z.boolean().optional().describe("是否连接 MCP server 拉取工具清单"),
@@ -142,13 +142,26 @@ export async function startMcpServer() {
142
142
  suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json"),
143
143
  models: z.string().optional().describe("逗号分隔的模型 id,如 mock,dsv4-flash"),
144
144
  cases: z.string().optional().describe("只跑这些用例 id(逗号分隔)"),
145
+ profile: z
146
+ .string()
147
+ .optional()
148
+ .describe("按挂载组评分:all 或组名(逗号分隔);套件需含 profiles,结果里带 groups/lifted/jammed"),
149
+ repeat: z
150
+ .number()
151
+ .int()
152
+ .min(1)
153
+ .max(10)
154
+ .optional()
155
+ .describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
145
156
  failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2")
146
157
  }
147
158
  },
148
- async ({ suite, models, cases, failUnder }) => {
159
+ async ({ suite, models, cases, profile, repeat, failUnder }) => {
149
160
  const args = ["eval", "--json", "--suite", suitePath(suite)];
150
161
  if (models) args.push("--models", models);
151
162
  if (cases) args.push("--case", cases);
163
+ if (profile) args.push("--profile", profile);
164
+ if (repeat !== undefined) args.push("--repeat", String(repeat));
152
165
  if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
153
166
 
154
167
  const result = await runCli(args);