ExamAnswerAnalysisService.php 45 KB

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