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.
- package/README.md +7 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -7
- package/dist/init.js +15 -4
- package/dist/portGuard.js +2 -2
- package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
- package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +14 -6
- package/dist/vendored/card-editor/api.js +6 -6
- package/dist/vendored/mcp/client.mjs +53 -3
- package/dist/vendored/mcp/tools.mjs +17 -1
- package/dist/vendored/server/core/ai-chat.js +1443 -271
- package/dist/vendored/server/core/cardFiles.js +271 -39
- package/dist/vendored/server/core/cardImportMeta.js +16 -0
- package/dist/vendored/server/core/chatHistory.js +122 -0
- package/dist/vendored/server/core/index.js +10 -3
- package/dist/vendored/server/core/turnJournal.js +386 -0
- package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
- package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
- package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
- package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
- package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
- package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
- package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
- package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
- package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
- package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
- package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
- package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
- package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
- package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
- package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
- package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
- package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
- package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
- package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
- package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
- package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
- package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
- package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
- package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
- package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
- package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
- package/dist/vendored/tools/finmall-env.mjs +6 -6
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
- package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
- package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// 轮间对比:PASS→FAIL 回归检测、FAIL→PASS 修复确认、指标 delta。
|
|
3
3
|
// 用途:提交/落盘前门禁(存在回归时 exit 1)、训练报告中的轮次演进证据。
|
|
4
|
-
// 用法:node round_diff.mjs --from training/runs/r170 --to training/runs/r171 [--allow-regressions]
|
|
4
|
+
// 用法:node round_diff.mjs --from training/runs/r170 --to training/runs/r171 [--allow-regressions] [--json out.json]
|
|
5
5
|
// --from/--to 也接受压缩锚点文件(training/anchors/r176.json),干净克隆无 training/runs 时
|
|
6
|
-
// 仍可与冻结锚点对账:node round_diff.mjs --from training/anchors/r176.json --to training/runs/
|
|
6
|
+
// 仍可与冻结锚点对账:node round_diff.mjs --from training/anchors/r176.json --to training/runs/r262
|
|
7
|
+
// 读取与对比口径统一走 lib/runs.mjs(score_round / plan_next_round 同源)。
|
|
7
8
|
import fs from 'node:fs'
|
|
8
9
|
import path from 'node:path'
|
|
10
|
+
import { compareRounds, loadRunReports, reportsDirOf } from './lib/runs.mjs'
|
|
9
11
|
|
|
10
12
|
function parseArgs(argv) {
|
|
11
|
-
const out = { from: '', to: '', allowRegressions: false }
|
|
13
|
+
const out = { from: '', to: '', allowRegressions: false, json: '' }
|
|
12
14
|
const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
|
|
13
15
|
for (let index = 0; index < argv.length; index += 1) {
|
|
14
16
|
const token = argv[index]
|
|
@@ -21,113 +23,92 @@ function parseArgs(argv) {
|
|
|
21
23
|
return out
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
function loadReports(runDir) {
|
|
25
|
-
const resolved = path.resolve(runDir)
|
|
26
|
-
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
27
|
-
const anchor = JSON.parse(fs.readFileSync(resolved, 'utf8'))
|
|
28
|
-
const list = Array.isArray(anchor.cases) ? anchor.cases : Object.values(anchor.cases || {})
|
|
29
|
-
const map = new Map()
|
|
30
|
-
for (const report of list) if (report && report.caseId) map.set(report.caseId, report)
|
|
31
|
-
if (!map.size) throw new Error(`No cases in anchor file ${resolved}`)
|
|
32
|
-
return map
|
|
33
|
-
}
|
|
34
|
-
const reportDir = path.join(resolved, 'reports')
|
|
35
|
-
if (!fs.existsSync(reportDir)) throw new Error(`No reports at ${reportDir}`)
|
|
36
|
-
const map = new Map()
|
|
37
|
-
for (const file of fs.readdirSync(reportDir)) {
|
|
38
|
-
if (!file.endsWith('.json')) continue
|
|
39
|
-
if (/summary|skipped|review|semantic/.test(file)) continue
|
|
40
|
-
const report = JSON.parse(fs.readFileSync(path.join(reportDir, file), 'utf8'))
|
|
41
|
-
if (report.caseId) map.set(report.caseId, report)
|
|
42
|
-
}
|
|
43
|
-
return map
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
const args = parseArgs(process.argv.slice(2))
|
|
47
27
|
if (!args.from || !args.to) throw new Error('--from and --to are required')
|
|
48
28
|
|
|
49
|
-
const fromReports =
|
|
50
|
-
const toReports =
|
|
29
|
+
const fromReports = loadRunReports(args.from)
|
|
30
|
+
const toReports = loadRunReports(args.to)
|
|
31
|
+
const comparison = compareRounds(fromReports, toReports)
|
|
51
32
|
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
position: (r) => r.fidelity?.position,
|
|
57
|
-
structure: (r) => r.pixel?.structure,
|
|
58
|
-
}
|
|
59
|
-
const avg = (values) => {
|
|
60
|
-
const nums = values.filter((v) => typeof v === 'number')
|
|
61
|
-
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null
|
|
62
|
-
}
|
|
63
|
-
const fmt = (v, pct = true) => v == null ? 'N/A' : pct ? (v * 100).toFixed(1) + '%' : v.toFixed(2)
|
|
33
|
+
const pct = (value) => value == null ? 'N/A' : `${(value * 100).toFixed(1)}%`
|
|
34
|
+
const pp = (value) => value == null ? 'N/A' : `${value > 0 ? '+' : ''}${(value * 100).toFixed(1)}pp`
|
|
35
|
+
const deltaOf = (before, after) =>
|
|
36
|
+
typeof before === 'number' && typeof after === 'number' ? after - before : null
|
|
64
37
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
38
|
+
console.log(`══ 轮间对比 ${path.basename(String(args.from))} → ${path.basename(String(args.to))} ══`)
|
|
39
|
+
console.log(`案例: ${comparison.from.total} → ${comparison.to.total}(新增 ${comparison.added.length} / 移除 ${comparison.removed.length} / 共享 ${comparison.sharedCount})`)
|
|
40
|
+
console.log('')
|
|
41
|
+
console.log('── 指标 delta(均值)──')
|
|
42
|
+
console.log(` passRate: ${pct(comparison.from.passRate)} → ${pct(comparison.to.passRate)}`)
|
|
43
|
+
for (const key of ['fidelity', 'pixel', 'position', 'structure', 'components']) {
|
|
44
|
+
const before = comparison.from[key]
|
|
45
|
+
const after = comparison.to[key]
|
|
46
|
+
console.log(` ${key.padEnd(10)} ${pct(before)} → ${pct(after)} (${pp(deltaOf(before, after))})`)
|
|
47
|
+
}
|
|
48
|
+
console.log('')
|
|
49
|
+
console.log('── 状态迁移 ──')
|
|
50
|
+
console.log(` 修复 fail→pass: ${comparison.fixed.length}`)
|
|
51
|
+
for (const item of comparison.fixed) console.log(` + ${item.caseId} ${item.name}`)
|
|
52
|
+
console.log(` 回归 pass→fail: ${comparison.regressed.length}`)
|
|
53
|
+
for (const item of comparison.regressed) console.log(` ! ${item.caseId} ${item.name}`)
|
|
54
|
+
console.log(` 持续失败: ${comparison.stillFailing.length}`)
|
|
55
|
+
for (const item of comparison.stillFailing.slice(0, 10)) console.log(` - ${item.caseId} ${item.name}`)
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
const delta = {}
|
|
83
|
-
for (const [key, pick] of Object.entries(selectors)) {
|
|
84
|
-
const before = metric(fromReport, pick)
|
|
85
|
-
const after = metric(toReport, pick)
|
|
86
|
-
if (typeof before === 'number' && typeof after === 'number') delta[key] = after - before
|
|
57
|
+
const worst = comparison.metricDeltas
|
|
58
|
+
.filter((item) => typeof item.delta.fidelity === 'number')
|
|
59
|
+
.sort((a, b) => a.delta.fidelity - b.delta.fidelity)
|
|
60
|
+
.slice(0, 5)
|
|
61
|
+
.filter((item) => item.delta.fidelity < -0.005)
|
|
62
|
+
if (worst.length) {
|
|
63
|
+
console.log('')
|
|
64
|
+
console.log('── 还原度下滑 Top5(仍通过的也需关注)──')
|
|
65
|
+
for (const item of worst) {
|
|
66
|
+
console.log(` ${item.caseId} fidelity ${pp(item.delta.fidelity)} pixel ${pp(item.delta.pixel ?? 0)}`)
|
|
87
67
|
}
|
|
88
|
-
if (Object.keys(delta).length) metricDeltas.push({ caseId, delta })
|
|
89
|
-
}
|
|
90
|
-
for (const caseId of fromReports.keys()) {
|
|
91
|
-
if (!toReports.has(caseId)) removed.push(caseId)
|
|
92
68
|
}
|
|
93
69
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
console.log(` ${
|
|
70
|
+
// 均值退化清单:晋级门要求 fidelity/pixel/position/structure 均值不低于基线轮
|
|
71
|
+
const degraded = []
|
|
72
|
+
for (const [key, betterWhenHigher] of [['fidelity', true], ['position', true], ['structure', true], ['components', true], ['pixel', false]]) {
|
|
73
|
+
const delta = deltaOf(comparison.from[key], comparison.to[key])
|
|
74
|
+
if (delta == null) continue
|
|
75
|
+
const worse = betterWhenHigher ? delta < -1e-9 : delta > 1e-9
|
|
76
|
+
if (worse) degraded.push({ metric: key, delta: Number(delta.toFixed(5)) })
|
|
77
|
+
}
|
|
78
|
+
if (degraded.length) {
|
|
79
|
+
console.log('')
|
|
80
|
+
console.log('── 均值退化(晋级门「不低于锚点」不满足)──')
|
|
81
|
+
for (const item of degraded) console.log(` ${item.metric} ${pp(item.delta)}`)
|
|
106
82
|
}
|
|
107
|
-
console.log('')
|
|
108
|
-
console.log(`── 状态迁移 ──`)
|
|
109
|
-
console.log(` 修复 fail→pass: ${fixed.length}`)
|
|
110
|
-
for (const item of fixed) console.log(` + ${item.caseId} ${item.name}`)
|
|
111
|
-
console.log(` 回归 pass→fail: ${regressed.length}`)
|
|
112
|
-
for (const item of regressed) console.log(` ! ${item.caseId} ${item.name}`)
|
|
113
|
-
console.log(` 持续失败: ${stillFailing.length}`)
|
|
114
|
-
for (const item of stillFailing.slice(0, 10)) console.log(` - ${item.caseId} ${item.name}`)
|
|
115
83
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
84
|
+
// --json 支持裸开关(不带路径):默认落进 --to 轮的 reports/,避免把产物写进仓库根目录。
|
|
85
|
+
const jsonTarget = args.json === true
|
|
86
|
+
? path.join(reportsDirOf(args.to), 'round-diff.json')
|
|
87
|
+
: (typeof args.json === 'string' && args.json ? args.json : '')
|
|
88
|
+
if (jsonTarget) {
|
|
89
|
+
const payload = {
|
|
90
|
+
from: String(args.from),
|
|
91
|
+
to: String(args.to),
|
|
92
|
+
metrics: { from: comparison.from, to: comparison.to },
|
|
93
|
+
degraded,
|
|
94
|
+
fixed: comparison.fixed,
|
|
95
|
+
regressed: comparison.regressed,
|
|
96
|
+
stillFailing: comparison.stillFailing,
|
|
97
|
+
added: comparison.added,
|
|
98
|
+
removed: comparison.removed,
|
|
99
|
+
promotionBlockers: {
|
|
100
|
+
regressions: comparison.regressed.length,
|
|
101
|
+
degradedMetrics: degraded.map((item) => item.metric),
|
|
102
|
+
},
|
|
126
103
|
}
|
|
104
|
+
fs.mkdirSync(path.dirname(path.resolve(jsonTarget)), { recursive: true })
|
|
105
|
+
fs.writeFileSync(path.resolve(jsonTarget), JSON.stringify(payload, null, 2) + '\n')
|
|
106
|
+
console.log('')
|
|
107
|
+
console.log(`JSON: ${path.resolve(jsonTarget)}`)
|
|
127
108
|
}
|
|
128
109
|
|
|
129
|
-
if (regressed.length && !args.allowRegressions) {
|
|
110
|
+
if (comparison.regressed.length && !args.allowRegressions) {
|
|
130
111
|
console.log('')
|
|
131
|
-
console.log(`REGRESSION: ${regressed.length} 例 PASS→FAIL,退出码 1(--allow-regressions 可豁免)`)
|
|
112
|
+
console.log(`REGRESSION: ${comparison.regressed.length} 例 PASS→FAIL,退出码 1(--allow-regressions 可豁免)`)
|
|
132
113
|
process.exit(1)
|
|
133
114
|
}
|
|
@@ -13,15 +13,22 @@ function findPython() {
|
|
|
13
13
|
}
|
|
14
14
|
import fs from 'node:fs'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import {
|
|
16
|
+
import { pathToFileURL } from 'node:url'
|
|
17
17
|
import { PNG } from 'pngjs'
|
|
18
18
|
|
|
19
19
|
const repoRoot = path.resolve(import.meta.dirname, '../../..')
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 绝对路径动态 import:Windows 下裸盘符路径会被 ESM 加载器当成 `d:` 协议 URL,
|
|
23
|
+
* 抛 ERR_UNSUPPORTED_ESM_URL_SCHEME(训练轮在启动阶段就整体失败)。
|
|
24
|
+
*/
|
|
25
|
+
const importAbs = (target) => import(pathToFileURL(target).href)
|
|
26
|
+
|
|
21
27
|
function parseArgs(argv) {
|
|
22
28
|
const out = {
|
|
23
29
|
dataset: '', round: '', strategy: 'v3', limit: 0, splits: 'train,dev,eval', only: '',
|
|
24
|
-
onlyFailed: '', noCorrect: false,
|
|
30
|
+
onlyFailed: '', noCorrect: false, focus: '', focusBelow: 0.85, focusLimit: 0,
|
|
31
|
+
resume: false, inherit: '', noSummary: false,
|
|
25
32
|
}
|
|
26
33
|
const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
|
|
27
34
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -144,7 +151,13 @@ async function runMechanicalAutofix(schemaPath) {
|
|
|
144
151
|
return { repairedNodes: 0, repairs: [], autofixChanged: !autofix.error && autofixOutput.includes('修复'), autofixOutput }
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
imageFidelity, positionScore, textOverflowScore, duplicationScore, fidelityScore, fontMaskWiden,
|
|
156
|
+
loadFontMetrics,
|
|
157
|
+
evaluatePassGate, attributeFailure, failureSignature, exclusionCoverage,
|
|
158
|
+
errorKind,
|
|
159
|
+
} from './lib/scoring.mjs'
|
|
160
|
+
import { loadRunReports } from './lib/runs.mjs'
|
|
148
161
|
|
|
149
162
|
|
|
150
163
|
function sourceChanges() {
|
|
@@ -382,21 +395,56 @@ function expectedComponents(card) {
|
|
|
382
395
|
if (width <= 0 || height <= 0) return 0
|
|
383
396
|
return (width * height) / Math.min((ar.width || 1) * (ar.height || 1), (br.width || 1) * (br.height || 1))
|
|
384
397
|
}
|
|
385
|
-
const
|
|
398
|
+
const qualRowShell = (element) => {
|
|
386
399
|
const rect = rectOf(element)
|
|
387
400
|
return element.type === 'shape'
|
|
388
401
|
&& (rect.width || 0) >= 80 && (rect.height || 0) >= 30 && (rect.height || 0) <= 80
|
|
389
402
|
&& (element.style?.radius?.borderRadius || 0) >= 10
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
403
|
+
}
|
|
404
|
+
// 同尺寸壳竖直重复堆叠 = 列表/入口行栈(非按钮):按钮不会同尺寸成栈重复出现。
|
|
405
|
+
const stackedRowShells = new Set()
|
|
406
|
+
{
|
|
407
|
+
const bySize = new Map()
|
|
408
|
+
for (const element of (card?.elements || [])) {
|
|
409
|
+
if (!qualRowShell(element)) continue
|
|
410
|
+
const r = rectOf(element)
|
|
411
|
+
const key = `${Math.round(r.width || 0)}x${Math.round(r.height || 0)}`
|
|
412
|
+
if (!bySize.has(key)) bySize.set(key, [])
|
|
413
|
+
bySize.get(key).push(element)
|
|
414
|
+
}
|
|
415
|
+
for (const group of bySize.values()) {
|
|
416
|
+
if (group.length < 2) continue
|
|
417
|
+
const sorted = group.sort((a, b) => (rectOf(a).y || 0) - (rectOf(b).y || 0))
|
|
418
|
+
const gaps = sorted.slice(1).map((el, i) => (rectOf(el).y || 0)
|
|
419
|
+
- ((rectOf(sorted[i]).y || 0) + (rectOf(sorted[i]).height || 0)))
|
|
420
|
+
if (gaps.every((g) => g >= 0 && g <= 80)) sorted.forEach((el) => stackedRowShells.add(el))
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const controlShapes = (card?.elements || []).filter((element) => {
|
|
424
|
+
const rect = rectOf(element)
|
|
425
|
+
if (!qualRowShell(element)) return false
|
|
426
|
+
const inner = (card?.elements || []).filter((child) => {
|
|
427
|
+
if (child.type !== 'text' || !(child.text || child.value)) return false
|
|
428
|
+
const childRect = rectOf(child)
|
|
429
|
+
return (childRect.x || 0) >= (rect.x || 0) && (childRect.y || 0) >= (rect.y || 0)
|
|
430
|
+
&& ((childRect.x || 0) + (childRect.width || 0)) <= ((rect.x || 0) + (rect.width || 0))
|
|
431
|
+
&& ((childRect.y || 0) + (childRect.height || 0)) <= ((rect.y || 0) + (rect.height || 0))
|
|
432
|
+
})
|
|
433
|
+
if (!(inner.length > 0 && new Set(inner.map((child) => Math.round((rectOf(child).y || 0) / 18))).size <= 1)) return false
|
|
434
|
+
// 左锚定单文本 + 右端小亲和件(箭头/勾选)+ 同尺寸成栈 = 导航入口行(列表行),
|
|
435
|
+
// 不是按钮/输入控件:误判会把行内文本与箭头从期望组件中剔除,组件相似度系统性低估正确渲染。
|
|
436
|
+
const leftAnchored = inner.length === 1
|
|
437
|
+
&& ((rectOf(inner[0]).x || 0) - (rect.x || 0)) <= (rect.width || 0) * 0.2
|
|
438
|
+
const rightAffordance = (card?.elements || []).some((e2) => {
|
|
439
|
+
if (!['slice', 'symbol', 'shape'].includes(e2.type)) return false
|
|
440
|
+
const r2 = rectOf(e2)
|
|
441
|
+
const w = r2.width || 0
|
|
442
|
+
const h = r2.height || 0
|
|
443
|
+
if (w < 4 || w > 32 || h < 4 || h > 32) return false
|
|
444
|
+
return (r2.x || 0) + w >= (rect.x || 0) + (rect.width || 0) - 28
|
|
445
|
+
&& Math.abs(((r2.y || 0) + h / 2) - ((rect.y || 0) + (rect.height || 0) / 2)) <= (rect.height || 0) / 2
|
|
446
|
+
})
|
|
447
|
+
return !(leftAnchored && rightAffordance && stackedRowShells.has(element))
|
|
400
448
|
})
|
|
401
449
|
const insideControl = (element) => {
|
|
402
450
|
const center = centerOf(element)
|
|
@@ -596,7 +644,14 @@ async function main() {
|
|
|
596
644
|
const cardsDir = path.join(runDir, 'cards')
|
|
597
645
|
const slicesDir = path.join(runDir, 'slices')
|
|
598
646
|
const reportDir = path.join(runDir, 'reports')
|
|
599
|
-
|
|
647
|
+
// --resume:保留既有轮目录续跑(渲染假死 / kill -9 之后不必整轮重来,r202 的实际痛点);
|
|
648
|
+
// 默认仍是干净重建,避免上一轮残留报告混进本轮统计
|
|
649
|
+
if (args.resume) {
|
|
650
|
+
if (!fs.existsSync(reportDir)) throw new Error(`--resume: 既有轮目录不存在 ${runDir}`)
|
|
651
|
+
console.log(`[resume] 续跑 ${args.round}:已产出报告的案例跳过`)
|
|
652
|
+
} else {
|
|
653
|
+
fs.rmSync(runDir, { recursive: true, force: true })
|
|
654
|
+
}
|
|
600
655
|
fs.mkdirSync(cardsDir, { recursive: true })
|
|
601
656
|
fs.mkdirSync(reportDir, { recursive: true })
|
|
602
657
|
process.env.DSL_CARD_CARDS_DIR = cardsDir
|
|
@@ -606,10 +661,10 @@ async function main() {
|
|
|
606
661
|
if (args.strategy === 'geo') {
|
|
607
662
|
console.warn('[WARN] --strategy geo 已废弃(geo 生成器已删除),回退 v3')
|
|
608
663
|
}
|
|
609
|
-
const generatorFile = repoRoot
|
|
610
|
-
const { generate } = await
|
|
611
|
-
const { createRenderSession } = await
|
|
612
|
-
'
|
|
664
|
+
const generatorFile = path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'spec_to_schema_v3.mjs')
|
|
665
|
+
const { generate } = await importAbs(generatorFile)
|
|
666
|
+
const { createRenderSession } = await importAbs(
|
|
667
|
+
path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'render_card.mjs'))
|
|
613
668
|
|
|
614
669
|
const splits = String(args.splits).split(',').filter(Boolean)
|
|
615
670
|
const allCases = readDataset(path.resolve(args.dataset), splits)
|
|
@@ -621,28 +676,54 @@ async function main() {
|
|
|
621
676
|
return false
|
|
622
677
|
})
|
|
623
678
|
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
679
|
+
// 选样模式(互斥):--only 手点案例 / --only-failed 上轮失败例 / --focus 上轮焦点集。
|
|
680
|
+
// 焦点集 = 失败例 + 「通过但低于 --focus-below」的刚过线样本,按还原度升序,对应 SKILL
|
|
681
|
+
// 「定向还原度迭代协议」步骤 1;此前要人工翻 summary.md 抄 caseId,低分通过例常被漏掉。
|
|
682
|
+
const modes = ['only', 'onlyFailed', 'focus'].filter((key) => args[key])
|
|
683
|
+
if (modes.length > 1) throw new Error(`--only / --only-failed / --focus 互斥(同时传入:${modes.join(', ')})`)
|
|
684
|
+
let selectedSet = null
|
|
685
|
+
if (args.onlyFailed || args.focus) {
|
|
686
|
+
const prevRun = String(args.onlyFailed || args.focus)
|
|
687
|
+
let prevReports
|
|
630
688
|
try {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
689
|
+
prevReports = loadRunReports(prevRun)
|
|
690
|
+
} catch (error) {
|
|
691
|
+
throw new Error(`${args.onlyFailed ? '--only-failed' : '--focus'}: 读不到 ${prevRun}: ${error.message}`)
|
|
692
|
+
}
|
|
693
|
+
if (args.onlyFailed) {
|
|
694
|
+
selectedSet = new Set([...prevReports.values()].filter((report) => !report.passed).map((report) => report.caseId))
|
|
695
|
+
console.log(`[only-failed] ${path.basename(prevRun)} 失败 ${selectedSet.size} 例,本轮增量重跑`)
|
|
696
|
+
} else {
|
|
697
|
+
const threshold = Number(args.focusBelow) || 0.85
|
|
698
|
+
const cap = Number(args.focusLimit) || 0
|
|
699
|
+
const ranked = [...prevReports.values()]
|
|
700
|
+
.map((report) => ({
|
|
701
|
+
caseId: report.caseId,
|
|
702
|
+
fidelity: typeof report.fidelity?.overall === 'number' ? report.fidelity.overall : 0,
|
|
703
|
+
passed: !!report.passed,
|
|
704
|
+
}))
|
|
705
|
+
.filter((item) => !item.passed || item.fidelity < threshold)
|
|
706
|
+
.sort((a, b) => a.fidelity - b.fidelity || String(a.caseId).localeCompare(String(b.caseId)))
|
|
707
|
+
const picked = cap > 0 ? ranked.slice(0, cap) : ranked
|
|
708
|
+
selectedSet = new Set(picked.map((item) => item.caseId))
|
|
709
|
+
console.log(`[focus] ${path.basename(prevRun)} 焦点集 ${selectedSet.size} 例(未通过或 fidelity<${threshold}${cap > 0 ? `,上限 ${cap}` : ''})`)
|
|
710
|
+
if (picked.length) console.log(`[focus] 等价命令:--only ${picked.map((item) => item.caseId).join(',')}`)
|
|
711
|
+
}
|
|
712
|
+
} else if (args.only) {
|
|
713
|
+
selectedSet = new Set(String(args.only).split(',').filter(Boolean))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// --resume:已有报告的案例(含 --inherit 搬进来的)不再重跑,只补缺口
|
|
717
|
+
let doneSet = null
|
|
718
|
+
if (args.resume) {
|
|
719
|
+
try { doneSet = new Set(loadRunReports(runDir).keys()) } catch { doneSet = new Set() }
|
|
720
|
+
if (doneSet.size) console.log(`[resume] 跳过已完成 ${doneSet.size} 例`)
|
|
639
721
|
}
|
|
640
722
|
|
|
641
723
|
const cases = casePool.filter((testCase) => {
|
|
642
|
-
if (
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
return selected.has(testCase.caseId)
|
|
724
|
+
if (selectedSet && !selectedSet.has(testCase.caseId)) return false
|
|
725
|
+
if (doneSet && doneSet.has(testCase.caseId)) return false
|
|
726
|
+
return true
|
|
646
727
|
}).slice(0, Number(args.limit) || undefined)
|
|
647
728
|
const skippedPopups = allCases.filter((testCase) =>
|
|
648
729
|
(testCase.tags || []).some((tag) => popupTypes.has(String(tag).toLowerCase())))
|
|
@@ -657,14 +738,59 @@ async function main() {
|
|
|
657
738
|
})
|
|
658
739
|
const reviewQueue = []
|
|
659
740
|
const semanticReviewQueue = []
|
|
741
|
+
// --inherit:把上轮「本轮不重跑」的案例报告与截图搬进本轮,让增量轮成为完整可比轮。
|
|
742
|
+
// 否则 score_round 的 passRate 分母只剩失败例、round_diff 把未重跑案例全算成 removed,
|
|
743
|
+
// 快路径轮的指标读起来像整轮崩了。cards/slices 仍只含本轮重跑案例,
|
|
744
|
+
// 所以继承轮可用于门禁与回归对账,但不作冻结锚点载体(锚点必须整轮全量跑)。
|
|
745
|
+
const inheritedCases = []
|
|
746
|
+
if (args.inherit) {
|
|
747
|
+
const inheritFrom = path.resolve(String(args.inherit))
|
|
748
|
+
const inheritReportsDir = path.join(inheritFrom, 'reports')
|
|
749
|
+
let inheritReports
|
|
750
|
+
try {
|
|
751
|
+
inheritReports = loadRunReports(inheritFrom)
|
|
752
|
+
} catch (error) {
|
|
753
|
+
throw new Error(`--inherit: 读不到 ${inheritFrom}: ${error.message}`)
|
|
754
|
+
}
|
|
755
|
+
const rerunIds = new Set(cases.map((testCase) => testCase.caseId))
|
|
756
|
+
for (const [caseId, report] of inheritReports) {
|
|
757
|
+
if (rerunIds.has(caseId)) continue
|
|
758
|
+
if (fs.existsSync(path.join(reportDir, `${caseId}.json`))) continue
|
|
759
|
+
for (const suffix of ['.json', '.render.png', '.design.png']) {
|
|
760
|
+
const source = path.join(inheritReportsDir, `${caseId}${suffix}`)
|
|
761
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(reportDir, `${caseId}${suffix}`))
|
|
762
|
+
}
|
|
763
|
+
inheritedCases.push(caseId)
|
|
764
|
+
if (!report.passed) {
|
|
765
|
+
reviewQueue.push({
|
|
766
|
+
caseId,
|
|
767
|
+
priority: report.validate?.errors ? 1 : 2,
|
|
768
|
+
reason: report.failureSignature || (report.error ? `case-error:${errorKind(report.error)}` : 'metric-gate'),
|
|
769
|
+
failureClass: report.failureClass || null,
|
|
770
|
+
round: args.round,
|
|
771
|
+
inherited: true,
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
fs.writeFileSync(path.join(reportDir, 'inherited.json'),
|
|
776
|
+
JSON.stringify({ from: inheritFrom, count: inheritedCases.length, caseIds: inheritedCases }, null, 2) + '\n')
|
|
777
|
+
console.log(`[inherit] 从 ${path.basename(inheritFrom)} 继承 ${inheritedCases.length} 例,本轮重跑 ${cases.length} 例`)
|
|
778
|
+
}
|
|
660
779
|
const sourceBefore = sourceChanges()
|
|
661
780
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
662
781
|
// 共享渲染会话:整轮复用一个 Chrome + HTTP server。
|
|
663
782
|
// 此前每卡各启一套(renderCardScreenshot 内部建/拆会话),~1.5-2s/卡全是启停开销。
|
|
664
783
|
// 冷帧(会话首帧缺背景图)根因已由 render_card.mjs 的 CDP 网络静默等待闭环,整轮无需暖机帧。
|
|
665
|
-
|
|
784
|
+
// 渲染会话懒启动:--resume 续跑「已全部完成」的轮次、或焦点集为空时,
|
|
785
|
+
// 不必白起一套 Chrome + HTTP server(本机实测 ~2-14s 启停开销)
|
|
786
|
+
if (!cases.length) console.log('[skip] 本轮无待跑案例(选样为空或已全部完成)')
|
|
787
|
+
let renderSession = null
|
|
788
|
+
const ensureRenderSession = async () => {
|
|
789
|
+
if (!renderSession) renderSession = await createRenderSession({ cardsRoot: cardsDir })
|
|
790
|
+
return renderSession
|
|
791
|
+
}
|
|
666
792
|
const renderOnce = async (id, w, h) => {
|
|
667
|
-
let res = await
|
|
793
|
+
let res = await (await ensureRenderSession()).render({
|
|
668
794
|
id, width: w, captureHeight: h, format: 'png', captureElements: true,
|
|
669
795
|
})
|
|
670
796
|
if (!res.ok && /session-closed|Target|WebSocket|Chrome|浏览器|超时/i.test(String(res.error || '') + String(res.message || ''))) {
|
|
@@ -716,6 +842,7 @@ async function main() {
|
|
|
716
842
|
}
|
|
717
843
|
}
|
|
718
844
|
const exclusionRects = (designCard.elements || [])
|
|
845
|
+
const fontRects = []
|
|
719
846
|
.filter((element) => ['missing-slice', 'background-slice-unavailable']
|
|
720
847
|
.includes(element.annotationDefect?.type))
|
|
721
848
|
.filter((element) => {
|
|
@@ -731,17 +858,23 @@ async function main() {
|
|
|
731
858
|
for (const element of designCard.elements || []) {
|
|
732
859
|
if (element.type !== 'text') continue
|
|
733
860
|
const family = String(element.textStyle?.fontFamily || element.textStyle?.fontFace || '')
|
|
734
|
-
|
|
861
|
+
// 环境感知掩膜门:探针实测「回退栈宽/标注宽」>1.06 即该字体在本机渲染环境不可得
|
|
862
|
+
// (PingFang SC 在 Windows 同样回退合成,家族名豁免会让最大像素热带漏掩);
|
|
863
|
+
// 探针缺失时回退家族名判据。掩膜只豁免字形度量差,位置/结构/颜色仍覆盖这些文本。
|
|
864
|
+
const widen = fontMaskWiden(element)
|
|
865
|
+
const familyUnsupported = family && !/^PingFang/i.test(family)
|
|
866
|
+
const envUnsupported = !!loadFontMetrics() && widen > 1.06
|
|
867
|
+
if (familyUnsupported || envUnsupported) {
|
|
735
868
|
// 掩膜按标注对齐方式锚定扩宽:居中窄体文本的渲染字形向两侧溢出设计层框,
|
|
736
869
|
// 只向右扩会漏掩左半溢出带(实测单带热区可达 66%)。
|
|
737
870
|
const base = toExclRect(element, 1)
|
|
738
|
-
const widened = toExclRect(element,
|
|
871
|
+
const widened = toExclRect(element, widen)
|
|
739
872
|
if (String(element.textStyle?.textAlign || 'left') === 'center') {
|
|
740
873
|
const shift = (widened.width - base.width) / 2
|
|
741
874
|
widened.x = Math.max(0, base.x - shift)
|
|
742
875
|
widened.width = Math.min(1 - widened.x, widened.width + shift)
|
|
743
876
|
}
|
|
744
|
-
|
|
877
|
+
fontRects.push({ ...widened, reason: 'unsupported-font' })
|
|
745
878
|
}
|
|
746
879
|
}
|
|
747
880
|
const renderWidth = Math.round(designCard?.rect?.width || spec.size?.width || 375)
|
|
@@ -895,12 +1028,38 @@ async function main() {
|
|
|
895
1028
|
fs.writeFileSync(renderPath, Buffer.from(rendered.base64, 'base64'))
|
|
896
1029
|
// 同步保存设计稿参考图到 reports 目录,便于 summary.md 并排对比
|
|
897
1030
|
try { fs.copyFileSync(testCase.input.designImage, path.join(reportDir, testCase.caseId + '.design.png')) } catch {}
|
|
1031
|
+
// ── 裁切兜底切图掩膜:导出素材缺失 → 只能从画板裁切(__artboard_crop__*)──
|
|
1032
|
+
// 裁切块自带周边底色/圆角残边/邻件像素,与「导出素材」的渲染差异属素材来源差距,
|
|
1033
|
+
// 不是生成缺陷 → 按设计稿标注盒精确掩膜(外扩 1.5px 吸收抗锯齿边),
|
|
1034
|
+
// 仅掩「生成 schema 真实引用了该裁切文件」的元素(未引用=生成端已另行还原,差异仍计分)。
|
|
1035
|
+
{
|
|
1036
|
+
const schemaJson = JSON.stringify(generated.schema)
|
|
1037
|
+
for (const element of designCard.elements || []) {
|
|
1038
|
+
const defect = element.annotationDefect
|
|
1039
|
+
const cropFile = defect?.cropSlice || (defect?.resolution === 'artboard-crop' ? defect?.name : null)
|
|
1040
|
+
if (!defect || defect.type !== 'missing-slice' || !cropFile) continue
|
|
1041
|
+
if (!schemaJson.includes(cropFile)) continue
|
|
1042
|
+
const pad = 1.5
|
|
1043
|
+
const base = toExclRect(element, 1)
|
|
1044
|
+
exclusionRects.push({
|
|
1045
|
+
x: Math.max(0, base.x - pad / _exclCardW),
|
|
1046
|
+
y: Math.max(0, base.y - pad / _exclCardH),
|
|
1047
|
+
width: Math.min(1, base.width + (pad * 2) / _exclCardW),
|
|
1048
|
+
height: Math.min(1, base.height + (pad * 2) / _exclCardH),
|
|
1049
|
+
reason: 'crop-fallback-slice',
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
898
1053
|
report.pixel = imageFidelity(
|
|
899
1054
|
fs.readFileSync(testCase.input.designImage),
|
|
900
1055
|
fs.readFileSync(renderPath),
|
|
901
1056
|
exclusionRects,
|
|
1057
|
+
fontRects,
|
|
902
1058
|
)
|
|
903
|
-
report.pixelExclusions = exclusionRects
|
|
1059
|
+
report.pixelExclusions = [...exclusionRects, ...fontRects]
|
|
1060
|
+
// 掩膜覆盖率:掩膜是「不计分」而不是「还原对」。覆盖率过高时通过结论不可信,
|
|
1061
|
+
// score_round 会把高掩膜案例单列出来要求人工复核(防止靠掩膜虚高过门)。
|
|
1062
|
+
report.exclusionCoverage = exclusionCoverage([...exclusionRects, ...fontRects])
|
|
904
1063
|
report.textRecall = textRecall(generated.schema, testCase.expected?.requiredTexts)
|
|
905
1064
|
// ── Element-level position scoring ──
|
|
906
1065
|
const cardRect = designCard?.rect || {}
|
|
@@ -970,33 +1129,38 @@ async function main() {
|
|
|
970
1129
|
report.position,
|
|
971
1130
|
report.textOverflow,
|
|
972
1131
|
)
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
report.
|
|
983
|
-
|
|
984
|
-
report.pixel.structure >= 0.85
|
|
1132
|
+
// 单案例门以 lib/scoring.mjs 的 PASS_GATE 为唯一来源(SKILL.md 验收表、score_round 同源,
|
|
1133
|
+
// 文档数字由 tools/tests/scoring.test.mjs 反向断言),并把未达标维度按缺口落盘供聚类归因
|
|
1134
|
+
const gate = evaluatePassGate(report)
|
|
1135
|
+
report.gateFailures = gate.failures
|
|
1136
|
+
report.passed = gate.passed
|
|
1137
|
+
report.failureSignature = failureSignature(report)
|
|
1138
|
+
const attribution = attributeFailure(report)
|
|
1139
|
+
if (attribution) {
|
|
1140
|
+
report.failureClass = attribution.failureClass
|
|
1141
|
+
report.failureReason = attribution.reason
|
|
1142
|
+
}
|
|
985
1143
|
} catch (error) {
|
|
986
1144
|
report.error = String(error.message || error)
|
|
987
1145
|
report.passed = false
|
|
1146
|
+
report.failureSignature = failureSignature(report)
|
|
1147
|
+
const attribution = attributeFailure(report)
|
|
1148
|
+
if (attribution) {
|
|
1149
|
+
report.failureClass = attribution.failureClass
|
|
1150
|
+
report.failureReason = attribution.reason
|
|
1151
|
+
}
|
|
988
1152
|
}
|
|
989
1153
|
report.durationMs = Date.now() - started
|
|
990
1154
|
fs.writeFileSync(path.join(reportDir, `${testCase.caseId}.json`),
|
|
991
1155
|
JSON.stringify(report, null, 2) + '\n')
|
|
992
1156
|
if (!report.passed) reviewQueue.push({
|
|
993
1157
|
caseId: testCase.caseId, priority: report.validate.errors ? 1 : 2,
|
|
994
|
-
reason
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1158
|
+
// reason 用主失败签名(归一化缺口最大的维度),并带失败类与全部未达标维度,
|
|
1159
|
+
// 复核队列因此可直接聚类,不必逐例打开报告
|
|
1160
|
+
reason: report.error || report.failureSignature || 'metric-gate',
|
|
1161
|
+
failureClass: report.failureClass || null,
|
|
1162
|
+
gateFailures: (report.gateFailures || []).map((item) => item.key),
|
|
1163
|
+
fidelity: report.fidelity?.overall ?? null,
|
|
1000
1164
|
round: args.round,
|
|
1001
1165
|
})
|
|
1002
1166
|
if (!report.passed && report.pixel?.comparable) semanticReviewQueue.push({
|
|
@@ -1023,7 +1187,7 @@ async function main() {
|
|
|
1023
1187
|
}
|
|
1024
1188
|
} finally {
|
|
1025
1189
|
// 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留
|
|
1026
|
-
try { await renderSession
|
|
1190
|
+
try { await renderSession?.close() } catch {}
|
|
1027
1191
|
}
|
|
1028
1192
|
fs.writeFileSync(path.join(reportDir, 'review-queue.jsonl'),
|
|
1029
1193
|
reviewQueue.map((item) => JSON.stringify(item)).join('\n') + (reviewQueue.length ? '\n' : ''))
|
|
@@ -1031,6 +1195,17 @@ async function main() {
|
|
|
1031
1195
|
semanticReviewQueue.map((item) => JSON.stringify(item)).join('\n') +
|
|
1032
1196
|
(semanticReviewQueue.length ? '\n' : ''))
|
|
1033
1197
|
|
|
1198
|
+
// 一轮跑完直接出门禁汇总:此前还要人工再敲一次 score_round,忘了就没有 summary.json / roundGate
|
|
1199
|
+
if (!args.noSummary) {
|
|
1200
|
+
const scoreScript = path.join(import.meta.dirname, 'score_round.mjs')
|
|
1201
|
+
const scored = spawnSync(process.execPath, [scoreScript, '--run', runDir, '--quiet'], {
|
|
1202
|
+
encoding: 'utf8', cwd: repoRoot,
|
|
1203
|
+
})
|
|
1204
|
+
const output = `${scored.stdout || ''}${scored.stderr || ''}`.trim()
|
|
1205
|
+
if (output) console.log(output)
|
|
1206
|
+
if (scored.status !== 0) console.warn(`[WARN] score_round 退出码 ${scored.status}(summary 可能未写出)`)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1034
1209
|
const newSourceChanges = sourceChanges().filter((line) => !sourceBefore.includes(line))
|
|
1035
1210
|
if (newSourceChanges.length) {
|
|
1036
1211
|
throw new Error(`SANDBOX_VIOLATION: training wrote into src/: ${newSourceChanges.join(', ')}`)
|