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,241 +1,419 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* diagnose_case.mjs —
|
|
3
|
+
* diagnose_case.mjs — 失败案例一键诊断证据包(支持批量)
|
|
4
4
|
*
|
|
5
5
|
* 把「肉眼并排 → 像素分带 → 标注几何 vs 渲染盒 → schema 溯源」这条手工诊断
|
|
6
6
|
* 链路产品化为单条命令。输出全部落在该轮 reports/ 目录(训练产物,不入 src):
|
|
7
7
|
* <caseId>.side.png 设计稿|渲染 并排图(红色分隔线)
|
|
8
|
-
* 终端打印
|
|
9
|
-
*
|
|
8
|
+
* 终端打印 门禁未达标维度、失败类归因、掩膜覆盖、上游聚焦门、像素分带直方图、
|
|
9
|
+
* 偏差元素表(dy/dx)、生成器源码行溯源、校正器干预记录、失败类建议
|
|
10
10
|
*
|
|
11
11
|
* 用法:
|
|
12
|
-
* node skills/card-skill-trainer/scripts/diagnose_case.mjs --run training/runs/
|
|
13
|
-
* [--dataset training/datasets/
|
|
12
|
+
* node skills/card-skill-trainer/scripts/diagnose_case.mjs --run training/runs/r262 --case <caseId>[,<caseId>...]
|
|
13
|
+
* [--dataset training/datasets/v7/train-set.jsonl] (缺省自动扫描 datasets)
|
|
14
|
+
* [--out training/runs/r262/reports/diagnosis.json] (机器可读,供多模态归因直接消费)
|
|
15
|
+
*
|
|
16
|
+
* 批量与口径说明:
|
|
17
|
+
* - 多案例共用一套 Chrome 渲染会话与一次生成器 import(此前每例各起一套,白烧 1.5-2s/例);
|
|
18
|
+
* - 标注几何取自「聚焦卡」的 elements(与 run_round 评分同源)。此前读 spec.elements
|
|
19
|
+
* (画板级),多卡画板下会把卡外元素算成偏差,产出幻影证据;
|
|
20
|
+
* - 门禁与归因口径来自 lib/scoring.mjs,与评分/汇总/计划完全一致。
|
|
14
21
|
*/
|
|
15
22
|
import fs from 'node:fs'
|
|
16
23
|
import path from 'node:path'
|
|
24
|
+
import { pathToFileURL } from 'node:url'
|
|
17
25
|
import { PNG } from 'pngjs'
|
|
26
|
+
import {
|
|
27
|
+
attributeFailure, evaluatePassGate, exclusionCoverage, failureSignature, MASK_REVIEW_THRESHOLD,
|
|
28
|
+
} from './lib/scoring.mjs'
|
|
18
29
|
|
|
19
30
|
const repoRoot = path.resolve(import.meta.dirname, '../../..')
|
|
20
31
|
|
|
32
|
+
/** 绝对路径动态 import(Windows 兼容,说明见 run_round.mjs 同名 helper)。 */
|
|
33
|
+
const importAbs = (target) => import(pathToFileURL(target).href)
|
|
34
|
+
|
|
21
35
|
function parseArgs(argv) {
|
|
22
|
-
const out = { run: '', case: '', dataset: '' }
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
const out = { run: '', case: '', dataset: '', out: '' }
|
|
37
|
+
const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
|
|
38
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
39
|
+
const token = argv[index]
|
|
40
|
+
if (!token.startsWith('--')) continue
|
|
41
|
+
const key = camel(token.replace(/^--/, ''))
|
|
42
|
+
const next = argv[index + 1]
|
|
43
|
+
if (next === undefined || next.startsWith('--')) out[key] = true
|
|
44
|
+
else { out[key] = next; index += 1 }
|
|
26
45
|
}
|
|
27
46
|
return out
|
|
28
47
|
}
|
|
48
|
+
|
|
29
49
|
const args = parseArgs(process.argv.slice(2))
|
|
30
50
|
if (!args.run || !args.case) {
|
|
31
|
-
console.error('usage: diagnose_case.mjs --run <roundDir> --case <caseId> [--dataset <jsonl>]')
|
|
51
|
+
console.error('usage: diagnose_case.mjs --run <roundDir> --case <caseId[,caseId...]> [--dataset <jsonl>] [--out <json>]')
|
|
32
52
|
process.exit(1)
|
|
33
53
|
}
|
|
54
|
+
const caseIds = String(args.case).split(',').map((item) => item.trim()).filter(Boolean)
|
|
34
55
|
const runDir = path.resolve(args.run)
|
|
35
56
|
const reportDir = path.join(runDir, 'reports')
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return files
|
|
53
|
-
})()
|
|
54
|
-
for (const file of candidates) {
|
|
55
|
-
const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean)
|
|
56
|
-
for (const line of lines) {
|
|
57
|
-
const item = JSON.parse(line)
|
|
58
|
-
if (item.caseId === args.case) return { entry: item, datasetFile: file }
|
|
57
|
+
|
|
58
|
+
// 渲染器/切图落点在 import 时就被 lib/paths.mjs 固化,必须先设环境变量再动态 import
|
|
59
|
+
process.env.DSL_CARD_CARDS_DIR = path.join(runDir, 'cards')
|
|
60
|
+
process.env.DSL_CARD_SLICES_DIR = path.join(runDir, 'slices')
|
|
61
|
+
process.env.SCHEMA_PROV = '1'
|
|
62
|
+
|
|
63
|
+
// —— 数据集索引(一次扫描,多案例复用)——
|
|
64
|
+
function buildDatasetIndex() {
|
|
65
|
+
const files = []
|
|
66
|
+
if (args.dataset) files.push(path.resolve(String(args.dataset)))
|
|
67
|
+
else {
|
|
68
|
+
const rootDir = path.join(repoRoot, 'training/datasets')
|
|
69
|
+
for (const version of fs.existsSync(rootDir) ? fs.readdirSync(rootDir) : []) {
|
|
70
|
+
const dir = path.join(rootDir, version)
|
|
71
|
+
if (!fs.statSync(dir).isDirectory()) continue
|
|
72
|
+
for (const file of fs.readdirSync(dir)) if (file.endsWith('.jsonl')) files.push(path.join(dir, file))
|
|
59
73
|
}
|
|
60
74
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const designPng = PNG.sync.read(fs.readFileSync(path.join(reportDir, `${args.case}.design.png`)))
|
|
70
|
-
const renderPng = PNG.sync.read(fs.readFileSync(path.join(reportDir, `${args.case}.render.png`)))
|
|
71
|
-
const sideW = designPng.width + renderPng.width + 8
|
|
72
|
-
const sideH = Math.max(designPng.height, renderPng.height)
|
|
73
|
-
const side = new PNG({ width: sideW, height: sideH, colorType: 2 })
|
|
74
|
-
for (let y = 0; y < sideH; y++) {
|
|
75
|
-
for (let x = 0; x < sideW; x++) {
|
|
76
|
-
const di = (y * sideW + x) * 4
|
|
77
|
-
side.data[di] = 255; side.data[di + 1] = 0; side.data[di + 2] = 0; side.data[di + 3] = 255
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const blit = (src, xOff) => {
|
|
81
|
-
for (let y = 0; y < src.height; y++) {
|
|
82
|
-
for (let x = 0; x < src.width; x++) {
|
|
83
|
-
const si = (y * src.width + x) * 4
|
|
84
|
-
const di = (y * sideW + x + xOff) * 4
|
|
85
|
-
side.data[di] = src.data[si]; side.data[di + 1] = src.data[si + 1]
|
|
86
|
-
side.data[di + 2] = src.data[si + 2]; side.data[di + 3] = 255
|
|
75
|
+
const index = new Map()
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (!fs.existsSync(file)) continue
|
|
78
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
|
|
79
|
+
try {
|
|
80
|
+
const item = JSON.parse(line)
|
|
81
|
+
if (item.caseId && !index.has(item.caseId)) index.set(item.caseId, { entry: item, datasetFile: file })
|
|
82
|
+
} catch {}
|
|
87
83
|
}
|
|
88
84
|
}
|
|
85
|
+
return index
|
|
89
86
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const mask = new Uint8Array(w * h)
|
|
98
|
-
for (const rc of report.pixelExclusions || []) {
|
|
99
|
-
const x0 = Math.floor((rc.x ?? 0) * w), y0 = Math.floor((rc.y ?? 0) * h)
|
|
100
|
-
const x1 = Math.min(w, Math.ceil(((rc.x ?? 0) + (rc.width ?? 0)) * w))
|
|
101
|
-
const y1 = Math.min(h, Math.ceil(((rc.y ?? 0) + (rc.height ?? 0)) * h))
|
|
102
|
-
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) mask[y * w + x] = 1
|
|
87
|
+
const datasetIndex = buildDatasetIndex()
|
|
88
|
+
|
|
89
|
+
const norm = (value) => String(value || '').replace(/\s+/g, '')
|
|
90
|
+
const num = (value, digits = 4) => typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : 'N/A'
|
|
91
|
+
|
|
92
|
+
function readPngIfExists(file) {
|
|
93
|
+
try { return fs.existsSync(file) ? PNG.sync.read(fs.readFileSync(file)) : null } catch { return null }
|
|
103
94
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
for (let y = 0; y <
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
95
|
+
|
|
96
|
+
function buildSideImage(designPng, renderPng, sidePath) {
|
|
97
|
+
const sideW = designPng.width + renderPng.width + 8
|
|
98
|
+
const sideH = Math.max(designPng.height, renderPng.height)
|
|
99
|
+
const side = new PNG({ width: sideW, height: sideH, colorType: 2 })
|
|
100
|
+
for (let y = 0; y < sideH; y++) {
|
|
101
|
+
for (let x = 0; x < sideW; x++) {
|
|
102
|
+
const di = (y * sideW + x) * 4
|
|
103
|
+
side.data[di] = 255; side.data[di + 1] = 0; side.data[di + 2] = 0; side.data[di + 3] = 255
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const blit = (src, xOff) => {
|
|
107
|
+
for (let y = 0; y < src.height; y++) {
|
|
108
|
+
for (let x = 0; x < src.width; x++) {
|
|
109
|
+
const si = (y * src.width + x) * 4
|
|
110
|
+
const di = (y * sideW + x + xOff) * 4
|
|
111
|
+
side.data[di] = src.data[si]; side.data[di + 1] = src.data[si + 1]
|
|
112
|
+
side.data[di + 2] = src.data[si + 2]; side.data[di + 3] = 255
|
|
113
|
+
}
|
|
114
|
+
}
|
|
120
115
|
}
|
|
116
|
+
blit(designPng, 0); blit(renderPng, designPng.width + 8)
|
|
117
|
+
fs.writeFileSync(sidePath, PNG.sync.write(side))
|
|
118
|
+
return sidePath
|
|
121
119
|
}
|
|
122
|
-
const bandPct = bandDiff.map((c, i) => (bandTotal[i] ? c / bandTotal[i] : 0))
|
|
123
|
-
const hotBands = bandPct.map((p2, i) => ({ band: i, y: Math.round((i / BANDS) * h), pct: p2 }))
|
|
124
|
-
.filter((b) => b.pct >= 0.05).sort((a, b) => b.pct - a.pct)
|
|
125
120
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
if (!rendered.ok) { console.error('re-render failed:', rendered.error); process.exit(1) }
|
|
136
|
-
const renderTexts = (rendered.elementBoxes || [])
|
|
137
|
-
.filter((b) => b.text && ['Text', 'Input', 'Button'].includes(b.cls))
|
|
138
|
-
const specTexts = (spec.elements || [])
|
|
139
|
-
.filter((el) => el.type === 'text' && (el.text || '').trim())
|
|
140
|
-
.map((el) => ({
|
|
141
|
-
text: String(el.text).trim().replace(/\s+/g, ''),
|
|
142
|
-
x: (el.rect?.x || 0) - (cardRect.x || 0),
|
|
143
|
-
y: (el.rect?.y || 0) - (cardRect.y || 0),
|
|
144
|
-
w: el.rect?.width || 0,
|
|
145
|
-
h: el.rect?.height || 0,
|
|
146
|
-
fs: el.textStyle?.fontSize || 14,
|
|
147
|
-
lineHeight: el.textStyle?.lineHeight ?? null,
|
|
148
|
-
}))
|
|
149
|
-
const norm = (s) => String(s || '').replace(/\s+/g, '')
|
|
150
|
-
const usedSpec = new Set()
|
|
151
|
-
const deviations = []
|
|
152
|
-
for (const rt of renderTexts) {
|
|
153
|
-
const key = norm(rt.text)
|
|
154
|
-
if (!key) continue
|
|
155
|
-
let best = null
|
|
156
|
-
let bestScore = 0
|
|
157
|
-
for (let i = 0; i < specTexts.length; i++) {
|
|
158
|
-
if (usedSpec.has(i)) continue
|
|
159
|
-
const st = specTexts[i]
|
|
160
|
-
let score = 0
|
|
161
|
-
if (st.text === key) score = 1
|
|
162
|
-
else if (st.text.includes(key) || key.includes(st.text)) score = 0.9
|
|
163
|
-
if (score > bestScore) { bestScore = score; best = { ...st, idx: i } }
|
|
121
|
+
function pixelBands(designPng, renderPng, exclusions) {
|
|
122
|
+
const w = Math.min(designPng.width, renderPng.width)
|
|
123
|
+
const h = Math.min(designPng.height, renderPng.height)
|
|
124
|
+
const mask = new Uint8Array(w * h)
|
|
125
|
+
for (const rc of exclusions || []) {
|
|
126
|
+
const x0 = Math.floor((rc.x ?? 0) * w), y0 = Math.floor((rc.y ?? 0) * h)
|
|
127
|
+
const x1 = Math.min(w, Math.ceil(((rc.x ?? 0) + (rc.width ?? 0)) * w))
|
|
128
|
+
const y1 = Math.min(h, Math.ceil(((rc.y ?? 0) + (rc.height ?? 0)) * h))
|
|
129
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) mask[y * w + x] = 1
|
|
164
130
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
let provHits = []
|
|
182
|
-
try {
|
|
183
|
-
process.env.SCHEMA_PROV = '1'
|
|
184
|
-
const { generate } = await import(repoRoot + '/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs')
|
|
185
|
-
const out = generate(spec, { id: 'diagnose', name: 'diagnose', cardIndex: entry.cardIndex ?? 0, designImage: entry.input.designImage })
|
|
186
|
-
const provByCid = new Map()
|
|
187
|
-
const textByCid = new Map()
|
|
188
|
-
const walkProv = (node) => {
|
|
189
|
-
if (!node) return
|
|
190
|
-
if (node.componentId) {
|
|
191
|
-
if (node.__prov) provByCid.set(node.componentId, node.__prov)
|
|
192
|
-
const prop = (node.properties || []).find((pp) => pp.itemKey === 'value')
|
|
193
|
-
const val = norm(prop?.itemDefaultValue || prop?.itemValue)
|
|
194
|
-
if (val && !textByCid.has(node.componentId)) textByCid.set(node.componentId, val)
|
|
131
|
+
const BANDS = 20
|
|
132
|
+
const bandDiff = new Array(BANDS).fill(0)
|
|
133
|
+
const bandTotal = new Array(BANDS).fill(0)
|
|
134
|
+
let totalDiff = 0
|
|
135
|
+
let totalCount = 0
|
|
136
|
+
for (let y = 0; y < h; y++) {
|
|
137
|
+
const band = Math.min(BANDS - 1, Math.floor((y / h) * BANDS))
|
|
138
|
+
for (let x = 0; x < w; x++) {
|
|
139
|
+
if (mask[y * w + x]) continue
|
|
140
|
+
const i = (y * designPng.width + x) * 4
|
|
141
|
+
const j = (y * renderPng.width + x) * 4
|
|
142
|
+
const diff = Math.abs(designPng.data[i] - renderPng.data[j]) > 15
|
|
143
|
+
|| Math.abs(designPng.data[i + 1] - renderPng.data[j + 1]) > 15
|
|
144
|
+
|| Math.abs(designPng.data[i + 2] - renderPng.data[j + 2]) > 15
|
|
145
|
+
if (diff) { bandDiff[band]++; totalDiff++ }
|
|
146
|
+
bandTotal[band]++; totalCount++
|
|
195
147
|
}
|
|
196
|
-
for (const c of node.components || []) walkProv(c)
|
|
197
|
-
for (const sl of node.slots || []) for (const c of sl.components || []) walkProv(c)
|
|
198
148
|
}
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
provHits = [{ error: String(e.message || e) }]
|
|
149
|
+
const bandPct = bandDiff.map((c, i) => (bandTotal[i] ? c / bandTotal[i] : 0))
|
|
150
|
+
const hotBands = bandPct.map((pct, i) => ({ band: i, y: Math.round((i / BANDS) * h), pct }))
|
|
151
|
+
.filter((b) => b.pct >= 0.05).sort((a, b) => b.pct - a.pct)
|
|
152
|
+
return { hotBands, diffRatio: totalCount ? totalDiff / totalCount : null }
|
|
204
153
|
}
|
|
205
154
|
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
155
|
+
function pairDeviations(specTexts, renderTexts) {
|
|
156
|
+
const usedSpec = new Set()
|
|
157
|
+
const deviations = []
|
|
158
|
+
for (const rt of renderTexts) {
|
|
159
|
+
const key = norm(rt.text)
|
|
160
|
+
if (!key) continue
|
|
161
|
+
let best = null
|
|
162
|
+
let bestScore = 0
|
|
163
|
+
for (let i = 0; i < specTexts.length; i++) {
|
|
164
|
+
if (usedSpec.has(i)) continue
|
|
165
|
+
const st = specTexts[i]
|
|
166
|
+
let score = 0
|
|
167
|
+
if (st.text === key) score = 1
|
|
168
|
+
else if (st.text.includes(key) || key.includes(st.text)) score = 0.9
|
|
169
|
+
if (score > bestScore) { bestScore = score; best = { ...st, idx: i } }
|
|
170
|
+
}
|
|
171
|
+
if (!best || bestScore < 0.9) continue
|
|
172
|
+
usedSpec.add(best.idx)
|
|
173
|
+
deviations.push({
|
|
174
|
+
text: key.slice(0, 18),
|
|
175
|
+
dy: +(rt.y - best.y).toFixed(1),
|
|
176
|
+
dx: +(rt.x - best.x).toFixed(1),
|
|
177
|
+
dw: +((rt.w || 0) - best.w).toFixed(1),
|
|
178
|
+
dh: +((rt.h || 0) - best.h).toFixed(1),
|
|
179
|
+
render: { x: rt.x, y: rt.y, w: rt.w, h: rt.h },
|
|
180
|
+
spec: { x: best.x, y: best.y, w: best.w, h: best.h, fs: best.fs, lineHeight: best.lineHeight },
|
|
181
|
+
cid: rt.cid,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
deviations.sort((a, b) => (Math.abs(b.dy) + Math.abs(b.dx)) - (Math.abs(a.dy) + Math.abs(a.dx)))
|
|
185
|
+
return deviations
|
|
226
186
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
const results = []
|
|
190
|
+
let session = null
|
|
191
|
+
let generate = null
|
|
192
|
+
const ensureSession = async () => {
|
|
193
|
+
if (session) return session
|
|
194
|
+
const { createRenderSession } = await importAbs(
|
|
195
|
+
path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'render_card.mjs'))
|
|
196
|
+
session = await createRenderSession({ cardsRoot: path.join(runDir, 'cards') })
|
|
197
|
+
return session
|
|
198
|
+
}
|
|
199
|
+
const ensureGenerator = async () => {
|
|
200
|
+
if (generate) return generate
|
|
201
|
+
const mod = await importAbs(
|
|
202
|
+
path.join(repoRoot, 'skills', 'dsl-card-generator', 'scripts', 'spec_to_schema_v3.mjs'))
|
|
203
|
+
generate = mod.generate
|
|
204
|
+
return generate
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const caseId of caseIds) {
|
|
208
|
+
const result = { caseId }
|
|
209
|
+
try {
|
|
210
|
+
const reportPath = path.join(reportDir, `${caseId}.json`)
|
|
211
|
+
if (!fs.existsSync(reportPath)) throw new Error(`报告不存在:${reportPath}`)
|
|
212
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
|
|
213
|
+
const found = datasetIndex.get(caseId)
|
|
214
|
+
if (!found) throw new Error(`caseId ${caseId} 不在 ${args.dataset || 'training/datasets/*'} 里`)
|
|
215
|
+
const { entry } = found
|
|
216
|
+
const spec = JSON.parse(fs.readFileSync(entry.input.specJson, 'utf8'))
|
|
217
|
+
const cardMeta = (spec.cards || [])[entry.cardIndex ?? 0] || (spec.cards || [])[0] || {}
|
|
218
|
+
const cardRect = cardMeta.rect || { x: 0, y: 0, width: 343, height: 200 }
|
|
219
|
+
// 与评分同源:只用聚焦卡自己的元素,避免把画板上其它卡的文本算成本卡偏差
|
|
220
|
+
const cardElements = cardMeta.elements || spec.elements || []
|
|
221
|
+
|
|
222
|
+
// —— 门禁与归因(口径来自 lib/scoring.mjs)——
|
|
223
|
+
const gate = Array.isArray(report.gateFailures) && report.gateFailures.length
|
|
224
|
+
? report.gateFailures : evaluatePassGate(report).failures
|
|
225
|
+
const attribution = report.failureClass
|
|
226
|
+
? { failureClass: report.failureClass, reason: report.failureReason || '' }
|
|
227
|
+
: attributeFailure({ ...report, passed: false })
|
|
228
|
+
const coverage = report.exclusionCoverage || exclusionCoverage(report.pixelExclusions || [])
|
|
229
|
+
|
|
230
|
+
// —— 1. 并排图(设计稿 / 渲染截图任一缺失则跳过,不再整条命令失败)——
|
|
231
|
+
const designPng = readPngIfExists(path.join(reportDir, `${caseId}.design.png`))
|
|
232
|
+
const renderPng = readPngIfExists(path.join(reportDir, `${caseId}.render.png`))
|
|
233
|
+
let sidePath = null
|
|
234
|
+
if (designPng && renderPng) {
|
|
235
|
+
sidePath = buildSideImage(designPng, renderPng, path.join(reportDir, `${caseId}.side.png`))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// —— 2. 像素分带直方图(含掩膜区剔除)——
|
|
239
|
+
const bands = designPng && renderPng
|
|
240
|
+
? pixelBands(designPng, renderPng, report.pixelExclusions)
|
|
241
|
+
: { hotBands: [], diffRatio: null }
|
|
242
|
+
|
|
243
|
+
// —— 3. 标注几何 vs 渲染盒(重渲一次拿 elementBoxes)——
|
|
244
|
+
const cardId = report.cardId || `${path.basename(runDir)}__${caseId}`
|
|
245
|
+
const captureHeight = designPng && renderPng
|
|
246
|
+
? Math.ceil(Math.min(designPng.height, renderPng.height) / (designPng.width / (cardRect.width || 343)))
|
|
247
|
+
: Math.ceil(cardRect.height || 400)
|
|
248
|
+
let deviations = []
|
|
249
|
+
let renderError = null
|
|
250
|
+
try {
|
|
251
|
+
const activeSession = await ensureSession()
|
|
252
|
+
const rendered = await activeSession.render({
|
|
253
|
+
id: cardId, width: cardRect.width || 343, captureHeight, format: 'png', captureElements: true,
|
|
254
|
+
})
|
|
255
|
+
if (!rendered.ok) renderError = String(rendered.error || rendered.message || 'render failed')
|
|
256
|
+
else {
|
|
257
|
+
const renderTexts = (rendered.elementBoxes || [])
|
|
258
|
+
.filter((b) => b.text && ['Text', 'Input', 'Button'].includes(b.cls))
|
|
259
|
+
const specTexts = cardElements
|
|
260
|
+
.filter((el) => el.type === 'text' && String(el.text || '').trim())
|
|
261
|
+
.map((el) => ({
|
|
262
|
+
text: norm(el.text),
|
|
263
|
+
x: (el.rect?.x || 0) - (cardRect.x || 0),
|
|
264
|
+
y: (el.rect?.y || 0) - (cardRect.y || 0),
|
|
265
|
+
w: el.rect?.width || 0,
|
|
266
|
+
h: el.rect?.height || 0,
|
|
267
|
+
fs: el.textStyle?.fontSize || 14,
|
|
268
|
+
lineHeight: el.textStyle?.lineHeight ?? null,
|
|
269
|
+
}))
|
|
270
|
+
deviations = pairDeviations(specTexts, renderTexts)
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
renderError = String(error?.message || error)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// —— 4. 生成器源码行溯源(SCHEMA_PROV 重生成,仅偏差 TopN)——
|
|
277
|
+
let provHits = []
|
|
278
|
+
try {
|
|
279
|
+
const generateFn = await ensureGenerator()
|
|
280
|
+
const out = generateFn(spec, {
|
|
281
|
+
id: 'diagnose', name: 'diagnose', cardIndex: entry.cardIndex ?? 0, designImage: entry.input.designImage,
|
|
282
|
+
})
|
|
283
|
+
const provByCid = new Map()
|
|
284
|
+
const textByCid = new Map()
|
|
285
|
+
const walkProv = (node) => {
|
|
286
|
+
if (!node) return
|
|
287
|
+
if (node.componentId) {
|
|
288
|
+
if (node.__prov) provByCid.set(node.componentId, node.__prov)
|
|
289
|
+
const prop = (node.properties || []).find((pp) => pp.itemKey === 'value')
|
|
290
|
+
const val = norm(prop?.itemDefaultValue || prop?.itemValue)
|
|
291
|
+
if (val && !textByCid.has(node.componentId)) textByCid.set(node.componentId, val)
|
|
292
|
+
}
|
|
293
|
+
for (const c of node.components || []) walkProv(c)
|
|
294
|
+
for (const sl of node.slots || []) for (const c of sl.components || []) walkProv(c)
|
|
295
|
+
}
|
|
296
|
+
walkProv(out.schema.view)
|
|
297
|
+
const topCids = [...new Set(deviations.slice(0, 6).map((d) => d.cid).filter(Boolean))]
|
|
298
|
+
provHits = topCids.map((cid) => ({
|
|
299
|
+
cid, prov: provByCid.get(cid) || '(未登记/由组装层创建)', text: (textByCid.get(cid) || '').slice(0, 18),
|
|
300
|
+
}))
|
|
301
|
+
} catch (e) {
|
|
302
|
+
provHits = [{ error: String(e.message || e) }]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// —— 5. 失败类建议 ——
|
|
306
|
+
const px = report.pixel || {}
|
|
307
|
+
const fid = report.fidelity || {}
|
|
308
|
+
const hints = []
|
|
309
|
+
if (attribution) hints.push(`归因 ${attribution.failureClass}:${attribution.reason}`)
|
|
310
|
+
if (px.bestAlignDy && Math.abs(px.bestAlignDy) >= 4) hints.push(`全局纵向偏移 dy=${px.bestAlignDy}:查行高/行距累积(Input 高度、行距标注优先、分割线渲染盒高)`)
|
|
311
|
+
if (bands.hotBands.length && bands.hotBands[0].pct >= 0.2) hints.push(`单带高热 (${(bands.hotBands[0].pct * 100).toFixed(0)}%@y≈${bands.hotBands[0].y}):查该区域语义块(换行折点/缺组件/结构错配)`)
|
|
312
|
+
if ((fid.components ?? 1) < 0.9) hints.push(`组件计数缺失 comp=${num(fid.components, 2)}:查语义消费(媒体未渲染/文本被吞/重复消费)`)
|
|
313
|
+
if ((report.duplication?.overall ?? 1) < 0.95) {
|
|
314
|
+
hints.push(`重影:查同源按钮/文本双路径渲染(${(report.duplication?.offenders || []).map((o) => `「${o.text}」x${o.rendered}/${o.annotated}`).join(' ')})`)
|
|
315
|
+
}
|
|
316
|
+
if ((coverage.total ?? 0) >= MASK_REVIEW_THRESHOLD) {
|
|
317
|
+
hints.push(`掩膜覆盖 ${(coverage.total * 100).toFixed(1)}% ≥ ${(MASK_REVIEW_THRESHOLD * 100).toFixed(0)}%:得分不代表整卡,必须看图复核`)
|
|
318
|
+
}
|
|
319
|
+
const bigDy = deviations.filter((d) => Math.abs(d.dy) >= 3)
|
|
320
|
+
if (bigDy.length >= 3) hints.push(`${bigDy.length} 个文本 |dy|≥3:纵向节奏问题(行距/padding 体系),非单点`)
|
|
321
|
+
if (!hints.length) hints.push('像素差异弥散且量级低:多为 1-2px 抗锯齿/字体度量差异,接近噪声底')
|
|
322
|
+
|
|
323
|
+
// —— 6. 输出 ——
|
|
324
|
+
console.log('═══════════════════════════════════════════════════════════')
|
|
325
|
+
console.log(`诊断 ${caseId} | ${report.artboardName || ''}`)
|
|
326
|
+
if (sidePath) console.log(`side-by-side: ${path.relative(repoRoot, sidePath)}`)
|
|
327
|
+
else console.log('side-by-side: (缺 design.png / render.png,已跳过并排图与像素分带)')
|
|
328
|
+
console.log(`passed=${report.passed} pix=${num(px.ratio)} struct=${num(px.structure)} dy=${px.bestAlignDy ?? 'N/A'} fid=${num(fid.overall)} pos=${num(fid.position)} comp=${num(fid.components)}`)
|
|
329
|
+
console.log('── 门禁未达标维度(缺口降序)──')
|
|
330
|
+
if (!gate.length) console.log(' (全部门通过)')
|
|
331
|
+
for (const failure of gate) {
|
|
332
|
+
console.log(` ${failure.key.padEnd(13)} ${failure.label}=${num(failure.actual)} 门 ${failure.op} ${failure.target} 缺口 ${(failure.deficit * 100).toFixed(1)}%`)
|
|
333
|
+
}
|
|
334
|
+
console.log(` 主签名: ${report.failureSignature || failureSignature(report) || '(无)'}`)
|
|
335
|
+
if (coverage.total != null) {
|
|
336
|
+
const byReason = Object.entries(coverage.byReason || {}).map(([reason, value]) => `${reason} ${(value * 100).toFixed(1)}%`).join(' / ')
|
|
337
|
+
console.log(`── 掩膜覆盖 ${(coverage.total * 100).toFixed(1)}%${byReason ? `(${byReason})` : ''} ──`)
|
|
338
|
+
}
|
|
339
|
+
const upstream = report.upstream
|
|
340
|
+
if (upstream) {
|
|
341
|
+
const defects = upstream.annotationDefects || []
|
|
342
|
+
console.log(`── 上游聚焦门 passed=${upstream.passed} 标注完整性=${num(upstream.annotationCompleteness, 3)} 文本保留=${num(upstream.textRetention, 3)} 结构聚焦=${upstream.structuralFocus} 标注缺陷=${defects.length}${defects.length ? `(${[...new Set(defects.map((d) => d.type))].join(',')})` : ''} ──`)
|
|
343
|
+
}
|
|
344
|
+
if (report.layoutCorrections?.length) {
|
|
345
|
+
console.log(`校正器干预 ${report.layoutCorrections.length} 处(守卫通过):`, JSON.stringify(report.layoutCorrections.slice(0, 5)))
|
|
346
|
+
}
|
|
347
|
+
if (report.correctorRejected) {
|
|
348
|
+
console.log(`校正器干预被守卫拒绝(${report.correctorRejected.reason}):`, JSON.stringify(report.correctorRejected.adjustments?.slice(0, 5) || []))
|
|
349
|
+
}
|
|
350
|
+
console.log('── 像素热区 (band≥5%) ──')
|
|
351
|
+
if (!bands.hotBands.length) console.log(' (无高热区或截图缺失)')
|
|
352
|
+
for (const b of bands.hotBands) {
|
|
353
|
+
console.log(` y≈${String(b.y).padStart(4)} ${(b.pct * 100).toFixed(1)}% ${'#'.repeat(Math.round(b.pct * 50))}`)
|
|
354
|
+
}
|
|
355
|
+
console.log('── 元素偏差 Top10 (|dy|+|dx| 降序, pt@卡宽) ──')
|
|
356
|
+
if (renderError) console.log(` 重渲失败:${renderError}`)
|
|
357
|
+
for (const d of deviations.slice(0, 10)) {
|
|
358
|
+
console.log(` dy=${String(d.dy).padStart(5)} dx=${String(d.dx).padStart(5)} dw=${String(d.dw).padStart(5)} dh=${String(d.dh).padStart(5)} '${d.text}' spec[y=${d.spec.y} h=${d.spec.h} fs=${d.spec.fs} lh=${d.spec.lineHeight ?? '-'}] render[y=${d.render.y} h=${d.render.h}]`)
|
|
359
|
+
}
|
|
360
|
+
console.log('── 偏差节点 → 生成器源码行 ──')
|
|
361
|
+
for (const phit of provHits) {
|
|
362
|
+
if (phit.error) { console.log(' 溯源失败:', phit.error); continue }
|
|
363
|
+
console.log(` ${phit.cid} '${phit.text}' → ${phit.prov}`)
|
|
364
|
+
}
|
|
365
|
+
console.log('── 失败类建议 ──')
|
|
366
|
+
for (const hint of hints) console.log(' •', hint)
|
|
367
|
+
|
|
368
|
+
results.push({
|
|
369
|
+
...result,
|
|
370
|
+
artboardName: report.artboardName || null,
|
|
371
|
+
passed: !!report.passed,
|
|
372
|
+
signature: report.failureSignature || failureSignature(report) || null,
|
|
373
|
+
failureClass: attribution?.failureClass || null,
|
|
374
|
+
failureReason: attribution?.reason || null,
|
|
375
|
+
gateFailures: gate,
|
|
376
|
+
exclusionCoverage: coverage,
|
|
377
|
+
upstream: upstream ? {
|
|
378
|
+
passed: upstream.passed,
|
|
379
|
+
annotationCompleteness: upstream.annotationCompleteness,
|
|
380
|
+
textRetention: upstream.textRetention,
|
|
381
|
+
structuralFocus: upstream.structuralFocus,
|
|
382
|
+
annotationDefects: (upstream.annotationDefects || []).length,
|
|
383
|
+
} : null,
|
|
384
|
+
metrics: {
|
|
385
|
+
fidelity: fid.overall ?? null, pixel: px.ratio ?? null, structure: px.structure ?? null,
|
|
386
|
+
position: fid.position ?? null, components: fid.components ?? null,
|
|
387
|
+
duplication: report.duplication?.overall ?? null, bestAlignDy: px.bestAlignDy ?? null,
|
|
388
|
+
},
|
|
389
|
+
sideImage: sidePath ? path.relative(repoRoot, sidePath).split(path.sep).join('/') : null,
|
|
390
|
+
hotBands: bands.hotBands.slice(0, 8),
|
|
391
|
+
deviations: deviations.slice(0, 10),
|
|
392
|
+
provenance: provHits,
|
|
393
|
+
layoutCorrections: report.layoutCorrections || [],
|
|
394
|
+
correctorRejected: report.correctorRejected || null,
|
|
395
|
+
hints,
|
|
396
|
+
})
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error(`[diagnose] ${caseId} 失败:${String(error?.message || error)}`)
|
|
399
|
+
results.push({ ...result, error: String(error?.message || error) })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try { await session?.close() } catch {}
|
|
404
|
+
|
|
405
|
+
if (args.out) {
|
|
406
|
+
const outPath = path.resolve(String(args.out))
|
|
407
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
408
|
+
fs.writeFileSync(outPath, JSON.stringify({ run: path.basename(runDir), results }, null, 2) + '\n')
|
|
409
|
+
console.log('')
|
|
410
|
+
console.log(`诊断 JSON: ${path.relative(repoRoot, outPath).split(path.sep).join('/')}`)
|
|
411
|
+
}
|
|
412
|
+
const failed = results.filter((item) => item.error)
|
|
413
|
+
if (failed.length && failed.length === results.length) process.exit(1)
|
|
231
414
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if ((report.duplication?.overall ?? 1) < 0.95) hints.push('重影:查同源按钮/文本双路径渲染')
|
|
238
|
-
const bigDy = deviations.filter((d) => Math.abs(d.dy) >= 3)
|
|
239
|
-
if (bigDy.length >= 3) hints.push(`${bigDy.length} 个文本 |dy|≥3:纵向节奏问题(行距/padding 体系),非单点`)
|
|
240
|
-
if (!hints.length) hints.push('像素差异弥散且量级低:多为 1-2px 抗锯齿/字体度量差异,接近噪声底')
|
|
241
|
-
for (const hint of hints) console.log(' •', hint)
|
|
415
|
+
|
|
416
|
+
main().catch((error) => {
|
|
417
|
+
console.error(error)
|
|
418
|
+
process.exit(1)
|
|
419
|
+
})
|