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
@@ -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
- * 终端打印 像素分带直方图、偏差元素表(dy/dx)、生成器源码行溯源、
9
- * 校正器干预记录、失败类建议
8
+ * 终端打印 门禁未达标维度、失败类归因、掩膜覆盖、上游聚焦门、像素分带直方图、
9
+ * 偏差元素表(dy/dx)、生成器源码行溯源、校正器干预记录、失败类建议
10
10
  *
11
11
  * 用法:
12
- * node skills/card-skill-trainer/scripts/diagnose_case.mjs --run training/runs/r171 --case <caseId>
13
- * [--dataset training/datasets/v4/train-set.sampled.jsonl] (缺省自动扫描 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
- for (let index = 0; index < argv.length; index += 2) {
24
- const key = argv[index].replace(/^--/, '')
25
- out[key] = argv[index + 1]
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
- const report = JSON.parse(fs.readFileSync(path.join(reportDir, `${args.case}.json`), 'utf8'))
37
-
38
- // —— 数据集条目定位(spec 路径 + cardIndex)——
39
- function findDatasetEntry() {
40
- const candidates = args.dataset
41
- ? [path.resolve(args.dataset)]
42
- : (() => {
43
- const rootDir = path.join(repoRoot, 'training/datasets')
44
- const files = []
45
- for (const ver of fs.readdirSync(rootDir)) {
46
- const dir = path.join(rootDir, ver)
47
- if (!fs.statSync(dir).isDirectory()) continue
48
- for (const f of fs.readdirSync(dir)) {
49
- if (f.endsWith('.jsonl')) files.push(path.join(dir, f))
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
- throw new Error(`caseId ${args.case} not found in ${args.dataset || 'training/datasets/*'}`)
62
- }
63
- const { entry } = findDatasetEntry()
64
- const spec = JSON.parse(fs.readFileSync(entry.input.specJson, 'utf8'))
65
- const cardMeta = (spec.cards || [])[entry.cardIndex ?? 0] || (spec.cards || [])[0]
66
- const cardRect = cardMeta?.rect || { x: 0, y: 0, width: 343, height: 200 }
67
-
68
- // —— 1. 并排图(持久化到 reports/)——
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
- blit(designPng, 0); blit(renderPng, designPng.width + 8)
91
- const sidePath = path.join(reportDir, `${args.case}.side.png`)
92
- fs.writeFileSync(sidePath, PNG.sync.write(side))
93
-
94
- // —— 2. 像素分带直方图(含豁免区掩码)——
95
- const w = Math.min(designPng.width, renderPng.width)
96
- const h = Math.min(designPng.height, renderPng.height)
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
- const BANDS = 20
105
- const bandDiff = new Array(BANDS).fill(0)
106
- const bandTotal = new Array(BANDS).fill(0)
107
- let totalDiff = 0
108
- let totalCount = 0
109
- for (let y = 0; y < h; y++) {
110
- const band = Math.min(BANDS - 1, Math.floor((y / h) * BANDS))
111
- for (let x = 0; x < w; x++) {
112
- if (mask[y * w + x]) continue
113
- const i = (y * designPng.width + x) * 4
114
- const j = (y * renderPng.width + x) * 4
115
- const diff = Math.abs(designPng.data[i] - renderPng.data[j]) > 15
116
- || Math.abs(designPng.data[i + 1] - renderPng.data[j + 1]) > 15
117
- || Math.abs(designPng.data[i + 2] - renderPng.data[j + 2]) > 15
118
- if (diff) { bandDiff[band]++; totalDiff++ }
119
- bandTotal[band]++; totalCount++
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
- // —— 3. 标注几何 vs 渲染盒(文本元素,按 |dy| 排序)——
127
- process.env.DSL_CARD_CARDS_DIR = path.join(runDir, 'cards')
128
- process.env.DSL_CARD_SLICES_DIR = path.join(runDir, 'slices')
129
- const { createRenderSession } = await import(repoRoot + '/skills/dsl-card-generator/scripts/render_card.mjs')
130
- const session = await createRenderSession({ cardsRoot: path.join(runDir, 'cards') })
131
- const scaleX = designPng.width / (cardRect.width || 343)
132
- const cardId = report.cardId || `${path.basename(runDir)}__${args.case}`
133
- const rendered = await session.render({ id: cardId, width: cardRect.width || 343, captureHeight: Math.ceil(h / scaleX), format: 'png', captureElements: true })
134
- await session.close()
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
- if (!best || bestScore < 0.9) continue
166
- usedSpec.add(best.idx)
167
- deviations.push({
168
- text: key.slice(0, 18),
169
- dy: +(rt.y - best.y).toFixed(1),
170
- dx: +(rt.x - best.x).toFixed(1),
171
- dw: +((rt.w || 0) - best.w).toFixed(1),
172
- dh: +((rt.h || 0) - best.h).toFixed(1),
173
- render: { x: rt.x, y: rt.y, w: rt.w, h: rt.h },
174
- spec: { x: best.x, y: best.y, w: best.w, h: best.h, fs: best.fs, lineHeight: best.lineHeight },
175
- cid: rt.cid,
176
- })
177
- }
178
- deviations.sort((a, b) => (Math.abs(b.dy) + Math.abs(b.dx)) - (Math.abs(a.dy) + Math.abs(a.dx)))
179
-
180
- // —— 4. 生成器源码行溯源(SCHEMA_PROV 重生成,仅偏差 TopN)——
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
- walkProv(out.schema.view)
200
- const topCids = [...new Set(deviations.slice(0, 6).map((d) => d.cid).filter(Boolean))]
201
- provHits = topCids.map((cid) => ({ cid, prov: provByCid.get(cid) || '(未登记/由组装层创建)', text: (textByCid.get(cid) || '').slice(0, 18) }))
202
- } catch (e) {
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
- // —— 5. 汇总输出 ——
207
- const px = report.pixel || {}
208
- const fid = report.fidelity || {}
209
- console.log('═══════════════════════════════════════════════════════════')
210
- console.log(`诊断 ${args.case} | ${report.artboardName || ''}`)
211
- console.log(`side-by-side: ${path.relative(repoRoot, sidePath)}`)
212
- console.log(`passed=${report.passed} pix=${px.ratio?.toFixed(4)} struct=${px.structure?.toFixed(4)} dy=${px.bestAlignDy} fid=${fid.overall?.toFixed(4)} pos=${fid.position?.toFixed(4)} comp=${fid.components?.toFixed(4)}`)
213
- if (report.layoutCorrections?.length) {
214
- console.log(`校正器干预 ${report.layoutCorrections.length} 处(守卫通过):`, JSON.stringify(report.layoutCorrections.slice(0, 5)))
215
- }
216
- if (report.correctorRejected) {
217
- console.log(`校正器干预被守卫拒绝(${report.correctorRejected.reason}):`, JSON.stringify(report.correctorRejected.adjustments?.slice(0, 5) || []))
218
- }
219
- console.log('── 像素热区 (band≥5%) ──')
220
- for (const b of hotBands) {
221
- console.log(` y≈${String(b.y).padStart(4)} ${(b.pct * 100).toFixed(1)}% ${'#'.repeat(Math.round(b.pct * 50))}`)
222
- }
223
- console.log('── 元素偏差 Top10 (|dy|+|dx| 降序, pt@343宽) ──')
224
- for (const d of deviations.slice(0, 10)) {
225
- 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}]`)
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
- console.log('── 偏差节点 → 生成器源码行 ──')
228
- for (const phit of provHits) {
229
- if (phit.error) { console.log(' 溯源失败:', phit.error); continue }
230
- console.log(` ${phit.cid} '${phit.text}' → ${phit.prov}`)
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
- console.log('── 失败类建议 ──')
233
- const hints = []
234
- if (px.bestAlignDy && Math.abs(px.bestAlignDy) >= 4) hints.push(`全局纵向偏移 dy=${px.bestAlignDy}:查行高/行距累积(Input 高度、行距标注优先、分割线渲染盒高)`)
235
- if (hotBands.length && hotBands[0].pct >= 0.2) hints.push(`单带高热 (${(hotBands[0].pct * 100).toFixed(0)}%@y≈${hotBands[0].y}):查该区域语义块(换行折点/缺组件/结构错配)`)
236
- if ((fid.components ?? 1) < 0.9) hints.push(`组件计数缺失 comp=${fid.components?.toFixed(2)}:查语义消费(媒体未渲染/文本被吞/重复消费)`)
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
+ })