cardbuddy 0.14.3 → 0.16.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 (108) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  3. package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  7. package/dist/vendored/mcp/client.mjs +9 -2
  8. package/dist/vendored/server/core/ai-chat.js +1702 -134
  9. package/dist/vendored/server/core/browser.js +12 -3
  10. package/dist/vendored/server/core/cardFiles.js +3 -0
  11. package/dist/vendored/server/core/cardHistory.js +1 -0
  12. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  13. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  14. package/dist/vendored/server/core/cardSave.js +1 -0
  15. package/dist/vendored/server/core/cardSync.js +3 -0
  16. package/dist/vendored/server/core/cardValidate.js +16 -5
  17. package/dist/vendored/server/core/codeBridge.js +5 -1
  18. package/dist/vendored/server/core/logStream.js +1 -0
  19. package/dist/vendored/server/core/sliceStatic.js +24 -4
  20. package/dist/vendored/server/core/sliceUpload.js +1 -0
  21. package/dist/vendored/server/core/turnJournal.js +6 -0
  22. package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
  23. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  24. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  26. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  34. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
  37. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  38. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  39. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
  40. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
  41. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  43. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
  44. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  45. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
  46. package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
  47. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
  50. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
  51. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  52. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  53. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
  54. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
  55. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  56. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  57. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  58. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
  59. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
  60. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  61. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  62. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  63. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  65. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  66. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  67. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  68. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  69. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  70. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  71. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  72. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  73. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  74. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  75. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  76. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  77. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  78. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  79. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  80. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  81. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
  82. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
  83. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
  85. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
  86. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  88. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  89. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
  90. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
  91. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  92. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  94. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  95. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  96. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
  97. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
  102. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
  103. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
  106. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
  107. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  108. 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()
@@ -777,46 +796,95 @@ async function main() {
777
796
  console.log(`[inherit] 从 ${path.basename(inheritFrom)} 继承 ${inheritedCases.length} 例,本轮重跑 ${cases.length} 例`)
778
797
  }
779
798
  const sourceBefore = sourceChanges()
799
+ const roundStartedAt = Date.now()
780
800
  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 启停开销)
801
+ // 渲染会话 per-worker 懒启动(2026-09-25 起支持 --concurrency 并发):
802
+ // 每个 worker 持有独立 Chrome + HTTP server(render_card.mjs 的 server.listen(0) 随机端口、
803
+ // mkdtempSync 唯一 userDataDir,多实例天然安全)。worker=1 时行为与旧单会话串行完全一致。
804
+ // 上限 4:Chrome 内存线性涨,且 CPU 侧像素评分(同步循环)会互相阻塞事件循环,再多无益。
786
805
  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
806
+ // 像素评分重计算已下沉 worker_threads(P0-2),主事件循环不再被同步评分阻塞,
807
+ // 故并发上限由 4 提到 6;再高会受 Chrome 内存与 spawnSync 校验/ lint 串行拖累。
808
+ const workerCount = Math.max(1, Math.min(Number(args.concurrency) || 2, cases.length || 1, 6))
809
+ // 像素评分 worker 池:池大小与渲染会话数一致,避免 oversubscription;
810
+ // worker 不可用时 scorePixel 自动同步回退,训练轮不崩。
811
+ if (cases.length) initPixelWorkers(workerCount)
812
+ if (workerCount > 1) console.log(`[concurrency] ${workerCount} 个渲染会话并行`)
813
+ // —— 轮级 telemetry:耗时 / 错误 / token 三类信号,收尾落 reports/telemetry.json ——
814
+ const telemetry = {
815
+ round: args.round,
816
+ startedAt: new Date().toISOString(),
817
+ concurrency: workerCount,
818
+ pixelWorker: pixelWorkerStatus(),
819
+ cases: cases.length,
820
+ scoredCases: 0,
821
+ renderCalls: 0,
822
+ renderRetries: 0,
823
+ sessionRebuilds: 0,
824
+ stageMs: {},
825
+ stageErrors: {},
826
+ caseErrors: 0,
827
+ usage: null,
828
+ reviewTokensEst: { total: 0, cases: 0 },
791
829
  }
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({
830
+ const accumulateStage = (name, ms) => {
831
+ telemetry.stageMs[name] = (telemetry.stageMs[name] || 0) + ms
832
+ }
833
+ const recordStageError = (stage, message) => {
834
+ telemetry.stageErrors[stage] = (telemetry.stageErrors[stage] || 0) + 1
835
+ return { stage, message: String(message).slice(0, 300) }
836
+ }
837
+ const makeWorkerSession = () => {
838
+ const state = { session: null }
839
+ const ensureRenderSession = async () => {
840
+ if (!state.session) state.session = await createRenderSession({ cardsRoot: cardsDir })
841
+ return state.session
842
+ }
843
+ const renderOnce = async (id, w, h) => {
844
+ telemetry.renderCalls += 1
845
+ let res = await (await ensureRenderSession()).render({
801
846
  id, width: w, captureHeight: h, format: 'png', captureElements: true,
802
847
  })
848
+ if (!res.ok && /session-closed|Target|WebSocket|Chrome|浏览器|超时/i.test(String(res.error || '') + String(res.message || ''))) {
849
+ // Chrome 崩溃/断连:重建会话再试一次,避免整轮作废
850
+ telemetry.renderRetries += 1
851
+ telemetry.sessionRebuilds += 1
852
+ try { await state.session.close() } catch {}
853
+ state.session = await createRenderSession({ cardsRoot: cardsDir })
854
+ res = await state.session.render({
855
+ id, width: w, captureHeight: h, format: 'png', captureElements: true,
856
+ })
857
+ }
858
+ return res
803
859
  }
804
- return res
860
+ return { state, ensureRenderSession, renderOnce }
805
861
  }
806
- try {
807
- for (const [index, testCase] of cases.entries()) {
862
+ const liveSessions = []
863
+ const processCase = async (testCase, index, worker) => {
808
864
  const started = Date.now()
809
865
  const cardId = `${args.round}__${testCase.caseId}`
866
+ const timer = createStageTimer()
867
+ const stageErrors = []
868
+ // try 外提升:catch / 收尾 telemetry 汇总也要读(案例中途失败时可能是 null)
869
+ let designBuf = null
870
+ let renderBuf = null
871
+ let designCard = null
872
+ // P1-2:失败案例结构化诊断(在环归因证据包),try 内算出、收尾 telemetry / semantic-review 复用
873
+ let _diag = null
810
874
  const report = {
811
875
  caseId: testCase.caseId, cardId, source: testCase.source,
812
876
  artboardName: testCase.artboardName, split: testCase.split,
813
877
  validate: { errors: 0 },
814
878
  lint: { errors: 0 },
879
+ stageErrors,
880
+ timings: timer.timings,
815
881
  }
816
882
  try {
883
+ // 设计稿只读一次(校正守卫 / 最终像素评分 / token 估算三处复用,省重复磁盘 IO)
884
+ designBuf = timer.time('parse', () => fs.readFileSync(testCase.input.designImage))
817
885
  const spec = JSON.parse(fs.readFileSync(testCase.input.specJson, 'utf8'))
818
886
  const cardPosition = Number(testCase.cardIndex) || 0
819
- const designCard = (spec.cards || [])[cardPosition]
887
+ designCard = (spec.cards || [])[cardPosition]
820
888
  if (!designCard) throw new Error('focus-card-missing-or-skipped')
821
889
  // 纯文本区域(对话气泡/回复段落)不是卡片:无富组件(壳/切图/符号)且 ≥3 文本块。
822
890
  // 用户规则:对话气泡一律跳过,不计入训练指标(避免污染还原度统计与生成器归因)。
@@ -824,9 +892,9 @@ async function main() {
824
892
  if ((_fm.richComponentCount || 0) === 0 && (_fm.textCount || 0) >= 3) {
825
893
  console.log('[SKIP] conversation-text ' + testCase.caseId)
826
894
  skippedConversationTexts.push({ caseId: testCase.caseId, source: testCase.source, artboardName: testCase.artboardName })
827
- continue
895
+ return
828
896
  }
829
- report.upstream = upstreamQuality(spec, designCard)
897
+ report.upstream = timer.time('upstream', () => upstreamQuality(spec, designCard))
830
898
  const _cardRectForExcl = designCard.rect || {}
831
899
  const _exclCardW = Math.max(1, _cardRectForExcl.width || 1)
832
900
  const _exclCardH = Math.max(1, _cardRectForExcl.height || 1)
@@ -879,25 +947,38 @@ async function main() {
879
947
  }
880
948
  const renderWidth = Math.round(designCard?.rect?.width || spec.size?.width || 375)
881
949
  const renderHeight = Math.round(designCard?.rect?.height || spec.size?.height || 0) || null
882
- const generated = generate(spec, {
950
+ const generated = timer.time('generate', () => generate(spec, {
883
951
  id: cardId,
884
952
  name: testCase.artboardName,
885
953
  cardIndex: testCase.cardIndex || 0,
886
954
  designImage: testCase.input.designImage,
887
- })
955
+ }))
956
+ // LLM usage 挂钩(接口预留):v3 纯规则引擎当前恒无 usage;生成链路未来 LLM 化
957
+ // (meta.usage = { inputTokens, outputTokens, calls })后此处自动累计,无需再改 trainer。
958
+ if (generated.meta?.usage) {
959
+ report.usage = generated.meta.usage
960
+ telemetry.usage = telemetry.usage || { inputTokens: 0, outputTokens: 0, calls: 0 }
961
+ telemetry.usage.inputTokens += generated.meta.usage.inputTokens || 0
962
+ telemetry.usage.outputTokens += generated.meta.usage.outputTokens || 0
963
+ telemetry.usage.calls += generated.meta.usage.calls || 1
964
+ }
888
965
  const cardDir = path.join(cardsDir, cardId)
889
966
  fs.mkdirSync(cardDir, { recursive: true })
890
967
  fs.writeFileSync(path.join(cardDir, 'schema.json'), JSON.stringify(generated.schema, null, 2) + '\n')
891
968
  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)
969
+ report.repair = timer.time('autofix', () => runMechanicalAutofix(path.join(cardDir, 'schema.json')))
970
+ report.sliceCount = timer.time('slices', () => copyReferencedSlices(generated.schema, testCase.input.slicesDir, slicesDir))
971
+ report.validate = timer.time('validate', () => runValidator(cardId, cardsDir))
972
+ if (report.validate.error) stageErrors.push(recordStageError('validate', report.validate.error))
973
+ report.lint = timer.time('lint', () => runLinter(cardId, cardsDir, slicesDir))
974
+ if (report.lint.error) stageErrors.push(recordStageError('lint', report.lint.error))
975
+ let rendered = await timer.timeAsync('render', () => worker.renderOnce(cardId, renderWidth, renderHeight))
897
976
  if (!rendered.ok) {
898
977
  report.renderRetry = { firstError: rendered.error || rendered.message }
978
+ stageErrors.push(recordStageError('render', rendered.message || rendered.error))
979
+ telemetry.renderRetries += 1
899
980
  await sleep(500)
900
- rendered = await renderOnce(cardId, renderWidth, renderHeight)
981
+ rendered = await timer.timeAsync('render', () => worker.renderOnce(cardId, renderWidth, renderHeight))
901
982
  }
902
983
  if (!rendered.ok) throw new Error(rendered.message || rendered.error || 'render failed')
903
984
 
@@ -956,23 +1037,18 @@ async function main() {
956
1037
  const cardColForSim = generated.schema.view?.components?.[0]
957
1038
  const corrected = !args.noCorrect && correctCardLayout(generated.schema, rendered.elementBoxes, _deduped, _cr, _adjustments, _sim)
958
1039
  // ── 模拟否决快路径 ──
959
- // 纯纵向 paddingTop 校正在流式 Column 中是精确的刚体平移:
960
- // 子树盒 +delta,后续兄弟盒 +累计 delta。模拟 position 分不提升 ⇒ 真实渲染
961
- // 必然不提升,可直接回退并跳过二次渲染(每轮省 ~20 次渲染)。
1040
+ // 纵向 paddingTop(Y)与横向 paddingLeft(X)校正,在各自轴向上都是精确的刚体平移:
1041
+ // Y:子树盒 +delta,后续兄弟盒 +累计 delta(纵向流式堆叠)。
1042
+ // X:子树盒 +delta,不跨兄弟级联(纵向流里每子项横向独立,各自 paddingLeft)。
1043
+ // 详见 lib/layout-correction-sim.mjs。模拟 position 分不提升 ⇒ 真实渲染必然不提升,
1044
+ // 可直接回退并跳过二次渲染(每轮省 ~20 次渲染)。
1045
+ // 原逻辑仅准入纯 Y 调整,含 X 调整必二次渲染——现放宽到 Y/X 且 rigidSafe 均成立。
962
1046
  // CORR_SIM_VALIDATE=1 时模拟与真实渲染双跑对账,用于验证模拟器保真。
963
1047
  let _simRejectPos = null
964
1048
  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
- })
1049
+ if (corrected && canSimulateReject(_adjustments, _sim.cidToChild, cardColForSim?.slots?.[0]?.components)) {
1050
+ const { shifted: _shifted } = simulateLayoutCorrection(
1051
+ rendered.elementBoxes || [], _adjustments, _sim.cidToChild)
976
1052
  const _simPos = positionScore(_deduped,
977
1053
  _shifted.filter((el) => el.text && ['Text', 'Input', 'Button'].includes(el.cls)),
978
1054
  { cardW: _cr.width || renderWidth, cardH: _cr.height || renderHeight, specShapes: _specShapes })
@@ -989,14 +1065,15 @@ async function main() {
989
1065
  }
990
1066
  } else if (corrected) {
991
1067
  fs.writeFileSync(path.join(cardDir, 'schema.json'), JSON.stringify(generated.schema, null, 2) + '\n')
992
- const correctedRendered = await renderOnce(cardId, renderWidth, renderHeight)
1068
+ const correctedRendered = await timer.timeAsync('reRender', () => worker.renderOnce(cardId, renderWidth, renderHeight))
993
1069
  if (correctedRendered.ok) {
994
1070
  const _correctedPos = positionScore(_deduped,
995
1071
  (correctedRendered.elementBoxes || []).filter((el) => el.text && ['Text', 'Input', 'Button'].includes(el.cls)),
996
1072
  { 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)
1073
+ const _pixelBefore = await timer.timeAsync('pixel', () =>
1074
+ scorePixel(designBuf, Buffer.from(_renderedBackup.base64, 'base64'), exclusionRects, [], { includeAlignDy: false }))
1075
+ const _pixelAfter = await timer.timeAsync('pixel', () =>
1076
+ scorePixel(designBuf, Buffer.from(correctedRendered.base64, 'base64'), exclusionRects, [], { includeAlignDy: false }))
1000
1077
  const _pixelOk = _pixelAfter.ratio <= _pixelBefore.ratio + 0.005
1001
1078
  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
1079
  if (process.env.CORR_SIM_VALIDATE && _simLastPos != null) {
@@ -1025,7 +1102,8 @@ async function main() {
1025
1102
  }
1026
1103
 
1027
1104
  const renderPath = path.join(reportDir, `${testCase.caseId}.render.png`)
1028
- fs.writeFileSync(renderPath, Buffer.from(rendered.base64, 'base64'))
1105
+ renderBuf = Buffer.from(rendered.base64, 'base64')
1106
+ fs.writeFileSync(renderPath, renderBuf)
1029
1107
  // 同步保存设计稿参考图到 reports 目录,便于 summary.md 并排对比
1030
1108
  try { fs.copyFileSync(testCase.input.designImage, path.join(reportDir, testCase.caseId + '.design.png')) } catch {}
1031
1109
  // ── 裁切兜底切图掩膜:导出素材缺失 → 只能从画板裁切(__artboard_crop__*)──
@@ -1037,8 +1115,8 @@ async function main() {
1037
1115
  for (const element of designCard.elements || []) {
1038
1116
  const defect = element.annotationDefect
1039
1117
  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
1118
+ if (!defect || (defect.type !== 'missing-slice' && defect.type !== 'slice-visual-mismatch') || !cropFile) continue
1119
+ if (!(schemaJson.includes(cropFile) || schemaJson.includes(cropFile.replace(/\s+/g, '-')))) continue
1042
1120
  const pad = 1.5
1043
1121
  const base = toExclRect(element, 1)
1044
1122
  exclusionRects.push({
@@ -1050,12 +1128,13 @@ async function main() {
1050
1128
  })
1051
1129
  }
1052
1130
  }
1053
- report.pixel = imageFidelity(
1054
- fs.readFileSync(testCase.input.designImage),
1055
- fs.readFileSync(renderPath),
1131
+ report.pixel = await timer.timeAsync('pixel', () => scorePixel(
1132
+ designBuf,
1133
+ renderBuf,
1056
1134
  exclusionRects,
1057
1135
  fontRects,
1058
- )
1136
+ { includeAlignDy: true },
1137
+ ))
1059
1138
  report.pixelExclusions = [...exclusionRects, ...fontRects]
1060
1139
  // 掩膜覆盖率:掩膜是「不计分」而不是「还原对」。覆盖率过高时通过结论不可信,
1061
1140
  // score_round 会把高掩膜案例单列出来要求人工复核(防止靠掩膜虚高过门)。
@@ -1113,22 +1192,24 @@ async function main() {
1113
1192
  return { x: (r.x || 0) - (cardRect.x || 0), y: (r.y || 0) - (cardRect.y || 0), w: r.width || 0, h: r.height || 0 }
1114
1193
  })
1115
1194
  .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,
1195
+ timer.time('score', () => {
1196
+ report.position = positionScore(specTexts, renderedTexts, {
1197
+ cardW: cardRect.width || renderWidth,
1198
+ cardH: cardRect.height || renderHeight,
1199
+ specShapes: _specShapesFinal,
1200
+ })
1201
+ report.textOverflow = textOverflowScore(rendered.elementBoxes || [])
1202
+ // 语义重复渲染检测(重影):同一文案渲染次数不得超出标注次数
1203
+ report.duplication = duplicationScore(specTexts, renderedTexts)
1204
+ report.fidelity = fidelityScore(
1205
+ report.pixel,
1206
+ expectedComponents(designCard),
1207
+ schemaComponents(generated.schema),
1208
+ report.textRecall,
1209
+ report.position,
1210
+ report.textOverflow,
1211
+ )
1120
1212
  })
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
1213
  // 单案例门以 lib/scoring.mjs 的 PASS_GATE 为唯一来源(SKILL.md 验收表、score_round 同源,
1133
1214
  // 文档数字由 tools/tests/scoring.test.mjs 反向断言),并把未达标维度按缺口落盘供聚类归因
1134
1215
  const gate = evaluatePassGate(report)
@@ -1140,9 +1221,16 @@ async function main() {
1140
1221
  report.failureClass = attribution.failureClass
1141
1222
  report.failureReason = attribution.reason
1142
1223
  }
1224
+ // P1-2:失败案例在环归因用的轻量结构化证据包(零额外渲染,复用 report / 设计稿 buffer /
1225
+ // 标注文本 / 渲染盒),塞进 semantic-review 队列供 Agent 消费,避免直接读双 PNG 烧图像 token。
1226
+ if (report.pixel?.comparable) {
1227
+ _diag = buildInlineDiagnosis(report, { designBuf, renderBuf, specTexts, renderedTexts })
1228
+ }
1143
1229
  } catch (error) {
1144
1230
  report.error = String(error.message || error)
1145
1231
  report.passed = false
1232
+ stageErrors.push(recordStageError('case', report.error))
1233
+ telemetry.caseErrors += 1
1146
1234
  report.failureSignature = failureSignature(report)
1147
1235
  const attribution = attributeFailure(report)
1148
1236
  if (attribution) {
@@ -1151,6 +1239,21 @@ async function main() {
1151
1239
  }
1152
1240
  }
1153
1241
  report.durationMs = Date.now() - started
1242
+ // —— telemetry 汇总(阶段耗时 / 失败案例归因证据包 token 估算)——
1243
+ telemetry.scoredCases += 1
1244
+ for (const [stage, ms] of Object.entries(timer.timings)) accumulateStage(stage, ms)
1245
+ if (!report.passed) {
1246
+ const specChars = (() => { try { return JSON.stringify(designCard ?? {}).length } catch { return 0 } })()
1247
+ // P1-2:失败案例在环归因默认消费结构化 diagnosis(见下方 semanticReviewQueue 的 diagnosis
1248
+ // 字段),不再强制读取双 PNG;故 token 估算以结构化证据包为主,旧双图估算留存
1249
+ // reviewTokensEstImage 供对照。两者均不参与任何门禁,仅作预算参考。
1250
+ const estImage = estimateReviewTokens({ designBuffer: designBuf, renderBuffer: renderBuf, specChars })
1251
+ const est = _diag ? estimateStructuredReviewTokens(_diag) : estImage
1252
+ report.reviewTokensEst = est
1253
+ report.reviewTokensEstImage = estImage
1254
+ telemetry.reviewTokensEst.total += est.total
1255
+ telemetry.reviewTokensEst.cases += 1
1256
+ }
1154
1257
  fs.writeFileSync(path.join(reportDir, `${testCase.caseId}.json`),
1155
1258
  JSON.stringify(report, null, 2) + '\n')
1156
1259
  if (!report.passed) reviewQueue.push({
@@ -1174,6 +1277,10 @@ async function main() {
1174
1277
  color: report.pixel.color ?? null,
1175
1278
  components: report.fidelity?.components ?? null,
1176
1279
  },
1280
+ // P1-2:结构化证据包(零额外渲染,复用 report/设计稿 buffer/标注文本/渲染盒)。
1281
+ // 在环 Agent 据此做聚类与归因,避免直接加载两张 PNG 烧图像 token。
1282
+ // provenance(cid→生成器源码行)按需由 diagnose_case.mjs 深度补全。
1283
+ diagnosis: _diag,
1177
1284
  evidence: {
1178
1285
  designImage: testCase.input.designImage,
1179
1286
  renderImage: path.join(reportDir, `${testCase.caseId}.render.png`),
@@ -1181,14 +1288,44 @@ async function main() {
1181
1288
  cardIndex: testCase.cardIndex || 0,
1182
1289
  schema: path.join(cardsDir, cardId, 'schema.json'),
1183
1290
  },
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.',
1291
+ instruction: '优先消费结构化「diagnosis」字段做聚类与归因:gateFailures(按 deficit 降序的未达标维度)、deviations(文本 dy/dx 偏差表)、hotBands(像素热区带)、metrics、hints(已给失败类建议)。仅当结构化证据不足以定位根因时,才打开 evidence.images 两张 PNG 复核。归因只输出通用 UI/DSL 规则,禁止硬编码特定卡片文案或业务实体名称。',
1185
1292
  })
1186
- console.log(`[${index + 1}/${cases.length}] ${report.passed ? 'PASS' : 'FAIL'} ${testCase.caseId}`)
1293
+ console.log(`[${index + 1}/${cases.length}]${workerCount > 1 ? ` (w${worker.id})` : ''} ${report.passed ? 'PASS' : 'FAIL'} ${testCase.caseId} ${report.durationMs}ms`)
1187
1294
  }
1295
+ // worker 池:cursor 单调分派案例,每 worker 持独立渲染会话;单案例异常已在 processCase 内消化
1296
+ try {
1297
+ let cursor = 0
1298
+ const workerMain = async (workerId) => {
1299
+ const worker = { id: workerId + 1, ...makeWorkerSession() }
1300
+ liveSessions.push(worker.state)
1301
+ while (cursor < cases.length) {
1302
+ const index = cursor++
1303
+ await processCase(cases[index], index, worker)
1304
+ }
1305
+ try { await worker.state.session?.close() } catch {}
1306
+ }
1307
+ await Promise.all(Array.from({ length: workerCount }, (_, i) => workerMain(i)))
1188
1308
  } finally {
1189
- // 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留
1190
- try { await renderSession?.close() } catch {}
1309
+ // 任何异常路径都要关掉 Chrome + HTTP server,杜绝进程残留(worker 异常中断时兜底)
1310
+ await Promise.all(liveSessions.map(async (s) => { try { await s.session?.close() } catch {} }))
1311
+ try { await closePixelWorkers() } catch {}
1191
1312
  }
1313
+ // —— 轮级 telemetry 落盘:错误 / 耗时 / token 三类信号的唯一轮级出口 ——
1314
+ const wallMs = Date.now() - roundStartedAt
1315
+ const stageAverages = Object.fromEntries(
1316
+ Object.entries(telemetry.stageMs)
1317
+ .map(([stage, ms]) => [stage, Math.round(ms / Math.max(telemetry.scoredCases, 1))]))
1318
+ const telemetryOut = {
1319
+ ...telemetry,
1320
+ finishedAt: new Date().toISOString(),
1321
+ wallMs,
1322
+ stageAverages,
1323
+ wallSecondsPerCase: telemetry.scoredCases ? Number((wallMs / telemetry.scoredCases / 1000).toFixed(2)) : null,
1324
+ casesPerMinute: wallMs > 0 && telemetry.scoredCases ? Number((telemetry.scoredCases / (wallMs / 60000)).toFixed(2)) : null,
1325
+ reviewTokensNote: '失败案例 Agent 在环归因证据包的 input token 估算(图像面积/750 + spec 文本/4 chars);v3 生成器为纯规则引擎无 LLM 调用,usage 字段为生成链路 LLM 化后预留',
1326
+ }
1327
+ fs.writeFileSync(path.join(reportDir, 'telemetry.json'), JSON.stringify(telemetryOut, null, 2) + '\n')
1328
+ console.log(`[telemetry] ${telemetry.scoredCases} 例 / ${workerCount} 并发 / 墙钟 ${(wallMs / 1000).toFixed(1)}s(${telemetryOut.casesPerMinute ?? '-'} 例/分钟 · 渲染 ${telemetry.renderCalls} 次${telemetry.renderRetries ? ` · 重试 ${telemetry.renderRetries}` : ''}${telemetry.caseErrors ? ` · 案例错误 ${telemetry.caseErrors}` : ''})`)
1192
1329
  fs.writeFileSync(path.join(reportDir, 'review-queue.jsonl'),
1193
1330
  reviewQueue.map((item) => JSON.stringify(item)).join('\n') + (reviewQueue.length ? '\n' : ''))
1194
1331
  fs.writeFileSync(path.join(reportDir, 'semantic-review.jsonl'),
@@ -1206,6 +1343,58 @@ async function main() {
1206
1343
  if (scored.status !== 0) console.warn(`[WARN] score_round 退出码 ${scored.status}(summary 可能未写出)`)
1207
1344
  }
1208
1345
 
1346
+
1347
+ // —— 轮后落盘:plan_next_round + lesson 草稿自动沉淀 + 训练总结 ——
1348
+ if (!args.noSink) {
1349
+ const planScript = path.join(import.meta.dirname, 'plan_next_round.mjs')
1350
+ const planArgs = [planScript, '--run', runDir, '--history', 'auto']
1351
+ if (args.anchor) planArgs.push('--anchor', String(args.anchor))
1352
+ const planned = spawnSync(process.execPath, planArgs, { encoding: 'utf8', cwd: repoRoot })
1353
+ const planOutput = `${planned.stdout || ''}${planned.stderr || ''}`.trim()
1354
+ if (planOutput) console.log(planOutput)
1355
+ if (planned.status !== 0) console.warn(`[WARN] plan_next_round 退出码 ${planned.status}(落盘可能不完整)`)
1356
+
1357
+ // 自动沉淀 lesson 草稿到 training/lessons/lessons.jsonl(按 signature 去重)
1358
+ const draftsFile = path.join(reportDir, 'lesson-drafts.jsonl')
1359
+ const lessonsFile = path.join(repoRoot, 'training', 'lessons', 'lessons.jsonl')
1360
+ if (fs.existsSync(draftsFile)) {
1361
+ const drafts = fs.readFileSync(draftsFile, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l))
1362
+ if (drafts.length) {
1363
+ const existingSignatures = new Set()
1364
+ if (fs.existsSync(lessonsFile)) {
1365
+ for (const line of fs.readFileSync(lessonsFile, 'utf8').split('\n').filter(Boolean)) {
1366
+ try { const row = JSON.parse(line); if (row.signature) existingSignatures.add(row.signature.toLowerCase()) } catch {}
1367
+ }
1368
+ }
1369
+ const newLessons = drafts.filter((d) => d.signature && !existingSignatures.has(d.signature.toLowerCase()))
1370
+ if (newLessons.length) {
1371
+ fs.appendFileSync(lessonsFile, newLessons.map((l) => JSON.stringify(l)).join('\n') + '\n')
1372
+ console.log(`[sink] ${newLessons.length} 条新 lesson 沉淀到 ${path.relative(repoRoot, lessonsFile)}`)
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ // 回灌规则到目标 skill 文档(lessons → generation_pitfalls.md / SKILL.md 等)
1378
+ const sinkScript = path.join(import.meta.dirname, 'sink_rules.mjs')
1379
+ const sunk = spawnSync(process.execPath, [sinkScript], { encoding: 'utf8', cwd: repoRoot })
1380
+ const sinkOutput = `${sunk.stdout || ''}${sunk.stderr || ''}`.trim()
1381
+ if (sinkOutput) console.log(sinkOutput.split('\n').map((l) => l.startsWith('[') ? l : null).filter(Boolean).join('\n'))
1382
+ if (sunk.status !== 0) console.warn(`[WARN] sink_rules 退出码 ${sunk.status}`)
1383
+
1384
+ // 写 TRAINING-SUMMARY.md(人读训练总结)
1385
+ const summaryFile = path.join(reportDir, 'summary.json')
1386
+ let summaryData = null
1387
+ try { summaryData = JSON.parse(fs.readFileSync(summaryFile, 'utf8')) } catch {}
1388
+ if (summaryData) {
1389
+ const pct = (v) => v == null ? 'N/A' : (v * 100).toFixed(1) + '%'
1390
+ const roundName = path.basename(runDir)
1391
+ const passed = summaryData.roundGatePassed
1392
+ 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`
1393
+ fs.writeFileSync(path.join(runDir, 'TRAINING-SUMMARY.md'), md)
1394
+ console.log(`[sink] 训练总结写入 ${path.relative(repoRoot, path.join(runDir, 'TRAINING-SUMMARY.md'))}`)
1395
+ }
1396
+ }
1397
+
1209
1398
  const newSourceChanges = sourceChanges().filter((line) => !sourceBefore.includes(line))
1210
1399
  if (newSourceChanges.length) {
1211
1400
  throw new Error(`SANDBOX_VIOLATION: training wrote into src/: ${newSourceChanges.join(', ')}`)