modelfunction-cli 0.2.0 → 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
@@ -65,13 +65,13 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
65
65
  | 命令 | 作用 |
66
66
  | --- | --- |
67
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` 按挂载组分别评分 |
68
+ | `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分 |
69
69
  | `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
70
70
  | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
71
- | `mf lint` | 静态检查描述与参数 schema;有 `profiles` 时给出「这些组不要合并挂载」 |
71
+ | `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
72
72
  | `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
73
73
  | `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
74
- | `mf cluster` | 聚类工具,建议挂载分组;`--out` 把分组写成套件的 `profiles` |
74
+ | `mf cluster` | 聚类工具,建议挂载分组;参数形状拆散的同一族(如 `sessions_*`)列为「同族候选」;`--out` 把分组写成套件的 `profiles` |
75
75
  | `mf exec` | 单工具执行 + 指标(Node / Python),支持 `--allow-read/write/net`、超时、sideEffects 准入 |
76
76
  | `mf eval --execute` | 一次跑完选择 + 参数 + 运行断言(`toolInput` / `expect`) |
77
77
  | `mf sandbox check` | 探测本机可用隔离引擎与降级建议 |
@@ -114,7 +114,7 @@ mf eval --suite mf.suite.json --models internal-ds \
114
114
  }
115
115
  ```
116
116
 
117
- 暴露:`scan_tools`、`list_suite`、`coverage`、`add_cases`、`run_eval`(`run_eval` 支持 `profile` 参数,传 `all` 或组名即可按挂载组评分)。
117
+ 暴露:`scan_tools`、`list_suite`、`coverage`、`add_cases`、`run_eval`(`run_eval` 支持 `profile` 与 `repeat` 参数:传 `all` 或组名按挂载组评分,`repeat` 取多数结果)。
118
118
 
119
119
  ## 数据边界
120
120
 
package/lib/cases.js CHANGED
@@ -141,7 +141,7 @@ export function computeCoverage(suite) {
141
141
  if (unreviewed > 0) warnings.push(`${unreviewed} 条未复核用例不计入正式分数`);
142
142
 
143
143
  const skills = suite.skills?.length ?? 0;
144
- if (skills > 0) warnings.push(`${skills} 个 skill 不参与工具评测(当前只覆盖 function / MCP 工具)`);
144
+ if (skills > 0) warnings.push(`${skills} 个 skill 只做静态检查,不参与模型评测`);
145
145
 
146
146
  const profileList = suite.profiles
147
147
  ? normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name))
@@ -173,7 +173,7 @@ export function formatCoverage(report) {
173
173
  const lines = [
174
174
  `cases: ${report.total}(负例 ${report.negative},${Math.round(report.negativeRatio * 100)}%;未复核 ${report.unreviewed})`
175
175
  ];
176
- if (report.skills > 0) lines.push(`skills: ${report.skills}(不参与工具评测)`);
176
+ if (report.skills > 0) lines.push(`skills: ${report.skills}(仅静态检查,不参与模型评测)`);
177
177
  lines.push(
178
178
  "",
179
179
  "tool cases args reviewed",
@@ -207,9 +207,9 @@ export function formatCoverage(report) {
207
207
  return lines.join("\n");
208
208
  }
209
209
 
210
- export function pairSimilarity(x, y) {
211
- const a = tokens(`${x.name} ${x.description}`);
212
- const b = tokens(`${y.name} ${y.description}`);
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
213
  const union = new Set([...a, ...b]);
214
214
  let lexical = 0;
215
215
  if (union.size > 0) {
@@ -241,12 +241,12 @@ export function familyScore(x, y) {
241
241
  return paramNames(x).size === 0 && paramNames(y).size === 0 ? 0.6 : 0.2;
242
242
  }
243
243
 
244
- export function suspectedPairs(tools) {
244
+ export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop } = {}) {
245
245
  const pairs = [];
246
246
  for (let i = 0; i < tools.length; i += 1) {
247
247
  for (let j = i + 1; j < tools.length; j += 1) {
248
- const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
249
- if (similarity >= PAIR_SIMILARITY) {
248
+ const { similarity, basis } = pairSimilarity(tools[i], tools[j], { extraStop });
249
+ if (similarity >= threshold) {
250
250
  pairs.push({ a: tools[i].name, b: tools[j].name, similarity, basis });
251
251
  }
252
252
  }
@@ -275,12 +275,25 @@ function structuralScore(x, y) {
275
275
  return 0.5 + 0.5 * (0.6 * paramOverlap + 0.4 * requiredOverlap);
276
276
  }
277
277
 
278
- function nameTokens(name) {
279
- 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)
280
292
  .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
281
293
  .toLowerCase()
282
294
  .split(/[^a-z0-9]+/)
283
- .filter(Boolean);
295
+ .filter(Boolean)
296
+ .map(normalizeWord);
284
297
  }
285
298
 
286
299
  function paramNames(tool) {
@@ -295,11 +308,17 @@ function jaccard(a, b) {
295
308
  return shared / union.size;
296
309
  }
297
310
 
298
- function tokens(text) {
311
+ function tokens(text, extraStop) {
299
312
  const set = new Set();
300
313
  const lowered = String(text ?? "").toLowerCase();
301
314
  for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) {
302
- if (!STOPWORDS.has(word)) set.add(word);
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
+ }
303
322
  }
304
323
  for (const chunk of lowered.match(/[一-鿿]+/g) ?? []) {
305
324
  if (chunk.length === 1) set.add(chunk);
package/lib/cluster.js CHANGED
@@ -1,4 +1,16 @@
1
- import { pairSimilarity, familyScore, GENERIC_NAME_TOKENS } from "./cases.js";
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
+ ]);
2
14
 
3
15
  export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
4
16
  const scores = tools.map(() => new Array(tools.length).fill(0));
@@ -38,6 +50,8 @@ export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
38
50
 
39
51
  const clusterOf = new Map();
40
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);
41
55
 
42
56
  const usedLabels = new Set();
43
57
  const clusters = raw.map((cluster, position) => {
@@ -56,7 +70,39 @@ export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
56
70
  }))
57
71
  .sort((x, y) => y.similarity - x.similarity);
58
72
 
59
- return { threshold, total: tools.length, clusters, riskyPairs };
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);
60
106
  }
61
107
 
62
108
  function labelFor(names) {
@@ -99,5 +145,14 @@ export function formatClusters(result) {
99
145
  lines.push(` ${pair.a} × ${pair.b} ${pair.similarity.toFixed(2)}(${reason})`);
100
146
  }
101
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
+ }
102
157
  return lines.join("\n");
103
158
  }
package/lib/lint.js CHANGED
Binary file
package/lib/scan.js CHANGED
@@ -294,7 +294,9 @@ export function buildSuiteDraft(report) {
294
294
  })),
295
295
  cases: []
296
296
  };
297
- if (report.skills.length > 0) draft.skills = report.skills.map((skill) => skill.name);
297
+ if (report.skills.length > 0) {
298
+ draft.skills = report.skills.map((skill) => ({ name: skill.name, description: skill.description }));
299
+ }
298
300
  // Where a tool came from is the only mount hint the scan can observe without
299
301
  // guessing: one MCP server or one spec file is normally one thing agents mount.
300
302
  const profiles = groupBySource(report.tools);
@@ -354,7 +356,7 @@ export function formatScanSummary(report) {
354
356
  lines.push(
355
357
  "",
356
358
  `tools: ${report.tools.length}`,
357
- `skills: ${report.skills.length}(不进入工具评测)`,
359
+ `skills: ${report.skills.length}(仅静态检查)`,
358
360
  `mcp servers: ${report.mcpServers.length}${probed > 0 ? `(已 probe ${probed} 个)` : "(需 --probe 才能拿到工具清单)"}`,
359
361
  `code match: ${report.codeMatches.length}(low confidence,需人工确认)`
360
362
  );
package/lib/suite.js CHANGED
@@ -29,7 +29,7 @@ export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
29
29
  tools,
30
30
  cases,
31
31
  profiles: raw.profiles ?? null,
32
- skills: Array.isArray(raw.skills) ? raw.skills.map(String) : [],
32
+ skills: Array.isArray(raw.skills) ? raw.skills.map(normalizeSkill) : [],
33
33
  execution:
34
34
  raw.execution && typeof raw.execution === "object" && !Array.isArray(raw.execution)
35
35
  ? { ...raw.execution }
@@ -58,6 +58,23 @@ async function normalizeTool(entry, baseDir) {
58
58
  };
59
59
  }
60
60
 
61
+ // Skills carry no parameter schema in the prompt-injected form (SKILL.md
62
+ // frontmatter), so the only checkable surface is name + description. Both the
63
+ // bare-name shorthand and the full form are accepted.
64
+ function normalizeSkill(entry, index) {
65
+ if (typeof entry === "string") {
66
+ const name = entry.trim();
67
+ if (!name) throw new Error(`skill #${index + 1}: name must be a non-empty string`);
68
+ return { name, description: "" };
69
+ }
70
+ if (entry && typeof entry === "object") {
71
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
72
+ if (!name) throw new Error(`skill #${index + 1}: "name" must be a non-empty string`);
73
+ return { name, description: typeof entry.description === "string" ? entry.description : "" };
74
+ }
75
+ throw new Error(`skill #${index + 1}: must be a name or {name, description}`);
76
+ }
77
+
61
78
  function normalizeCase(entry, index, toolNames) {
62
79
  const id = entry.id ?? `case_${index + 1}`;
63
80
  if (typeof entry.input !== "string" || entry.input.trim() === "") {
package/mf.js CHANGED
@@ -50,10 +50,10 @@ Usage:
50
50
  mf scan [options]
51
51
  mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
52
52
  mf coverage [--suite <file>] [--json]
53
- mf lint [--suite <file>] [--json]
53
+ mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
54
54
  mf compare <before.json> <after.json> [--json]
55
55
  mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
56
- mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组
56
+ mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
57
57
  mf exec --entry <file> (--input <json> | --input-json <json>) [--runtime node|python] [--python <cmd>] [--export run] [--allow-read <paths>] [--allow-write <paths>] [--allow-net <hosts>] [--timeout <ms>] [--warmup <n>] [--json]
58
58
  mf sandbox check [--json] # probe available isolation engines on this machine
59
59
  mf mcp # start stdio MCP server for coding agents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",