|
|
@@ -0,0 +1,324 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Http\Controllers\Api;
|
|
|
+
|
|
|
+use App\Http\Controllers\Controller;
|
|
|
+use App\Models\Paper;
|
|
|
+use App\Models\PaperQuestion;
|
|
|
+use App\Services\LearningAnalyticsService;
|
|
|
+use App\Services\ExamPdfExportService;
|
|
|
+use App\Services\QuestionBankService;
|
|
|
+use Illuminate\Http\JsonResponse;
|
|
|
+use Illuminate\Http\Request;
|
|
|
+use Illuminate\Support\Facades\Log;
|
|
|
+use Illuminate\Support\Facades\URL;
|
|
|
+
|
|
|
+class IntelligentExamController extends Controller
|
|
|
+{
|
|
|
+ private LearningAnalyticsService $learningAnalyticsService;
|
|
|
+ private QuestionBankService $questionBankService;
|
|
|
+ private ExamPdfExportService $pdfExportService;
|
|
|
+
|
|
|
+ public function __construct(
|
|
|
+ LearningAnalyticsService $learningAnalyticsService,
|
|
|
+ QuestionBankService $questionBankService,
|
|
|
+ ExamPdfExportService $pdfExportService
|
|
|
+ ) {
|
|
|
+ $this->learningAnalyticsService = $learningAnalyticsService;
|
|
|
+ $this->questionBankService = $questionBankService;
|
|
|
+ $this->pdfExportService = $pdfExportService;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 外部API:生成智能试卷,保存并返回 PDF/判卷链接
|
|
|
+ */
|
|
|
+ public function store(Request $request): JsonResponse
|
|
|
+ {
|
|
|
+ $normalized = $this->normalizePayload($request->all());
|
|
|
+
|
|
|
+ $validator = validator($normalized, [
|
|
|
+ 'student_id' => 'required|string',
|
|
|
+ 'teacher_id' => 'required|string',
|
|
|
+ 'paper_name' => 'nullable|string|max:255',
|
|
|
+ 'grade' => 'nullable|string|max:50',
|
|
|
+ 'total_questions' => 'required|integer|min:6|max:100',
|
|
|
+ 'difficulty_category' => 'nullable|string',
|
|
|
+ 'kp_codes' => 'required|array|min:1',
|
|
|
+ 'kp_codes.*' => 'string',
|
|
|
+ 'skills' => 'array',
|
|
|
+ 'skills.*' => 'string',
|
|
|
+ 'question_type_ratio' => 'array',
|
|
|
+ 'difficulty_ratio' => 'array',
|
|
|
+ 'total_score' => 'nullable|numeric|min:1|max:1000',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if ($validator->fails()) {
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => '参数错误',
|
|
|
+ 'errors' => $validator->errors()->toArray(),
|
|
|
+ ], 422);
|
|
|
+ }
|
|
|
+
|
|
|
+ $data = $validator->validated();
|
|
|
+
|
|
|
+ $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
|
|
|
+ $difficultyRatio = $this->normalizeDifficultyRatio($data['difficulty_ratio'] ?? []);
|
|
|
+ $paperName = $data['paper_name'] ?? ('智能试卷_' . now()->format('Ymd_His'));
|
|
|
+ $difficultyCategory = $this->normalizeDifficultyCategory($data['difficulty_category'] ?? null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ $result = $this->learningAnalyticsService->generateIntelligentExam([
|
|
|
+ 'student_id' => $data['student_id'],
|
|
|
+ 'grade' => $data['grade'] ?? null,
|
|
|
+ 'total_questions' => $data['total_questions'],
|
|
|
+ 'kp_codes' => $data['kp_codes'],
|
|
|
+ 'skills' => $data['skills'] ?? [],
|
|
|
+ 'question_type_ratio' => $questionTypeRatio,
|
|
|
+ 'difficulty_ratio' => $difficultyRatio,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (empty($result['success'])) {
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => $result['message'] ?? '智能出卷失败',
|
|
|
+ ], 400);
|
|
|
+ }
|
|
|
+
|
|
|
+ $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
|
|
|
+
|
|
|
+ if (empty($questions)) {
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => '未能生成有效题目,请检查知识点或题库数据',
|
|
|
+ ], 400);
|
|
|
+ }
|
|
|
+
|
|
|
+ $totalScore = array_sum(array_column($questions, 'score'));
|
|
|
+
|
|
|
+ $paperId = $this->questionBankService->saveExamToDatabase([
|
|
|
+ 'paper_name' => $paperName,
|
|
|
+ 'student_id' => $data['student_id'],
|
|
|
+ 'teacher_id' => $data['teacher_id'],
|
|
|
+ 'difficulty_category' => $difficultyCategory,
|
|
|
+ 'total_score' => $data['total_score'] ?? $totalScore,
|
|
|
+ 'questions' => $questions,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!$paperId) {
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => '试卷保存失败',
|
|
|
+ ], 500);
|
|
|
+ }
|
|
|
+
|
|
|
+ $paperPayload = $this->buildPaperPayload($paperId);
|
|
|
+
|
|
|
+ // 生成真实 PDF(试卷 + 判卷),若失败则回退到 HTML 预览
|
|
|
+ $pdfUrl = $this->pdfExportService->generateExamPdf($paperId)
|
|
|
+ ?? $this->questionBankService->exportExamToPdf($paperId)
|
|
|
+ ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
|
|
|
+
|
|
|
+ $gradingUrl = $this->pdfExportService->generateGradingPdf($paperId)
|
|
|
+ ?? route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]);
|
|
|
+
|
|
|
+ $payload = [
|
|
|
+ 'success' => true,
|
|
|
+ 'message' => '智能试卷生成成功',
|
|
|
+ 'data' => [
|
|
|
+ 'paper' => $paperPayload,
|
|
|
+ 'pdf_url' => $pdfUrl,
|
|
|
+ 'grading_url' => $gradingUrl,
|
|
|
+ 'stats' => $result['stats'] ?? null,
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 返回不转义的完整 URL
|
|
|
+ return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES);
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ Log::error('Intelligent exam API failed', [
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return response()->json([
|
|
|
+ 'success' => false,
|
|
|
+ 'message' => '服务异常,请稍后重试',
|
|
|
+ ], 500);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 兼容字符串/数组入参
|
|
|
+ */
|
|
|
+ private function normalizePayload(array $payload): array
|
|
|
+ {
|
|
|
+ if (isset($payload['kp_codes']) && is_string($payload['kp_codes'])) {
|
|
|
+ $payload['kp_codes'] = array_values(array_filter(array_map('trim', explode(',', $payload['kp_codes']))));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isset($payload['skills']) && is_string($payload['skills'])) {
|
|
|
+ $payload['skills'] = array_values(array_filter(array_map('trim', explode(',', $payload['skills']))));
|
|
|
+ }
|
|
|
+
|
|
|
+ return $payload;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function normalizeQuestionTypeRatio(array $input): array
|
|
|
+ {
|
|
|
+ $defaults = [
|
|
|
+ '选择题' => 40,
|
|
|
+ '填空题' => 30,
|
|
|
+ '解答题' => 30,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $normalized = [];
|
|
|
+ foreach ($input as $key => $value) {
|
|
|
+ if (!is_numeric($value)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $type = $this->normalizeQuestionTypeKey($key);
|
|
|
+ if ($type) {
|
|
|
+ $normalized[$type] = (float) $value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_merge($defaults, $normalized);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function normalizeQuestionTypeKey(string $key): ?string
|
|
|
+ {
|
|
|
+ $key = trim($key);
|
|
|
+ if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice'])) {
|
|
|
+ return '选择题';
|
|
|
+ }
|
|
|
+ if (in_array($key, ['fill', '填空题', 'blank'])) {
|
|
|
+ return '填空题';
|
|
|
+ }
|
|
|
+ if (in_array($key, ['answer', '解答题', '计算题'])) {
|
|
|
+ return '解答题';
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function normalizeDifficultyRatio(array $input): array
|
|
|
+ {
|
|
|
+ $defaults = [
|
|
|
+ '基础' => 50,
|
|
|
+ '中等' => 35,
|
|
|
+ '拔高' => 15,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $normalized = [];
|
|
|
+ foreach ($input as $key => $value) {
|
|
|
+ if (!is_numeric($value)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $label = trim($key);
|
|
|
+ if (in_array($label, ['基础', 'easy', '简单'])) {
|
|
|
+ $normalized['基础'] = (float) $value;
|
|
|
+ } elseif (in_array($label, ['中等', 'medium'])) {
|
|
|
+ $normalized['中等'] = (float) $value;
|
|
|
+ } elseif (in_array($label, ['拔高', 'hard', '困难', '竞赛'])) {
|
|
|
+ $normalized['拔高'] = (float) $value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_merge($defaults, $normalized);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function normalizeDifficultyCategory(?string $category): string
|
|
|
+ {
|
|
|
+ if (!$category) {
|
|
|
+ return '基础';
|
|
|
+ }
|
|
|
+
|
|
|
+ $category = trim($category);
|
|
|
+ if (in_array($category, ['基础', '进阶', '中等', 'easy'])) {
|
|
|
+ return $category === 'easy' ? '基础' : $category;
|
|
|
+ }
|
|
|
+ if (in_array($category, ['拔高', '困难', 'hard', '竞赛'])) {
|
|
|
+ return '拔高';
|
|
|
+ }
|
|
|
+
|
|
|
+ return '基础';
|
|
|
+ }
|
|
|
+
|
|
|
+ private function hydrateQuestions(array $questions, array $kpCodes): array
|
|
|
+ {
|
|
|
+ $normalized = [];
|
|
|
+ foreach ($questions as $question) {
|
|
|
+ $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
|
|
|
+ $score = $question['score'] ?? $this->defaultScore($type);
|
|
|
+
|
|
|
+ $normalized[] = [
|
|
|
+ 'id' => $question['id'] ?? $question['question_id'] ?? null,
|
|
|
+ 'question_id' => $question['question_id'] ?? null,
|
|
|
+ 'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
|
|
|
+ 'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
|
|
|
+ 'content' => $question['content'] ?? $question['stem'] ?? '',
|
|
|
+ 'options' => $question['options'] ?? ($question['choices'] ?? []),
|
|
|
+ 'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
|
|
|
+ 'solution' => $question['solution'] ?? '',
|
|
|
+ 'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
|
|
|
+ 'score' => $score,
|
|
|
+ 'estimated_time' => $question['estimated_time'] ?? 300,
|
|
|
+ 'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
|
|
|
+ 'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_values(array_filter($normalized, fn ($q) => !empty($q['id'])));
|
|
|
+ }
|
|
|
+
|
|
|
+ private function guessType(array $question): string
|
|
|
+ {
|
|
|
+ if (!empty($question['options']) && is_array($question['options'])) {
|
|
|
+ return '选择题';
|
|
|
+ }
|
|
|
+
|
|
|
+ $content = $question['stem'] ?? $question['content'] ?? '';
|
|
|
+ if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
|
|
|
+ return '填空题';
|
|
|
+ }
|
|
|
+
|
|
|
+ return '解答题';
|
|
|
+ }
|
|
|
+
|
|
|
+ private function defaultScore(string $type): int
|
|
|
+ {
|
|
|
+ if ($type === '选择题' || $type === '填空题') {
|
|
|
+ return 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ return 10;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function buildPaperPayload(string $paperId): array
|
|
|
+ {
|
|
|
+ $paper = Paper::with('questions')->find($paperId);
|
|
|
+ $questions = $paper ? $paper->questions : collect();
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'paper_name' => $paper?->paper_name ?? '',
|
|
|
+ 'student_id' => $paper?->student_id ?? '',
|
|
|
+ 'teacher_id' => $paper?->teacher_id ?? '',
|
|
|
+ 'total_questions' => $questions->count(),
|
|
|
+ 'total_score' => $paper?->total_score ?? 0,
|
|
|
+ 'difficulty_category' => $paper?->difficulty_category ?? '基础',
|
|
|
+ 'questions' => $questions->map(function (PaperQuestion $q) {
|
|
|
+ return [
|
|
|
+ 'question_bank_id' => $q->question_bank_id,
|
|
|
+ 'question_number' => $q->question_number,
|
|
|
+ 'question_type' => $q->question_type,
|
|
|
+ 'knowledge_point' => $q->knowledge_point,
|
|
|
+ 'difficulty' => $q->difficulty,
|
|
|
+ 'score' => $q->score,
|
|
|
+ 'estimated_time' => $q->estimated_time,
|
|
|
+ ];
|
|
|
+ })->toArray(),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+}
|