modelfunction-cli 0.2.0 → 0.4.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 +5 -5
- package/lib/cases.js +32 -13
- package/lib/cluster.js +57 -2
- package/lib/js-tools.js +954 -0
- package/lib/lint.js +0 -0
- package/lib/mcp-server.js +1 -0
- package/lib/scan.js +80 -14
- package/lib/suite.js +20 -3
- package/mf.js +12 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,14 +64,14 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
64
64
|
|
|
65
65
|
| 命令 | 作用 |
|
|
66
66
|
| --- | --- |
|
|
67
|
-
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool
|
|
68
|
-
| `mf eval` | 跑评分卡;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分 |
|
|
67
|
+
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--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
70
|
| `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
|
|
71
|
-
| `mf lint` |
|
|
71
|
+
| `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
|
|
72
72
|
| `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
|
|
73
73
|
| `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
|
|
74
|
-
| `mf cluster` |
|
|
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`
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
}
|