modelfunction-cli 0.1.3 → 0.3.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.3 --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` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--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,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
72
+ | `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
73
+ | `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
74
+ | `mf cluster` | 聚类工具,建议挂载分组;参数形状拆散的同一族(如 `sessions_*`)列为「同族候选」;`--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.3", "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` 与 `repeat` 参数:传 `all` 或组名按挂载组评分,`repeat` 取多数结果)。
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.3 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 只做静态检查,不参与模型评测`);
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,26 +207,47 @@ export function formatCoverage(report) {
157
207
  return lines.join("\n");
158
208
  }
159
209
 
160
- export function suspectedPairs(tools) {
210
+ export function pairSimilarity(x, y, { extraStop } = {}) {
211
+ const a = tokens(`${x.name} ${x.description}`, extraStop);
212
+ const b = tokens(`${y.name} ${y.description}`, extraStop);
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
+
244
+ export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop } = {}) {
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 lexical = shared / union.size;
171
- const structural = structuralScore(tools[i], tools[j]);
172
- const similarity = Math.max(lexical, structural);
173
- if (similarity >= PAIR_SIMILARITY) {
174
- pairs.push({
175
- a: tools[i].name,
176
- b: tools[j].name,
177
- similarity,
178
- basis: structural > lexical ? "structural" : "lexical"
179
- });
248
+ const { similarity, basis } = pairSimilarity(tools[i], tools[j], { extraStop });
249
+ if (similarity >= threshold) {
250
+ pairs.push({ a: tools[i].name, b: tools[j].name, similarity, basis });
180
251
  }
181
252
  }
182
253
  }
@@ -188,8 +259,8 @@ export function suspectedPairs(tools) {
188
259
  // lexical score alone misses. Name order is unreliable: `tavily_search` leads
189
260
  // with the vendor, `search_docs` with the action.
190
261
  function structuralScore(x, y) {
191
- const left = nameTokens(x.name);
192
- const right = nameTokens(y.name);
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));
193
264
  const sharedName = left.some((token) => right.includes(token));
194
265
  if (!sharedName) return 0;
195
266
  const params = paramNames(x);
@@ -204,12 +275,25 @@ function structuralScore(x, y) {
204
275
  return 0.5 + 0.5 * (0.6 * paramOverlap + 0.4 * requiredOverlap);
205
276
  }
206
277
 
207
- function nameTokens(name) {
208
- return String(name ?? "")
278
+ // Plural folds matter on real tool sets: sessions_list / session_status and
279
+ // issue / issues describe the same thing, but naive token matching misses both.
280
+ // Only the safe cases are folded; "class"/"process" already end in ss and pass
281
+ // through untouched.
282
+ export function normalizeWord(word) {
283
+ const value = String(word ?? "").toLowerCase();
284
+ if (value.length < 3) return value;
285
+ if (/ies$/.test(value)) return `${value.slice(0, -3)}y`;
286
+ if (/s$/.test(value) && !/(ss|us|is)$/.test(value)) return value.slice(0, -1);
287
+ return value;
288
+ }
289
+
290
+ export function nameTokens(name) {
291
+ return String(name)
209
292
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
210
293
  .toLowerCase()
211
294
  .split(/[^a-z0-9]+/)
212
- .filter(Boolean);
295
+ .filter(Boolean)
296
+ .map(normalizeWord);
213
297
  }
214
298
 
215
299
  function paramNames(tool) {
@@ -224,10 +308,18 @@ function jaccard(a, b) {
224
308
  return shared / union.size;
225
309
  }
226
310
 
227
- function tokens(text) {
311
+ function tokens(text, extraStop) {
228
312
  const set = new Set();
229
313
  const lowered = String(text ?? "").toLowerCase();
230
- for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) set.add(word);
314
+ for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) {
315
+ for (const part of word.split("_")) {
316
+ const normalized = normalizeWord(part);
317
+ if (!normalized) continue;
318
+ if (STOPWORDS.has(part) || STOPWORDS.has(normalized)) continue;
319
+ if (extraStop?.has(part) || extraStop?.has(normalized)) continue;
320
+ set.add(normalized);
321
+ }
322
+ }
231
323
  for (const chunk of lowered.match(/[一-鿿]+/g) ?? []) {
232
324
  if (chunk.length === 1) set.add(chunk);
233
325
  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,158 @@
1
+ import { pairSimilarity, familyScore, nameTokens, GENERIC_NAME_TOKENS } from "./cases.js";
2
+
3
+ // A shared domain noun ("session", "goal", "tool") says these tools serve one
4
+ // purpose even when their verbs and parameter shapes differ — exactly what
5
+ // familyScore refuses to merge. Surface it as a hint instead of guessing: the
6
+ // platform already knows its real mounting map.
7
+ const FAMILY_HINT_STOP = new Set([
8
+ ...GENERIC_NAME_TOKENS,
9
+ "run", "call", "use", "search", "find", "query", "fetch", "create", "update",
10
+ "list", "send", "read", "write", "add", "remove", "delete", "start", "stop",
11
+ "open", "close", "poll", "log", "status", "data", "info", "result", "generate",
12
+ "save", "load", "merge", "sync", "scan", "check", "compare", "exec", "probe"
13
+ ]);
14
+
15
+ export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
16
+ const scores = tools.map(() => new Array(tools.length).fill(0));
17
+ const pairs = [];
18
+ for (let i = 0; i < tools.length; i += 1) {
19
+ for (let j = i + 1; j < tools.length; j += 1) {
20
+ const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
21
+ const family = familyScore(tools[i], tools[j]);
22
+ const link = Math.max(similarity, family);
23
+ scores[i][j] = link;
24
+ scores[j][i] = link;
25
+ if (link >= threshold) {
26
+ pairs.push({ i, j, similarity, basis: family > similarity ? "family" : basis });
27
+ }
28
+ }
29
+ }
30
+
31
+ const degree = tools.map((_, index) =>
32
+ pairs.reduce((sum, pair) => sum + (pair.i === index || pair.j === index ? 1 : 0), 0)
33
+ );
34
+ const order = tools
35
+ .map((tool, index) => index)
36
+ .sort((a, b) => degree[b] - degree[a] || tools[a].name.localeCompare(tools[b].name));
37
+
38
+ const raw = [];
39
+ for (const index of order) {
40
+ let best = null;
41
+ for (const cluster of raw) {
42
+ if (cluster.members.length >= maxSize) continue;
43
+ const average =
44
+ cluster.members.reduce((sum, member) => sum + scores[index][member], 0) / cluster.members.length;
45
+ if (average >= threshold && (!best || average > best.average)) best = { cluster, average };
46
+ }
47
+ if (best) best.cluster.members.push(index);
48
+ else raw.push({ members: [index] });
49
+ }
50
+
51
+ const clusterOf = new Map();
52
+ raw.forEach((cluster, position) => cluster.members.forEach((member) => clusterOf.set(member, position)));
53
+ const clusterSize = raw.map((cluster) => cluster.members.length);
54
+ const familyHints = buildFamilyHints(tools, clusterOf, clusterSize);
55
+
56
+ const usedLabels = new Set();
57
+ const clusters = raw.map((cluster, position) => {
58
+ const members = [...cluster.members].sort((a, b) => tools[a].name.localeCompare(tools[b].name));
59
+ const label = uniqueLabel(labelFor(members.map((index) => tools[index].name)), usedLabels, position);
60
+ return { label, tools: members.map((index) => tools[index].name) };
61
+ });
62
+
63
+ const riskyPairs = pairs
64
+ .filter((pair) => clusterOf.get(pair.i) !== clusterOf.get(pair.j))
65
+ .map((pair) => ({
66
+ a: tools[pair.i].name,
67
+ b: tools[pair.j].name,
68
+ similarity: Number(pair.similarity.toFixed(2)),
69
+ basis: pair.basis
70
+ }))
71
+ .sort((x, y) => y.similarity - x.similarity);
72
+
73
+ return { threshold, total: tools.length, clusters, riskyPairs, familyHints };
74
+ }
75
+
76
+ function buildFamilyHints(tools, clusterOf, clusterSize) {
77
+ const byToken = new Map();
78
+ tools.forEach((tool, index) => {
79
+ for (const token of new Set(nameTokens(tool.name))) {
80
+ if (token.length < 3 || FAMILY_HINT_STOP.has(token)) continue;
81
+ const list = byToken.get(token) ?? [];
82
+ list.push(index);
83
+ byToken.set(token, list);
84
+ }
85
+ });
86
+
87
+ const hints = [];
88
+ const seen = new Set();
89
+ for (const [token, members] of byToken) {
90
+ if (members.length < 2) continue;
91
+ const clusterIds = new Set(members.map((index) => clusterOf.get(index)));
92
+ if (clusterIds.size < 2) continue;
93
+ const names = members.map((index) => tools[index].name).sort();
94
+ const signature = names.join("\n");
95
+ if (seen.has(signature)) continue;
96
+ seen.add(signature);
97
+ const isLoose = (index) => clusterSize[clusterOf.get(index)] === 1;
98
+ hints.push({
99
+ token,
100
+ size: names.length,
101
+ merged: members.filter((index) => !isLoose(index)).map((index) => tools[index].name).sort(),
102
+ loose: members.filter(isLoose).map((index) => tools[index].name).sort()
103
+ });
104
+ }
105
+ return hints.sort((a, b) => b.size - a.size || a.token.localeCompare(b.token)).slice(0, 8);
106
+ }
107
+
108
+ function labelFor(names) {
109
+ const counts = new Map();
110
+ for (const name of names) {
111
+ for (const token of String(name).toLowerCase().split(/[^a-z0-9]+/)) {
112
+ if (!token || GENERIC_NAME_TOKENS.has(token)) continue;
113
+ counts.set(token, (counts.get(token) ?? 0) + 1);
114
+ }
115
+ }
116
+ let best = null;
117
+ for (const [token, count] of counts) {
118
+ if (!best || count > best.count || (count === best.count && token < best.token)) best = { token, count };
119
+ }
120
+ return best && best.count > 1 ? best.token : "";
121
+ }
122
+
123
+ function uniqueLabel(base, used, position) {
124
+ let label = base || `cluster-${position + 1}`;
125
+ let suffix = 2;
126
+ while (used.has(label)) {
127
+ label = `${base || `cluster-${position + 1}`}-${suffix}`;
128
+ suffix += 1;
129
+ }
130
+ used.add(label);
131
+ return label;
132
+ }
133
+
134
+ export function formatClusters(result) {
135
+ const lines = [
136
+ `tools ${result.total} → clusters ${result.clusters.length}(threshold ${result.threshold})`
137
+ ];
138
+ for (const cluster of result.clusters) {
139
+ lines.push(` [${cluster.label}] ${cluster.tools.length}: ${cluster.tools.join(", ")}`);
140
+ }
141
+ if (result.riskyPairs.length > 0) {
142
+ lines.push("", "跨组高风险(相似但被分到不同组,合并挂载会互抢):");
143
+ for (const pair of result.riskyPairs) {
144
+ const reason = pair.basis === "structural" ? "形状接近" : "描述相近";
145
+ lines.push(` ${pair.a} × ${pair.b} ${pair.similarity.toFixed(2)}(${reason})`);
146
+ }
147
+ }
148
+ if ((result.familyHints ?? []).length > 0) {
149
+ lines.push("", "同族候选(共享标识词但参数形状不同,未自动合并;以平台侧真实挂载映射为准):");
150
+ for (const hint of result.familyHints) {
151
+ const parts = [];
152
+ if (hint.merged.length > 0) parts.push(`已合并 ${hint.merged.join(", ")}`);
153
+ if (hint.loose.length > 0) parts.push(`未合并 ${hint.loose.join(", ")}`);
154
+ lines.push(` [${hint.token}] ${hint.size} 个:${parts.join(" | ")}`);
155
+ }
156
+ }
157
+ return lines.join("\n");
158
+ }
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]));