modelfunction-cli 0.5.2 → 0.5.4
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 +6 -4
- package/SCHEMA.md +17 -10
- package/lib/audit.js +3 -3
- package/lib/cases.js +66 -42
- package/lib/check.d.ts +11 -4
- package/lib/check.js +34 -6
- package/lib/cluster.js +10 -3
- package/lib/lint.js +13 -5
- package/lib/profile.js +68 -23
- package/lib/score.js +32 -4
- package/lib/suite.js +7 -0
- package/mf.js +32 -5
- 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.5.
|
|
8
|
+
npx -y modelfunction-cli@0.5.3 --version
|
|
9
9
|
# 或
|
|
10
10
|
npm i -D modelfunction-cli
|
|
11
11
|
npx mf --version
|
|
@@ -126,7 +126,7 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
126
126
|
"mcpServers": {
|
|
127
127
|
"modelfunction": {
|
|
128
128
|
"command": "npx",
|
|
129
|
-
"args": ["-y", "modelfunction-cli@0.5.
|
|
129
|
+
"args": ["-y", "modelfunction-cli@0.5.3", "mcp"]
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
@@ -138,7 +138,9 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
138
138
|
|
|
139
139
|
对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
|
|
140
140
|
|
|
141
|
-
`0.5.
|
|
141
|
+
`0.5.3` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查、门禁严重度收敛、静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms)、`installed` 形状校验与 `profiles` 平台形态兼容。示例统一使用 `@0.5.3`。
|
|
142
|
+
|
|
143
|
+
`0.5.4`(待发布):用例新增 `excludeFromBase` / `excludeFromProfiles`(布尔,排除计分但不省调用);评分卡新增行级 `errorKind` 与 `errors` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`;挂载对比不再把超时/HTTP 错误记成 jammed,分组表单列 `errors`;`mf audit` 分组表增加 `neg%`。契约见 `SCHEMA.md`。
|
|
142
144
|
|
|
143
145
|
## 数据边界
|
|
144
146
|
|
|
@@ -167,7 +169,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
|
|
|
167
169
|
|
|
168
170
|
```bash
|
|
169
171
|
# 1. 把 mf 挂成 MCP server
|
|
170
|
-
claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.
|
|
172
|
+
claude mcp add modelfunction -- npx -y modelfunction-cli@0.5.3 mcp
|
|
171
173
|
```
|
|
172
174
|
|
|
173
175
|
```text
|
package/SCHEMA.md
CHANGED
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"note": "",
|
|
52
52
|
"source": "manual",
|
|
53
53
|
"reviewed": true,
|
|
54
|
+
"excludeFromBase": false,
|
|
55
|
+
"excludeFromProfiles": false,
|
|
54
56
|
"tags": []
|
|
55
57
|
}
|
|
56
58
|
```
|
|
@@ -59,6 +61,7 @@
|
|
|
59
61
|
- `expectedArgs` 仅在有 `expectedTool` 时可用;值为断言,支持字面量、`$contains`、`$oneOf`、`$exists`。
|
|
60
62
|
- `toolInput` + `expect` 用于 `--execute`:`expect` 支持 `ok`、`contains`、`equals`、`errorType`、`maxDurationMs`、`maxPeakRssMB`。
|
|
61
63
|
- `reviewed` 缺省为 `true`(手写用例);`mf case add` 缺省写入 `source=agent`、`reviewed=false`。
|
|
64
|
+
- `excludeFromBase` / `excludeFromProfiles` 必须是布尔值。用例仍会实际调用(不省 token),只是不计入对应范围的正式指标;用于「该用例只对某一挂载形态有意义」的情况。
|
|
62
65
|
|
|
63
66
|
### skills[]
|
|
64
67
|
|
|
@@ -73,7 +76,7 @@ skills 只做静态检查,不参与模型评测。
|
|
|
73
76
|
|
|
74
77
|
### profiles
|
|
75
78
|
|
|
76
|
-
组名 →
|
|
79
|
+
组名 → 工具名数组(`{ "group": ["tool", ...] }`)。也接受平台常见的两种形态:对象 `{ "0": { agentType, tools, skills } }` 与数组 `[{ agentType, tools, skills }]`,组名依次取 `name` → `agentType` → 键/下标;`skills` 字段目前被忽略(profiles 只用于工具挂载与 eval 分组)。所有工具名必须存在;组可重叠,组名不可重复。`mf eval --profile` 与 `mf ablate --profile` 依赖它。
|
|
77
80
|
|
|
78
81
|
### execution
|
|
79
82
|
|
|
@@ -89,8 +92,10 @@ skills 只做静态检查,不参与模型评测。
|
|
|
89
92
|
"fingerprint": "sha256:...",
|
|
90
93
|
"execution": { "runtime": "node", "engine": "node-permission", "entry": "index.mjs", "hash": "..." }
|
|
91
94
|
},
|
|
92
|
-
"models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "
|
|
93
|
-
"
|
|
95
|
+
"models": [ { "model": "dsv4-flash", "total": 12, "reviewedTotal": 12, "excludedTotal": 0,
|
|
96
|
+
"selectionAccuracy": 0.83, "selectionAccuracyExcludingErrors": 0.83,
|
|
97
|
+
"argAccuracy": 0.9, "passRate": 0.75, "errors": 0, "errorsByKind": {},
|
|
98
|
+
"scoredTotal": 12, "p50LatencyMs": 900,
|
|
94
99
|
"p95LatencyMs": 1800, "avgCostCny": 0.001, "totalCostCny": 0.012,
|
|
95
100
|
"inputTokens": 1000, "cachedTokens": 0, "outputTokens": 200,
|
|
96
101
|
"rows": [ { "id": "c1", "expectedTool": "...", "actualTool": "...",
|
|
@@ -101,9 +106,10 @@ skills 只做静态检查,不参与模型评测。
|
|
|
101
106
|
}
|
|
102
107
|
```
|
|
103
108
|
|
|
104
|
-
- 正式指标只统计 `reviewed`
|
|
109
|
+
- 正式指标只统计 `reviewed` 且未被 `excludeFromBase` 排除的行;未复核与排除结果单独给出。
|
|
110
|
+
- `errors` 是调用层失败(超时、HTTP、网络)的数量,按 `errorsByKind` 分类;它们仍计入 `selectionAccuracy`(保守口径),同时给出排除错误后的 `selectionAccuracyExcludingErrors` 与可评分行数 `scoredTotal`。
|
|
105
111
|
- `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
|
|
106
|
-
- `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed
|
|
112
|
+
- `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`;错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
|
|
107
113
|
- `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
|
|
108
114
|
|
|
109
115
|
## 3. 扫描输出(`mf scan`)
|
|
@@ -112,7 +118,7 @@ skills 只做静态检查,不参与模型评测。
|
|
|
112
118
|
|
|
113
119
|
report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys`,headers 只保留 `headerKeys`;URL 去掉 userinfo / query / hash)、`probes[]`(`server` / `ok` / `toolCount?` / `tools?` / `error?`)、`sources[]`、`warnings[]`。`--probe` 支持 stdio 与 streamable HTTP 两种传输。
|
|
114
120
|
|
|
115
|
-
`scan --json`
|
|
121
|
+
`scan --json` 在工具与 skill 都未找到时退出码为 3(只有 skill 也算有结果,退出 0)。
|
|
116
122
|
|
|
117
123
|
## 4. lint JSON
|
|
118
124
|
|
|
@@ -159,7 +165,7 @@ report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` /
|
|
|
159
165
|
import { check } from "modelfunction-cli";
|
|
160
166
|
```
|
|
161
167
|
|
|
162
|
-
输入 `{ manifest, installed }`:`manifest` 是一个能力(`name` 必填,`description` 可选,`parameters` / `inputSchema` / `input` 任一为 JSON Schema;无 schema 视为 skill)。`installed`
|
|
168
|
+
输入 `{ manifest, installed }`:`manifest` 是一个能力(`name` 必填,`description` 可选,`parameters` / `inputSchema` / `input` 任一为 JSON Schema;无 schema 视为 skill)。`installed` 可以是能力数组、`{ tools: [], skills: [] }`,或直接传 `mf scan --json` 的输出(能力在 `report` 下)。空对象视为没有已装能力;其他无法识别的形状会抛错,避免静默返回假的 `pass`。
|
|
163
169
|
|
|
164
170
|
返回:
|
|
165
171
|
|
|
@@ -175,8 +181,9 @@ import { check } from "modelfunction-cli";
|
|
|
175
181
|
|
|
176
182
|
- `verdict` 只有 `pass` / `warn`,**没有 fail**;质量判定不阻断安装。
|
|
177
183
|
- **严重度策略(门禁语境)**:只有会改变模型行为的规则保持原等级——`desc-missing`、`param-type-missing`(error),`required-unsourced`、`pair-no-boundary`、`skill-pair-no-boundary`(warn);描述、命名、必填数量等质量规则降为 `info`,避免成熟工具集每次安装都告警。`mf lint` 不受影响,仍输出原始严重度。
|
|
178
|
-
-
|
|
184
|
+
- 抛错场景:`manifest.name` 缺失或为空;`installed` 不是上述三种形状之一(空对象仍视为无已装能力)。
|
|
179
185
|
- 同步、无网络、无文件系统、无状态。类型声明见 `lib/check.d.ts`。
|
|
186
|
+
- 性能(0.5.3):每个能力只分词一次,且只比较涉及新能力的对。真实 100 能力清单(53 新 + 47 已装)约 40–50ms,47 工具对 53 skill 约 25ms;550 个高度同质的合成最坏情况约 2s。
|
|
180
187
|
|
|
181
188
|
批量(一次安装一包):
|
|
182
189
|
|
|
@@ -229,7 +236,7 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
|
|
|
229
236
|
| 0 | 成功(`mf check` 的 warn 也是 0) |
|
|
230
237
|
| 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
|
|
231
238
|
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退,或 `--max-cost` 超限 |
|
|
232
|
-
| 3 | `mf exec` 被权限策略拒绝;`mf scan --json`
|
|
239
|
+
| 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具或 skill |
|
|
233
240
|
| 4 | `mf exec` 超时 |
|
|
234
241
|
|
|
235
242
|
## 9. 本地文件(0.5.0 起)
|
|
@@ -337,4 +344,4 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
|
|
|
337
344
|
}
|
|
338
345
|
```
|
|
339
346
|
|
|
340
|
-
`profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory
|
|
347
|
+
`profiles` 为 `null` 表示套件未定义分组。`clusters`/`coverage` 的明细与 §5/§4 同源;`actions` 是给人看的优先级列表。`mf audit` 恒退出 0(advisory),不阻断流程;负例占比告警只按整 suite 计算,分组表里的 `negativeRatio` 是该组「正例 + 负例」内的比例,仅供参考。
|
package/lib/audit.js
CHANGED
|
@@ -134,11 +134,11 @@ export function formatAudit(report) {
|
|
|
134
134
|
if (!profiles) {
|
|
135
135
|
lines.push("未定义 profiles(平台可先用 `mf cluster --out <suite>` 生成候选分组)。");
|
|
136
136
|
} else {
|
|
137
|
-
lines.push("group tools cases negatives tools-without-cases");
|
|
138
|
-
lines.push("---------------------- ----- ----- --------- -------------------");
|
|
137
|
+
lines.push("group tools cases negatives neg% tools-without-cases");
|
|
138
|
+
lines.push("---------------------- ----- ----- --------- ---- -------------------");
|
|
139
139
|
for (const row of profiles.groups) {
|
|
140
140
|
lines.push(
|
|
141
|
-
`${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${row.missing.length ? row.missing.join(", ") : "-"}`
|
|
141
|
+
`${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(9)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
|
|
142
142
|
);
|
|
143
143
|
}
|
|
144
144
|
if (profiles.unmounted.length > 0) lines.push("", `未挂载工具:${profiles.unmounted.join(", ")}`);
|
package/lib/cases.js
CHANGED
|
@@ -185,12 +185,12 @@ export function formatCoverage(report) {
|
|
|
185
185
|
);
|
|
186
186
|
}
|
|
187
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("---------------------- ----- ----- --- -------------------");
|
|
188
|
+
lines.push("", "profiles(分组后可评分的用例;负例每组都跑,比例按组内正例+负例计):");
|
|
189
|
+
lines.push("group tools cases neg neg% tools-without-cases");
|
|
190
|
+
lines.push("---------------------- ----- ----- --- ---- -------------------");
|
|
191
191
|
for (const row of report.profiles) {
|
|
192
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(", ") : "-"}`
|
|
193
|
+
`${row.name.padEnd(22)} ${String(row.tools).padStart(5)} ${String(row.cases).padStart(5)} ${String(row.negatives).padStart(3)} ${`${Math.round((row.negativeRatio ?? 0) * 100)}%`.padStart(4)} ${row.missing.length ? row.missing.join(", ") : "-"}`
|
|
194
194
|
);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -207,38 +207,52 @@ export function formatCoverage(report) {
|
|
|
207
207
|
return lines.join("\n");
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
210
|
+
// O(n²) callers prepare each capability once; the pair loop then only compares
|
|
211
|
+
// sets. Tokenizing inside the loop made a 550-capability gate take ~14s.
|
|
212
|
+
export function prepareItem(item, { extraStop } = {}) {
|
|
213
|
+
const names = nameTokens(item.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
|
|
214
|
+
return {
|
|
215
|
+
name: item.name,
|
|
216
|
+
tokens: tokens(`${item.name} ${item.description}`, extraStop),
|
|
217
|
+
nameTokens: names,
|
|
218
|
+
nameTokenSet: new Set(names),
|
|
219
|
+
params: paramNames(item),
|
|
220
|
+
required: new Set(item.parameters?.required ?? [])
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function pairSimilarityPrepared(a, b) {
|
|
225
|
+
const shared = intersectionSize(a.tokens, b.tokens);
|
|
226
|
+
const union = a.tokens.size + b.tokens.size - shared;
|
|
227
|
+
const lexical = union > 0 ? shared / union : 0;
|
|
228
|
+
const structural = intersects(a.nameTokenSet, b.nameTokenSet) ? structuralScorePrepared(a, b) : 0;
|
|
221
229
|
return {
|
|
222
230
|
similarity: Math.max(lexical, structural),
|
|
223
231
|
basis: structural > lexical ? "structural" : "lexical"
|
|
224
232
|
};
|
|
225
233
|
}
|
|
226
234
|
|
|
235
|
+
export function pairSimilarity(x, y, { extraStop } = {}) {
|
|
236
|
+
return pairSimilarityPrepared(prepareItem(x, { extraStop }), prepareItem(y, { extraStop }));
|
|
237
|
+
}
|
|
238
|
+
|
|
227
239
|
// Clustering wants "same family" (alarm_query / alarm_ack belong to one agent),
|
|
228
240
|
// which is looser than "confusable" (lint's question). Shared name token plus
|
|
229
241
|
// any parameter overlap is enough to belong together; two shared tokens are
|
|
230
242
|
// enough on their own (fault_alarm_query / fault_alarm_ack take different ids).
|
|
231
243
|
// One shared token with disjoint params is not: cross-domain tools often share
|
|
232
244
|
// a bare verb (query / list) and nothing else.
|
|
233
|
-
export function
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
if (shared.length === 0) return 0;
|
|
238
|
-
const paramOverlap = jaccard(paramNames(x), paramNames(y));
|
|
245
|
+
export function familyScorePrepared(x, y) {
|
|
246
|
+
const shared = intersectionSize(x.nameTokenSet, y.nameTokenSet);
|
|
247
|
+
if (shared === 0) return 0;
|
|
248
|
+
const paramOverlap = jaccard(x.params, y.params);
|
|
239
249
|
if (paramOverlap > 0) return 0.8;
|
|
240
|
-
if (shared
|
|
241
|
-
return
|
|
250
|
+
if (shared >= 2) return 0.7;
|
|
251
|
+
return x.params.size === 0 && y.params.size === 0 ? 0.6 : 0.2;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function familyScore(x, y) {
|
|
255
|
+
return familyScorePrepared(prepareItem(x), prepareItem(y));
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
// Turn a confusable pair into an authoring instruction. We do not invent the
|
|
@@ -280,12 +294,13 @@ export function boundaryHint(x, y) {
|
|
|
280
294
|
}
|
|
281
295
|
|
|
282
296
|
export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop } = {}) {
|
|
297
|
+
const prepared = tools.map((tool) => prepareItem(tool, { extraStop }));
|
|
283
298
|
const pairs = [];
|
|
284
|
-
for (let i = 0; i <
|
|
285
|
-
for (let j = i + 1; j <
|
|
286
|
-
const { similarity, basis } =
|
|
299
|
+
for (let i = 0; i < prepared.length; i += 1) {
|
|
300
|
+
for (let j = i + 1; j < prepared.length; j += 1) {
|
|
301
|
+
const { similarity, basis } = pairSimilarityPrepared(prepared[i], prepared[j]);
|
|
287
302
|
if (similarity >= threshold) {
|
|
288
|
-
pairs.push({ a:
|
|
303
|
+
pairs.push({ a: prepared[i].name, b: prepared[j].name, similarity, basis });
|
|
289
304
|
}
|
|
290
305
|
}
|
|
291
306
|
}
|
|
@@ -296,20 +311,13 @@ export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop }
|
|
|
296
311
|
// parameter shape confuse the model even when the wording differs, which the
|
|
297
312
|
// lexical score alone misses. Name order is unreliable: `tavily_search` leads
|
|
298
313
|
// with the vendor, `search_docs` with the action.
|
|
299
|
-
function
|
|
300
|
-
const
|
|
301
|
-
const right = nameTokens(y.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
|
|
302
|
-
const sharedName = left.some((token) => right.includes(token));
|
|
314
|
+
function structuralScorePrepared(x, y) {
|
|
315
|
+
const sharedName = x.nameTokens.some((token) => y.nameTokens.includes(token));
|
|
303
316
|
if (!sharedName) return 0;
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
if (params.size === 0 && otherParams.size === 0) return 0;
|
|
307
|
-
const paramOverlap = jaccard(params, otherParams);
|
|
317
|
+
if (x.params.size === 0 && y.params.size === 0) return 0;
|
|
318
|
+
const paramOverlap = jaccard(x.params, y.params);
|
|
308
319
|
if (paramOverlap < 0.34) return 0;
|
|
309
|
-
const requiredOverlap = jaccard(
|
|
310
|
-
new Set(x.parameters?.required ?? []),
|
|
311
|
-
new Set(y.parameters?.required ?? [])
|
|
312
|
-
);
|
|
320
|
+
const requiredOverlap = jaccard(x.required, y.required);
|
|
313
321
|
return 0.5 + 0.5 * (0.6 * paramOverlap + 0.4 * requiredOverlap);
|
|
314
322
|
}
|
|
315
323
|
|
|
@@ -340,10 +348,26 @@ function paramNames(tool) {
|
|
|
340
348
|
|
|
341
349
|
function jaccard(a, b) {
|
|
342
350
|
if (a.size === 0 && b.size === 0) return 1;
|
|
343
|
-
const
|
|
351
|
+
const shared = intersectionSize(a, b);
|
|
352
|
+
return shared / (a.size + b.size - shared);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Iterate the smaller set; avoids allocating a union Set for every pair in the
|
|
356
|
+
// O(n²) loops.
|
|
357
|
+
function intersectionSize(a, b) {
|
|
358
|
+
const small = a.size <= b.size ? a : b;
|
|
359
|
+
const large = small === a ? b : a;
|
|
344
360
|
let shared = 0;
|
|
345
|
-
for (const item of
|
|
346
|
-
return shared
|
|
361
|
+
for (const item of small) if (large.has(item)) shared += 1;
|
|
362
|
+
return shared;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function intersects(a, b) {
|
|
366
|
+
if (a.size === 0 || b.size === 0) return false;
|
|
367
|
+
const small = a.size <= b.size ? a : b;
|
|
368
|
+
const large = small === a ? b : a;
|
|
369
|
+
for (const item of small) if (large.has(item)) return true;
|
|
370
|
+
return false;
|
|
347
371
|
}
|
|
348
372
|
|
|
349
373
|
function tokens(text, extraStop) {
|
package/lib/check.d.ts
CHANGED
|
@@ -17,9 +17,14 @@ export interface InstalledCapabilities {
|
|
|
17
17
|
skills?: Capability[];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** `mf scan --json` output; its capabilities live under `report`. */
|
|
21
|
+
export interface ScanReport {
|
|
22
|
+
report: InstalledCapabilities;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export interface CheckInput {
|
|
21
26
|
manifest: Capability;
|
|
22
|
-
installed?: Capability[] | InstalledCapabilities;
|
|
27
|
+
installed?: Capability[] | InstalledCapabilities | ScanReport;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
export interface CheckFinding {
|
|
@@ -48,7 +53,7 @@ export interface CheckResult {
|
|
|
48
53
|
|
|
49
54
|
export interface CheckManyInput {
|
|
50
55
|
manifests: Capability[];
|
|
51
|
-
installed?: Capability[] | InstalledCapabilities;
|
|
56
|
+
installed?: Capability[] | InstalledCapabilities | ScanReport;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
export interface CheckItemResult extends CheckResult {
|
|
@@ -66,14 +71,16 @@ export interface CheckManyResult {
|
|
|
66
71
|
/**
|
|
67
72
|
* Static install gate: judge one new capability against installed ones.
|
|
68
73
|
* No model call, no network, no file system, no state.
|
|
69
|
-
* @throws
|
|
74
|
+
* @throws when `manifest.name` is missing or empty, or `installed` is neither
|
|
75
|
+
* an array, `{ tools, skills }`, nor an `mf scan --json` report.
|
|
70
76
|
*/
|
|
71
77
|
export declare function check(input: CheckInput): CheckResult;
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
80
|
* Batch install gate: judge a pack of new capabilities against installed ones
|
|
75
81
|
* and against each other.
|
|
76
|
-
* @throws when manifests is empty, an item lacks a name,
|
|
82
|
+
* @throws when manifests is empty, an item lacks a name, names are duplicated,
|
|
83
|
+
* or `installed` is not an accepted shape.
|
|
77
84
|
*/
|
|
78
85
|
export declare function checkMany(input: CheckManyInput): CheckManyResult;
|
|
79
86
|
|
package/lib/check.js
CHANGED
|
@@ -40,12 +40,32 @@ export function checkMany({ manifests, installed = [] } = {}) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// A gate that silently ignores a mis-shaped `installed` returns a false pass —
|
|
44
|
+
// the worst failure mode for an install check. Accept the array and
|
|
45
|
+
// {tools,skills} forms, plus `mf scan --json` output (wrapped in `report`);
|
|
46
|
+
// anything else non-empty is an input error.
|
|
47
|
+
function installedPool(installed) {
|
|
48
|
+
if (installed == null) return [];
|
|
49
|
+
if (Array.isArray(installed)) return installed;
|
|
50
|
+
if (typeof installed !== "object") {
|
|
51
|
+
throw new Error("installed 需要能力数组、{ tools, skills } 或 mf scan --json 的 report");
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(installed).length === 0) return [];
|
|
54
|
+
const source =
|
|
55
|
+
installed.report && typeof installed.report === "object" ? installed.report : installed;
|
|
56
|
+
const tools = source.tools ?? [];
|
|
57
|
+
const skills = source.skills ?? [];
|
|
58
|
+
if (!Array.isArray(tools) || !Array.isArray(skills)) {
|
|
59
|
+
throw new Error("installed.tools / installed.skills 必须是数组");
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(source.tools) && !Array.isArray(source.skills)) {
|
|
62
|
+
throw new Error("installed 需要能力数组、{ tools, skills } 或 mf scan --json 的 report");
|
|
48
63
|
}
|
|
64
|
+
return [...tools, ...skills];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function evaluateTargets(targets, installed) {
|
|
68
|
+
const pool = installedPool(installed);
|
|
49
69
|
const entries = pool.map(normalizeEntry).filter(Boolean);
|
|
50
70
|
|
|
51
71
|
const tools = [];
|
|
@@ -53,7 +73,10 @@ function evaluateTargets(targets, installed) {
|
|
|
53
73
|
for (const entry of entries) (isToolLike(entry) ? tools : skills).push(entry);
|
|
54
74
|
for (const target of targets) (isToolLike(target) ? tools : skills).push(target);
|
|
55
75
|
|
|
56
|
-
|
|
76
|
+
// The gate only reports findings touching the new capabilities; skipping
|
|
77
|
+
// installed × installed pairs keeps a 500-capability pool linear-ish.
|
|
78
|
+
const pairScope = new Set(targets.map((target) => target.name));
|
|
79
|
+
const report = lintSuite({ tools, skills }, { pairScope });
|
|
57
80
|
const pairFindings = report.findings.filter((finding) => finding.pair);
|
|
58
81
|
const results = targets.map((target) => {
|
|
59
82
|
const findings = report.findings
|
|
@@ -102,6 +125,11 @@ function normalizeEntry(entry) {
|
|
|
102
125
|
return {
|
|
103
126
|
name,
|
|
104
127
|
description: typeof entry.description === "string" ? entry.description : "",
|
|
128
|
+
// Scan marks descriptions assembled at runtime; without this the gate
|
|
129
|
+
// treats every Claude-Code-style tool as "description missing".
|
|
130
|
+
...(typeof entry.descriptionSource === "string"
|
|
131
|
+
? { descriptionSource: entry.descriptionSource }
|
|
132
|
+
: {}),
|
|
105
133
|
parameters: parameters && typeof parameters === "object" ? parameters : null
|
|
106
134
|
};
|
|
107
135
|
}
|
package/lib/cluster.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
prepareItem,
|
|
3
|
+
pairSimilarityPrepared,
|
|
4
|
+
familyScorePrepared,
|
|
5
|
+
nameTokens,
|
|
6
|
+
GENERIC_NAME_TOKENS
|
|
7
|
+
} from "./cases.js";
|
|
2
8
|
|
|
3
9
|
// A shared domain noun ("session", "goal", "tool") says these tools serve one
|
|
4
10
|
// purpose even when their verbs and parameter shapes differ — exactly what
|
|
@@ -21,12 +27,13 @@ const BOUNDARY_FAMILY = 0.6;
|
|
|
21
27
|
|
|
22
28
|
export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
|
|
23
29
|
const scores = tools.map(() => new Array(tools.length).fill(0));
|
|
30
|
+
const prepared = tools.map((tool) => prepareItem(tool));
|
|
24
31
|
const pairs = [];
|
|
25
32
|
const edges = [];
|
|
26
33
|
for (let i = 0; i < tools.length; i += 1) {
|
|
27
34
|
for (let j = i + 1; j < tools.length; j += 1) {
|
|
28
|
-
const { similarity, basis } =
|
|
29
|
-
const family =
|
|
35
|
+
const { similarity, basis } = pairSimilarityPrepared(prepared[i], prepared[j]);
|
|
36
|
+
const family = familyScorePrepared(prepared[i], prepared[j]);
|
|
30
37
|
const link = Math.max(similarity, family);
|
|
31
38
|
scores[i][j] = link;
|
|
32
39
|
scores[j][i] = link;
|
package/lib/lint.js
CHANGED
|
@@ -57,7 +57,12 @@ const BOUNDARY_HINT = /(不适用|不要用于|不用于|不能用于|而非|区
|
|
|
57
57
|
// Chinese descriptions are compared as bigrams, so mid-0.3s similarity is noise.
|
|
58
58
|
const NO_BOUNDARY_SIMILARITY = 0.5;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// `pairScope` (a Set of names) is for incremental gates: only emit pair
|
|
61
|
+
// findings that touch one of the new capabilities. Installed × installed pairs
|
|
62
|
+
// are computed and then discarded by the caller, which dominates large sets.
|
|
63
|
+
export function lintSuite(suite, { pairScope } = {}) {
|
|
64
|
+
const inScope = (pair) => !pairScope || pairScope.has(pair.a) || pairScope.has(pair.b);
|
|
65
|
+
const toolByName = new Map(suite.tools.map((tool) => [tool.name, tool]));
|
|
61
66
|
const findings = [];
|
|
62
67
|
const add = (severity, code, tool, message, extra = {}) =>
|
|
63
68
|
findings.push({ severity, code, kind: "tool", tool, message, ...extra });
|
|
@@ -205,9 +210,10 @@ export function lintSuite(suite) {
|
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
for (const pair of suspectedPairs(suite.tools)) {
|
|
213
|
+
if (!inScope(pair)) continue;
|
|
208
214
|
const similarity = pair.similarity.toFixed(2);
|
|
209
215
|
const declaresBoundary = [pair.a, pair.b].some((name) => {
|
|
210
|
-
const tool =
|
|
216
|
+
const tool = toolByName.get(name);
|
|
211
217
|
return BOUNDARY_HINT.test(tool?.description ?? "");
|
|
212
218
|
});
|
|
213
219
|
if (declaresBoundary) {
|
|
@@ -223,8 +229,8 @@ export function lintSuite(suite) {
|
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
const reason = pair.basis === "structural" ? "名字与参数形状接近" : "描述相近";
|
|
226
|
-
const toolA =
|
|
227
|
-
const toolB =
|
|
232
|
+
const toolA = toolByName.get(pair.a);
|
|
233
|
+
const toolB = toolByName.get(pair.b);
|
|
228
234
|
add("warn", "pair-no-boundary", pair.a, `与 ${pair.b} ${reason}(相似度 ${similarity}),且两边都没写“不适用于”边界,模型只能靠相似度硬猜`, {
|
|
229
235
|
pair: [pair.a, pair.b],
|
|
230
236
|
suggestion: boundaryHint(toolA, toolB).suggestion
|
|
@@ -236,6 +242,7 @@ export function lintSuite(suite) {
|
|
|
236
242
|
// question is narrower than for tools: is the description specific enough to
|
|
237
243
|
// be triggered, and does it collide with another installed skill?
|
|
238
244
|
const skills = suite.skills ?? [];
|
|
245
|
+
const skillByName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
239
246
|
for (const skill of skills) {
|
|
240
247
|
const description = String(skill.description ?? "").trim();
|
|
241
248
|
if (!description) {
|
|
@@ -258,9 +265,10 @@ export function lintSuite(suite) {
|
|
|
258
265
|
threshold: SKILL_PAIR_SIMILARITY,
|
|
259
266
|
extraStop: SKILL_BOILERPLATE
|
|
260
267
|
})) {
|
|
268
|
+
if (!inScope(pair)) continue;
|
|
261
269
|
const similarity = pair.similarity.toFixed(2);
|
|
262
270
|
const declaresBoundary = [pair.a, pair.b].some((name) => {
|
|
263
|
-
const skill =
|
|
271
|
+
const skill = skillByName.get(name);
|
|
264
272
|
return BOUNDARY_HINT.test(skill?.description ?? "");
|
|
265
273
|
});
|
|
266
274
|
// Same-vendor siblings ("feishu-doc" / "feishu-wiki") share the vendor token
|
package/lib/profile.js
CHANGED
|
@@ -2,17 +2,28 @@ import { pct } from "./score.js";
|
|
|
2
2
|
|
|
3
3
|
export function normalizeProfiles(raw, toolNames) {
|
|
4
4
|
if (raw === undefined || raw === null) return null;
|
|
5
|
-
if (typeof raw !== "object"
|
|
6
|
-
throw new Error('suite "profiles" must be an object
|
|
5
|
+
if (typeof raw !== "object") {
|
|
6
|
+
throw new Error('suite "profiles" must be an object or an array of groups');
|
|
7
7
|
}
|
|
8
8
|
const known = new Set(toolNames);
|
|
9
9
|
const profiles = [];
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
// Platform registries often hand us an array of
|
|
12
|
+
// { agentType, tools, skills } rather than { "group": ["tool"] }.
|
|
13
|
+
const entries = Array.isArray(raw)
|
|
14
|
+
? raw.map((value, index) => [String(index), value])
|
|
15
|
+
: Object.entries(raw);
|
|
16
|
+
for (const [key, value] of entries) {
|
|
17
|
+
// Array form: { "group": ["tool", ...] }. Object form (platform registries
|
|
18
|
+
// often look like this): { "0": { agentType, tools, skills } }.
|
|
19
|
+
const list = Array.isArray(value) ? value : value?.tools;
|
|
20
|
+
const name = String(Array.isArray(value) ? key : value?.name ?? value?.agentType ?? key).trim();
|
|
12
21
|
if (!name) throw new Error('suite "profiles": group name must be non-empty');
|
|
13
22
|
if (!Array.isArray(list) || list.length === 0) {
|
|
14
23
|
throw new Error(`profile "${name}": must be a non-empty array of tool names`);
|
|
15
24
|
}
|
|
25
|
+
if (seen.has(name)) throw new Error(`profile "${name}": duplicate group name`);
|
|
26
|
+
seen.add(name);
|
|
16
27
|
const tools = [];
|
|
17
28
|
for (const item of list) {
|
|
18
29
|
if (typeof item !== "string" || item.trim() === "") {
|
|
@@ -43,7 +54,11 @@ export function selectProfiles(profiles, wanted) {
|
|
|
43
54
|
// A case belongs to a group when its expected tool is mounted there. Negative
|
|
44
55
|
// cases ("no tool applies") hold everywhere: the group's agent still gets them.
|
|
45
56
|
export function selectProfileCases(cases, mountedTools) {
|
|
46
|
-
return cases.filter(
|
|
57
|
+
return cases.filter(
|
|
58
|
+
(testCase) =>
|
|
59
|
+
testCase.excludeFromProfiles !== true &&
|
|
60
|
+
(testCase.expectedTool === null || mountedTools.has(testCase.expectedTool))
|
|
61
|
+
);
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
export function profileWarnings(profiles, suite) {
|
|
@@ -65,11 +80,12 @@ export function profileWarnings(profiles, suite) {
|
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
export function profileCaseCoverage(profiles, cases) {
|
|
68
|
-
const
|
|
83
|
+
const considered = cases.filter((testCase) => testCase.excludeFromProfiles !== true);
|
|
84
|
+
const negatives = considered.filter((testCase) => testCase.expectedTool === null).length;
|
|
69
85
|
return profiles.map((profile) => {
|
|
70
86
|
const mounted = new Set(profile.tools);
|
|
71
87
|
const counts = new Map(profile.tools.map((name) => [name, 0]));
|
|
72
|
-
for (const testCase of
|
|
88
|
+
for (const testCase of considered) {
|
|
73
89
|
if (testCase.expectedTool !== null && mounted.has(testCase.expectedTool)) {
|
|
74
90
|
counts.set(testCase.expectedTool, counts.get(testCase.expectedTool) + 1);
|
|
75
91
|
}
|
|
@@ -77,7 +93,14 @@ export function profileCaseCoverage(profiles, cases) {
|
|
|
77
93
|
const missing = [...counts.entries()].filter(([, count]) => count === 0).map(([name]) => name);
|
|
78
94
|
let total = 0;
|
|
79
95
|
for (const count of counts.values()) total += count;
|
|
80
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
name: profile.name,
|
|
98
|
+
tools: profile.tools.length,
|
|
99
|
+
cases: total,
|
|
100
|
+
negatives,
|
|
101
|
+
negativeRatio: total + negatives > 0 ? negatives / (total + negatives) : 0,
|
|
102
|
+
missing
|
|
103
|
+
};
|
|
81
104
|
});
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -88,18 +111,21 @@ export function analyzeMount(profile, mounted, allInOne) {
|
|
|
88
111
|
for (const before of allInOne.rows) {
|
|
89
112
|
const row = after.get(before.id);
|
|
90
113
|
if (!row || before.reviewed === false || row.reviewed === false) continue;
|
|
114
|
+
// A provider error is not evidence about mounting; score it separately.
|
|
115
|
+
if (before.error || row.error) continue;
|
|
91
116
|
if (!before.selectionCorrect && row.selectionCorrect) lifted.push(changeOf(before, row));
|
|
92
117
|
else if (before.selectionCorrect && !row.selectionCorrect) jammed.push(changeOf(before, row));
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
const mountedMetrics = metricsOf(mounted);
|
|
96
121
|
const allMetrics = metricsOf(allInOne);
|
|
97
|
-
const reviewed =
|
|
122
|
+
const reviewed = mountedMetrics.scored;
|
|
98
123
|
return {
|
|
99
124
|
profile: profile.name,
|
|
100
125
|
mountedTools: profile.tools.length,
|
|
101
126
|
cases: mounted.total,
|
|
102
127
|
reviewed,
|
|
128
|
+
errors: mountedMetrics.errors,
|
|
103
129
|
mounted: mountedMetrics,
|
|
104
130
|
allInOne: allMetrics,
|
|
105
131
|
delta: {
|
|
@@ -117,32 +143,44 @@ export function analyzeMount(profile, mounted, allInOne) {
|
|
|
117
143
|
|
|
118
144
|
export function modelTotals(entries) {
|
|
119
145
|
let reviewed = 0;
|
|
146
|
+
let errors = 0;
|
|
120
147
|
let mountedCorrect = 0;
|
|
121
148
|
let allCorrect = 0;
|
|
149
|
+
const usage = { inputTokens: 0, cachedTokens: 0, outputTokens: 0, costCny: 0, estimated: false };
|
|
122
150
|
for (const entry of entries) {
|
|
123
151
|
reviewed += entry.reviewed;
|
|
152
|
+
errors += entry.errors ?? 0;
|
|
124
153
|
mountedCorrect += entry.mounted.correct;
|
|
125
154
|
allCorrect += entry.allInOne.correct;
|
|
155
|
+
usage.inputTokens += entry.usage?.inputTokens ?? 0;
|
|
156
|
+
usage.cachedTokens += entry.usage?.cachedTokens ?? 0;
|
|
157
|
+
usage.outputTokens += entry.usage?.outputTokens ?? 0;
|
|
158
|
+
usage.costCny += entry.usage?.costCny ?? 0;
|
|
159
|
+
usage.estimated ||= Boolean(entry.usage?.estimated);
|
|
126
160
|
}
|
|
127
161
|
if (reviewed === 0) {
|
|
128
162
|
return {
|
|
129
163
|
reviewed: 0,
|
|
164
|
+
errors,
|
|
130
165
|
mountedCorrect: 0,
|
|
131
166
|
allCorrect: 0,
|
|
132
167
|
mountedAccuracy: null,
|
|
133
168
|
allAccuracy: null,
|
|
134
|
-
delta: null
|
|
169
|
+
delta: null,
|
|
170
|
+
usage
|
|
135
171
|
};
|
|
136
172
|
}
|
|
137
173
|
const mountedAccuracy = mountedCorrect / reviewed;
|
|
138
174
|
const allAccuracy = allCorrect / reviewed;
|
|
139
175
|
return {
|
|
140
176
|
reviewed,
|
|
177
|
+
errors,
|
|
141
178
|
mountedCorrect,
|
|
142
179
|
allCorrect,
|
|
143
180
|
mountedAccuracy,
|
|
144
181
|
allAccuracy,
|
|
145
|
-
delta: mountedAccuracy - allAccuracy
|
|
182
|
+
delta: mountedAccuracy - allAccuracy,
|
|
183
|
+
usage
|
|
146
184
|
};
|
|
147
185
|
}
|
|
148
186
|
|
|
@@ -158,15 +196,16 @@ export function formatProfileReport(report, { showCases = false } = {}) {
|
|
|
158
196
|
entry.profile,
|
|
159
197
|
String(entry.mountedTools),
|
|
160
198
|
String(entry.cases),
|
|
199
|
+
String(entry.errors ?? 0),
|
|
161
200
|
pct(entry.mounted.selectionAccuracy),
|
|
162
201
|
pct(entry.allInOne.selectionAccuracy),
|
|
163
202
|
signedPoints(entry.delta.selection)
|
|
164
203
|
]);
|
|
165
204
|
lines.push(
|
|
166
205
|
...renderTable(
|
|
167
|
-
["profile", "tools", "cases", "mounted", "all-in-one", "delta"],
|
|
206
|
+
["profile", "tools", "cases", "errors", "mounted", "all-in-one", "delta"],
|
|
168
207
|
rows,
|
|
169
|
-
[1, 2, 3, 4, 5]
|
|
208
|
+
[1, 2, 3, 4, 5, 6]
|
|
170
209
|
)
|
|
171
210
|
);
|
|
172
211
|
const totals = model.totals;
|
|
@@ -174,7 +213,8 @@ export function formatProfileReport(report, { showCases = false } = {}) {
|
|
|
174
213
|
lines.push(
|
|
175
214
|
` 合计: 分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed})` +
|
|
176
215
|
` / 全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed})` +
|
|
177
|
-
`,${signedPoints(totals.delta)}`
|
|
216
|
+
`,${signedPoints(totals.delta)}` +
|
|
217
|
+
(totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
|
|
178
218
|
);
|
|
179
219
|
}
|
|
180
220
|
const unstable = unstableOf(model.profiles);
|
|
@@ -213,12 +253,12 @@ export function formatProfileMarkdown(report) {
|
|
|
213
253
|
lines.push(
|
|
214
254
|
`### ${model.model}`,
|
|
215
255
|
"",
|
|
216
|
-
"| group | tools | cases | mounted | all-in-one | delta |",
|
|
217
|
-
"| --- | --- | --- | --- | --- | --- |"
|
|
256
|
+
"| group | tools | cases | errors | mounted | all-in-one | delta |",
|
|
257
|
+
"| --- | --- | --- | --- | --- | --- | --- |"
|
|
218
258
|
);
|
|
219
259
|
for (const entry of model.profiles) {
|
|
220
260
|
lines.push(
|
|
221
|
-
`| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
|
|
261
|
+
`| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${entry.errors ?? 0} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
|
|
222
262
|
);
|
|
223
263
|
}
|
|
224
264
|
const totals = model.totals;
|
|
@@ -226,7 +266,8 @@ export function formatProfileMarkdown(report) {
|
|
|
226
266
|
if (totals.reviewed > 0) {
|
|
227
267
|
lines.push(
|
|
228
268
|
`- 合计:分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed}),` +
|
|
229
|
-
`全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}`
|
|
269
|
+
`全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}` +
|
|
270
|
+
(totals.errors > 0 ? `;${totals.errors} 次调用错误不计入对比` : "")
|
|
230
271
|
);
|
|
231
272
|
} else {
|
|
232
273
|
lines.push("- 合计:没有已复核用例,无法比较");
|
|
@@ -256,14 +297,18 @@ function changeOf(before, after) {
|
|
|
256
297
|
|
|
257
298
|
function metricsOf(summary) {
|
|
258
299
|
const reviewedRows = summary.rows.filter((row) => row.reviewed !== false);
|
|
259
|
-
const
|
|
300
|
+
const errorRows = reviewedRows.filter((row) => row.error);
|
|
301
|
+
const viableRows = reviewedRows.filter((row) => !row.error);
|
|
302
|
+
const correct = viableRows.filter((row) => row.selectionCorrect).length;
|
|
260
303
|
return {
|
|
261
|
-
selectionAccuracy:
|
|
304
|
+
selectionAccuracy: viableRows.length ? correct / viableRows.length : null,
|
|
262
305
|
argAccuracy: summary.argAccuracy,
|
|
263
|
-
passRate:
|
|
264
|
-
?
|
|
306
|
+
passRate: viableRows.length
|
|
307
|
+
? viableRows.filter((row) => row.passed).length / viableRows.length
|
|
265
308
|
: null,
|
|
266
|
-
correct
|
|
309
|
+
correct,
|
|
310
|
+
errors: errorRows.length,
|
|
311
|
+
scored: viableRows.length
|
|
267
312
|
};
|
|
268
313
|
}
|
|
269
314
|
|
package/lib/score.js
CHANGED
|
@@ -89,7 +89,7 @@ export function costCnyOf(usage, pricing) {
|
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function scoreModel({ model, cases, results, pricing }) {
|
|
92
|
+
export function scoreModel({ model, cases, results, pricing, scope = "base" }) {
|
|
93
93
|
const rows = cases.map((testCase, index) => {
|
|
94
94
|
const result = results[index];
|
|
95
95
|
const actualTool = result.ok ? (result.toolCalls?.[0]?.name ?? null) : null;
|
|
@@ -98,6 +98,7 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
98
98
|
? matchArgs(result.toolCalls?.[0]?.arguments, testCase.expectedArgs)
|
|
99
99
|
: { total: 0, matched: 0, failures: [] };
|
|
100
100
|
const passed = Boolean(result.ok) && selectionCorrect && args.failures.length === 0;
|
|
101
|
+
const error = result.error ?? null;
|
|
101
102
|
return {
|
|
102
103
|
id: testCase.id,
|
|
103
104
|
expectedTool: testCase.expectedTool,
|
|
@@ -106,21 +107,35 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
106
107
|
args,
|
|
107
108
|
passed,
|
|
108
109
|
reviewed: testCase.reviewed !== false,
|
|
110
|
+
...(testCase.excludeFromBase ? { excludeFromBase: true } : {}),
|
|
111
|
+
...(testCase.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
|
|
109
112
|
source: testCase.source ?? "manual",
|
|
110
113
|
latencyMs: result.latencyMs ?? 0,
|
|
111
114
|
usage: result.usage ?? { input: 0, output: 0 },
|
|
112
|
-
error
|
|
115
|
+
error,
|
|
116
|
+
...(error ? { errorKind: classifyError(error) } : {})
|
|
113
117
|
};
|
|
114
118
|
});
|
|
115
119
|
|
|
116
120
|
const total = rows.length;
|
|
117
|
-
|
|
121
|
+
// An excluded case still runs (so profile / all-in-one views keep their rows);
|
|
122
|
+
// it just does not contribute to this scope's official metrics.
|
|
123
|
+
const excluded = (row) => (scope === "profiles" ? row.excludeFromProfiles : row.excludeFromBase);
|
|
124
|
+
const reviewedRows = rows.filter((row) => row.reviewed && !excluded(row));
|
|
118
125
|
const unreviewedRows = rows.filter((row) => !row.reviewed);
|
|
126
|
+
const excludedRows = rows.filter((row) => row.reviewed && excluded(row));
|
|
119
127
|
const selectionHits = reviewedRows.filter((row) => row.selectionCorrect).length;
|
|
120
128
|
const argFields = reviewedRows.reduce((sum, row) => sum + row.args.total, 0);
|
|
121
129
|
const argHits = reviewedRows.reduce((sum, row) => sum + row.args.matched, 0);
|
|
122
130
|
const passed = reviewedRows.filter((row) => row.passed).length;
|
|
123
|
-
const
|
|
131
|
+
const errorRows = reviewedRows.filter((row) => row.error);
|
|
132
|
+
const errors = errorRows.length;
|
|
133
|
+
const errorsByKind = errorRows.reduce((counts, row) => {
|
|
134
|
+
counts[row.errorKind] = (counts[row.errorKind] ?? 0) + 1;
|
|
135
|
+
return counts;
|
|
136
|
+
}, {});
|
|
137
|
+
const scoredTotal = reviewedRows.length - errors;
|
|
138
|
+
const selectionHitsWithoutErrors = reviewedRows.filter((row) => !row.error && row.selectionCorrect).length;
|
|
124
139
|
const latencies = rows.filter((row) => row.latencyMs > 0).map((row) => row.latencyMs);
|
|
125
140
|
const inputTokens = rows.reduce((sum, row) => sum + (row.usage.input ?? 0), 0);
|
|
126
141
|
const outputTokens = rows.reduce((sum, row) => sum + (row.usage.output ?? 0), 0);
|
|
@@ -135,14 +150,18 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
135
150
|
model,
|
|
136
151
|
total,
|
|
137
152
|
reviewedTotal,
|
|
153
|
+
excludedTotal: excludedRows.length,
|
|
138
154
|
unreviewedTotal: unreviewedRows.length,
|
|
139
155
|
unreviewedSelectionAccuracy: unreviewedRows.length
|
|
140
156
|
? unreviewedRows.filter((row) => row.selectionCorrect).length / unreviewedRows.length
|
|
141
157
|
: null,
|
|
142
158
|
selectionAccuracy: reviewedTotal ? selectionHits / reviewedTotal : 0,
|
|
159
|
+
selectionAccuracyExcludingErrors: scoredTotal ? selectionHitsWithoutErrors / scoredTotal : null,
|
|
143
160
|
argAccuracy: argFields ? argHits / argFields : null,
|
|
144
161
|
passRate: reviewedTotal ? passed / reviewedTotal : 0,
|
|
145
162
|
errors,
|
|
163
|
+
errorsByKind,
|
|
164
|
+
scoredTotal,
|
|
146
165
|
p50LatencyMs: percentile(latencies, 0.5),
|
|
147
166
|
p95LatencyMs: percentile(latencies, 0.95),
|
|
148
167
|
avgCostCny: total ? costCny / total : 0,
|
|
@@ -154,6 +173,15 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
154
173
|
};
|
|
155
174
|
}
|
|
156
175
|
|
|
176
|
+
export function classifyError(error) {
|
|
177
|
+
const text = String(error ?? "");
|
|
178
|
+
if (/timeout|timed out/i.test(text)) return "timeout";
|
|
179
|
+
if (/^HTTP 429\b/.test(text)) return "rate-limit";
|
|
180
|
+
if (/^HTTP \d{3}\b/.test(text)) return "http";
|
|
181
|
+
if (/ECONN|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(text)) return "network";
|
|
182
|
+
return "other";
|
|
183
|
+
}
|
|
184
|
+
|
|
157
185
|
function percentile(values, ratio) {
|
|
158
186
|
if (values.length === 0) return 0;
|
|
159
187
|
const sorted = [...values].sort((a, b) => a - b);
|
package/lib/suite.js
CHANGED
|
@@ -95,6 +95,11 @@ function normalizeCase(entry, index, toolNames) {
|
|
|
95
95
|
if (toolInput && expectedTool === null) {
|
|
96
96
|
throw new Error(`case ${id}: toolInput requires an expectedTool`);
|
|
97
97
|
}
|
|
98
|
+
for (const key of ["excludeFromBase", "excludeFromProfiles"]) {
|
|
99
|
+
if (entry[key] !== undefined && typeof entry[key] !== "boolean") {
|
|
100
|
+
throw new Error(`case ${id}: "${key}" must be a boolean`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
98
103
|
return {
|
|
99
104
|
id,
|
|
100
105
|
input: entry.input,
|
|
@@ -103,6 +108,8 @@ function normalizeCase(entry, index, toolNames) {
|
|
|
103
108
|
note: entry.note ?? "",
|
|
104
109
|
source: typeof entry.source === "string" && entry.source ? entry.source : "manual",
|
|
105
110
|
reviewed: entry.reviewed === undefined ? true : Boolean(entry.reviewed),
|
|
111
|
+
...(entry.excludeFromBase ? { excludeFromBase: true } : {}),
|
|
112
|
+
...(entry.excludeFromProfiles ? { excludeFromProfiles: true } : {}),
|
|
106
113
|
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
|
|
107
114
|
toolInput,
|
|
108
115
|
expect:
|
package/mf.js
CHANGED
|
@@ -153,7 +153,8 @@ export async function runEval(options, io = console) {
|
|
|
153
153
|
model: adapter.id,
|
|
154
154
|
cases,
|
|
155
155
|
results: evaluated.results,
|
|
156
|
-
pricing
|
|
156
|
+
pricing,
|
|
157
|
+
scope: "base"
|
|
157
158
|
});
|
|
158
159
|
if (evaluated.stability) summary.stability = evaluated.stability;
|
|
159
160
|
runs.push({ adapter, results: evaluated.results, summary });
|
|
@@ -250,6 +251,15 @@ export async function runEval(options, io = console) {
|
|
|
250
251
|
if (summary.unreviewedTotal > 0) {
|
|
251
252
|
io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
|
|
252
253
|
}
|
|
254
|
+
if (summary.excludedTotal > 0) {
|
|
255
|
+
io.log(`note: ${summary.excludedTotal} case(s) marked excludeFromBase, excluded from base score`);
|
|
256
|
+
}
|
|
257
|
+
if (summary.errors > 0) {
|
|
258
|
+
const kinds = Object.entries(summary.errorsByKind)
|
|
259
|
+
.map(([kind, count]) => `${kind} ${count}`)
|
|
260
|
+
.join(", ");
|
|
261
|
+
io.log(`note: ${summary.errors} call error(s) counted as misses (${kinds})`);
|
|
262
|
+
}
|
|
253
263
|
if (summary.exec) {
|
|
254
264
|
const parts = [
|
|
255
265
|
`exec: ${summary.exec.passed}/${summary.exec.executed} passed`,
|
|
@@ -482,7 +492,7 @@ export async function runScan(options, io = console) {
|
|
|
482
492
|
|
|
483
493
|
if (options.json) {
|
|
484
494
|
io.log(JSON.stringify({ report: redactReport(report), draft }, null, 2));
|
|
485
|
-
return report.tools.length > 0 ? 0 : 3;
|
|
495
|
+
return report.tools.length > 0 || report.skills.length > 0 ? 0 : 3;
|
|
486
496
|
}
|
|
487
497
|
|
|
488
498
|
io.log(formatScanSummary(report));
|
|
@@ -497,7 +507,7 @@ export async function runScan(options, io = console) {
|
|
|
497
507
|
io.log(`wrote ${reportPath}`);
|
|
498
508
|
}
|
|
499
509
|
|
|
500
|
-
return report.tools.length > 0 ? 0 : 3;
|
|
510
|
+
return report.tools.length > 0 || report.skills.length > 0 ? 0 : 3;
|
|
501
511
|
}
|
|
502
512
|
|
|
503
513
|
export async function runCaseAdd(options, io = console) {
|
|
@@ -915,6 +925,14 @@ async function evaluateProfiles({
|
|
|
915
925
|
const selected = selectProfiles(profiles, selection);
|
|
916
926
|
const indexById = new Map(cases.map((testCase, index) => [testCase.id, index]));
|
|
917
927
|
const warnings = profileWarnings(selected, suite);
|
|
928
|
+
const excludedFromProfiles = cases.filter((testCase) => testCase.excludeFromProfiles === true);
|
|
929
|
+
if (excludedFromProfiles.length > 0) {
|
|
930
|
+
warnings.push(
|
|
931
|
+
`${excludedFromProfiles.length} 条用例标记 excludeFromProfiles,不参与分组评分: ${excludedFromProfiles
|
|
932
|
+
.map((testCase) => testCase.id)
|
|
933
|
+
.join(", ")}`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
918
936
|
|
|
919
937
|
const groups = [];
|
|
920
938
|
const caseSets = selected.map((profile) => {
|
|
@@ -946,15 +964,24 @@ async function evaluateProfiles({
|
|
|
946
964
|
model: run.adapter.id,
|
|
947
965
|
cases: profileCases,
|
|
948
966
|
results: mountedEval.results,
|
|
949
|
-
pricing
|
|
967
|
+
pricing,
|
|
968
|
+
scope: "profiles"
|
|
950
969
|
});
|
|
951
970
|
const allInOne = scoreModel({
|
|
952
971
|
model: run.adapter.id,
|
|
953
972
|
cases: profileCases,
|
|
954
973
|
results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
|
|
955
|
-
pricing
|
|
974
|
+
pricing,
|
|
975
|
+
scope: "profiles"
|
|
956
976
|
});
|
|
957
977
|
const entry = analyzeMount(profile, mounted, allInOne);
|
|
978
|
+
entry.usage = {
|
|
979
|
+
inputTokens: mounted.inputTokens,
|
|
980
|
+
cachedTokens: mounted.cachedTokens,
|
|
981
|
+
outputTokens: mounted.outputTokens,
|
|
982
|
+
costCny: mounted.totalCostCny,
|
|
983
|
+
estimated: mounted.rows.some((row) => row.usage?.estimated)
|
|
984
|
+
};
|
|
958
985
|
if (mountedEval.stability) entry.stability = mountedEval.stability;
|
|
959
986
|
entries.push(entry);
|
|
960
987
|
}
|