modelfunction-cli 0.1.3 → 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 +35 -10
- package/fixtures/suite.json +5 -1
- package/lib/ablate.js +2 -1
- package/lib/cases.js +96 -23
- package/lib/cluster.js +103 -0
- package/lib/compare.js +84 -0
- package/lib/lint.js +0 -0
- package/lib/mcp-server.js +15 -2
- package/lib/profile.js +304 -0
- package/lib/scan.js +185 -5
- package/lib/score.js +22 -0
- package/lib/suite.js +2 -0
- package/mf.js +260 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx -y modelfunction-cli@0.
|
|
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` |
|
|
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.
|
|
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.
|
|
133
|
+
claude mcp add modelfunction -- npx -y modelfunction-cli@0.2.0 mcp
|
|
109
134
|
```
|
|
110
135
|
|
|
111
136
|
```text
|
package/fixtures/suite.json
CHANGED
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(
|
|
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
|
|
|
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
|
|
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);
|
|
248
|
+
const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
|
|
173
249
|
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
|
-
});
|
|
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);
|
|
@@ -227,7 +298,9 @@ function jaccard(a, b) {
|
|
|
227
298
|
function tokens(text) {
|
|
228
299
|
const set = new Set();
|
|
229
300
|
const lowered = String(text ?? "").toLowerCase();
|
|
230
|
-
for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? [])
|
|
301
|
+
for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) {
|
|
302
|
+
if (!STOPWORDS.has(word)) set.add(word);
|
|
303
|
+
}
|
|
231
304
|
for (const chunk of lowered.match(/[一-鿿]+/g) ?? []) {
|
|
232
305
|
if (chunk.length === 1) set.add(chunk);
|
|
233
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);
|