cardbuddy 0.13.3 → 0.14.1

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.
Files changed (61) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +4 -4
  3. package/dist/config.js +7 -7
  4. package/dist/init.js +15 -4
  5. package/dist/portGuard.js +2 -2
  6. package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
  7. package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +14 -6
  10. package/dist/vendored/card-editor/api.js +6 -6
  11. package/dist/vendored/mcp/client.mjs +53 -3
  12. package/dist/vendored/mcp/tools.mjs +17 -1
  13. package/dist/vendored/server/core/ai-chat.js +1443 -271
  14. package/dist/vendored/server/core/cardFiles.js +271 -39
  15. package/dist/vendored/server/core/cardImportMeta.js +16 -0
  16. package/dist/vendored/server/core/chatHistory.js +122 -0
  17. package/dist/vendored/server/core/index.js +10 -3
  18. package/dist/vendored/server/core/turnJournal.js +386 -0
  19. package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
  20. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
  21. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
  22. package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
  23. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
  24. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
  26. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
  27. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
  28. package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
  29. package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
  31. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
  33. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
  34. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
  37. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
  38. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
  44. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
  45. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
  46. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
  47. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
  49. package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
  50. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
  51. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
  52. package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
  53. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
  54. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
  55. package/dist/vendored/tools/finmall-env.mjs +6 -6
  56. package/package.json +1 -1
  57. package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
  58. package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
  59. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
  60. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
  61. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
@@ -0,0 +1,484 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * plan_next_round.mjs — 失败自动归因 + 下一轮计划(工程待补清单 P1-8)
4
+ *
5
+ * 此前的闭环断点:run_round 产出 review-queue / semantic-review,score_round 产出签名与门禁,
6
+ * round_diff 产出回归清单,但「下一轮到底修什么、修哪个文件、是否已经收敛」全靠人读三份产物。
7
+ * 单卡实测(training/plans/card-gen-speed-analysis.md):99.7% 的时间花在分析与返工上,
8
+ * 不是工具耗时——所以这里把归因、聚类、焦点集、门禁判定、收敛早停一次算完。
9
+ *
10
+ * 用法:
11
+ * node skills/card-skill-trainer/scripts/plan_next_round.mjs --run training/runs/r263 \
12
+ * [--anchor training/anchors/r176.json] [--blind training/runs/r264] \
13
+ * [--history auto|<dir,dir>] [--history-depth 8] [--focus-below 0.85] [--focus-limit 12] [--json]
14
+ *
15
+ * 产出(全部落在本轮 reports/,不写 src/):
16
+ * next-round-plan.json 机器可读计划:聚类 / 焦点集 / 晋级门 / 泛化门 / 收敛信号
17
+ * lesson-drafts.jsonl lessons.jsonl 新格式草稿(status=candidate,人工审批后落盘)
18
+ * convergence-report.json 仅在收敛/早停信号触发时写出(SKILL「收敛检测与早停」要求)
19
+ *
20
+ * 归因只认指标与标注缺陷证据(缺口、掩膜、缺切图计数、几何偏移),不认业务词/画板名。
21
+ */
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import {
25
+ attributeFailure, evaluateGeneralizationGate, evaluatePassGate, failureSignature,
26
+ MASK_REVIEW_THRESHOLD,
27
+ } from './lib/scoring.mjs'
28
+ import { compareRounds, listRounds, loadRunReports, loadRunSummary, roundMetrics } from './lib/runs.mjs'
29
+
30
+ const repoRoot = path.resolve(import.meta.dirname, '../../..')
31
+
32
+ /** 失败类 → 建议修复落点(通用文件,禁止案例专属补丁)。 */
33
+ const TARGETS_BY_CLASS = {
34
+ PARSER: [
35
+ 'skills/sketch-spec-to-card/scripts/parse_meaxure.py',
36
+ 'skills/sketch-spec-to-card/scripts/vision.py',
37
+ 'skills/sketch-spec-to-card/references/boundary.json',
38
+ ],
39
+ DSL: [
40
+ 'skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs',
41
+ 'skills/dsl-card-generator/references/generation_pitfalls.md',
42
+ ],
43
+ STYLE: [
44
+ 'skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs',
45
+ 'skills/dsl-card-generator/references/previewer_contract.md',
46
+ 'skills/dsl-card-generator/references/component_rules.md',
47
+ ],
48
+ DATA: [
49
+ 'skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs',
50
+ 'skills/dsl-card-generator/references/amount_formatting.md',
51
+ ],
52
+ INTERACTION: [
53
+ 'skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs',
54
+ 'skills/dsl-card-generator/references/component_specs.md',
55
+ ],
56
+ ASSET: [
57
+ 'skills/dsl-card-generator/scripts/crop_slices.mjs',
58
+ 'skills/sketch-spec-to-card/scripts/parse_meaxure.py',
59
+ 'skills/dsl-card-generator/references/slices_convention.md',
60
+ ],
61
+ EVALUATOR: [
62
+ 'skills/card-skill-trainer/scripts/run_round.mjs',
63
+ 'skills/card-skill-trainer/scripts/lib/scoring.mjs',
64
+ 'skills/dsl-card-generator/scripts/render_card.mjs',
65
+ ],
66
+ }
67
+
68
+ /** 失败类 → lessons.jsonl 的 category(沿用 lessons 里已有的词表)。 */
69
+ const LESSON_CATEGORY_BY_CLASS = {
70
+ PARSER: 'PARSER', DSL: 'DSL', STYLE: 'LAYOUT', DATA: 'TEXT',
71
+ INTERACTION: 'DSL', ASSET: 'MEDIA', EVALUATOR: 'TRAINER',
72
+ }
73
+
74
+ function parseArgs(argv) {
75
+ const out = {
76
+ run: '', anchor: '', blind: '', history: 'auto', historyDepth: 8,
77
+ focusBelow: 0.85, focusLimit: 12, json: false,
78
+ }
79
+ const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
80
+ for (let index = 0; index < argv.length; index += 1) {
81
+ const token = argv[index]
82
+ if (!token.startsWith('--')) continue
83
+ const key = camel(token.replace(/^--/, ''))
84
+ const next = argv[index + 1]
85
+ if (next === undefined || next.startsWith('--')) out[key] = true
86
+ else { out[key] = next; index += 1 }
87
+ }
88
+ return out
89
+ }
90
+
91
+ const average = (values) => {
92
+ const numbers = values.filter((value) => typeof value === 'number' && Number.isFinite(value))
93
+ return numbers.length ? numbers.reduce((sum, value) => sum + value, 0) / numbers.length : null
94
+ }
95
+ const round3 = (value) => typeof value === 'number' && Number.isFinite(value) ? Number(value.toFixed(4)) : null
96
+
97
+ /** 统一口径:老报告没有 failureSignature / failureClass / gateFailures,按当前尺子现算。 */
98
+ function normalizeReport(report) {
99
+ const gate = Array.isArray(report.gateFailures) && report.gateFailures.length
100
+ ? report.gateFailures
101
+ : (report.passed ? [] : evaluatePassGate(report).failures)
102
+ const attribution = report.passed ? null : (report.failureClass
103
+ ? { failureClass: report.failureClass, reason: report.failureReason || '' }
104
+ : attributeFailure({ ...report, passed: false }))
105
+ return {
106
+ ...report,
107
+ gateFailures: gate,
108
+ signature: report.failureSignature || failureSignature(report) || (report.passed ? null : 'metric-gate'),
109
+ failureClass: attribution?.failureClass || (report.passed ? null : 'UNCLASSIFIED'),
110
+ failureReason: attribution?.reason || '',
111
+ }
112
+ }
113
+
114
+ function nextRoundId(round) {
115
+ const matched = /^r(\d+)(.*)$/.exec(String(round || ''))
116
+ if (!matched) return `${round || 'r'}-next`
117
+ return `r${String(Number(matched[1]) + 1).padStart(matched[1].length, '0')}${matched[2]}`
118
+ }
119
+
120
+ function buildClusters(reports) {
121
+ const buckets = new Map()
122
+ for (const report of reports.filter((item) => !item.passed)) {
123
+ const key = `${report.failureClass}/${report.signature}`
124
+ if (!buckets.has(key)) buckets.set(key, [])
125
+ buckets.get(key).push(report)
126
+ }
127
+ return [...buckets.entries()]
128
+ .map(([key, items]) => {
129
+ const [failureClass, signature] = key.split('/')
130
+ const coFailures = {}
131
+ for (const item of items) {
132
+ for (const failure of item.gateFailures) coFailures[failure.key] = (coFailures[failure.key] || 0) + 1
133
+ }
134
+ const missingSliceCases = items.filter((item) =>
135
+ (item.upstream?.annotationDefects || []).some((defect) => defect?.type === 'missing-slice')).length
136
+ const highMaskCases = items.filter((item) =>
137
+ (item.exclusionCoverage?.total ?? 0) >= MASK_REVIEW_THRESHOLD).length
138
+ const worst = [...items].sort((a, b) =>
139
+ (a.fidelity?.overall ?? 0) - (b.fidelity?.overall ?? 0))[0]
140
+ return {
141
+ failureClass,
142
+ signature,
143
+ count: items.length,
144
+ caseIds: items.map((item) => item.caseId),
145
+ avgFidelity: round3(average(items.map((item) => item.fidelity?.overall))),
146
+ avgPixel: round3(average(items.map((item) => item.pixel?.ratio))),
147
+ avgPosition: round3(average(items.map((item) => item.fidelity?.position))),
148
+ coFailures: Object.entries(coFailures).sort((a, b) => b[1] - a[1]).map(([dim, count]) => ({ dim, count })),
149
+ evidence: {
150
+ missingSliceCases,
151
+ highMaskCases,
152
+ avgAbsAlignDy: round3(average(items.map((item) => Math.abs(item.pixel?.bestAlignDy ?? Number.NaN)))),
153
+ correctorRejected: items.filter((item) => item.correctorRejected).length,
154
+ sampleReason: worst?.failureReason || '',
155
+ },
156
+ suggestedTargets: TARGETS_BY_CLASS[failureClass] || TARGETS_BY_CLASS.DSL,
157
+ worstCaseId: worst?.caseId || null,
158
+ }
159
+ })
160
+ .sort((a, b) => b.count - a.count || (a.avgFidelity ?? 0) - (b.avgFidelity ?? 0))
161
+ }
162
+
163
+ function buildFocus(reports, threshold, limit) {
164
+ const ranked = reports
165
+ .map((item) => ({
166
+ caseId: item.caseId,
167
+ fidelity: typeof item.fidelity?.overall === 'number' ? item.fidelity.overall : 0,
168
+ passed: !!item.passed,
169
+ }))
170
+ .filter((item) => !item.passed || item.fidelity < threshold)
171
+ .sort((a, b) => a.fidelity - b.fidelity || String(a.caseId).localeCompare(String(b.caseId)))
172
+ const picked = Number(limit) > 0 ? ranked.slice(0, Number(limit)) : ranked
173
+ return { threshold, limit: Number(limit) || null, caseIds: picked.map((item) => item.caseId), ranked: picked }
174
+ }
175
+
176
+ /** 跨轮签名趋势:同一签名在最近 N 轮反复出现 = 脚本补丁无效,需升级到 Skill 级范式。 */
177
+ function buildHistory(historyDirs, currentRound, currentReports) {
178
+ const rounds = []
179
+ for (const dir of historyDirs) {
180
+ let reports
181
+ try { reports = [...loadRunReports(dir).values()].map(normalizeReport) } catch { continue }
182
+ if (!reports.length) continue
183
+ const signatures = {}
184
+ for (const report of reports.filter((item) => !item.passed)) {
185
+ signatures[report.signature] = (signatures[report.signature] || 0) + 1
186
+ }
187
+ rounds.push({
188
+ round: path.basename(dir),
189
+ dir,
190
+ metrics: roundMetrics(reports),
191
+ caseIds: reports.map((report) => report.caseId),
192
+ signatures,
193
+ })
194
+ }
195
+ const currentSignatures = {}
196
+ for (const report of currentReports.filter((item) => !item.passed)) {
197
+ currentSignatures[report.signature] = (currentSignatures[report.signature] || 0) + 1
198
+ }
199
+ rounds.push({
200
+ round: currentRound, dir: null, metrics: roundMetrics(currentReports),
201
+ caseIds: currentReports.map((report) => report.caseId), signatures: currentSignatures,
202
+ })
203
+
204
+ // 重复签名:在本轮出现,且在之前的轮次里也出现过 ≥2 次(合计 ≥3 轮)
205
+ const repeated = []
206
+ for (const signature of Object.keys(currentSignatures)) {
207
+ const seen = rounds.filter((round) => (round.signatures[signature] || 0) > 0)
208
+ if (seen.length >= 3) {
209
+ repeated.push({
210
+ signature,
211
+ rounds: seen.map((round) => `${round.round}:${round.signatures[signature]}`),
212
+ occurrences: seen.length,
213
+ })
214
+ }
215
+ }
216
+ repeated.sort((a, b) => b.occurrences - a.occurrences)
217
+
218
+ // 边际收益:取最近 ≤4 轮的共同案例集,算交集上的 avgFidelity 逐轮 delta
219
+ const tail = rounds.slice(-4)
220
+ let marginal = null
221
+ if (tail.length >= 3) {
222
+ const intersection = tail.reduce((acc, round) =>
223
+ acc.filter((caseId) => round.caseIds.includes(caseId)), [...tail[0].caseIds])
224
+ if (intersection.length) {
225
+ const series = tail.map((round) => ({ round: round.round, avgFidelity: round.metrics.fidelity }))
226
+ marginal = { sharedCases: intersection.length, series, note: '均值基于共同案例集,跨批次轮次不可直接比' }
227
+ }
228
+ }
229
+ return { rounds: rounds.map((round) => ({ round: round.round, metrics: round.metrics })), repeatedSignatures: repeated, marginal }
230
+ }
231
+
232
+ function main() {
233
+ const args = parseArgs(process.argv.slice(2))
234
+ if (!args.run) throw new Error('--run 必填(例:--run training/runs/r263)')
235
+ const runDir = path.resolve(repoRoot, String(args.run))
236
+ const reports = [...loadRunReports(runDir).values()].map(normalizeReport)
237
+ if (!reports.length) throw new Error(`${runDir} 里没有案例报告`)
238
+ const roundName = path.basename(runDir)
239
+ const summary = loadRunSummary(runDir)
240
+ const metrics = summary
241
+ ? {
242
+ total: summary.total, passed: summary.passed, passRate: summary.passRate,
243
+ fidelity: summary.avgFidelity, pixel: summary.avgPixelDiff, position: summary.avgPositionFidelity,
244
+ structure: summary.avgStructureFidelity, components: summary.avgFidelityComponents,
245
+ minFidelity: summary.minFidelity,
246
+ }
247
+ : roundMetrics(reports)
248
+ const reportDir = path.join(runDir, 'reports')
249
+ fs.mkdirSync(reportDir, { recursive: true })
250
+
251
+ const clusters = buildClusters(reports)
252
+ const focus = buildFocus(reports, Number(args.focusBelow) || 0.85, Number(args.focusLimit) || 0)
253
+ const failing = reports.filter((item) => !item.passed)
254
+
255
+ // —— 晋级门:与冻结锚点(轮目录或入库压缩锚点)对账 ——
256
+ let promotionGate = { available: false, reason: '未提供 --anchor(干净克隆可用 training/anchors/r176.json)' }
257
+ if (args.anchor) {
258
+ const anchorReports = loadRunReports(String(args.anchor))
259
+ const comparison = compareRounds(anchorReports, new Map(reports.map((item) => [item.caseId, item])))
260
+ promotionGate = {
261
+ available: true,
262
+ anchor: String(args.anchor),
263
+ sharedCases: comparison.sharedCount,
264
+ regressions: comparison.regressed,
265
+ fixed: comparison.fixed,
266
+ degradedMeans: (() => {
267
+ const degraded = []
268
+ for (const [key, higherIsBetter] of [['fidelity', true], ['position', true], ['structure', true], ['components', true], ['pixel', false]]) {
269
+ const before = comparison.from[key]
270
+ const after = comparison.to[key]
271
+ if (typeof before !== 'number' || typeof after !== 'number') continue
272
+ const delta = after - before
273
+ if (higherIsBetter ? delta < -1e-9 : delta > 1e-9) degraded.push({ metric: key, delta: Number(delta.toFixed(5)) })
274
+ }
275
+ return degraded
276
+ })(),
277
+ added: comparison.added.length,
278
+ removed: comparison.removed.length,
279
+ }
280
+ promotionGate.passed = promotionGate.regressions.length === 0 && promotionGate.degradedMeans.length === 0
281
+ if (comparison.sharedCount === 0) {
282
+ promotionGate.note = '与锚点无共享案例:本轮是新批次,只作盲测/基线,不构成锚点回归'
283
+ promotionGate.passed = null
284
+ }
285
+ }
286
+
287
+ // —— 泛化盲测门:--blind 传未见样本轮 ——
288
+ let generalizationGate = { available: false, reason: '未提供 --blind(每 3 轮或晋级前必须盲测 ≥24 例)' }
289
+ if (args.blind) {
290
+ const blindDir = path.resolve(repoRoot, String(args.blind))
291
+ const blindSummary = loadRunSummary(blindDir) || (() => {
292
+ const blindMetrics = roundMetrics(loadRunReports(blindDir))
293
+ return { total: blindMetrics.total, passRate: blindMetrics.passRate, avgFidelity: blindMetrics.fidelity }
294
+ })()
295
+ const anchorSummary = args.anchor
296
+ ? (loadRunSummary(String(args.anchor)) || (() => {
297
+ const anchorMetrics = roundMetrics(loadRunReports(String(args.anchor)))
298
+ return { avgFidelity: anchorMetrics.fidelity }
299
+ })())
300
+ : { avgFidelity: metrics.fidelity }
301
+ generalizationGate = {
302
+ available: true,
303
+ blind: String(args.blind),
304
+ ...evaluateGeneralizationGate(blindSummary, anchorSummary),
305
+ }
306
+ }
307
+
308
+ // —— 历史趋势与收敛信号 ——
309
+ let historyDirs = []
310
+ if (String(args.history) === 'auto') {
311
+ const runsRoot = path.join(repoRoot, 'training', 'runs')
312
+ historyDirs = listRounds(runsRoot)
313
+ .filter((name) => name !== roundName)
314
+ .slice(-(Math.max(1, Number(args.historyDepth) || 8)))
315
+ .map((name) => path.join(runsRoot, name))
316
+ } else if (args.history && args.history !== 'none') {
317
+ historyDirs = String(args.history).split(',').filter(Boolean).map((item) => path.resolve(repoRoot, item.trim()))
318
+ }
319
+ const history = buildHistory(historyDirs, roundName, reports)
320
+
321
+ const signals = []
322
+ for (const item of history.repeatedSignatures) {
323
+ signals.push({
324
+ signal: 'same-signature-3-rounds',
325
+ detail: `签名 ${item.signature} 在 ${item.occurrences} 轮重复出现(${item.rounds.join(' → ')}):脚本补丁无效,需升级 Skill 级范式`,
326
+ })
327
+ }
328
+ const series = history.marginal?.series || []
329
+ if (series.length >= 4) {
330
+ const deltas = series.slice(1).map((item, index) => item.avgFidelity - series[index].avgFidelity)
331
+ if (deltas.every((delta) => delta < 0.01)) {
332
+ signals.push({
333
+ signal: 'marginal-gain-below-1pt',
334
+ detail: `连续 ${deltas.length} 轮共同案例集 avgFidelity 提升均 <1pt(${deltas.map((d) => (d * 100).toFixed(2) + 'pp').join(' / ')}):停止脚本微调`,
335
+ })
336
+ }
337
+ }
338
+ if (generalizationGate.available) {
339
+ if (!generalizationGate.anchorGap.met && generalizationGate.anchorGap.actual != null) {
340
+ signals.push({
341
+ signal: 'anchor-blind-gap-over-10pts',
342
+ detail: `锚点−盲测差 ${(generalizationGate.anchorGap.actual * 100).toFixed(1)}pts > 10pts:过拟合风险,强制回退候选规则`,
343
+ })
344
+ }
345
+ if (promotionGate.available && typeof metrics.fidelity === 'number') {
346
+ const blindFidelity = generalizationGate.avgFidelity?.actual
347
+ if (typeof blindFidelity === 'number' && metrics.fidelity > blindFidelity && promotionGate.regressions?.length === 0) {
348
+ signals.push({
349
+ signal: 'anchor-up-blind-down',
350
+ detail: `锚点侧 avgFidelity ${(metrics.fidelity * 100).toFixed(1)}% 高于盲测 ${(blindFidelity * 100).toFixed(1)}%:判定过拟合,拒绝晋级`,
351
+ })
352
+ }
353
+ }
354
+ }
355
+ const highMaskPassing = reports.filter((item) =>
356
+ item.passed && (item.exclusionCoverage?.total ?? 0) >= MASK_REVIEW_THRESHOLD)
357
+ if (highMaskPassing.length) {
358
+ signals.push({
359
+ signal: 'pass-by-mask',
360
+ detail: `${highMaskPassing.length} 例靠掩膜通过(覆盖 ≥${(MASK_REVIEW_THRESHOLD * 100).toFixed(0)}%):${highMaskPassing.slice(0, 5).map((item) => item.caseId).join(',')},须看图复核,不得计入晋级证据`,
361
+ })
362
+ }
363
+
364
+ // —— lesson 草稿(新格式,status=candidate;人工审批后再落盘 lessons.jsonl)——
365
+ const lessonDrafts = clusters
366
+ .filter((cluster) => cluster.count >= 2 || cluster.signature !== 'metric-gate')
367
+ .slice(0, 10)
368
+ .map((cluster, index) => ({
369
+ lessonId: `${roundName}-draft-${String(index + 1).padStart(2, '0')}`,
370
+ signature: cluster.signature,
371
+ failureClass: cluster.failureClass,
372
+ evidenceCases: cluster.caseIds.slice(0, 5),
373
+ rule: 'TODO:人工补写通用规则(只允许几何/样式/结构判据,禁业务词与画板名)',
374
+ targetFiles: cluster.suggestedTargets,
375
+ status: 'candidate',
376
+ round: roundName,
377
+ metrics: { avgFidelity: cluster.avgFidelity, avgPixel: cluster.avgPixel, coFailures: cluster.coFailures },
378
+ }))
379
+ const draftsPath = path.join(reportDir, 'lesson-drafts.jsonl')
380
+ fs.writeFileSync(draftsPath, lessonDrafts.map((item) => JSON.stringify(item)).join('\n') + (lessonDrafts.length ? '\n' : ''))
381
+
382
+ const nextRound = nextRoundId(roundName)
383
+ const rel = (target) => path.relative(repoRoot, target).split(path.sep).join('/')
384
+ const nextActions = []
385
+ if (failing.length) {
386
+ nextActions.push(`逐例诊断 Top${Math.min(3, clusters.length)} 聚类代表:${clusters.slice(0, 3)
387
+ .map((cluster) => `node skills/card-skill-trainer/scripts/diagnose_case.mjs --run ${rel(runDir)} --case ${cluster.worstCaseId}`)
388
+ .join(' ; ')}`)
389
+ }
390
+ if (focus.caseIds.length) {
391
+ nextActions.push(`焦点集增量轮:node skills/card-skill-trainer/scripts/run_round.mjs --dataset <dataset>.jsonl --round ${nextRound} --focus ${rel(runDir)} --focus-limit ${focus.caseIds.length} --inherit ${rel(runDir)}`)
392
+ }
393
+ if (args.anchor) {
394
+ nextActions.push(`锚点回归:node skills/card-skill-trainer/scripts/round_diff.mjs --from ${rel(path.resolve(repoRoot, String(args.anchor)))} --to training/runs/${nextRound}`)
395
+ }
396
+ nextActions.push('规则落盘后:node training/tools/aggregate_lessons.mjs 复核 lesson 草稿,并 npm run ci 过硬门禁')
397
+
398
+ const plan = {
399
+ round: roundName,
400
+ generatedAt: new Date().toISOString(),
401
+ inputs: {
402
+ run: rel(runDir),
403
+ anchor: args.anchor ? String(args.anchor) : null,
404
+ blind: args.blind ? String(args.blind) : null,
405
+ history: history.rounds.map((item) => item.round),
406
+ },
407
+ metrics,
408
+ failingCount: failing.length,
409
+ clusters,
410
+ focus: { threshold: focus.threshold, limit: focus.limit, caseIds: focus.caseIds },
411
+ promotionGate,
412
+ generalizationGate,
413
+ convergence: { triggered: signals.length > 0, signals },
414
+ highMaskPassing: highMaskPassing.map((item) => ({ caseId: item.caseId, coverage: round3(item.exclusionCoverage?.total) })),
415
+ lessonDrafts: rel(draftsPath),
416
+ nextActions,
417
+ }
418
+ const planPath = path.join(reportDir, 'next-round-plan.json')
419
+ fs.writeFileSync(planPath, JSON.stringify(plan, null, 2) + '\n')
420
+ if (signals.length) {
421
+ fs.writeFileSync(path.join(reportDir, 'convergence-report.json'),
422
+ JSON.stringify({ round: roundName, generatedAt: plan.generatedAt, signals, clusters, nextActions }, null, 2) + '\n')
423
+ }
424
+
425
+ if (args.json) { console.log(JSON.stringify(plan, null, 2)); return }
426
+
427
+ const pctValue = (value) => value == null ? 'N/A' : `${(value * 100).toFixed(1)}%`
428
+ console.log(`══ 下一轮计划 ${roundName} ══`)
429
+ console.log(`案例 ${metrics.total} / 通过 ${metrics.passed} (${pctValue(metrics.passRate)}) / 失败 ${failing.length}`)
430
+ console.log(`avgFid ${pctValue(metrics.fidelity)} avgPix ${pctValue(metrics.pixel)} avgPos ${pctValue(metrics.position)} minFid ${pctValue(metrics.minFidelity)}`)
431
+ console.log('')
432
+ console.log('── 失败聚类(按例数降序)──')
433
+ if (!clusters.length) console.log(' (本轮无失败)')
434
+ for (const cluster of clusters.slice(0, 8)) {
435
+ console.log(` [${cluster.failureClass}] ${cluster.signature} × ${cluster.count} avgFid=${pctValue(cluster.avgFidelity)}`)
436
+ console.log(` 未达标维度: ${cluster.coFailures.map((item) => `${item.dim}×${item.count}`).join(' ')}`)
437
+ console.log(` 证据: 缺切图案例 ${cluster.evidence.missingSliceCases} / 高掩膜 ${cluster.evidence.highMaskCases} / |dy|均值 ${cluster.evidence.avgAbsAlignDy ?? 'N/A'} / 校正被拒 ${cluster.evidence.correctorRejected}`)
438
+ console.log(` 落点: ${cluster.suggestedTargets.join(' , ')}`)
439
+ console.log(` 案例: ${cluster.caseIds.slice(0, 6).join(',')}${cluster.caseIds.length > 6 ? '…' : ''}`)
440
+ }
441
+ console.log('')
442
+ console.log(`── 焦点集(fidelity<${focus.threshold} 或未通过,共 ${focus.caseIds.length} 例)──`)
443
+ if (focus.caseIds.length) console.log(` --only ${focus.caseIds.join(',')}`)
444
+ console.log('')
445
+ console.log('── 门禁判定 ──')
446
+ if (promotionGate.available) {
447
+ const verdict = promotionGate.passed == null ? 'N/A(新批次)' : promotionGate.passed ? 'PASS' : 'FAIL'
448
+ console.log(` 晋级门(锚点 ${path.basename(promotionGate.anchor)}, 共享 ${promotionGate.sharedCases} 例): ${verdict}`)
449
+ if (promotionGate.regressions?.length) console.log(` 回归 pass→fail: ${promotionGate.regressions.map((item) => item.caseId).join(',')}`)
450
+ // 无共享案例时均值不可比(新批次 vs 锚点),只在有交集时才报退化
451
+ if (promotionGate.degradedMeans?.length && promotionGate.sharedCases > 0) {
452
+ console.log(` 均值退化: ${promotionGate.degradedMeans.map((item) => `${item.metric} ${(item.delta * 100).toFixed(2)}pp`).join(' ')}`)
453
+ }
454
+ } else console.log(` 晋级门: ${promotionGate.reason}`)
455
+ if (generalizationGate.available) {
456
+ console.log(` 泛化盲测门(${path.basename(String(args.blind))}): ${generalizationGate.passed ? 'PASS' : 'FAIL'}`)
457
+ for (const [key, check] of Object.entries(generalizationGate)) {
458
+ if (!check || typeof check !== 'object') continue
459
+ console.log(` ${check.met ? 'PASS' : 'FAIL'} ${key}: target=${check.target} actual=${check.actual}`)
460
+ }
461
+ } else console.log(` 泛化盲测门: ${generalizationGate.reason}`)
462
+ console.log('')
463
+ console.log('── 收敛 / 早停信号 ──')
464
+ if (!signals.length) console.log(' (无)')
465
+ for (const signal of signals) console.log(` ! ${signal.signal}: ${signal.detail}`)
466
+ if (history.repeatedSignatures.length) {
467
+ console.log('')
468
+ console.log('── 跨轮重复签名 ──')
469
+ for (const item of history.repeatedSignatures.slice(0, 6)) console.log(` ${item.signature} ${item.rounds.join(' → ')}`)
470
+ }
471
+ console.log('')
472
+ console.log('── 下一轮动作 ──')
473
+ for (const action of nextActions) console.log(` • ${action}`)
474
+ console.log('')
475
+ console.log(`计划: ${rel(planPath)}`)
476
+ console.log(`lesson 草稿: ${rel(draftsPath)}(${lessonDrafts.length} 条,status=candidate,需人工审批后落盘 lessons.jsonl)`)
477
+ }
478
+
479
+ try {
480
+ main()
481
+ } catch (error) {
482
+ console.error(String(error?.message || error))
483
+ process.exit(1)
484
+ }