MasteryCalculator.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Support\Collection;
  6. /**
  7. * 知识掌握度计算引擎(PHP版本)
  8. * 基于学案基准难度的动态加减逻辑计算掌握度
  9. *
  10. * 新算法特点:
  11. * 1. 根据学案基准难度(筑基、提分、培优、竞赛)动态调整权重
  12. * 2. 难度映射:0.0-1.0 → 1-4级
  13. * 3. 权重计算:越级、适应、降级三种情况
  14. * 4. 父节点掌握度:子节点平均值
  15. */
  16. class MasteryCalculator
  17. {
  18. /**
  19. * 掌握度阈值配置
  20. */
  21. private const MASTERY_THRESHOLD_WEAK = 0.50; // 薄弱点阈值
  22. private const MASTERY_THRESHOLD_GOOD = 0.70; // 良好阈值
  23. private const MASTERY_THRESHOLD_MASTER = 0.85; // 掌握阈值
  24. /**
  25. * 最小正确率要求
  26. */
  27. private const MIN_CORRECT_RATE = 0.60;
  28. /**
  29. * 计算学生对指定知识点的掌握度
  30. *
  31. * @param string $studentId 学生ID
  32. * @param string $kpCode 知识点编码
  33. * @param array|null $attempts 答题记录(可选,默认从数据库查询)
  34. * @param int|null $examBaseDifficulty 学案基准难度(1-4级,1=筑基, 2=提分, 3=培优, 4=竞赛)
  35. * @return array 返回['mastery' => 掌握度, 'confidence' => 置信度, 'trend' => 趋势]
  36. */
  37. public function calculateMasteryLevel(string $studentId, string $kpCode, ?array $attempts = null, ?int $examBaseDifficulty = null): array
  38. {
  39. // 优先使用传递的答题记录,如果没有则从数据库查询
  40. if ($attempts === null || empty($attempts)) {
  41. $attempts = $this->getStudentAttempts($studentId, $kpCode);
  42. }
  43. if (empty($attempts)) {
  44. Log::warning('没有答题记录,无法计算掌握度', [
  45. 'student_id' => $studentId,
  46. 'kp_code' => $kpCode,
  47. 'attempts_provided' => $attempts !== null,
  48. ]);
  49. return [
  50. 'mastery' => 0.0,
  51. 'confidence' => 0.0,
  52. 'trend' => 'insufficient',
  53. 'total_attempts' => 0,
  54. 'correct_attempts' => 0,
  55. 'accuracy_rate' => 0.0,
  56. ];
  57. }
  58. // 如果没有学案基准难度,使用默认值2(提分)
  59. if ($examBaseDifficulty === null) {
  60. Log::warning('缺少学案基准难度,使用默认值2(提分)', [
  61. 'student_id' => $studentId,
  62. 'kp_code' => $kpCode,
  63. 'attempts_count' => count($attempts),
  64. ]);
  65. $examBaseDifficulty = 2; // 默认提分难度
  66. }
  67. $masteryData = $this->calculateMasteryWithExamDifficulty($studentId, $kpCode, $attempts, $examBaseDifficulty);
  68. Log::info('掌握度计算完成', [
  69. 'student_id' => $studentId,
  70. 'kp_code' => $kpCode,
  71. 'exam_base_difficulty' => $examBaseDifficulty,
  72. 'difficulty_name' => $this->getDifficultyName($examBaseDifficulty),
  73. 'total_attempts' => count($attempts),
  74. 'correct_attempts' => $masteryData['correct_attempts'],
  75. 'final_mastery' => $masteryData['mastery'],
  76. 'confidence' => $masteryData['confidence'],
  77. 'trend' => $masteryData['trend'],
  78. ]);
  79. return $masteryData;
  80. }
  81. /**
  82. * 获取难度等级名称
  83. */
  84. private function getDifficultyName(int $difficultyLevel): string
  85. {
  86. return match ($difficultyLevel) {
  87. 1 => '筑基',
  88. 2 => '提分',
  89. 3 => '培优',
  90. 4 => '竞赛',
  91. default => '未知',
  92. };
  93. }
  94. /**
  95. * 【新算法】使用学案基准难度的动态加减逻辑计算掌握度
  96. */
  97. private function calculateMasteryWithExamDifficulty(string $studentId, string $kpCode, array $attempts, int $examBaseDifficulty): array
  98. {
  99. // 获取历史掌握度
  100. $historyMastery = DB::table('student_knowledge_mastery')
  101. ->where('student_id', $studentId)
  102. ->where('kp_code', $kpCode)
  103. ->first();
  104. $oldMastery = $historyMastery->mastery_level ?? 0.5; // 默认0.5
  105. // 统计正确和错误次数
  106. $totalAttempts = count($attempts);
  107. $correctAttempts = 0;
  108. $incorrectAttempts = 0;
  109. // 计算每次答题的权重变化
  110. $totalChange = 0.0;
  111. foreach ($attempts as $attempt) {
  112. $isCorrect = boolval($attempt['is_correct'] ?? false);
  113. $questionDifficulty = floatval($attempt['question_difficulty'] ?? 0.6);
  114. // 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
  115. $questionLevel = $this->mapDifficultyToLevel($questionDifficulty);
  116. // 根据难度关系计算权重变化
  117. $change = $this->calculateWeightByDifficultyRelation($questionLevel, $examBaseDifficulty, $isCorrect);
  118. $totalChange += $change;
  119. if ($isCorrect) {
  120. $correctAttempts++;
  121. } else {
  122. $incorrectAttempts++;
  123. }
  124. Log::debug('掌握度变化计算', [
  125. 'question_id' => $attempt['question_id'] ?? '',
  126. 'question_difficulty' => $questionDifficulty,
  127. 'question_level' => $questionLevel,
  128. 'exam_base_difficulty' => $examBaseDifficulty,
  129. 'is_correct' => $isCorrect,
  130. 'change' => $change,
  131. 'running_total' => $totalChange
  132. ]);
  133. }
  134. // 计算平均变化(避免单次考试影响过大)
  135. $averageChange = $totalAttempts > 0 ? $totalChange / $totalAttempts : 0.0;
  136. // 应用权重调整(避免单次考试变化过大)
  137. $weightedChange = $averageChange * min($totalAttempts / 10.0, 1.0); // 最多10次考试达到满权重
  138. // 新掌握度 = 旧掌握度 + 加权变化
  139. $newMastery = $oldMastery + $weightedChange;
  140. // 边界限制:0.0 ~ 1.0
  141. $newMastery = max(0.0, min(1.0, $newMastery));
  142. // 计算置信度(基于答题次数)
  143. $confidence = $this->calculateConfidence($attempts);
  144. // 判断趋势
  145. $trend = $this->determineTrend($attempts);
  146. // 保存到数据库
  147. DB::table('student_knowledge_mastery')
  148. ->updateOrInsert(
  149. ['student_id' => $studentId, 'kp_code' => $kpCode],
  150. [
  151. 'mastery_level' => $newMastery,
  152. 'confidence_level' => $confidence,
  153. 'total_attempts' => ($historyMastery->total_attempts ?? 0) + $totalAttempts,
  154. 'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + $correctAttempts,
  155. 'mastery_trend' => $trend,
  156. 'last_mastery_update' => now(),
  157. 'updated_at' => now(),
  158. ]
  159. );
  160. return [
  161. 'mastery' => round($newMastery, 4),
  162. 'confidence' => round($confidence, 4),
  163. 'trend' => $trend,
  164. 'total_attempts' => $totalAttempts,
  165. 'correct_attempts' => $correctAttempts,
  166. 'accuracy_rate' => round(($correctAttempts / $totalAttempts) * 100, 2),
  167. 'old_mastery' => $oldMastery,
  168. 'change' => round($weightedChange, 4),
  169. 'details' => [
  170. 'exam_base_difficulty' => $examBaseDifficulty,
  171. 'total_change' => round($totalChange, 4),
  172. 'average_change' => round($averageChange, 4),
  173. 'weighted_change' => round($weightedChange, 4),
  174. ],
  175. ];
  176. }
  177. /**
  178. * 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
  179. */
  180. private function mapDifficultyToLevel(float $difficulty): int
  181. {
  182. if ($difficulty >= 0.0 && $difficulty < 0.25) {
  183. return 1; // 1级
  184. } elseif ($difficulty >= 0.25 && $difficulty < 0.5) {
  185. return 2; // 2级
  186. } elseif ($difficulty >= 0.5 && $difficulty < 0.75) {
  187. return 3; // 3级
  188. } else {
  189. return 4; // 4级
  190. }
  191. }
  192. /**
  193. * 根据难度关系计算权重变化
  194. */
  195. private function calculateWeightByDifficultyRelation(int $questionLevel, int $examBaseDifficulty, bool $isCorrect): float
  196. {
  197. if ($questionLevel > $examBaseDifficulty) {
  198. // 越级:题目难度 > 学案基准难度
  199. return $isCorrect ? 0.15 : -0.05;
  200. } elseif ($questionLevel == $examBaseDifficulty) {
  201. // 适应:题目难度 = 学案基准难度
  202. return $isCorrect ? 0.10 : -0.10;
  203. } else {
  204. // 降级:题目难度 < 学案基准难度
  205. return $isCorrect ? 0.05 : -0.15;
  206. }
  207. }
  208. /**
  209. * 计算置信度
  210. */
  211. private function calculateConfidence(array $attempts): float
  212. {
  213. if (empty($attempts)) {
  214. return 0.0;
  215. }
  216. $totalAttempts = count($attempts);
  217. // 基于答题次数的置信度:答题越多,置信度越高
  218. // 5次以下线性增长,5次以上增长放缓
  219. if ($totalAttempts < 5) {
  220. $baseConfidence = $totalAttempts / 5.0;
  221. } else {
  222. $baseConfidence = 0.5 + (1.0 - 0.5) * (1 - exp(-($totalAttempts - 5) / 10));
  223. }
  224. // 计算正确率
  225. $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
  226. $accuracyRate = $correctAttempts / $totalAttempts;
  227. // 正确率也影响置信度
  228. $accuracyFactor = 0.5 + $accuracyRate * 0.5;
  229. // 综合置信度
  230. $confidence = $baseConfidence * $accuracyFactor;
  231. return min($confidence, 1.0);
  232. }
  233. /**
  234. * 判断学习趋势
  235. */
  236. private function determineTrend(array $attempts): string
  237. {
  238. if (count($attempts) < 3) {
  239. return 'insufficient'; // 数据不足
  240. }
  241. // 按时间排序
  242. usort($attempts, function($a, $b) {
  243. $timeA = strtotime($a['completed_at'] ?? $a['created_at'] ?? 0);
  244. $timeB = strtotime($b['completed_at'] ?? $b['created_at'] ?? 0);
  245. return $timeA <=> $timeB;
  246. });
  247. // 分为前后两部分
  248. $midPoint = intdiv(count($attempts), 2);
  249. $firstHalf = array_slice($attempts, 0, $midPoint);
  250. $secondHalf = array_slice($attempts, $midPoint);
  251. // 计算前半部分和后半部分的正确率
  252. $firstHalfCorrect = count(array_filter($firstHalf, fn($a) => $a['is_correct']));
  253. $firstHalfAccuracy = $firstHalfCorrect / count($firstHalf);
  254. $secondHalfCorrect = count(array_filter($secondHalf, fn($a) => $a['is_correct']));
  255. $secondHalfAccuracy = $secondHalfCorrect / count($secondHalf);
  256. $improvement = $secondHalfAccuracy - $firstHalfAccuracy;
  257. if ($improvement > 0.1) {
  258. return 'improving'; // 提升
  259. } elseif ($improvement < -0.1) {
  260. return 'declining'; // 下降
  261. } else {
  262. return 'stable'; // 稳定
  263. }
  264. }
  265. /**
  266. * 获取学生的答题记录
  267. */
  268. private function getStudentAttempts(string $studentId, string $kpCode): array
  269. {
  270. $attempts = [];
  271. // 优先从 student_answer_steps 表获取(步骤级记录)
  272. $stepAttempts = DB::table('student_answer_steps')
  273. ->where('student_id', $studentId)
  274. ->where('kp_id', $kpCode)
  275. ->orderBy('created_at', 'asc')
  276. ->get();
  277. foreach ($stepAttempts as $step) {
  278. $attempts[] = [
  279. 'student_id' => $step->student_id,
  280. 'paper_id' => $step->exam_id,
  281. 'question_id' => $step->question_id,
  282. 'kp_code' => $step->kp_id,
  283. 'is_correct' => (bool) $step->is_correct,
  284. 'score_obtained' => $step->step_score,
  285. 'max_score' => $step->step_score, // 步骤分数本身就是满分
  286. 'created_at' => $step->created_at,
  287. ];
  288. }
  289. // 如果没有步骤级记录,从 student_answer_questions 表获取(题目级记录)
  290. if (empty($attempts)) {
  291. $questionAttempts = DB::table('student_answer_questions')
  292. ->where('student_id', $studentId)
  293. ->orderBy('created_at', 'asc')
  294. ->get();
  295. foreach ($questionAttempts as $question) {
  296. $attempts[] = [
  297. 'student_id' => $question->student_id,
  298. 'paper_id' => $question->exam_id,
  299. 'question_id' => $question->question_id,
  300. 'kp_code' => $kpCode, // 使用传入的kpCode
  301. 'is_correct' => ($question->score_obtained ?? 0) > 0,
  302. 'score_obtained' => $question->score_obtained ?? 0,
  303. 'max_score' => $question->max_score ?? 0,
  304. 'created_at' => $question->created_at,
  305. ];
  306. }
  307. }
  308. return $attempts;
  309. }
  310. /**
  311. * 批量更新学生掌握度
  312. */
  313. public function batchUpdateMastery(string $studentId, array $kpCodes): array
  314. {
  315. $results = [];
  316. foreach ($kpCodes as $kpCode) {
  317. $masteryData = $this->calculateMasteryLevel($studentId, $kpCode);
  318. // 保存到数据库
  319. DB::table('student_knowledge_mastery')
  320. ->updateOrInsert(
  321. ['student_id' => $studentId, 'kp_code' => $kpCode],
  322. [
  323. 'mastery_level' => $masteryData['mastery'],
  324. 'confidence_level' => $masteryData['confidence'],
  325. 'total_attempts' => $masteryData['total_attempts'],
  326. 'correct_attempts' => $masteryData['correct_attempts'],
  327. 'mastery_trend' => $masteryData['trend'],
  328. 'last_mastery_update' => now(),
  329. 'updated_at' => now(),
  330. ]
  331. );
  332. $results[$kpCode] = $masteryData;
  333. }
  334. return $results;
  335. }
  336. /**
  337. * 获取学生所有知识点的掌握度概览
  338. */
  339. public function getStudentMasteryOverview(string $studentId): array
  340. {
  341. $masteryList = DB::table('student_knowledge_mastery')
  342. ->where('student_id', $studentId)
  343. ->get();
  344. if ($masteryList->isEmpty()) {
  345. return [
  346. 'total_knowledge_points' => 0,
  347. 'average_mastery_level' => 0.0,
  348. 'mastered_knowledge_points' => 0,
  349. 'good_knowledge_points' => 0,
  350. 'weak_knowledge_points' => 0,
  351. 'weak_knowledge_points_list' => [],
  352. 'details' => [],
  353. ];
  354. }
  355. $masteryArray = $masteryList->toArray();
  356. $total = count($masteryArray);
  357. $average = $masteryArray ? array_sum(array_column($masteryArray, 'mastery_level')) / $total : 0;
  358. $mastered = [];
  359. $good = [];
  360. $weak = [];
  361. foreach ($masteryArray as $item) {
  362. $level = floatval($item->mastery_level);
  363. if ($level >= self::MASTERY_THRESHOLD_MASTER) {
  364. $mastered[] = $item;
  365. } elseif ($level >= self::MASTERY_THRESHOLD_GOOD) {
  366. $good[] = $item;
  367. } else {
  368. $weak[] = $item;
  369. }
  370. }
  371. return [
  372. 'total_knowledge_points' => $total,
  373. 'average_mastery_level' => round($average, 4),
  374. 'mastered_knowledge_points' => count($mastered),
  375. 'good_knowledge_points' => count($good),
  376. 'weak_knowledge_points' => count($weak),
  377. 'weak_knowledge_points_list' => $weak,
  378. 'details' => $masteryArray,
  379. ];
  380. }
  381. /**
  382. * 【新功能】获取知识点层级关系
  383. */
  384. private function getKnowledgePointHierarchy(string $kpCode): ?array
  385. {
  386. try {
  387. $kp = DB::table('knowledge_points')
  388. ->where('kp_code', $kpCode)
  389. ->first();
  390. if (!$kp) {
  391. return null;
  392. }
  393. return [
  394. 'kp_code' => $kp->kp_code,
  395. 'parent_kp_code' => $kp->parent_kp_code,
  396. 'level' => $kp->level ?? 1,
  397. ];
  398. } catch (\Exception $e) {
  399. Log::warning('获取知识点层级关系失败', [
  400. 'kp_code' => $kpCode,
  401. 'error' => $e->getMessage(),
  402. ]);
  403. return null;
  404. }
  405. }
  406. /**
  407. * 【新功能】计算父节点掌握度(子节点平均值)
  408. */
  409. public function calculateParentMastery(string $studentId, string $parentKpCode): float
  410. {
  411. try {
  412. // 获取所有子节点
  413. $childKps = DB::table('knowledge_points')
  414. ->where('parent_kp_code', $parentKpCode)
  415. ->pluck('kp_code')
  416. ->toArray();
  417. if (empty($childKps)) {
  418. // 如果没有子节点,返回0
  419. return 0.0;
  420. }
  421. // 获取所有子节点的掌握度
  422. $masteryLevels = [];
  423. foreach ($childKps as $childKpCode) {
  424. $mastery = DB::table('student_knowledge_mastery')
  425. ->where('student_id', $studentId)
  426. ->where('kp_code', $childKpCode)
  427. ->value('mastery_level');
  428. if ($mastery !== null) {
  429. $masteryLevels[] = floatval($mastery);
  430. }
  431. }
  432. if (empty($masteryLevels)) {
  433. return 0.0;
  434. }
  435. // 计算算术平均数
  436. $averageMastery = array_sum($masteryLevels) / count($masteryLevels);
  437. Log::info('父节点掌握度计算', [
  438. 'student_id' => $studentId,
  439. 'parent_kp_code' => $parentKpCode,
  440. 'child_count' => count($childKps),
  441. 'mastery_count' => count($masteryLevels),
  442. 'average_mastery' => round($averageMastery, 4),
  443. 'child_masteries' => $masteryLevels,
  444. ]);
  445. return round($averageMastery, 4);
  446. } catch (\Exception $e) {
  447. Log::error('计算父节点掌握度失败', [
  448. 'student_id' => $studentId,
  449. 'parent_kp_code' => $parentKpCode,
  450. 'error' => $e->getMessage(),
  451. ]);
  452. return 0.0;
  453. }
  454. }
  455. /**
  456. * 【增强】获取学生所有知识点的掌握度概览(支持父节点计算)
  457. */
  458. public function getStudentMasteryOverviewWithHierarchy(string $studentId): array
  459. {
  460. $masteryList = DB::table('student_knowledge_mastery')
  461. ->where('student_id', $studentId)
  462. ->get();
  463. if ($masteryList->isEmpty()) {
  464. return [
  465. 'total_knowledge_points' => 0,
  466. 'average_mastery_level' => 0.0,
  467. 'mastered_knowledge_points' => 0,
  468. 'good_knowledge_points' => 0,
  469. 'weak_knowledge_points' => 0,
  470. 'weak_knowledge_points_list' => [],
  471. 'details' => [],
  472. 'parent_mastery_levels' => [], // 新增:父节点掌握度
  473. ];
  474. }
  475. $masteryArray = $masteryList->toArray();
  476. $total = count($masteryArray);
  477. $average = $masteryArray ? array_sum(array_column($masteryArray, 'mastery_level')) / $total : 0;
  478. $mastered = [];
  479. $good = [];
  480. $weak = [];
  481. foreach ($masteryArray as $item) {
  482. $level = floatval($item->mastery_level);
  483. if ($level >= self::MASTERY_THRESHOLD_MASTER) {
  484. $mastered[] = $item;
  485. } elseif ($level >= self::MASTERY_THRESHOLD_GOOD) {
  486. $good[] = $item;
  487. } else {
  488. $weak[] = $item;
  489. }
  490. }
  491. // 【新功能】计算父节点掌握度
  492. $parentMasteryLevels = [];
  493. $parentKpCodes = DB::table('knowledge_points')
  494. ->whereNotNull('parent_kp_code')
  495. ->distinct()
  496. ->pluck('parent_kp_code')
  497. ->toArray();
  498. foreach ($parentKpCodes as $parentKpCode) {
  499. $parentMastery = $this->calculateParentMastery($studentId, $parentKpCode);
  500. $parentMasteryLevels[$parentKpCode] = $parentMastery;
  501. }
  502. return [
  503. 'total_knowledge_points' => $total,
  504. 'average_mastery_level' => round($average, 4),
  505. 'mastered_knowledge_points' => count($mastered),
  506. 'good_knowledge_points' => count($good),
  507. 'weak_knowledge_points' => count($weak),
  508. 'weak_knowledge_points_list' => $weak,
  509. 'details' => $masteryArray,
  510. 'parent_mastery_levels' => $parentMasteryLevels, // 新增:父节点掌握度
  511. ];
  512. }
  513. }