modelfunction-cli 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/lint.js CHANGED
@@ -1,4 +1,5 @@
1
- import { suspectedPairs } from "./cases.js";
1
+ import { suspectedPairs, nameTokens } from "./cases.js";
2
+ import { normalizeProfiles } from "./profile.js";
2
3
 
3
4
  const GENERIC_NAMES = new Set([
4
5
  "run",
@@ -29,13 +30,18 @@ const DESCRIPTION_ID_HINT = /(会话\s*id|session\s*id|租户|tenant|trace|请
29
30
  const ID_SOURCE_HINT = /(来源|取自|来自|传入|注入|平台|系统|上下文|请求头|header|无需|自动|会提供|由调用方|上一步)/i;
30
31
  const FIXED_ID_LENGTHS = new Set([8, 16, 24, 32, 36, 40, 64]);
31
32
 
32
- function nameTokens(name) {
33
- return String(name)
34
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
35
- .split(/[^A-Za-z0-9]+/)
36
- .map((token) => token.toLowerCase())
37
- .filter(Boolean);
38
- }
33
+ // Skills have no parameter schema, so name + description is all there is to
34
+ // compare. Two skills above this similarity are competing for the same intent.
35
+ // SKILL.md files share an authoring vocabulary ("Discord archive: search, sync
36
+ // freshness") that says nothing about overlap; tuned against a real 53-skill
37
+ // set, without it unrelated per-platform skills pair up.
38
+ const SKILL_PAIR_SIMILARITY = 0.35;
39
+ const SKILL_BOILERPLATE = new Set([
40
+ "skill", "description", "operation", "management", "archive", "sync",
41
+ "freshness", "triage", "work", "group", "related", "state", "count", "sql",
42
+ "repo", "activate", "mention", "pipeline", "provider", "handle", "handling"
43
+ ]);
44
+ const SKILL_NAME = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
39
45
 
40
46
  function idStrength(name) {
41
47
  const tokens = nameTokens(name);
@@ -54,7 +60,32 @@ const NO_BOUNDARY_SIMILARITY = 0.5;
54
60
  export function lintSuite(suite) {
55
61
  const findings = [];
56
62
  const add = (severity, code, tool, message, extra = {}) =>
57
- findings.push({ severity, code, tool, message, ...extra });
63
+ findings.push({ severity, code, kind: "tool", tool, message, ...extra });
64
+ const addSkill = (severity, code, skill, message, extra = {}) =>
65
+ findings.push({ severity, code, kind: "skill", skill, message, ...extra });
66
+
67
+ // Mounting groups, when declared: a confusable pair split across groups is a
68
+ // merge hazard (mounting both groups on one agent recreates the collision),
69
+ // while the same pair inside one group is just a normal authoring problem.
70
+ const profileGroups = suite.profiles
71
+ ? normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name))
72
+ : [];
73
+ const toolGroups = new Map();
74
+ for (const profile of profileGroups) {
75
+ for (const name of profile.tools) {
76
+ const groups = toolGroups.get(name);
77
+ if (groups) groups.push(profile.name);
78
+ else toolGroups.set(name, [profile.name]);
79
+ }
80
+ }
81
+ const crossGroups = new Map();
82
+ const recordCrossPair = (groupA, groupB, pair) => {
83
+ const [a, b] = groupA < groupB ? [groupA, groupB] : [groupB, groupA];
84
+ const key = `${JSON.stringify([a, b])}`;
85
+ const entry = crossGroups.get(key) ?? { a, b, pairs: [] };
86
+ entry.pairs.push({ toolA: pair.a, toolB: pair.b, similarity: pair.similarity });
87
+ crossGroups.set(key, entry);
88
+ };
58
89
 
59
90
  for (const tool of suite.tools) {
60
91
  const description = (tool.description ?? "").trim();
@@ -178,6 +209,13 @@ export function lintSuite(suite) {
178
209
  pair: [pair.a, pair.b]
179
210
  });
180
211
  } else if (pair.similarity >= NO_BOUNDARY_SIMILARITY) {
212
+ const aGroups = toolGroups.get(pair.a) ?? [];
213
+ const bGroups = toolGroups.get(pair.b) ?? [];
214
+ if (aGroups.length > 0 && bGroups.length > 0 && !aGroups.some((group) => bGroups.includes(group))) {
215
+ for (const groupA of aGroups) {
216
+ for (const groupB of bGroups) recordCrossPair(groupA, groupB, pair);
217
+ }
218
+ }
181
219
  const reason = pair.basis === "structural" ? "名字与参数形状接近" : "描述相近";
182
220
  add("warn", "pair-no-boundary", pair.a, `与 ${pair.b} ${reason}(相似度 ${similarity}),且两边都没写“不适用于”边界,模型只能靠相似度硬猜`, {
183
221
  pair: [pair.a, pair.b],
@@ -186,23 +224,159 @@ export function lintSuite(suite) {
186
224
  }
187
225
  }
188
226
 
227
+ // Skills are prompt-injected (name + description only), so the install-time
228
+ // question is narrower than for tools: is the description specific enough to
229
+ // be triggered, and does it collide with another installed skill?
230
+ const skills = suite.skills ?? [];
231
+ for (const skill of skills) {
232
+ const description = String(skill.description ?? "").trim();
233
+ if (!description) {
234
+ addSkill("info", "skill-desc-missing", skill.name, "skill 没有 description,模型只能靠名字判断是否触发", {
235
+ suggestion: "在 SKILL.md frontmatter 补一句 description,写清楚“用于什么任务、不适用于什么”"
236
+ });
237
+ } else if (description.length < 20) {
238
+ addSkill("warn", "skill-desc-short", skill.name, `description 只有 ${description.length} 字,信息不足`, {
239
+ suggestion: "写清楚触发场景与边界,例如「用于____;不适用于____」"
240
+ });
241
+ } else if (description.length > 600) {
242
+ addSkill("info", "skill-desc-long", skill.name, `description ${description.length} 字,可能挤占上下文`);
243
+ }
244
+ if (!SKILL_NAME.test(skill.name)) {
245
+ addSkill("warn", "skill-name-style", skill.name, "skill 名建议用小写 kebab-case 或 snake_case");
246
+ }
247
+ }
248
+
249
+ for (const pair of suspectedPairs(skills, {
250
+ threshold: SKILL_PAIR_SIMILARITY,
251
+ extraStop: SKILL_BOILERPLATE
252
+ })) {
253
+ const similarity = pair.similarity.toFixed(2);
254
+ const declaresBoundary = [pair.a, pair.b].some((name) => {
255
+ const skill = skills.find((item) => item.name === name);
256
+ return BOUNDARY_HINT.test(skill?.description ?? "");
257
+ });
258
+ // Same-vendor siblings ("feishu-doc" / "feishu-wiki") share the vendor token
259
+ // but each name carries its own distinguishing word. That is a family, not a
260
+ // duplicate — worth telling the author, not worth a warning.
261
+ const nameBits = [pair.a, pair.b].map(
262
+ (name) => new Set(nameTokens(name).filter((token) => !SKILL_BOILERPLATE.has(token)))
263
+ );
264
+ const sharedNames = [...nameBits[0]].filter((token) => nameBits[1].has(token));
265
+ const uniqueNames = nameBits.map((own, index) =>
266
+ [...own].filter((token) => !nameBits[1 - index].has(token))
267
+ );
268
+ const sibling = sharedNames.length > 0 && uniqueNames.every((list) => list.length > 0);
269
+
270
+ if (declaresBoundary) {
271
+ addSkill("info", "skill-pair-overlap", pair.a, `与 ${pair.b} 的描述相似度 ${similarity},已写互斥边界`, {
272
+ pair: [pair.a, pair.b]
273
+ });
274
+ } else if (sibling) {
275
+ addSkill(
276
+ "info",
277
+ "skill-pair-sibling",
278
+ pair.a,
279
+ `与 ${pair.b} 同族(共享 ${sharedNames.join("/")},以 ${uniqueNames[0].join("/")} 与 ${uniqueNames[1].join("/")} 区分),相似度 ${similarity}`,
280
+ {
281
+ pair: [pair.a, pair.b],
282
+ suggestion: `确认 ${pair.a} 与 ${pair.b} 的 description 里各自写清区别;同族分开挂载通常没问题`
283
+ }
284
+ );
285
+ } else {
286
+ addSkill(
287
+ "warn",
288
+ "skill-pair-no-boundary",
289
+ pair.a,
290
+ `与 ${pair.b} 的描述高度重叠(相似度 ${similarity}),skills 没有参数形状可区分,模型只能靠字面猜`,
291
+ {
292
+ pair: [pair.a, pair.b],
293
+ suggestion: `在 ${pair.a} 和 ${pair.b} 的 description 里写清各自边界,或合并成一个 skill`
294
+ }
295
+ );
296
+ }
297
+ }
298
+
189
299
  const counts = { error: 0, warn: 0, info: 0 };
190
300
  for (const finding of findings) counts[finding.severity] += 1;
191
- return { tools: suite.tools.length, counts, findings };
301
+ const profilePairs = [...crossGroups.values()]
302
+ .map((entry) => ({ ...entry, max: Math.max(...entry.pairs.map((item) => item.similarity)) }))
303
+ .sort(
304
+ (x, y) =>
305
+ y.max - x.max ||
306
+ y.pairs.length - x.pairs.length ||
307
+ `${x.a} ${x.b}`.localeCompare(`${y.a} ${y.b}`)
308
+ );
309
+ return { tools: suite.tools.length, skills: skills.length, counts, findings, profilePairs };
310
+ }
311
+
312
+ // A mature 47-tool gateway produces ~70 findings where 60 are the same rule
313
+ // repeated. An install gate that prints all of them is unreadable, so repeats
314
+ // are folded into one block; the JSON keeps every finding for tooling.
315
+ const AGGREGATE_MIN = 3;
316
+ const KEEP_INDIVIDUAL = new Set([
317
+ "pair-no-boundary",
318
+ "pair-overlap",
319
+ "skill-pair-no-boundary",
320
+ "skill-pair-overlap"
321
+ ]);
322
+
323
+ function subjectOf(finding) {
324
+ if (finding.kind === "skill") return `skill ${finding.skill}`;
325
+ return `${finding.tool}${finding.param ? `.${finding.param}` : ""}`;
192
326
  }
193
327
 
194
328
  export function formatLint(report) {
195
- const lines = [
196
- `tools: ${report.tools} findings: ${report.findings.length}(error ${report.counts.error} / warn ${report.counts.warn} / info ${report.counts.info})`
197
- ];
198
- if (report.findings.length === 0) {
199
- lines.push("没有发现问题");
200
- return lines.join("\n");
329
+ const { counts, findings } = report;
330
+ const skillCount = report.skills ?? 0;
331
+ const head = `tools: ${report.tools}${skillCount > 0 ? ` skills: ${skillCount}` : ""} findings: ${findings.length}(error ${counts.error} / warn ${counts.warn} / info ${counts.info})`;
332
+ const profilePairs = report.profilePairs ?? [];
333
+ if (findings.length === 0 && profilePairs.length === 0) {
334
+ return `${head}\n没有发现问题`;
201
335
  }
202
- for (const finding of report.findings) {
203
- const param = finding.param ? `.${finding.param}` : "";
204
- lines.push(`[${finding.severity}] ${finding.tool}${param} ${finding.code}: ${finding.message}`);
205
- if (finding.suggestion) lines.push(` ${finding.suggestion}`);
336
+
337
+ const byCode = new Map();
338
+ for (const finding of findings) {
339
+ const list = byCode.get(finding.code) ?? [];
340
+ list.push(finding);
341
+ byCode.set(finding.code, list);
342
+ }
343
+ const bulk = [...byCode].filter(
344
+ ([code, list]) =>
345
+ list.length >= AGGREGATE_MIN && !KEEP_INDIVIDUAL.has(code) && list[0].severity !== "error"
346
+ );
347
+ const bulkCodes = new Set(bulk.map(([code]) => code));
348
+ const lines = [head];
349
+
350
+ if (bulk.length > 0) {
351
+ lines.push("", `重复问题(同一规则 ≥${AGGREGATE_MIN} 条,已合并;逐条明细见 --json):`);
352
+ for (const [code, list] of bulk) {
353
+ const first = list[0];
354
+ lines.push(`[${first.severity}] ${code} × ${list.length} ${first.message}`);
355
+ const subjects = list.map(subjectOf);
356
+ lines.push(
357
+ ` 涉及:${subjects.slice(0, 8).join("、")}${subjects.length > 8 ? ` 等 ${subjects.length} 处` : ""}`
358
+ );
359
+ if (first.suggestion) lines.push(` ${first.suggestion}`);
360
+ }
361
+ }
362
+
363
+ const rest = findings.filter((finding) => !bulkCodes.has(finding.code));
364
+ if (rest.length > 0) {
365
+ if (bulk.length > 0) lines.push("", "逐条:");
366
+ for (const finding of rest) {
367
+ lines.push(`[${finding.severity}] ${subjectOf(finding)} ${finding.code}: ${finding.message}`);
368
+ if (finding.suggestion) lines.push(` ${finding.suggestion}`);
369
+ }
370
+ }
371
+
372
+ if (profilePairs.length > 0) {
373
+ lines.push("", "组间高风险对(这些组不建议合并挂载):");
374
+ for (const group of profilePairs) {
375
+ lines.push(` ${group.a} × ${group.b} ${group.pairs.length} 对(最高 ${group.max.toFixed(2)})`);
376
+ for (const item of group.pairs) {
377
+ lines.push(` ${item.toolA} × ${item.toolB} ${item.similarity.toFixed(2)}`);
378
+ }
379
+ }
206
380
  }
207
381
  return lines.join("\n");
208
382
  }
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);
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
+ }