cardbuddy 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/init.js +0 -2
  2. package/dist/preview-editor/assets/index-CXDf5tVg.css +1 -0
  3. package/dist/preview-editor/assets/{index-CIIfMlwI.js → index-DLQkCPLT.js} +113 -86
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  6. package/dist/vendored/mcp/client.mjs +1 -1
  7. package/dist/vendored/server/core/ai-chat.js +251 -80
  8. package/dist/vendored/server/core/browser.js +17 -0
  9. package/dist/vendored/server/core/cardFiles.js +49 -1
  10. package/dist/vendored/server/core/cardImage.js +103 -2
  11. package/dist/vendored/server/core/cardValidate.js +16 -5
  12. package/dist/vendored/server/core/screenshot.js +94 -0
  13. package/dist/vendored/skills/card-skill-trainer/SKILL.md +68 -13
  14. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  15. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  16. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  17. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  18. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  19. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  20. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  21. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  22. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +5 -2
  23. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +286 -92
  24. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  25. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  26. package/dist/vendored/skills/dsl-card-generator/SKILL.md +93 -27
  27. package/dist/vendored/skills/dsl-card-generator/references/component_rules.md +14 -0
  28. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  29. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +217 -44
  31. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +87 -3
  32. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +35 -6
  33. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +21 -0
  34. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +28 -27
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +30 -8
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +3 -2
  37. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +5 -2
  38. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +2 -3
  39. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +1 -4
  40. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  41. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +88 -0
  42. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +143 -42
  43. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  44. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +99 -53
  45. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +5 -2
  46. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +388 -29
  47. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  48. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  49. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  50. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  51. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  52. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  53. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  54. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  55. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  56. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  57. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  58. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  59. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scaffold.mjs +48 -1
  60. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  61. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +366 -45
  62. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +169 -12
  63. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +17 -9
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +119 -78
  65. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +48 -50
  66. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  67. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  68. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +5 -10
  69. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +122 -8
  70. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +11 -2
  71. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  72. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +46 -2
  73. package/dist/vendored/skills/mbank-design-gen/SKILL.md +76 -0
  74. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-apps.png +0 -0
  75. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-arrow-right-blue.png +0 -0
  76. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-check.png +0 -0
  77. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-settings.png +0 -0
  78. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-sound.png +0 -0
  79. package/dist/vendored/skills/mbank-design-gen/assets/icons/icon-voice-switch.png +0 -0
  80. package/dist/vendored/skills/mbank-design-gen/assets/icons/progress-thumb.png +0 -0
  81. package/dist/vendored/skills/mbank-design-gen/assets/icons/tag-bg.png +0 -0
  82. package/dist/vendored/skills/mbank-design-gen/assets/icons/vs-badge.png +0 -0
  83. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-chat-options.png +0 -0
  84. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-home-scene.png +0 -0
  85. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-product-detail.png +0 -0
  86. package/dist/vendored/skills/mbank-design-gen/assets/ref/ref-rate-table.png +0 -0
  87. package/dist/vendored/skills/mbank-design-gen/assets/robot.png +0 -0
  88. package/dist/vendored/skills/mbank-design-gen/references/component-library.md +127 -0
  89. package/dist/vendored/skills/mbank-design-gen/references/design-spec.md +261 -0
  90. package/dist/vendored/skills/mbank-design-gen/templates/mockup-template.html +138 -0
  91. package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +34 -0
  92. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  93. package/dist/vendored/skills/shared/spec-contract.md +127 -0
  94. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +27 -10
  95. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +3 -3
  96. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +0 -8
  97. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +121 -47
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +0 -5
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +98 -16
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +55 -37
  102. package/dist/vendored/templates/helloCard/schema.json +62 -1
  103. package/dist/vendored/tools/finmall-env.mjs +1 -18
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-DQn0zQBP.css +0 -1
  106. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  107. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
@@ -1,10 +1,15 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
 
3
- /** 跨平台 Python 探测:DSL_CARD_PYTHON > WORKBUDDY_PYTHON > python3 > python */
3
+ /**
4
+ * 跨平台 Python 探测:DSL_CARD_PYTHON > WORKBUDDY_PYTHON > DSL_PYTHON > PYTHON > python3/python/py。
5
+ * 语义与 `skills/dsl-card-generator/scripts/lib/python.mjs`(权威实现)对齐;trainer 需要能单独
6
+ * 分发,故不 import 而是照搬。`py` 必须在内:Windows 上 Python 从 Store 安装时往往只有它。
7
+ */
4
8
  function findPython() {
5
- if (process.env.DSL_CARD_PYTHON) return process.env.DSL_CARD_PYTHON;
6
- if (process.env.WORKBUDDY_PYTHON) return process.env.WORKBUDDY_PYTHON;
7
- for (const bin of ['python3', 'python']) {
9
+ for (const key of ['DSL_CARD_PYTHON', 'WORKBUDDY_PYTHON', 'DSL_PYTHON', 'PYTHON']) {
10
+ if (process.env[key]) return process.env[key];
11
+ }
12
+ for (const bin of ['python3', 'python', 'py']) {
8
13
  try {
9
14
  if (spawnSync(bin, ['--version'], { stdio: 'ignore' }).status === 0) return bin;
10
15
  } catch {}
@@ -15,6 +20,7 @@ import fs from 'node:fs'
15
20
  import path from 'node:path'
16
21
  import { pathToFileURL } from 'node:url'
17
22
  import { PNG } from 'pngjs'
23
+ import { sliceNameMatches } from '../../dsl-card-generator/scripts/lib/slice_dedupe.mjs'
18
24
 
19
25
  const repoRoot = path.resolve(import.meta.dirname, '../../..')
20
26
 
@@ -28,7 +34,7 @@ function parseArgs(argv) {
28
34
  const out = {
29
35
  dataset: '', round: '', strategy: 'v3', limit: 0, splits: 'train,dev,eval', only: '',
30
36
  onlyFailed: '', noCorrect: false, focus: '', focusBelow: 0.85, focusLimit: 0,
31
- resume: false, inherit: '', noSummary: false,
37
+ resume: false, inherit: '', noSummary: false, noSink: false, concurrency: 2,
32
38
  }
33
39
  const camel = (raw) => raw.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase())
34
40
  for (let index = 0; index < argv.length; index += 1) {
@@ -86,6 +92,7 @@ function copyReferencedSlices(schema, sourceSlicesDir, targetSlicesDir) {
86
92
  referenced.add(m[1])
87
93
  }
88
94
  let copied = 0
95
+ const files = []
89
96
  const visit = (dir) => {
90
97
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
91
98
  const full = path.join(dir, entry.name)
@@ -93,15 +100,20 @@ function copyReferencedSlices(schema, sourceSlicesDir, targetSlicesDir) {
93
100
  visit(full)
94
101
  continue
95
102
  }
96
- const relative = path.relative(sourceSlicesDir, full).split(path.sep).join('_').replace(/ /g, '_')
97
- if (referenced.has(relative)) {
98
- fs.mkdirSync(targetSlicesDir, { recursive: true })
99
- fs.copyFileSync(full, path.join(targetSlicesDir, relative))
100
- copied += 1
101
- }
103
+ // 扁平名(/ → _);空格等保留字符不再就地替换——匹配统一走 sliceNameMatches
104
+ // (与 schema 引用名同源消毒,r31c 等式)。旧数据集源文件名可带空格/保留字符。
105
+ files.push({ relative: path.relative(sourceSlicesDir, full).split(path.sep).join('_'), full })
102
106
  }
103
107
  }
104
108
  visit(sourceSlicesDir)
109
+ for (const ref of referenced) {
110
+ const hit = files.find((f) => sliceNameMatches(f.relative, ref))
111
+ if (!hit) continue
112
+ // 落盘名 = 引用名(消毒后),URL 才能解析;源名可以带空格/保留字符(历史数据集兼容)
113
+ fs.mkdirSync(targetSlicesDir, { recursive: true })
114
+ fs.copyFileSync(hit.full, path.join(targetSlicesDir, ref))
115
+ copied += 1
116
+ }
105
117
  return copied
106
118
  }
107
119
 
@@ -111,10 +123,10 @@ function runValidator(cardId, cardsDir) {
111
123
  path.join(cardsDir, cardId, 'schema.json'),
112
124
  ], { encoding: 'utf8' })
113
125
  if (result.error) {
114
- return { errors: 1, warnings: 0, output: String(result.error.message || result.error) }
126
+ return { errors: 1, warnings: 0, output: String(result.error.message || result.error), error: String(result.error.message || result.error) }
115
127
  }
116
128
  if (result.status !== 0 && !/\[result\]/.test(`${result.stdout || ''}${result.stderr || ''}`)) {
117
- return { errors: 1, warnings: 0, output: `${result.stdout || ''}${result.stderr || ''}` }
129
+ return { errors: 1, warnings: 0, output: `${result.stdout || ''}${result.stderr || ''}`, error: `validator-exit-${result.status}` }
118
130
  }
119
131
  const output = `${result.stdout || ''}${result.stderr || ''}`
120
132
  const errors = (output.match(/\[ERROR\]/g) || []).length
@@ -152,12 +164,16 @@ async function runMechanicalAutofix(schemaPath) {
152
164
  }
153
165
 
154
166
  import {
155
- imageFidelity, positionScore, textOverflowScore, duplicationScore, fidelityScore, fontMaskWiden,
167
+ positionScore, textOverflowScore, duplicationScore, fidelityScore, fontMaskWiden,
156
168
  loadFontMetrics,
157
169
  evaluatePassGate, attributeFailure, failureSignature, exclusionCoverage,
158
170
  errorKind,
159
171
  } from './lib/scoring.mjs'
160
172
  import { loadRunReports } from './lib/runs.mjs'
173
+ import { estimateReviewTokens, estimateStructuredReviewTokens, createStageTimer } from './lib/telemetry.mjs'
174
+ import { initPixelWorkers, closePixelWorkers, scorePixel, pixelWorkerStatus } from './lib/pixel-worker.mjs'
175
+ import { buildInlineDiagnosis } from './lib/diagnosis.mjs'
176
+ import { canSimulateReject, simulateLayoutCorrection } from './lib/layout-correction-sim.mjs'
161
177
 
162
178
 
163
179
  function sourceChanges() {
@@ -168,6 +184,9 @@ function sourceChanges() {
168
184
  return String(result.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean).sort()
169
185
  }
170
186
 
187
+ // ── Telemetry helpers 在 lib/telemetry.mjs(pngSize / estimateReviewTokens / createStageTimer,
188
+ // tools/tests/trainer-telemetry.test.mjs 直测该模块)──
189
+
171
190
  function textRecall(schema, expectedTexts) {
172
191
  if (!expectedTexts?.length) return null
173
192
  const rendered = new Set()
@@ -298,8 +317,13 @@ function upstreamQuality(spec, card) {
298
317
  ? retainedTextCount / expectedTexts.length
299
318
  : 1
300
319
  const metrics = card.focusMetrics || {}
301
- const structuralFocus = (metrics.richComponentCount || 0) >= 2 &&
302
- ((metrics.textCount || 0) > 0 || (metrics.sliceCount || 0) > 0)
320
+ // 结构聚焦两条路(2026-09-28):
321
+ // a) 常规卡:rich ≥ 2 且有文本或切片;
322
+ // b) 图像卡:切片覆盖 ≥ 60% 卡区(整卡被设计稿导成一张大图,卡内无其他标注)。
323
+ // 该形态解析器兜底产 1 张卡,结构上只有这张切片,按像素/尺寸门正常评分。
324
+ const structuralFocus = ((metrics.richComponentCount || 0) >= 2 &&
325
+ ((metrics.textCount || 0) > 0 || (metrics.sliceCount || 0) > 0)) ||
326
+ (metrics.sliceAreaRatio || 0) >= 0.6
303
327
  const passed = card.trainingEligible !== false &&
304
328
  !!card.image &&
305
329
  structuralFocus &&
@@ -777,46 +801,95 @@ async function main() {
777
801
  console.log(`[inherit] 从 ${path.basename(inheritFrom)} 继承 ${inheritedCases.length} 例,本轮重跑 ${cases.length} 例`)
778
802
  }
779
803
  const sourceBefore = sourceChanges()
804
+ const roundStartedAt = Date.now()
780
805
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
781
- // 共享渲染会话:整轮复用一个 Chrome + HTTP server。
782
- // 此前每卡各启一套(renderCardScreenshot 内部建/拆会话),~1.5-2s/卡全是启停开销。
783
- // 冷帧(会话首帧缺背景图)根因已由 render_card.mjs 的 CDP 网络静默等待闭环,整轮无需暖机帧。
784
- // 渲染会话懒启动:--resume 续跑「已全部完成」的轮次、或焦点集为空时,
785
- // 不必白起一套 Chrome + HTTP server(本机实测 ~2-14s 启停开销)
806
+ // 渲染会话 per-worker 懒启动(2026-09-25 起支持 --concurrency 并发):
807
+ // 每个 worker 持有独立 Chrome + HTTP server(render_card.mjs 的 server.listen(0) 随机端口、
808
+ // mkdtempSync 唯一 userDataDir,多实例天然安全)。worker=1 时行为与旧单会话串行完全一致。
809
+ // 上限 4:Chrome 内存线性涨,且 CPU 侧像素评分(同步循环)会互相阻塞事件循环,再多无益。
786
810
  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
811
+ // 像素评分重计算已下沉 worker_threads(P0-2),主事件循环不再被同步评分阻塞,
812
+ // 故并发上限由 4 提到 6;再高会受 Chrome 内存与 spawnSync 校验/ lint 串行拖累。
813
+ const workerCount = Math.max(1, Math.min(Number(args.concurrency) || 2, cases.length || 1, 6))
814
+ // 像素评分 worker 池:池大小与渲染会话数一致,避免 oversubscription;
815
+ // worker 不可用时 scorePixel 自动同步回退,训练轮不崩。
816
+ if (cases.length) initPixelWorkers(workerCount)
817
+ if (workerCount > 1) console.log(`[concurrency] ${workerCount} 个渲染会话并行`)
818
+ // —— 轮级 telemetry:耗时 / 错误 / token 三类信号,收尾落 reports/telemetry.json ——
819
+ const telemetry = {
820
+ round: args.round,
821
+ startedAt: new Date().toISOString(),
822
+ concurrency: workerCount,
823
+ pixelWorker: pixelWorkerStatus(),
824
+ cases: cases.length,
825
+ scoredCases: 0,
826
+ renderCalls: 0,
827
+ renderRetries: 0,
828
+ sessionRebuilds: 0,
829
+ stageMs: {},
830
+ stageErrors: {},
831
+ caseErrors: 0,
832
+ usage: null,
833
+ reviewTokensEst: { total: 0, cases: 0 },
791
834
  }
792
- const renderOnce = async (id, w, h) => {
793
- let res = await (await ensureRenderSession()).render({
794
- id, width: w, captureHeight: h, format: 'png', captureElements: true,
795
- })
796
- if (!res.ok && /session-closed|Target|WebSocket|Chrome|浏览器|超时/i.test(String(res.error || '') + String(res.message || ''))) {
797
- // Chrome 崩溃/断连:重建会话再试一次,避免整轮作废
798
- try { await renderSession.close() } catch {}
799
- renderSession = await createRenderSession({ cardsRoot: cardsDir })
800
- res = await renderSession.render({
835
+ const accumulateStage = (name, ms) => {
836
+ telemetry.stageMs[name] = (telemetry.stageMs[name] || 0) + ms
837
+ }
838
+ const recordStageError = (stage, message) => {
839
+ telemetry.stageErrors[stage] = (telemetry.stageErrors[stage] || 0) + 1
840
+ return { stage, message: String(message).slice(0, 300) }
841
+ }
842
+ const makeWorkerSession = () => {
843
+ const state = { session: null }
844
+ const ensureRenderSession = async () => {
845
+ if (!state.session) state.session = await createRenderSession({ cardsRoot: cardsDir })
846
+ return state.session
847
+ }
848
+ const renderOnce = async (id, w, h) => {
849
+ telemetry.renderCalls += 1
850
+ let res = await (await ensureRenderSession()).render({
801
851
  id, width: w, captureHeight: h, format: 'png', captureElements: true,
802
852
  })
853
+ if (!res.ok && /session-closed|Target|WebSocket|Chrome|浏览器|超时/i.test(String(res.error || '') + String(res.message || ''))) {
854
+ // Chrome 崩溃/断连:重建会话再试一次,避免整轮作废
855
+ telemetry.renderRetries += 1
856
+ telemetry.sessionRebuilds += 1
857
+ try { await state.session.close() } catch {}
858
+ state.session = await createRenderSession({ cardsRoot: cardsDir })
859
+ res = await state.session.render({
860
+ id, width: w, captureHeight: h, format: 'png', captureElements: true,
861
+ })
862
+ }
863
+ return res
803
864
  }
804
- return res
865
+ return { state, ensureRenderSession, renderOnce }
805
866
  }
806
- try {
807
- for (const [index, testCase] of cases.entries()) {
867
+ const liveSessions = []
868
+ const processCase = async (testCase, index, worker) => {
808
869
  const started = Date.now()
809
870
  const cardId = `${args.round}__${testCase.caseId}`
871
+ const timer = createStageTimer()
872
+ const stageErrors = []
873
+ // try 外提升:catch / 收尾 telemetry 汇总也要读(案例中途失败时可能是 null)
874
+ let designBuf = null
875
+ let renderBuf = null
876
+ let designCard = null
877
+ // P1-2:失败案例结构化诊断(在环归因证据包),try 内算出、收尾 telemetry / semantic-review 复用
878
+ let _diag = null
810
879
  const report = {
811
880
  caseId: testCase.caseId, cardId, source: testCase.source,
812
881
  artboardName: testCase.artboardName, split: testCase.split,
813
882
  validate: { errors: 0 },
814
883
  lint: { errors: 0 },
884
+ stageErrors,
885
+ timings: timer.timings,
815
886
  }
816
887
  try {
888
+ // 设计稿只读一次(校正守卫 / 最终像素评分 / token 估算三处复用,省重复磁盘 IO)
889
+ designBuf = timer.time('parse', () => fs.readFileSync(testCase.input.designImage))
817
890
  const spec = JSON.parse(fs.readFileSync(testCase.input.specJson, 'utf8'))
818
891
  const cardPosition = Number(testCase.cardIndex) || 0
819
- const designCard = (spec.cards || [])[cardPosition]
892
+ designCard = (spec.cards || [])[cardPosition]
820
893
  if (!designCard) throw new Error('focus-card-missing-or-skipped')
821
894
  // 纯文本区域(对话气泡/回复段落)不是卡片:无富组件(壳/切图/符号)且 ≥3 文本块。
822
895
  // 用户规则:对话气泡一律跳过,不计入训练指标(避免污染还原度统计与生成器归因)。
@@ -824,9 +897,9 @@ async function main() {
824
897
  if ((_fm.richComponentCount || 0) === 0 && (_fm.textCount || 0) >= 3) {
825
898
  console.log('[SKIP] conversation-text ' + testCase.caseId)
826
899
  skippedConversationTexts.push({ caseId: testCase.caseId, source: testCase.source, artboardName: testCase.artboardName })
827
- continue
900
+ return
828
901
  }
829
- report.upstream = upstreamQuality(spec, designCard)
902
+ report.upstream = timer.time('upstream', () => upstreamQuality(spec, designCard))
830
903
  const _cardRectForExcl = designCard.rect || {}
831
904
  const _exclCardW = Math.max(1, _cardRectForExcl.width || 1)
832
905
  const _exclCardH = Math.max(1, _cardRectForExcl.height || 1)
@@ -879,25 +952,38 @@ async function main() {
879
952
  }
880
953
  const renderWidth = Math.round(designCard?.rect?.width || spec.size?.width || 375)
881
954
  const renderHeight = Math.round(designCard?.rect?.height || spec.size?.height || 0) || null
882
- const generated = generate(spec, {
955
+ const generated = timer.time('generate', () => generate(spec, {
883
956
  id: cardId,
884
957
  name: testCase.artboardName,
885
958
  cardIndex: testCase.cardIndex || 0,
886
959
  designImage: testCase.input.designImage,
887
- })
960
+ }))
961
+ // LLM usage 挂钩(接口预留):v3 纯规则引擎当前恒无 usage;生成链路未来 LLM 化
962
+ // (meta.usage = { inputTokens, outputTokens, calls })后此处自动累计,无需再改 trainer。
963
+ if (generated.meta?.usage) {
964
+ report.usage = generated.meta.usage
965
+ telemetry.usage = telemetry.usage || { inputTokens: 0, outputTokens: 0, calls: 0 }
966
+ telemetry.usage.inputTokens += generated.meta.usage.inputTokens || 0
967
+ telemetry.usage.outputTokens += generated.meta.usage.outputTokens || 0
968
+ telemetry.usage.calls += generated.meta.usage.calls || 1
969
+ }
888
970
  const cardDir = path.join(cardsDir, cardId)
889
971
  fs.mkdirSync(cardDir, { recursive: true })
890
972
  fs.writeFileSync(path.join(cardDir, 'schema.json'), JSON.stringify(generated.schema, null, 2) + '\n')
891
973
  fs.writeFileSync(path.join(cardDir, 'data.json'), JSON.stringify(generated.data, null, 2) + '\n')
892
- report.repair = runMechanicalAutofix(path.join(cardDir, 'schema.json'))
893
- report.sliceCount = copyReferencedSlices(generated.schema, testCase.input.slicesDir, slicesDir)
894
- report.validate = runValidator(cardId, cardsDir)
895
- report.lint = runLinter(cardId, cardsDir, slicesDir)
896
- let rendered = await renderOnce(cardId, renderWidth, renderHeight)
974
+ report.repair = timer.time('autofix', () => runMechanicalAutofix(path.join(cardDir, 'schema.json')))
975
+ report.sliceCount = timer.time('slices', () => copyReferencedSlices(generated.schema, testCase.input.slicesDir, slicesDir))
976
+ report.validate = timer.time('validate', () => runValidator(cardId, cardsDir))
977
+ if (report.validate.error) stageErrors.push(recordStageError('validate', report.validate.error))
978
+ report.lint = timer.time('lint', () => runLinter(cardId, cardsDir, slicesDir))
979
+ if (report.lint.error) stageErrors.push(recordStageError('lint', report.lint.error))
980
+ let rendered = await timer.timeAsync('render', () => worker.renderOnce(cardId, renderWidth, renderHeight))
897
981
  if (!rendered.ok) {
898
982
  report.renderRetry = { firstError: rendered.error || rendered.message }
983
+ stageErrors.push(recordStageError('render', rendered.message || rendered.error))
984
+ telemetry.renderRetries += 1
899
985
  await sleep(500)
900
- rendered = await renderOnce(cardId, renderWidth, renderHeight)
986
+ rendered = await timer.timeAsync('render', () => worker.renderOnce(cardId, renderWidth, renderHeight))
901
987
  }
902
988
  if (!rendered.ok) throw new Error(rendered.message || rendered.error || 'render failed')
903
989
 
@@ -956,23 +1042,18 @@ async function main() {
956
1042
  const cardColForSim = generated.schema.view?.components?.[0]
957
1043
  const corrected = !args.noCorrect && correctCardLayout(generated.schema, rendered.elementBoxes, _deduped, _cr, _adjustments, _sim)
958
1044
  // ── 模拟否决快路径 ──
959
- // 纯纵向 paddingTop 校正在流式 Column 中是精确的刚体平移:
960
- // 子树盒 +delta,后续兄弟盒 +累计 delta。模拟 position 分不提升 ⇒ 真实渲染
961
- // 必然不提升,可直接回退并跳过二次渲染(每轮省 ~20 次渲染)。
1045
+ // 纵向 paddingTop(Y)与横向 paddingLeft(X)校正,在各自轴向上都是精确的刚体平移:
1046
+ // Y:子树盒 +delta,后续兄弟盒 +累计 delta(纵向流式堆叠)。
1047
+ // X:子树盒 +delta,不跨兄弟级联(纵向流里每子项横向独立,各自 paddingLeft)。
1048
+ // 详见 lib/layout-correction-sim.mjs。模拟 position 分不提升 ⇒ 真实渲染必然不提升,
1049
+ // 可直接回退并跳过二次渲染(每轮省 ~20 次渲染)。
1050
+ // 原逻辑仅准入纯 Y 调整,含 X 调整必二次渲染——现放宽到 Y/X 且 rigidSafe 均成立。
962
1051
  // CORR_SIM_VALIDATE=1 时模拟与真实渲染双跑对账,用于验证模拟器保真。
963
1052
  let _simRejectPos = null
964
1053
  let _simLastPos = null
965
- if (corrected && _adjustments.length
966
- && _adjustments.every((a) => a.axis === 'y' && a.rigidSafe !== false)
967
- && _sim.cidToChild
968
- && ((cardColForSim?.slots?.[0]?.components) || []).every((c) => c.name !== 'AbsoluteLayout')) {
969
- const _deltas = new Map()
970
- for (const a of _adjustments) _deltas.set(a.childIndex, (_deltas.get(a.childIndex) || 0) + (a.to - a.from))
971
- const _shiftFor = (idx) => { let acc = 0; for (const [i, d] of _deltas) if (i <= idx) acc += d; return acc }
972
- const _shifted = (rendered.elementBoxes || []).map((b) => {
973
- const idx = _sim.cidToChild.get(b.cid)
974
- return idx == null ? b : { ...b, y: b.y + _shiftFor(idx) }
975
- })
1054
+ if (corrected && canSimulateReject(_adjustments, _sim.cidToChild, cardColForSim?.slots?.[0]?.components)) {
1055
+ const { shifted: _shifted } = simulateLayoutCorrection(
1056
+ rendered.elementBoxes || [], _adjustments, _sim.cidToChild)
976
1057
  const _simPos = positionScore(_deduped,
977
1058
  _shifted.filter((el) => el.text && ['Text', 'Input', 'Button'].includes(el.cls)),
978
1059
  { cardW: _cr.width || renderWidth, cardH: _cr.height || renderHeight, specShapes: _specShapes })
@@ -989,14 +1070,15 @@ async function main() {
989
1070
  }
990
1071
  } else if (corrected) {
991
1072
  fs.writeFileSync(path.join(cardDir, 'schema.json'), JSON.stringify(generated.schema, null, 2) + '\n')
992
- const correctedRendered = await renderOnce(cardId, renderWidth, renderHeight)
1073
+ const correctedRendered = await timer.timeAsync('reRender', () => worker.renderOnce(cardId, renderWidth, renderHeight))
993
1074
  if (correctedRendered.ok) {
994
1075
  const _correctedPos = positionScore(_deduped,
995
1076
  (correctedRendered.elementBoxes || []).filter((el) => el.text && ['Text', 'Input', 'Button'].includes(el.cls)),
996
1077
  { cardW: _cr.width || renderWidth, cardH: _cr.height || renderHeight, specShapes: _specShapes })
997
- const _designBuf0 = fs.readFileSync(testCase.input.designImage)
998
- const _pixelBefore = imageFidelity(_designBuf0, Buffer.from(_renderedBackup.base64, 'base64'), exclusionRects)
999
- const _pixelAfter = imageFidelity(_designBuf0, Buffer.from(correctedRendered.base64, 'base64'), exclusionRects)
1078
+ const _pixelBefore = await timer.timeAsync('pixel', () =>
1079
+ scorePixel(designBuf, Buffer.from(_renderedBackup.base64, 'base64'), exclusionRects, [], { includeAlignDy: false }))
1080
+ const _pixelAfter = await timer.timeAsync('pixel', () =>
1081
+ scorePixel(designBuf, Buffer.from(correctedRendered.base64, 'base64'), exclusionRects, [], { includeAlignDy: false }))
1000
1082
  const _pixelOk = _pixelAfter.ratio <= _pixelBefore.ratio + 0.005
1001
1083
  if (process.env.CORR_DEBUG) console.error('[corr] initialPos=' + _initialPos.overall.toFixed(4) + ' correctedPos=' + _correctedPos.overall.toFixed(4) + ' pixel ' + _pixelBefore.ratio.toFixed(4) + '→' + _pixelAfter.ratio.toFixed(4))
1002
1084
  if (process.env.CORR_SIM_VALIDATE && _simLastPos != null) {
@@ -1025,7 +1107,8 @@ async function main() {
1025
1107
  }
1026
1108
 
1027
1109
  const renderPath = path.join(reportDir, `${testCase.caseId}.render.png`)
1028
- fs.writeFileSync(renderPath, Buffer.from(rendered.base64, 'base64'))
1110
+ renderBuf = Buffer.from(rendered.base64, 'base64')
1111
+ fs.writeFileSync(renderPath, renderBuf)
1029
1112
  // 同步保存设计稿参考图到 reports 目录,便于 summary.md 并排对比
1030
1113
  try { fs.copyFileSync(testCase.input.designImage, path.join(reportDir, testCase.caseId + '.design.png')) } catch {}
1031
1114
  // ── 裁切兜底切图掩膜:导出素材缺失 → 只能从画板裁切(__artboard_crop__*)──
@@ -1037,8 +1120,8 @@ async function main() {
1037
1120
  for (const element of designCard.elements || []) {
1038
1121
  const defect = element.annotationDefect
1039
1122
  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
1123
+ if (!defect || (defect.type !== 'missing-slice' && defect.type !== 'slice-visual-mismatch') || !cropFile) continue
1124
+ if (!(schemaJson.includes(cropFile) || schemaJson.includes(cropFile.replace(/\s+/g, '-')))) continue
1042
1125
  const pad = 1.5
1043
1126
  const base = toExclRect(element, 1)
1044
1127
  exclusionRects.push({
@@ -1050,12 +1133,13 @@ async function main() {
1050
1133
  })
1051
1134
  }
1052
1135
  }
1053
- report.pixel = imageFidelity(
1054
- fs.readFileSync(testCase.input.designImage),
1055
- fs.readFileSync(renderPath),
1136
+ report.pixel = await timer.timeAsync('pixel', () => scorePixel(
1137
+ designBuf,
1138
+ renderBuf,
1056
1139
  exclusionRects,
1057
1140
  fontRects,
1058
- )
1141
+ { includeAlignDy: true },
1142
+ ))
1059
1143
  report.pixelExclusions = [...exclusionRects, ...fontRects]
1060
1144
  // 掩膜覆盖率:掩膜是「不计分」而不是「还原对」。覆盖率过高时通过结论不可信,
1061
1145
  // score_round 会把高掩膜案例单列出来要求人工复核(防止靠掩膜虚高过门)。
@@ -1113,22 +1197,24 @@ async function main() {
1113
1197
  return { x: (r.x || 0) - (cardRect.x || 0), y: (r.y || 0) - (cardRect.y || 0), w: r.width || 0, h: r.height || 0 }
1114
1198
  })
1115
1199
  .filter((el) => el.w > 0 && el.h > 0)
1116
- report.position = positionScore(specTexts, renderedTexts, {
1117
- cardW: cardRect.width || renderWidth,
1118
- cardH: cardRect.height || renderHeight,
1119
- specShapes: _specShapesFinal,
1200
+ timer.time('score', () => {
1201
+ report.position = positionScore(specTexts, renderedTexts, {
1202
+ cardW: cardRect.width || renderWidth,
1203
+ cardH: cardRect.height || renderHeight,
1204
+ specShapes: _specShapesFinal,
1205
+ })
1206
+ report.textOverflow = textOverflowScore(rendered.elementBoxes || [])
1207
+ // 语义重复渲染检测(重影):同一文案渲染次数不得超出标注次数
1208
+ report.duplication = duplicationScore(specTexts, renderedTexts)
1209
+ report.fidelity = fidelityScore(
1210
+ report.pixel,
1211
+ expectedComponents(designCard),
1212
+ schemaComponents(generated.schema),
1213
+ report.textRecall,
1214
+ report.position,
1215
+ report.textOverflow,
1216
+ )
1120
1217
  })
1121
- report.textOverflow = textOverflowScore(rendered.elementBoxes || [])
1122
- // 语义重复渲染检测(重影):同一文案渲染次数不得超出标注次数
1123
- report.duplication = duplicationScore(specTexts, renderedTexts)
1124
- report.fidelity = fidelityScore(
1125
- report.pixel,
1126
- expectedComponents(designCard),
1127
- schemaComponents(generated.schema),
1128
- report.textRecall,
1129
- report.position,
1130
- report.textOverflow,
1131
- )
1132
1218
  // 单案例门以 lib/scoring.mjs 的 PASS_GATE 为唯一来源(SKILL.md 验收表、score_round 同源,
1133
1219
  // 文档数字由 tools/tests/scoring.test.mjs 反向断言),并把未达标维度按缺口落盘供聚类归因
1134
1220
  const gate = evaluatePassGate(report)
@@ -1140,9 +1226,16 @@ async function main() {
1140
1226
  report.failureClass = attribution.failureClass
1141
1227
  report.failureReason = attribution.reason
1142
1228
  }
1229
+ // P1-2:失败案例在环归因用的轻量结构化证据包(零额外渲染,复用 report / 设计稿 buffer /
1230
+ // 标注文本 / 渲染盒),塞进 semantic-review 队列供 Agent 消费,避免直接读双 PNG 烧图像 token。
1231
+ if (report.pixel?.comparable) {
1232
+ _diag = buildInlineDiagnosis(report, { designBuf, renderBuf, specTexts, renderedTexts })
1233
+ }
1143
1234
  } catch (error) {
1144
1235
  report.error = String(error.message || error)
1145
1236
  report.passed = false
1237
+ stageErrors.push(recordStageError('case', report.error))
1238
+ telemetry.caseErrors += 1
1146
1239
  report.failureSignature = failureSignature(report)
1147
1240
  const attribution = attributeFailure(report)
1148
1241
  if (attribution) {
@@ -1151,6 +1244,21 @@ async function main() {
1151
1244
  }
1152
1245
  }
1153
1246
  report.durationMs = Date.now() - started
1247
+ // —— telemetry 汇总(阶段耗时 / 失败案例归因证据包 token 估算)——
1248
+ telemetry.scoredCases += 1
1249
+ for (const [stage, ms] of Object.entries(timer.timings)) accumulateStage(stage, ms)
1250
+ if (!report.passed) {
1251
+ const specChars = (() => { try { return JSON.stringify(designCard ?? {}).length } catch { return 0 } })()
1252
+ // P1-2:失败案例在环归因默认消费结构化 diagnosis(见下方 semanticReviewQueue 的 diagnosis
1253
+ // 字段),不再强制读取双 PNG;故 token 估算以结构化证据包为主,旧双图估算留存
1254
+ // reviewTokensEstImage 供对照。两者均不参与任何门禁,仅作预算参考。
1255
+ const estImage = estimateReviewTokens({ designBuffer: designBuf, renderBuffer: renderBuf, specChars })
1256
+ const est = _diag ? estimateStructuredReviewTokens(_diag) : estImage
1257
+ report.reviewTokensEst = est
1258
+ report.reviewTokensEstImage = estImage
1259
+ telemetry.reviewTokensEst.total += est.total
1260
+ telemetry.reviewTokensEst.cases += 1
1261
+ }
1154
1262
  fs.writeFileSync(path.join(reportDir, `${testCase.caseId}.json`),
1155
1263
  JSON.stringify(report, null, 2) + '\n')
1156
1264
  if (!report.passed) reviewQueue.push({
@@ -1174,6 +1282,10 @@ async function main() {
1174
1282
  color: report.pixel.color ?? null,
1175
1283
  components: report.fidelity?.components ?? null,
1176
1284
  },
1285
+ // P1-2:结构化证据包(零额外渲染,复用 report/设计稿 buffer/标注文本/渲染盒)。
1286
+ // 在环 Agent 据此做聚类与归因,避免直接加载两张 PNG 烧图像 token。
1287
+ // provenance(cid→生成器源码行)按需由 diagnose_case.mjs 深度补全。
1288
+ diagnosis: _diag,
1177
1289
  evidence: {
1178
1290
  designImage: testCase.input.designImage,
1179
1291
  renderImage: path.join(reportDir, `${testCase.caseId}.render.png`),
@@ -1181,14 +1293,44 @@ async function main() {
1181
1293
  cardIndex: testCase.cardIndex || 0,
1182
1294
  schema: path.join(cardsDir, cardId, 'schema.json'),
1183
1295
  },
1184
- instruction: 'Classify card boundary and each changed cluster as static-text/display-value/input/action/navigation/list/media/decoration; attribute one primary failure class; propose only a generic UI or DSL rule.',
1296
+ instruction: '优先消费结构化「diagnosis」字段做聚类与归因:gateFailures(按 deficit 降序的未达标维度)、deviations(文本 dy/dx 偏差表)、hotBands(像素热区带)、metrics、hints(已给失败类建议)。仅当结构化证据不足以定位根因时,才打开 evidence.images 两张 PNG 复核。归因只输出通用 UI/DSL 规则,禁止硬编码特定卡片文案或业务实体名称。',
1185
1297
  })
1186
- console.log(`[${index + 1}/${cases.length}] ${report.passed ? 'PASS' : 'FAIL'} ${testCase.caseId}`)
1298
+ console.log(`[${index + 1}/${cases.length}]${workerCount > 1 ? ` (w${worker.id})` : ''} ${report.passed ? 'PASS' : 'FAIL'} ${testCase.caseId} ${report.durationMs}ms`)
1187
1299
  }
1300
+ // worker 池:cursor 单调分派案例,每 worker 持独立渲染会话;单案例异常已在 processCase 内消化
1301
+ try {
1302
+ let cursor = 0
1303
+ const workerMain = async (workerId) => {
1304
+ const worker = { id: workerId + 1, ...makeWorkerSession() }
1305
+ liveSessions.push(worker.state)
1306
+ while (cursor < cases.length) {
1307
+ const index = cursor++
1308
+ await processCase(cases[index], index, worker)
1309
+ }
1310
+ try { await worker.state.session?.close() } catch {}
1311
+ }
1312
+ await Promise.all(Array.from({ length: workerCount }, (_, i) => workerMain(i)))
1188
1313
  } finally {
1189
- // 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留
1190
- try { await renderSession?.close() } catch {}
1314
+ // 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留(worker 异常中断时兜底)
1315
+ await Promise.all(liveSessions.map(async (s) => { try { await s.session?.close() } catch {} }))
1316
+ try { await closePixelWorkers() } catch {}
1191
1317
  }
1318
+ // —— 轮级 telemetry 落盘:错误 / 耗时 / token 三类信号的唯一轮级出口 ——
1319
+ const wallMs = Date.now() - roundStartedAt
1320
+ const stageAverages = Object.fromEntries(
1321
+ Object.entries(telemetry.stageMs)
1322
+ .map(([stage, ms]) => [stage, Math.round(ms / Math.max(telemetry.scoredCases, 1))]))
1323
+ const telemetryOut = {
1324
+ ...telemetry,
1325
+ finishedAt: new Date().toISOString(),
1326
+ wallMs,
1327
+ stageAverages,
1328
+ wallSecondsPerCase: telemetry.scoredCases ? Number((wallMs / telemetry.scoredCases / 1000).toFixed(2)) : null,
1329
+ casesPerMinute: wallMs > 0 && telemetry.scoredCases ? Number((telemetry.scoredCases / (wallMs / 60000)).toFixed(2)) : null,
1330
+ reviewTokensNote: '失败案例 Agent 在环归因证据包的 input token 估算(图像面积/750 + spec 文本/4 chars);v3 生成器为纯规则引擎无 LLM 调用,usage 字段为生成链路 LLM 化后预留',
1331
+ }
1332
+ fs.writeFileSync(path.join(reportDir, 'telemetry.json'), JSON.stringify(telemetryOut, null, 2) + '\n')
1333
+ console.log(`[telemetry] ${telemetry.scoredCases} 例 / ${workerCount} 并发 / 墙钟 ${(wallMs / 1000).toFixed(1)}s(${telemetryOut.casesPerMinute ?? '-'} 例/分钟 · 渲染 ${telemetry.renderCalls} 次${telemetry.renderRetries ? ` · 重试 ${telemetry.renderRetries}` : ''}${telemetry.caseErrors ? ` · 案例错误 ${telemetry.caseErrors}` : ''})`)
1192
1334
  fs.writeFileSync(path.join(reportDir, 'review-queue.jsonl'),
1193
1335
  reviewQueue.map((item) => JSON.stringify(item)).join('\n') + (reviewQueue.length ? '\n' : ''))
1194
1336
  fs.writeFileSync(path.join(reportDir, 'semantic-review.jsonl'),
@@ -1206,6 +1348,58 @@ async function main() {
1206
1348
  if (scored.status !== 0) console.warn(`[WARN] score_round 退出码 ${scored.status}(summary 可能未写出)`)
1207
1349
  }
1208
1350
 
1351
+
1352
+ // —— 轮后落盘:plan_next_round + lesson 草稿自动沉淀 + 训练总结 ——
1353
+ if (!args.noSink) {
1354
+ const planScript = path.join(import.meta.dirname, 'plan_next_round.mjs')
1355
+ const planArgs = [planScript, '--run', runDir, '--history', 'auto']
1356
+ if (args.anchor) planArgs.push('--anchor', String(args.anchor))
1357
+ const planned = spawnSync(process.execPath, planArgs, { encoding: 'utf8', cwd: repoRoot })
1358
+ const planOutput = `${planned.stdout || ''}${planned.stderr || ''}`.trim()
1359
+ if (planOutput) console.log(planOutput)
1360
+ if (planned.status !== 0) console.warn(`[WARN] plan_next_round 退出码 ${planned.status}(落盘可能不完整)`)
1361
+
1362
+ // 自动沉淀 lesson 草稿到 training/lessons/lessons.jsonl(按 signature 去重)
1363
+ const draftsFile = path.join(reportDir, 'lesson-drafts.jsonl')
1364
+ const lessonsFile = path.join(repoRoot, 'training', 'lessons', 'lessons.jsonl')
1365
+ if (fs.existsSync(draftsFile)) {
1366
+ const drafts = fs.readFileSync(draftsFile, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l))
1367
+ if (drafts.length) {
1368
+ const existingSignatures = new Set()
1369
+ if (fs.existsSync(lessonsFile)) {
1370
+ for (const line of fs.readFileSync(lessonsFile, 'utf8').split('\n').filter(Boolean)) {
1371
+ try { const row = JSON.parse(line); if (row.signature) existingSignatures.add(row.signature.toLowerCase()) } catch {}
1372
+ }
1373
+ }
1374
+ const newLessons = drafts.filter((d) => d.signature && !existingSignatures.has(d.signature.toLowerCase()))
1375
+ if (newLessons.length) {
1376
+ fs.appendFileSync(lessonsFile, newLessons.map((l) => JSON.stringify(l)).join('\n') + '\n')
1377
+ console.log(`[sink] ${newLessons.length} 条新 lesson 沉淀到 ${path.relative(repoRoot, lessonsFile)}`)
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // 回灌规则到目标 skill 文档(lessons → generation_pitfalls.md / SKILL.md 等)
1383
+ const sinkScript = path.join(import.meta.dirname, 'sink_rules.mjs')
1384
+ const sunk = spawnSync(process.execPath, [sinkScript], { encoding: 'utf8', cwd: repoRoot })
1385
+ const sinkOutput = `${sunk.stdout || ''}${sunk.stderr || ''}`.trim()
1386
+ if (sinkOutput) console.log(sinkOutput.split('\n').map((l) => l.startsWith('[') ? l : null).filter(Boolean).join('\n'))
1387
+ if (sunk.status !== 0) console.warn(`[WARN] sink_rules 退出码 ${sunk.status}`)
1388
+
1389
+ // 写 TRAINING-SUMMARY.md(人读训练总结)
1390
+ const summaryFile = path.join(reportDir, 'summary.json')
1391
+ let summaryData = null
1392
+ try { summaryData = JSON.parse(fs.readFileSync(summaryFile, 'utf8')) } catch {}
1393
+ if (summaryData) {
1394
+ const pct = (v) => v == null ? 'N/A' : (v * 100).toFixed(1) + '%'
1395
+ const roundName = path.basename(runDir)
1396
+ const passed = summaryData.roundGatePassed
1397
+ const md = `# 训练总结 — ${roundName}\n\n## 门禁结果:${summaryData.roundGatePassed ? 'PASS' : 'FAIL'}\n\n| 维度 | 分数 | 门禁 | 状态 |\n|------|------|------|------|\n| 还原度 | ${pct(summaryData.avgFidelity)} | >=85% | ${passed ? 'PASS' : 'FAIL'} |\n| 像素差异 | ${pct(summaryData.avgPixelDiff)} | <=8% | ${passed ? 'PASS' : 'FAIL'} |\n| 位置精度 | ${pct(summaryData.avgPositionFidelity)} | >=80% | ${passed ? 'PASS' : 'FAIL'} |\n| 结构还原 | ${pct(summaryData.avgStructureFidelity)} | >=85% | ${passed ? 'PASS' : 'FAIL'} |\n| 通过率 | ${pct(summaryData.passRate)} | >=95% | ${passed ? 'PASS' : 'FAIL'} |\n\n- 案例: ${summaryData.total} / 通过: ${summaryData.passed} / 失败: ${summaryData.total - summaryData.passed}\n- 渲染调用: ${summaryData.telemetry?.renderCalls ?? 'N/A'}\n- 墙钟: ${((summaryData.telemetry?.wallMs ?? 0) / 1000).toFixed(1)}s\n- 失败签名: ${(summaryData.failureSignatures || []).map((s) => s.signature + 'x' + s.count).join(', ') || '无'}\n\n## 产物路径\n- 训练产物: \`training/runs/${roundName}/\`\n- 诊断报告: \`training/runs/${roundName}/reports/\`\n- 下一轮计划: \`training/runs/${roundName}/reports/next-round-plan.json\`\n- Lesson 草稿: \`training/runs/${roundName}/reports/lesson-drafts.jsonl\`\n`
1398
+ fs.writeFileSync(path.join(runDir, 'TRAINING-SUMMARY.md'), md)
1399
+ console.log(`[sink] 训练总结写入 ${path.relative(repoRoot, path.join(runDir, 'TRAINING-SUMMARY.md'))}`)
1400
+ }
1401
+ }
1402
+
1209
1403
  const newSourceChanges = sourceChanges().filter((line) => !sourceBefore.includes(line))
1210
1404
  if (newSourceChanges.length) {
1211
1405
  throw new Error(`SANDBOX_VIOLATION: training wrote into src/: ${newSourceChanges.join(', ')}`)