QuestionDifficultyResolver.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Schema;
  5. class QuestionDifficultyResolver
  6. {
  7. private const TABLE = 'question_difficulty_calibrations';
  8. private const SERVED_DIFF_CONFIDENCE_DENOMINATOR = 20.0;
  9. private ?bool $tableReady = null;
  10. /**
  11. * @param array<int, int|string> $questionIds
  12. * @return array<int, array<string, float|null>> question_bank_id => calibration snapshot
  13. */
  14. public function mapCalibratedDifficulty(array $questionIds): array
  15. {
  16. if (! $this->isReady()) {
  17. return [];
  18. }
  19. $questionIds = collect($questionIds)
  20. ->map(fn ($id) => (int) $id)
  21. ->filter(fn ($id) => $id > 0)
  22. ->unique()
  23. ->values()
  24. ->all();
  25. if ($questionIds === []) {
  26. return [];
  27. }
  28. $rows = DB::table(self::TABLE)
  29. ->whereIn('question_bank_id', $questionIds)
  30. ->orderByDesc('updated_at')
  31. ->orderByDesc('id')
  32. ->get(['id', 'question_bank_id', 'original_difficulty', 'calibrated_difficulty', 'weighted_attempts']);
  33. $map = [];
  34. foreach ($rows as $row) {
  35. $qid = (int) ($row->question_bank_id ?? 0);
  36. if ($qid <= 0 || array_key_exists($qid, $map)) {
  37. continue;
  38. }
  39. $map[$qid] = [
  40. 'original_difficulty' => $row->original_difficulty !== null ? (float) $row->original_difficulty : null,
  41. 'calibrated_difficulty' => $row->calibrated_difficulty !== null ? (float) $row->calibrated_difficulty : null,
  42. 'weighted_attempts' => $row->weighted_attempts !== null ? (float) $row->weighted_attempts : null,
  43. ];
  44. }
  45. return $map;
  46. }
  47. /**
  48. * 批量给题目数组计算组卷使用难度 served_diff:
  49. * served_diff = alpha * calibrated + (1 - alpha) * original
  50. * alpha = min(1, weighted_attempts / 20)
  51. * 前提:仅当 calibrated_difficulty 存在时才融合;否则保持原始 difficulty 不变。
  52. *
  53. * @param array<int, array<string, mixed>> $questions
  54. * @return array<int, array<string, mixed>>
  55. */
  56. public function applyCalibratedDifficulty(array $questions): array
  57. {
  58. if ($questions === []) {
  59. return $questions;
  60. }
  61. $ids = [];
  62. foreach ($questions as $q) {
  63. $id = (int) ($q['id'] ?? $q['question_id'] ?? $q['question_bank_id'] ?? 0);
  64. if ($id > 0) {
  65. $ids[] = $id;
  66. }
  67. }
  68. $map = $this->mapCalibratedDifficulty($ids);
  69. if ($map === []) {
  70. return $questions;
  71. }
  72. foreach ($questions as &$q) {
  73. $id = (int) ($q['id'] ?? $q['question_id'] ?? $q['question_bank_id'] ?? 0);
  74. if ($id <= 0 || ! array_key_exists($id, $map)) {
  75. continue;
  76. }
  77. $snapshot = $map[$id] ?? [];
  78. $calibrated = $snapshot['calibrated_difficulty'] ?? null;
  79. if ($calibrated === null) {
  80. continue;
  81. }
  82. $original = isset($q['difficulty']) && is_numeric($q['difficulty'])
  83. ? (float) $q['difficulty']
  84. : (float) ($snapshot['original_difficulty'] ?? $calibrated);
  85. $weightedAttempts = max(0.0, (float) ($snapshot['weighted_attempts'] ?? 0.0));
  86. $alpha = min(1.0, $weightedAttempts / self::SERVED_DIFF_CONFIDENCE_DENOMINATOR);
  87. $servedDifficulty = ($alpha * (float) $calibrated) + ((1.0 - $alpha) * $original);
  88. $q['difficulty'] = round($servedDifficulty, 4);
  89. $q['difficulty_source'] = $alpha >= 0.9999 ? 'calibrated' : 'served_blend';
  90. $q['difficulty_original'] = round($original, 4);
  91. $q['difficulty_calibrated'] = round((float) $calibrated, 4);
  92. $q['difficulty_alpha'] = round($alpha, 4);
  93. $q['difficulty_weighted_attempts'] = round($weightedAttempts, 4);
  94. }
  95. unset($q);
  96. return $questions;
  97. }
  98. private function isReady(): bool
  99. {
  100. if ($this->tableReady !== null) {
  101. return $this->tableReady;
  102. }
  103. $this->tableReady = Schema::hasTable(self::TABLE);
  104. return $this->tableReady;
  105. }
  106. }