ExamAnswerAnalysisService.php 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183
  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. * 考试答题分析服务(步骤级分析)
  8. * 基于卷子分析思考文档的思路实现
  9. *
  10. * 核心流程:
  11. * 1. 接收卷子ID和每道题的对错、简答题的分步骤对错
  12. * 2. 将原子信息映射到知识点/技能
  13. * 3. 计算知识点掌握度向量
  14. * 4. 生成详细分析报告
  15. * 5. 提供智能出卷推荐依据
  16. */
  17. class ExamAnswerAnalysisService
  18. {
  19. public function __construct(
  20. private readonly MasteryCalculator $masteryCalculator,
  21. private readonly KnowledgeMasteryService $knowledgeMasteryService,
  22. private readonly LocalAIAnalysisService $aiAnalysisService
  23. ) {}
  24. /**
  25. * 分析考试答题数据
  26. *
  27. * @param array $examData 考试数据
  28. * [
  29. * 'paper_id' => 'exam_001',
  30. * 'student_id' => 'student_001',
  31. * 'questions' => [
  32. * [
  33. * 'question_id' => 'Q1',
  34. * 'score' => 5,
  35. * 'score_obtained' => 5,
  36. * 'steps' => [
  37. * ['step_index' => 1, 'is_correct' => true, 'kp_id' => 'K-SQRT-SIMPLE'],
  38. * ['step_index' => 2, 'is_correct' => true, 'kp_id' => 'K-NUM-ADD-SUB']
  39. * ]
  40. * ]
  41. * ]
  42. * ]
  43. *
  44. * @return array 分析结果
  45. */
  46. public function analyzeExamAnswers(array $examData): array
  47. {
  48. Log::info('开始分析考试答题', [
  49. 'paper_id' => $examData['paper_id'] ?? 'unknown',
  50. 'student_id' => $examData['student_id'] ?? 'unknown',
  51. 'question_count' => count($examData['questions'] ?? [])
  52. ]);
  53. $studentId = $examData['student_id'];
  54. $questions = $examData['questions'] ?? [];
  55. // 0. 自动计算分数(如果用户未提供)
  56. $questions = $this->autoCalculateScores($questions);
  57. // 1. 获取学案基准难度
  58. $examBaseDifficulty = $this->getExamBaseDifficulty($examData['paper_id'] ?? '');
  59. // 2. 保存答题记录到数据库
  60. $this->saveExamAnswerRecords($examData);
  61. // 3. 获取题目知识点映射
  62. $questionMappings = $this->getQuestionKnowledgeMappings($questions);
  63. // 4. 计算每个知识点的加权掌握度(传入学案基准难度)
  64. $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings, $examBaseDifficulty, $studentId);
  65. // 5. 更新学生掌握度
  66. $updatedMastery = $this->updateStudentMastery($studentId, $knowledgeMasteryVector);
  67. // 5. 生成题目维度分析
  68. $questionAnalysis = $this->analyzeQuestions($questions, $questionMappings);
  69. // 6. 生成知识点维度分析
  70. $knowledgePointAnalysis = $this->analyzeKnowledgePoints($knowledgeMasteryVector, $questionMappings);
  71. // 7. 生成整体掌握度总结
  72. $overallSummary = $this->generateOverallSummary($updatedMastery);
  73. // 8. 生成智能出卷推荐依据
  74. $smartQuizRecommendation = $this->generateSmartQuizRecommendation($updatedMastery);
  75. // 9. 保存分析结果
  76. $analysisResult = [
  77. 'paper_id' => $examData['paper_id'],
  78. 'student_id' => $studentId,
  79. 'timestamp' => now()->toISOString(),
  80. 'question_analysis' => $questionAnalysis,
  81. 'knowledge_point_analysis' => $knowledgePointAnalysis,
  82. 'overall_summary' => $overallSummary,
  83. 'smart_quiz_recommendation' => $smartQuizRecommendation,
  84. 'mastery_vector' => $updatedMastery,
  85. ];
  86. $this->saveAnalysisResult($studentId, $examData['paper_id'], $analysisResult);
  87. Log::info('考试答题分析完成', [
  88. 'student_id' => $studentId,
  89. 'paper_id' => $examData['paper_id'],
  90. 'analyzed_knowledge_points' => count($knowledgeMasteryVector)
  91. ]);
  92. return $analysisResult;
  93. }
  94. /**
  95. * 获取学案基准难度(映射为1-4级)
  96. */
  97. private function getExamBaseDifficulty(string $paperId): ?int
  98. {
  99. if (empty($paperId)) {
  100. return null;
  101. }
  102. try {
  103. // 从试卷表获取difficulty_category
  104. $paper = DB::table('papers')
  105. ->where('paper_id', $paperId)
  106. ->first();
  107. if (!$paper) {
  108. Log::warning('未找到试卷,尝试从缓存获取', ['paper_id' => $paperId]);
  109. return null;
  110. }
  111. $difficultyCategory = $paper->difficulty_category ?? '中等';
  112. // 映射为1-4级(筑基、提分、培优、竞赛)
  113. return match (strtolower($difficultyCategory)) {
  114. '筑基', 'easy', 'foundation', '1' => 1,
  115. '提分', 'medium', 'improvement', '2' => 2,
  116. '培优', 'hard', 'excellent', 'difficult', '3' => 3,
  117. '竞赛', 'competition', 'very hard', 'very difficult', '4' => 4,
  118. default => 2, // 默认提分
  119. };
  120. } catch (\Exception $e) {
  121. Log::warning('获取学案基准难度失败,使用默认2级', [
  122. 'paper_id' => $paperId,
  123. 'error' => $e->getMessage(),
  124. ]);
  125. return 2; // 默认中等难度
  126. }
  127. }
  128. /**
  129. * 获取题目知识点映射
  130. */
  131. private function getQuestionKnowledgeMappings(array $questions): array
  132. {
  133. $mappings = [];
  134. $failedQuestions = [];
  135. // 直接从题目数据中提取知识点信息(不再调用外部服务)
  136. foreach ($questions as $question) {
  137. $questionId = $question['question_id'] ?? null;
  138. if (!$questionId) continue;
  139. // 提取知识点信息(优先使用请求数据中的字段)
  140. $kpMapping = [];
  141. // 尝试多个可能的知识点字段
  142. $kpCode = $question['kp_code']
  143. ?? $question['knowledge_point']
  144. ?? $question['kp_code']
  145. ?? null;
  146. // 如果请求中没有知识点信息,从数据库自动获取
  147. if (empty($kpCode)) {
  148. $dbKpInfo = $this->getQuestionKnowledgePointFromDb($questionId);
  149. if ($dbKpInfo) {
  150. $kpMapping[] = [
  151. 'kp_id' => $dbKpInfo['kp_id'],
  152. 'kp_name' => $dbKpInfo['kp_name'],
  153. 'weight' => 1.0
  154. ];
  155. Log::debug('从数据库获取题目知识点', [
  156. 'question_id' => $questionId,
  157. 'kp_id' => $dbKpInfo['kp_id']
  158. ]);
  159. }
  160. } else {
  161. $kpMapping[] = [
  162. 'kp_id' => $kpCode,
  163. 'kp_name' => $question['kp_name'] ?? $kpCode,
  164. 'weight' => 1.0
  165. ];
  166. }
  167. // 如果仍然没有知识点信息,跳过该题目
  168. if (empty($kpMapping)) {
  169. $failedQuestions[] = $questionId;
  170. Log::warning('跳过无法获取知识点的题目', [
  171. 'question_id' => $questionId
  172. ]);
  173. continue;
  174. }
  175. $mappings[$questionId] = [
  176. 'question_id' => $questionId,
  177. 'kp_mapping' => $kpMapping
  178. ];
  179. }
  180. if (!empty($failedQuestions)) {
  181. Log::warning('部分题目无法获取知识点信息', [
  182. 'failed_questions' => $failedQuestions,
  183. 'total_questions' => count($questions),
  184. 'mapped_questions' => count($mappings)
  185. ]);
  186. }
  187. Log::info('题目知识点映射构建完成', [
  188. 'total_questions' => count($questions),
  189. 'mapped_questions' => count($mappings),
  190. 'failed_questions' => count($failedQuestions)
  191. ]);
  192. return $mappings;
  193. }
  194. /**
  195. * 从数据库获取题目的知识点信息
  196. */
  197. private function getQuestionKnowledgePointFromDb(string $questionId): ?array
  198. {
  199. try {
  200. // 优先从 MySQL 的 questions 表获取
  201. $question = DB::connection('mysql')
  202. ->table('questions')
  203. ->where('id', $questionId)
  204. ->orWhere('question_code', $questionId)
  205. ->first();
  206. if ($question) {
  207. // 尝试从 kp_code 或 knowledge_point 字段获取
  208. $kpCode = $question->kp_code
  209. ?? $question->knowledge_point
  210. ?? $question->knowledge_points
  211. ?? null;
  212. if ($kpCode) {
  213. return [
  214. 'kp_id' => $kpCode,
  215. 'kp_name' => $kpCode
  216. ];
  217. }
  218. }
  219. // 备选:从 QuestionBank API 获取知识点
  220. $questionBankInfo = $this->getQuestionFromQuestionBank($questionId);
  221. if ($questionBankInfo && !empty($questionBankInfo['knowledge_points'])) {
  222. $kpCode = $questionBankInfo['knowledge_points'][0]['code'] ?? null;
  223. if ($kpCode) {
  224. return [
  225. 'kp_id' => $kpCode,
  226. 'kp_name' => $questionBankInfo['knowledge_points'][0]['name'] ?? $kpCode
  227. ];
  228. }
  229. }
  230. return null;
  231. } catch (\Exception $e) {
  232. Log::warning('从数据库获取题目知识点失败', [
  233. 'question_id' => $questionId,
  234. 'error' => $e->getMessage()
  235. ]);
  236. return null;
  237. }
  238. }
  239. /**
  240. * 从 QuestionBank API 获取题目信息
  241. */
  242. private function getQuestionFromQuestionBank(string $questionId): ?array
  243. {
  244. try {
  245. $baseUrl = config('services.question_bank_api.base_url', 'http://question-bank-api:5015');
  246. $response = Http::timeout(10)->get($baseUrl . '/questions/' . $questionId);
  247. if ($response->successful()) {
  248. $data = $response->json();
  249. return $data['data'] ?? null;
  250. }
  251. return null;
  252. } catch (\Exception $e) {
  253. Log::warning('从QuestionBank获取题目信息失败', [
  254. 'question_id' => $questionId,
  255. 'error' => $e->getMessage()
  256. ]);
  257. return null;
  258. }
  259. }
  260. /**
  261. * 计算知识点掌握度向量
  262. * 【修复】集成MasteryCalculator的BKT模型进行精确计算
  263. *
  264. * 核心算法说明:
  265. * 1. 从考试答题中提取每个知识点的答题记录
  266. * 2. 调用MasteryCalculator计算掌握度(包含:正确率、难度加权、时间效率、遗忘曲线)
  267. * 3. 返回包含掌握度、置信度、趋势等完整信息的向量
  268. */
  269. private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings, ?int $examBaseDifficulty = null, ?string $studentId = null): array
  270. {
  271. // 按知识点聚合答题记录
  272. $knowledgeAttempts = [];
  273. foreach ($questions as $question) {
  274. $questionId = $question['question_id'];
  275. $score = floatval($question['score_obtained'] ?? 0);
  276. $maxScore = floatval($question['score'] ?? $score);
  277. $steps = $question['steps'] ?? [];
  278. $isCorrect = $question['is_correct'] ?? ($score >= $maxScore);
  279. $mapping = $questionMappings[$questionId] ?? null;
  280. if (!$mapping || !isset($mapping['kp_mapping'])) {
  281. continue;
  282. }
  283. // 构建答题记录(用于MasteryCalculator)
  284. $attemptRecord = [
  285. 'question_id' => $questionId,
  286. 'is_correct' => $isCorrect,
  287. 'partial_score' => $maxScore > 0 ? $score / $maxScore : 0,
  288. 'question_difficulty' => $question['difficulty'] ?? 0.6,
  289. 'attempt_time_seconds' => $question['time_spent'] ?? 120,
  290. 'completed_at' => now()->toISOString(),
  291. 'created_at' => now()->toISOString(),
  292. ];
  293. // 如果有步骤级分析,使用步骤分析
  294. if (!empty($steps)) {
  295. foreach ($steps as $step) {
  296. $kpId = $step['kp_id'];
  297. if (empty($kpId)) {
  298. Log::warning('ExamAnswerAnalysisService: 步骤缺少知识点ID', [
  299. 'question_id' => $questionId,
  300. 'step' => $step
  301. ]);
  302. continue;
  303. }
  304. if (!isset($knowledgeAttempts[$kpId])) {
  305. $knowledgeAttempts[$kpId] = [
  306. 'attempts' => [],
  307. 'step_details' => [],
  308. ];
  309. }
  310. // 每个步骤作为独立答题记录
  311. $stepAttempt = $attemptRecord;
  312. $stepAttempt['is_correct'] = $step['is_correct'];
  313. $stepAttempt['step_index'] = $step['step_index'];
  314. $knowledgeAttempts[$kpId]['attempts'][] = $stepAttempt;
  315. $knowledgeAttempts[$kpId]['step_details'][] = [
  316. 'question_id' => $questionId,
  317. 'step_index' => $step['step_index'],
  318. 'score' => $step['score'] ?? ($maxScore / count($steps)),
  319. 'is_correct' => $step['is_correct'],
  320. ];
  321. }
  322. } else {
  323. // 题目整体分析
  324. foreach ($mapping['kp_mapping'] as $kpMapping) {
  325. $kpId = $kpMapping['kp_id'];
  326. if (!isset($knowledgeAttempts[$kpId])) {
  327. $knowledgeAttempts[$kpId] = [
  328. 'attempts' => [],
  329. 'step_details' => [],
  330. ];
  331. }
  332. $knowledgeAttempts[$kpId]['attempts'][] = $attemptRecord;
  333. $knowledgeAttempts[$kpId]['step_details'][] = [
  334. 'question_id' => $questionId,
  335. 'score' => $score,
  336. 'max_score' => $maxScore,
  337. 'is_correct' => $isCorrect,
  338. ];
  339. }
  340. }
  341. }
  342. // 【核心】使用MasteryCalculator计算每个知识点的掌握度
  343. $masteryVector = [];
  344. foreach ($knowledgeAttempts as $kpId => $data) {
  345. $attempts = $data['attempts'];
  346. // 如果没有学案基准难度,使用默认值2(提分)
  347. $baseDifficulty = $examBaseDifficulty ?? 2;
  348. // 调用MasteryCalculator的核心算法(传入学案基准难度)
  349. // 该算法包含:正确率、难度加权、时间效率、技能熟练度、遗忘曲线衰减
  350. $masteryResult = $this->masteryCalculator->calculateMasteryLevel(
  351. $studentId ?? '', // 传递学生ID,用于保存掌握度到数据库
  352. $kpId,
  353. $attempts,
  354. $baseDifficulty
  355. );
  356. $masteryVector[$kpId] = [
  357. 'kp_id' => $kpId,
  358. 'mastery' => $masteryResult['mastery'],
  359. 'confidence' => $masteryResult['confidence'],
  360. 'trend' => $masteryResult['trend'],
  361. 'total_attempts' => $masteryResult['total_attempts'],
  362. 'correct_attempts' => $masteryResult['correct_attempts'],
  363. 'accuracy_rate' => $masteryResult['accuracy_rate'],
  364. 'step_details' => $data['step_details'],
  365. // 计算细节(用于调试和分析)
  366. 'calculation_details' => $masteryResult['details'] ?? [],
  367. ];
  368. }
  369. Log::info('知识点掌握度向量计算完成', [
  370. 'knowledge_points_count' => count($masteryVector),
  371. 'sample' => array_slice($masteryVector, 0, 3, true),
  372. ]);
  373. return $masteryVector;
  374. }
  375. /**
  376. * 更新学生掌握度(与历史数据合并)
  377. */
  378. private function updateStudentMastery(string $studentId, array $knowledgeMasteryVector): array
  379. {
  380. $updatedMastery = [];
  381. foreach ($knowledgeMasteryVector as $kpId => $data) {
  382. // 获取历史掌握度
  383. $historyMastery = DB::connection('mysql')
  384. ->table('student_knowledge_mastery')
  385. ->where('student_id', $studentId)
  386. ->where('kp_code', $kpId)
  387. ->first();
  388. $historyMasteryLevel = $historyMastery->mastery_level ?? 0.5;
  389. $historyWeight = $historyMastery->total_attempts ?? 0;
  390. $currentWeight = $data['total_attempts'] ?? 1;
  391. // 合并计算:历史权重 + 当前权重
  392. $newMastery = $historyWeight > 0
  393. ? ($historyWeight * $historyMasteryLevel + $currentWeight * $data['mastery'])
  394. / ($historyWeight + $currentWeight)
  395. : $data['mastery'];
  396. $newConfidence = $data['confidence'];
  397. // 保存到数据库
  398. DB::connection('mysql')
  399. ->table('student_knowledge_mastery')
  400. ->updateOrInsert(
  401. ['student_id' => $studentId, 'kp_code' => $kpId],
  402. [
  403. 'mastery_level' => $newMastery,
  404. 'confidence_level' => $newConfidence,
  405. 'total_attempts' => ($historyMastery->total_attempts ?? 0) + 1,
  406. 'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + intval($data['correct_attempts'] > 0),
  407. 'mastery_trend' => $this->determineMasteryTrend($historyMasteryLevel, $newMastery),
  408. 'last_mastery_update' => now(),
  409. 'updated_at' => now(),
  410. ]
  411. );
  412. $updatedMastery[$kpId] = [
  413. 'kp_id' => $kpId,
  414. 'current_mastery' => $newMastery,
  415. 'previous_mastery' => $historyMasteryLevel,
  416. 'confidence' => $newConfidence,
  417. 'change' => $newMastery - $historyMasteryLevel,
  418. 'weight' => $currentWeight
  419. ];
  420. }
  421. return $updatedMastery;
  422. }
  423. /**
  424. * 生成题目维度分析(包含AI分析和解题思路)
  425. */
  426. private function analyzeQuestions(array $questions, array $questionMappings): array
  427. {
  428. $analysis = [];
  429. foreach ($questions as $question) {
  430. $questionId = $question['question_id'];
  431. $score = floatval($question['score_obtained'] ?? 0);
  432. $maxScore = floatval($question['score'] ?? $score);
  433. $steps = $question['steps'] ?? [];
  434. $isCorrect = $question['is_correct'] ?? ($score >= $maxScore);
  435. $mapping = $questionMappings[$questionId] ?? ['kp_mapping' => []];
  436. if (empty($mapping['kp_mapping'])) {
  437. Log::warning('ExamAnswerAnalysisService: 题目无知识点映射', ['question_id' => $questionId]);
  438. continue;
  439. }
  440. $kpCode = $mapping['kp_mapping'][0]['kp_id'];
  441. // 步骤分析
  442. $stepAnalysis = [];
  443. if (!empty($steps)) {
  444. foreach ($steps as $step) {
  445. $kpId = $step['kp_id'];
  446. if (empty($kpId)) {
  447. Log::warning('ExamAnswerAnalysisService: 步骤缺少知识点ID', [
  448. 'question_id' => $questionId,
  449. 'step_index' => $step['step_index'] ?? 'unknown'
  450. ]);
  451. continue;
  452. }
  453. $stepAnalysis[] = [
  454. 'step_index' => $step['step_index'],
  455. 'is_correct' => $step['is_correct'],
  456. 'kp_id' => $kpId,
  457. 'description' => $step['description'] ?? ''
  458. ];
  459. }
  460. }
  461. // 知识点关联
  462. $knowledgePoints = array_map(function($kp) {
  463. return [
  464. 'kp_id' => $kp['kp_id'],
  465. 'kp_name' => $kp['kp_name'] ?? $kp['kp_id'],
  466. 'weight' => $kp['weight'] ?? 1.0
  467. ];
  468. }, $mapping['kp_mapping']);
  469. // 【集成】调用AI分析服务,获取解题思路和错误分析
  470. $aiAnalysis = $this->getQuestionAIAnalysis($question, $mapping);
  471. $analysis[] = [
  472. 'question_id' => $questionId,
  473. 'score_obtained' => $score,
  474. 'max_score' => $maxScore,
  475. 'accuracy_rate' => $maxScore > 0 ? $score / $maxScore : 0,
  476. 'is_correct' => $isCorrect,
  477. 'step_analysis' => $stepAnalysis,
  478. 'knowledge_points' => $knowledgePoints,
  479. 'performance_summary' => $this->generateQuestionPerformanceSummary($question, $stepAnalysis),
  480. // 【新增】解题思路和错误分析
  481. 'solution_process' => $aiAnalysis['solution_process'] ?? '',
  482. 'error_analysis' => $aiAnalysis['error_analysis'] ?? '',
  483. 'mistake_type' => $aiAnalysis['mistake_type'] ?? '',
  484. 'suggestions' => $aiAnalysis['suggestions'] ?? '',
  485. 'next_steps' => $aiAnalysis['next_steps'] ?? [],
  486. ];
  487. }
  488. return $analysis;
  489. }
  490. /**
  491. * 获取题目的AI分析(解题思路、错误分析)
  492. */
  493. private function getQuestionAIAnalysis(array $question, array $mapping): array
  494. {
  495. $isCorrect = $question['is_correct'] ?? false;
  496. $score = floatval($question['score_obtained'] ?? 0);
  497. $maxScore = floatval($question['score'] ?? 10);
  498. $kpCode = $mapping['kp_mapping'][0]['kp_id'] ?? null;
  499. if (empty($kpCode)) {
  500. Log::warning('ExamAnswerAnalysisService: getQuestionAIAnalysis缺少知识点ID', [
  501. 'question_id' => $question['question_id'] ?? 'unknown',
  502. 'mapping' => $mapping
  503. ]);
  504. $kpCode = 'UNKNOWN_KP';
  505. }
  506. // 调用LocalAIAnalysisService进行分析
  507. try {
  508. $analysisResult = $this->aiAnalysisService->analyzeAnswer([
  509. 'question_id' => $question['question_id'],
  510. 'question_text' => $question['question_text'] ?? '',
  511. 'student_answer' => $question['student_answer'] ?? '',
  512. 'correct_answer' => $question['correct_answer'] ?? '',
  513. 'score' => $score,
  514. 'max_score' => $maxScore,
  515. 'kp_code' => $kpCode,
  516. ]);
  517. $data = $analysisResult['data'] ?? [];
  518. // 根据正确性生成不同的解题思路
  519. if ($isCorrect) {
  520. return [
  521. 'solution_process' => $data['correct_solution'] ?? '该题作答正确,解题思路清晰',
  522. 'error_analysis' => '',
  523. 'mistake_type' => '',
  524. 'suggestions' => $data['suggestions'] ?? '继续保持良好的解题习惯',
  525. 'next_steps' => $data['next_steps'] ?? ['尝试更高难度的同类题目'],
  526. ];
  527. }
  528. // 错误题目:返回详细分析
  529. return [
  530. 'solution_process' => $data['correct_solution'] ?? '请参考标准解题步骤',
  531. 'error_analysis' => $data['reason'] ?? '解题过程中存在错误',
  532. 'mistake_type' => $data['mistake_type'] ?? '计算或理解错误',
  533. 'suggestions' => $data['suggestions'] ?? '建议针对薄弱知识点进行专项练习',
  534. 'next_steps' => $data['next_steps'] ?? ['复习相关知识点', '做同类型练习题'],
  535. ];
  536. } catch (\Exception $e) {
  537. Log::warning('AI分析失败,使用默认分析', [
  538. 'question_id' => $question['question_id'],
  539. 'error' => $e->getMessage(),
  540. ]);
  541. // 回退到基础分析
  542. return $this->getFallbackAnalysis($question, $isCorrect);
  543. }
  544. }
  545. /**
  546. * 回退分析(当AI分析失败时)
  547. */
  548. private function getFallbackAnalysis(array $question, bool $isCorrect): array
  549. {
  550. if ($isCorrect) {
  551. return [
  552. 'solution_process' => '该题作答正确',
  553. 'error_analysis' => '',
  554. 'mistake_type' => '',
  555. 'suggestions' => '继续保持',
  556. 'next_steps' => ['尝试更高难度的题目'],
  557. ];
  558. }
  559. $scoreRatio = floatval($question['score_obtained'] ?? 0) / max(floatval($question['score'] ?? 1), 1);
  560. return [
  561. 'solution_process' => '请参考标准答案和解题步骤',
  562. 'error_analysis' => $scoreRatio < 0.3 ? '知识点理解存在偏差' : '解题过程中出现错误',
  563. 'mistake_type' => $scoreRatio < 0.3 ? '概念错误' : '计算/步骤错误',
  564. 'suggestions' => '建议复习相关知识点,加强练习',
  565. 'next_steps' => ['复习基础概念', '做同类型练习题', '请教老师或同学'],
  566. ];
  567. }
  568. /**
  569. * 生成知识点维度分析
  570. */
  571. private function analyzeKnowledgePoints(array $knowledgeMasteryVector, array $questionMappings): array
  572. {
  573. $analysis = [];
  574. foreach ($knowledgeMasteryVector as $kpId => $data) {
  575. $analysis[] = [
  576. 'kp_id' => $kpId,
  577. 'mastery_level' => $data['mastery'],
  578. 'confidence_level' => $data['confidence'],
  579. 'performance_in_exam' => $this->evaluatePerformanceLevel($data['mastery']),
  580. 'evidence_count' => count($data['step_details']),
  581. 'step_evidence' => $data['step_details'],
  582. 'recommendation' => $this->generateKnowledgePointRecommendation($data)
  583. ];
  584. }
  585. return $analysis;
  586. }
  587. /**
  588. * 生成整体掌握度总结
  589. */
  590. private function generateOverallSummary(array $updatedMastery): array
  591. {
  592. $knowledgePoints = array_values($updatedMastery);
  593. if (empty($knowledgePoints)) {
  594. return [
  595. 'total_knowledge_points' => 0,
  596. 'average_mastery' => 0,
  597. 'mastery_distribution' => [
  598. 'mastered' => 0,
  599. 'good' => 0,
  600. 'weak' => 0
  601. ],
  602. 'top_strengths' => [],
  603. 'top_weaknesses' => []
  604. ];
  605. }
  606. // 计算平均掌握度
  607. $averageMastery = array_sum(array_column($knowledgePoints, 'current_mastery')) / count($knowledgePoints);
  608. // 掌握度分布
  609. $mastered = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] >= 0.85);
  610. $good = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] >= 0.70 && $kp['current_mastery'] < 0.85);
  611. $weak = array_filter($knowledgePoints, fn($kp) => $kp['current_mastery'] < 0.70);
  612. // 排序找出优势和薄弱点
  613. usort($knowledgePoints, fn($a, $b) => $b['current_mastery'] <=> $a['current_mastery']);
  614. $topStrengths = array_slice($knowledgePoints, 0, 3);
  615. $topWeaknesses = array_slice(array_reverse($knowledgePoints), 0, 3);
  616. return [
  617. 'total_knowledge_points' => count($knowledgePoints),
  618. 'average_mastery' => round($averageMastery, 4),
  619. 'mastery_distribution' => [
  620. 'mastered' => count($mastered),
  621. 'good' => count($good),
  622. 'weak' => count($weak)
  623. ],
  624. 'top_strengths' => $topStrengths,
  625. 'top_weaknesses' => $topWeaknesses,
  626. 'overall_performance' => $this->evaluateOverallPerformance($averageMastery)
  627. ];
  628. }
  629. /**
  630. * 生成智能出卷推荐依据
  631. * 基于文档中的推荐优先级算法
  632. */
  633. private function generateSmartQuizRecommendation(array $updatedMastery): array
  634. {
  635. $recommendations = [];
  636. foreach ($updatedMastery as $kpId => $data) {
  637. $mastery = $data['current_mastery'];
  638. $confidence = $data['confidence'];
  639. $weight = $data['weight'];
  640. // 推荐优先级 = (1 - 掌握度) * 重要性 * 覆盖需求
  641. // 重要性可以根据知识点在中考/阶段考试中的权重,这里简化为1.0
  642. $importance = 1.0;
  643. // 覆盖需求:最近没考过或考得少,值大
  644. $coverageNeed = max(1.0, 1.5 - ($weight / 10));
  645. $priority = (1 - $mastery) * $importance * $coverageNeed;
  646. $recommendations[] = [
  647. 'kp_id' => $kpId,
  648. 'current_mastery' => $mastery,
  649. 'priority' => $priority,
  650. 'recommended_questions' => $this->calculateRecommendedQuestions($mastery),
  651. 'focus_type' => $this->determineFocusType($mastery)
  652. ];
  653. }
  654. // 按优先级排序
  655. usort($recommendations, fn($a, $b) => $b['priority'] <=> $a['priority']);
  656. // 控制难度节奏:40%巩固型 + 40%修补型 + 20%挑战型
  657. $totalRecommendations = count($recommendations);
  658. $consolidation = array_slice($recommendations, 0, intval($totalRecommendations * 0.4));
  659. $remediation = array_slice($recommendations, intval($totalRecommendations * 0.4), intval($totalRecommendations * 0.4));
  660. $challenge = array_slice($recommendations, intval($totalRecommendations * 0.8));
  661. return [
  662. 'priority_list' => $recommendations,
  663. 'quiz_structure' => [
  664. 'consolidation_type' => $consolidation,
  665. 'remediation_type' => $remediation,
  666. 'challenge_type' => $challenge
  667. ],
  668. 'total_recommended_questions' => array_sum(array_column($recommendations, 'recommended_questions'))
  669. ];
  670. }
  671. /**
  672. * 保存考试答题记录
  673. */
  674. private function saveExamAnswerRecords(array $examData): void
  675. {
  676. $studentId = $examData['student_id'];
  677. $examId = $examData['paper_id'];
  678. // 【修复】先清理该考试的所有答题记录(支持重复考试)
  679. DB::connection('mysql')->table('student_answer_questions')
  680. ->where('student_id', $studentId)
  681. ->where('exam_id', $examId)
  682. ->delete();
  683. DB::connection('mysql')->table('student_answer_steps')
  684. ->where('student_id', $studentId)
  685. ->where('exam_id', $examId)
  686. ->delete();
  687. foreach ($examData['questions'] as $question) {
  688. $questionId = $question['question_id'];
  689. $steps = $question['steps'] ?? [];
  690. // 保存步骤级记录
  691. if (!empty($steps)) {
  692. foreach ($steps as $step) {
  693. $kpId = $step['kp_id'] ?? null;
  694. if (empty($kpId)) {
  695. Log::warning('ExamAnswerAnalysisService: 步骤保存缺少知识点ID', [
  696. 'student_id' => $studentId,
  697. 'exam_id' => $examId,
  698. 'question_id' => $questionId,
  699. 'step_index' => $step['step_index'] ?? 'unknown'
  700. ]);
  701. continue;
  702. }
  703. DB::connection('mysql')->table('student_answer_steps')->insert([
  704. 'student_id' => $studentId,
  705. 'exam_id' => $examId,
  706. 'question_id' => $questionId,
  707. 'step_index' => $step['step_index'],
  708. 'kp_id' => $kpId,
  709. 'is_correct' => $step['is_correct'],
  710. 'step_score' => $step['score'] ?? 0,
  711. 'created_at' => now(),
  712. 'updated_at' => now(),
  713. ]);
  714. }
  715. } else {
  716. // 保存题目级记录
  717. try {
  718. DB::connection('mysql')->table('student_answer_questions')->insertOrIgnore([
  719. 'student_id' => $studentId,
  720. 'exam_id' => $examId,
  721. 'question_id' => $questionId,
  722. 'score_obtained' => $question['score_obtained'] ?? 0,
  723. 'max_score' => $question['score'] ?? 0,
  724. 'created_at' => now(),
  725. 'updated_at' => now(),
  726. ]);
  727. } catch (\Exception $e) {
  728. Log::warning('保存答题记录失败', [
  729. 'student_id' => $studentId,
  730. 'exam_id' => $examId,
  731. 'question_id' => $questionId,
  732. 'error' => $e->getMessage(),
  733. ]);
  734. }
  735. }
  736. }
  737. Log::info('答题记录保存完成', [
  738. 'student_id' => $studentId,
  739. 'exam_id' => $examId,
  740. 'question_count' => count($examData['questions']),
  741. ]);
  742. }
  743. /**
  744. * 保存分析结果并创建掌握度快照
  745. */
  746. private function saveAnalysisResult(string $studentId, string $paperId, array $result): void
  747. {
  748. // 【修复】支持重复分析:先删除旧的分析结果
  749. DB::connection('mysql')->table('exam_analysis_results')
  750. ->where('student_id', $studentId)
  751. ->where('paper_id', $paperId)
  752. ->delete();
  753. // 插入新的分析结果
  754. DB::connection('mysql')->table('exam_analysis_results')->insert([
  755. 'student_id' => $studentId,
  756. 'paper_id' => $paperId,
  757. 'analysis_data' => json_encode($result),
  758. 'created_at' => now(),
  759. 'updated_at' => now(),
  760. ]);
  761. Log::info('分析结果保存完成', [
  762. 'student_id' => $studentId,
  763. 'paper_id' => $paperId,
  764. 'data_size' => strlen(json_encode($result)),
  765. ]);
  766. // 【集成】创建知识点掌握度快照
  767. $this->createMasterySnapshot($studentId, $paperId, $result);
  768. // 【新增】异步生成学情分析PDF
  769. try {
  770. Log::info('开始异步生成学情分析PDF', [
  771. 'student_id' => $studentId,
  772. 'paper_id' => $paperId,
  773. ]);
  774. // 使用队列异步生成PDF,避免阻塞主流程
  775. dispatch(new \App\Jobs\GenerateAnalysisPdfJob($paperId, $studentId, null));
  776. Log::info('PDF生成任务已加入队列', [
  777. 'student_id' => $studentId,
  778. 'paper_id' => $paperId,
  779. ]);
  780. } catch (\Exception $e) {
  781. Log::error('PDF生成任务加入队列失败', [
  782. 'student_id' => $studentId,
  783. 'paper_id' => $paperId,
  784. 'error' => $e->getMessage(),
  785. ]);
  786. }
  787. }
  788. /**
  789. * 创建知识点掌握度快照
  790. * 【集成】使用LocalAIAnalysisService的快照功能
  791. *
  792. * 快照用途:
  793. * 1. 追踪学生掌握度变化趋势
  794. * 2. 生成学情报告时对比历史数据
  795. * 3. 为智能出卷提供决策依据
  796. */
  797. private function createMasterySnapshot(string $studentId, string $paperId, array $analysisResult): void
  798. {
  799. try {
  800. // 计算快照数据
  801. $masteryVector = $analysisResult['mastery_vector'] ?? [];
  802. $overallMastery = 0;
  803. $weakCount = 0;
  804. $strongCount = 0;
  805. foreach ($masteryVector as $kpData) {
  806. $mastery = $kpData['current_mastery'] ?? $kpData['mastery'] ?? 0;
  807. $overallMastery += $mastery;
  808. if ($mastery < 0.6) {
  809. $weakCount++;
  810. } elseif ($mastery >= 0.85) {
  811. $strongCount++;
  812. }
  813. }
  814. $kpCount = count($masteryVector);
  815. $overallMastery = $kpCount > 0 ? round($overallMastery / $kpCount, 4) : 0;
  816. // 生成快照ID
  817. $snapshotId = 'snap_' . $paperId . '_' . now()->format('YmdHis');
  818. // 保存到快照表
  819. DB::connection('mysql')->table('knowledge_point_mastery_snapshots')->insert([
  820. 'snapshot_id' => $snapshotId,
  821. 'student_id' => $studentId,
  822. 'paper_id' => $paperId,
  823. 'answer_record_id' => null,
  824. 'mastery_data' => json_encode($masteryVector),
  825. 'overall_mastery' => $overallMastery,
  826. 'weak_knowledge_points_count' => $weakCount,
  827. 'strong_knowledge_points_count' => $strongCount,
  828. 'snapshot_time' => now(),
  829. 'analysis_id' => null,
  830. 'created_at' => now(),
  831. 'updated_at' => now(),
  832. ]);
  833. Log::info('掌握度快照创建成功', [
  834. 'snapshot_id' => $snapshotId,
  835. 'student_id' => $studentId,
  836. 'paper_id' => $paperId,
  837. 'overall_mastery' => $overallMastery,
  838. 'weak_count' => $weakCount,
  839. 'strong_count' => $strongCount,
  840. ]);
  841. } catch (\Exception $e) {
  842. // 快照创建失败不影响主流程
  843. Log::warning('掌握度快照创建失败', [
  844. 'student_id' => $studentId,
  845. 'paper_id' => $paperId,
  846. 'error' => $e->getMessage(),
  847. ]);
  848. }
  849. }
  850. /**
  851. * 判断掌握度趋势
  852. */
  853. private function determineMasteryTrend(float $previous, float $current): string
  854. {
  855. $change = $current - $previous;
  856. if ($change > 0.1) {
  857. return 'improving';
  858. } elseif ($change < -0.1) {
  859. return 'declining';
  860. } else {
  861. return 'stable';
  862. }
  863. }
  864. /**
  865. * 评估表现水平
  866. */
  867. private function evaluatePerformanceLevel(float $mastery): string
  868. {
  869. if ($mastery >= 0.85) {
  870. return 'excellent';
  871. } elseif ($mastery >= 0.70) {
  872. return 'good';
  873. } elseif ($mastery >= 0.50) {
  874. return 'fair';
  875. } else {
  876. return 'poor';
  877. }
  878. }
  879. /**
  880. * 生成题目表现总结
  881. */
  882. private function generateQuestionPerformanceSummary(array $question, array $stepAnalysis): string
  883. {
  884. if (empty($stepAnalysis)) {
  885. return '整题作答';
  886. }
  887. $correctSteps = count(array_filter($stepAnalysis, fn($s) => $s['is_correct']));
  888. $totalSteps = count($stepAnalysis);
  889. if ($correctSteps === $totalSteps) {
  890. return '所有步骤正确';
  891. } elseif ($correctSteps > 0) {
  892. return "部分正确 ({$correctSteps}/{$totalSteps} 步骤正确)";
  893. } else {
  894. return '所有步骤错误';
  895. }
  896. }
  897. /**
  898. * 生成知识点建议
  899. */
  900. private function generateKnowledgePointRecommendation(array $data): string
  901. {
  902. $mastery = $data['mastery'];
  903. if ($mastery >= 0.85) {
  904. return '掌握良好,可安排综合练习';
  905. } elseif ($mastery >= 0.70) {
  906. return '基本掌握,建议加强练习';
  907. } elseif ($mastery >= 0.50) {
  908. return '需要重点练习,建议安排专项训练';
  909. } else {
  910. return '薄弱知识点,建议系统学习和大量练习';
  911. }
  912. }
  913. /**
  914. * 评估整体表现
  915. */
  916. private function evaluateOverallPerformance(float $averageMastery): string
  917. {
  918. if ($averageMastery >= 0.85) {
  919. return '优秀';
  920. } elseif ($averageMastery >= 0.70) {
  921. return '良好';
  922. } elseif ($averageMastery >= 0.50) {
  923. return '一般';
  924. } else {
  925. return '需加强';
  926. }
  927. }
  928. /**
  929. * 计算推荐题目数量
  930. */
  931. private function calculateRecommendedQuestions(float $mastery): int
  932. {
  933. if ($mastery >= 0.85) {
  934. return 1; // 巩固型:1题
  935. } elseif ($mastery >= 0.50) {
  936. return 2; // 修补型:2题
  937. } else {
  938. return 3; // 挑战型:3题
  939. }
  940. }
  941. /**
  942. * 确定重点类型
  943. */
  944. private function determineFocusType(float $mastery): string
  945. {
  946. if ($mastery >= 0.70 && $mastery < 0.85) {
  947. return 'consolidation'; // 巩固型
  948. } elseif ($mastery < 0.70) {
  949. return 'remediation'; // 修补型
  950. } else {
  951. return 'challenge'; // 挑战型
  952. }
  953. }
  954. /**
  955. * 自动计算题目分数
  956. * 如果用户未提供 score 和 score_obtained,则根据 is_correct 字段自动计算
  957. *
  958. * @param array $questions 题目列表
  959. * @return array 处理后的题目列表
  960. */
  961. private function autoCalculateScores(array $questions): array
  962. {
  963. foreach ($questions as &$question) {
  964. // 如果用户没有提供 score,尝试从数据库获取或使用默认值
  965. if (!isset($question['score'])) {
  966. $question['score'] = $this->getQuestionDefaultScore($question['question_id'] ?? '');
  967. }
  968. // 如果用户没有提供 score_obtained,根据 is_correct 计算
  969. if (!isset($question['score_obtained'])) {
  970. $question['score_obtained'] = $this->calculateScoreObtained(
  971. $question['score'] ?? 0,
  972. $question['is_correct'] ?? []
  973. );
  974. }
  975. Log::debug('自动计算分数', [
  976. 'question_id' => $question['question_id'],
  977. 'default_score' => $question['score'],
  978. 'score_obtained' => $question['score_obtained'],
  979. 'is_correct' => $question['is_correct'] ?? []
  980. ]);
  981. }
  982. return $questions;
  983. }
  984. /**
  985. * 获取题目默认分数
  986. */
  987. private function getQuestionDefaultScore(string $questionId): float
  988. {
  989. if (empty($questionId)) {
  990. return 2.0; // 默认分数
  991. }
  992. try {
  993. // 尝试从题库获取题目分数
  994. $question = DB::connection('mysql')
  995. ->table('questions')
  996. ->where('id', $questionId)
  997. ->orWhere('question_code', $questionId)
  998. ->first();
  999. if ($question && isset($question->score)) {
  1000. return (float) $question->score;
  1001. }
  1002. // 如果没有找到,根据题目ID生成一个合理的默认分数
  1003. // 这里可以根据需要调整默认分数逻辑
  1004. return 2.0;
  1005. } catch (\Exception $e) {
  1006. Log::warning('获取题目默认分数失败,使用默认值', [
  1007. 'question_id' => $questionId,
  1008. 'error' => $e->getMessage()
  1009. ]);
  1010. return 2.0;
  1011. }
  1012. }
  1013. /**
  1014. * 根据 is_correct 数组计算得分
  1015. *
  1016. * @param float $totalScore 总分
  1017. * @param array $isCorrect 正确性数组 [0, 1, 1] 表示第1题错误,第2、3题正确
  1018. * @return float 得分
  1019. */
  1020. private function calculateScoreObtained(float $totalScore, array $isCorrect): float
  1021. {
  1022. if (empty($isCorrect)) {
  1023. return 0.0;
  1024. }
  1025. $correctCount = array_sum($isCorrect);
  1026. $totalCount = count($isCorrect);
  1027. if ($totalCount === 0) {
  1028. return 0.0;
  1029. }
  1030. // 按正确率计算得分
  1031. $scoreRatio = $correctCount / $totalCount;
  1032. return round($totalScore * $scoreRatio, 2);
  1033. }
  1034. }