calibrate-review-weights.mjs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /**
  2. * 审稿评分权重校准脚本
  3. *
  4. * 通过黄金标准场景和网格搜索找到最优的维度和严重程度权重组合。
  5. * 用法: node scripts/calibrate-review-weights.mjs
  6. */
  7. // ---- 黄金标准场景 ----
  8. // 每个场景定义了:一组审稿问题 + 期望的总分(人工评估的"正确答案")
  9. const GOLD_STANDARD_SCENARIOS = [
  10. {
  11. name: "完美章节(零问题)",
  12. issues: [],
  13. expectedTotalScore: 100,
  14. notes: "无任何问题的章节应得满分",
  15. },
  16. {
  17. name: "轻微时间线错误",
  18. issues: [
  19. { severity: "error", type: "timeline", count: 1 },
  20. ],
  21. expectedTotalScore: 85,
  22. notes: "单个 facts 维度错误应扣减适量分数",
  23. },
  24. {
  25. name: "重大角色一致性问题",
  26. issues: [
  27. { severity: "error", type: "character_consistency", count: 2 },
  28. { severity: "warning", type: "character_consistency", count: 2 },
  29. ],
  30. expectedTotalScore: 65,
  31. notes: "多个角色问题应大幅拉低总分,但不至于不及格",
  32. },
  33. {
  34. name: "水文 + 缺钩子",
  35. issues: [
  36. { severity: "error", type: "plot", count: 1 },
  37. { severity: "warning", type: "plot", count: 2 },
  38. { severity: "error", type: "style", count: 1 },
  39. ],
  40. expectedTotalScore: 72,
  41. notes: "剧情推进问题 + 节奏问题共现",
  42. },
  43. {
  44. name: "多处事实错误 + 轻微人物问题",
  45. issues: [
  46. { severity: "error", type: "timeline", count: 2 },
  47. { severity: "error", type: "foreshadowing", count: 1 },
  48. { severity: "warning", type: "character_consistency", count: 1 },
  49. { severity: "info", type: "style", count: 3 },
  50. ],
  51. expectedTotalScore: 60,
  52. notes: "事实一致性严重受损,但不应直接归零",
  53. },
  54. {
  55. name: "全面崩坏(多维度严重错误)",
  56. issues: [
  57. { severity: "error", type: "character_consistency", count: 3 },
  58. { severity: "error", type: "timeline", count: 2 },
  59. { severity: "error", type: "plot", count: 2 },
  60. { severity: "error", type: "foreshadowing", count: 1 },
  61. { severity: "warning", type: "style", count: 4 },
  62. { severity: "info", type: "style", count: 5 },
  63. ],
  64. expectedTotalScore: 35,
  65. notes: "多维度严重错误,总分应在30-40之间",
  66. },
  67. {
  68. name: "轻微提示(仅 info 级别)",
  69. issues: [
  70. { severity: "info", type: "style", count: 3 },
  71. { severity: "info", type: "plot", count: 1 },
  72. ],
  73. expectedTotalScore: 88,
  74. notes: "仅有 info 级别建议,高分轻微下降",
  75. },
  76. ]
  77. // ---- 权重搜索空间 ----
  78. // 每个维度的权重 + 每种严重度的扣分值
  79. const WEIGHT_RANGES = {
  80. plot: { min: 0.10, max: 0.25, step: 11 }, // 11 points: 0.10, 0.115, ..., 0.25
  81. character: { min: 0.10, max: 0.20, step: 9 }, // 9 points
  82. world: { min: 0.05, max: 0.15, step: 9 },
  83. pacing: { min: 0.10, max: 0.20, step: 9 },
  84. facts: { min: 0.20, max: 0.35, step: 13 },
  85. compliance: { min: 0.10, max: 0.20, step: 9 },
  86. }
  87. const DEDUCTION_RANGES = {
  88. error: { min: 15, max: 30, step: 16 }, // 16 points: 15, 16, ..., 30
  89. warning: { min: 8, max: 15, step: 8 },
  90. info: { min: 3, max: 8, step: 6 },
  91. }
  92. // ---- 默认值(当前实现)----
  93. const DEFAULT_WEIGHTS = {
  94. plot: 0.20,
  95. character: 0.15,
  96. world: 0.10,
  97. pacing: 0.15,
  98. facts: 0.25,
  99. compliance: 0.15,
  100. }
  101. const DEFAULT_DEDUCTIONS = {
  102. error: 20,
  103. warning: 10,
  104. info: 5,
  105. }
  106. // ---- 辅助函数 ----
  107. function range(min, max, steps) {
  108. const result = []
  109. const stepSize = (max - min) / (steps - 1)
  110. for (let i = 0; i < steps; i++) {
  111. result.push(Math.round((min + i * stepSize) * 1000) / 1000)
  112. }
  113. return result
  114. }
  115. function generateWeightCombinations(ranges) {
  116. const dims = Object.keys(ranges)
  117. const combos = []
  118. const values = {}
  119. for (const dim of dims) {
  120. values[dim] = range(ranges[dim].min, ranges[dim].max, ranges[dim].step)
  121. }
  122. for (const p of values.plot) {
  123. for (const c of values.character) {
  124. for (const w of values.world) {
  125. for (const pa of values.pacing) {
  126. for (const f of values.facts) {
  127. for (const co of values.compliance) {
  128. const sum = p + c + w + pa + f + co
  129. if (Math.abs(sum - 1.0) < 0.01) {
  130. combos.push({ plot: p, character: c, world: w, pacing: pa, facts: f, compliance: co })
  131. }
  132. }
  133. }
  134. }
  135. }
  136. }
  137. }
  138. return combos
  139. }
  140. function generateDeductionCombinations(ranges) {
  141. const combos = []
  142. const errors = range(ranges.error.min, ranges.error.max, ranges.error.step)
  143. const warnings = range(ranges.warning.min, ranges.warning.max, ranges.warning.step)
  144. const infos = range(ranges.info.min, ranges.info.max, ranges.info.step)
  145. for (const e of errors) {
  146. for (const w of warnings) {
  147. for (const i of infos) {
  148. // error > warning > info must hold
  149. if (e > w && w > i) {
  150. combos.push({ error: e, warning: w, info: i })
  151. }
  152. }
  153. }
  154. }
  155. return combos
  156. }
  157. const TYPE_TO_DIM_MAP = {
  158. "character_consistency": "character",
  159. "timeline": "facts",
  160. "foreshadowing": "facts",
  161. "plot": "plot",
  162. "style": "pacing",
  163. "world": "world",
  164. "compliance": "compliance",
  165. }
  166. function computeScore(issues, weights, deductions) {
  167. const dimIssues = {}
  168. for (const dim of Object.keys(weights)) {
  169. dimIssues[dim] = []
  170. }
  171. for (const issue of issues) {
  172. const dim = TYPE_TO_DIM_MAP[issue.type] || "facts"
  173. for (let j = 0; j < issue.count; j++) {
  174. dimIssues[dim].push(issue.severity)
  175. }
  176. }
  177. let totalScore = 0
  178. for (const dim of Object.keys(weights)) {
  179. const deduction = dimIssues[dim].reduce((sum, sev) => {
  180. return sum + (deductions[sev] || 5)
  181. }, 0)
  182. const dimScore = Math.max(0, 100 - deduction)
  183. totalScore += dimScore * weights[dim]
  184. }
  185. return Math.round(totalScore)
  186. }
  187. // ---- 主校准流程 ----
  188. console.log("====== 审稿评分权重校准 ======\n")
  189. console.log(`黄金标准场景数: ${GOLD_STANDARD_SCENARIOS.length}`)
  190. // 搜索权重
  191. console.log("\n[1/2] 搜索最优维度权重...")
  192. const weightCombos = generateWeightCombinations(WEIGHT_RANGES)
  193. console.log(` 候选权重组合: ${weightCombos.length}`)
  194. let bestWeightCombo = null
  195. let bestWeightError = Infinity
  196. for (const combo of weightCombos) {
  197. let totalError = 0
  198. for (const scenario of GOLD_STANDARD_SCENARIOS) {
  199. const score = computeScore(scenario.issues, combo, DEFAULT_DEDUCTIONS)
  200. totalError += Math.abs(score - scenario.expectedTotalScore)
  201. }
  202. if (totalError < bestWeightError) {
  203. bestWeightError = totalError
  204. bestWeightCombo = combo
  205. }
  206. }
  207. // 搜索扣分值
  208. console.log("\n[2/2] 搜索最优扣分值...")
  209. const deductionCombos = generateDeductionCombinations(DEDUCTION_RANGES)
  210. console.log(` 候选扣分量组合: ${deductionCombos.length}`)
  211. let bestDeductionCombo = null
  212. let bestDeductionError = Infinity
  213. for (const combo of deductionCombos) {
  214. let totalError = 0
  215. for (const scenario of GOLD_STANDARD_SCENARIOS) {
  216. const score = computeScore(scenario.issues, bestWeightCombo, combo)
  217. totalError += Math.abs(score - scenario.expectedTotalScore)
  218. }
  219. if (totalError < bestDeductionError) {
  220. bestDeductionError = totalError
  221. bestDeductionCombo = combo
  222. }
  223. }
  224. // ---- 输出结果 ----
  225. console.log("\n====== 校准结果 ======\n")
  226. console.log("📊 最佳维度权重:")
  227. for (const dim of Object.keys(DEFAULT_WEIGHTS)) {
  228. const defVal = DEFAULT_WEIGHTS[dim]
  229. const calVal = bestWeightCombo[dim]
  230. const diff = ((calVal - defVal) / defVal * 100).toFixed(1)
  231. const arrow = calVal > defVal ? "↑" : calVal < defVal ? "↓" : "→"
  232. console.log(` ${dim.padEnd(12)} ${defVal.toFixed(2)} → ${calVal.toFixed(2)} (${arrow}${Math.abs(diff)}%)`)
  233. }
  234. console.log("\n📊 最佳扣分值:")
  235. for (const sev of Object.keys(DEFAULT_DEDUCTIONS)) {
  236. const defVal = DEFAULT_DEDUCTIONS[sev]
  237. const calVal = bestDeductionCombo[sev]
  238. const diff = ((calVal - defVal) / defVal * 100).toFixed(1)
  239. const arrow = calVal > defVal ? "↑" : calVal < defVal ? "↓" : "→"
  240. console.log(` ${sev.padEnd(12)} ${defVal} → ${calVal} (${arrow}${Math.abs(diff)}%)`)
  241. }
  242. console.log("\n📊 各场景得分对比:")
  243. console.log(" 场景".padEnd(24) + "期望".padEnd(8) + "校准前".padEnd(8) + "校准后".padEnd(8) + "改进")
  244. console.log(" " + "-".repeat(56))
  245. let totalBefore = 0
  246. let totalAfter = 0
  247. for (const scenario of GOLD_STANDARD_SCENARIOS) {
  248. const before = computeScore(scenario.issues, DEFAULT_WEIGHTS, DEFAULT_DEDUCTIONS)
  249. const after = computeScore(scenario.issues, bestWeightCombo, bestDeductionCombo)
  250. totalBefore += Math.abs(before - scenario.expectedTotalScore)
  251. totalAfter += Math.abs(after - scenario.expectedTotalScore)
  252. const improvement = (Math.abs(before - scenario.expectedTotalScore) - Math.abs(after - scenario.expectedTotalScore)).toFixed(1)
  253. const arrow = improvement > 0 ? "✅" : improvement < 0 ? "❌" : "➡️"
  254. console.log(` ${scenario.name.padEnd(22)} ${String(scenario.expectedTotalScore).padEnd(8)} ${String(before).padEnd(8)} ${String(after).padEnd(8)} ${arrow} ${improvement}`)
  255. }
  256. console.log(`\n 总绝对误差: 校准前 ${totalBefore} → 校准后 ${totalAfter} (降低 ${(totalBefore - totalAfter).toFixed(1)})`)
  257. // ---- 归一化权重 ----
  258. const weightSum = Object.values(bestWeightCombo).reduce((a, b) => a + b, 0)
  259. for (const dim of Object.keys(bestWeightCombo)) {
  260. bestWeightCombo[dim] = Math.round(bestWeightCombo[dim] / weightSum * 1000) / 1000
  261. }
  262. // ---- 输出推荐配置 ----
  263. console.log("\n====== 推荐配置(可直接用于 ReviewScoringOptions)======")
  264. console.log("\ndimensionWeights: {")
  265. for (const dim of Object.keys(bestWeightCombo)) {
  266. console.log(` ${dim}: ${bestWeightCombo[dim].toFixed(3)},`)
  267. }
  268. console.log("}")
  269. console.log("\nseverityDeductions: {")
  270. for (const sev of Object.keys(bestDeductionCombo)) {
  271. console.log(` ${sev}: ${bestDeductionCombo[sev]},`)
  272. }
  273. console.log("}")
  274. console.log("\n✅ 校准完成!")