MasteryCalculator.php 19 KB

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