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/lib/profile.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { pct } from "./score.js";
|
|
2
|
+
|
|
3
|
+
export function normalizeProfiles(raw, toolNames) {
|
|
4
|
+
if (raw === undefined || raw === null) return null;
|
|
5
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
6
|
+
throw new Error('suite "profiles" must be an object of { "group": ["tool", ...] }');
|
|
7
|
+
}
|
|
8
|
+
const known = new Set(toolNames);
|
|
9
|
+
const profiles = [];
|
|
10
|
+
for (const [key, list] of Object.entries(raw)) {
|
|
11
|
+
const name = String(key).trim();
|
|
12
|
+
if (!name) throw new Error('suite "profiles": group name must be non-empty');
|
|
13
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
14
|
+
throw new Error(`profile "${name}": must be a non-empty array of tool names`);
|
|
15
|
+
}
|
|
16
|
+
const tools = [];
|
|
17
|
+
for (const item of list) {
|
|
18
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
19
|
+
throw new Error(`profile "${name}": tool names must be non-empty strings`);
|
|
20
|
+
}
|
|
21
|
+
const toolName = item.trim();
|
|
22
|
+
if (!known.has(toolName)) throw new Error(`profile "${name}": unknown tool "${toolName}"`);
|
|
23
|
+
if (!tools.includes(toolName)) tools.push(toolName);
|
|
24
|
+
}
|
|
25
|
+
profiles.push({ name, tools });
|
|
26
|
+
}
|
|
27
|
+
if (profiles.length === 0) throw new Error('suite "profiles" is empty');
|
|
28
|
+
return profiles;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function selectProfiles(profiles, wanted) {
|
|
32
|
+
if (!wanted || wanted.length === 0 || wanted.includes("all")) return profiles;
|
|
33
|
+
const byName = new Map(profiles.map((profile) => [profile.name, profile]));
|
|
34
|
+
const missing = wanted.filter((name) => !byName.has(name));
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`unknown profile(s): ${missing.join(", ")}(可用:${profiles.map((profile) => profile.name).join(", ")})`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return wanted.map((name) => byName.get(name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A case belongs to a group when its expected tool is mounted there. Negative
|
|
44
|
+
// cases ("no tool applies") hold everywhere: the group's agent still gets them.
|
|
45
|
+
export function selectProfileCases(cases, mountedTools) {
|
|
46
|
+
return cases.filter((testCase) => testCase.expectedTool === null || mountedTools.has(testCase.expectedTool));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function profileWarnings(profiles, suite) {
|
|
50
|
+
const mounted = new Set(profiles.flatMap((profile) => profile.tools));
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const unmounted = suite.tools.map((tool) => tool.name).filter((name) => !mounted.has(name));
|
|
53
|
+
if (unmounted.length > 0) warnings.push(`工具不在任何挂载组: ${unmounted.join(", ")}`);
|
|
54
|
+
const homeless = suite.cases.filter(
|
|
55
|
+
(testCase) => testCase.expectedTool !== null && !mounted.has(testCase.expectedTool)
|
|
56
|
+
).length;
|
|
57
|
+
if (homeless > 0) warnings.push(`${homeless} 条用例的期望工具不在任何挂载组,不参与分组评分`);
|
|
58
|
+
const everything = suite.tools.length;
|
|
59
|
+
for (const profile of profiles) {
|
|
60
|
+
if (profile.tools.length === everything) {
|
|
61
|
+
warnings.push(`profile "${profile.name}" 挂载了全部工具,与全量对比没有意义`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return warnings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function profileCaseCoverage(profiles, cases) {
|
|
68
|
+
const negatives = cases.filter((testCase) => testCase.expectedTool === null).length;
|
|
69
|
+
return profiles.map((profile) => {
|
|
70
|
+
const mounted = new Set(profile.tools);
|
|
71
|
+
const counts = new Map(profile.tools.map((name) => [name, 0]));
|
|
72
|
+
for (const testCase of cases) {
|
|
73
|
+
if (testCase.expectedTool !== null && mounted.has(testCase.expectedTool)) {
|
|
74
|
+
counts.set(testCase.expectedTool, counts.get(testCase.expectedTool) + 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const missing = [...counts.entries()].filter(([, count]) => count === 0).map(([name]) => name);
|
|
78
|
+
let total = 0;
|
|
79
|
+
for (const count of counts.values()) total += count;
|
|
80
|
+
return { name: profile.name, tools: profile.tools.length, cases: total, negatives, missing };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function analyzeMount(profile, mounted, allInOne) {
|
|
85
|
+
const after = new Map(mounted.rows.map((row) => [row.id, row]));
|
|
86
|
+
const lifted = [];
|
|
87
|
+
const jammed = [];
|
|
88
|
+
for (const before of allInOne.rows) {
|
|
89
|
+
const row = after.get(before.id);
|
|
90
|
+
if (!row || before.reviewed === false || row.reviewed === false) continue;
|
|
91
|
+
if (!before.selectionCorrect && row.selectionCorrect) lifted.push(changeOf(before, row));
|
|
92
|
+
else if (before.selectionCorrect && !row.selectionCorrect) jammed.push(changeOf(before, row));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mountedMetrics = metricsOf(mounted);
|
|
96
|
+
const allMetrics = metricsOf(allInOne);
|
|
97
|
+
const reviewed = mounted.reviewedTotal;
|
|
98
|
+
return {
|
|
99
|
+
profile: profile.name,
|
|
100
|
+
mountedTools: profile.tools.length,
|
|
101
|
+
cases: mounted.total,
|
|
102
|
+
reviewed,
|
|
103
|
+
mounted: mountedMetrics,
|
|
104
|
+
allInOne: allMetrics,
|
|
105
|
+
delta: {
|
|
106
|
+
selection: reviewed ? mountedMetrics.selectionAccuracy - allMetrics.selectionAccuracy : null,
|
|
107
|
+
args:
|
|
108
|
+
mountedMetrics.argAccuracy === null || allMetrics.argAccuracy === null
|
|
109
|
+
? null
|
|
110
|
+
: mountedMetrics.argAccuracy - allMetrics.argAccuracy,
|
|
111
|
+
pass: reviewed ? mountedMetrics.passRate - allMetrics.passRate : null
|
|
112
|
+
},
|
|
113
|
+
lifted,
|
|
114
|
+
jammed
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function modelTotals(entries) {
|
|
119
|
+
let reviewed = 0;
|
|
120
|
+
let mountedCorrect = 0;
|
|
121
|
+
let allCorrect = 0;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
reviewed += entry.reviewed;
|
|
124
|
+
mountedCorrect += entry.mounted.correct;
|
|
125
|
+
allCorrect += entry.allInOne.correct;
|
|
126
|
+
}
|
|
127
|
+
if (reviewed === 0) {
|
|
128
|
+
return {
|
|
129
|
+
reviewed: 0,
|
|
130
|
+
mountedCorrect: 0,
|
|
131
|
+
allCorrect: 0,
|
|
132
|
+
mountedAccuracy: null,
|
|
133
|
+
allAccuracy: null,
|
|
134
|
+
delta: null
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const mountedAccuracy = mountedCorrect / reviewed;
|
|
138
|
+
const allAccuracy = allCorrect / reviewed;
|
|
139
|
+
return {
|
|
140
|
+
reviewed,
|
|
141
|
+
mountedCorrect,
|
|
142
|
+
allCorrect,
|
|
143
|
+
mountedAccuracy,
|
|
144
|
+
allAccuracy,
|
|
145
|
+
delta: mountedAccuracy - allAccuracy
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatProfileReport(report, { showCases = false } = {}) {
|
|
150
|
+
const lines = [
|
|
151
|
+
`mount compare: ${report.groups.length} 组(profiles ${report.hash})`,
|
|
152
|
+
"组内用例只在对应挂载下评分;all-in-one 用同一批用例、全部工具都挂。"
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (const model of report.models) {
|
|
156
|
+
lines.push("", `[${model.model}]`);
|
|
157
|
+
const rows = model.profiles.map((entry) => [
|
|
158
|
+
entry.profile,
|
|
159
|
+
String(entry.mountedTools),
|
|
160
|
+
String(entry.cases),
|
|
161
|
+
pct(entry.mounted.selectionAccuracy),
|
|
162
|
+
pct(entry.allInOne.selectionAccuracy),
|
|
163
|
+
signedPoints(entry.delta.selection)
|
|
164
|
+
]);
|
|
165
|
+
lines.push(
|
|
166
|
+
...renderTable(
|
|
167
|
+
["profile", "tools", "cases", "mounted", "all-in-one", "delta"],
|
|
168
|
+
rows,
|
|
169
|
+
[1, 2, 3, 4, 5]
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
const totals = model.totals;
|
|
173
|
+
if (totals.reviewed > 0) {
|
|
174
|
+
lines.push(
|
|
175
|
+
` 合计: 分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed})` +
|
|
176
|
+
` / 全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed})` +
|
|
177
|
+
`,${signedPoints(totals.delta)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const unstable = unstableOf(model.profiles);
|
|
181
|
+
if (unstable.length > 0) lines.push(` 组内不稳定: ${unstable.join(", ")}`);
|
|
182
|
+
if (showCases) {
|
|
183
|
+
for (const entry of model.profiles) {
|
|
184
|
+
for (const item of entry.lifted) {
|
|
185
|
+
lines.push(
|
|
186
|
+
` + ${entry.profile}/${item.id}: ${item.before ?? "(no tool)"} → ${item.after ?? "(no tool)"}(期望 ${item.expected ?? "(no tool)"})`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
for (const item of entry.jammed) {
|
|
190
|
+
lines.push(
|
|
191
|
+
` - ${entry.profile}/${item.id}: ${item.before ?? "(no tool)"} → ${item.after ?? "(no tool)"}(期望 ${item.expected ?? "(no tool)"})`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (report.warnings.length > 0) {
|
|
199
|
+
lines.push("", "warnings:");
|
|
200
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
201
|
+
}
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function formatProfileMarkdown(report) {
|
|
206
|
+
const lines = [
|
|
207
|
+
"## 挂载对比(分组 vs 全量)",
|
|
208
|
+
"",
|
|
209
|
+
"组内用例只在对应挂载下评分;全量 = 同一批用例、全部工具都挂。",
|
|
210
|
+
""
|
|
211
|
+
];
|
|
212
|
+
for (const model of report.models) {
|
|
213
|
+
lines.push(
|
|
214
|
+
`### ${model.model}`,
|
|
215
|
+
"",
|
|
216
|
+
"| group | tools | cases | mounted | all-in-one | delta |",
|
|
217
|
+
"| --- | --- | --- | --- | --- | --- |"
|
|
218
|
+
);
|
|
219
|
+
for (const entry of model.profiles) {
|
|
220
|
+
lines.push(
|
|
221
|
+
`| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const totals = model.totals;
|
|
225
|
+
lines.push("");
|
|
226
|
+
if (totals.reviewed > 0) {
|
|
227
|
+
lines.push(
|
|
228
|
+
`- 合计:分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed}),` +
|
|
229
|
+
`全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}`
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
lines.push("- 合计:没有已复核用例,无法比较");
|
|
233
|
+
}
|
|
234
|
+
const unstable = unstableOf(model.profiles);
|
|
235
|
+
if (unstable.length > 0) lines.push(`- 组内不稳定:${unstable.join(", ")}`);
|
|
236
|
+
lines.push("");
|
|
237
|
+
}
|
|
238
|
+
if (report.warnings.length > 0) lines.push(`> ${report.warnings.join(";")}`, "");
|
|
239
|
+
return lines.join("\n").trimEnd();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function unstableOf(entries) {
|
|
243
|
+
return entries.flatMap((entry) =>
|
|
244
|
+
(entry.stability?.unstable ?? []).map((item) => `${entry.profile}/${item.id}`)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function changeOf(before, after) {
|
|
249
|
+
return {
|
|
250
|
+
id: before.id,
|
|
251
|
+
expected: before.expectedTool ?? null,
|
|
252
|
+
before: before.actualTool ?? null,
|
|
253
|
+
after: after.actualTool ?? null
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function metricsOf(summary) {
|
|
258
|
+
const reviewedRows = summary.rows.filter((row) => row.reviewed !== false);
|
|
259
|
+
const correct = reviewedRows.filter((row) => row.selectionCorrect).length;
|
|
260
|
+
return {
|
|
261
|
+
selectionAccuracy: reviewedRows.length ? correct / reviewedRows.length : null,
|
|
262
|
+
argAccuracy: summary.argAccuracy,
|
|
263
|
+
passRate: reviewedRows.length
|
|
264
|
+
? reviewedRows.filter((row) => row.passed).length / reviewedRows.length
|
|
265
|
+
: null,
|
|
266
|
+
correct
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function signedPoints(value) {
|
|
271
|
+
if (value === null || value === undefined) return "n/a";
|
|
272
|
+
const points = Math.round(value * 100);
|
|
273
|
+
return `${points > 0 ? "+" : ""}${points}pp`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderTable(headers, rows, numericColumns) {
|
|
277
|
+
const widths = headers.map((header, index) =>
|
|
278
|
+
Math.max(displayWidth(header), ...rows.map((row) => displayWidth(row[index])))
|
|
279
|
+
);
|
|
280
|
+
const line = (cells) =>
|
|
281
|
+
cells
|
|
282
|
+
.map((cell, index) => padDisplay(cell, widths[index], numericColumns.includes(index)))
|
|
283
|
+
.join(" ");
|
|
284
|
+
return [line(headers), widths.map((width) => "-".repeat(width)).join(" "), ...rows.map(line)].map(
|
|
285
|
+
(text) => ` ${text}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function padDisplay(text, width, rightAlign) {
|
|
290
|
+
const fill = " ".repeat(Math.max(0, width - displayWidth(text)));
|
|
291
|
+
return rightAlign ? fill + text : text + fill;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function displayWidth(text) {
|
|
295
|
+
let width = 0;
|
|
296
|
+
for (const char of String(text)) {
|
|
297
|
+
width += /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(
|
|
298
|
+
char
|
|
299
|
+
)
|
|
300
|
+
? 2
|
|
301
|
+
: 1;
|
|
302
|
+
}
|
|
303
|
+
return width;
|
|
304
|
+
}
|
package/lib/scan.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
const SKIP_DIRS = new Set([
|
|
5
5
|
"node_modules",
|
|
@@ -43,6 +43,10 @@ const OPENAPI_PATTERN = /^(openapi|swagger)[^/]*\.json$/i;
|
|
|
43
43
|
const SKILL_FILE = "skill.md";
|
|
44
44
|
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py"]);
|
|
45
45
|
|
|
46
|
+
// Test fixtures and mocks use the same APIs as real tool definitions, so any
|
|
47
|
+
// match there is noise for a tool inventory.
|
|
48
|
+
const TEST_PATH = /(^|[\\/])(__tests__|tests?)([\\/])|\.(test|spec)\.[cm]?[jt]sx?$|(^|[\\/])test_[^\\/]+\.py$|_test\.py$/i;
|
|
49
|
+
|
|
46
50
|
const CODE_PATTERNS = [
|
|
47
51
|
{ label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
48
52
|
{ label: "server.tool", confidence: "medium", re: /\.tool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
@@ -209,7 +213,21 @@ export async function scanProject(root = ".") {
|
|
|
209
213
|
if (!info || info.size > MAX_FILE_BYTES) continue;
|
|
210
214
|
const text = await readText(file, report);
|
|
211
215
|
if (!text) continue;
|
|
212
|
-
|
|
216
|
+
if (name.endsWith(".py") && !TEST_PATH.test(rel)) {
|
|
217
|
+
const pythonTools = scanPythonTools(text);
|
|
218
|
+
if (pythonTools.length > 0) {
|
|
219
|
+
addTools(report, pythonTools, {
|
|
220
|
+
source: rel,
|
|
221
|
+
origin: "py-tool",
|
|
222
|
+
confidence: "medium",
|
|
223
|
+
note: "从 @tool 装饰器与类型注解提取,需人工确认"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (TEST_PATH.test(rel)) continue;
|
|
228
|
+
// Only matches that carry a tool name are actionable; a bare `input_schema:`
|
|
229
|
+
// in API plumbing is not a tool and only inflates the report.
|
|
230
|
+
const matches = scanCode(text).filter((match) => match.name);
|
|
213
231
|
for (const match of matches) {
|
|
214
232
|
report.codeMatches.push({
|
|
215
233
|
source: rel,
|
|
@@ -236,13 +254,13 @@ export async function scanProject(root = ".") {
|
|
|
236
254
|
return report;
|
|
237
255
|
}
|
|
238
256
|
|
|
239
|
-
export function addTools(report, tools, { source, origin, confidence = "high", note }) {
|
|
257
|
+
export function addTools(report, tools, { source, origin, confidence = "high", note, meta }) {
|
|
240
258
|
const existing = new Set(report.tools.map((tool) => tool.name));
|
|
241
259
|
const added = [];
|
|
242
260
|
for (const tool of tools) {
|
|
243
261
|
if (!tool?.name || existing.has(tool.name)) continue;
|
|
244
262
|
existing.add(tool.name);
|
|
245
|
-
report.tools.push({ ...tool, source, origin });
|
|
263
|
+
report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
|
|
246
264
|
added.push(tool.name);
|
|
247
265
|
}
|
|
248
266
|
report.sources.push({
|
|
@@ -266,7 +284,7 @@ export function redactReport(report) {
|
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
export function buildSuiteDraft(report) {
|
|
269
|
-
|
|
287
|
+
const draft = {
|
|
270
288
|
name: `${basename(report.root)}-draft`,
|
|
271
289
|
description: "由 mf scan 生成。请人工确认工具描述与参数,并补充 cases 后再运行 mf eval。",
|
|
272
290
|
tools: report.tools.map((tool) => ({
|
|
@@ -276,6 +294,34 @@ export function buildSuiteDraft(report) {
|
|
|
276
294
|
})),
|
|
277
295
|
cases: []
|
|
278
296
|
};
|
|
297
|
+
if (report.skills.length > 0) draft.skills = report.skills.map((skill) => skill.name);
|
|
298
|
+
// Where a tool came from is the only mount hint the scan can observe without
|
|
299
|
+
// guessing: one MCP server or one spec file is normally one thing agents mount.
|
|
300
|
+
const profiles = groupBySource(report.tools);
|
|
301
|
+
if (profiles) draft.profiles = profiles;
|
|
302
|
+
return draft;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function groupBySource(tools) {
|
|
306
|
+
const groups = new Map();
|
|
307
|
+
const owner = new Map();
|
|
308
|
+
for (const tool of tools) {
|
|
309
|
+
const key = tool.provider ?? tool.source ?? "";
|
|
310
|
+
if (!key) continue;
|
|
311
|
+
let label = profileLabel(key);
|
|
312
|
+
if (owner.has(label) && owner.get(label) !== key) label = String(key).replace(/[\\/]+/g, "-");
|
|
313
|
+
owner.set(label, key);
|
|
314
|
+
if (!groups.has(label)) groups.set(label, []);
|
|
315
|
+
groups.get(label).push(tool.name);
|
|
316
|
+
}
|
|
317
|
+
if (groups.size < 2) return null;
|
|
318
|
+
return Object.fromEntries([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function profileLabel(key) {
|
|
322
|
+
const text = String(key);
|
|
323
|
+
if (!/[\\/]|\.(json|ya?ml|toml|js|mjs|ts|py)$/i.test(text)) return text;
|
|
324
|
+
return basename(text, extname(text)) || text;
|
|
279
325
|
}
|
|
280
326
|
|
|
281
327
|
export function formatScanSummary(report) {
|
|
@@ -480,6 +526,140 @@ function parseFrontmatter(text) {
|
|
|
480
526
|
return meta;
|
|
481
527
|
}
|
|
482
528
|
|
|
529
|
+
// LangChain-style tool definitions: @tool above a typed function. The runtime
|
|
530
|
+
// builds the JSON schema from the same annotations, so this is the real schema,
|
|
531
|
+
// not a guess — only the description can drift (first docstring line vs args_schema).
|
|
532
|
+
export function scanPythonTools(text) {
|
|
533
|
+
const lines = text.split(/\r?\n/);
|
|
534
|
+
const tools = [];
|
|
535
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
536
|
+
if (!/^\s*@tool\b/.test(lines[i])) continue;
|
|
537
|
+
let j = i + 1;
|
|
538
|
+
while (j < lines.length && j - i <= 4 && !/^\s*(async\s+)?def\s+\w+\s*\(/.test(lines[j])) j += 1;
|
|
539
|
+
const defMatch = lines[j]?.match(/^\s*(?:async\s+)?def\s+(\w+)\s*\(/);
|
|
540
|
+
if (!defMatch) continue;
|
|
541
|
+
const signature = collectSignature(lines, j, defMatch[0].length);
|
|
542
|
+
if (signature === null) continue;
|
|
543
|
+
const properties = {};
|
|
544
|
+
const required = [];
|
|
545
|
+
for (const part of splitPyParams(signature.text)) {
|
|
546
|
+
const param = parsePyParam(part);
|
|
547
|
+
if (!param) continue;
|
|
548
|
+
properties[param.name] = param.schema;
|
|
549
|
+
if (param.required) required.push(param.name);
|
|
550
|
+
}
|
|
551
|
+
tools.push({
|
|
552
|
+
name: defMatch[1],
|
|
553
|
+
description: pythonDocstring(lines, signature.endLine),
|
|
554
|
+
parameters: {
|
|
555
|
+
type: "object",
|
|
556
|
+
properties,
|
|
557
|
+
...(required.length > 0 ? { required } : {})
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
i = signature.endLine;
|
|
561
|
+
}
|
|
562
|
+
return tools;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function collectSignature(lines, startLine, offset) {
|
|
566
|
+
let depth = 1;
|
|
567
|
+
let out = "";
|
|
568
|
+
for (let i = startLine; i < lines.length; i += 1) {
|
|
569
|
+
const line = lines[i];
|
|
570
|
+
for (let c = i === startLine ? offset : 0; c < line.length; c += 1) {
|
|
571
|
+
const ch = line[c];
|
|
572
|
+
if (ch === "(") depth += 1;
|
|
573
|
+
else if (ch === ")") {
|
|
574
|
+
depth -= 1;
|
|
575
|
+
if (depth === 0) return { text: out, endLine: i };
|
|
576
|
+
}
|
|
577
|
+
out += ch;
|
|
578
|
+
}
|
|
579
|
+
out += "\n";
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function splitPyParams(text) {
|
|
585
|
+
const parts = [];
|
|
586
|
+
let depth = 0;
|
|
587
|
+
let quote = null;
|
|
588
|
+
let current = "";
|
|
589
|
+
for (const ch of text) {
|
|
590
|
+
if (quote) {
|
|
591
|
+
current += ch;
|
|
592
|
+
if (ch === quote) quote = null;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (ch === '"' || ch === "'") {
|
|
596
|
+
quote = ch;
|
|
597
|
+
current += ch;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
601
|
+
if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
|
|
602
|
+
if (ch === "," && depth === 0) {
|
|
603
|
+
parts.push(current);
|
|
604
|
+
current = "";
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
current += ch;
|
|
608
|
+
}
|
|
609
|
+
if (current.trim()) parts.push(current);
|
|
610
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const PY_TYPES = {
|
|
614
|
+
str: "string",
|
|
615
|
+
int: "number",
|
|
616
|
+
float: "number",
|
|
617
|
+
bool: "boolean",
|
|
618
|
+
list: "array",
|
|
619
|
+
List: "array",
|
|
620
|
+
dict: "object",
|
|
621
|
+
Dict: "object"
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
function parsePyParam(part) {
|
|
625
|
+
const eq = part.indexOf("=");
|
|
626
|
+
const head = eq === -1 ? part : part.slice(0, eq);
|
|
627
|
+
const hasDefault = eq !== -1;
|
|
628
|
+
const colon = head.indexOf(":");
|
|
629
|
+
const name = (colon === -1 ? head : head.slice(0, colon)).trim().replace(/^\*+/, "");
|
|
630
|
+
if (!name || name === "self" || name === "cls") return null;
|
|
631
|
+
const typeText = colon === -1 ? "" : head.slice(colon + 1).trim();
|
|
632
|
+
const description = (typeText.match(/Annotated\s*\[[^,]+,\s*["']([^"']+)["']/) ?? [])[1] ?? "";
|
|
633
|
+
const schema = { type: pyType(typeText) };
|
|
634
|
+
if (description) schema.description = description;
|
|
635
|
+
return { name, schema, required: !hasDefault };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pyType(typeText) {
|
|
639
|
+
const core = typeText
|
|
640
|
+
.replace(/^Annotated\s*\[\s*([^,\]]+).*/s, "$1")
|
|
641
|
+
.replace(/^Optional\s*\[\s*(.*?)\s*\]$/s, "$1")
|
|
642
|
+
.trim();
|
|
643
|
+
return PY_TYPES[core] ?? (core.endsWith("List") || core.startsWith("list") ? "array" : "string");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function pythonDocstring(lines, fromLine) {
|
|
647
|
+
for (let i = fromLine + 1; i < Math.min(lines.length, fromLine + 6); i += 1) {
|
|
648
|
+
const match = lines[i].match(/^\s*(?:[rRuU])?("""|''')\s*(.*)$/);
|
|
649
|
+
if (!match) continue;
|
|
650
|
+
const quote = match[1];
|
|
651
|
+
const rest = match[2];
|
|
652
|
+
const sameLine = rest.includes(quote) ? rest.split(quote)[0] : rest;
|
|
653
|
+
if (sameLine.trim()) return sameLine.trim();
|
|
654
|
+
for (let k = i + 1; k < lines.length; k += 1) {
|
|
655
|
+
const text = lines[k].trim();
|
|
656
|
+
if (!text) continue;
|
|
657
|
+
return text.startsWith(quote) ? "" : text;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return "";
|
|
661
|
+
}
|
|
662
|
+
|
|
483
663
|
function scanCode(text) {
|
|
484
664
|
const matches = [];
|
|
485
665
|
for (const pattern of CODE_PATTERNS) {
|
package/lib/score.js
CHANGED
|
@@ -54,6 +54,28 @@ export function matchArgs(actualArgs, expectedArgs) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Even at temperature 0 providers can flip a tool call between identical
|
|
58
|
+
// requests. Repeats exist so the scorecard can show that instead of baking one
|
|
59
|
+
// sample into the number.
|
|
60
|
+
export function pickRepresentative(runs) {
|
|
61
|
+
const signature = (run) => (run?.ok ? run.toolCalls?.[0]?.name ?? "(no tool)" : `error: ${run?.error ?? ""}`);
|
|
62
|
+
const counts = new Map();
|
|
63
|
+
for (const run of runs) {
|
|
64
|
+
const key = signature(run);
|
|
65
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const [key, count] of counts) {
|
|
69
|
+
if (!best || count > best.count) best = { key, count };
|
|
70
|
+
}
|
|
71
|
+
const chosen = runs.find((run) => signature(run) === best.key) ?? runs[0];
|
|
72
|
+
return {
|
|
73
|
+
result: chosen,
|
|
74
|
+
unstable: counts.size > 1,
|
|
75
|
+
outcomes: [...counts.entries()].map(([outcome, count]) => ({ outcome, count }))
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
export function scoreModel({ model, cases, results, pricing }) {
|
|
58
80
|
const rows = cases.map((testCase, index) => {
|
|
59
81
|
const result = results[index];
|
package/lib/suite.js
CHANGED
|
@@ -28,6 +28,8 @@ export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
|
|
|
28
28
|
path: abs,
|
|
29
29
|
tools,
|
|
30
30
|
cases,
|
|
31
|
+
profiles: raw.profiles ?? null,
|
|
32
|
+
skills: Array.isArray(raw.skills) ? raw.skills.map(String) : [],
|
|
31
33
|
execution:
|
|
32
34
|
raw.execution && typeof raw.execution === "object" && !Array.isArray(raw.execution)
|
|
33
35
|
? { ...raw.execution }
|