IntelligentExamController.php 28 KB

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