IntelligentExamController.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. <?php
  2. namespace App\Http\Controllers\Api;
  3. use App\Http\Controllers\Controller;
  4. use App\Models\Paper;
  5. use App\Models\PaperQuestion;
  6. use App\Services\LearningAnalyticsService;
  7. use App\Services\ExamPdfExportService;
  8. use App\Services\QuestionBankService;
  9. use Illuminate\Http\JsonResponse;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Support\Facades\Http;
  12. use Illuminate\Support\Facades\Log;
  13. use Illuminate\Support\Facades\URL;
  14. class IntelligentExamController extends Controller
  15. {
  16. private LearningAnalyticsService $learningAnalyticsService;
  17. private QuestionBankService $questionBankService;
  18. private ExamPdfExportService $pdfExportService;
  19. public function __construct(
  20. LearningAnalyticsService $learningAnalyticsService,
  21. QuestionBankService $questionBankService,
  22. ExamPdfExportService $pdfExportService
  23. ) {
  24. $this->learningAnalyticsService = $learningAnalyticsService;
  25. $this->questionBankService = $questionBankService;
  26. $this->pdfExportService = $pdfExportService;
  27. }
  28. /**
  29. * 外部API:生成智能试卷(异步模式)
  30. * 立即返回任务ID,PDF生成在后台进行,完成后通过回调通知
  31. */
  32. public function store(Request $request): JsonResponse
  33. {
  34. $normalized = $this->normalizePayload($request->all());
  35. $validator = validator($normalized, [
  36. 'student_id' => 'required|string',
  37. 'teacher_id' => 'required|string',
  38. 'paper_name' => 'nullable|string|max:255',
  39. 'grade' => 'nullable|string|max:50',
  40. 'total_questions' => 'required|integer|min:6|max:100',
  41. 'difficulty_category' => 'nullable|string',
  42. 'kp_codes' => 'nullable|array',
  43. 'kp_codes.*' => 'string',
  44. 'skills' => 'array',
  45. 'skills.*' => 'string',
  46. 'question_type_ratio' => 'array',
  47. 'difficulty_ratio' => 'array',
  48. 'total_score' => 'nullable|numeric|min:1|max:1000',
  49. ]);
  50. if ($validator->fails()) {
  51. return response()->json([
  52. 'success' => false,
  53. 'message' => '参数错误',
  54. 'errors' => $validator->errors()->toArray(),
  55. ], 422);
  56. }
  57. $data = $validator->validated();
  58. // 确保 kp_codes 是数组,如果为空则设置为空数组
  59. $data['kp_codes'] = $data['kp_codes'] ?? [];
  60. if (!is_array($data['kp_codes'])) {
  61. $data['kp_codes'] = [];
  62. }
  63. $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
  64. $difficultyRatio = $this->normalizeDifficultyRatio($data['difficulty_ratio'] ?? []);
  65. $paperName = $data['paper_name'] ?? ('智能试卷_' . now()->format('Ymd_His'));
  66. $difficultyCategory = $this->normalizeDifficultyCategory($data['difficulty_category'] ?? null);
  67. try {
  68. // 第一步:生成智能试卷(同步)
  69. $result = $this->learningAnalyticsService->generateIntelligentExam([
  70. 'student_id' => $data['student_id'],
  71. 'grade' => $data['grade'] ?? null,
  72. 'total_questions' => $data['total_questions'],
  73. 'kp_codes' => $data['kp_codes'],
  74. 'skills' => $data['skills'] ?? [],
  75. 'question_type_ratio' => $questionTypeRatio,
  76. 'difficulty_ratio' => $difficultyRatio,
  77. ]);
  78. if (empty($result['success'])) {
  79. return response()->json([
  80. 'success' => false,
  81. 'message' => $result['message'] ?? '智能出卷失败',
  82. ], 400);
  83. }
  84. $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
  85. if (empty($questions)) {
  86. return response()->json([
  87. 'success' => false,
  88. 'message' => '未能生成有效题目,请检查知识点或题库数据',
  89. ], 400);
  90. }
  91. $totalScore = array_sum(array_column($questions, 'score'));
  92. // 第二步:保存试卷到数据库(同步)
  93. $paperId = $this->questionBankService->saveExamToDatabase([
  94. 'paper_name' => $paperName,
  95. 'student_id' => $data['student_id'],
  96. 'teacher_id' => $data['teacher_id'],
  97. 'difficulty_category' => $difficultyCategory,
  98. 'total_score' => $data['total_score'] ?? $totalScore,
  99. 'questions' => $questions,
  100. ]);
  101. if (!$paperId) {
  102. return response()->json([
  103. 'success' => false,
  104. 'message' => '试卷保存失败',
  105. ], 500);
  106. }
  107. // 第三步:创建异步任务(异步)
  108. $taskId = $this->createAsyncTask($paperId, $data);
  109. // 立即返回完整的试卷数据(不等待PDF生成)
  110. $examContent = $this->buildCompleteExamContent($paperId);
  111. $payload = [
  112. 'success' => true,
  113. 'message' => '智能试卷创建成功,PDF正在后台生成...',
  114. 'data' => [
  115. 'task_id' => $taskId,
  116. 'paper_id' => $paperId,
  117. 'status' => 'processing',
  118. 'exam_content' => $examContent,
  119. 'urls' => [
  120. // 通过paper_id获取HTML预览
  121. 'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
  122. 'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
  123. ],
  124. 'pdfs' => [
  125. // PDF生成完成后通过状态查询获取
  126. 'exam_paper_pdf' => null,
  127. 'grading_pdf' => null,
  128. ],
  129. 'stats' => $result['stats'] ?? null,
  130. 'created_at' => now()->toISOString(),
  131. ],
  132. ];
  133. return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES);
  134. } catch (\Exception $e) {
  135. Log::error('Intelligent exam API failed', [
  136. 'error' => $e->getMessage(),
  137. 'trace' => $e->getTraceAsString(),
  138. ]);
  139. return response()->json([
  140. 'success' => false,
  141. 'message' => '服务异常,请稍后重试',
  142. ], 500);
  143. }
  144. }
  145. /**
  146. * 轮询任务状态
  147. */
  148. public function status(string $taskId): JsonResponse
  149. {
  150. try {
  151. $task = $this->getTaskStatus($taskId);
  152. if (!$task) {
  153. return response()->json([
  154. 'success' => false,
  155. 'message' => '任务不存在',
  156. ], 404);
  157. }
  158. return response()->json([
  159. 'success' => true,
  160. 'data' => $task,
  161. ]);
  162. } catch (\Exception $e) {
  163. Log::error('查询任务状态失败', [
  164. 'task_id' => $taskId,
  165. 'error' => $e->getMessage(),
  166. ]);
  167. return response()->json([
  168. 'success' => false,
  169. 'message' => '查询失败,请稍后重试',
  170. ], 500);
  171. }
  172. }
  173. /**
  174. * 创建异步任务
  175. */
  176. private function createAsyncTask(string $paperId, array $data): string
  177. {
  178. $taskId = 'task_' . uniqid() . '_' . substr(md5($paperId . time()), 0, 8);
  179. // 保存任务信息到缓存
  180. $taskData = [
  181. 'task_id' => $taskId,
  182. 'paper_id' => $paperId,
  183. 'status' => 'processing',
  184. 'created_at' => now()->toISOString(),
  185. 'updated_at' => now()->toISOString(),
  186. 'progress' => 0,
  187. 'message' => '正在生成试卷...',
  188. 'data' => $data,
  189. 'callback_url' => $data['callback_url'] ?? null, // 支持回调URL
  190. ];
  191. // 保存到缓存,24小时过期
  192. cache()->put("exam_task:{$taskId}", $taskData, now()->addDay());
  193. // 触发后台处理(在实际项目中,这里应该使用队列)
  194. // dispatch(new GenerateExamPdfJob($taskId, $paperId));
  195. // 目前使用同步调用来模拟异步
  196. $this->processPdfGeneration($taskId, $paperId);
  197. return $taskId;
  198. }
  199. /**
  200. * 获取任务状态
  201. */
  202. private function getTaskStatus(string $taskId): ?array
  203. {
  204. return cache()->get("exam_task:{$taskId}");
  205. }
  206. /**
  207. * 处理PDF生成(模拟后台任务)
  208. * 在实际项目中,这个方法应该在队列worker中执行
  209. */
  210. private function processPdfGeneration(string $taskId, string $paperId): void
  211. {
  212. try {
  213. // 更新任务状态
  214. $this->updateTaskStatus($taskId, [
  215. 'status' => 'processing',
  216. 'progress' => 10,
  217. 'message' => '开始生成试卷PDF...',
  218. ]);
  219. // 生成试卷PDF
  220. $pdfUrl = $this->pdfExportService->generateExamPdf($paperId)
  221. ?? $this->questionBankService->exportExamToPdf($paperId)
  222. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
  223. $this->updateTaskStatus($taskId, [
  224. 'progress' => 50,
  225. 'message' => '试卷PDF生成完成,开始生成判卷PDF...',
  226. ]);
  227. // 生成判卷PDF
  228. $gradingPdfUrl = $this->pdfExportService->generateGradingPdf($paperId)
  229. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
  230. // 构建完整的试卷内容
  231. $examContent = $this->buildCompleteExamContent($paperId);
  232. // 更新任务状态为完成
  233. $this->updateTaskStatus($taskId, [
  234. 'status' => 'completed',
  235. 'progress' => 100,
  236. 'message' => 'PDF生成完成',
  237. 'exam_content' => $examContent, // 包含完整试卷数据
  238. 'pdfs' => [
  239. 'exam_paper_pdf' => $pdfUrl,
  240. 'grading_pdf' => $gradingPdfUrl,
  241. ],
  242. 'completed_at' => now()->toISOString(),
  243. ]);
  244. Log::info('异步任务完成', [
  245. 'task_id' => $taskId,
  246. 'paper_id' => $paperId,
  247. 'pdf_url' => $pdfUrl,
  248. 'grading_pdf_url' => $gradingPdfUrl,
  249. ]);
  250. // 发送回调通知(如果提供了callback_url)
  251. $this->sendCallbackNotification($taskId);
  252. } catch (\Exception $e) {
  253. Log::error('PDF生成失败', [
  254. 'task_id' => $taskId,
  255. 'paper_id' => $paperId,
  256. 'error' => $e->getMessage(),
  257. ]);
  258. // 更新任务状态为失败
  259. $this->updateTaskStatus($taskId, [
  260. 'status' => 'failed',
  261. 'progress' => 0,
  262. 'message' => 'PDF生成失败: ' . $e->getMessage(),
  263. 'error' => $e->getMessage(),
  264. ]);
  265. }
  266. }
  267. /**
  268. * 更新任务状态
  269. */
  270. private function updateTaskStatus(string $taskId, array $updates): void
  271. {
  272. $task = $this->getTaskStatus($taskId);
  273. if (!$task) {
  274. return;
  275. }
  276. $updatedTask = array_merge($task, $updates, [
  277. 'updated_at' => now()->toISOString(),
  278. ]);
  279. cache()->put("exam_task:{$taskId}", $updatedTask, now()->addDay());
  280. }
  281. /**
  282. * 发送回调通知
  283. */
  284. private function sendCallbackNotification(string $taskId): void
  285. {
  286. $task = $this->getTaskStatus($taskId);
  287. if (!$task || !$task['callback_url']) {
  288. return; // 没有回调URL,不需要发送通知
  289. }
  290. try {
  291. $payload = [
  292. 'task_id' => $task['task_id'],
  293. 'paper_id' => $task['paper_id'],
  294. 'status' => $task['status'],
  295. 'exam_content' => $task['exam_content'] ?? null,
  296. 'pdfs' => $task['pdfs'] ?? null,
  297. 'stats' => $task['stats'] ?? null,
  298. 'completed_at' => $task['completed_at'],
  299. 'callback_type' => 'exam_pdf_generated',
  300. ];
  301. $response = Http::timeout(30)
  302. ->post($task['callback_url'], $payload);
  303. if ($response->successful()) {
  304. Log::info('回调通知发送成功', [
  305. 'task_id' => $taskId,
  306. 'callback_url' => $task['callback_url'],
  307. ]);
  308. } else {
  309. Log::warning('回调通知发送失败', [
  310. 'task_id' => $taskId,
  311. 'callback_url' => $task['callback_url'],
  312. 'status' => $response->status(),
  313. ]);
  314. }
  315. } catch (\Exception $e) {
  316. Log::error('回调通知异常', [
  317. 'task_id' => $taskId,
  318. 'callback_url' => $task['callback_url'] ?? 'unknown',
  319. 'error' => $e->getMessage(),
  320. ]);
  321. }
  322. }
  323. /**
  324. * 兼容字符串/数组入参
  325. */
  326. private function normalizePayload(array $payload): array
  327. {
  328. // 处理 kp_codes:空字符串或null转换为空数组
  329. if (isset($payload['kp_codes'])) {
  330. if (is_string($payload['kp_codes'])) {
  331. $kpCodes = trim($payload['kp_codes']);
  332. if (empty($kpCodes)) {
  333. $payload['kp_codes'] = [];
  334. } else {
  335. $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $kpCodes))));
  336. }
  337. } elseif (!is_array($payload['kp_codes'])) {
  338. $payload['kp_codes'] = [];
  339. }
  340. } else {
  341. $payload['kp_codes'] = [];
  342. }
  343. if (isset($payload['skills']) && is_string($payload['skills'])) {
  344. $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills']))));
  345. }
  346. return $payload;
  347. }
  348. private function normalizeQuestionTypeRatio(array $input): array
  349. {
  350. // 默认按 4:2:4
  351. $defaults = [
  352. '选择题' => 40,
  353. '填空题' => 20,
  354. '解答题' => 40,
  355. ];
  356. $normalized = [];
  357. foreach ($input as $key => $value) {
  358. if (!is_numeric($value)) {
  359. continue;
  360. }
  361. $type = $this->normalizeQuestionTypeKey($key);
  362. if ($type) {
  363. $normalized[$type] = (float) $value;
  364. }
  365. }
  366. $merged = array_merge($defaults, $normalized);
  367. // 归一化到 100%
  368. $sum = array_sum($merged);
  369. if ($sum > 0) {
  370. foreach ($merged as $k => $v) {
  371. $merged[$k] = round(($v / $sum) * 100, 2);
  372. }
  373. }
  374. return $merged;
  375. }
  376. private function normalizeQuestionTypeKey(string $key): ?string
  377. {
  378. $key = trim($key);
  379. if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice'])) {
  380. return '选择题';
  381. }
  382. if (in_array($key, ['fill', '填空题', 'blank'])) {
  383. return '填空题';
  384. }
  385. if (in_array($key, ['answer', '解答题', '计算题'])) {
  386. return '解答题';
  387. }
  388. return null;
  389. }
  390. private function normalizeDifficultyRatio(array $input): array
  391. {
  392. $defaults = [
  393. '基础' => 50,
  394. '中等' => 35,
  395. '拔高' => 15,
  396. ];
  397. $normalized = [];
  398. foreach ($input as $key => $value) {
  399. if (!is_numeric($value)) {
  400. continue;
  401. }
  402. $label = trim($key);
  403. if (in_array($label, ['基础', 'easy', '简单'])) {
  404. $normalized['基础'] = (float) $value;
  405. } elseif (in_array($label, ['中等', 'medium'])) {
  406. $normalized['中等'] = (float) $value;
  407. } elseif (in_array($label, ['拔高', 'hard', '困难', '竞赛'])) {
  408. $normalized['拔高'] = (float) $value;
  409. }
  410. }
  411. return array_merge($defaults, $normalized);
  412. }
  413. private function normalizeDifficultyCategory(?string $category): string
  414. {
  415. if (!$category) {
  416. return '基础';
  417. }
  418. $category = trim($category);
  419. if (in_array($category, ['基础', '进阶', '中等', 'easy'])) {
  420. return $category === 'easy' ? '基础' : $category;
  421. }
  422. if (in_array($category, ['拔高', '困难', 'hard', '竞赛'])) {
  423. return '拔高';
  424. }
  425. return '基础';
  426. }
  427. private function hydrateQuestions(array $questions, array $kpCodes): array
  428. {
  429. $normalized = [];
  430. foreach ($questions as $question) {
  431. $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
  432. $score = $question['score'] ?? $this->defaultScore($type);
  433. $normalized[] = [
  434. 'id' => $question['id'] ?? $question['question_id'] ?? null,
  435. 'question_id' => $question['question_id'] ?? null,
  436. 'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
  437. 'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
  438. 'content' => $question['content'] ?? $question['stem'] ?? '',
  439. 'options' => $question['options'] ?? ($question['choices'] ?? []),
  440. 'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
  441. 'solution' => $question['solution'] ?? '',
  442. 'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
  443. 'score' => $score,
  444. 'estimated_time' => $question['estimated_time'] ?? 300,
  445. 'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
  446. 'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
  447. ];
  448. }
  449. return array_values(array_filter($normalized, fn ($q) => !empty($q['id'])));
  450. }
  451. private function guessType(array $question): string
  452. {
  453. if (!empty($question['options']) && is_array($question['options'])) {
  454. return '选择题';
  455. }
  456. $content = $question['stem'] ?? $question['content'] ?? '';
  457. if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
  458. return '填空题';
  459. }
  460. return '解答题';
  461. }
  462. private function defaultScore(string $type): int
  463. {
  464. if ($type === '选择题' || $type === '填空题') {
  465. return 5;
  466. }
  467. return 10;
  468. }
  469. /**
  470. * 构建完整的试卷信息(包含所有题目详情)
  471. */
  472. private function buildCompleteExamContent(string $paperId): array
  473. {
  474. $paper = Paper::with('questions')->find($paperId);
  475. $questions = $paper ? $paper->questions : collect();
  476. return [
  477. // 试卷基本信息
  478. 'paper_info' => [
  479. 'paper_id' => $paperId,
  480. 'paper_name' => $paper?->paper_name ?? '',
  481. 'student_id' => $paper?->student_id ?? '',
  482. 'teacher_id' => $paper?->teacher_id ?? '',
  483. 'total_questions' => $questions->count(),
  484. 'total_score' => $paper?->total_score ?? 0,
  485. 'difficulty_category' => $paper?->difficulty_category ?? '基础',
  486. 'created_at' => $paper?->created_at?->toISOString(),
  487. 'updated_at' => $paper?->updated_at?->toISOString(),
  488. ],
  489. // 完整题目信息
  490. 'questions' => $questions->map(function (PaperQuestion $q) {
  491. // 构建选择题选项(如果适用)
  492. $options = [];
  493. if ($q->question_type === 'choice') {
  494. // 从题目文本中提取选项
  495. $questionText = $q->question_text ?? '';
  496. preg_match_all('/([A-D])\s*[\.\、\:]\s*([^A-D]+?)(?=[A-D]\s*[\.\、\:]|$)/u', $questionText, $matches, PREG_SET_ORDER);
  497. foreach ($matches as $match) {
  498. $options[] = [
  499. 'label' => $match[1],
  500. 'content' => trim($match[2]),
  501. ];
  502. }
  503. }
  504. return [
  505. // 基本信息
  506. 'question_number' => $q->question_number,
  507. 'question_id' => $q->question_id,
  508. 'question_bank_id' => $q->question_bank_id,
  509. 'question_type' => $q->question_type,
  510. 'knowledge_point' => $q->knowledge_point,
  511. 'difficulty' => $q->difficulty,
  512. 'score' => $q->score,
  513. 'estimated_time' => $q->estimated_time,
  514. // 题目内容
  515. 'stem' => $q->question_text ?? '',
  516. 'options' => $options,
  517. // 答案和解析
  518. 'correct_answer' => $q->correct_answer ?? '',
  519. 'solution' => $q->solution ?? '',
  520. // 元数据
  521. 'student_answer' => $q->student_answer,
  522. 'is_correct' => $q->is_correct,
  523. 'score_obtained' => $q->score_obtained,
  524. 'score_ratio' => $q->score_ratio,
  525. 'teacher_comment' => $q->teacher_comment,
  526. 'graded_at' => $q->graded_at?->toISOString(),
  527. 'graded_by' => $q->graded_by,
  528. // 题目属性
  529. 'metadata' => [
  530. 'has_solution' => !empty($q->solution),
  531. 'is_choice' => $q->question_type === 'choice',
  532. 'is_fill' => $q->question_type === 'fill',
  533. 'is_answer' => $q->question_type === 'answer',
  534. 'difficulty_label' => $this->getDifficultyLabel($q->difficulty),
  535. 'question_type_label' => $this->getQuestionTypeLabel($q->question_type),
  536. ],
  537. ];
  538. })->toArray(),
  539. // 统计信息
  540. 'statistics' => [
  541. 'type_distribution' => $this->getTypeDistribution($questions),
  542. 'difficulty_distribution' => $this->getDifficultyDistribution($questions),
  543. 'knowledge_point_distribution' => $this->getKnowledgePointDistribution($questions),
  544. 'total_score' => $questions->sum('score'),
  545. 'average_difficulty' => $questions->avg('difficulty'),
  546. 'total_estimated_time' => $questions->sum('estimated_time'),
  547. ],
  548. // 知识点和技能标签
  549. 'knowledge_points' => $questions->pluck('knowledge_point')->unique()->filter()->values()->toArray(),
  550. 'skills' => $this->extractSkillsFromQuestions($questions),
  551. ];
  552. }
  553. /**
  554. * 获取题型中文标签
  555. */
  556. private function getQuestionTypeLabel(string $type): string
  557. {
  558. return match($type) {
  559. 'choice' => '选择题',
  560. 'fill' => '填空题',
  561. 'answer' => '解答题',
  562. default => '未知题型'
  563. };
  564. }
  565. /**
  566. * 获取难度中文标签
  567. */
  568. private function getDifficultyLabel(?float $difficulty): string
  569. {
  570. if ($difficulty === null) return '未知';
  571. if ($difficulty <= 0.4) return '基础';
  572. if ($difficulty <= 0.7) return '中等';
  573. return '拔高';
  574. }
  575. /**
  576. * 获取题型分布
  577. */
  578. private function getTypeDistribution($questions): array
  579. {
  580. $distribution = [];
  581. foreach ($questions as $q) {
  582. $type = $q->question_type;
  583. $distribution[$type] = ($distribution[$type] ?? 0) + 1;
  584. }
  585. return $distribution;
  586. }
  587. /**
  588. * 获取难度分布
  589. */
  590. private function getDifficultyDistribution($questions): array
  591. {
  592. $distribution = [];
  593. foreach ($questions as $q) {
  594. $label = $this->getDifficultyLabel($q->difficulty);
  595. $distribution[$label] = ($distribution[$label] ?? 0) + 1;
  596. }
  597. return $distribution;
  598. }
  599. /**
  600. * 获取知识点分布
  601. */
  602. private function getKnowledgePointDistribution($questions): array
  603. {
  604. $distribution = [];
  605. foreach ($questions as $q) {
  606. $kp = $q->knowledge_point;
  607. if ($kp) {
  608. $distribution[$kp] = ($distribution[$kp] ?? 0) + 1;
  609. }
  610. }
  611. return $distribution;
  612. }
  613. /**
  614. * 从题目中提取技能标签
  615. */
  616. private function extractSkillsFromQuestions($questions): array
  617. {
  618. $skills = [];
  619. // 注意:由于题库在PostgreSQL中,MySQL的questions表可能不存在
  620. // 我们从PaperQuestion的solution或metadata中提取技能信息
  621. foreach ($questions as $q) {
  622. // 从解题过程中提取技能关键词
  623. $solution = $q->solution ?? '';
  624. if ($solution) {
  625. // 简单的技能提取(基于常见关键词)
  626. $skillKeywords = ['代入法', '配方法', '因式分解', '换元法', '判别式', '求根公式', '韦达定理'];
  627. foreach ($skillKeywords as $keyword) {
  628. if (strpos($solution, $keyword) !== false) {
  629. $skills[] = $keyword;
  630. }
  631. }
  632. }
  633. // 从题目文本中提取技能标签(如果存在)
  634. $stem = $q->question_text ?? '';
  635. if ($stem) {
  636. // 尝试从题干中提取技能信息(格式如:{技能1,技能2})
  637. preg_match_all('/\{([^}]+)\}/', $stem, $matches);
  638. foreach ($matches[1] as $match) {
  639. $skillList = array_map('trim', explode(',', $match));
  640. $skills = array_merge($skills, $skillList);
  641. }
  642. }
  643. }
  644. return array_unique(array_filter($skills));
  645. }
  646. }